// Prose: rendered long-form HTML on the Theme's type roles (ADR 0025, 0027). // // Prose styles the elements a Markdown renderer emits — the full CommonMark // and GFM set — from the outside, through descendant variants. Every selector // is wrapped as `:where(&) `, so a rule costs only its element's own // specificity: a Kit component placed inside a document (a CodeBlock, an // Alert, a Link) keeps every property its own classes set, while bare // rendered markup gets the document look. // // What each element reads as: // // - h1 is the title role, h2 and h3 the heading role, h4 and h5 the body // role at the Brand's bold and medium weights, h6 the eyebrow role. The // Theme decides the sizes, so a Marketing island reads larger with no prop. // - Running copy is the body role in ink; captions, footnotes and table // captions the caption role in secondary ink (`ink-muted`, never `muted`). // - Links are ink with a persistent underline (WCAG 1.4.1, ADR 0023) that // thickens on hover, and draw the ink focus outline. // - Lists keep visible markers at every depth (disc, circle, square; // decimal, lower-alpha, lower-roman), and a GFM task item trades its // marker for its checkbox. // // Vertical rhythm is Density's layout tier (ADR 0024): blocks sit // `layout-gap-sm` apart, a heading opens `layout-gap-lg` (h1, h2) or // `layout-gap-md` (h3–h6) above itself, and the component tier spaces list // items and the parts inside a block. Horizontal indents are typographic // (`em`), because a marker's room is a glyph width, not a Density step. // // The measure is the reading width, on DS-named container widths (ADR 0021): // `prose` (65ch of the current body size, the default), `content` or `full`. import { tv, type VariantProps } from "tailwind-variants"; import { typeRoleMerge } from "./type-roles"; const FLOW = [ // Blocks: one layout step apart, nothing above the first. "[:where(&)>*+*]:mt-[var(--reddb-spatial-layout-gap-sm)]", "[:where(&)>:first-child]:mt-0", // Blocks nested in a container element keep the same rhythm inside it. "[:where(&)_:is(blockquote,details,figure,[data-footnotes])>*+*]:mt-[var(--reddb-spatial-layout-gap-sm)]", // List items and the paragraphs or lists inside one: the component tier. "[:where(&)_li+li]:mt-[var(--reddb-spatial-gap-md)]", "[:where(&)_li>*+*]:mt-[var(--reddb-spatial-gap-md)]", "[:where(&)_dd>*+*]:mt-[var(--reddb-spatial-gap-md)]", ]; const HEADINGS = [ "[:where(&)_:is(h1,h2,h3,h4,h5)]:text-foreground", "[:where(&)_:is(h1,h2,h3,h4,h5,h6)]:text-balance", "[:where(&)_h1]:text-title", "[:where(&)_h2]:text-heading", "[:where(&)_h3]:text-heading", "[:where(&)_h4]:text-body", "[:where(&)_h4]:font-bold", "[:where(&)_h5]:text-body", "[:where(&)_h5]:font-medium", "[:where(&)_h6]:text-eyebrow", "[:where(&)_h6]:uppercase", "[:where(&)_h6]:text-ink-muted", // A heading opens a section, so the space above it is the larger step; // what it introduces follows close, at the component tier. "[:where(&)_:is(h1,h2)]:mt-[var(--reddb-spatial-layout-gap-lg)]", "[:where(&)_:is(h3,h4,h5,h6)]:mt-[var(--reddb-spatial-layout-gap-md)]", "[:where(&)_:is(h1,h2,h3,h4,h5,h6)+*]:mt-[var(--reddb-spatial-gap-md)]", ]; const INLINE = [ // Links: ink, a persistent underline, a thicker one on hover, the ink focus. "[:where(&)_a]:text-foreground", "[:where(&)_a]:underline", "[:where(&)_a]:decoration-current", "[:where(&)_a]:underline-offset-[var(--reddb-spatial-gap-sm)]", "[:where(&)_a:hover]:decoration-2", "[:where(&)_a:focus-visible]:rounded-sm", "[:where(&)_a:focus-visible]:outline-2", "[:where(&)_a:focus-visible]:outline-offset-2", "[:where(&)_a:focus-visible]:outline-focus", "[:where(&)_:is(strong,b)]:font-bold", "[:where(&)_:is(del,s)]:text-ink-muted", "[:where(&)_abbr[title]]:underline", "[:where(&)_abbr[title]]:decoration-dotted", // Inline code and key caps: the mono family on a quiet surface, at the // size of the copy around them. "[:where(&)_code]:font-mono", "[:where(&)_code]:rounded-sm", "[:where(&)_code]:bg-muted", "[:where(&)_code]:px-[0.3em]", "[:where(&)_code]:py-[0.1em]", "[:where(&)_kbd]:font-mono", "[:where(&)_kbd]:rounded-sm", "[:where(&)_kbd]:border", "[:where(&)_kbd]:border-b-2", "[:where(&)_kbd]:border-muted", "[:where(&)_kbd]:px-[0.35em]", "[:where(&)_kbd]:whitespace-nowrap", // Highlight: the warning tint under ink, never the accent. "[:where(&)_mark]:rounded-sm", "[:where(&)_mark]:bg-feedback-warning-surface", "[:where(&)_mark]:text-foreground", "[:where(&)_mark]:px-[0.15em]", ]; const LISTS = [ "[:where(&)_ul]:ps-[1.5em]", "[:where(&)_ol]:ps-[2em]", "[:where(&)_ul]:list-disc", "[:where(&)_ul_ul]:list-[circle]", "[:where(&)_ul_ul_ul]:list-[square]", "[:where(&)_ol]:list-decimal", "[:where(&)_ol_ol]:list-[lower-alpha]", "[:where(&)_ol_ol_ol]:list-[lower-roman]", "[:where(&)_li::marker]:text-ink-muted", // GFM task items: the checkbox is the marker. Checked is the Brand accent, // as on every checked control (DESIGN.md). "[:where(&)_li:has(>input[type=checkbox])]:list-none", "[:where(&)_li>input[type=checkbox]]:me-[0.5em]", "[:where(&)_li>input[type=checkbox]]:align-middle", "[:where(&)_li>input[type=checkbox]]:accent-primary", // Definition lists. "[:where(&)_dt]:font-medium", "[:where(&)_dd]:ps-[1.5em]", "[:where(&)_dt+dd]:mt-[var(--reddb-spatial-gap-sm)]", "[:where(&)_dd+dt]:mt-[var(--reddb-spatial-layout-gap-sm)]", ]; const BLOCKS = [ "[:where(&)_blockquote]:border-s-2", "[:where(&)_blockquote]:border-ink-muted", "[:where(&)_blockquote]:ps-[var(--reddb-spatial-inset-md)]", "[:where(&)_blockquote]:text-ink-muted", // Code blocks: a sunken, bordered panel that scrolls rather than widening // the measure; the code inside it drops the inline treatment. "[:where(&)_pre]:overflow-x-auto", "[:where(&)_pre]:rounded-md", "[:where(&)_pre]:border", "[:where(&)_pre]:border-muted", "[:where(&)_pre]:bg-elevation-sunken-surface", "[:where(&)_pre]:p-[var(--reddb-spatial-inset-md)]", "[:where(&)_pre]:font-mono", "[:where(&)_pre]:text-caption", "[:where(&)_pre]:text-elevation-sunken-foreground", "[:where(&)_pre_code]:rounded-none", "[:where(&)_pre_code]:bg-transparent", "[:where(&)_pre_code]:p-0", "[:where(&)_hr]:border-muted", "[:where(&)_hr]:my-[var(--reddb-spatial-layout-gap-lg)]", "[:where(&)_img]:max-w-full", "[:where(&)_img]:h-auto", "[:where(&)_img]:rounded-md", "[:where(&)_figcaption]:mt-[var(--reddb-spatial-gap-md)]", "[:where(&)_figcaption]:text-caption", "[:where(&)_figcaption]:text-ink-muted", // Disclosure: a quiet bordered box whose summary is the control. "[:where(&)_details]:rounded-md", "[:where(&)_details]:border", "[:where(&)_details]:border-muted", "[:where(&)_details]:px-[var(--reddb-spatial-inset-md)]", "[:where(&)_details]:py-[var(--reddb-spatial-inset-sm)]", "[:where(&)_summary]:cursor-pointer", "[:where(&)_summary]:font-medium", "[:where(&)_summary:focus-visible]:rounded-sm", "[:where(&)_summary:focus-visible]:outline-2", "[:where(&)_summary:focus-visible]:outline-offset-2", "[:where(&)_summary:focus-visible]:outline-focus", // GFM footnotes: the section a renderer appends, in the caption role. "[:where(&)_[data-footnotes]]:mt-[var(--reddb-spatial-layout-gap-lg)]", "[:where(&)_[data-footnotes]]:border-t", "[:where(&)_[data-footnotes]]:border-muted", "[:where(&)_[data-footnotes]]:pt-[var(--reddb-spatial-layout-gap-sm)]", "[:where(&)_[data-footnotes]]:text-caption", "[:where(&)_[data-footnotes]]:text-ink-muted", ]; const TABLES = [ "[:where(&)_table]:w-full", "[:where(&)_table]:border-collapse", "[:where(&)_caption]:pb-[var(--reddb-spatial-gap-md)]", "[:where(&)_caption]:text-start", "[:where(&)_caption]:text-caption", "[:where(&)_caption]:text-ink-muted", "[:where(&)_:is(th,td)]:px-[var(--reddb-spatial-inset-sm)]", "[:where(&)_:is(th,td)]:py-[var(--reddb-spatial-gap-md)]", "[:where(&)_:is(th,td)]:align-top", "[:where(&)_th]:font-medium", "[:where(&)_th]:border-b-2", "[:where(&)_th]:border-muted", "[:where(&)_td]:border-b", "[:where(&)_td]:border-muted", "[:where(&)_td]:tabular-nums", // GFM column alignment arrives as `align`; a cell without one reads from // the start edge, so a right-to-left document stays right. "[:where(&)_:is(th,td):not([align])]:text-start", "[:where(&)_[align=left]]:text-start", "[:where(&)_[align=center]]:text-center", "[:where(&)_[align=right]]:text-end", ]; const MEASURE = { prose: "max-w-prose", content: "max-w-content", full: "max-w-none", } as const; export const prose = tv( { base: [ "block min-w-0 text-body text-foreground wrap-break-word", ...FLOW, ...HEADINGS, ...INLINE, ...LISTS, ...BLOCKS, ...TABLES, ].join(" "), variants: { measure: MEASURE, striped: { true: "[:where(&)_tbody_tr:nth-child(even)]:bg-muted", false: "", }, }, defaultVariants: { measure: "prose", striped: false }, }, typeRoleMerge, ); export type ProseVariants = VariantProps; export type ProseMeasure = NonNullable; export const PROSE_MEASURES = Object.keys(MEASURE) as readonly ProseMeasure[]; /** The elements Prose may render as: a neutral block, or a document landmark. */ export const PROSE_ELEMENTS = ["div", "article", "section"] as const; export type ProseElement = (typeof PROSE_ELEMENTS)[number];