mosage 0.1.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -0
- package/README.md +16 -217
- package/bin.js +2 -0
- package/dist/build-C7NW_3Pk.js +14 -0
- package/dist/check-CP4873Wx.js +41 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +228 -0
- package/dist/config-DPm1BBAb.js +2619 -0
- package/dist/config-TlTe7Ona.d.ts +24 -0
- package/dist/context-BqsdSrAQ.js +1084 -0
- package/dist/dev-Biz42qlu.js +17 -0
- package/dist/diagram-xlVDekYk.js +763 -0
- package/dist/export-Bi6nuxjT.js +31 -0
- package/dist/import-D2jNB07F.js +25 -0
- package/dist/index.d.ts +455 -0
- package/dist/index.js +693 -0
- package/dist/init-Bbtj2pxF.js +262 -0
- package/dist/preview-CLm51aRt.js +19 -0
- package/dist/sdk-DjpX6mCv.js +51 -0
- package/dist/vite/index.d.ts +25 -0
- package/dist/vite/index.js +2 -0
- package/env.d.ts +83 -0
- package/package.json +84 -64
- package/skills/apply-comments/SKILL.md +43 -56
- package/skills/create-doc/SKILL.md +106 -0
- package/skills/create-theme/SKILL.md +184 -0
- package/skills/current-doc/SKILL.md +120 -0
- package/skills/doc-authoring/SKILL.md +434 -0
- package/skills/doc-authoring/references/assets.md +47 -0
- package/skills/doc-authoring/references/design-system.md +81 -0
- package/skills/doc-authoring/references/long-form.md +131 -0
- package/skills/doc-authoring/references/pagination.md +118 -0
- package/skills/doc-authoring/references/tables-and-charts.md +161 -0
- package/src/app/app.tsx +42 -0
- package/src/app/components/data-table.tsx +196 -0
- package/src/app/components/design-panel/design-panel.tsx +318 -0
- package/src/app/components/design-panel/design-provider.tsx +121 -0
- package/src/app/components/design-panel/use-design.ts +85 -0
- package/src/app/components/diagram.tsx +76 -0
- package/src/app/components/doc-assets.tsx +129 -0
- package/src/app/components/doc-search.tsx +248 -0
- package/src/app/components/doc-sidebar.tsx +162 -0
- package/src/app/components/flow-page.tsx +93 -0
- package/src/app/components/footnote.tsx +204 -0
- package/src/app/components/image-placeholder.tsx +50 -0
- package/src/app/components/inspector/inspector.tsx +518 -0
- package/src/app/components/numbering.tsx +224 -0
- package/src/app/components/page-frame.tsx +70 -0
- package/src/app/components/sidebar/folder-item.tsx +212 -0
- package/src/app/components/sidebar/icon-picker.tsx +99 -0
- package/src/app/components/sidebar/sidebar.tsx +252 -0
- package/src/app/components/table-of-contents.tsx +93 -0
- package/src/app/components/theme-toggle.tsx +50 -0
- package/src/app/components/themes/markdown.tsx +249 -0
- package/src/app/components/themes/theme-preview.tsx +74 -0
- package/src/app/components/ui/menu.tsx +143 -0
- package/src/app/index.html +12 -0
- package/src/app/lib/agent-bridge.ts +140 -0
- package/src/app/lib/assets.ts +151 -0
- package/src/app/lib/design-presets.ts +109 -0
- package/src/app/lib/design.ts +88 -0
- package/src/app/lib/diagnostics.ts +282 -0
- package/src/app/lib/doc-preview.tsx +29 -0
- package/src/app/lib/docs.ts +26 -0
- package/src/app/lib/docx/extract.ts +1623 -0
- package/src/app/lib/docx/fonts.test.ts +136 -0
- package/src/app/lib/docx/fonts.ts +166 -0
- package/src/app/lib/docx/media.ts +102 -0
- package/src/app/lib/docx/model.ts +206 -0
- package/src/app/lib/docx/paragraph.test.ts +92 -0
- package/src/app/lib/docx/paragraph.ts +107 -0
- package/src/app/lib/docx/props.ts +187 -0
- package/src/app/lib/docx/styles.ts +306 -0
- package/src/app/lib/docx/units.ts +35 -0
- package/src/app/lib/docx/write.test.ts +507 -0
- package/src/app/lib/docx/write.ts +581 -0
- package/src/app/lib/docx/xml.ts +39 -0
- package/src/app/lib/export-docx.ts +289 -0
- package/src/app/lib/export-dom.ts +318 -0
- package/src/app/lib/export-html.ts +156 -0
- package/src/app/lib/export-image.ts +70 -0
- package/src/app/lib/export-pdf.ts +165 -0
- package/src/app/lib/flow-measure.test.ts +31 -0
- package/src/app/lib/flow-measure.ts +183 -0
- package/src/app/lib/flow.test.ts +110 -0
- package/src/app/lib/flow.ts +136 -0
- package/src/app/lib/folders.ts +192 -0
- package/src/app/lib/footnotes.test.tsx +102 -0
- package/src/app/lib/footnotes.ts +94 -0
- package/src/app/lib/inspector/fiber.ts +99 -0
- package/src/app/lib/labels.test.ts +18 -0
- package/src/app/lib/labels.ts +181 -0
- package/src/app/lib/outline.ts +118 -0
- package/src/app/lib/page-context.tsx +43 -0
- package/src/app/lib/page-range.test.ts +95 -0
- package/src/app/lib/page-range.ts +90 -0
- package/src/app/lib/print-ready.ts +69 -0
- package/src/app/lib/rasterize.ts +173 -0
- package/src/app/lib/scan.ts +26 -0
- package/src/app/lib/sdk.test.ts +32 -0
- package/src/app/lib/sdk.ts +115 -0
- package/src/app/lib/themes.ts +31 -0
- package/src/app/lib/use-doc-module.ts +53 -0
- package/src/app/lib/use-doc-pages.ts +147 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/lib/view-mode.test.ts +91 -0
- package/src/app/lib/view-mode.ts +104 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +257 -0
- package/src/app/routes/doc.tsx +877 -0
- package/src/app/routes/home-shell.tsx +203 -0
- package/src/app/routes/home.tsx +269 -0
- package/src/app/routes/themes.tsx +121 -0
- package/src/app/styles.css +97 -0
- package/src/app/virtual.d.ts +30 -0
- package/template/AGENTS.md +27 -0
- package/template/README.md +39 -0
- package/template/docs/getting-started/index.tsx +230 -0
- package/template/mosage.config.ts +5 -0
- package/template/package.json +24 -0
- package/template/tsconfig.json +17 -0
- package/README.en.md +0 -57
- package/dist/cli.js +0 -4545
- package/dist/web/assets/index-Czg2WeHe.js +0 -182
- package/dist/web/assets/index-DKAyt92W.css +0 -1
- package/dist/web/index.html +0 -15
- package/skills/current-position/SKILL.md +0 -65
- package/skills/kickoff/SKILL.md +0 -105
- package/skills/mosage-reference/SKILL.md +0 -164
- package/skills/outline/SKILL.md +0 -79
- package/skills/review/SKILL.md +0 -64
- package/skills/write-chapter/SKILL.md +0 -58
- package/template/book/STYLE.md +0 -6
- package/template/book/assets/.gitkeep +0 -0
- package/template/book/book.yaml +0 -42
- package/template/book/brief.md +0 -6
- package/template/book/chapters/.gitkeep +0 -0
- package/template/book/notes/README.md +0 -7
- package/template/project/AGENTS.md +0 -82
- package/template/project/CLAUDE.md +0 -1
- package/template/project/README.md +0 -48
- package/template/project/books/.gitkeep +0 -0
- package/template/project/gitignore +0 -5
- package/template/project/mosage.yaml +0 -23
- package/template/project/notes/README.md +0 -8
- package/template/project/package.json +0 -14
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Footnotes, numbering, cross-references, and data
|
|
2
|
+
|
|
3
|
+
Everything here shares one mechanism: the framework **scans the rendered pages**
|
|
4
|
+
and fills in what only the finished layout knows. That is why none of these
|
|
5
|
+
numbers are written by hand, and why they are all correct after a page is
|
|
6
|
+
inserted in the middle of the document.
|
|
7
|
+
|
|
8
|
+
The trade is the same one `<TableOfContents>` makes: values are empty on the
|
|
9
|
+
first render pass and filled on the next. Both exporters scan their own copy
|
|
10
|
+
before serializing, so a PDF never contains a blank number.
|
|
11
|
+
|
|
12
|
+
## Footnotes
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
import { Footnote } from 'mosage';
|
|
16
|
+
|
|
17
|
+
<p style={p}>
|
|
18
|
+
Spend grew 8% quarter over quarter
|
|
19
|
+
<Footnote>Billing export, 2026-10-02. Excludes the edge tier.</Footnote>, driven
|
|
20
|
+
almost entirely by one service.
|
|
21
|
+
</p>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- The marker is numbered by position in the document — across fixed pages and
|
|
25
|
+
flow sections alike. Insert a note anywhere and everything after it renumbers.
|
|
26
|
+
- Inside a `flow()` section the note prints at the **foot of whatever page its
|
|
27
|
+
marker landed on**, and the space it takes is subtracted from that page's
|
|
28
|
+
budget before the packer decides where to break. You do not reserve anything.
|
|
29
|
+
- Give a note an `id` when something needs to point at it: `<Footnote id="src-1">`.
|
|
30
|
+
|
|
31
|
+
**On a fixed page, add `<Footnotes />` where the notes should print.** A fixed
|
|
32
|
+
page is your layout, so the framework does not inject anything into it:
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
const Cover: DocPage = () => (
|
|
36
|
+
<div style={page}>
|
|
37
|
+
<p>Q3 2026<Footnote>Covers the platform tier only.</Footnote></p>
|
|
38
|
+
<Footnotes />
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Put `<Footnote>` in the JSX you hand to `flow()`.** The notes are lifted out of
|
|
44
|
+
the blocks before measurement, by walking the element tree you wrote. One hidden
|
|
45
|
+
inside a helper component's own body is invisible to that walk and will render
|
|
46
|
+
inline instead of at the foot of the page.
|
|
47
|
+
|
|
48
|
+
## Figures and tables
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
import { Figure } from 'mosage';
|
|
52
|
+
|
|
53
|
+
<Figure id="topology" caption="Service topology, Q3 2026">
|
|
54
|
+
<img src={diagram} alt="Service topology" style={{ width: '100%', display: 'block' }} />
|
|
55
|
+
</Figure>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- `kind="table"` numbers it with the tables instead, and captions above rather
|
|
59
|
+
than below.
|
|
60
|
+
- Content and caption are **one block**, so a flow section never separates them.
|
|
61
|
+
- `caption` may carry markup; pass `captionText` as well when it does, so the
|
|
62
|
+
list of figures has plain text to print.
|
|
63
|
+
|
|
64
|
+
`<ListOfFigures />` and `<ListOfTables />` build the lists, exactly like
|
|
65
|
+
`<TableOfContents />` builds the contents.
|
|
66
|
+
|
|
67
|
+
## Cross-references
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import { Ref } from 'mosage';
|
|
71
|
+
|
|
72
|
+
<p style={p}>The shape in <Ref to="topology" /> is what the table hides.</p>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Renders `Figure 3`, and appends the page — `Figure 3 (p. 12)` — only when the
|
|
76
|
+
target sits on another sheet. Force it either way with `showPage`.
|
|
77
|
+
|
|
78
|
+
**Never write "see Figure 3 on page 12" by hand.** Both numbers move.
|
|
79
|
+
|
|
80
|
+
A reference to an id nothing declares renders `[?the-id]` on the page and is
|
|
81
|
+
reported by `mosage check` as an error. That is deliberate: a silent blank is
|
|
82
|
+
worse than a visible hole.
|
|
83
|
+
|
|
84
|
+
## What things are called
|
|
85
|
+
|
|
86
|
+
Numbering is structural; the words around it are the document's own business.
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
export const meta: DocMeta = {
|
|
90
|
+
title: '平台可靠度回顧',
|
|
91
|
+
labels: { figure: '圖', table: '表', onPage: '(第 {page} 頁)' },
|
|
92
|
+
};
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`footnotes` adds a heading above a page's notes; leave it empty for none.
|
|
96
|
+
|
|
97
|
+
## Data
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
import { DataTable } from 'mosage';
|
|
101
|
+
import services from './data/services.csv';
|
|
102
|
+
|
|
103
|
+
<DataTable
|
|
104
|
+
id="service-table"
|
|
105
|
+
caption="Platform tier, Q3 2026"
|
|
106
|
+
rows={services}
|
|
107
|
+
columns={[
|
|
108
|
+
{ key: 'service', label: 'Service' },
|
|
109
|
+
{ key: 'requests', label: 'Requests', format: 'integer' },
|
|
110
|
+
{ key: 'error_rate', label: 'Errors', format: 'percent' },
|
|
111
|
+
]}
|
|
112
|
+
/>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- `.csv` and `.tsv` under the document resolve to an array of objects **at build
|
|
116
|
+
time**. Quoted fields, embedded commas and newlines, and CRLF all work; a
|
|
117
|
+
numeric cell becomes a number, an empty cell becomes `null`.
|
|
118
|
+
- Data is never fetched at render time, and must not be: the packer measures the
|
|
119
|
+
real DOM to decide where pages break, so anything that arrives a tick later
|
|
120
|
+
arrives after the layout is decided.
|
|
121
|
+
- Columns default to the keys of the first row. A column whose values are all
|
|
122
|
+
numbers aligns right and gets `tabular-nums` without being told.
|
|
123
|
+
- `format`: `'integer'`, `'number'`, `'percent'`, `'text'`, or your own
|
|
124
|
+
`(value, row) => ReactNode`.
|
|
125
|
+
- `caption` numbers the table and prints the caption above it; without one you
|
|
126
|
+
get a plain table.
|
|
127
|
+
- `limit` prints the first N rows and says how many were left out — never
|
|
128
|
+
truncate a table silently.
|
|
129
|
+
|
|
130
|
+
**Retyping numbers into JSX is the thing this replaces.** If the user has the
|
|
131
|
+
data in a file, import the file.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Pagination — flow sections and the vertical budget
|
|
2
|
+
|
|
3
|
+
A document's default export is a list of **entries**. There are two kinds, and they mix freely:
|
|
4
|
+
|
|
5
|
+
| Entry | What it is | Who decides the page break |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| `DocPage` (a component) | Exactly one sheet | You |
|
|
8
|
+
| `flow(<>…</>)` | Continuous content | The framework, by measuring |
|
|
9
|
+
|
|
10
|
+
**Default to `flow()` for body content.** Use fixed pages for the cover, a contents page, a section divider — anything whose layout is the point. Hand-splitting prose into fixed pages is how you end up with eleven pages that are each 60% full.
|
|
11
|
+
|
|
12
|
+
## Flow sections
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
import { flow, type DocEntry } from 'mosage';
|
|
16
|
+
|
|
17
|
+
const Body = flow(
|
|
18
|
+
<>
|
|
19
|
+
<h1 style={h1}>1. 概觀</h1>
|
|
20
|
+
<p style={p}>…</p>
|
|
21
|
+
<Table>…</Table>
|
|
22
|
+
<p style={caption} data-od-keep-with-previous>表 1 — …</p>
|
|
23
|
+
<h2 style={h2}>1.1 細節</h2>
|
|
24
|
+
<p style={p}>…</p>
|
|
25
|
+
</>,
|
|
26
|
+
{ footer: Footer },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export default [Cover, Contents, Body] satisfies DocEntry[];
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **Each direct child of the fragment is one block.** Blocks are atomic: a block never splits across a page, so a table either fits whole or moves to the next page.
|
|
33
|
+
- The framework owns the page shell for flow pages — margin, background, and base typography come from the `design` const. Your blocks keep their own styles. `flow(node, { padding })` overrides the margin for that section.
|
|
34
|
+
- `footer` is a component rendered on **every** page the section expands into. `useDocPageNumber()` works inside it, so a running footer needs no extra wiring.
|
|
35
|
+
- Blocks are measured in the real DOM at the real page width after fonts settle, then packed greedily. Change a paragraph and the pagination re-runs on hot reload.
|
|
36
|
+
|
|
37
|
+
### Keep rules
|
|
38
|
+
|
|
39
|
+
| Rule | How | Default |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| Heading never last on a page | `data-od-keep-with-next` | **on** for `h1`–`h4` |
|
|
42
|
+
| Caption stays with its figure | `data-od-keep-with-previous` on the caption | off |
|
|
43
|
+
| Force a new page here | `data-od-break-before` | off |
|
|
44
|
+
|
|
45
|
+
Set `data-od-keep-with-next="false"` on a heading if you deliberately want it to end a page.
|
|
46
|
+
|
|
47
|
+
### What flow does not do
|
|
48
|
+
|
|
49
|
+
- **It never splits a block.** A 40-row table taller than one page stays whole and overflows — split it yourself into two tables with a repeated header.
|
|
50
|
+
- **It does not balance pages.** Content fills top-down; the last page can be short.
|
|
51
|
+
- **It does not reorder anything.** Blocks keep the order you wrote them in.
|
|
52
|
+
|
|
53
|
+
## Fixed pages and the vertical budget
|
|
54
|
+
|
|
55
|
+
Everything below applies to `DocPage` components — the pages you lay out yourself.
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
usable_height = page_height − 2 × margin
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| Page | Margin | Usable height |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| A4 portrait (1123px) | 76 | **971px** |
|
|
64
|
+
| A4 portrait | 96 | 931px |
|
|
65
|
+
| A4 landscape (794px) | 76 | 642px |
|
|
66
|
+
| B4 portrait (1376px) | 76 | 1224px |
|
|
67
|
+
| A3 portrait (1587px) | 76 | 1435px |
|
|
68
|
+
|
|
69
|
+
A running footer sits inside the margin band, so it costs nothing from the budget — but leave **24px of clearance** above it, i.e. treat the budget as ~947px on A4 when a footer is present.
|
|
70
|
+
|
|
71
|
+
### Estimating how many lines a paragraph takes
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
chars_per_line ≈ text_block_width / (font_size × 0.5)
|
|
75
|
+
lines = ceil(characters / chars_per_line)
|
|
76
|
+
height = lines × font_size × line_height
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The `0.5` is the average glyph-width ratio for a humanist sans at body sizes. Use `0.52` for serif faces, `0.6` for monospace, and **`1.0` for CJK** (one character per em).
|
|
80
|
+
|
|
81
|
+
On A4 with 76px margins (text block 642px) at 14px body:
|
|
82
|
+
|
|
83
|
+
- Latin: `642 / (14 × 0.5)` ≈ **92 characters per line**
|
|
84
|
+
- CJK: `642 / 14` ≈ **45 characters per line**
|
|
85
|
+
|
|
86
|
+
A 400-character English paragraph → `ceil(400/92)` = 5 lines → `5 × 14 × 1.55` = **109px**.
|
|
87
|
+
|
|
88
|
+
### Worked example — A4 content page, 76px margins, footer present (budget 947px)
|
|
89
|
+
|
|
90
|
+
| Element | Height |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| H1 28px × 1.2 | 34px |
|
|
93
|
+
| Gap | 20px |
|
|
94
|
+
| Intro paragraph, 380 chars → 5 lines × 14 × 1.55 | 109px |
|
|
95
|
+
| Gap | 14px |
|
|
96
|
+
| H2 20px × 1.25 | 25px |
|
|
97
|
+
| Gap | 12px |
|
|
98
|
+
| Body paragraph, 620 chars → 7 lines | 152px |
|
|
99
|
+
| Gap | 14px |
|
|
100
|
+
| Table: header 28px + 6 rows × 26px | 184px |
|
|
101
|
+
| Gap | 14px |
|
|
102
|
+
| Caption 10px × 1.4 | 14px |
|
|
103
|
+
| Gap | 24px |
|
|
104
|
+
| Body paragraph, 520 chars → 6 lines | 130px |
|
|
105
|
+
| **Total** | **746px ✅ fits in 947** |
|
|
106
|
+
|
|
107
|
+
### Where to split a fixed page
|
|
108
|
+
|
|
109
|
+
1. **At a section boundary.** A new H1 starts a new page.
|
|
110
|
+
2. **Before a subsection heading.** Never leave an H2 as the last element on a page.
|
|
111
|
+
3. **Between a table/figure and its surrounding prose.** Keep a caption with its table.
|
|
112
|
+
4. **Mid-prose, at a paragraph boundary.** Never split a paragraph across two page components.
|
|
113
|
+
|
|
114
|
+
Rules: give every page the same top edge; if a page is more than ~85% full, move its last block; if you find yourself shrinking type or margins to fit, add a page instead. **Or convert the section to `flow()` and stop doing this arithmetic.**
|
|
115
|
+
|
|
116
|
+
## Landscape pages
|
|
117
|
+
|
|
118
|
+
`meta.orientation` applies to the **whole document** — there is no per-page orientation. If one wide table needs landscape, either rotate it inside a portrait page (`transform: rotate(-90deg)` on a sized wrapper) or make the whole document landscape. Mixing is not supported.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Tables, stats, and charts
|
|
2
|
+
|
|
3
|
+
Reports are mostly evidence. This file covers the three shapes evidence takes on a page.
|
|
4
|
+
|
|
5
|
+
## Tables
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
const Th = ({ children, align = 'left' }: { children: ReactNode; align?: 'left' | 'right' }) => (
|
|
9
|
+
<th
|
|
10
|
+
style={{
|
|
11
|
+
textAlign: align,
|
|
12
|
+
fontFamily: 'var(--od-font-heading)',
|
|
13
|
+
fontSize: 11,
|
|
14
|
+
fontWeight: 600,
|
|
15
|
+
letterSpacing: '0.04em',
|
|
16
|
+
textTransform: 'uppercase',
|
|
17
|
+
color: 'var(--od-muted)',
|
|
18
|
+
borderBottom: '1px solid var(--od-rule)',
|
|
19
|
+
padding: '0 8px 6px',
|
|
20
|
+
}}
|
|
21
|
+
>
|
|
22
|
+
{children}
|
|
23
|
+
</th>
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const Td = ({ children, align = 'left' }: { children: ReactNode; align?: 'left' | 'right' }) => (
|
|
27
|
+
<td
|
|
28
|
+
style={{
|
|
29
|
+
textAlign: align,
|
|
30
|
+
fontSize: 12,
|
|
31
|
+
padding: '7px 8px',
|
|
32
|
+
borderBottom: '1px solid var(--od-rule)',
|
|
33
|
+
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
{children}
|
|
37
|
+
</td>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
|
41
|
+
<thead>
|
|
42
|
+
<tr>
|
|
43
|
+
<Th>Service</Th>
|
|
44
|
+
<Th align="right">Requests</Th>
|
|
45
|
+
<Th align="right">p99</Th>
|
|
46
|
+
</tr>
|
|
47
|
+
</thead>
|
|
48
|
+
<tbody>
|
|
49
|
+
<tr>
|
|
50
|
+
<Td>checkout-api</Td>
|
|
51
|
+
<Td align="right">18,402,111</Td>
|
|
52
|
+
<Td align="right">412 ms</Td>
|
|
53
|
+
</tr>
|
|
54
|
+
</tbody>
|
|
55
|
+
</table>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Rules:
|
|
59
|
+
|
|
60
|
+
- `borderCollapse: 'collapse'` + `tableLayout: 'fixed'`, always. Auto layout produces columns that shift when the copy changes.
|
|
61
|
+
- Numbers right-aligned with `fontVariantNumeric: 'tabular-nums'`. Text left-aligned. Never center either.
|
|
62
|
+
- Horizontal rules only. Vertical borders and zebra striping both add noise that print exaggerates.
|
|
63
|
+
- Cell font 11–12px, row padding 6–8px → a row costs ~26–30px. Budget accordingly (`references/pagination.md`).
|
|
64
|
+
- Portrait A4 tops out around **7 columns**; past that, either drop columns or transpose the table.
|
|
65
|
+
- One unit per column, declared in the header (`p99 (ms)`), not repeated in every cell.
|
|
66
|
+
- A caption goes **below** the table at `--od-size-caption`, muted, and must stay on the same page as the table.
|
|
67
|
+
|
|
68
|
+
### Long tables
|
|
69
|
+
|
|
70
|
+
Split by row groups across pages. Repeat the full header row and mark the continuation in the caption ("Table 3 (continued)"). Never let a header land on one page and its rows on the next.
|
|
71
|
+
|
|
72
|
+
## Stat rows
|
|
73
|
+
|
|
74
|
+
A row of 3–4 headline numbers under a section title. Define one component, instantiate it per stat — do **not** `map` over a data array (an explicit instance per stat keeps each number editable on its own):
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
const Stat = ({ value, label, note }: { value: string; label: string; note?: string }) => (
|
|
78
|
+
<div style={{ flex: 1 }}>
|
|
79
|
+
<div style={{ fontFamily: 'var(--od-font-heading)', fontSize: 30, fontWeight: 650, lineHeight: 1.1 }}>
|
|
80
|
+
{value}
|
|
81
|
+
</div>
|
|
82
|
+
<div style={{ fontSize: 12, marginTop: 4 }}>{label}</div>
|
|
83
|
+
{note && <div style={{ fontSize: 10, color: 'var(--od-muted)', marginTop: 2 }}>{note}</div>}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
<div style={{ display: 'flex', gap: 24, borderTop: '1px solid var(--od-rule)', paddingTop: 14 }}>
|
|
88
|
+
<Stat value="99.94%" label="Availability" note="target 99.9%" />
|
|
89
|
+
<Stat value="412 ms" label="p99 latency" note="−18% QoQ" />
|
|
90
|
+
<Stat value="$41.2k" label="Monthly spend" note="+6% QoQ" />
|
|
91
|
+
</div>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Every stat needs a comparison (target, prior period) — a number with nothing to compare against tells the reader nothing.
|
|
95
|
+
|
|
96
|
+
## Charts
|
|
97
|
+
|
|
98
|
+
No chart library is available, and none is needed. Write **inline SVG** sized in absolute px:
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
const Bars = () => {
|
|
102
|
+
const data = [
|
|
103
|
+
{ label: 'Jan', value: 38 },
|
|
104
|
+
{ label: 'Feb', value: 52 },
|
|
105
|
+
{ label: 'Mar', value: 47 },
|
|
106
|
+
];
|
|
107
|
+
const w = 642;
|
|
108
|
+
const h = 180;
|
|
109
|
+
const max = 60;
|
|
110
|
+
const gap = 12;
|
|
111
|
+
const barW = (w - gap * (data.length - 1)) / data.length;
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<svg width={w} height={h} role="img" aria-label="Monthly incidents">
|
|
115
|
+
<title>Monthly incidents</title>
|
|
116
|
+
{data.map((d, i) => {
|
|
117
|
+
const barH = (d.value / max) * (h - 24);
|
|
118
|
+
return (
|
|
119
|
+
<g key={d.label}>
|
|
120
|
+
<rect x={i * (barW + gap)} y={h - 24 - barH} width={barW} height={barH} fill="var(--od-accent)" rx={2} />
|
|
121
|
+
<text x={i * (barW + gap) + barW / 2} y={h - 8} textAnchor="middle" fontSize={10} fill="var(--od-muted)">
|
|
122
|
+
{d.label}
|
|
123
|
+
</text>
|
|
124
|
+
</g>
|
|
125
|
+
);
|
|
126
|
+
})}
|
|
127
|
+
</svg>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Rules:
|
|
133
|
+
|
|
134
|
+
- Size the SVG to the text block width (A4 @ 76px margins → 642) so it aligns with the prose.
|
|
135
|
+
- Vector only. SVG stays sharp in the PDF; a rasterized chart does not.
|
|
136
|
+
- Label directly on the chart — a separate legend forces the reader to look twice. If you must have a legend, put it on one line above the plot.
|
|
137
|
+
- Start bar axes at zero. Truncated axes in a report are a credibility problem, not a style choice.
|
|
138
|
+
- Two decimals maximum, units in the axis label or the caption.
|
|
139
|
+
- `data.map` inside one SVG is fine — the "explicit instance" rule is about repeated *page elements*, not path geometry.
|
|
140
|
+
- If the chart needs data the user has to supply, don't fabricate plausible numbers. Use `<ImagePlaceholder hint="Bar chart: monthly incidents, Jan–Sep, from the ops dashboard" height={180} />` and flag it at hand-off.
|
|
141
|
+
|
|
142
|
+
## Callouts
|
|
143
|
+
|
|
144
|
+
One box style, used sparingly (at most one or two per page):
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
<div
|
|
148
|
+
style={{
|
|
149
|
+
borderLeft: '3px solid var(--od-accent)',
|
|
150
|
+
background: '#f8fafc',
|
|
151
|
+
padding: '12px 14px',
|
|
152
|
+
borderRadius: 'var(--od-radius)',
|
|
153
|
+
fontSize: 12,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<strong style={{ display: 'block', marginBottom: 4 }}>Recommendation</strong>
|
|
157
|
+
Move the checkout queue to its own cluster before the November peak.
|
|
158
|
+
</div>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Reserve callouts for recommendations, risks, and definitions. A page where everything is boxed emphasizes nothing.
|
package/src/app/app.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import config from 'virtual:mosage/config';
|
|
2
|
+
import { BrowserRouter, Link, Route, Routes } from 'react-router-dom';
|
|
3
|
+
import { AssetsPage } from './routes/assets';
|
|
4
|
+
import { Doc } from './routes/doc';
|
|
5
|
+
import { Home } from './routes/home';
|
|
6
|
+
import { HomeShell } from './routes/home-shell';
|
|
7
|
+
import { ThemeDetailPage, ThemesGalleryPage } from './routes/themes';
|
|
8
|
+
|
|
9
|
+
export function App() {
|
|
10
|
+
return (
|
|
11
|
+
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
|
12
|
+
<Routes>
|
|
13
|
+
{config.build.showDocBrowser ? (
|
|
14
|
+
<Route element={<HomeShell />}>
|
|
15
|
+
<Route path="/" element={<Home />} />
|
|
16
|
+
<Route path="/themes" element={<ThemesGalleryPage />} />
|
|
17
|
+
<Route path="/themes/:themeId" element={<ThemeDetailPage />} />
|
|
18
|
+
<Route path="/assets" element={<AssetsPage />} />
|
|
19
|
+
</Route>
|
|
20
|
+
) : (
|
|
21
|
+
<Route path="/" element={<NotFound />} />
|
|
22
|
+
)}
|
|
23
|
+
<Route path="/d/:docId" element={<Doc />} />
|
|
24
|
+
<Route path="*" element={<NotFound />} />
|
|
25
|
+
</Routes>
|
|
26
|
+
</BrowserRouter>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function NotFound() {
|
|
31
|
+
return (
|
|
32
|
+
<div className="grid h-screen place-items-center bg-background px-6 text-center text-foreground">
|
|
33
|
+
<div>
|
|
34
|
+
<p className="font-mono text-muted-foreground text-xs uppercase tracking-widest">404</p>
|
|
35
|
+
<h1 className="mt-2 font-medium text-xl tracking-tight">Nothing here</h1>
|
|
36
|
+
<Link to="/" className="mt-4 inline-block text-muted-foreground text-xs underline">
|
|
37
|
+
Back to documents
|
|
38
|
+
</Link>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
2
|
+
import { Figure } from './numbering';
|
|
3
|
+
|
|
4
|
+
export type DataAlign = 'left' | 'center' | 'right';
|
|
5
|
+
|
|
6
|
+
export type DataFormat =
|
|
7
|
+
| 'text'
|
|
8
|
+
| 'number'
|
|
9
|
+
| 'integer'
|
|
10
|
+
| 'percent'
|
|
11
|
+
| ((value: unknown, row: Record<string, unknown>) => ReactNode);
|
|
12
|
+
|
|
13
|
+
export type DataColumn = {
|
|
14
|
+
key: string;
|
|
15
|
+
label?: ReactNode;
|
|
16
|
+
align?: DataAlign;
|
|
17
|
+
format?: DataFormat;
|
|
18
|
+
/** Column width. A number is px; `tableLayout: fixed` means these are honoured. */
|
|
19
|
+
width?: number | string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type DataTableProps = {
|
|
23
|
+
/** Rows as parsed from a `.csv`/`.tsv` import, or any array of objects. */
|
|
24
|
+
rows: Array<Record<string, unknown>>;
|
|
25
|
+
/** Columns to print, in order. Derived from the first row when omitted. */
|
|
26
|
+
columns?: Array<DataColumn | string>;
|
|
27
|
+
/** Numbers the table and prints the caption above it. */
|
|
28
|
+
caption?: ReactNode;
|
|
29
|
+
/** Stable id for `<Ref to>`. Only meaningful alongside `caption`. */
|
|
30
|
+
id?: string;
|
|
31
|
+
/** Print at most this many rows, with a note about what was left out. */
|
|
32
|
+
limit?: number;
|
|
33
|
+
/** Tighter rows, for a dense appendix table. */
|
|
34
|
+
compact?: boolean;
|
|
35
|
+
/** What an empty cell prints as. Defaults to an em dash. */
|
|
36
|
+
emptyValue?: ReactNode;
|
|
37
|
+
style?: CSSProperties;
|
|
38
|
+
className?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function normalizeColumn(column: DataColumn | string): DataColumn {
|
|
42
|
+
return typeof column === 'string' ? { key: column } : column;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isNumber(value: unknown): value is number {
|
|
46
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A column of numbers aligns right; anything mixed stays left. */
|
|
50
|
+
function inferAlign(rows: Array<Record<string, unknown>>, key: string): DataAlign {
|
|
51
|
+
let seen = 0;
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
const value = row[key];
|
|
54
|
+
if (value === null || value === undefined || value === '') continue;
|
|
55
|
+
if (!isNumber(value)) return 'left';
|
|
56
|
+
seen++;
|
|
57
|
+
}
|
|
58
|
+
return seen > 0 ? 'right' : 'left';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatValue(
|
|
62
|
+
value: unknown,
|
|
63
|
+
format: DataFormat | undefined,
|
|
64
|
+
row: Record<string, unknown>,
|
|
65
|
+
emptyValue: ReactNode,
|
|
66
|
+
): ReactNode {
|
|
67
|
+
if (typeof format === 'function') return format(value, row);
|
|
68
|
+
if (value === null || value === undefined || value === '') return emptyValue;
|
|
69
|
+
|
|
70
|
+
if (isNumber(value)) {
|
|
71
|
+
if (format === 'integer') return Math.round(value).toLocaleString();
|
|
72
|
+
if (format === 'percent')
|
|
73
|
+
return `${(value * 100).toLocaleString(undefined, { maximumFractionDigits: 1 })}%`;
|
|
74
|
+
if (format === 'text') return String(value);
|
|
75
|
+
// Grouping separators are what make a column of figures readable; the
|
|
76
|
+
// fraction digits follow the data rather than padding every integer.
|
|
77
|
+
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return String(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A print-shaped table from data, so the numbers in a report come from a file
|
|
85
|
+
* instead of being retyped into JSX. Alignment and grouping follow the column's
|
|
86
|
+
* contents, which is the part people get wrong by hand.
|
|
87
|
+
*/
|
|
88
|
+
export function DataTable({
|
|
89
|
+
rows,
|
|
90
|
+
columns,
|
|
91
|
+
caption,
|
|
92
|
+
id,
|
|
93
|
+
limit,
|
|
94
|
+
compact = false,
|
|
95
|
+
emptyValue = '—',
|
|
96
|
+
style,
|
|
97
|
+
className,
|
|
98
|
+
}: DataTableProps) {
|
|
99
|
+
const resolved: DataColumn[] = (columns ?? Object.keys(rows[0] ?? {})).map(normalizeColumn);
|
|
100
|
+
|
|
101
|
+
const shown = limit !== undefined ? rows.slice(0, limit) : rows;
|
|
102
|
+
const hidden = rows.length - shown.length;
|
|
103
|
+
|
|
104
|
+
const cellPadding = compact ? '4px 8px' : '7px 8px';
|
|
105
|
+
|
|
106
|
+
const table = (
|
|
107
|
+
<table
|
|
108
|
+
className={className}
|
|
109
|
+
style={{
|
|
110
|
+
width: '100%',
|
|
111
|
+
borderCollapse: 'collapse',
|
|
112
|
+
tableLayout: 'fixed',
|
|
113
|
+
margin: 0,
|
|
114
|
+
...style,
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
<thead>
|
|
118
|
+
<tr>
|
|
119
|
+
{resolved.map((column) => {
|
|
120
|
+
const align = column.align ?? inferAlign(rows, column.key);
|
|
121
|
+
return (
|
|
122
|
+
<th
|
|
123
|
+
key={column.key}
|
|
124
|
+
style={{
|
|
125
|
+
textAlign: align,
|
|
126
|
+
width: column.width,
|
|
127
|
+
fontFamily: 'var(--od-font-heading)',
|
|
128
|
+
fontSize: 11,
|
|
129
|
+
fontWeight: 600,
|
|
130
|
+
letterSpacing: '0.04em',
|
|
131
|
+
textTransform: 'uppercase',
|
|
132
|
+
color: 'var(--od-muted)',
|
|
133
|
+
borderBottom: '1px solid var(--od-rule)',
|
|
134
|
+
padding: '0 8px 6px',
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{column.label ?? column.key}
|
|
138
|
+
</th>
|
|
139
|
+
);
|
|
140
|
+
})}
|
|
141
|
+
</tr>
|
|
142
|
+
</thead>
|
|
143
|
+
<tbody>
|
|
144
|
+
{shown.map((row, rowIndex) => (
|
|
145
|
+
// Rows come from a file and carry no id of their own; their position
|
|
146
|
+
// is what identifies them.
|
|
147
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: row order is the row's identity here
|
|
148
|
+
<tr key={rowIndex}>
|
|
149
|
+
{resolved.map((column) => {
|
|
150
|
+
const align = column.align ?? inferAlign(rows, column.key);
|
|
151
|
+
return (
|
|
152
|
+
<td
|
|
153
|
+
key={column.key}
|
|
154
|
+
style={{
|
|
155
|
+
textAlign: align,
|
|
156
|
+
fontSize: compact ? 11 : 12,
|
|
157
|
+
padding: cellPadding,
|
|
158
|
+
borderBottom: '1px solid var(--od-rule)',
|
|
159
|
+
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
|
160
|
+
overflowWrap: 'anywhere',
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
{formatValue(row[column.key], column.format, row, emptyValue)}
|
|
164
|
+
</td>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
167
|
+
</tr>
|
|
168
|
+
))}
|
|
169
|
+
</tbody>
|
|
170
|
+
{hidden > 0 && (
|
|
171
|
+
<tfoot>
|
|
172
|
+
<tr>
|
|
173
|
+
<td
|
|
174
|
+
colSpan={resolved.length}
|
|
175
|
+
style={{
|
|
176
|
+
fontSize: 'var(--od-size-caption)',
|
|
177
|
+
color: 'var(--od-muted)',
|
|
178
|
+
padding: '6px 8px 0',
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{hidden} more row{hidden === 1 ? '' : 's'} not shown
|
|
182
|
+
</td>
|
|
183
|
+
</tr>
|
|
184
|
+
</tfoot>
|
|
185
|
+
)}
|
|
186
|
+
</table>
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
if (!caption) return table;
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<Figure kind="table" caption={caption} {...(id !== undefined ? { id } : {})}>
|
|
193
|
+
{table}
|
|
194
|
+
</Figure>
|
|
195
|
+
);
|
|
196
|
+
}
|