diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b27122f..fece097 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 diff --git a/docs/design/typesetting.md b/docs/design/typesetting.md new file mode 100644 index 0000000..6611188 --- /dev/null +++ b/docs/design/typesetting.md @@ -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. diff --git a/package-lock.json b/package-lock.json index fb6270b..86bd099 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,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", @@ -2871,6 +2872,13 @@ "dev": true, "license": "MIT" }, + "node_modules/justif": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/justif/-/justif-0.9.1.tgz", + "integrity": "sha512-8jYO7UFnRZdswmPQ/uYzMWapVX9CTondzxYt0dtKbymIHy4U6IvDOkbdqkxeWsTddHWAyxnpufghUV3INsm72A==", + "dev": true, + "license": "MIT" + }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", diff --git a/package.json b/package.json index 880335d..d3605ce 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ui-notices.mjs b/scripts/ui-notices.mjs index cef32e4..696dd54 100644 --- a/scripts/ui-notices.mjs +++ b/scripts/ui-notices.mjs @@ -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()) { diff --git a/ui/src/Transcript.tsx b/ui/src/Transcript.tsx index 39799ec..090a447 100644 --- a/ui/src/Transcript.tsx +++ b/ui/src/Transcript.tsx @@ -95,7 +95,11 @@ export function Transcript({ {turn.responseParts.map((part, index) => part.kind === "markdown" ? ( - + ) : part.kind === "toolCall" ? ( ); } -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 ( -
+ + ); +}); +function ProseBlock({ text, settled }: { text: string; settled: boolean }) { + const ref = useTypeset(settled, text); + return ( +
); -}); +} export function Empty({ title, children, diff --git a/ui/src/style.css b/ui/src/style.css index 8ddbe3e..d91253a 100644 --- a/ui/src/style.css +++ b/ui/src/style.css @@ -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; +} code, pre { font-family: "IBM Plex Mono", ui-monospace, monospace; diff --git a/ui/src/typeset.tsx b/ui/src/typeset.tsx new file mode 100644 index 0000000..70ca29b --- /dev/null +++ b/ui/src/typeset.tsx @@ -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(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( + settled: boolean, + key: string, +) { + const ref = useRef(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(true, text); + return createElement( + as, + { ref, className: className ? `typeset ${className}` : "typeset" }, + text, + ); +}