+
+ );
+});
+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,
+ );
+}