Visual MD is a markdown reader. You drop a folder on it and every markdown file inside — nested folders included — is on the shelf, instantly. You pick a document, read it on a quiet page, and move through it with an outline of its headings. It should feel like a cozy library, not a terminal.
That is the whole product. This guide explains how to change it while preserving the qualities that make it worth reading in.
- Keep the rings intact. Dependencies point inward, and an architecture test makes the boundary visible.
- Research typography before adjusting it. Where a craft discipline exists, learn its rules, measure the actual page, and use visual judgment as the final check rather than the only evidence.
- Keep the docs true.
docs/is checked mechanically. When cited code changes, update the explanation and its citation together.
Everything below is detail on those three.
Flutter lives at /opt/homebrew/bin; export it before anything else, or the
commands are not found:
export PATH="/opt/homebrew/bin:$PATH"
flutter run -d macos # develop natively (needs full Xcode)
bin/tools/prepare-web-assets.sh
flutter run -d chrome # develop on the web
flutter test # everything, including the two validators
flutter analyze # should finish with no issues
flutter build macos # → build/macos/Build/Products/Release/Visual MD.app
flutter build web --release # → build/webTo look at a real page without a browser session, build for web, serve
build/web, and drive headless Chrome with --screenshot. Launch options make
this practical: ?open=sample, ?theme=<id>, ?paragraphs=indented,
?serif=<family>. Look at what you changed. Rendering it has caught bugs
that no test suggested — clipped code blocks, hard line breaks, a code block
shrink-wrapped to its shortest line.
macOS is sandboxed. The reader's own files live at
~/Library/Containers/com.visualmd.visualmd/Data/Library/Application Support/com.visualmd.visualmd/Visual MD/
— preferences.json and a themes/ folder with a README explaining the format.
lib/
├── domain/ what a document and a library ARE no packages at all
├── application/ use cases and the ports they need
├── presentation/ contracts a contribution is written against no packages at all
├── api/ Flutter: widgets, the renderer, the shell
├── infrastructure/ adapters implementing ports
└── main.dart composition root — the only file that sees every ring
| Ring | May import |
|---|---|
domain |
domain only |
application |
application, domain |
presentation |
presentation only |
api |
api, presentation, application, domain |
infrastructure |
infrastructure, application, domain |
main.dart |
everything |
domain and presentation are framework-free: neither may import any
package. That is what makes a theme data rather than code, and a domain rule
testable without Flutter. Platform packages (web, desktop_drop,
file_selector, window_manager) and dart:io / dart:js_interop /
dart:html are infrastructure-only.
test/architecture/dependency_rules_test.dart parses every import under lib/
and produces one test per file. If you need a new capability, add a port
where it is needed and an adapter in infrastructure; do not reach across.
Read docs/00-foundation/03-dependency-direction.md before your first structural change.
This is the most important section in this file.
Visual MD is a reader. Someone opens it to learn something — to study, to digest, to come away understanding more than they did. Typography is not the decoration on that; it is the mechanism of it. A reader who loses their place on the return sweep, or whose eye is dragged to a heading that shouted when it should have murmured, understands less. Everything else in this app — the shelf, the outline, the themes, the whole architecture — exists to get out of the way of a column of text.
So the standard here is higher than "it looks good."
Do not adjust a number only until the page looks right. Taste can identify a problem, but it cannot establish the rule or give the next contributor a reason they can verify.
The distinction matters. Compare:
760px looks about right for a column.
and
At 18px Literata the mean advance is 8.78px, so 760px sets 87 characters — well past the 60–75 band where the eye reliably finds the start of the next line.
The measured statement can be checked, challenged, and improved by the next contributor. That kind of measurement also revealed two defects that visual inspection alone had missed:
- Body text was rendering 7% smaller than the interface face beside it, because x-height differs between faces at the same nominal size.
- The baseline grid was counted in a unit the page never used — the beat came from the nominal size while text rendered at the normalised one.
Both had survived several visual reviews because the page still appeared plausible.
- Find the discipline. Vertical rhythm, hanging punctuation, the measure, optical sizing — these are named things with literature. Search for it. The answer is rarely "it depends."
- Read what it actually says, including where it contradicts your plan. In one earlier investigation, the legibility research argued against the proposed font swap. Recording that result was more useful than quietly proceeding.
- Measure the artefact. Not a blog post's numbers — this font, this renderer, this page. The toolkit is below.
- Encode the rule as a named function with the reasoning in the doc
comment.
spaceAbove(level),leadingFor(family),HangingPunctuation .fractionFor(mark)— a rule with a name is a rule that can be found, questioned and changed on purpose. - Write a test that states the invariant in prose. "The running text returns on the beat after every departure" is a test. "spaceAfter returns 34" is a restatement of the implementation and guards nothing.
- Render it and look. Measurement proves the rule holds; only looking catches the thing you did not think to measure.
Use these findings as the current baseline. Extend or revise them when stronger evidence gives us a reason to.
| Finding | What it means here |
|---|---|
| Serif vs sans shows no consistent difference (Lund, review of 72 studies) | The family of the face is not the lever. Do not argue about it. |
| X-height, not nominal size, governs how large text reads | A size in this reader is a size of letters; FontMetrics.sizeFor converts. |
Letter differentiation matters; Garamond's e was identified correctly a tenth as often as Verdana's |
Bookish old-style faces can be worse for documents full of identifiers. This is why the obvious "make it feel like a book" face swap is a trap. |
| Italic slows continuous reading; bold does not | Both are for emphasis and headings, never for passages. It is why a blockquote is not set in italic. |
| Capitals slow reading | Short chrome labels only. Never inside a document. |
| Harder-to-read fonts do not aid retention — the disfluency effect died under a meta-analysis of 17 studies | The current evidence does not support "desirable difficulty" typography. |
| Typeface choice matters far less than designers assume, provided the face is conventional and drawn for its medium | This is the one to remember. Effort spent choosing a face is usually effort not spent on measure, leading and rhythm, which do matter. |
And from Lupton, on structure rather than legibility: an economy of signals (no more than three cues per level, and emphasis needs only one); indents and paragraph spacing are alternatives, not companions; never indent the opening paragraph; quotation marks carve a notch out of the left edge unless hung.
Each value below has a recorded derivation and a corresponding test.
| System | Derivation |
|---|---|
| Size | A size of letters. fontSize = size × 0.55 / xHeight(face), x-height read from the font's OS/2 table. Alegreya's is 0.452, so an "18" renders at 21.9px; Literata's is 0.507, so 19.5px. |
| Measure | 66 characters, from the mean advance of the face actually in use (ReadingMeasure), not a fixed pixel width. A wider face gets a wider column, not a longer line. |
| Leading | cap + descender + 1.26 × x-height, per face. Only the 1.26 was chosen by reading — and it is the value that reproduces the 1.65 the reader had already settled on for Literata, which is why it is trusted elsewhere. Alegreya wants less leading than Literata, because Literata's ascenders are unusually tall. |
| Vertical rhythm | Bringhurst: the space taken by any departure from the running text comes to a whole number of body lines. External gaps are forward-owned by the preceding block through spaceAfter(current, next); Lupton's spaced paragraphs use a half-beat rather than a wasteful blank line; heading boxes round up; a code block is n+1 beats; a strut stops an inline code span pushing a line off the beat. |
| Hierarchy | Size, one weight, and tone. Tone is a distance from the running text toward ink or away from it — never a lightness, or it inverts on a dark page. The body is never dimmed: contrast is what legibility rests on. |
| Detail | Hanging punctuation by each mark's own advance (quotes whole, dashes half); widow binding; old-style figures in prose and lining tabular in tables; a slashed zero in code; a little extra tracking on dark grounds. |
The criteria, in order — and a face that fails the first three is not a candidate however handsome it is:
- Drawn for the medium. Long reading on screens, across rendering technologies.
- Variable, so weight is an axis rather than a pile of static files.
- The figure set we rely on: old-style (
onum) for prose, lining and tabular (lnum,tnum) for tables. - An optical-size axis (
opsz) is a real advantage — the face is then several designs, and text and display sizes get the cut they were drawn for. - Measured x-height, cap height and descender, added to
FontMetricsfrom the file, never looked up.
Martel failed on 2 and 3 — static weights only, kern and liga and nothing
else — which is how a candidate gets ruled out without anyone appealing to
taste.
Font tables, in a scratch script — x-height, cap height, descender, and which OpenType features exist:
# OS/2: sxHeight at +86, sCapHeight at +88 (version ≥ 2); head: unitsPerEm at +18Glyph and text measurement, in a test — ReadingMeasure.advance and
widthOf wrap TextPainter and cache by family, size and weight.
test/typography_measure_test.dart is a measuring stick, not a guard: run
it to print what the bundled faces actually do — advances, characters per line
at candidate widths, the effect of the optical-size axis. It prints; it does not
assert. Extend it when you need a number.
Rendering: build for web, serve build/web, and screenshot headless Chrome with
?open=sample&theme=paper&serif=<family>¶graphs=indented. Comparing two
faces on the same real document at the same letter size is a fair comparison;
comparing specimens at the same nominal size is not.
Real failures from this codebase, so you can recognise them:
- Reaching for a new typeface when the problem was size. The page felt underwhelming; the cause was a 7% x-height deficit, not the face.
- Patching a symptom. A heading gap was shortened because it appeared too large, while the actual problem was that the page had no baseline grid and the whole document was off the beat.
- Adding a signal instead of removing one. A quotation marked by a rule, a colour and italic. Three cues where one would do, and the italic hurt reading.
- Believing a number that was never measured.
softLineBreak: truehad every hard-wrapped document rendering its source's 80-column wrapping onto the page, invisible until the column narrowed enough to expose it.
The sources are recorded in docs/04-presentation/10-sources.md — the book, the research, and the checklist used as an audit — so the next person can check the reasoning against the originals instead of taking our word for it. If you settle something new, add it there.
docs/ is an inventory of what every component is for and how it is wired,
written to be read inside Visual MD — drop the folder on the app. It is
guarded by two suites and one tool:
test/architecture/dependency_rules_test.dart— the ring rules above.test/docs/docs_library_test.dart— opensdocs/through the app's own domain code and fails on a broken link, a missing anchor, a folder without a README, a document without a title, a placeholder word, a missing source file, or an exact-line citation.
flutter test test/docs # the gateConventions when writing here: one component per document, the seven sections
from
docs/00-foundation/05-component-document-template.md
in order, code claims supported by stable file references such as
`lib/path/file.dart`, a README in every folder whose link text matches
each target's H1 exactly, usually 40–140 lines, and no placeholders. A longer
document is justified when it keeps one contract or research trail intact;
split it when the parts can stand alone, not merely to satisfy a count.
Do not cite exact source lines in Markdown. Line movement is not a behavioral change, and prose should not become stale when unrelated code is inserted above the implementation it describes. Point to the smallest owning file instead.
final classby default;sealedfor closed sets;abstract interface classfor ports;abstract final classfor a namespace.- Comments explain why, not what. If a decision would look arbitrary to the next reader, the comment is the place the reasoning lives.
- Test names are sentences about behaviour: "a heading sits closer to what it introduces than to what it follows", not "testGapBefore".
- Record incomplete work in
docs/07-roadmap/02-backlog.md, with the reason
it remains open, instead of leaving an unactionable
TODO. - Fonts are bundled under
assets/fonts/with their OFL licence text, which ships and is registered for the licence page. If you add one, add its x-height, cap height and descender toFontMetrics— measured from the file, not looked up.
The kernel — open a folder, shelve it, read with an outline — is meant to feel finished and change rarely. Everything else attaches at the edge as typed hooks: events for what happened, extension points for what to contribute, UI slots for where to show it. It is a direction, not yet code; do not build the framework before the plugins that need it exist. See docs/07-roadmap/01-plugin-architecture.md.
Known gaps:
- No justification or hyphenation. The remaining book signal. Flutter has
TextAlign.justifybut no hyphenation, and justification without it gives rivers; real hyphenation means Knuth–Liang patterns and verifying Flutter honoursU+00AD. - Hanging punctuation only applies to a paragraph's opening mark, not to every line that happens to begin with one — that needs per-line control the text engine does not expose.
- A lone browser-selected Markdown file cannot resolve neighbouring images. Relative images work when the browser has been offered the containing folder or directory context.
- Windows keeps the classic native title bar. Workspace commands live in the Visual MD wordmark menu below it, and Windows 11's caption is tinted to the active reader theme. Its x64 release builds and runs on Windows 11, including under Windows on ARM emulation; replacing native caption controls remains a separate product-polish decision.
- Apple credentials remain machine-local. The repository enables hardened runtime, ships its privacy declaration, and audits the release bundle and archive. The Mac App Store route has a team-signed archive; a direct download still requires a Developer ID identity and notarization on its release machine. See Releasing for macOS.
bin/tools/beautipass.sh # canonical format, every gate, web + host buildThen look at the change in the running app. The automated gates establish the contracts; a visual review catches rendering problems they cannot see.