Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- Lucide, Lucide contributors, ISC: https://github.com/lucide-icons/lucide
- React Markdown and remark-gfm, unified contributors, MIT:
https://github.com/remarkjs/react-markdown and https://github.com/remarkjs/remark-gfm
- Justif, Lyall Cooper, MIT: https://github.com/lyallcooper/justif
- DM Sans and IBM Plex Mono font packages, SIL Open Font License 1.1:
https://fontsource.org/fonts/dm-sans and https://fontsource.org/fonts/ibm-plex-mono

Expand Down
50 changes: 50 additions & 0 deletions docs/design/typesetting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Typesetting

## What changed

Prose in the workspace is now set with [Justif](https://github.com/lyallcooper/justif)
(MIT): Knuth–Plass line breaking over the whole paragraph, TeX hyphenation
patterns, punctuation hung into the margin, and glyph protrusion at the edges.
The browser's one-line-at-a-time justification produces rivers and loose
lines; Justif evaluates the paragraph as a whole, the way a book is set.

Where it applies:

- Every Markdown paragraph, list item and blockquote in the agent transcript and
the discussion (the `Prose` component), once the text has settled.
- Not while a turn is still streaming: those paragraphs stay ragged with
`text-wrap: pretty` so the text does not reflow under the reader, and are
typeset the moment the turn completes.
- Not on teasers and labels. Two-line card descriptions were hyphenating
("exist-ing parser API"), which is fussier than it is beautiful, so short
copy keeps the browser's `pretty` breaker and headings get `balance`.

Files: `ui/src/typeset.tsx` (the `typeset()` helper, `useTypeset` hook and a
`Typeset` block for plain text), `ui/src/components.tsx` (`Prose` gains a
`settled` prop), `ui/src/Transcript.tsx`, `ui/src/style.css`.

## How it coexists with React

Justif rewrites the inside of each paragraph (one span per line with tuned
word spacing and tracking), and restores it on `destroy()`. React must never
update nodes Justif has rearranged, so `Prose` keys its element by content:
new text means a fresh element, the old controller is destroyed on unmount,
and the new one runs in a layout effect. Resize and font loading are handled
by Justif's own observers.

One CSS detail mattered: `overflow-wrap: anywhere` (which the prose container
uses so long URLs cannot blow out the layout) lets the browser take emergency
breaks inside words, which undid Justif's plan and produced a stray short
line after inline `code`. Justified blocks now set `overflow-wrap: normal`;
long unbreakable strings are handled by Justif declining that paragraph and
leaving it native.

## Where else it could go

- The agent's tool output and comments already flow through `Prose`. The
activity feed still renders raw comment text with `white-space: pre-wrap`;
moving it to `Prose` would justify it too.
- Justif supports per-line `wdth` adjustments on variable fonts. AXP Runde is
static, so that lever is unused; Recursive (see `typography.md`) would enable it.
- German, French and twenty other hyphenation dictionaries are available if
the workspace ever carries a `lang` other than English.
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"aamp-sdk": "0.1.24",
"c8": "^11.0.0",
"eslint": "^10.0.0",
"justif": "^0.9.1",
"lucide-react": "1.41.0",
"prettier": "^3.9.0",
"react": "19.2.8",
Expand Down
1 change: 1 addition & 0 deletions scripts/ui-notices.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const roots = [
"remark-gfm",
"@fontsource-variable/dm-sans",
"@fontsource/ibm-plex-mono",
"justif",
];
const packages = new Map();
async function visit(name, parent = process.cwd()) {
Expand Down
6 changes: 5 additions & 1 deletion ui/src/Transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ export function Transcript({
</div>
{turn.responseParts.map((part, index) =>
part.kind === "markdown" ? (
<Prose key={index} text={part.content} />
<Prose
key={index}
text={part.content}
settled={turn.id !== chat.activeTurn?.id}
/>
) : part.kind === "toolCall" ? (
<Tool
key={index}
Expand Down
23 changes: 20 additions & 3 deletions ui/src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
Contribution,
WorkspaceView,
} from "../../src/workspace-contract.js";
import { useTypeset } from "./typeset.js";

export function Mark({ small = false }: { small?: boolean }) {
return (
Expand Down Expand Up @@ -161,9 +162,25 @@ export function ContributionCard({
</button>
);
}
export const Prose = memo(function Prose({ text }: { text: string }) {
/** Markdown prose. Once `settled`, its paragraphs are typeset: justified with
* Knuth–Plass breaks, hyphenated, punctuation hung in the margin. While a turn
* is still streaming the text stays ragged so it does not reflow under the
* reader. Keyed by content so Justif and React never edit the same nodes. */
export const Prose = memo(function Prose({
text,
settled = true,
}: {
text: string;
settled?: boolean;
}) {
return (
<div className="prose">
<ProseBlock key={`${settled}:${text}`} text={text} settled={settled} />
);
});
function ProseBlock({ text, settled }: { text: string; settled: boolean }) {
const ref = useTypeset<HTMLDivElement>(settled, text);
return (
<div ref={ref} className={settled ? "prose prose--set" : "prose"}>
<Markdown
remarkPlugins={[remarkGfm]}
skipHtml
Expand All @@ -183,7 +200,7 @@ export const Prose = memo(function Prose({ text }: { text: string }) {
</Markdown>
</div>
);
});
}
export function Empty({
title,
children,
Expand Down
31 changes: 31 additions & 0 deletions ui/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,37 @@ h3,
p {
margin: 0;
}
/* Ragged text gets the browser's better breaker; headings balance their lines. */
p,
li,
dd,
blockquote {
text-wrap: pretty;
}
h1,
h2,
h3 {
text-wrap: balance;
}
/* Justif enhances only elements whose computed text-align is justify. */
.prose--set p,
.prose--set li,
.prose--set dd,
.prose--set blockquote,
.typeset {
text-align: justify;
hyphens: auto;
-webkit-hyphens: auto;
hanging-punctuation: first last;
/* Justif plans every break; emergency breaks inside words would undo them. */
overflow-wrap: normal;
}
.prose--set code,
.typeset code {
hyphens: none;
-webkit-hyphens: none;
overflow-wrap: normal;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve emergency wrapping for long inline code

When settled Markdown contains an inline code token longer than the available line, this higher-specificity rule overrides the existing code { overflow-wrap: anywhere } fallback while also disabling hyphenation. Such a token cannot wrap, and the transcript's .contribution-content container uses overflow: hidden, so its tail is clipped on narrow panes; retain an emergency wrapping path for unbreakable code that Justif cannot set.

Useful? React with 👍 / 👎.

}
code,
pre {
font-family: "IBM Plex Mono", ui-monospace, monospace;
Expand Down
73 changes: 73 additions & 0 deletions ui/src/typeset.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { createElement, useLayoutEffect, useRef } from "react";
import { justify } from "justif";
import { hyphenateEnUS } from "justif/hyphenate/en-us";

/* Paragraph-level typesetting.
*
* Justif runs Knuth–Plass line breaking over each paragraph it is given,
* hyphenates with TeX patterns, hangs punctuation, and protrudes glyphs at
* the margin. It only touches elements whose computed text-align is
* `justify`, so the CSS decides which surfaces get the treatment and this
* module decides when: never while text is still streaming in, and always
* on a fresh element (callers key the element by its content) so React and
* Justif never edit the same nodes. */

const BLOCKS = "p, li, dd, blockquote, figcaption";

export function typeset(root: HTMLElement): () => void {
const targets = root.matches(BLOCKS)
? [root]
: [...root.querySelectorAll<HTMLElement>(BLOCKS)];
if (!targets.length) return () => {};
const controller = justify(targets, {
hyphenate: hyphenateEnUS,
// Bringhurst's "at least a third": short last lines are widened rather
// than left as a single hanging word.
lastLineMinWidth: 0.33,
cleanClipboard: true,
});
return () => controller.destroy();
}

/** Typeset the referenced element once it is settled. Re-run when `key` changes. */
export function useTypeset<T extends HTMLElement>(
settled: boolean,
key: string,
) {
const ref = useRef<T>(null);
useLayoutEffect(() => {
if (!settled || !ref.current) return;
return typeset(ref.current);
}, [settled, key]);
return ref;
}

/** A justified block of plain text. The element remounts when its text changes. */
export function Typeset({
as = "p",
className,
children,
}: {
as?: "p" | "div";
className?: string;
children: string;
}) {
return createElement(Block, { key: children, as, className, text: children });
}

function Block({
as,
className,
text,
}: {
as: "p" | "div";
className?: string;
text: string;
}) {
const ref = useTypeset<HTMLElement>(true, text);
return createElement(
as,
{ ref, className: className ? `typeset ${className}` : "typeset" },
text,
);
}
Loading