aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
commit8e16347b0eb329e84892af8ece36886324c95f62 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /docs
parentc6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff)
parentea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff)
Merge branch 'buffer-implementation'HEADmain
Establishes the first working baseline: moji <file> opens a file into a ropey rope and edits it with Helix selection-first modal editing, under a DO-178C DAL-C requirements and traceability process. Prior to this, main tracked four files and src/main.rs was still println!("Hello, world!") — there was no buildable state to build on. Verified on a fresh clone of the branch with no untracked files: cargo build; clippy --all-targets -D warnings clean; 294 tests passing; scripts/check-trace.sh reports 98/98 requirements traced in both directions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'docs')
-rw-r--r--docs/process.md308
-rw-r--r--docs/requirements/derived.md161
-rw-r--r--docs/requirements/hlr.md151
-rw-r--r--docs/requirements/llr.md160
-rw-r--r--docs/reviews/code-checklist.md202
-rw-r--r--docs/reviews/library-selection.md98
-rw-r--r--docs/reviews/requirements-checklist.md112
-rw-r--r--docs/traceability/trace.md287
8 files changed, 1479 insertions, 0 deletions
diff --git a/docs/process.md b/docs/process.md
new file mode 100644
index 0000000..9ce5fb9
--- /dev/null
+++ b/docs/process.md
@@ -0,0 +1,308 @@
+# mojibake — Requirements and Commit Process
+
+Software Level: **DAL-C** (DO-178C)
+Project status: **early development** — see the README for what does not work yet.
+
+This document explains the system: what the requirement artifacts are, how they
+relate to each other and to the code, and what a commit must satisfy to be
+accepted. It is the entry point — read this before `docs/requirements/`.
+
+---
+
+## 1. Why the artifacts exist
+
+DO-178C does not ask "is the code good?" It asks a narrower, checkable
+question: **can you show that every behaviour the software has was asked for,
+and that everything asked for was built and verified?**
+
+Answering it needs four things, and each one is a directory here:
+
+| Artifact | Answers |
+|---|---|
+| `docs/requirements/hlr.md` | What must the software do? |
+| `docs/requirements/llr.md` | How, specifically, in terms a test can check? |
+| `docs/requirements/derived.md` | What did the implementation need that nobody asked for? |
+| `docs/traceability/trace.md` | Where is each of those in the code, and which test proves it? |
+
+The chain runs **HLR → LLR → source → test**, and it must hold in *both*
+directions. Forward: every requirement is implemented and verified. Backward:
+every piece of behaviour traces to a requirement. Backward traceability is the
+one people skip, and it is the one that catches unrequested behaviour — code
+that does something nobody asked for is either a missing requirement or a
+defect, and you cannot tell which until you look.
+
+---
+
+## 2. The three requirement kinds
+
+### High-level requirements — `MJB-HLR-nnn`
+
+What the software does, in terms a user would recognise. One behaviour per
+requirement, phrased with "shall", and stated so that it is *observable* —
+if you cannot describe how to check it, it is not a requirement yet.
+
+> **MJB-HLR-007** — The editor shall provide next-word-start,
+> previous-word-start and next-word-end motions bound by default to `w`, `b`
+> and `e`. Consistent with Helix and unlike Vim, each shall leave a selection
+> spanning the traversed text rather than a collapsed cursor […]
+
+HLRs must not specify implementation. Two here do — MJB-HLR-004 names a rope
+and MJB-HLR-014 names TOML — because those were themselves requested. That
+exception is recorded in the requirements review rather than hidden.
+
+### Low-level requirements — `MJB-LLR-nnn`
+
+The same behaviour decomposed until each statement maps to one identifiable
+thing in the code and one test. An LLR names types, functions and exact values;
+an HLR does not.
+
+> **MJB-LLR-065** — `move_next_word_start` shall return a range whose anchor is
+> the pre-motion cursor position and whose head is the start of the following
+> word, so the result is a selection spanning the traversed text.
+
+Every LLR **must** cite a parent HLR. An LLR with no parent is either a derived
+requirement in disguise or scope creep.
+
+### Derived requirements — `MJB-DR-nnn`
+
+Requirements the implementation turned out to need that no HLR anticipated.
+DO-178C §5.1.1.b requires these to be recorded **and fed back to the safety
+assessment**, because by definition nobody evaluated them when the HLRs were
+written.
+
+Every entry states what forced it and carries an explicit *"Reviewer must
+judge"* clause. A derived requirement is not a note-to-self; it is an open
+question addressed to someone else.
+
+Two of the seven here could not have been anticipated, and both are instructive:
+
+- **MJB-DR-002** — choosing byte indices over char indices admits a failure
+ mode char indexing cannot express: an offset landing inside a character,
+ which makes ropey panic.
+- **MJB-DR-007** — `encoding_rs` decodes UTF-16 but silently refuses to
+ *encode* it, substituting UTF-8. Found by a round-trip test, not by reading
+ the code.
+
+---
+
+## 3. The ID system
+
+```
+MJB-HLR-nnn high-level requirement
+MJB-LLR-nnn low-level requirement
+MJB-DR-nnn derived requirement
+```
+
+IDs are **permanent and never reused**. Deleting a requirement means marking it
+withdrawn, not freeing its number — a trace matrix that silently renumbers is
+worse than no matrix, because reviews and commits reference IDs by number.
+
+LLR numbers are allocated in blocks by subsystem so related requirements read
+together:
+
+| Block | Subsystem |
+|---|---|
+| 001–019 | selection |
+| 020–039 | graphemes |
+| 040–059 | transactions, history |
+| 060–089 | movement |
+| 090–109 | viewport |
+| 110–129 | document, encoding, line endings |
+| 130–149 | save |
+| 150–179 | keymap, commands |
+| 180–199 | configuration |
+| 200–219 | presentation |
+
+Blocks are a convenience, not a rule. Leave gaps.
+
+---
+
+## 4. How code and tests bind to requirements
+
+Two conventions, both chosen so a machine can check them.
+
+**Source carries a tag** immediately above the item implementing it:
+
+```rust
+/// MJB-LLR-005: the byte offset the block cursor is drawn at.
+pub fn cursor(&self, text: RopeSlice) -> usize {
+```
+
+**Tests are named for what they verify**:
+
+```rust
+#[test]
+fn mjb_llr_065_w_leaves_a_selection() {
+```
+
+A test name is a claim. `mjb_llr_065_w_leaves_a_selection` asserts that
+MJB-LLR-065 holds; if the test does not actually establish that, the name is a
+lie and the matrix is worthless. Prefer a name that states the expected
+behaviour over one that describes the mechanics.
+
+More than one test may carry the same ID. Robustness cases usually do.
+
+---
+
+## 5. The matrix is verified mechanically
+
+Inspection does not scale and does not survive refactoring. Three `grep`s
+answer the whole question:
+
+```bash
+grep -oE 'MJB-LLR-[0-9]+' docs/requirements/llr.md | sort -u > defined
+grep -rhoE 'MJB-LLR-[0-9]+' src/ | sort -u > tagged
+grep -rhoE 'mjb_llr_[0-9]+' src/ tests/ | sed 's/mjb_llr_/MJB-LLR-/' | sort -u > tested
+
+comm -23 defined tagged # requirement with no implementation
+comm -23 defined tested # requirement with no test
+comm -13 defined tested # test naming a requirement that does not exist
+```
+
+**All three sets must be empty.** The third catches typos and stale references
+after a requirement is renumbered or withdrawn — a check that is easy to omit
+and that fails silently when omitted.
+
+Run it before every commit. It is not optional, and it takes under a second.
+
+---
+
+## 6. Commit requirements
+
+DO-178C Table A-8 (configuration management) wants changes to be controlled,
+traceable, and reviewable. In practice, for this repository, that reduces to
+five rules.
+
+### Rule 1 — A commit is a complete unit
+
+A commit must leave the tree in a state where all of the following pass:
+
+```bash
+cargo build
+cargo clippy --all-targets -- -D warnings
+cargo test
+```
+
+plus the traceability check from §5. Requirements, implementation, and tests
+land **together**. Do not commit an LLR whose test arrives in the next commit:
+between the two, the matrix is broken and the repository cannot be reviewed.
+
+### Rule 2 — The message states which requirements are affected
+
+```
+<subject line, imperative, ≤72 chars>
+
+<why the change is needed — the problem, not the diff>
+
+Requirements: MJB-LLR-065, MJB-LLR-066
+Derived: MJB-DR-007 (omit if none)
+Verified: cargo test (294 passing), clippy clean, trace 98/98/98
+```
+
+The body explains *why*. The diff already shows what changed; it cannot show
+what problem you were solving, and that is the part a reviewer cannot
+reconstruct.
+
+### Rule 3 — Classify the change
+
+Every commit is exactly one of these, and the class determines what else must
+be in it:
+
+| Class | Also required in the same commit |
+|---|---|
+| **Implementation only** — behaviour already specified | Test naming the existing LLR |
+| **New behaviour** | New LLR, its parent HLR, source tag, test |
+| **Requirement change** | Updated LLR/HLR, updated tests, updated trace matrix |
+| **Derived requirement discovered** | Entry in `derived.md` with a "Reviewer must judge" clause |
+| **Defect fix** | Regression test named for the LLR that was violated |
+| **Process/docs only** | Nothing further; state that no source changed |
+
+If a change does not fit a class, that is the signal: either it is unrequested
+behaviour needing a requirement, or it is two commits.
+
+### Rule 4 — A defect fix explains how it was found
+
+This is the rule most worth keeping. Recording the *detection method* tells the
+next person which verification activities are actually working:
+
+> **MJB-DR-007** — `encoding_rs::encode` silently substitutes UTF-8 for UTF-16.
+> Invisible to inspection; found only by a byte-comparing round-trip test. Any
+> encoding added later should be guarded by the same test shape.
+
+Four defects in the initial buffer work were found by four different means — a
+failing integration test, a clippy lint, a round-trip test, and a coverage
+report showing 0% on a function nobody called. None were found by re-reading
+the code. That is worth knowing.
+
+### Rule 5 — Reviews are recorded, not implied
+
+DAL-C permits **review without independence**: the author may review their own
+work. It does not permit skipping the review. Completion is recorded in
+`docs/reviews/`:
+
+- `requirements-checklist.md` — against DO-178C Tables A-3 and A-4
+- `code-checklist.md` — against Table A-5
+- `library-selection.md` — rationale for non-obvious dependencies
+
+A checklist that says only "Pass" everywhere has not been used. The value is in
+the entries that say something: a conflict found, a requirement reworded, a
+dependency accepted with a stated risk.
+
+---
+
+## 7. Definition of done
+
+```
+[ ] Requirements written or updated before the code
+[ ] Every new LLR cites a parent HLR
+[ ] Source tagged // MJB-LLR-nnn
+[ ] Test named mjb_llr_nnn_* for every affected LLR
+[ ] Robustness cases covered: empty, boundary, malformed, overflow
+[ ] Trace matrix updated
+[ ] Three-way grep check: all sets empty
+[ ] cargo build
+[ ] cargo clippy --all-targets -- -D warnings
+[ ] cargo test
+[ ] Statement coverage reported for changed modules
+[ ] Derived requirements recorded and flagged
+[ ] Review checklist updated
+[ ] Commit message names affected requirements and classifies the change
+```
+
+---
+
+## 8. What is deliberately *not* claimed
+
+Overstating compliance is worse than not claiming it, because it removes the
+reader's ability to judge. This project claims:
+
+- **Statement coverage only.** MC/DC and decision coverage are DAL-A and DAL-B
+ objectives. They are not measured and are not claimed.
+- **Review without independence.** The author reviewed their own work, which is
+ permitted at DAL-C and stated plainly in each checklist.
+- **No qualified tools.** `cargo-llvm-cov` and `clippy` are not
+ tool-qualified per DO-330. Their output is evidence, not proof.
+- **Six open derived requirements.** They need a safety-assessment judgement
+ that has not happened. They are listed as open, not quietly resolved.
+
+---
+
+## 9. Worked example
+
+Adding `t` (find-till-char) would go:
+
+1. **HLR** — does an existing one cover it? MJB-HLR-006 covers character and
+ line motion but not character search. So a new HLR is needed.
+2. **LLR** — decompose: the pending-argument state, the forward search, the
+ stop-one-short semantics, the not-found case, the count interaction. Roughly
+ five LLRs in the 060–089 block.
+3. **Derived?** — the keymap has no way to capture "the next keystroke as a
+ literal character". That is a real gap MJB-HLR-015 did not anticipate:
+ record it in `derived.md` and flag it.
+4. **Implement**, tagging each item.
+5. **Test** each LLR, including: not found, at end of buffer, on a multi-byte
+ character, with a count exceeding the number of matches.
+6. **Update** the trace matrix; run the three-way grep.
+7. **Commit** as class *New behaviour*, naming every new ID.
+
+The step people skip is 3. It is the one DO-178C exists to catch.
diff --git a/docs/requirements/derived.md b/docs/requirements/derived.md
new file mode 100644
index 0000000..8b31070
--- /dev/null
+++ b/docs/requirements/derived.md
@@ -0,0 +1,161 @@
+# mojibake — Derived Requirements
+
+Software Level: **DAL-C** (DO-178C)
+
+DO-178C §5.1.1.b: requirements arising from design decisions that are not
+traceable to a higher-level requirement must be recorded and **flagged for
+review** by the safety assessment process. Each entry below states what forced
+it and what the reviewer must judge.
+
+---
+
+## MJB-DR-001 — UTF-8 is strict; declared encodings are transcoded
+
+**Status:** **resolved** — no longer an open conflict
+**Relates to:** the originating task statement, which listed a "non-UTF-8
+rejection path" as a robustness case.
+
+### How it stood
+
+Full Helix save fidelity transcodes rather than rejects, so the first
+implementation decoded every input leniently and replaced bad bytes with U+FFFD.
+That appeared to contradict the requested rejection path, and the contradiction
+was recorded here for the safety assessment. The flagged risk was concrete:
+substitution is invisible to the user, and saving a buffer that contains
+substituted characters writes U+FFFD over their data.
+
+### How it was resolved
+
+The requester directed that the UTF-8 rule be made an **exception**. The two
+behaviours are not in fact in conflict once split by whether the encoding is
+*known*:
+
+| Input | Behaviour | Rationale |
+|---|---|---|
+| BOM declares a non-UTF-8 encoding | Transcode; invalid sequences become U+FFFD | The encoding is known, so the substitution is reproducible on write and Helix fidelity is preserved |
+| UTF-8, declared or assumed | **Reject** with the failing byte offset | Nothing is known that would make a repair reproducible, so refusing is the only non-destructive answer |
+
+**Derived requirements.** MJB-LLR-113 governs the transcoding branch;
+**MJB-LLR-118** governs the UTF-8 exception. MJB-LLR-115 continues to require
+that a detected encoding and BOM round-trip through load and save.
+
+**Residual item for the reviewer.** A user who genuinely wants to inspect a
+binary file now cannot open it at all. No override (`moji --binary`, or a
+`:e!`-style force) is provided. This is a deliberate omission rather than an
+oversight: an override would reintroduce the lossy-save hazard through a
+different door, and should be added only with a corresponding requirement that
+makes such a buffer read-only.
+
+---
+
+## MJB-DR-002 — Byte offsets require explicit char-boundary defence
+
+**Status:** open — needs review
+**Forced by:** the choice of ropey 2.0 with `metric_chars` disabled.
+
+Under char indexing, an index cannot fall inside a character. Under byte
+indexing it can, and `Rope::insert`/`remove` panic when it does. This failure
+mode does not exist in the Helix design being ported and so is not covered by
+any HLR.
+
+**Derived requirement.** Every externally supplied byte offset shall be clamped
+into range and moved to a char boundary at construction (MJB-LLR-011), and
+change-set application shall validate operation boundaries and return an error
+rather than allow a rope panic (MJB-LLR-044).
+
+**Reviewer must judge:** whether returning an error is the correct response, or
+whether a violated boundary indicates a defect that should abort. Current choice
+is to return an error, consistent with MJB-HLR-018.
+
+---
+
+## MJB-DR-003 — Soft wrap is not implemented
+
+**Status:** open — needs review
+**Forced by:** scope.
+
+Helix's viewport carries a `vertical_offset` to address rows within a
+soft-wrapped line. With no soft wrap, one buffer line occupies exactly one
+screen row, so `vertical_offset` is always zero and is omitted from
+`ViewPosition` (MJB-LLR-090).
+
+**Derived requirement.** Lines wider than the viewport shall scroll horizontally
+rather than wrap (MJB-LLR-099).
+
+**Reviewer must judge:** that horizontal scrolling is acceptable for the intended
+use, and that reintroducing soft wrap later is understood to require reworking
+the viewport anchor.
+
+---
+
+## MJB-DR-004 — Keymap ownership is split between App and Buffer
+
+**Status:** open — needs review
+**Forced by:** the template's event routing, which delivers every key both to the
+application keymap and to every component.
+
+Leaving that routing intact would let a global binding such as `q` fire while the
+user types in insert mode. Ownership is therefore split: `App` owns only the
+`Global` mode and consumes matching keys; the buffer owns all other modes.
+
+**Derived requirement.** `App` shall not forward a key to components once the
+`Global` keymap has matched it (MJB-LLR-203).
+
+**Reviewer must judge:** that `Global` bindings are intentionally unreachable
+from every mode, and that placing `Ctrl-c` there is intended even though Helix
+binds `Ctrl-c` to comment-toggle in normal mode.
+
+---
+
+## MJB-DR-005 — Count is accumulated but consumed by few commands
+
+**Status:** open — needs review
+**Forced by:** MJB-LLR-155 being cheap to implement but not required by any
+command in the mandated binding set.
+
+No command in MJB-HLR-006 through MJB-HLR-013 requires a count. The count is
+accumulated and passed to command execution, where motions honour it and other
+commands ignore it.
+
+**Reviewer must judge:** whether silently ignoring a count on a command that does
+not use it is acceptable, or whether it should be reported as an error.
+
+---
+
+## MJB-DR-007 — UTF-16 must be encoded by hand
+
+**Status:** open — needs review
+**Found during:** implementation, by a failing round-trip test.
+
+`encoding_rs::Encoding::encode` is deliberately asymmetric. It decodes UTF-16
+but refuses to encode to it, silently substituting UTF-8 and reporting the
+substitution only through a return value that is easy to discard. Delegating
+the save path to it wrote UTF-8 bytes beneath a UTF-16 byte order mark — a
+file that no longer matched its own BOM.
+
+**Derived requirement.** UTF-16LE and UTF-16BE shall be encoded directly from
+`str::encode_utf16`, with explicit little- and big-endian byte order, and shall
+not be routed through `encoding_rs::Encoding::encode` (MJB-LLR-115).
+
+**Reviewer must judge:** whether the remaining encodings, which *are* delegated
+to `encoding_rs`, share any comparable asymmetry. The known set is UTF-16LE and
+UTF-16BE; single-byte and UTF-8 encodings round-trip correctly. Note that this
+defect was invisible to inspection and was caught only by a round-trip test —
+the same test shape should guard any encoding added later.
+
+---
+
+## MJB-DR-006 — Pre-release dependency under DAL-C
+
+**Status:** accepted by the requester — recorded for review
+**See:** [../reviews/library-selection.md](../reviews/library-selection.md)
+
+ropey 2.0.0-beta.1 is a pre-release, self-described as not battle-tested. It was
+selected over the stable 1.6.1 deliberately, with the trade-off stated.
+
+**Derived requirement.** The dependency shall be pinned to an exact version and
+reached only through `src/buffer/document.rs`, so that a replacement is confined
+to one module.
+
+**Reviewer must judge:** whether a pre-release dependency is acceptable for the
+intended deployment, and whether the confinement is in fact maintained.
diff --git a/docs/requirements/hlr.md b/docs/requirements/hlr.md
new file mode 100644
index 0000000..0db3d34
--- /dev/null
+++ b/docs/requirements/hlr.md
@@ -0,0 +1,151 @@
+# mojibake — High-Level Requirements
+
+Software Level: **DAL-C** (DO-178C)
+Scope: the file buffer component, its configuration, and the removal of the
+template widgets.
+
+ID scheme: `MJB-HLR-nnn`. Low-level requirements deriving from these are in
+[llr.md](llr.md); requirements the implementation needed that these did not
+anticipate are in [derived.md](derived.md). Traceability is in
+[../traceability/trace.md](../traceability/trace.md).
+
+---
+
+## File loading and representation
+
+**MJB-HLR-001 — File load from command line**
+The editor shall accept an optional file path as a positional command-line
+argument and load its contents into the buffer. When no path is supplied the
+editor shall present an empty buffer. When the path does not exist the editor
+shall present an empty buffer associated with that path, so that a subsequent
+write creates the file.
+
+**MJB-HLR-002 — Character encoding and byte order mark**
+The editor shall detect a byte order mark on load, decode the file contents
+using the detected encoding, and record both the encoding and the presence of
+the BOM so that a subsequent write reproduces them. In the absence of a BOM the
+editor shall decode as UTF-8.
+
+Where the encoding is declared by a byte order mark and is not UTF-8, byte
+sequences invalid in that encoding shall be decoded to replacement characters
+and shall not terminate the editor; the declared encoding makes such a
+substitution reproducible on write.
+
+**UTF-8 is an exception to the preceding paragraph.** Content decoded as UTF-8,
+whether declared by a byte order mark or assumed in its absence, shall be
+validated strictly. A file containing an invalid UTF-8 sequence shall be
+**refused**, with a diagnostic identifying the offset at which validation
+failed, and shall leave the file unmodified. Repairing such content would write
+the repair back over the user's data on the next save.
+
+**MJB-HLR-003 — Line ending detection and preservation**
+The editor shall detect the predominant line ending of a loaded file (LF, CRLF
+or CR), record it, and reproduce that line ending on write. A file whose line
+ending cannot be determined shall use the platform default.
+
+**MJB-HLR-004 — Rope text storage**
+The editor shall hold buffer text in a rope structure indexed by byte offset,
+such that insertion and deletion cost does not scale with total file size.
+
+## Selection and cursor
+
+**MJB-HLR-005 — Selection model**
+The editor shall represent the cursor as a selection range with an anchor and a
+head, both byte offsets into the rope. Ranges shall be half-open — inclusive of
+the lower bound and exclusive of the upper bound — regardless of whether the
+head precedes or follows the anchor. The visible block cursor shall occupy one
+grapheme cluster inward from the head.
+
+## Motion
+
+**MJB-HLR-006 — Character and line motions**
+The editor shall provide, in normal mode, motions by one grapheme cluster left
+and right and by one line up and down, bound by default to `h`, `l`, `k` and
+`j`. Motions shall not move outside the buffer bounds.
+
+**MJB-HLR-007 — Word motions select**
+The editor shall provide next-word-start, previous-word-start and
+next-word-end motions bound by default to `w`, `b` and `e`. Consistent with
+Helix and unlike Vim, each shall leave a selection spanning the traversed text
+rather than a collapsed cursor, so that a subsequent operator acts on that
+selection without operator-pending state.
+
+**MJB-HLR-008 — Goto commands**
+The editor shall provide goto commands for start of file, end of file, start of
+line and end of line, bound by default to the two-key sequences `gg`, `ge`,
+`gh` and `gl`. Multi-key sequences shall resolve deterministically and shall not
+depend on the interval between keystrokes.
+
+## Modification
+
+**MJB-HLR-009 — Insert-mode entry**
+The editor shall enter insert mode via commands that place the cursor before the
+selection, after the selection, at the first character of the line, at the end
+of the line, and on a newly opened line below or above the current line — bound
+by default to `i`, `a`, `I`, `A`, `o` and `O`.
+
+**MJB-HLR-010 — Text modification**
+The editor shall delete the current selection on `d` and shall delete the
+current selection and enter insert mode on `c`. In insert mode the editor shall
+insert typed printable characters at the cursor and shall delete the preceding
+character on backspace.
+
+**MJB-HLR-011 — Undo and redo**
+The editor shall express every buffer modification as a transaction and shall
+retain the inverse of each applied transaction, such that `u` reverts the most
+recent modification and `U` reapplies it. Undo when no modification remains and
+redo when no reverted modification remains shall be no-ops and shall not be
+errors.
+
+## Presentation
+
+**MJB-HLR-012 — Viewport pagination**
+The editor shall render only those lines intersecting the visible viewport. The
+work performed per frame shall be proportional to the viewport height and shall
+not scale with the number of lines in the buffer.
+
+**MJB-HLR-013 — Scrolling and paging**
+The editor shall keep the cursor within the viewport, maintaining a configurable
+scroll-off margin from the top and bottom edges, clamped so that the margin
+never exceeds half the viewport. The editor shall provide half-viewport paging
+bound by default to `Ctrl-u` and `Ctrl-d` and full-viewport paging bound by
+default to `Ctrl-b` and `Ctrl-f`; each shall move the cursor together with the
+viewport.
+
+**MJB-HLR-019 — Single-widget presentation**
+The buffer shall be the only widget the editor presents. The frame-rate counter
+and the placeholder widget inherited from the application template shall be
+removed, together with their registrations and configuration, leaving no
+unreachable code.
+
+## Configuration and input
+
+**MJB-HLR-014 — TOML configuration**
+The editor shall read its configuration from TOML. No other configuration format
+shall be accepted, and no parser for another format shall remain in the
+dependency graph.
+
+**MJB-HLR-015 — Config-driven modal keymap**
+Key bindings shall be defined in configuration and scoped by editor mode, with
+the bindings required by MJB-HLR-006 through MJB-HLR-013 supplied as built-in
+defaults that user configuration overrides per binding. The keymap shall support
+multi-key sequences, and shall support modes in which an unbound printable key
+carries a default meaning rather than being discarded.
+
+**MJB-HLR-016 — Command mode**
+The editor shall provide a command line entered with `:` supporting at minimum
+write, quit, write-and-quit, force-quit and force-write, using the command names
+and aliases of Helix.
+
+## Robustness
+
+**MJB-HLR-017 — File write**
+The editor shall write buffer contents to the associated path on command. The
+write shall preserve a symbolic link target rather than replacing the link,
+shall preserve file permissions, shall refuse to write a read-only path, and
+shall restore the previous contents if the write fails partway.
+
+**MJB-HLR-018 — Error handling**
+The editor shall not terminate abnormally in response to malformed input,
+malformed configuration, or a failed file operation. Such conditions shall be
+reported to the user and the editor shall remain usable.
diff --git a/docs/requirements/llr.md b/docs/requirements/llr.md
new file mode 100644
index 0000000..67dfe6e
--- /dev/null
+++ b/docs/requirements/llr.md
@@ -0,0 +1,160 @@
+# mojibake — Low-Level Requirements
+
+Software Level: **DAL-C** (DO-178C)
+
+Each LLR cites its parent HLR. Source items implementing an LLR carry a
+`// MJB-LLR-nnn` comment immediately above them. Tests exercising an LLR are
+named `mjb_llr_nnn_<description>`.
+
+All positions are **byte offsets** into the rope. `LT` denotes
+`ropey::LineType::LF_CR`, the line-break convention enabled by default.
+
+---
+
+## selection.rs — Range and Selection (MJB-HLR-005)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-001 | `Range` shall store `anchor` and `head` as byte offsets. |
+| MJB-LLR-002 | `Range::from()` shall return `min(anchor, head)`; `Range::to()` shall return `max(anchor, head)`. |
+| MJB-LLR-003 | `Range::is_empty()` shall return true exactly when `anchor == head`. |
+| MJB-LLR-004 | `Range::direction()` shall return `Backward` when `head < anchor` and `Forward` otherwise. |
+| MJB-LLR-005 | `Range::cursor(text)` shall return `prev_grapheme_boundary(text, head)` when `head > anchor`, and `head` otherwise. |
+| MJB-LLR-006 | `Range::put_cursor(text, byte_idx, extend)` with `extend == false` shall return a point range at `byte_idx`. |
+| MJB-LLR-007 | `Range::put_cursor(text, byte_idx, extend)` with `extend == true` shall retain the anchor, adjusting it by one grapheme when the range flips direction across it, and shall place the head one grapheme past `byte_idx` when the anchor precedes it. |
+| MJB-LLR-008 | `Range::line_range(text)` shall return the inclusive line-index span covered by the range. |
+| MJB-LLR-009 | `Selection` shall maintain the invariant that it contains exactly one range and that `primary_index` is zero. |
+| MJB-LLR-010 | `Selection::primary()` shall return the range at `primary_index`. |
+| MJB-LLR-011 | Constructing a `Range` shall clamp both offsets into `0..=text.len()` and shall move each to the nearest char boundary. |
+
+## grapheme.rs — Grapheme boundaries and width (MJB-HLR-005, MJB-HLR-013)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-020 | `prev_grapheme_boundary(text, byte_idx)` shall return the byte offset of the grapheme boundary preceding `byte_idx`, or `0` when none exists. |
+| MJB-LLR-021 | `next_grapheme_boundary(text, byte_idx)` shall return the byte offset of the grapheme boundary following `byte_idx`, or `text.len()` when none exists. |
+| MJB-LLR-022 | Grapheme boundary computation shall operate across rope chunk edges, producing the same result as if the text were contiguous. |
+| MJB-LLR-023 | `is_grapheme_boundary(text, byte_idx)` shall return whether `byte_idx` lies on a grapheme cluster boundary. |
+| MJB-LLR-024 | `grapheme_width(g)` shall return the terminal display width of a grapheme cluster, treating a tab as advancing to the next tab stop and treating zero-width and control characters as width zero. |
+| MJB-LLR-025 | `display_column(line, byte_idx)` shall return the display column of `byte_idx` within a line, accumulating grapheme widths rather than counting bytes. |
+
+## transaction.rs / history.rs — Edits and undo (MJB-HLR-010, MJB-HLR-011)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-040 | `Operation` shall have variants `Retain(usize)`, `Delete(usize)` and `Insert(String)`, whose counts are byte lengths. |
+| MJB-LLR-041 | `ChangeSet` shall record `len` (required document length before application) and `len_after` (document length after application). |
+| MJB-LLR-042 | `ChangeSet::apply(rope)` shall return an error, leaving the rope unmodified, when `rope.len() != self.len`. |
+| MJB-LLR-043 | `ChangeSet::apply(rope)` shall realise `Retain` by advancing the position, `Delete(n)` by `rope.remove(pos..pos + n)`, and `Insert(s)` by `rope.insert(pos, s)` followed by advancing. |
+| MJB-LLR-044 | `ChangeSet::apply` shall return an error when any operation boundary does not fall on a char boundary of the rope, rather than panicking inside the rope. |
+| MJB-LLR-045 | `ChangeSet::invert(original)` shall map `Retain(n)` to `Retain(n)`, `Delete(n)` to `Insert` of the corresponding slice of `original`, and `Insert(s)` to `Delete(s.len())`. |
+| MJB-LLR-046 | Applying a change set and then applying its inverse shall reproduce the original rope contents exactly. |
+| MJB-LLR-047 | `Transaction` shall pair a `ChangeSet` with an optional resulting `Selection`. |
+| MJB-LLR-048 | `Transaction::change(rope, changes)` shall build a change set from an iterator of `(from, to, Option<String>)` triples ordered by ascending `from`. |
+| MJB-LLR-049 | `Transaction::insert(rope, selection, text)` shall insert `text` at the cursor of the selection. |
+| MJB-LLR-050 | `Transaction::delete(rope, selection)` shall delete the span `from()..to()` of the selection's primary range. |
+| MJB-LLR-051 | `History::commit` shall push the pair (forward transaction, inverse transaction) and discard any reverted entries ahead of the cursor. |
+| MJB-LLR-052 | `History::undo` shall return the inverse of the entry preceding the cursor and decrement the cursor; when the cursor is at zero it shall return `None`. |
+| MJB-LLR-053 | `History::redo` shall return the forward transaction at the cursor and increment the cursor; when the cursor is at the end it shall return `None`. |
+
+## movement.rs — Motions (MJB-HLR-006, MJB-HLR-007, MJB-HLR-008)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-060 | `CharCategory` shall classify a char as `Eol`, `Whitespace`, `Word` or `Punctuation`; `Word` shall comprise alphanumerics and underscore. |
+| MJB-LLR-061 | `is_word_boundary(a, b)` shall return `categorize(a) != categorize(b)`. |
+| MJB-LLR-062 | `move_char_left` shall move the head one grapheme toward zero, producing a point range, and shall be a no-op at offset zero. |
+| MJB-LLR-063 | `move_char_right` shall move the head one grapheme toward the end, producing a point range, and shall be a no-op at the end of the buffer. |
+| MJB-LLR-064 | `move_line_up` and `move_line_down` shall move the cursor to the same display column on the adjacent line, clamping to that line's length, and shall be no-ops on the first and last line respectively. |
+| MJB-LLR-065 | `move_next_word_start` shall return a range whose anchor is the pre-motion cursor position and whose head is the start of the following word, so the result is a selection spanning the traversed text. |
+| MJB-LLR-066 | `move_prev_word_start` shall return a range spanning backward from the pre-motion cursor to the start of the preceding word. |
+| MJB-LLR-067 | `move_next_word_end` shall return a range spanning from the pre-motion cursor to the end of the following word. |
+| MJB-LLR-068 | Word motions shall skip line-ending characters and shall stop at a category transition as defined by MJB-LLR-061. |
+| MJB-LLR-069 | Word motions shall be no-ops when already at the corresponding buffer boundary. |
+| MJB-LLR-070 | `goto_file_start` shall place a point range at offset zero. |
+| MJB-LLR-071 | `goto_last_line` shall place a point range at the first offset of the last line. |
+| MJB-LLR-072 | `goto_line_start` shall place a point range at the first offset of the cursor's line. |
+| MJB-LLR-073 | `goto_line_end` shall place a point range at the offset of the line's last character, excluding its line terminator. |
+
+## view.rs — Viewport (MJB-HLR-012, MJB-HLR-013)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-090 | `ViewPosition` shall store `anchor`, the byte offset of the first visible line's start, and `horizontal_offset`, a display-column count. |
+| MJB-LLR-091 | `View::top_line(rope)` shall return `rope.byte_to_line_idx(anchor, LT)`. |
+| MJB-LLR-092 | `View::visible_lines(rope, height)` shall yield at most `height` lines beginning at the top line, obtained via `rope.lines_at(top, LT)`, touching no other line. |
+| MJB-LLR-093 | `ensure_cursor_in_view` shall clamp the top scroll-off margin to `min(scrolloff, (height - 1) / 2)` and the bottom margin to `min(scrolloff, height / 2)`. |
+| MJB-LLR-094 | `ensure_cursor_in_view` shall set the top line to `cursor_line - scrolloff_top` when the cursor line is above the top margin. |
+| MJB-LLR-095 | `ensure_cursor_in_view` shall set the top line to `cursor_line + scrolloff_bottom + 1 - height` when the cursor line is at or below the bottom margin. |
+| MJB-LLR-096 | `ensure_cursor_in_view` shall leave the anchor unchanged when the cursor lies within both margins. |
+| MJB-LLR-097 | The computed top line shall be clamped to `0..len_lines(LT)` and converted back to a byte anchor with `rope.line_to_byte_idx(top, LT)`. |
+| MJB-LLR-098 | `ensure_cursor_in_view` shall be a no-op when the viewport height is zero, rather than underflowing. |
+| MJB-LLR-099 | `ensure_horizontal_in_view` shall adjust `horizontal_offset` so the cursor's display column lies within `[offset, offset + width)`. |
+| MJB-LLR-100 | `page_cursor_half_up` and `page_cursor_half_down` shall move the cursor and the viewport by `height / 2` lines. |
+| MJB-LLR-101 | `page_up` and `page_down` shall move the cursor and the viewport by `height` lines. |
+| MJB-LLR-102 | Paging shall saturate at the first and last line rather than wrapping or underflowing. |
+
+## document.rs / encoding.rs / line_ending.rs (MJB-HLR-001..004)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-110 | `detect_bom(bytes)` shall recognise the UTF-8, UTF-16LE and UTF-16BE byte order marks and return the encoding and BOM length. |
+| MJB-LLR-111 | `Document::open(path)` shall decode file contents through the detected encoding into a rope, recording encoding and BOM presence. |
+| MJB-LLR-112 | `Document::open` shall produce an empty rope, with the path retained, when the path does not exist. |
+| MJB-LLR-113 | Decoding content whose encoding is declared by a byte order mark and is not UTF-8 shall substitute replacement characters for sequences invalid in that encoding, and shall not return an error. |
+| MJB-LLR-118 | Content decoded as UTF-8, whether BOM-declared or assumed, shall be validated strictly; an invalid sequence shall produce `DecodeError::InvalidUtf8` carrying the byte offset at which validation failed, and `Document::open` shall propagate it without modifying the file. |
+| MJB-LLR-114 | `LineEnding::detect(rope)` shall return the line ending of the first terminator present, and the platform default when the buffer contains none. |
+| MJB-LLR-115 | `Document::encode()` shall reproduce the recorded BOM, translate line terminators to the recorded line ending, and encode via the recorded encoding. |
+| MJB-LLR-116 | `Document` shall expose a `modified` flag, set on the first applied transaction and cleared on a successful write. |
+| MJB-LLR-117 | `Document::apply(transaction)` shall apply the change set to the rope, commit the inverse to history, update the selection, and set `modified`. |
+
+## save.rs — Write path (MJB-HLR-017)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-130 | The write target shall be the resolved symbolic link target when the path is a symbolic link, with a relative target joined onto the link's parent directory. |
+| MJB-LLR-131 | The write shall fail with `PermissionDenied`, before modifying anything, when the target exists and is not writable. |
+| MJB-LLR-132 | The write shall fail with a message directing the user to `:w!` when the target's parent directory does not exist; with force, the parent shall be created recursively. |
+| MJB-LLR-133 | `must_copy` shall be true when the target is a symbolic link or has a hard link count greater than one. |
+| MJB-LLR-134 | A backup shall be created in the target's own directory, by copy when `must_copy` and by rename otherwise, so that no rename crosses a filesystem boundary. |
+| MJB-LLR-135 | When the write fails and a backup exists, the backup shall be restored — copied back when `must_copy`, renamed back otherwise. |
+| MJB-LLR-136 | When the write succeeds, permissions shall be copied from the backup onto the target and the backup shall be removed. |
+| MJB-LLR-137 | A successful write shall clear the document's `modified` flag. |
+
+## keymap.rs / command.rs — Input (MJB-HLR-015, MJB-HLR-016)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-150 | `Keymap::new` shall precompute the set of all proper prefixes of every bound key sequence, per mode. |
+| MJB-LLR-151 | On a key that completes a bound sequence, resolution shall yield `Matched` and clear the pending sequence. |
+| MJB-LLR-152 | On a key extending a known prefix without completing a binding, resolution shall yield `Pending` and retain the sequence. |
+| MJB-LLR-153 | On a key that neither completes a binding nor extends a known prefix, resolution shall yield `Cancelled` carrying the accumulated keys, and clear the pending sequence. |
+| MJB-LLR-154 | Resolution shall not depend on elapsed time; no pending sequence shall be discarded on a timer. |
+| MJB-LLR-155 | In normal and select modes with no pending sequence, `1`–`9`, and `0` once a count is in progress, shall accumulate a decimal count consumed by the next matched command. |
+| MJB-LLR-156 | In insert mode a `Cancelled` result carrying a single printable character with neither Control nor Alt held shall be interpreted as inserting that character. |
+| MJB-LLR-157 | The `Command` enum shall have one unit variant per bound editor command and shall deserialize from its variant name. |
+| MJB-LLR-158 | Command mode shall route keys to a line editor rather than the keymap, accepting printable characters, backspace, `Enter` to submit and `Escape` to cancel. |
+| MJB-LLR-159 | The command line shall parse `w`/`write`, `q`/`quit`, `wq`/`x`/`write-quit`, `q!`/`quit!` and `w!`/`write!`, and shall report an unrecognised command without terminating. |
+| MJB-LLR-160 | `q` with unsaved modifications shall be refused with a message; `q!` shall discard them. |
+
+## config.rs (MJB-HLR-014, MJB-HLR-018)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-180 | The built-in default configuration shall be the compiled-in contents of `.config/config.toml`, parsed with `toml::from_str`. |
+| MJB-LLR-181 | The only configuration file consulted shall be `config.toml` in the configuration directory. |
+| MJB-LLR-182 | User bindings shall override built-in defaults per individual binding, leaving unlisted defaults in force. |
+| MJB-LLR-183 | An unparsable key-sequence string in user configuration shall produce a recoverable error identifying the offending string, and shall not panic. |
+| MJB-LLR-184 | `Mode` shall have variants `Normal`, `Insert`, `Select`, `Command` and `Global`, deserialized from their lower-case names. |
+| MJB-LLR-185 | Configuration shall supply `scrolloff` and `insert_final_newline`, with defaults of 5 and true. |
+
+## components/buffer.rs / app.rs (MJB-HLR-012, MJB-HLR-019)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-200 | The buffer component shall render only the lines yielded by `View::visible_lines`. |
+| MJB-LLR-201 | The buffer component shall render the block cursor at the primary range's cursor position and shall style the selection span distinctly. |
+| MJB-LLR-202 | The buffer component shall reserve the final viewport row for a status line showing mode, path, modified indicator and cursor position. |
+| MJB-LLR-203 | `App` shall consult the `Global` keymap first and shall not forward a key to components when that lookup matches. |
+| MJB-LLR-204 | `App` shall register no component other than the buffer. |
+| MJB-LLR-205 | `App` shall not retain per-tick key state, the chord-timeout mechanism having been removed. |
diff --git a/docs/reviews/code-checklist.md b/docs/reviews/code-checklist.md
new file mode 100644
index 0000000..ce16b46
--- /dev/null
+++ b/docs/reviews/code-checklist.md
@@ -0,0 +1,202 @@
+# Source code review checklist
+
+Software Level: **DAL-C** (DO-178C)
+Reviewer: author — **review without independence**, which DO-178C permits at
+Software Level C.
+Scope: `src/buffer/**`, `src/components/buffer.rs`, `src/config.rs`,
+`src/app.rs`, `src/cli.rs`, `src/action.rs`
+
+Derived from DO-178C Table A-5 (verification of the source code), reduced to
+the objectives that apply at DAL-C.
+
+---
+
+## Compliance and traceability
+
+| # | Check | Result |
+|---|---|---|
+| 1 | Source complies with the low-level requirements | **Pass** |
+| 2 | Source is traceable to the LLRs | **Pass** — 98/98 tagged `// MJB-LLR-nnn`, verified by `grep`, not inspection |
+| 3 | Source complies with the software architecture | **Pass** — buffer core has no `ratatui` dependency and is testable headless |
+| 4 | Source is verifiable | **Pass** — 96.21% statement coverage of `src/buffer/**` |
+| 5 | Source conforms to a coding standard | **Pass** — `cargo clippy --all-targets -- -D warnings` is clean |
+| 6 | Source is accurate and consistent | **Pass** — see the defects found, below |
+
+Reproduce objectives 2, 4 and 5:
+
+```bash
+cargo clippy --all-targets -- -D warnings # clean
+cargo test # 294 passing
+cargo llvm-cov --summary-only test
+```
+
+---
+
+## No dead code
+
+| # | Check | Result |
+|---|---|---|
+| 7 | Removed widgets leave no unreachable code | **Pass** |
+| 8 | No `allow(dead_code)` masks anything | **Pass** |
+| 9 | Removed dependencies are gone from the graph | **Pass** |
+
+Evidence:
+
+```bash
+# The widget modules are deleted, not merely unregistered.
+ls src/components/ # buffer.rs only
+grep -rni "FpsCounter\|mod fps\|mod home" src/ # only a comment in app.rs
+
+# No dead-code suppression anywhere in the crate.
+grep -rn "allow(dead_code)" src/ # nothing
+
+# The chord-timeout state is gone, not just unused.
+grep -rn "last_tick_key_events" src/ # nothing
+
+# JSON is out of the dependency graph, not merely out of the call path.
+cargo tree | grep -iE "json|yaml|ini\b" # nothing
+```
+
+Three items of inherited dead code were removed during review, beyond the two
+widgets the task named:
+
+- `Action::Help` — declared by the application template, never produced or
+ handled.
+- `Command::leaves_insert_mode` — written by me during implementation, then
+ never called. Caught because `buffer/command.rs` showed 0% coverage.
+- `#![allow(dead_code)]` in `src/tui.rs` — verified to mask nothing before
+ removal, by deleting it and re-running clippy.
+
+---
+
+## Robustness and error handling
+
+| # | Check | Result |
+|---|---|---|
+| 10 | No panic on malformed input | **Pass** — invalid UTF-8 is a reported error (MJB-LLR-118); a declared non-UTF-8 encoding substitutes U+FFFD (MJB-LLR-113) |
+| 11 | No panic on malformed configuration | **Pass** — a bad key sequence is a recoverable error naming itself (MJB-LLR-183) |
+| 12 | No panic on a failed file operation | **Pass** — every `save` path returns `Result` |
+| 13 | Boundary conditions handled | **Pass** — empty buffer, no trailing newline, zero-height viewport, scrolloff exceeding the viewport, undo past history start |
+| 14 | Integer overflow/underflow guarded | **Pass** — `saturating_*` throughout `view.rs` and `movement.rs` |
+
+`unwrap`/`expect`/`panic!` outside test code is confined to two files, neither
+in the buffer core:
+
+| File | Count | Assessment |
+|---|---|---|
+| `src/errors.rs` | 1 | Inside the panic hook itself |
+| `src/tui.rs` | 2 | Template code in `Drop`; pre-existing |
+
+`src/buffer/**` contains **zero** panicking calls outside `#[cfg(test)]`.
+
+The one genuinely new failure mode created by this change — a byte offset
+landing inside a character, which makes ropey panic — is guarded in two places:
+`Range::clamped` (MJB-LLR-011) snaps every externally supplied offset onto a
+char boundary, and `ChangeSet::apply` (MJB-LLR-044) validates every operation
+boundary *before* mutating, so a rejected change leaves the rope untouched
+rather than half-applied.
+
+---
+
+## Defects found and fixed during review
+
+Eight defects were found by review and test activity rather than by writing
+the code. Each records how it was found, because that tells the next person
+which verification activities are actually working (docs/process.md §6, Rule 4).
+
+1. **Every shifted key binding was shadowed by its lowercase twin.**
+ `parse_key_event` lowercased the entire key string, so `"<A>"` and `"<a>"`
+ both parsed to `KeyCode::Char('a')`. `A`, `I`, `O`, `U`, `W`, `B` and `E`
+ were all unreachable, and whichever binding won the hash map ran instead —
+ so `a` silently invoked `InsertAtLineEnd`. Found by seven integration tests
+ failing at once. Fixed by preserving the final token's case and inferring
+ `SHIFT` from an uppercase letter, matching what crossterm reports.
+
+2. **UTF-16 files would have been corrupted on save.**
+ `encoding_rs::Encoding::encode` refuses to encode to UTF-16 and silently
+ substitutes UTF-8, so a UTF-16 document would have been written as UTF-8
+ bytes beneath a UTF-16 BOM. Invisible to inspection; found only by a
+ byte-comparing round-trip test. Fixed by encoding UTF-16 directly from
+ `str::encode_utf16`. Recorded as **MJB-DR-007**.
+
+3. **`o` on a file with no trailing newline put the cursor in the wrong place.**
+ `open_below` anchored to the next line's start, which does not exist on a
+ final unterminated line. Found by clippy's `if_same_then_else` flagging the
+ suspicious `if above { at } else { at }`, then confirmed by a robustness
+ test. Fixed by anchoring to the line's content end.
+
+4. **`a` appended in the wrong position.** Helix's normal-mode cursor is a
+ one-grapheme range, so `a` lands past that grapheme; mojibake's empty range
+ has `to() == from()`. Fixed by stepping forward one grapheme when the range
+ is empty.
+
+A later quality pass over the same code found four more. Note that three were
+in paths no test covered and one was in inherited template code that only
+became reachable when the buffer started consuming `[styles]`.
+
+5. **Counted word motion was broken.** Each iteration of the count loop
+ re-derived its start from the partial range's *cursor*, which for a forward
+ range is one grapheme behind the head. `2w` on `"aaa bbb ccc"` therefore
+ returned `Range(3, 4)` — anchor in the wrong place, head stalled inside the
+ first gap — instead of `Range(0, 8)`. Found by restructuring `word_move`
+ into three named functions and checking each against a worked example; no
+ test covered a count greater than one on a word motion. Fixed by holding the
+ anchor fixed and advancing only the head. Regression tests
+ `mjb_llr_065_counted_next_word_start_advances_once_per_count` and siblings.
+
+6. **Three panics reachable from user configuration.** `parse_color` indexed
+ `rgb` operands unchecked and added into `u8` unguarded, so `"rgb"`,
+ `"rgb1"`, `"gray99"` and `"rgb999"` in a `[styles]` block aborted the
+ editor at startup — a direct violation of MJB-HLR-018. Found by running
+ clippy with `-W clippy::pedantic` and following up the
+ `cast_possible_truncation` hits by hand. Fixed by bounds-checked parsing and
+ clamping to the actual xterm palette ranges. Regression test
+ `mjb_llr_183_malformed_colours_never_panic`.
+
+7. **Bright colours were indistinguishable from their base colours.** The
+ template wrote `c.wrapping_shl(8)`, which on a `u8` masks the shift to
+ `8 % 8 == 0` and returns the value unchanged. Found while fixing defect 6.
+ Fixed to the ANSI convention of base + 8, saturating.
+
+8. **The modified flag stayed set after undoing back to the saved state.** A
+ latched `bool` cannot distinguish "edited" from "edited and reverted", so a
+ buffer byte-identical to its file still refused `:q`. Found by review of the
+ flag's lifecycle rather than by a test. Replaced with a comparison against
+ the history revision the file was written at.
+
+ The first attempt at that fix was itself wrong and worth recording: using
+ the history *cursor* as the revision means saving at depth 3, undoing, then
+ making a **different** edit returns to depth 3 while the content differs —
+ reporting clean when it is not. Caught by reasoning through the case before
+ committing, and fixed by giving each history entry a unique, never-reused
+ id. Regression test `mjb_llr_116_revision_is_not_stack_depth`.
+
+---
+
+## Architecture
+
+| # | Check | Result |
+|---|---|---|
+| 15 | The pre-release rope dependency is confined | **Pass** — `ropey::Rope` is owned only by `buffer/document.rs`; other modules take `RopeSlice` parameters (MJB-DR-006) |
+| 16 | The buffer core is independent of the UI | **Pass** — no `ratatui` import under `src/buffer/` |
+| 17 | Pagination is structural, not an optimisation | **Pass** — `View::visible_lines` is the only path to line content, and is bounded by viewport height |
+
+Objective 17 is the one most worth stating plainly: MJB-HLR-012 is satisfied
+because there is no code path that walks the document, not because a fast path
+was added. `mjb_llr_200_renders_only_the_visible_window` checks the observable
+consequence, and `mjb_llr_092_scrolling_a_large_file_stays_responsive` checks
+that 500 half-page scrolls over a 200,000-line file stay well inside a time
+budget a full scan could not meet.
+
+---
+
+## Summary
+
+Code review **complete**. Eight defects found and fixed; three items of dead
+code removed; no `allow(dead_code)` remains; clippy clean at `-D warnings`;
+294 tests passing; 96.21% statement coverage of the buffer core.
+
+Outstanding items are the six open derived requirements in
+`docs/requirements/derived.md`, which need a safety-assessment judgement rather
+than a code change. MJB-DR-001 is resolved: UTF-8 is now validated strictly and
+malformed input is refused, so the lossy-save hazard it flagged is gone.
diff --git a/docs/reviews/library-selection.md b/docs/reviews/library-selection.md
new file mode 100644
index 0000000..3b9efa2
--- /dev/null
+++ b/docs/reviews/library-selection.md
@@ -0,0 +1,98 @@
+# Library selection record
+
+Software Level: **DAL-C** (DO-178C)
+Reviewed by: author (independence not required at DAL-C)
+
+Records the non-obvious dependency choices for the buffer component and the
+reasoning behind each, so a reviewer can judge them without re-deriving the
+trade-offs.
+
+---
+
+## ropey 2.0.0-beta.1 — a pre-release dependency
+
+**Decision.** ropey 2.0.0-beta.1, default features, **byte-indexed**.
+`metric_chars` is deliberately **not** enabled.
+
+**Alternatives considered.**
+
+| Option | Assessment |
+|---|---|
+| ropey 1.6.1 (stable) | Char-indexed by default; exactly what Helix pins, so Helix's algorithms port near-verbatim. The conservative choice. |
+| 2.0.0-beta.1 + `metric_chars` | Keeps the char API, but re-adds the per-node metadata 2.0 exists to remove — the beta risk without the benefit. |
+| **2.0.0-beta.1, byte-indexed (selected)** | Lowest memory and fastest edits; requires translating Helix's algorithms into byte offsets rather than porting them. |
+
+**Known risk.** The crate is self-described as "not battle-tested like Ropey
+1.x", with minor breaking API changes possible before release. This was raised
+before implementation and the requester selected it anyway; the choice is
+theirs and is recorded here rather than re-litigated.
+
+**Mitigations in force.**
+
+1. The version is pinned exactly in `Cargo.toml`.
+2. `ropey::Rope` is *owned* only by `src/buffer/document.rs`; other modules
+ receive `RopeSlice` parameters. Replacing the rope is therefore confined to
+ one module. (MJB-DR-006)
+3. The buffer core carries 93.85% statement coverage against invariants we
+ assert ourselves, rather than trusting the library's own guarantees.
+
+**Consequence the reviewer must accept.** Byte indexing admits a failure mode
+char indexing cannot express: an offset landing inside a character, which makes
+`Rope::insert`/`remove` panic. This is addressed by MJB-LLR-011 (clamp and snap
+at construction) and MJB-LLR-044 (validate before mutating). See MJB-DR-002.
+
+---
+
+## encoding_rs — asymmetric, and the asymmetry matters
+
+**Decision.** Used for decoding all encodings and for encoding everything
+*except* UTF-16, which is encoded by hand.
+
+**Why.** `encoding_rs::Encoding::encode` will not encode to UTF-16; it silently
+substitutes UTF-8 and signals the substitution only through a return value that
+is easy to discard. Delegating the save path to it wrote UTF-8 bytes beneath a
+UTF-16 byte order mark — a file inconsistent with its own BOM.
+
+This was **not caught by inspection**. It was caught by a round-trip test
+(`mjb_llr_115_utf16le_round_trips`) that decoded, re-encoded, and compared
+bytes. Any encoding added later should be guarded by the same test shape.
+
+Recorded as MJB-DR-007.
+
+---
+
+## unicode-segmentation and unicode-width
+
+**Decision.** `unicode_segmentation::GraphemeCursor` for cluster boundaries;
+`unicode_width` for terminal display width.
+
+**Why.** `GraphemeCursor` is chunk-aware: it reports `PrevChunk`/`NextChunk`/
+`PreContext` when a cluster straddles a fragment edge, which pairs exactly with
+ropey's `chunk(byte_idx) -> (&str, chunk_start)`. Clusters spanning rope chunk
+boundaries therefore resolve correctly (MJB-LLR-022) without materialising the
+text. This is the same pairing Helix uses.
+
+`unicode_width` is required because byte length, character count, and display
+width are three different numbers; the cursor must track the third. A CJK
+character occupies two terminal columns and three UTF-8 bytes.
+
+---
+
+## thiserror
+
+**Decision.** Used for error types in the buffer core.
+
+**Why.** MJB-HLR-018 forbids abnormal termination, so every failure path must
+carry a reportable message. `thiserror` generates `Display` and `From` without
+runtime cost or a dynamic error type.
+
+---
+
+## config, with default features disabled
+
+**Decision.** `config = { default-features = false, features = ["toml"] }`.
+
+**Why.** MJB-HLR-014 requires that no parser for another format remain in the
+dependency graph. Removing the `json5` *call site* would not satisfy that —
+the crate's default features pull `json5` in regardless. Disabling default
+features is what actually removes it, and is verifiable with `cargo tree`.
diff --git a/docs/reviews/requirements-checklist.md b/docs/reviews/requirements-checklist.md
new file mode 100644
index 0000000..c6ced28
--- /dev/null
+++ b/docs/reviews/requirements-checklist.md
@@ -0,0 +1,112 @@
+# Requirements review checklist
+
+Software Level: **DAL-C** (DO-178C)
+Reviewer: author — **review without independence**, which DO-178C permits at
+Software Level C.
+Scope: `docs/requirements/{hlr,llr,derived}.md`
+
+Derived from DO-178C Table A-3 (verification of software requirements) and
+Table A-4 (verification of the design), reduced to the objectives that apply at
+DAL-C.
+
+---
+
+## High-level requirements
+
+| # | Check | Result |
+|---|---|---|
+| 1 | Every HLR is traceable to a request in the originating task statement | **Pass** — the statement's scope items map to MJB-HLR-001…019 |
+| 2 | HLRs are accurate and unambiguous | **Pass** with one note, below |
+| 3 | HLRs are verifiable — each states an observable behaviour | **Pass** |
+| 4 | HLRs do not specify implementation | **Pass**, except MJB-HLR-004 and MJB-HLR-014, where the rope and the TOML format were themselves requested |
+| 5 | HLRs conform to a consistent standard | **Pass** — "shall" throughout, one behaviour per requirement |
+| 6 | Conflicting requirements are identified and resolved | **Pass** — one conflict found, see below |
+
+**Note on objective 2.** MJB-HLR-007 needed rewording during review. It
+originally read "shall provide next-word-start … motions", which reads as a
+cursor move and matches the wording of Helix's own keymap documentation. That
+wording is misleading: Helix's `move_next_word_start` returns a *range*. The
+requirement now states the selection behaviour explicitly, because the
+misreading would have produced Vim semantics that pass a naive test.
+
+**Conflict found and resolved (objective 6).** The task statement asked for a
+"non-UTF-8 rejection path" *and* for full Helix save fidelity. Helix transcodes
+via `encoding_rs` rather than rejecting, and the two initially looked mutually
+exclusive; the conflict was recorded as **MJB-DR-001** and flagged.
+
+It is now **resolved**, on the requester's direction to make UTF-8 an exception.
+Splitting the behaviour by whether the encoding is *known* satisfies both
+requests: a BOM-declared non-UTF-8 encoding is transcoded, because the
+substitution is reproducible on write (MJB-LLR-113); UTF-8 is validated strictly
+and refused when malformed, because nothing is known that would make a repair
+reproducible (MJB-LLR-118). The lossy-save hazard that motivated the flag is
+eliminated rather than accepted.
+
+---
+
+## Low-level requirements
+
+| # | Check | Result |
+|---|---|---|
+| 7 | Every LLR traces to a parent HLR | **Pass** — `docs/traceability/trace.md`, HLR → LLR table |
+| 8 | Every LLR is traceable to source | **Pass** — 98/98 tagged, verified by `grep`, not by inspection |
+| 9 | LLRs are verifiable | **Pass** — 98/98 have a test named for them |
+| 10 | LLRs are consistent with the HLRs they derive from | **Pass** |
+| 11 | Algorithms are accurate | **Pass** with one caveat, below |
+| 12 | LLRs describe behaviour, not code structure | **Pass** |
+
+**Caveat on objective 11.** The viewport and word-motion algorithms were
+translated from Helix, not ported: Helix indexes by character and mojibake by
+byte. Every offset therefore changed meaning. This is the highest-risk area of
+the change and is why `buffer/view.rs` (99.57%) and `buffer/movement.rs`
+(97.98%) carry the highest coverage in the crate.
+
+---
+
+## Derived requirements
+
+| # | Check | Result |
+|---|---|---|
+| 13 | Derived requirements are identified as such | **Pass** — seven, in `derived.md` |
+| 14 | Each states what forced it | **Pass** |
+| 15 | Each is flagged for the safety assessment | **Pass** — every entry carries a "Reviewer must judge" clause |
+| 16 | Derived requirements do not silently weaken an HLR | **Pass** — MJB-DR-001 previously weakened the originating request; it has since been resolved so that both the transcoding and rejection behaviours hold, each in its own domain |
+
+Seven derived requirements were recorded, of which **one (MJB-DR-001) is now
+resolved** and six remain open. Two were found only during implementation and
+could not have been anticipated:
+
+- **MJB-DR-002** — byte indexing admits offsets inside a character, a failure
+ mode char indexing cannot express.
+- **MJB-DR-007** — `encoding_rs` decodes UTF-16 but silently refuses to encode
+ it, substituting UTF-8. Found by a round-trip test, **not** by inspection.
+
+---
+
+## Open items for the safety assessment
+
+**Six of seven** derived requirements remain open; MJB-DR-001 is resolved. The
+most consequential remaining:
+
+1. **MJB-DR-006** — ropey 2.0.0-beta.1 is a pre-release under a DAL-C
+ classification. Raised before implementation, accepted by the requester,
+ mitigated by confining the dependency to one module.
+2. **MJB-DR-007** — `encoding_rs` refuses to encode UTF-16 and substitutes
+ UTF-8 silently. Worked around; the reviewer must judge whether any other
+ delegated encoding shares the asymmetry.
+3. **MJB-DR-002** — byte indexing admits offsets inside a character. Guarded in
+ two places; the reviewer must judge whether returning an error is right where
+ a violated boundary may instead indicate a defect.
+
+A residual item is noted under MJB-DR-001: with UTF-8 now strict, a binary file
+cannot be opened at all, and no override is provided. That omission is
+deliberate and its rationale is recorded, but it is a usability decision a
+reviewer may want to revisit.
+
+---
+
+## Summary
+
+Requirements review **complete**. 19 HLRs, 98 LLRs, 7 derived requirements.
+One requirements conflict found and **resolved**; one HLR reworded for accuracy;
+six derived requirements outstanding for the safety assessment.
diff --git a/docs/traceability/trace.md b/docs/traceability/trace.md
new file mode 100644
index 0000000..1b02e71
--- /dev/null
+++ b/docs/traceability/trace.md
@@ -0,0 +1,287 @@
+# mojibake — Traceability Matrix
+
+Software Level: **DAL-C** (DO-178C)
+
+Bidirectional trace: **HLR → LLR → source → test**. Source items carry a
+`// MJB-LLR-nnn` comment; tests are named `mjb_llr_nnn_<description>` so this
+matrix can be checked mechanically:
+
+```bash
+# every LLR tagged in source
+grep -rho 'MJB-LLR-[0-9]\+' src/ | sort -u
+# every LLR exercised by a test
+grep -rho 'mjb_llr_[0-9]\+' src/ tests/ | sort -u
+```
+
+Paths are relative to the repository root.
+
+---
+
+## HLR → LLR
+
+| HLR | Subject | LLRs |
+|---|---|---|
+| MJB-HLR-001 | File load from command line | 111, 112 |
+| MJB-HLR-002 | Encoding and BOM | 110, 111, 113, 115, 118 |
+| MJB-HLR-003 | Line ending preservation | 114, 115 |
+| MJB-HLR-004 | Rope text storage | 090–092, 117 |
+| MJB-HLR-005 | Selection model | 001–011, 020–025 |
+| MJB-HLR-006 | Character and line motion | 062, 063, 064 |
+| MJB-HLR-007 | Word motions select | 060, 061, 065–069 |
+| MJB-HLR-008 | Goto commands | 070–073, 150–154 |
+| MJB-HLR-009 | Insert-mode entry | 049, 117 |
+| MJB-HLR-010 | Text modification | 040–050, 117 |
+| MJB-HLR-011 | Undo and redo | 045, 046, 051–053 |
+| MJB-HLR-012 | Viewport pagination | 090–092, 200 |
+| MJB-HLR-013 | Scrolling and paging | 093–102 |
+| MJB-HLR-014 | TOML configuration | 180, 181 |
+| MJB-HLR-015 | Config-driven modal keymap | 150–157, 182, 184 |
+| MJB-HLR-016 | Command mode | 158, 159, 160 |
+| MJB-HLR-017 | File write | 130–137 |
+| MJB-HLR-018 | Error handling | 042, 044, 118, 183 |
+| MJB-HLR-019 | Single-widget presentation | 200–205 |
+
+---
+
+## LLR → source → test
+
+### Selection (`src/buffer/selection.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 001 | `Range` | `mjb_llr_001_offsets_are_byte_indices` |
+| 002 | `Range::from`, `Range::to` | `mjb_llr_002_from_and_to_ignore_direction` |
+| 003 | `Range::is_empty` | `mjb_llr_003_is_empty` |
+| 004 | `Range::direction` | `mjb_llr_004_direction` |
+| 005 | `Range::cursor` | `mjb_llr_005_cursor_steps_back_on_forward_range`, `mjb_llr_005_cursor_respects_grapheme_clusters` |
+| 006 | `Range::put_cursor` | `mjb_llr_006_put_cursor_without_extend_collapses` |
+| 007 | `Range::put_cursor` | `mjb_llr_007_put_cursor_with_extend_keeps_anchor`, `mjb_llr_007_put_cursor_extend_flips_direction`, `mjb_llr_007_select_mode_motions_extend` |
+| 008 | `Range::line_range` | `mjb_llr_008_line_range` |
+| 009 | `Selection` | `mjb_llr_009_selection_invariant` |
+| 010 | `Selection::primary` | `mjb_llr_010_primary_round_trips` |
+| 011 | `Range::clamped` | `mjb_llr_011_clamped_snaps_into_bounds_and_onto_char_boundary`, `mjb_llr_011_motions_at_boundaries_never_leave_the_buffer` |
+
+### Graphemes (`src/buffer/grapheme.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 020 | `prev_grapheme_boundary` | `mjb_llr_020_prev_boundary_saturates_at_zero`, `mjb_llr_020_combining_mark_is_one_cluster` |
+| 021 | `next_grapheme_boundary` | `mjb_llr_021_next_boundary_saturates_at_end`, `mjb_llr_021_multibyte_advances_whole_char` |
+| 022 | chunk-walking loops in both | `mjb_llr_022_boundaries_resolve_across_chunk_edges` |
+| 023 | `is_grapheme_boundary` | `mjb_llr_023_boundary_detection` |
+| 024 | `grapheme_width` | `mjb_llr_024_widths` |
+| 025 | `display_column` | `mjb_llr_025_display_column_counts_width_not_bytes`, `mjb_llr_025_display_column_tab_expands` |
+
+### Transactions and history (`src/buffer/transaction.rs`, `history.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 040 | `Operation` | `mjb_llr_040_operation_counts_are_byte_lengths` |
+| 041 | `ChangeSet` | `mjb_llr_041_changeset_records_both_lengths`, `mjb_llr_041_empty_changeset_reports_equal_lengths` |
+| 042 | `ChangeSet::apply` length check | `mjb_llr_042_length_mismatch_is_rejected`, `mjb_llr_042_rejected_change_leaves_rope_untouched` |
+| 043 | `ChangeSet::apply` | `mjb_llr_043_apply_insert_and_delete` |
+| 044 | `ChangeSet::apply` boundary check | `mjb_llr_044_non_char_boundary_errors_rather_than_panics` |
+| 045 | `ChangeSet::invert` | `mjb_llr_045_invert_maps_each_operation` |
+| 046 | `apply` + `invert` | `mjb_llr_046_apply_then_invert_round_trips` |
+| 047 | `Transaction` | `mjb_llr_047_transaction_carries_a_selection` |
+| 048 | `Transaction::change` | `mjb_llr_048_multiple_ordered_changes` |
+| 049 | `Transaction::insert` | `mjb_llr_049_insert_at_cursor` |
+| 050 | `Transaction::delete` | `mjb_llr_050_delete_selection_span`, `mjb_llr_050_d_with_an_empty_selection_deletes_one_grapheme`, `mjb_llr_050_d_on_an_empty_buffer_is_a_noop` |
+| 051 | `History::commit` | `mjb_llr_051_commit_then_undo_then_redo`, `mjb_llr_051_commit_after_undo_discards_the_redo_branch`, `mjb_llr_051_undo_restores_typed_text` |
+| 052 | `History::undo`, `Document::undo` | `mjb_llr_052_undo_past_start_is_a_noop`, `mjb_llr_052_undo_past_history_start_is_a_noop`, `mjb_llr_052_undo_past_history_start_is_safe`, `mjb_llr_052_u_undoes_a_deletion` |
+| 053 | `History::redo`, `Document::redo` | `mjb_llr_053_redo_past_end_is_a_noop`, `mjb_llr_053_redo_past_end_is_safe`, `mjb_llr_053_capital_u_redoes` |
+
+### Movement (`src/buffer/movement.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 060 | `CharCategory`, `categorize_char` | `mjb_llr_060_categories` |
+| 061 | `is_word_boundary` | `mjb_llr_061_word_boundary_is_category_change` |
+| 062 | `move_char_left` | `mjb_llr_062_move_char_left_stops_at_zero`, `mjb_llr_062_h_and_l_move_by_one_grapheme`, `mjb_llr_062_h_at_start_of_buffer_is_a_noop` |
+| 063 | `move_char_right` | `mjb_llr_063_move_char_right_stops_at_end`, `mjb_llr_063_move_char_right_skips_whole_multibyte_char`, `mjb_llr_063_l_at_end_of_buffer_is_a_noop` |
+| 064 | `move_vertically` | `mjb_llr_064_vertical_motion_preserves_column`, `mjb_llr_064_vertical_motion_clamps_to_short_line`, `mjb_llr_064_vertical_motion_is_noop_at_edges`, `mjb_llr_064_j_and_k_move_between_lines` |
+| 065 | `next_word_start` | `mjb_llr_065_next_word_start_produces_a_selection`, `mjb_llr_065_w_leaves_a_selection`, `mjb_llr_065_w_then_d_deletes_the_word`, `mjb_llr_065_counted_next_word_start_advances_once_per_count` |
+| 066 | `prev_word_start` | `mjb_llr_066_prev_word_start_spans_backward`, `mjb_llr_066_b_selects_backward`, `mjb_llr_066_counted_prev_word_start_advances_once_per_count` |
+| 067 | `next_word_end` | `mjb_llr_067_next_word_end_spans_the_word`, `mjb_llr_067_e_selects_to_the_word_end`, `mjb_llr_067_counted_next_word_end_advances_once_per_count` |
+| 068 | `is_separator` and the skip loops in the three word functions | `mjb_llr_068_word_motion_stops_at_punctuation`, `mjb_llr_068_long_word_motion_absorbs_punctuation`, `mjb_llr_068_word_motion_crosses_line_endings`, `mjb_llr_068_capital_w_treats_punctuation_as_word_characters` |
+| 069 | `word_move` boundary guards | `mjb_llr_069_word_motion_is_noop_at_boundaries`, `mjb_llr_069_w_at_end_of_buffer_is_a_noop`, `mjb_llr_069_counted_motion_saturates_at_the_buffer_end` |
+| 070 | `goto_file_start` | `mjb_llr_070_goto_file_start`, `mjb_llr_070_gg_goes_to_file_start` |
+| 071 | `goto_last_line` | `mjb_llr_071_goto_last_line`, `mjb_llr_071_goto_last_line_without_trailing_newline`, `mjb_llr_071_ge_goes_to_the_last_line` |
+| 072 | `goto_line_start` | `mjb_llr_072_goto_line_start`, `mjb_llr_072_gh_goes_to_the_line_start`, `mjb_llr_072_gs_goes_to_first_non_whitespace` |
+| 073 | `goto_line_end`, `line_end_byte` | `mjb_llr_073_goto_line_end_excludes_terminator`, `mjb_llr_073_goto_line_end_handles_crlf`, `mjb_llr_073_gl_goes_to_the_line_end` |
+
+### Viewport (`src/buffer/view.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 090 | `ViewPosition` | `mjb_llr_090_anchor_is_a_byte_offset_at_a_line_start` |
+| 091 | `View::top_line` | `mjb_llr_091_top_line_from_anchor` |
+| 092 | `View::visible_lines`, `visible_line_range` | `mjb_llr_092_visible_lines_are_bounded_by_height`, `mjb_llr_092_visible_lines_clamp_near_end_of_buffer`, `mjb_llr_092_empty_buffer_renders_safely`, `mjb_llr_092_visible_line_count_is_independent_of_file_size`, `mjb_llr_092_scrolling_a_large_file_stays_responsive` |
+| 093 | scroll-off clamping | `mjb_llr_093_scrolloff_larger_than_viewport_is_clamped` |
+| 094 | scroll-up branch | `mjb_llr_094_scrolls_up_to_honour_top_margin`, `mjb_llr_094_scroll_near_start_saturates_at_zero` |
+| 095 | scroll-down branch | `mjb_llr_095_scrolls_down_to_honour_bottom_margin` |
+| 096 | no-scroll branch | `mjb_llr_096_no_scroll_when_cursor_is_comfortable` |
+| 097 | `View::set_top_line` | `mjb_llr_097_top_line_clamps_into_buffer` |
+| 098 | zero-height guard | `mjb_llr_098_zero_height_viewport_is_a_noop`, `mjb_llr_098_zero_height_viewport_does_not_panic` |
+| 099 | `ensure_horizontal_in_view` | `mjb_llr_099_horizontal_scroll_follows_cursor` |
+| 100 | `View::page` (half) | `mjb_llr_100_half_page_moves_cursor_and_view`, `mjb_llr_100_ctrl_d_pages_half_a_screen_down`, `mjb_llr_100_ctrl_u_pages_back_up` |
+| 101 | `View::page` (full) | `mjb_llr_101_full_page_moves_by_height`, `mjb_llr_101_ctrl_f_pages_a_full_screen_down` |
+| 102 | paging saturation | `mjb_llr_102_paging_saturates_at_both_ends` |
+
+### Document, encoding, line endings
+
+| LLR | Source item | Test |
+|---|---|---|
+| 110 | `encoding::detect_bom` | `mjb_llr_110_detects_each_bom` |
+| 111 | `Document::open`, `Buffer::new` | `mjb_llr_111_loads_contents`, `mjb_llr_111_plain_utf8_round_trips`, `mjb_llr_111_no_path_yields_a_scratch_buffer` |
+| 112 | `Document::open` not-found arm | `mjb_llr_112_missing_file_yields_empty_buffer_that_remembers_the_path`, `mjb_llr_112_empty_file_is_editable`, `mjb_llr_112_file_without_trailing_newline_round_trips`, `mjb_llr_112_writing_a_new_file_creates_it`, `mjb_llr_112_missing_file_opens_as_an_empty_buffer` |
+| 113 | `encoding::decode` non-UTF-8 branch | `mjb_llr_113_declared_utf16_is_transcoded_not_rejected`, `mjb_llr_113_empty_input_decodes_to_empty`, `mjb_llr_113_declared_utf16_file_opens`, `mjb_llr_113_declared_utf16_file_opens_and_edits` |
+| 118 | `DecodeError`, `encoding::decode` UTF-8 branch, `Document::open` | `mjb_llr_118_invalid_utf8_is_rejected`, `mjb_llr_118_rejection_names_the_offset`, `mjb_llr_118_truncated_multibyte_char_is_rejected`, `mjb_llr_118_declared_utf8_is_strict_too`, `mjb_llr_118_valid_multibyte_utf8_is_accepted`, `mjb_llr_118_invalid_utf8_file_is_refused`, `mjb_llr_118_binary_file_is_refused_not_opened` |
+| 114 | `LineEnding::detect` | `mjb_llr_114_detects_lf`, `_detects_crlf`, `_detects_lone_cr`, `_falls_back_to_platform_default`, `_first_terminator_decides`, `_crlf_is_detected_and_normalized_for_storage` |
+| 115 | `encoding::encode`, `Document::encode` | `mjb_llr_115_bom_round_trips`, `mjb_llr_115_utf16le_round_trips`, `mjb_llr_115_utf16be_round_trips`, `mjb_llr_115_utf16_handles_non_ascii_and_surrogates`, `mjb_llr_115_apply_restores_original_ending`, `mjb_llr_115_crlf_round_trips_through_save` |
+| 116 | `Document::is_modified`, `History::revision` | `mjb_llr_116_modified_flag_lifecycle`, `mjb_llr_116_revision_is_not_stack_depth`, `mjb_llr_116_revision_is_zero_when_pristine_and_returns_on_undo`, `mjb_llr_116_undo_back_to_saved_state_is_clean`, `mjb_llr_116_divergent_edit_at_the_same_depth_stays_modified` |
+| 117 | `Document::apply` | `mjb_llr_117_apply_updates_text_and_records_history` |
+
+### Save path (`src/buffer/save.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 130 | `resolve_write_path` | `mjb_llr_130_write_follows_symlink_without_replacing_it`, `mjb_llr_130_relative_symlink_resolves_against_its_own_directory`, `mjb_llr_130_write_through_a_symlink_end_to_end` |
+| 131 | `readonly` | `mjb_llr_131_readonly_target_is_refused`, `mjb_llr_131_missing_file_is_not_readonly`, `mjb_llr_131_readonly_file_reports_and_preserves_contents` |
+| 132 | missing-parent branch | `mjb_llr_132_missing_parent_is_refused_without_force`, `mjb_llr_132_force_creates_the_parent` |
+| 133 | `must_copy` | `mjb_llr_133_hardlink_is_detected_and_preserved`, `mjb_llr_133_plain_file_does_not_need_copy_mode` |
+| 134 | `backup_path`, backup creation | `mjb_llr_134_backup_is_created_beside_the_target`, `mjb_llr_134_backup_path_handles_a_bare_file_name` |
+| 135 | restore-on-failure | `mjb_llr_135_failed_write_restores_the_original` |
+| 136 | `copy_permissions`, backup removal | `mjb_llr_136_no_backup_file_is_left_behind` |
+| 137 | `Document::save` | `mjb_llr_116_modified_flag_lifecycle` |
+
+### Keymap and commands (`src/buffer/keymap.rs`, `mod.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 150 | `Keymap::new` prefix set | `mjb_llr_150_proper_prefixes_are_precomputed`, `mjb_llr_150_prefix_set_is_empty_for_a_mode_without_sequences` |
+| 151 | `Keymap::resolve` match arm | `mjb_llr_151_single_key_binding_matches_immediately`, `mjb_llr_151_two_key_sequence_resolves`, `mjb_llr_151_sequences_sharing_a_prefix_stay_distinct` |
+| 152 | `Keymap::resolve` prefix arm | `mjb_llr_152_prefix_key_waits_for_more` |
+| 153 | `Keymap::resolve` cancel arm | `mjb_llr_153_unknown_continuation_cancels`, `mjb_llr_153_unbound_key_cancels_immediately`, `mjb_llr_153_unknown_g_sequence_does_not_corrupt_state` |
+| 154 | absence of any timer | `mjb_llr_154_pending_is_not_discarded_by_time`, `mjb_llr_154_gg_resolves_regardless_of_intervening_time` |
+| 155 | count accumulation | `mjb_llr_155_count_accumulates_and_is_delivered`, `_count_is_consumed_once`, `_leading_zero_is_not_a_count`, `_zero_extends_an_existing_count`, `_digits_are_not_counts_in_insert_mode`, `_count_repeats_a_motion` |
+| 156 | `self_insert_char` | `mjb_llr_156_self_insert_accepts_plain_printables`, `_rejects_control_and_alt`, `_rejects_non_char_and_sequences`, `_i_then_typing_inserts_text`, `_command_keys_type_literally_in_insert_mode`, `_unbound_control_key_is_discarded_in_insert_mode` |
+| 157 | `Command` enum | `mjb_llr_157_deserializes_from_the_variant_name`, `mjb_llr_157_unknown_command_name_is_an_error_not_a_panic`, `mjb_llr_157_display_round_trips_through_deserialization` |
+| 158 | `Buffer::handle_command_mode_key` | `mjb_llr_158_escape_cancels_the_command_line`, `_command_line_accepts_backspace`, `_backspacing_an_empty_command_line_leaves_command_mode` |
+| 159 | `Buffer::run_command_line` | `mjb_llr_159_write_saves_to_disk`, `_quit_returns_the_quit_outcome`, `_wq_writes_then_quits`, `_x_is_an_alias_for_wq`, `_unknown_command_is_reported_not_fatal`, `_force_write_creates_missing_directories`, `_empty_command_line_does_nothing` |
+| 160 | unsaved-changes guard | `mjb_llr_160_quit_with_unsaved_changes_is_refused`, `mjb_llr_160_force_quit_discards_changes` |
+
+### Configuration (`src/config.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 180 | `CONFIG`, `Config::new` | `mjb_llr_180_builtin_defaults_parse` |
+| 181 | single TOML source | `mjb_llr_181_only_config_toml_is_read` + `cargo tree` check (see below) |
+| 182 | default-merge loop | `mjb_llr_182_user_bindings_merge_per_binding` |
+| 183 | `KeyBindings::deserialize`, `parse_color` | `mjb_llr_183_invalid_keybinding_is_recoverable`, `mjb_llr_183_malformed_colours_never_panic`, `mjb_llr_183_colour_cube_bounds`, `mjb_llr_183_grayscale_ramp_bounds`, `mjb_llr_183_bright_colour_is_base_plus_eight` |
+| 184 | `Mode` | `mjb_llr_184_modes_deserialize_lowercase`, `mjb_llr_184_v_toggles_select_mode` |
+| 185 | `EditorConfig` | `mjb_llr_185_editor_defaults`, `mjb_llr_185_final_newline_only_added_when_missing`, `mjb_llr_185_final_newline_added_on_encode_when_requested`, `mjb_llr_185_empty_document_encodes_empty` |
+
+### Presentation (`src/components/buffer.rs`, `src/app.rs`)
+
+Rendering is tested against a real cell grid via ratatui's `TestBackend`
+(`tests/rendering.rs`), not asserted by inspection.
+
+| LLR | Source item | Test |
+|---|---|---|
+| 200 | `BufferComponent::draw` render loop | `mjb_llr_200_renders_the_file_contents`, `mjb_llr_200_renders_only_the_visible_window`, `mjb_llr_200_line_numbers_are_shown_in_the_gutter`, `mjb_llr_200_empty_file_renders_without_panicking`, `mjb_llr_200_wide_characters_render` |
+| 201 | cursor/selection styling in `draw` | `mjb_llr_201_cursor_is_styled_distinctly` |
+| 202 | `status_line`, `message_line` | `mjb_llr_202_status_line_shows_mode_and_path`, `mjb_llr_202_status_line_marks_an_unmodified_file`, `mjb_llr_202_scratch_buffer_is_labelled` |
+| 203 | `App::handle_global_key`, `Keymap::lookup_single` | `mjb_llr_203_lookup_single_does_not_disturb_pending`, `mjb_llr_203_globally_bound_key_is_not_typed_as_text` |
+| 204 | `App::new` component vec | `mjb_llr_204_no_fps_counter_or_hello_world_is_rendered` |
+| 205 | absence of `last_tick_key_events` | `mjb_llr_205_pending_chord_survives_redraws` |
+
+`mjb_llr_200_renders_only_the_visible_window` is the direct check on
+MJB-HLR-012: a 1000-line file in an 8-row terminal must not put line 500 on
+screen.
+
+---
+
+## Completeness check
+
+The matrix above is verified mechanically, not by inspection:
+
+```bash
+grep -oE 'MJB-LLR-[0-9]+' docs/requirements/llr.md | sort -u > defined
+grep -rhoE 'MJB-LLR-[0-9]+' src/ | sort -u > tagged
+grep -rhoE 'mjb_llr_[0-9]+' src/ tests/ | sed 's/mjb_llr_/MJB-LLR-/' | sort -u > tested
+comm -23 defined tagged # LLRs with no source tag
+comm -23 defined tested # LLRs with no test
+comm -13 defined tested # tests naming an LLR that does not exist
+```
+
+Result at time of writing — all three sets empty:
+
+```
+defined: 98 tagged: 98 tested: 98
+```
+
+Every low-level requirement is tagged in source and exercised by at least one
+test named for it. Test totals: **282 passing** (179 unit, 89 editing
+integration, 14 rendering integration).
+
+---
+
+## Coverage
+
+Statement (line) coverage of the buffer core, the DAL-C structural criterion:
+
+```
+src/buffer/** statement coverage: 2665/2770 = 96.21%
+```
+
+Measured with `cargo llvm-cov --summary-only test`. Per-module figures:
+
+| Module | Lines | Covered |
+|---|---|---|
+| `buffer/view.rs` | 233 | 99.57% |
+| `buffer/transaction.rs` | 265 | 98.87% |
+| `buffer/encoding.rs` | 140 | 98.57% |
+| `buffer/movement.rs` | 346 | 97.98% |
+| `buffer/selection.rs` | 182 | 97.80% |
+| `buffer/keymap.rs` | 223 | 97.76% |
+| `buffer/history.rs` | 90 | 96.67% |
+| `buffer/command.rs` | 28 | 96.43% |
+| `buffer/document.rs` | 268 | 94.40% |
+| `buffer/mod.rs` | 408 | 93.87% |
+| `buffer/save.rs` | 218 | 93.12% |
+| `buffer/line_ending.rs` | 87 | 89.66% |
+| `buffer/grapheme.rs` | 195 | 89.23% |
+
+**MC/DC and decision coverage are not DAL-C objectives and are not claimed.**
+Only statement coverage is reported.
+
+### Uncovered code, and why
+
+- `buffer/grapheme.rs` (89.23%) — the lowest figure. The uncovered lines are
+ defensive `Err(_) => return` arms for `GraphemeIncomplete` variants that
+ cannot arise from a cursor constructed over the whole slice. They exist so a
+ malformed state degrades instead of panicking (MJB-HLR-018), and are
+ unreachable by construction, so no test can drive them.
+- `buffer/save.rs` (93.12%) — the restore-on-failure path is reachable only
+ when the process cannot write to a directory it owns. Running the suite as
+ root defeats the permission bits, so
+ `mjb_llr_135_failed_write_restores_the_original` skips its assertion in that
+ case rather than reporting a false pass. The path is covered when the suite
+ runs as an ordinary user.
+- `buffer/line_ending.rs` (89.66%) — `LineEnding::platform_default` has one
+ arm per platform; only the host's arm executes.
+- Remaining gaps are `Display`/`From` glue generated by `thiserror` on error
+ variants that no test provokes.
+
+### Verification not expressible as a unit test
+
+`cargo tree` confirms MJB-LLR-181 and MJB-HLR-014 — that no parser for a
+non-TOML configuration format remains in the dependency graph:
+
+```bash
+cargo tree | grep -iE "json|yaml|ini\b" # returns nothing
+```
+
+Confirmed: `config v0.15.25` resolves with only `pathdiff`, `serde_core`,
+`toml`, and `winnow`.