From 499af7d6d56a4ec088b4dd6054890cf2af4cdb63 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 2 Sep 2026 10:59:22 -0500 Subject: [PATCH 1/7] feat: size fanfold's table columns from content, and fold notes into the row Wide tables in the fanfold theme ran past the sheet and hid the end of every line. On one conformance page five tables overflowed, 738px of text sat off the right edge, and nothing on screen said so. Columns are measured at build time now. A new rehype plugin finds the one column of a table that holds prose and gives every other cell a size class taken from the longest run of text in it that cannot be broken, so a theme can state widths rather than guess from the column count. Long identifiers are given `` seams at their punctuation and capitals, so a name wraps instead of being cut in half. fanfold folds the prose column under its row and opens it on demand, which gives a paragraph the measure body copy uses instead of whatever the labels leave. - content column is 768px wide - 0 tables reach past the sheet, down from 5, and nothing is clipped - tallest row is 136px, down from 840px - 187 of 193 label cells sit on one line, and none is broken mid-word --- .../chronicle/src/components/mdx/table.tsx | 44 ++- .../src/lib/rehype-table-columns.test.ts | 358 ++++++++++++++++++ .../chronicle/src/lib/rehype-table-columns.ts | 341 +++++++++++++++++ packages/chronicle/src/server/vite-config.ts | 2 + .../src/themes/fanfold/ExpandableRow.tsx | 86 +++++ .../src/themes/fanfold/Page.module.css | 213 ++++++++++- 6 files changed, 1035 insertions(+), 9 deletions(-) create mode 100644 packages/chronicle/src/lib/rehype-table-columns.test.ts create mode 100644 packages/chronicle/src/lib/rehype-table-columns.ts create mode 100644 packages/chronicle/src/themes/fanfold/ExpandableRow.tsx diff --git a/packages/chronicle/src/components/mdx/table.tsx b/packages/chronicle/src/components/mdx/table.tsx index 3313848a..4eea522b 100644 --- a/packages/chronicle/src/components/mdx/table.tsx +++ b/packages/chronicle/src/components/mdx/table.tsx @@ -1,7 +1,26 @@ 'use client' import { Table } from '@raystack/apsara' -import type { ComponentProps, ReactNode } from 'react' +import { Children, isValidElement, type ComponentProps, type ReactElement, type ReactNode } from 'react' +import { usePageContext } from '@/lib/page-context' +import { FanfoldExpandableRow } from '@/themes/fanfold/ExpandableRow' + +/** A cell as `rehype-table-columns` leaves it: a size class, or none if prose. */ +type CellElement = ReactElement<{ 'data-col'?: string; children?: ReactNode }> + +/** + * Separates a row's label cells from its one prose cell, or returns null when + * the row is not one `rehype-table-columns` marked up — every label cell carries + * a size class and the prose cell carries none, so the row can tell on its own + * without being told which table it belongs to. + */ +function splitRow(children: ReactNode) { + const cells = Children.toArray(children).filter(isValidElement) as CellElement[] + const labels = cells.filter(cell => cell.props['data-col'] != null) + const prose = cells.filter(cell => cell.props['data-col'] == null) + if (labels.length < 2 || prose.length !== 1) return null + return { cells, labels, prose: prose[0] } +} type TableProps = ComponentProps<'table'> @@ -24,6 +43,29 @@ export function MdxTbody({ children, ...props }: TbodyProps) { type TrProps = ComponentProps<'tr'> export function MdxTr({ children, ...props }: TrProps) { + const { config } = usePageContext() + + // Only fanfold folds the paragraph away. The other themes give a table the + // full width of the page, so their prose column has room to stay in the row. + if (config.theme?.name === 'fanfold') { + const split = splitRow(children) + + if (split?.cells.every(cell => cell.type === MdxTh)) { + // The prose column's heading moves over the toggle, so the reader can see + // what is folded away rather than a bare chevron. + return ( + + {split.labels} + {split.prose.props.children} + + ) + } + + if (split?.cells.every(cell => cell.type === MdxTd)) { + return + } + } + return {children} } diff --git a/packages/chronicle/src/lib/rehype-table-columns.test.ts b/packages/chronicle/src/lib/rehype-table-columns.test.ts new file mode 100644 index 00000000..3fa12ef5 --- /dev/null +++ b/packages/chronicle/src/lib/rehype-table-columns.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, test } from 'bun:test'; +import type { Element, ElementContent, Root } from 'hast'; +import rehypeTableColumns from './rehype-table-columns'; + +function cell(tagName: 'th' | 'td', value: string): Element { + return { type: 'element', tagName, properties: {}, children: [{ type: 'text', value }] }; +} + +function row(tagName: 'th' | 'td', values: string[]): Element { + return { + type: 'element', + tagName: 'tr', + properties: {}, + children: values.map(value => cell(tagName, value)), + }; +} + +/** A markdown table as rehype sees it: one `thead` row, then the body. */ +function table(headers: string[], body: string[][]): Element { + return { + type: 'element', + tagName: 'table', + properties: {}, + children: [ + { type: 'element', tagName: 'thead', properties: {}, children: [row('th', headers)] }, + { + type: 'element', + tagName: 'tbody', + properties: {}, + children: body.map(values => row('td', values)), + }, + ], + }; +} + +function run(node: Element): Element { + const tree: Root = { type: 'root', children: [node] }; + const transform = rehypeTableColumns.call({ use: () => undefined } as never) as ( + tree: Root + ) => void; + transform(tree); + return tree.children[0] as Element; +} + +/** The size class of each column, reading the header row. `null` is the prose column. */ +function classes(node: Element): Array { + const thead = node.children.find( + (child): child is Element => child.type === 'element' && child.tagName === 'thead' + ); + const headerRow = thead?.children.find( + (child): child is Element => child.type === 'element' && child.tagName === 'tr' + ); + return (headerRow?.children ?? []) + .filter((child): child is Element => child.type === 'element') + .map(th => (th.properties['data-col'] as string) ?? null); +} + +/** Which column carries `data-grow`, reading the header row. */ +function growColumn(node: Element): number { + const thead = node.children.find( + (child): child is Element => child.type === 'element' && child.tagName === 'thead' + ); + const headerRow = thead?.children.find( + (child): child is Element => child.type === 'element' && child.tagName === 'tr' + ); + return (headerRow?.children ?? []) + .filter((child): child is Element => child.type === 'element') + .findIndex(th => th.properties['data-grow'] != null); +} + +/** The cells of the first body row, as text with `|` marking each break opportunity. */ +function bodyParts(node: Element): string[] { + const tbody = node.children.find( + (child): child is Element => child.type === 'element' && child.tagName === 'tbody' + ); + const firstRow = tbody?.children.find( + (child): child is Element => child.type === 'element' && child.tagName === 'tr' + ); + return (firstRow?.children ?? []) + .filter((child): child is Element => child.type === 'element') + .map(flatten); +} + +/** Text with `|` for each break opportunity, however deeply the markup nests. */ +function flatten(node: ElementContent): string { + if (node.type === 'text') return node.value; + if (node.type !== 'element') return ''; + if (node.tagName === 'wbr') return '|'; + return node.children.map(flatten).join(''); +} + +const prose = (length: number) => 'x'.repeat(length); + +describe('rehypeTableColumns', () => { + test('states widths for a conformance table: one prose column, the rest labels', () => { + const result = run( + table(['Item', 'Description', 'Reference', 'Status', 'Support', 'Notes'], [ + ['SPP-14', 'Space Packet', '4.1', 'M', 'Yes', prose(400)], + ['SPP-15', 'Packet Primary Header', '4.1.3', 'M', 'Yes', prose(350)], + ]) + ); + + expect(result.properties['data-fit']).toBe('stated'); + // Sized by the longest run rather than the longest cell: "Packet Primary + // Header" is 21 characters but wraps at its spaces, so the column only has + // to fit "Description" in the heading above it. + expect(classes(result)).toEqual(['xs', 's', 's', 'xs', 'xs', null]); + }); + + test('leaves a field-and-value pair alone: one label column needs no stated width', () => { + const result = run( + table(['Field', 'Value'], [ + ['Implementation Name', 'astro/pkg/spp'], + ['Other Information', prose(200)], + ]) + ); + + expect(result.properties['data-fit']).toBeUndefined(); + expect(classes(result)).toEqual([null, null]); + }); + + test('leaves a table with no prose column alone', () => { + const result = run( + table(['Category', 'Total', 'Supported'], [ + ['Mandatory', '31', '31'], + ['Optional', '12', '9'], + ]) + ); + + expect(result.properties['data-fit']).toBeUndefined(); + }); + + test('leaves a table with a second prose column alone', () => { + const result = run( + table(['Requirement', 'Reference', 'Why it is not checked'], [ + [prose(200), '4.1.4', prose(300)], + [prose(180), '4.1.5', prose(250)], + ]) + ); + + expect(result.properties['data-fit']).toBeUndefined(); + }); + + test('a long label column is still a label column', () => { + const result = run( + table(['Item', 'Description', 'Notes'], [ + ['TM-1', prose(88), prose(333)], + ['TM-2', prose(60), prose(300)], + ]) + ); + + expect(result.properties['data-fit']).toBe('stated'); + expect(classes(result)).toEqual(['xs', 'xl', null]); + }); + + test('a runaway cell in a label column makes it a second prose column', () => { + // One 156-character entry is enough: the column could need a paragraph's + // room, so the table is left to size itself rather than have that room + // taken from the Notes column beside it. + const result = run( + table(['Item', 'Description', 'Notes'], [ + ['TM-1', prose(156), prose(130)], + ['TM-2', 'short', prose(125)], + ]) + ); + + expect(result.properties['data-fit']).toBeUndefined(); + }); + + test('the prose column can sit in the middle of a table', () => { + // The last column here is a flag; the paragraphs are in column four. + const result = run( + table(['Item', 'Description', 'Reference', 'Notes', 'Status'], [ + ['TM-1', prose(90), '4.1.4', prose(349), 'Yes'], + ['TM-2', prose(60), '4.1.5', prose(200), 'No'], + ]) + ); + + expect(result.properties['data-fit']).toBe('stated'); + expect(classes(result)).toEqual(['xs', 'xl', 's', null, 'xs']); + }); + + test('a header counts towards the width a column must fit', () => { + // Every value is one character, but "Support" is seven, so the column is + // sized for the heading rather than collapsing under it. + const result = run( + table(['Item', 'Support', 'Notes'], [ + ['A', 'M', prose(200)], + ['B', 'C', prose(180)], + ]) + ); + + expect(classes(result)).toEqual(['xs', 'xs', null]); + }); + + test('sizes a column to fit its longest word, so no word has to break', () => { + // "Unsupported" is eleven characters with nothing to break at. Sizing this + // column by its longest cell put it in a class that fits nine, and the word + // came out as "Unsupporte" above a stray "d". + const result = run( + table(['Item', 'Status', 'Notes'], [ + ['XT-1', 'Unsupported', prose(300)], + ['XT-2', 'Ignored', prose(280)], + ]) + ); + + expect(classes(result)[1]).toBe('m'); + }); + + test('a hyphen already breaks, so it does not widen a column', () => { + // "SPP-14" is six characters but CSS may break after the hyphen, so the + // column only has to fit "SPP-". + const result = run( + table(['Item', 'Ref', 'Notes'], [ + ['SPP-14', '4.1', prose(300)], + ['SPP-15', '4.2', prose(280)], + ]) + ); + + expect(classes(result)[0]).toBe('xs'); + }); + + test('a run too long for any class takes the widest rather than giving up', () => { + // Falling back to automatic layout here would hide the end of every line + // off the sheet, which is worse than one broken word. + const result = run( + table(['Item', 'Description', 'Notes'], [ + ['A', 'Averyveryverylongunbrokenidentifier', prose(300)], + ['B', 'short', prose(280)], + ]) + ); + + expect(result.properties['data-fit']).toBe('stated'); + expect(classes(result)[1]).toBe('xl'); + }); + + test('breaks a long label at its punctuation, not mid-word', () => { + const result = run( + table(['Item', 'Description', 'Notes'], [ + ['SPP-10', 'Octet_String.indication', prose(300)], + ['SPP-11', 'Packet.request', prose(280)], + ]) + ); + + expect(bodyParts(result)[1]).toBe('Octet_|String.|indication'); + }); + + test('leaves short labels whole, so a clause reference keeps its dots', () => { + const result = run( + table(['Item', 'Reference', 'Support', 'Notes'], [ + ['SPP-10', '4.1.3.3.3.4', 'Yes', prose(300)], + ['SPP-11', '4.1.4', 'Yes', prose(280)], + ]) + ); + + expect(bodyParts(result)[1]).toBe('4.1.3.3.3.4'); + }); + + test('seams the prose column too, since its width is only the remainder', () => { + const result = run( + table(['Item', 'Description', 'Notes'], [ + ['SPP-10', 'short', `${prose(300)} Service.ReceivePacketIndication()`], + ['SPP-11', 'short', prose(280)], + ]) + ); + + expect(bodyParts(result)[2]).toContain('Service.|Receive|Packet|Indication()'); + }); + + test('breaks an identifier at its capitals when it has no punctuation', () => { + // `TransmissionConstraintList` has nothing else to break at. Left whole it + // overflowed its cell, and the cells are clipped with an ellipsis, so the + // end of the name simply disappeared. + const result = run( + table(['Element', 'Status', 'Notes'], [ + ['TransmissionConstraintList', 'Unsupported', prose(300)], + ['VerifierSet', 'Unsupported', prose(280)], + ]) + ); + + expect(bodyParts(result)[0]).toBe('Transmission|Constraint|List'); + // Sized for its longest part now, not the whole name. + expect(classes(result)[0]).toBe('m'); + }); + + test('adds nothing to a table it does not mark up', () => { + const result = run( + table(['Field', 'Value'], [['Implementation.Name.Long', prose(200)]]) + ); + + expect(bodyParts(result)[0]).toBe('Implementation.Name.Long'); + }); + + test('breaks a long label inside markup, such as an inline code span', () => { + const node = table(['Item', 'Description', 'Notes'], [ + ['SPP-10', 'placeholder', prose(300)], + ]); + const tbody = node.children[1] as Element; + const row = (tbody.children[0] as Element).children as Element[]; + row[1].children = [ + { type: 'element', tagName: 'code', properties: {}, children: [ + { type: 'text', value: 'Service.SendPacket' }, + ] }, + ]; + + expect(bodyParts(run(node))[1]).toBe('Service.|Send|Packet'); + }); + + test('marks the label column holding the most text as the one to grow', () => { + const result = run( + table(['Item', 'Description', 'Reference', 'Status', 'Notes'], [ + ['SPP-1', 'Space Packet Service Data Unit', '3.2.2', 'M', prose(300)], + ['SPP-2', 'Octet String SDU', '3.2.3', 'M', prose(280)], + ]) + ); + + // Description, not the item code beside it nor the clause reference. + expect(growColumn(result)).toBe(1); + }); + + test('never marks the prose column to grow', () => { + const result = run( + table(['Item', 'Description', 'Notes'], [ + ['A', 'short', prose(600)], + ['B', 'short', prose(500)], + ]) + ); + + const grow = growColumn(result); + expect(grow).not.toBe(2); + expect(classes(result)[grow]).not.toBeNull(); + }); + + test('marks exactly one column to grow', () => { + const result = run( + table(['Item', 'Description', 'Reference', 'Notes'], [ + ['A', 'Something long here', '3.2.2', prose(300)], + ['B', 'Another long one', '3.2.3', prose(280)], + ]) + ); + + const marked = classes(result).filter((_, i) => growColumn(result) === i); + expect(marked).toHaveLength(1); + }); + + test('ignores a table with a spanning cell', () => { + const node = table(['Item', 'Description', 'Notes'], [['A', 'b', prose(200)]]); + const tbody = node.children[1] as Element; + const firstRow = tbody.children[0] as Element; + (firstRow.children[0] as Element).properties.colSpan = 2; + + expect(run(node).properties['data-fit']).toBeUndefined(); + }); + + test('ignores a table with no body rows', () => { + expect(run(table(['Item', 'Notes'], [])).properties['data-fit']).toBeUndefined(); + }); +}); diff --git a/packages/chronicle/src/lib/rehype-table-columns.ts b/packages/chronicle/src/lib/rehype-table-columns.ts new file mode 100644 index 00000000..30e515c7 --- /dev/null +++ b/packages/chronicle/src/lib/rehype-table-columns.ts @@ -0,0 +1,341 @@ +import type { Element, ElementContent, Root } from 'hast' +import type { Plugin } from 'unified' +import { visit } from 'unist-util-visit' + +/** + * How long a single cell has to be before its column counts as able to hold + * prose. Measured against the corpus this was built for: across 482 tables the + * columns holding sentences top out well past this, while the ones holding + * names, clause references and flags sit under 100 characters. Anything below + * the line is a column of labels, however wide the widest label happens to be. + */ +const PROSE_MIN_CHARS = 120 + +/** + * Size classes for the columns that are not the prose column, by the longest run + * of text in the column that cannot be broken. The theme turns these into + * widths; the classes exist so that the decision about how much room a column of + * clause references deserves lives in the theme, next to the type it is sizing, + * rather than here. + * + * The run is what matters rather than the longest cell, because a stated width + * is narrower than the longest cell by design — the cell is expected to wrap. It + * is only the unbreakable run that has to fit on one line, and a class narrower + * than its own run is what produced `Unsupporte` above a stray `d`. So each + * class here is a promise the theme keeps: a column of this class fits this many + * characters without breaking a word. + * + * The last class is a catch-all. A run too long even for that is set narrow and + * breaks, which is the lesser fault: the alternative is handing the table back + * to automatic layout, and that hides the end of every line off the sheet. + */ +const SIZE_CLASSES = [ + { name: 'xs', maxRun: 6 }, + { name: 's', maxRun: 10 }, + { name: 'm', maxRun: 13 }, + { name: 'l', maxRun: 17 }, +] as const + +const WIDEST_CLASS = 'xl' + +function sizeClass(run: number): string { + for (const size of SIZE_CLASSES) { + if (run <= size.maxRun) return size.name + } + return WIDEST_CLASS +} + +/** + * Header text is set smaller than the cells below it, so a heading of a given + * length needs less room than the same length of body copy. Without this a + * column of one-character flags would be sized for the whole of "SUPPORT" as + * though it were body text, and four such columns would take 176px from the one + * column holding paragraphs. + */ +const HEADER_CHAR_RATIO = 0.85 + +/** + * How many columns of labels a table needs before stating their widths is worth + * it. One label beside a paragraph is a field-and-value pair: automatic layout + * already fits it, and stating a width there would only make one table look + * different from the untagged ones beside it. + */ +const MIN_LABEL_COLUMNS = 2 + +/** + * Punctuation a long label may break after. These are the seams of an + * identifier — `Octet_String.indication` reads as three parts, and breaking it + * at one of them is the difference between a wrapped name and a typo. + * + * A hyphen is absent because CSS already offers a break after one, so an item + * code like "SPP-14" can break there without any help; that is what the 8ch + * floor further up is for. + */ +const BREAK_AFTER = new Set(['.', '_', '/', ':']) + +/** + * Words at or below this length are left alone. The narrowest label column + * holds about eleven characters, so anything shorter than this cannot be forced + * to break and gains nothing from the extra markup — a clause reference like + * "4.1.3.3.3.4" keeps every dot unbroken. + */ +const MIN_BREAKABLE_WORD = 12 + +/** + * Splits a string where a long word could sensibly break, returning the parts + * that a break opportunity should sit between. + * + * Two kinds of seam. After the punctuation of an identifier, and before a + * capital that starts a new word inside one — `TransmissionConstraintList` has + * no punctuation at all, and the humps are the only thing it can be broken at + * without cutting a word in half. + * + * Neither is taken within two characters of either end of the word, where a + * break would stand one or two letters on a line by themselves. + */ +function breakParts(value: string): string[] { + const parts: string[] = [] + let start = 0 + + for (const match of value.matchAll(/\S+/g)) { + const word = match[0] + if (word.length <= MIN_BREAKABLE_WORD || match.index === undefined) continue + + for (let index = 2; index <= word.length - 2; index++) { + const afterPunctuation = BREAK_AFTER.has(word[index - 1]) + const camelHump = /[A-Z]/.test(word[index]) && /[a-z0-9]/.test(word[index - 1]) + if (!afterPunctuation && !camelHump) continue + + const cut = match.index + index + parts.push(value.slice(start, cut)) + start = cut + } + } + + parts.push(value.slice(start)) + return parts +} + +/** + * The longest run of characters in a string with no way to break inside it — + * what the column has to fit on one line. Whitespace, the seams that + * `breakParts` finds, and hyphens all end a run, the last because CSS already + * offers a break after one. + */ +function longestRun(value: string): number { + let longest = 0 + + for (const part of breakParts(value)) { + for (const word of part.split(/\s+/)) { + for (const run of word.split(/(?<=-)/)) { + longest = Math.max(longest, run.length) + } + } + } + + return longest +} + +/** + * Offers the line breaker somewhere sensible to break the long labels in a cell. + * + * A stated width is narrower than the longest label it has to hold, so those + * labels wrap — and `overflow-wrap: break-word` wraps a word with no break + * opportunity in it by cutting anywhere, which turned `Packet.request` into + * `Packet.requ` and a stray `est`. Widening the column instead would work, but + * a 23-character identifier needs 209px of it, taken from the one column that + * is holding paragraphs. + * + * `` costs nothing and reads as nothing: it is a break opportunity rather + * than a character, so it leaves the text it sits in untouched when copied. + * `break-word` stays on underneath as the fallback for a word with no seam in + * it at all, so that nothing can ever grow past its cell — the cells here are + * `overflow: hidden` with an ellipsis, and a word that refuses to break is a + * word whose end is quietly cut off. + */ +function addBreakOpportunities(parent: Element): void { + const children: ElementContent[] = [] + let inserted = false + + for (const child of parent.children) { + if (child.type === 'element') { + addBreakOpportunities(child) + children.push(child) + continue + } + + if (child.type !== 'text') { + children.push(child) + continue + } + + const parts = breakParts(child.value) + if (parts.length === 1) { + children.push(child) + continue + } + + inserted = true + parts.forEach((part, index) => { + if (index > 0) { + children.push({ type: 'element', tagName: 'wbr', properties: {}, children: [] }) + } + children.push({ type: 'text', value: part }) + }) + } + + if (inserted) parent.children = children +} + +/** Concatenates every text descendant, so a cell's markup does not hide its length. */ +function toText(node: unknown): string { + if (!node || typeof node !== 'object') return '' + const n = node as { children?: unknown[]; value?: unknown } + if (Array.isArray(n.children)) return n.children.map(toText).join('') + return typeof n.value === 'string' ? n.value : '' +} + +function isCell(node: Element): boolean { + return node.tagName === 'th' || node.tagName === 'td' +} + +/** The cells of one row, or null if any of them spans more than one column. */ +function cellsOf(row: Element): Element[] | null { + const cells = row.children.filter( + (child): child is Element => child.type === 'element' && isCell(child) + ) + for (const cell of cells) { + if (cell.properties.colSpan != null || cell.properties.rowSpan != null) return null + } + return cells +} + +interface ColumnStats { + /** Longest cell in the column, header included — what makes a column prose. */ + longest: number + /** Longest unbreakable run, in body characters — what the column must fit. */ + run: number +} + +/** + * Marks up what each column of a table holds, so a theme can lay one out + * without guessing from the column count. + * + * The problem it solves: a conformance table puts an item code, a name, a clause + * reference and two flags beside a column of full paragraphs. Automatic table + * layout gives the paragraph column whatever it asks for, which runs the table + * past its container and hides the end of every line, and it sizes each table + * from that table's own content, so no two on a page line up. Stating the widths + * fixes both, but only a theme that knows which column is which can state them, + * and CSS cannot ask how much text is in a column. + * + * So a table whose shape a theme can state widths for is given `data-fit`, and + * each of its label cells a `data-col` size class sized to the longest run of + * text in that column that cannot be broken. The label column holding the most + * text also gets `data-grow`, marking it as the one to give any spare width to. The prose column is left + * without one: in a fixed layout it takes whatever the stated columns do not, + * which is exactly what it should have. Absence of a class is what identifies + * it, so no second attribute has to agree with the first. + * + * A table that is any other shape gets no attributes at all. Marking one up and + * leaving the theme to opt out in CSS was worse: a width meant for a stated + * layout leaks into automatic layout as a preference, and a field-and-value pair + * came out with a narrower label column than the untagged tables beside it. + * + * One threshold does all the deciding. A column with a cell of at least + * PROSE_MIN_CHARS can hold prose; every other column is a column of labels, + * whatever the length of its longest label. A table qualifies when exactly one + * of its columns clears that line and at least two do not. + */ +const rehypeTableColumns: Plugin<[], Root> = () => { + return tree => { + visit(tree, 'element', (table: Element) => { + if (table.tagName !== 'table') return + + const rows: Element[] = [] + visit(table, 'element', (node: Element) => { + if (node.tagName === 'tr') rows.push(node) + }) + if (rows.length < 2) return + + const grid: Element[][] = [] + for (const row of rows) { + const cells = cellsOf(row) + // A spanning cell means the columns below it are not a straight grid, + // and every measurement here assumes one cell per column per row. + if (!cells) return + grid.push(cells) + } + + const columns = grid[0].length + if (columns < 2) return + if (grid.some(row => row.length !== columns)) return + + // The first row is the header — its text counts towards what a column has + // to fit, because a one-word heading over a column of flags is usually the + // widest thing in it, but not towards how much the column carries. + const [header, ...body] = grid + if (body.length === 0) return + + const stats: ColumnStats[] = [] + for (let column = 0; column < columns; column++) { + const texts = body.map(row => toText(row[column]).trim()) + const headerText = toText(header[column]).trim() + stats.push({ + longest: Math.max(headerText.length, ...texts.map(text => text.length)), + run: Math.max( + Math.ceil(longestRun(headerText) * HEADER_CHAR_RATIO), + ...texts.map(longestRun) + ), + }) + } + + // Exactly one column may hold prose. Two would each need a paragraph's room + // and there is only one remainder to give away, so such a table is left to + // size itself and scroll — as is one with no prose column at all, which + // automatic layout already serves. + const proseColumns = stats.filter(column => column.longest >= PROSE_MIN_CHARS) + if (proseColumns.length !== 1) return + const proseColumn = stats.indexOf(proseColumns[0]) + + // Every other column is a column of labels, and its class is the width its + // longest line needs. Nothing is marked up unless the whole table is a + // shape a theme can state widths for, so automatic layout is left + // completely alone everywhere else. + if (columns - 1 < MIN_LABEL_COLUMNS) return + const classes = stats.map((column, index) => + index === proseColumn ? null : sizeClass(column.run) + ) + + // One label column is marked to take whatever the others leave. A theme + // that folds the prose column away frees most of the table's width, and + // without somewhere to send it that room pools in whichever column the + // theme left unsized — on a three-column table that was 77% of the sheet + // standing empty beside a name wrapped over five lines. The column + // carrying the most text is the one that can use it. + let growColumn = -1 + for (let column = 0; column < columns; column++) { + if (classes[column] === null) continue + // `>=` so that a later column wins a tie, where the labels tend to run + // from short codes towards longer names. + if (growColumn === -1 || stats[column].longest >= stats[growColumn].longest) { + growColumn = column + } + } + + table.properties['data-fit'] = 'stated' + for (const row of grid) { + for (let column = 0; column < columns; column++) { + const name = classes[column] + if (name) row[column].properties['data-col'] = name + if (name && column === growColumn) row[column].properties['data-grow'] = 'true' + // The prose column gets seams too: its width is the remainder, which + // on a crowded table is narrow enough to break an identifier. + addBreakOpportunities(row[column]) + } + } + }) + } +} + +export default rehypeTableColumns diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts index 1583c7df..d9d6ff00 100644 --- a/packages/chronicle/src/server/vite-config.ts +++ b/packages/chronicle/src/server/vite-config.ts @@ -14,6 +14,7 @@ import remarkReadingTime from 'remark-reading-time'; import remarkUnusedDirectives from '../lib/remark-unused-directives'; import type { Pluggable } from 'unified'; import remarkValidateMdx from '../lib/remark-validate-mdx'; +import rehypeTableColumns from '../lib/rehype-table-columns'; import rehypeTocText from '../lib/rehype-toc-text'; // Literal names rather than `string`: Nitro types the connector as db0's @@ -222,6 +223,7 @@ export async function createViteConfig( rehypePlugins: (plugins: Pluggable[]) => [ ...plugins.filter(plugin => !isRehypeToc(plugin)), rehypeTocText, + rehypeTableColumns, ], remarkPlugins: [ remarkDirective, diff --git a/packages/chronicle/src/themes/fanfold/ExpandableRow.tsx b/packages/chronicle/src/themes/fanfold/ExpandableRow.tsx new file mode 100644 index 00000000..8ce84d9e --- /dev/null +++ b/packages/chronicle/src/themes/fanfold/ExpandableRow.tsx @@ -0,0 +1,86 @@ +'use client' + +import { type ReactNode, useEffect, useId, useRef, useState } from 'react' + +interface ExpandableRowProps { + /** The row's label cells, already rendered, in source order. */ + labels: ReactNode[] + /** Contents of the prose cell, shown only while the row is open. */ + notes: ReactNode +} + +/** + * One row of a conformance table, with its paragraph folded away. + * + * A stated-width table gives its prose column whatever the label columns leave, + * which on a seven-column table is under 200px — a measure of about 23 + * characters, and rows tall enough that the reader loses the row they are on + * partway down it. Folding the paragraph under the row gives it the width of the + * whole sheet when it is wanted and takes it out of the way when it is not, and + * the labels above keep the geometry they already had. + * + * The row stays a real row of a real table: the paragraph is a second `tr` + * spanning every column rather than a panel beside the table, so the columns go + * on lining up and a screen reader still reads the thing as a table. + */ +export function FanfoldExpandableRow({ labels, notes }: ExpandableRowProps) { + const [open, setOpen] = useState(false) + const notesId = useId() + const notesRow = useRef(null) + + // `hidden="until-found"` lets the browser open a folded row when the reader + // searches the page for something inside it. It has to be set here rather than + // in the markup below because React holds `hidden` as a boolean attribute and + // writes any value of it out as a bare `hidden`, losing the keyword. + // + // Rewriting it after every render is safe: React only touches an attribute + // when its prop changes, and `hidden={!open}` does not change while the row + // stays shut, so nothing here is undone until the row opens. + useEffect(() => { + const row = notesRow.current + if (!row || open) return + row.setAttribute('hidden', 'until-found') + }, [open]) + + // The browser drops the attribute itself when a search matches inside the row, + // so React has to be told, or its next render would hide the match again. + useEffect(() => { + const row = notesRow.current + if (!row) return + const reveal = () => setOpen(true) + row.addEventListener('beforematch', reveal) + return () => row.removeEventListener('beforematch', reveal) + }, []) + + return ( + <> + + {labels} + + + + + + {/* One more than the labels, for the column the toggle sits in. */} + +
{notes}
+ + + + ) +} diff --git a/packages/chronicle/src/themes/fanfold/Page.module.css b/packages/chronicle/src/themes/fanfold/Page.module.css index 09d9ed29..a954b55a 100644 --- a/packages/chronicle/src/themes/fanfold/Page.module.css +++ b/packages/chronicle/src/themes/fanfold/Page.module.css @@ -10,7 +10,7 @@ .sheet { flex: 1; min-width: 0; - max-width: 904px; + max-width: 768px; margin-inline: auto; } @@ -469,13 +469,203 @@ color: var(--fan-ink); } -/* The field-name column is the spine of a field map. Auto table layout shares - width by content, so a long notes column would otherwise break short names - like "Packet Version Number" across three lines. A plain length is used - because Chrome ignores a table cell's min-width once a percentage is in it, - which is why the narrow-screen value is set in the media query below. */ +/* The field-name column is the spine of a field map, so it asks for the room a + name like "Packet Version Number" needs rather than letting a long notes + column squeeze it to a word per line. + + `width` rather than `min-width`: in auto table layout a width is the column's + preference, not a floor, so the column yields it back when the row cannot + afford it. A floor could not be overruled, and a conformance table whose first + column holds an item code like "SPP-14" was handing six characters 202px and + pushing the table 370px past the sheet. The same declaration now caps the + column too — auto layout will not grow a column past a width it was given + unless the content leaves it no choice. + + The 8ch floor is there because auto layout, given the chance, shrinks the + column to its longest unbreakable run — and an item code like "SPP-14" has a + break opportunity at the hyphen, so it was collapsing to the width of "SPP-" + and setting the code on two lines. + + A plain length is used because Chrome ignores a table cell's width once a + percentage is in it, which is why the narrow-screen value is set in the media + query below. */ .article :is(th, td):first-child { - min-width: 24ch; + min-width: 8ch; + width: 24ch; +} + +/* A table with a column of paragraphs is a different animal from a field map, + and `rehype-table-columns` is what tells them apart: it measures every column + at build time and marks the table with the index of the one holding prose, + giving each other cell a size class from its own longest line. CSS cannot ask + how much text is in a column, so without that the only thing to key off was + the column count — which said nothing about the table's shape. + + Automatic layout hands the paragraph column whatever it asks for: 616px in one + conformance table, which ran the table 238px past the sheet and hid the end of + every line in it, with nothing on screen to say so. It also sizes each table + from that table's own content, so no two on a page line up. + + Stating the widths fixes both. The short columns are told what they need, the + prose column is deliberately left without a width so that it takes whatever + they do not, and `width: 100%` means the table can no longer be wider than the + sheet, so nothing is hidden. + + The plugin marks a table only when its whole shape is one that can be stated + this way — a single column of prose beside two or more of labels — so the + attribute is the entire condition here, and every other table is left to + automatic layout untouched. */ +.article table[data-fit="stated"] { + table-layout: fixed; + width: 100%; +} + +/* The size classes, in the type they are sizing. A class is the longest line the + column has to hold, so these are the widths that line needs — the flags at the + bottom must not wrap at all, while a column of names may take two lines and is + given less than its longest entry would need on one. + + The widths are px rather than ch because fixed layout reads them off the header + row, where `ch` resolves against the header's 10.5px type instead of the 14px + of the cells below — 16ch there measured 101px, not the 134px meant. + + They are also tighter than each class could use, because what the label + columns leave over is the prose column's whole measure. On a seven-column + table the first values tried here left it 156px; these leave it 230px and take + 4% off the height of the page, at the cost of a second line in some of the + longer names. Nothing is broken mid-word to get there: the clause references + still set on one line, and every label that wraps does so at a space. */ +.article table[data-fit="stated"] :is(th, td)[data-col="xl"] { + width: 202px; +} + +.article table[data-fit="stated"] :is(th, td)[data-col="l"] { + width: 160px; +} + +.article table[data-fit="stated"] :is(th, td)[data-col="m"] { + width: 126px; +} + +.article table[data-fit="stated"] :is(th, td)[data-col="s"] { + width: 102px; +} + +/* Wider than the values need, and with the padding trimmed, because the header + words are what has to fit: at the standard 16px of cell padding these columns + were breaking "STATUS" and "SUPPORT" mid-word into STAT/US and SUPP/ORT. The + values below them run to a character or three, so the padding was not earning + its keep there anyway. */ +.article table[data-fit="stated"] :is(th, td)[data-col="xs"] { + width: 64px; + padding-right: 4px; +} + +/* One label column takes whatever the others leave, marked at build time as the + one holding the most text. + + Without it the spare width pooled wherever a column had no stated width, which + here is the toggle's: on a three-column table 554px of the 720px sheet stood + empty beside a name wrapping over five lines. The class widths above are the + minimum each column needs to hold its longest word, which was the right + measure while the paragraph column was competing for the same pixels and is + simply too tight now that it is folded away. + + This rule has to follow the class widths it overrides — the two selectors have + the same specificity, so their order is what settles it. */ +.article table[data-fit="stated"] :is(th, td)[data-grow] { + width: auto; +} + +/* ---- folded notes ---- */ + +/* The paragraph column of a stated table is folded under its row and opened on + demand (`ExpandableRow.tsx`). Data attributes rather than classes because CSS + modules rewrite class names but leave attributes alone, so a component in the + theme can hand its markup over to be styled from here. + + Styling has to live in this file rather than beside the component: the rules + above are scoped `.article td`, which outranks a bare class from another + module, so a class there could not set so much as the cell's padding. */ + +/* No stated width, so this column takes whatever the labels leave and the + toggle sits against the right edge of the sheet. */ +/* Stated, so this column no longer soaks up every spare pixel. 96px holds + "\203A NOTES" at the header's tracking with room to spare, and the label + shortens to "Hide" once the row is open. */ +.article table[data-fit="stated"] :is(th, td)[data-fanfold-toggle] { + width: 96px; + text-align: right; + white-space: nowrap; +} + +.article [data-fanfold-toggle] button { + display: inline-flex; + align-items: baseline; + gap: 7px; + margin: 0; + padding: 0; + border: 0; + background: none; + font-family: var(--fan-mono); + font-size: 10.5px; + line-height: 18px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--fan-ink-3); + cursor: pointer; +} + +.article [data-fanfold-toggle] button:hover { + color: var(--fan-ink); +} + +/* A dashed ring matches the rules the rest of the sheet is ruled with. */ +.article [data-fanfold-toggle] button:focus-visible { + outline: 1px dashed var(--fan-rule-strong); + outline-offset: 3px; +} + +.article [data-fanfold-chevron] { + display: inline-block; + transition: transform 120ms ease; +} + +/* Reduced motion turns the sweep off; the rotation still happens, so the state + of the row is never carried by the animation alone. */ +@media (prefers-reduced-motion: reduce) { + .article [data-fanfold-chevron] { + transition: none; + } +} + +.article [data-fanfold-summary][data-open] [data-fanfold-chevron] { + transform: rotate(90deg); +} + +/* An open row is marked at both ends: the labels take the bar tint, and the + paragraph below carries a rule down its left the way a quote does. */ +.article [data-fanfold-summary][data-open] > :is(th, td) { + background: var(--fan-bar); +} + +.article [data-fanfold-notes] > td { + padding: 0 0 20px; + border: 0; + background: var(--fan-bar); +} + +/* 600px is the measure body copy uses, so an opened note reads at the same + line length as the prose around the table rather than the width of a sheet. */ +.article [data-fanfold-notes] > td > div { + max-width: 600px; + margin-left: var(--rs-space-5); + padding: 2px 0 0 var(--rs-space-5); + border-left: 1px dashed var(--fan-rule-strong); + font-size: 14px; + line-height: 24px; + letter-spacing: 0; + color: var(--fan-ink); } /* ---- footer band ---- */ @@ -640,7 +830,14 @@ } .article :is(th, td):first-child { - min-width: 10ch; + width: 10ch; + } + + /* Narrower than the short columns add up to, so the stated widths are dropped + and a wide table goes back to sizing itself and scrolling inside the + article, which is steadier than six columns all at their minimum. */ + .article table[data-fit="stated"] { + table-layout: auto; } .display[data-size="code"] { From 4801b85f6ceaaedb01c83e3d322701f145f4714b Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 2 Sep 2026 10:59:22 -0500 Subject: [PATCH 2/7] fix: line up the fanfold sidebar footer, and stop the repo link repeating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer mixed three sources and looked like it: one link the theme rendered itself, two that Apsara's sidebar items rendered at 16px with 8px of indent, and a theme toggle centred across the rail. The repository was also listed twice, once from `navigation.social` as "github" and once from `config.links` as "GitHub" — neither component could see it, since each is handed only its own list. - the rail now deduplicates against `config.links` as well, and yields to it: `SidebarLinks` adds UTM parameters and routes relative hrefs through the router, so its copy is the one worth keeping - Apsara's nav items are restyled down to the label. The span it nests the text in declares its own colour and a 500 weight, so styling the anchor alone left them reading darker and heavier than the links beside them, and its wrapper swallowed the footer's gap - `.railFooterToggle` moves below `.iconButton`, which was quietly beating it at equal specificity and leaving every override in it dead, and shrinks to its label instead of being stretched across the rail and centred in it --- .../src/themes/fanfold/Layout.module.css | 56 +++++++++++++++++-- .../chronicle/src/themes/fanfold/Layout.tsx | 13 ++++- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/packages/chronicle/src/themes/fanfold/Layout.module.css b/packages/chronicle/src/themes/fanfold/Layout.module.css index 87dc7968..74a13a11 100644 --- a/packages/chronicle/src/themes/fanfold/Layout.module.css +++ b/packages/chronicle/src/themes/fanfold/Layout.module.css @@ -458,21 +458,45 @@ a.navSubLabel:hover { gap: var(--rs-space-2); } -/* SidebarLinks brings its own list markup; this only has to line it up with the - rest of the footer column. */ -.railFooterLinks { +/* SidebarLinks renders Apsara's own sidebar items, which arrive at 16px with + 8px of indent — sized for a nav rail rather than for this footer, so they sat + larger than and offset from the links above them. + + The label inside each one has to be restyled as well as the anchor around it. + Apsara nests the text in its own span, and that span declares its own colour + and a 500 weight, so anything set on the anchor alone left the label reading + darker and heavier than every other link in the column. + + Restyled by descendant rather than by class: Apsara's class names are hashed, + so a theme cannot name them. The values match `.railFooterLink` below, which + is what these sit beside. */ +.railFooterLinks, +.railFooterLinks > div { display: flex; flex-direction: column; + /* Apsara's own wrapper sits between this and the links, so the footer's gap + never reached them and the two rows bunched up under the ones above. */ + gap: var(--rs-space-2); } -.railFooterToggle { - justify-content: flex-start; +.railFooterLinks a, +.railFooterLinks a * { + min-height: 0; + height: auto; + padding: 0; + border-radius: 0; + background: none; font-size: 12px; + font-weight: 400; line-height: 20px; + letter-spacing: 0; color: var(--fan-ink-3); + text-decoration: none; } -.railFooterToggle:hover { +.railFooterLinks a:hover, +.railFooterLinks a:hover * { + background: none; color: var(--fan-ink); } @@ -531,6 +555,26 @@ a.navSubLabel:hover { color: var(--fan-ink); } +/* Declared after `.iconButton` on purpose. The toggle carries both classes, and + `.iconButton` styles a real icon button — centred, and a step darker than the + links this one sits beside. At equal specificity the later rule wins, so while + this block sat above it every override here was dead: the label came out + centred in the rail and in the wrong ink. + + `align-self` also does a job `justify-content` could not. The rail is a flex + column, so the button was being stretched to its full 200px; shrinking it to + its label is what actually puts the text under the links. */ +.railFooterToggle { + align-self: flex-start; + font-size: 12px; + line-height: 20px; + color: var(--fan-ink-3); +} + +.railFooterToggle:hover { + color: var(--fan-ink); +} + .mobileMenu { display: none; } diff --git a/packages/chronicle/src/themes/fanfold/Layout.tsx b/packages/chronicle/src/themes/fanfold/Layout.tsx index 02fac2c4..efe3e1d9 100644 --- a/packages/chronicle/src/themes/fanfold/Layout.tsx +++ b/packages/chronicle/src/themes/fanfold/Layout.tsx @@ -44,6 +44,14 @@ const WEB_FONTS = * `navigation.social` often points at the same repository as a * `navigation.links` entry, so the list is deduplicated by destination — the * first spelling of a URL wins. + * + * `config.links` is deduplicated against as well, even though it is rendered + * elsewhere. A site that names its repository under both keys — a `social` + * entry for the icon and a footer link for the label — was getting it twice in + * one column, once as "github" and once as "GitHub". Neither component could + * see that on its own, since each is handed only its own list. The rail is the + * one that yields: `SidebarLinks` adds UTM parameters and routes relative hrefs + * through the router, so its copy is the one worth keeping. */ function useRailLinks() { const { config } = usePageContext(); @@ -54,9 +62,10 @@ function useRailLinks() { href: s.href })) ]; - const seen = new Set(); + const canonical = (href: string) => href.replace(/\/+$/, ''); + const seen = new Set((config.links ?? []).map(link => canonical(link.href))); return all.filter(link => { - const key = link.href.replace(/\/+$/, ''); + const key = canonical(link.href); if (seen.has(key)) return false; seen.add(key); return true; From ceb0fc846df1cef217b8eb0704d4064ad8b97dea Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 2 Sep 2026 11:42:21 -0500 Subject: [PATCH 3/7] fix: hold everything on the fanfold sheet to one 80-column measure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prose was capped at 600px while the tables, the code blocks, the star rule, the meta lines and the title all ran to 720px, so every paragraph stopped 120px short of the rule above it and the paper looked wider than the report on it. A line printer had no way to set a paragraph narrower than a table — the carriage was the carriage — so a theme drawn as printer output wants one width for everything on the page. The paper is narrowed to that width rather than the prose widened to the paper's: 672px of content between the gutters, which is exactly 80 characters of body type and the carriage this theme is an impression of. Both alternatives were measured first. Holding prose to 600px and letting the tables and code blocks run wide left the mismatch above. Widening the prose to the old 720px instead put 86 characters on a line, well past what reads. 80 sits at the edge of a comfortable measure, which monospace on 24px leading carries. Every cap now reads from one custom property, so the measure is two numbers in one place instead of six scattered lengths. - one right edge for prose, tables, code, headings, rules and furniture - 0 tables reach past the sheet, and no label breaks mid-word: the narrowest paragraph column in a stated table is 156px, wider than its own longest word - code blocks needing a sideways scroll go from 4% to 7% of 620, the cost of taking 48px off the sheet --- .../src/themes/fanfold/Page.module.css | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/packages/chronicle/src/themes/fanfold/Page.module.css b/packages/chronicle/src/themes/fanfold/Page.module.css index a954b55a..eb275b91 100644 --- a/packages/chronicle/src/themes/fanfold/Page.module.css +++ b/packages/chronicle/src/themes/fanfold/Page.module.css @@ -3,15 +3,33 @@ align-items: flex-start; } -/* The rails are pinned to the window edges, so the column of type is capped and +/* 720px is 672px of content between the two 24px gutters, and 672px is exactly + 80 characters of body type — a monospace advance is a flat 0.6em, so 80 x 14 x + 0.6. Eighty columns is the carriage this whole theme is an impression of, and + the paper being that wide is what lets one measure serve everything on it. + + The rails are pinned to the window edges, so the column of type is capped and centred in what is left between them. Auto inline margins on a flex item soak up the leftover space, which keeps the type centred on screen because the two rails are the same width. */ .sheet { flex: 1; min-width: 0; - max-width: 768px; + max-width: 720px; margin-inline: auto; + + /* Every run of prose, every table and every code block is held to this, so + they share one right edge with the star rule, the meta lines and the title + above them. A line printer had no way to set a paragraph narrower than a + table, and a theme drawn as printer output reads wrong when it does: the + paper looks wider than the report on it. + + `100%` is the width of whichever band the content sits in, which is the + 672px above. Both the alternatives were tried: holding prose to 600px left + the furniture running 120px past every paragraph, and widening the paper + instead of narrowing it put 86 characters on a line. Change the two numbers + together — this and the cap above — and everything follows. */ + --fan-measure: 100%; } /* ---- header band: the strip a printer lays down before the report ---- */ @@ -75,7 +93,7 @@ /* The lede stays a step above body copy. */ .subtitle { margin: 18px 0 0; - max-width: 620px; + max-width: var(--fan-measure); font-size: 15px; line-height: 26px; color: var(--fan-ink-2); @@ -116,13 +134,11 @@ letter-spacing: 0; } -/* 600px is 71 characters at 14px, since a monospace advance is a flat 0.6em. - Holding the old 680px would have run the line to 81. */ .article p, .article ul, .article ol, .article dl { - max-width: 600px; + max-width: var(--fan-measure); margin: 0 0 var(--rs-space-6); } @@ -211,7 +227,7 @@ .article blockquote { margin: 0 0 var(--rs-space-6); - max-width: 600px; + max-width: var(--fan-measure); padding-left: var(--rs-space-6); border-left: 1px solid var(--fan-rule-strong); color: var(--fan-ink-2); @@ -229,7 +245,7 @@ inner elements share the same class prefix, hence the descendant reset. */ .article div[aria-live] { - max-width: 600px; + max-width: var(--fan-measure); margin: 0 0 var(--rs-space-6); padding: var(--rs-space-4) 0 var(--rs-space-4) 18px; border: 0; @@ -333,6 +349,7 @@ .article pre { margin: 0; + max-width: var(--fan-measure); padding: var(--rs-space-4) 0; background: none; border: 0; @@ -402,7 +419,7 @@ .article table { width: 100%; - max-width: 100%; + max-width: var(--fan-measure); margin: 26px 0; border-collapse: collapse; background: none; @@ -590,9 +607,12 @@ /* No stated width, so this column takes whatever the labels leave and the toggle sits against the right edge of the sheet. */ -/* Stated, so this column no longer soaks up every spare pixel. 96px holds - "\203A NOTES" at the header's tracking with room to spare, and the label - shortens to "Hide" once the row is open. */ +/* Stated, so this column no longer soaks up every spare pixel. What it does take + comes off the paragraph column, the one column of a stated table with nothing + to spare, so it is only as wide as the word beside the chevron needs. 96px is + affordable at 672px of paper: it leaves the narrowest paragraph column in the + corpus 156px, still wider than that column's own longest word. On a narrower + sheet the word would have to go and the chevron carry the column alone. */ .article table[data-fit="stated"] :is(th, td)[data-fanfold-toggle] { width: 96px; text-align: right; @@ -655,10 +675,10 @@ background: var(--fan-bar); } -/* 600px is the measure body copy uses, so an opened note reads at the same - line length as the prose around the table rather than the width of a sheet. */ +/* The same measure body copy uses, so an opened note reads at the line length of + the prose around the table. */ .article [data-fanfold-notes] > td > div { - max-width: 600px; + max-width: var(--fan-measure); margin-left: var(--rs-space-5); padding: 2px 0 0 var(--rs-space-5); border-left: 1px dashed var(--fan-rule-strong); From d6b7b6cfb84416c1470b2e439aa02578a7fe1539 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 2 Sep 2026 12:10:25 -0500 Subject: [PATCH 4/7] feat: leave the line numbers off fanfold's ASCII diagrams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fence with no language is a drawing, not a listing — a diagram, a tree, a captured terminal — and nobody cites a line of one by number. The numbers also indented every such block by 3ch plus a gutter, which threw the drawing's own alignment out and made it read as source. Nothing in the rendered markup said what a block was, so `addLanguageClass` is turned on: it puts `language-` on the code element, and the theme keys off that. `plaintext` is a narrower signal than it looks. fumadocs highlights a bare fence with its `defaultLanguage`, which is `plaintext`, while a fence naming a language nothing can highlight falls back to `text`. So an author who wrote nothing gets a drawing, and one who wrote ```logql keeps a numbered listing. In the docs this was written against that is 213 bare fences losing their numbers and 407 named ones keeping them. The counter still counts and only the digits are hidden, so nothing downstream needs to know. --- packages/chronicle/src/server/vite-config.ts | 6 ++++++ .../chronicle/src/themes/fanfold/Page.module.css | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts index d9d6ff00..5da531c7 100644 --- a/packages/chronicle/src/server/vite-config.ts +++ b/packages/chronicle/src/server/vite-config.ts @@ -215,6 +215,12 @@ export async function createViteConfig( rehypeCodeOptions: { ...rehypeCodeDefaultOptions, fallbackLanguage: 'text', + // Puts `language-` on the `code` element, which is the only + // thing in the rendered markup that says what a block is. A theme + // needs it to tell a listing from a drawing: a fence with no + // language highlights as `plaintext`, and fanfold hangs line + // numbers off everything except that. + addLanguageClass: true, }, // Swap fumadocs' rehypeToc for a text-only toc: it exports heading // content as JSX evaluated at module scope, so any component in a diff --git a/packages/chronicle/src/themes/fanfold/Page.module.css b/packages/chronicle/src/themes/fanfold/Page.module.css index eb275b91..68573a49 100644 --- a/packages/chronicle/src/themes/fanfold/Page.module.css +++ b/packages/chronicle/src/themes/fanfold/Page.module.css @@ -388,6 +388,21 @@ min-height: 22px; } +/* A fence with no language is a drawing, not a listing — an ASCII diagram, a + tree, a captured terminal — and nobody cites a line of it by number. The + numbers also push the whole thing in by 3ch plus a gutter, which throws a + diagram's own alignment out and makes it read as source. + + `plaintext` is the signal, and it is a narrower one than it looks: fumadocs + highlights a bare fence with its `defaultLanguage`, which is `plaintext`, + while a fence naming a language nothing can highlight falls back to `text`. + So an author who wrote nothing gets no numbers, and one who wrote + ```logql keeps them. The counter still counts; only the digits are dropped, + so nothing downstream has to know about this. */ +.article pre code:global(.language-plaintext) :global(.line)::before { + display: none; +} + /* Right-aligned so the digits stay in a column once a listing passes line 99. */ .article pre code :global(.line)::before { content: counter(fan-line, decimal-leading-zero); From 1cdf30b3bb52f7d934adc3b929ea55808ffa6221 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 2 Sep 2026 12:10:25 -0500 Subject: [PATCH 5/7] chore: drop the completed TODO list Every item on it was ticked off, and it had not been touched since #110. --- TODO.md | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 25fc9738..00000000 --- a/TODO.md +++ /dev/null @@ -1,8 +0,0 @@ -# TODO - -## Default theme - -- [x] Replace sidebar logo placeholder (`CubeIcon`) with configurable logo from `config.logo` (light/dark) -- [x] Add optional `icon` field per content dir and API in `chronicle.yaml` (accepts URL or inline SVG string) -- [x] "Open in AI" dropdown (Copy/View MD, Open in ChatGPT/Claude) in subNav -- [x] Sidebar nesting supports 2 levels; 3rd+ level items are ignored From 43a2b0a8f28145bae7620334b9690912f14734c9 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 2 Sep 2026 14:38:56 -0500 Subject: [PATCH 6/7] fix: tighten the fanfold paper and finish its foot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bits of outer geometry that had fallen behind the sheet inside them. The page was capped at 1600px, which its own comment worked out as two feed strips, two rails, a 904px column of type and a 64px gutter either side. The sheet is 720px now, so the same sum is 1416px — and at 1600 the gutters had opened to 155px, which is the comment's own warning about the rails drifting away from what they describe. The gap above the paper is now matched below it, so scrolling to the end of a long page reaches the foot of the sheet rather than a cut. Everything that subtracts that gap subtracts it twice, or the two margins push a short page past the viewport. The feed strips are stretched to the sheet instead of held a viewport tall and stuck. Sticky at `height: 100vh` covered the window while scrolling but could not know where the paper ended: on a page shorter than the window the strip ran past the foot of the sheet and raised a scrollbar over nothing, and at the end of a long page it carried the punch holes down into the gap below. The negative margin that was there to hide the first of those never actually did — a short page overflowed by the height of the gap either way. Stretching fixes both by construction and the hack goes with it. --- .../src/themes/fanfold/Layout.module.css | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/packages/chronicle/src/themes/fanfold/Layout.module.css b/packages/chronicle/src/themes/fanfold/Layout.module.css index 74a13a11..384c377e 100644 --- a/packages/chronicle/src/themes/fanfold/Layout.module.css +++ b/packages/chronicle/src/themes/fanfold/Layout.module.css @@ -42,15 +42,25 @@ --fan-display: "Doto", "Geist Mono", ui-monospace, monospace; --fan-head: "Departure Mono", "Geist Mono", ui-monospace, monospace; - /* 1600 = two 44px feed strips, two 240px rails, a 904px column of type and a - 64px gutter either side of it. Past that the paper stops growing, so the - rails never drift away from what they describe. */ - max-width: 1600px; - /* The gap sits above the paper, not inside it, so the sheet — bands, feed - strips and all — starts a little down the window and reads as a page laid - on a surface rather than one running off the top of the screen. */ - margin: var(--fan-page-top) auto 0; - min-height: calc(100vh - var(--fan-page-top)); + /* 1416 = two 44px feed strips, two 240px rails, the 720px sheet and a 64px + gutter either side of it. Past that the paper stops growing, so the rails + never drift away from what they describe. + + Keep this in step with the sheet's own cap in `Page.module.css`: the sheet + is centred in what the rails leave, so a figure too large here does not + widen anything, it just parks the rails further from the type — at 1600, + which is what this was while the sheet was 904px, the gutters came out at + 155px. */ + max-width: 1416px; + /* The gap sits around the paper, not inside it, so the sheet — bands, feed + strips and all — reads as a page laid on a surface rather than one running + off the edge of the screen. The same gap below as above, so scrolling to + the end of a long page reaches the foot of the paper rather than a cut. + + Everything that subtracts this has to subtract it twice, or the two margins + push a short page past the viewport and raise a scrollbar over nothing. */ + margin: var(--fan-page-top) auto; + min-height: calc(100vh - var(--fan-page-top) * 2); color: var(--fan-ink); font-family: var(--fan-mono); -webkit-font-smoothing: antialiased; @@ -138,11 +148,20 @@ centred, and a `background-attachment: fixed` gradient tiles from the window edge, not the paper's, so the perforations would fall outside the sheet. Sticky keeps them still while they stay in flow beside it. */ +/* Stretched to the paper rather than held a viewport tall and stuck. Sticky at + `height: 100vh` covered the window while scrolling but could not know where + the paper ended: on a page shorter than the window the strip's own box ran + past the foot of the sheet and put a scrollbar over nothing, and at the end of + a long page it carried the feed holes down into the gap below the paper. A + `margin-bottom` of minus one page gap used to hide the first of those, and it + never quite did. + + Stretching solves both by construction. The strip is exactly as tall as the + sheet beside it, so the holes run the full length of the paper and stop with + it, and a strip that always matches its container has nothing to stick to. */ .strip { - position: sticky; - top: 0; + align-self: stretch; width: var(--fan-strip); - height: 100vh; flex-shrink: 0; background-image: radial-gradient( circle at 22px 20px, @@ -151,10 +170,6 @@ ); background-size: var(--fan-strip) 40px; background-repeat: repeat-y; - /* The strip is a viewport tall so it still fills the window once stuck. The - negative margin stops that height from adding the page's top margin back on - as overflow at the bottom of a short page. */ - margin-bottom: calc(var(--fan-page-top) * -1); } /* The zebra sits between the two feed strips and scrolls with the page. */ @@ -171,7 +186,7 @@ transparent var(--fan-bar-height), transparent calc(var(--fan-bar-height) * 2) ); - min-height: calc(100vh - var(--fan-page-top)); + min-height: calc(100vh - var(--fan-page-top) * 2); } .rail { From cfee489e545871ec11f18f2f0101622f0c2dc551 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 2 Sep 2026 14:39:10 -0500 Subject: [PATCH 7/7] feat: let a page state its own identifiers, and prefer short labels in the trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fanfold header was designed to carry what a page is: the standard it implements, the package, the command. The theme cannot know any of that, so it was filling the two lines under the trail with the only facts it had — the site title, the section, and the URL. A page can now name them itself. `identifiers` is a list of lines printed verbatim under the trail, and a page without it keeps the lines the theme can work out on its own, so nothing else changes. Frontmatter reaches a theme through one allowlist in `extractFrontmatter`, so that is the only place a new field can enter — and the only edit needed here beyond the theme. Non-string entries are dropped rather than rendered: this comes straight from a content file, where one mistyped entry should not arrive as `[object Object]`. Named `identifiers` rather than `meta` because `meta.json` already means a directory's metadata and the two would read as the same thing. The trail now prefers a `short` where the tree carries one, so it reads "… / SPP" rather than "… / Space Packet Protocol". `getBreadcrumbItems` is fumadocs' own and returns names only, so the lookup is built here from the `short` that `attachShortNames` puts on every page node and each folder index. Kept local to this theme: the other two render their breadcrumbs from the shared helper and never use `short`. --- packages/chronicle/src/lib/source.ts | 13 ++++ .../chronicle/src/themes/fanfold/Page.tsx | 70 +++++++++++++++++-- packages/chronicle/src/types/content.ts | 9 +++ 3 files changed, 85 insertions(+), 7 deletions(-) diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index 17cf761e..c377bd9a 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -320,6 +320,18 @@ export async function getPageNav(slug: string[]): Promise { return navMap.get(url) ?? { prev: null, next: null }; } +/** + * A frontmatter list of plain strings, or undefined. Anything that is not a + * string is dropped rather than rendered: this comes straight from a content + * file, where a single mistyped entry should not reach a theme as `[object + * Object]`. + */ +function normalizeStringList(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + const items = value.filter((item): item is string => typeof item === 'string' && item.trim() !== '') + return items.length > 0 ? items : undefined +} + export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: string): Frontmatter { const d = page.data as Record; return { @@ -328,6 +340,7 @@ export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: stri order: d.order as number | undefined, icon: d.icon as string | undefined, lastModified: d.lastModified as string | undefined, + identifiers: normalizeStringList(d.identifiers), authors: normalizeAuthorList(d.authors), draft: d.draft as boolean | undefined, _readingTime: d._readingTime as number | undefined, diff --git a/packages/chronicle/src/themes/fanfold/Page.tsx b/packages/chronicle/src/themes/fanfold/Page.tsx index c69ac1f0..9ea0535a 100644 --- a/packages/chronicle/src/themes/fanfold/Page.tsx +++ b/packages/chronicle/src/themes/fanfold/Page.tsx @@ -2,11 +2,13 @@ import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb'; import { flattenTree } from 'fumadocs-core/page-tree'; +import type { Node } from 'fumadocs-core/page-tree'; import { useMemo } from 'react'; import { Link as RouterLink, useLocation } from 'react-router'; import { AuthorByline } from '@/components/common/author-byline'; import { getActiveContentDir } from '@/lib/navigation'; import { usePageContext } from '@/lib/page-context'; +import { NodeType, shortName } from '@/lib/tree-utils'; import { filterPageTreeByContentDir, filterPageTreeByVersion @@ -21,6 +23,36 @@ export const STARS = '*'.repeat(400); const pad = (n: number) => String(n).padStart(2, '0'); +/** + * Every `short` in a tree, by the URL that carries it. + * + * The printed trail wants the codes a reader already uses — "TRANSPORT / SPP" — + * where `getBreadcrumbItems` hands back full names, because it is fumadocs' own + * and knows nothing about `short`. The tree does: `attachShortNames` puts one on + * every page node and on each folder's index. Collecting them here rather than + * teaching the shared breadcrumb helper keeps the other two themes, which render + * their breadcrumbs from it and never use `short`, exactly as they were. + */ +function collectShortNames(nodes: Node[], into = new Map()) { + for (const node of nodes) { + if (node.type === NodeType.Folder) { + // A folder's own name comes from `meta.json`; its short, if any, is on the + // index page the crumb actually links to. + if (node.index) { + const short = shortName(node.index); + if (short) into.set(node.index.url, short); + } + collectShortNames(node.children, into); + continue; + } + if (node.type !== NodeType.Page) continue; + const short = shortName(node); + if (short) into.set(node.url, short); + } + return into; +} + + /** * A title this short is a code or a command — `SPP`, `XTCE`, `astro spp` — and * gets the masthead. Tune this and nothing else: it is the only place the @@ -64,12 +96,17 @@ export function Page({ page, config, tree }: ThemePageProps) { return holdsThisPage ? scoped : versioned; }, [tree, version, config, contentDir, pathname]); + const shorts = useMemo( + () => collectShortNames(sectionTree.children), + [sectionTree] + ); + const crumbs = useMemo( () => getBreadcrumbItems(pathname, sectionTree, { includePage: true }).map( - item => item.name + item => (item.url ? shorts.get(item.url) : undefined) ?? item.name ), - [pathname, sectionTree] + [pathname, sectionTree, shorts] ); // "PAGE 03 / 22" — where this page falls in the section being read. @@ -83,6 +120,15 @@ export function Page({ page, config, tree }: ThemePageProps) { const title = page.frontmatter.title ?? ''; const trail = [section, ...crumbs].filter(Boolean).join(' / '); + + /** + * The lines under the trail. A page that states its own identifiers — the + * standard it implements, the package, the command — has more to print here + * than the theme can work out for itself, so those win. Without them the + * header still fills: what site this is, what section, and where the page + * sits, which is all a theme can know on its own. + */ + const identifiers = page.frontmatter.identifiers ?? []; const counter = index > 0 ? `PAGE ${pad(index)} / ${pad(total)}` : null; return ( @@ -96,11 +142,21 @@ export function Page({ page, config, tree }: ThemePageProps) { ** {trail} {counter ? ` * ${counter}` : ''} -
- ** {config.site.title} - {section ? ` * ${section}` : ''} -
-
** {pathname}
+ {identifiers.length ? ( + identifiers.map(line => ( +
+ ** {line} +
+ )) + ) : ( + <> +
+ ** {config.site.title} + {section ? ` * ${section}` : ''} +
+
** {pathname}
+ + )}
diff --git a/packages/chronicle/src/types/content.ts b/packages/chronicle/src/types/content.ts index 621b2956..6b910f3e 100644 --- a/packages/chronicle/src/types/content.ts +++ b/packages/chronicle/src/types/content.ts @@ -27,6 +27,15 @@ export interface Frontmatter { */ short?: string description?: string + /** + * Extra identifying lines for this page — a standard's number, a package + * path, a command. Printed verbatim by a theme with somewhere to put them, + * and ignored by the ones without, so nothing here is required. + * + * Named `identifiers` rather than `meta` because `meta.json` already means a + * directory's metadata, and the two would read as the same thing. + */ + identifiers?: string[] order?: number icon?: string lastModified?: string