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
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/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/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts
index 1583c7df..5da531c7 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
@@ -214,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
@@ -222,6 +229,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/Layout.module.css b/packages/chronicle/src/themes/fanfold/Layout.module.css
index 87dc7968..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 {
@@ -458,21 +473,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 +570,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;
diff --git a/packages/chronicle/src/themes/fanfold/Page.module.css b/packages/chronicle/src/themes/fanfold/Page.module.css
index 09d9ed29..68573a49 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: 904px;
+ 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;
@@ -371,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);
@@ -402,7 +434,7 @@
.article table {
width: 100%;
- max-width: 100%;
+ max-width: var(--fan-measure);
margin: 26px 0;
border-collapse: collapse;
background: none;
@@ -469,13 +501,206 @@
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. 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;
+ 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);
+}
+
+/* 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: 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);
+ font-size: 14px;
+ line-height: 24px;
+ letter-spacing: 0;
+ color: var(--fan-ink);
}
/* ---- footer band ---- */
@@ -640,7 +865,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"] {
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}` : ''}
-
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