// ~/src/siteplane/components/typeset/typeset.tsx "use client"; import { Children, isValidElement, type ComponentProps, type JSX, type ReactElement, type ReactNode, type Ref, } from "react"; import { cn } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; import { Button } from "../button"; import { Code, CodeBlock } from "../code"; import { NativeTable, NativeTableBody, NativeTableCell, NativeTableFooter, NativeTableHead, NativeTableHeader, NativeTableRow, } from "../native-table"; import { Separator } from "../separator"; import "./typeset.css"; /** * How roomy the text is. `chat` is the kit's body type with a tight rhythm, * for a message; `document` is a size up with more room between blocks. */ export type TypesetDensity = "chat" | "document"; export type TypesetProps = ComponentProps<"div"> & { /** Defaults to `chat`. */ density?: TypesetDensity | undefined; }; /** * Kit typography for rendered markdown: it sets the kit's body type on its * container and stacks the blocks with a gap. The blocks are the Typeset parts * and the kit's own Code, CodeBlock and table, which `typesetComponents` maps * the elements of a markdown renderer to. The three values that tune it are * the custom properties `--sp-typeset-size`, `--sp-typeset-leading` and * `--sp-typeset-flow`. */ export function Typeset({ className, density = "chat", ...props }: TypesetProps): ReactElement { return (
); } /** A paragraph. */ export function TypesetParagraph({ className, ...props }: ComponentProps<"p">): ReactElement { return (

); } export type TypesetHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; export type TypesetHeadingProps = ComponentProps<"h1"> & { /** * The heading level, which is also the element. Levels 1 to 3 have a size * of their own; 4 to 6 share the body size in a heavier weight. Defaults * to 2. */ level?: TypesetHeadingLevel | undefined; }; const headingTags = { 1: "h1", 2: "h2", 3: "h3", 4: "h4", 5: "h5", 6: "h6", } as const; /** A heading, from `h1` to `h6` by `level`. */ export function TypesetHeading({ className, level = 2, ...props }: TypesetHeadingProps): ReactElement { const Tag = headingTags[level]; return ( ); } export type TypesetListProps = Omit, "ref"> & { /** A numbered list (`ol`) instead of a bulleted one (`ul`). */ ordered?: boolean | undefined; ref?: Ref | undefined; }; /** A bulleted or numbered list, with a gap between its items. */ export function TypesetList({ className, ordered = false, ...props }: TypesetListProps): ReactElement { // An `ul` takes the same attributes as far as it uses any; the casts only // give both elements one type. const Tag = (ordered ? "ol" : "ul") as "ol"; return ( )} /> ); } /** An item of a list. */ export function TypesetListItem({ className, ...props }: ComponentProps<"li">): ReactElement { return (

  • ); } /** * A link, in the kit's link look: the inline link Button, underlined in the * text colour, which fades on hover and press and keeps the kit's focus ring. */ export function TypesetLink({ children, className, ...props }: ComponentProps<"a">): ReactElement { return ( ); } /** A quote: its text is quieter, and a bar marks its start. */ export function TypesetQuote({ className, ...props }: ComponentProps<"blockquote">): ReactElement { return (
    ); } /** A thematic break: the kit's Separator, with the slot of a Typeset part. */ export function TypesetRule( props: ComponentProps, ): ReactElement { return ; } /** Strong text, in the kit's strong weight. */ export function TypesetStrong({ className, ...props }: ComponentProps<"strong">): ReactElement { return ( ); } /** The text a node renders, for the code of a fence. */ function textOf(node: ReactNode): string { if (typeof node === "string") return node; if (typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(textOf).join(""); if (isValidElement<{ children?: ReactNode }>(node)) return textOf(node.props.children); return ""; } export type TypesetCodeBlockProps = Omit, "title"> & { /** * Whether the closing fence has not come yet: the copy action stays * disabled until it has. Streamdown tells this with * `useIsCodeFenceIncomplete`. */ streaming?: boolean | undefined; }; /** * A fenced code block, as a markdown renderer passes it to `pre`: a `code` * element, with `language-x` as its class when the fence names a language. It * renders the kit's CodeBlock with that language, which it also shows as the * title, and the copy action; the language-less fence has the floating copy * action. `typesetComponents.pre` is this part. */ export function TypesetCodeBlock({ children, streaming, ...props }: TypesetCodeBlockProps): ReactElement { const fence = Children.toArray(children).find( ( child, ): child is ReactElement<{ children?: ReactNode; className?: string }> => isValidElement(child), ); const language = /(?:^|\s)language-(\S+)/.exec( fence?.props.className ?? "", )?.[1]; // The fence ends its last line with a line break of its own. const value = textOf(fence ? fence.props.children : children).replace( /\n$/, "", ); return ( ); } /* * The map for the `components` prop of a markdown renderer, react-markdown or * Streamdown: each element becomes a Typeset part, or the kit component that * owns it. A renderer passes the hast `node` along with the props of the * element; the parts below drop it, since a DOM element must not get it. */ type MarkdownProps = ComponentProps & { node?: unknown }; function withoutNode( props: Props, ): Omit { const { node, ...rest } = props; void node; return rest; } /** * The props of a table element without `node` and without `style`. A markdown * table part forwards no inline style: the one thing a renderer puts in it is * the alignment of a column, which becomes the part's `align`, and the rest of * the style is dropped, so the table keeps the look of its owner. */ function withoutNodeAndStyle( props: Props, ): Omit { const { node, style, ...rest } = props; void node; void style; return rest; } type CellAlignment = "center" | "end" | "start" | undefined; /** The alignment a markdown table gives a column, as a style or an attribute. */ function cellAlignment( align: string | undefined, style: ComponentProps<"td">["style"], ): CellAlignment { const value = style?.textAlign ?? align; if (value === "center") return "center"; if (value === "right" || value === "end") return "end"; if (value === "left" || value === "start") return "start"; return undefined; } function MarkdownHeading(level: TypesetHeadingLevel) { return function Heading(props: MarkdownProps<"h1">) { return ; }; } function MarkdownTableHead(props: MarkdownProps<"th">) { const { align, ...rest } = withoutNodeAndStyle(props); return ( ); } function MarkdownTableCell(props: MarkdownProps<"td">) { const { align, ...rest } = withoutNodeAndStyle(props); return ( ); } /** * Typeset parts for the elements of rendered markdown, for the `components` * prop of react-markdown and Streamdown: * * - paragraphs, headings (`h1` to `h6`), lists, list items, links, quotes, * rules and strong text are the Typeset parts; * - inline code is the kit's Code, and a fence (`pre` with its `code`) is * CodeBlock with the fence's language (see TypesetCodeBlock); * - a table is the kit's NativeTable, with its rows and cells. A table in * written text has no hover, since nothing in it acts, and takes no style: * a column's alignment becomes the part's `align`, the rest is dropped. * * The renderer itself is the app's; the kit has none. */ export const typesetComponents = { a: (props: MarkdownProps<"a">) => , blockquote: (props: MarkdownProps<"blockquote">) => ( ), code: (props: MarkdownProps<"code">) => , h1: MarkdownHeading(1), h2: MarkdownHeading(2), h3: MarkdownHeading(3), h4: MarkdownHeading(4), h5: MarkdownHeading(5), h6: MarkdownHeading(6), hr: () => , li: (props: MarkdownProps<"li">) => ( ), ol: (props: MarkdownProps<"ol">) => ( ), p: (props: MarkdownProps<"p">) => ( ), pre: (props: MarkdownProps<"pre">) => ( ), strong: (props: MarkdownProps<"strong">) => ( ), table: (props: MarkdownProps<"table">) => ( ), tbody: (props: MarkdownProps<"tbody">) => ( ), td: MarkdownTableCell, tfoot: (props: MarkdownProps<"tfoot">) => ( ), th: MarkdownTableHead, thead: (props: MarkdownProps<"thead">) => ( ), tr: (props: MarkdownProps<"tr">) => ( ), ul: (props: MarkdownProps<"ul">) => , }; // ~/src/siteplane/components/typeset/typeset.css @layer theme, base, components, siteplane, utilities; @layer siteplane { /* Rendered markdown. The container sets the kit's body type and stacks its * blocks with `gap` (S10), so a block adds its own space above itself and no * block restyles the one before it while a reply streams: no rule below * reads :last-child, :has() or :empty, which change as content arrives. * * Three custom properties tune it, as the density sets them: the size of the * text, its leading (a line height) and the flow (the gap between blocks). * The flow is a length from the size, so it does not change with the * font-size of whatever it is read on. A caller overrides them in a style * or with a utility class. * * Chat is the kit's own body type, 14 px on a 21 px line, with a tight * rhythm for a message. Document is a size up with a roomier rhythm. */ :where(.sp-typeset) { --sp-typeset-size: var(--sp-rw-font-size-default); --sp-typeset-leading: var(--sp-rw-line-height-default); --sp-typeset-flow: var(--sp-typeset-size); display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-typeset-flow); min-width: 0; font-size: var(--sp-typeset-size); line-height: var(--sp-typeset-leading); /* Long values wrap (rulebook K5): a URL or an identifier never pushes the * thread wider. */ overflow-wrap: anywhere; text-align: start; } .sp-typeset[data-density="document"] { --sp-typeset-size: var(--sp-rw-font-16); --sp-typeset-leading: var(--sp-rw-line-27); --sp-typeset-flow: calc(var(--sp-typeset-size) * 1.5); } /* Headings step up from the body size in the ratios of the kit's own type * (20, 18, 16 and 14 px at the chat size), so they scale with the density. * Titles take the title weight and the small ones the label weight, as * the kit's text roles do (Theming). A heading has half a flow of extra * space above it, in its own padding and not in a margin, so it belongs to * what follows more than to what comes before; the first block has none. */ :where(.sp-typeset-heading) { padding-block-start: calc(var(--sp-typeset-flow) * 0.5); font-size: 1em; font-weight: var(--sp-rw-font-weight-label); line-height: 1.5; } :where(.sp-typeset-heading:first-child) { padding-block-start: 0; } :where(.sp-typeset-heading[data-level="1"]) { font-size: calc(20em / 14); font-weight: var(--sp-rw-font-weight-title); } :where(.sp-typeset-heading[data-level="2"]) { font-size: calc(18em / 14); font-weight: var(--sp-rw-font-weight-title); } :where(.sp-typeset-heading[data-level="3"]) { font-size: calc(16em / 14); font-weight: var(--sp-rw-font-weight-title); } :where(.sp-typeset-strong) { font-weight: var(--sp-rw-font-weight-strong); } /* A list keeps its marks (the list semantics stay) and takes the flow's * smaller step between its items. Its mark is quieter than the text. */ :where(.sp-typeset-list) { display: grid; gap: var(--sp-rw-space-1-5); padding-inline-start: 1.625em; list-style-position: outside; list-style-type: disc; } :where(.sp-typeset-list[data-ordered]) { list-style-type: decimal; } :where(.sp-typeset-list-item .sp-typeset-list:not([data-ordered])) { list-style-type: circle; } :where(.sp-typeset-list-item)::marker { color: var(--sp-rw-fg-tertiary); } /* A list item is a list-item box, so it cannot be a grid, which would put * each run of its text in a cell of its own. The blocks inside it (a second * paragraph of a loose item, a nested list, a code block, a table) are * spaced by the margin above them, the one place a margin is the tool; * the owner of a block never sets its margin. */ :where(.sp-typeset-list-item > .sp-typeset-paragraph:not(:first-child)), :where(.sp-typeset-list-item > .sp-typeset-list), :where(.sp-typeset-list-item > .sp-typeset-quote), :where(.sp-typeset-list-item > .sp-typeset-rule), :where(.sp-typeset-list-item > .sp-code-block), :where(.sp-typeset-list-item > .sp-table-container) { margin-block-start: var(--sp-rw-space-1-5); } /* A quote is a bar at the start and quieter text. Its own blocks are * stacked by the same flow. */ :where(.sp-typeset-quote) { display: grid; gap: var(--sp-typeset-flow); padding-inline-start: var(--sp-rw-space-4); border-inline-start: calc(var(--sp-rw-border-default) * 3) solid var(--sp-rw-fg-quaternary); color: var(--sp-rw-fg-secondary); } /* A table scrolls sideways in its own container, so a long value cannot * push the thread wider (K5) and its cells keep words whole: they wrap * between words only, as Native Table's do outside Typeset. Before, they * took the wrap anywhere from the container above, and on a phone a name * broke mid-word (Lukas, 30 October 2026). */ :where(.sp-typeset .sp-table-container) { overflow-wrap: normal; } } // ~/src/siteplane/components/typeset/index.ts export * from "./typeset";