{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prose",
  "type": "registry:ui",
  "title": "Prose",
  "description": "The article-body wrapper. Renders an `<article>` whose descendants (`h1`–`h6`, `p`, `a`, `ul`/`ol`/`li`, `blockquote`, `code`/`pre`, `table`, `img`) get DS-token-mapped typography without per-element class gymnastics in the consumer. Think MDX-target…",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "blog",
    "primitive"
  ],
  "dependencies": [
    "class-variance-authority"
  ],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/prose.tsx",
      "target": "components/ui/prose.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\nimport * as React from 'react';\n\n// @interlace/prose v1.1.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/prose\n// What changed since: https://ds.interlace.tools/c/prose#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — Prose\n *\n * The article-body wrapper. Renders an `<article>` whose descendants\n * (`h1`–`h6`, `p`, `a`, `ul`/`ol`/`li`, `blockquote`, `code`/`pre`, `table`,\n * `img`) get DS-token-mapped typography without per-element class\n * gymnastics in the consumer. Think MDX-target wrapper: drop a stream of\n * Markdown / MDX inside and it reads like a published article — measure\n * bounded, GFM tables zebra-striped, headings deep-link-anchorable.\n *\n * The descendant cascade lives on the wrapper itself via Tailwind\n * arbitrary-variant utilities (`[&_h1]:…`) so the whole contract is\n * auditable from one cva base and every value still resolves to a DS token\n * (R19). A stable `prose-interlace` className is also applied as an\n * external hook for consumer overrides (e.g. fumadocs MDX plugins).\n *\n * `variant=\"long\"` switches the body to `--text-long` (17px / 1.7) for\n * extended reading per TYPOGRAPHY_PHILOSOPHY (Reading mode vs UI mode).\n *\n * ## Anatomy\n *\n *   <article data-slot=\"prose\" data-min-viewport=\"320\" class=\"prose-interlace …\">\n *     <h1>…</h1>\n *     <p>…</p>\n *     <ul><li>…</li></ul>\n *     <pre><code>…</code></pre>\n *     <table><thead/><tbody/></table>\n *   </article>\n *\n * The `as` prop accepts `'article' | 'div' | 'main' | 'section'` so the\n * wrapper can be the document root (`main`), a section in a wider\n * layout (`section`), or a non-landmark container (`div`).\n *\n * ## MIN_VIEWPORT — 320\n *\n * Long-form reading is one of the failure modes that MUST work on the\n * narrowest phone. Headings and measure all collapse cleanly at 320 CSS-px;\n * tables get `overflow-x: auto` (`[&_table]:block [&_table]:overflow-x-auto`)\n * so wide GFM tables scroll horizontally instead of breaking the page.\n *\n * | Rule | Concept                          | Where in this file                                          |\n * | ---- | -------------------------------- | ----------------------------------------------------------- |\n * | R4   | Extends native el + VariantProps | `React.ComponentProps<'article'> & VariantProps<...>`       |\n * | R6   | data-slot on root                | `data-slot=\"prose\"` + data-variant                          |\n * | R7   | className merged + ...rest + ref | `cn(proseVariants(...), 'prose-interlace', className)`      |\n * | R8   | No `isXxx`; enum for variants    | `variant` is an enum (`default` / `long`)                   |\n * | R10  | Composition seam (`as` prop)     | `as` accepts `article` / `div` / `main` / `section`         |\n * | R14  | Declares min viewport            | `data-min-viewport={String(MIN_VIEWPORT)}` + exported const |\n * | R18  | Tailwind only                    | Zero inline `style`; cva + arbitrary-variant utilities      |\n * | R19  | Tokens only                      | `--text-*`, `--spacing-*`, `--container-prose`, semantic colors |\n * | R20  | AA contrast                      | `text-foreground` / `text-muted-foreground` clear AA in light + dark |\n * | R25  | Client component                 | useLayoutEffect injects `tabindex=\"0\"` on overflowing `<pre>`/`<table>` for axe `scrollable-region-focusable` |\n * | R26  | A11y from native el              | semantic elements (`article`, `h*`, `a`, `table`) own the a11y tree |\n */\n\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '@/lib/utils';\n\n/**\n * Minimum viable viewport (CSS px) for this primitive. Below it, the\n * preflight contract draws a dev-mode outline; in prod the component still\n * renders. Exported so consumers / tests can read it.\n */\nexport const MIN_VIEWPORT = 320 as const;\n\n/**\n * Element seam — keep the set closed (`article` default + the three\n * landmark-or-neutral alternatives). Anything else (a `<span>`, a `<button>`)\n * would be a semantic mistake for an article-body wrapper.\n */\ntype ProseElement = 'article' | 'div' | 'main' | 'section';\n\n/**\n * Descendant cascade. Every selector resolves to a DS token (R19): heading\n * sizes from `--text-h*`, weights from the 400/500/600/700 ladder, spacing\n * from `--spacing-*`, colors from semantic tokens. Headings carry\n * `scroll-margin-top` (xl ≈ 64px) so deep-link anchors clear a sticky\n * header instead of hiding under it. GFM tables zebra-stripe via\n * `[&_tbody_tr:nth-child(even)]:bg-muted/40` and scroll horizontally on\n * narrow viewports.\n */\nconst PROSE_CASCADE = [\n  // Headings — type scale + scroll-margin for deep-link anchors.\n  '[&_h1]:font-body [&_h1]:text-h1 [&_h1]:font-bold [&_h1]:tracking-display [&_h1]:text-balance [&_h1]:scroll-mt-xl [&_h1]:mt-xl [&_h1]:mb-md',\n  '[&_h2]:font-body [&_h2]:text-h2 [&_h2]:font-bold [&_h2]:tracking-heading [&_h2]:text-balance [&_h2]:scroll-mt-xl [&_h2]:mt-lg [&_h2]:mb-sm',\n  '[&_h3]:font-body [&_h3]:text-h3 [&_h3]:font-semibold [&_h3]:tracking-heading [&_h3]:scroll-mt-xl [&_h3]:mt-lg [&_h3]:mb-sm',\n  '[&_h4]:font-body [&_h4]:text-h4 [&_h4]:font-semibold [&_h4]:scroll-mt-xl [&_h4]:mt-md [&_h4]:mb-xs',\n  '[&_h5]:font-body [&_h5]:text-h5 [&_h5]:font-semibold [&_h5]:scroll-mt-xl [&_h5]:mt-md [&_h5]:mb-xs',\n  '[&_h6]:font-body [&_h6]:text-h6 [&_h6]:font-semibold [&_h6]:scroll-mt-xl [&_h6]:mt-md [&_h6]:mb-xs',\n\n  // Paragraph rhythm.\n  '[&_p]:my-sm',\n\n  // Links — primary color, underline-offset for legibility, focus-ring contract.\n  '[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-from-font',\n  '[&_a:hover]:text-primary [&_a:hover]:opacity-90',\n  '[&_a:focus-visible]:outline-none [&_a:focus-visible]:ring-2 [&_a:focus-visible]:ring-ring [&_a:focus-visible]:ring-offset-2 [&_a:focus-visible]:rounded-sm',\n\n  // Lists.\n  '[&_ul]:my-sm [&_ul]:pl-md [&_ul]:list-disc',\n  '[&_ol]:my-sm [&_ol]:pl-md [&_ol]:list-decimal',\n  '[&_li]:my-xs [&_li]:pl-xs',\n\n  // Blockquote — quiet left rule with muted body.\n  '[&_blockquote]:my-md [&_blockquote]:border-l-4 [&_blockquote]:border-border [&_blockquote]:pl-md [&_blockquote]:text-muted-foreground [&_blockquote]:italic',\n\n  // Inline code — chip pill on muted surface. The vertical padding is the\n  // one arbitrary value in this chain and stays arbitrary on purpose: an\n  // inline chip has to grow with the text it sits inside, so the value is\n  // `em`-relative. Every --spacing-* token is `rem`, which would freeze the\n  // chip's height while the surrounding line scales (R19 exception).\n  '[&_code]:font-mono [&_code]:text-code [&_code]:bg-muted [&_code]:text-foreground [&_code]:rounded-sm [&_code]:px-xs [&_code]:py-[0.125em]',\n  // Code inside pre — clear the chip styles so the block code renders cleanly.\n  '[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:rounded-none',\n\n  // Pre — block code surface with horizontal overflow on narrow viewports.\n  // Pairs with the focus-visible ring contract: when consumers add the\n  // recommended `tabIndex={0}` (see Prose JSDoc \"Keyboard access\" section),\n  // the ring activates on keyboard focus.\n  '[&_pre]:my-md [&_pre]:rounded-md [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted [&_pre]:p-md [&_pre]:overflow-x-auto [&_pre]:font-mono [&_pre]:text-code [&_pre:focus-visible]:outline-none [&_pre:focus-visible]:ring-2 [&_pre:focus-visible]:ring-ring [&_pre:focus-visible]:ring-offset-2',\n\n  // Tables — GFM zebra rows; block + overflow-x for narrow viewports (R14).\n  // Same keyboard-focus contract as <pre>.\n  '[&_table]:my-md [&_table]:block [&_table]:w-full [&_table]:overflow-x-auto [&_table]:border-collapse [&_table]:text-ui [&_table:focus-visible]:outline-none [&_table:focus-visible]:ring-2 [&_table:focus-visible]:ring-ring [&_table:focus-visible]:ring-offset-2',\n  '[&_thead]:bg-muted',\n  '[&_th]:border [&_th]:border-border [&_th]:px-sm [&_th]:py-xs [&_th]:font-semibold [&_th]:text-left',\n  '[&_td]:border [&_td]:border-border [&_td]:px-sm [&_td]:py-xs',\n  '[&_tbody_tr:nth-child(even)]:bg-muted/40',\n\n  // Images — block layout + bounded radius + measure-respecting width.\n  '[&_img]:my-md [&_img]:block [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-md',\n].join(' ');\n\nconst proseVariants = cva(\n  // Base — bound measure to --container-prose (65ch) so every body container\n  // clears the \"≤75ch\" non-negotiable from TYPOGRAPHY_PHILOSOPHY. Token\n  // foreground keeps AA in light + dark.\n  ['max-w-(--container-prose) text-foreground', PROSE_CASCADE].join(' '),\n  {\n    variants: {\n      /**\n       * Reading mode. `default` uses the body type contract (16px / 1.6);\n       * `long` switches to the long-form contract (17px / 1.7) for extended\n       * articles per TYPOGRAPHY_PHILOSOPHY \"Reading mode vs UI mode\".\n       */\n      variant: {\n        default: 'text-body leading-body',\n        long: 'text-long leading-long',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n    },\n  },\n);\n\ninterface ProseProps\n  extends React.ComponentProps<'article'>,\n    VariantProps<typeof proseVariants> {\n  /**\n   * Override the rendered element. Defaults to `article` — the natural\n   * semantic for an article-body wrapper. Use `main` when this is the page's\n   * primary landmark, `section` inside a larger landmark, or `div` when the\n   * surrounding container already owns the landmark.\n   */\n  as?: ProseElement;\n}\n\n/**\n * Typographic article-body wrapper. Server component (no hooks).\n *\n * Drop any combination of `h1`–`h6`, `p`, `a`, `ul`/`ol`, `blockquote`,\n * `code`/`pre`, `table`, `img` inside; the cascade styles them with DS\n * tokens. The wrapper itself is just an `<article>` (or your `as` override)\n * with the measure bounded.\n */\nconst Prose = React.forwardRef<HTMLElement, ProseProps>(\n  ({ className, variant, as, children, ...props }, ref) => {\n    const Tag = (as ?? 'article') as React.ElementType;\n    const localRef = React.useRef<HTMLElement | null>(null);\n\n    /**\n     * axe `scrollable-region-focusable`: any element with `overflow:auto|scroll`\n     * that actually scrolls (scrollWidth/Height > clientWidth/Height) MUST be\n     * keyboard-reachable. The cleanest way to satisfy that without forcing\n     * every consumer to manually `tabIndex={0}` on every `<pre>` and `<table>`\n     * inside their MDX/HTML content is to inject it from the wrapper.\n     *\n     * useLayoutEffect runs after render but before paint, so the attribute is\n     * in place before the test-runner's axe scan inspects the DOM. We also\n     * re-run when children change (React reconciles a new key set) by keying\n     * on `children` identity via the dependency array.\n     */\n    React.useLayoutEffect(() => {\n      const root = localRef.current;\n      if (!root) return;\n      for (const el of root.querySelectorAll<HTMLElement>('pre, table')) {\n        if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');\n      }\n    }, [children]);\n\n    const setRef = (node: HTMLElement | null) => {\n      localRef.current = node;\n      if (typeof ref === 'function') ref(node);\n      else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;\n    };\n\n    return (\n      <Tag\n        ref={setRef}\n        data-slot=\"prose\"\n        data-min-viewport={String(MIN_VIEWPORT)}\n        data-variant={variant ?? undefined}\n        className={cn(proseVariants({ variant }), 'prose-interlace', className)}\n        {...props}\n      >\n        {children}\n      </Tag>\n    );\n  },\n);\nProse.displayName = 'Prose';\n\nexport { Prose, proseVariants };\nexport type { ProseProps };\n"
    }
  ],
  "meta": {
    "tier": "primitive",
    "client": true,
    "minViewport": 320,
    "loading": false,
    "version": "1.1.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/prose\n\nInstalled to `components/ui/prose.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/prose';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/prose\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
