// ~/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 (
}
variant="link"
>
{children}
);
}
/** 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";