Typeset
Set rendered markdown, such as a chat answer or a document, in the kit’s type.
Three accounts are likely to churn
Active seats fell by 40% or more over the last 12 weeks at three accounts on the Growth and Scale plans. The has the full list.
- Halden Logistics has open questions about its renewal.
- Brightwave Studio stopped inviting new people in September.
- Ortega Foods moved its team to a smaller plan.
This is the query behind the list:
sqlSELECT name, active_seats, active_seats_12w_ago
FROM usage
WHERE active_seats <= active_seats_12w_ago * 0.6
ORDER BY active_seats * 1.0 / active_seats_12w_ago
LIMIT 3;| Account | Seats now | 12 weeks ago | Change |
|---|---|---|---|
| Halden Logistics | 17 | 41 | −59% |
| Brightwave Studio | 8 | 18 | −56% |
| Ortega Foods | 14 | 26 | −46% |
I can draft a check-in email for each of them.
Installation
pnpm dlx shadcn@4.18.0 add @siteplane/typesetComplete the one-time setup first. Registry JSON lists source files and dependencies.
For this page’s examples, also install:
pnpm dlx shadcn@4.18.0 add @siteplane/code-highlightCode is installed with Typeset. code-highlight highlights the code fences; without it, a fence shows plain text. See Code.
Usage
import {
Typeset,
TypesetHeading,
TypesetParagraph,
} from "@/siteplane/components/typeset";
<Typeset>
<TypesetHeading level={3}>Three accounts are likely to churn</TypesetHeading>
<TypesetParagraph>
Active seats fell by 40% or more in 12 weeks.
</TypesetParagraph>
</Typeset>;Composition
Typeset
├── TypesetHeading
├── TypesetParagraph
│ ├── TypesetStrong
│ ├── TypesetLink
│ └── Code
├── TypesetList
│ └── TypesetListItem
├── TypesetQuote
├── TypesetRule
├── TypesetCodeBlock
└── a table, from typesetComponentsTypeset sets the kit’s body type and stacks its blocks with a gap. With a markdown renderer you do not write the parts yourself: typesetComponents maps each element the renderer passes to its part. Inline code is the kit’s Code, a fence is a Code Block with the fence’s language, and a table is a Native Table, the plain HTML table in the look of Table, without hover, since nothing in written text acts. A long value in the text wraps wherever it has to, so it never makes the thread wider. A table scrolls sideways when it is wider than the text, and its cells wrap between words only.
Markdown renderers
The kit has no markdown renderer. Pass typesetComponents to the components prop of your renderer and put its output in a Typeset. The examples on this page write out by hand the elements that a renderer passes to the map.
react-markdown
react-markdown renders its elements without a container, so they are the blocks of the Typeset. Tables need the remark-gfm plugin.
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Typeset, typesetComponents } from "@/siteplane/components/typeset";
export function Answer({ text }: { text: string }) {
return (
<Typeset>
<Markdown components={typesetComponents} remarkPlugins={[remarkGfm]}>
{text}
</Markdown>
</Typeset>
);
}Streamdown
Streamdown renders markdown that is still arriving, and puts its blocks in a container of its own, which spaces them with a margin. Give that container className="contents space-y-0": contents makes the blocks the Typeset’s own, so its flow spaces them, and space-y-0 removes the margin.
A fence whose closing line has not arrived yet keeps its copy action disabled. Read that with Streamdown’s useIsCodeFenceIncomplete and pass it to TypesetCodeBlock as streaming.
import type { ComponentProps } from "react";
import { Streamdown, useIsCodeFenceIncomplete } from "streamdown";
import {
Typeset,
TypesetCodeBlock,
typesetComponents,
} from "@/siteplane/components/typeset";
// The renderer's node is not an attribute of the DOM element.
function Fence({
node: _node,
...props
}: ComponentProps<"pre"> & { node?: unknown }) {
return <TypesetCodeBlock {...props} streaming={useIsCodeFenceIncomplete()} />;
}
const components = { ...typesetComponents, pre: Fence };
export function StreamingAnswer({
streaming,
text,
}: {
streaming: boolean;
text: string;
}) {
return (
<Typeset>
<Streamdown
className="contents space-y-0"
components={components}
isAnimating={streaming}
>
{text}
</Streamdown>
</Typeset>
);
}Densities
density="chat", the default, is the kit’s body type for an answer in a thread: 14 px text on a 21 px line, with 14 px between blocks. density="document" is a size up for an article or an answer read on its own: 16 px text on a 27 px line, with 24 px between blocks. Headings step up from the body size in both.
Churn review, week 43
Three accounts lost more than 40% of their active seats in 12 weeks. This review lists what we know and who reaches out first.
What changed
- Seats fell fastest on the
Growthplan:- Halden Logistics from 41 to 17 seats
- Brightwave Studio from 18 to 8 seats
- Ortega Foods kept its plan but moved half of its team out.
We were told the reorganization would pause hiring, so we stopped adding seats.
A support conversation with Halden Logistics, 12 October.
Who reaches out first
- Halden Logistics, by the account owner, before the renewal call.
- Brightwave Studio, with the usage report attached.
- Ortega Foods, once the support thread is answered.
Tuning
Three custom properties tune a Typeset, and a density sets all three: --sp-typeset-size is the size of the text, --sp-typeset-leading its line height and --sp-typeset-flow the space between blocks. Headings and the flow follow the size, so a new size alone keeps the proportions of the density. Set them with a utility class or a style on the Typeset; either wins over the density. Give the flow as a length, such as a token or a multiple of the size, not in em, which would grow with the text of a heading.
<Typeset className="[--sp-typeset-leading:var(--sp-rw-line-24)] [--sp-typeset-size:var(--sp-rw-font-16)]">
{answer}
</Typeset>Streaming
A reply that streams adds blocks at the end and grows its last block. Typeset spaces its blocks with a gap, and none of its rules looks at the blocks after an element, so a block that arrives never restyles the ones before it. In a fence that streams, the lines that are complete keep their highlighting while the last line grows.
AI SDK
A text part is markdown. Render it in a Typeset with the map, and tell Streamdown that the part is still arriving while its state is streaming. As with the steps on the Trace page, only the last message streams, and only while the chat’s status is streaming, so a part that stopped with the reply does not stay animated. In an assistant turn, the Typeset sits in a ghost Bubble, which gives the answer no surface of its own.
import type { UIMessage } from "ai";
export function AnswerText({
live,
message,
}: {
live: boolean;
message: UIMessage;
}) {
return message.parts.map((part, index) =>
part.type === "text" ? (
<StreamingAnswer
key={index}
streaming={live && part.state === "streaming"}
text={part.text}
/>
) : null,
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
Typeset. | TypesetDensity: chat | document | chat | chat is the body type with a tight rhythm, for a message; document is a size up with more room between blocks. |
Typeset | TypesetHeadingLevel: 1 | 2 | 3 | 4 | 5 | 6 | 2 | 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. |
Typeset | boolean | false | A numbered list (ol) instead of a bulleted one (ul). |
Typeset | boolean | false | The fence is still open: the copy action stays disabled until it closes. |
typeset | Record of element names to parts | — | The map for the components prop of react-markdown and Streamdown: a, blockquote, code, h1 to h6, hr, li, ol, p, pre, strong, the table elements and ul. |
Typeset renders a div that stacks its blocks with the flow as a gap. Each
part renders its element (TypesetParagraph a p, TypesetListItem an li,
TypesetQuote a blockquote, TypesetStrong a strong) and takes its props
and string className. TypesetLink is the kit’s inline link Button rendered
as an a, underlined in the text color, and takes the attributes of an anchor.
TypesetRule is the kit’s Separator.
TypesetCodeBlock reads the code element a renderer puts in a pre: its
language-x class becomes the Code Block’s language and title, and its text
the value, which the Code Block copies. The table parts take no inline style
from the renderer: a column’s alignment becomes the cell’s alignment, and the
rest is dropped. The parts of the map drop the node prop a renderer passes.
TypesetDensity, TypesetProps, TypesetHeadingLevel, TypesetHeadingProps,
TypesetListProps and TypesetCodeBlockProps are exported.
Usage notes
Typeset styles what the renderer gives it. Which markdown and HTML the renderer allows, and how it treats links and images, is the renderer’s. Elements the map leaves out, such as images, emphasis and strikethrough, render as the renderer renders them, in the type of the Typeset.
Typeset is for written text. The labels and text of an interface, such as a form or a card, take the type of their own components.