diff --git a/CLAUDE.md b/CLAUDE.md
index 050035e..0fcbed6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -67,6 +67,11 @@ are stale — they were last taken before the notes and class-register work, and
could not be retaken because the live session had lapsed. Every Schulcloud check fails with 401 when the live
session has lapsed — check the container's keepalive log before suspecting code.
+The editor's Markdown round trip is unit-tested; the **browser** side of
+`editor.js` is not, because nothing here runs one. It was checked by hand in
+Firefox against a page that drives the toolbar — WebKit, which is the engine on
+the phone this is written on, has still never run it.
+
Store tests need a database and skip without one:
`TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on
purpose — the generation/diff semantics are entirely SQL, so a mock would test
@@ -145,7 +150,20 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
**files** under `src/http/app/`, copied to `dist/` by `scripts/copy-assets.mjs`
and read relative to `import.meta.dirname` — real HTML, CSS and JS that an
editor and a linter understand, which is also what the CSP requires, since it
- forbids inline script.
+ forbids inline script. `app.js` is an ES **module**; a new asset must be added
+ to `ASSETS` *and* to the route's regex in `app-page.ts`, or it 404s.
+- **`http/app/markdown.js` + `editor.js`** — the note is edited as formatted
+ text and stored as Markdown, and these two are that translation.
+ `markdown.js` is the pair `markdownToHtml` / `markdownFromDom`; `editor.js`
+ drives a `contenteditable` element with `execCommand` (no library: the CSP
+ allows no outside script and the app has no bundler). **The round trip must
+ settle**: one pass may tidy a note, a second must change nothing, and
+ `editor.js` checks exactly that before opening a note formatted — a note that
+ fails opens in the Markdown view instead. `test/app-markdown.test.ts` covers
+ it against `test/mini-dom.ts`, ~60 lines of read-only DOM, because losing a
+ lesson's notes to a lossy serializer is not a bug anyone can recover from.
+ Pasted HTML goes through Markdown before it reaches the document, which is
+ where sanitising and formatting are the same operation.
- **`http/web-auth.ts`** — the app's login, which is a different kind of
credential from everything else here: a password a person types, not a token a
program was configured with. scrypt at startup, a signed `HttpOnly` /
diff --git a/README.md b/README.md
index 9cd9661..b81de63 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,11 @@ teachers, rooms, cancellations dropped and substitutions marked. Each heading is
indexed as its own lesson, so a search answers "my own note, Deutsch,
18.09.2026" rather than "Friday".
+Writing is **formatted, not Markdown**: headings, bold, lists, tick boxes,
+quotes, links and tables come from a toolbar, and `MD` shows the Markdown
+underneath when you want it. The file on disk stays Markdown either way — that
+is what the index reads and what outlives the app.
+
It saves as you type, keeps a local copy of every keystroke for when the signal
goes, and refuses a save that would overwrite a version it never saw. On a phone
it adds to the home screen and opens standalone.
diff --git a/docs/DEPLOY-NOTES.md b/docs/DEPLOY-NOTES.md
index 1a08a42..24a793d 100644
--- a/docs/DEPLOY-NOTES.md
+++ b/docs/DEPLOY-NOTES.md
@@ -12,7 +12,7 @@ should not make twice**, because changing it later means moving files by hand.
| | |
|---|---|
| **Your own lesson notes** | A directory of Markdown files the server reads, indexes and searches beside Schulcloud and WebUntis. Three tools: `list_notes`, `get_note`, `add_note`. |
-| **The app at `/app`** | A login, a day-at-a-time notes editor, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. |
+| **The app at `/app`** | A login, a day-at-a-time notes editor with a formatting toolbar, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. |
| **The WebUntis class register** | `untis_lesson_topics` now takes a subject as well as a period id, and `UNTIS_HISTORY_DAYS` of "what was actually taught" goes into the search index. |
Nothing here changes Schulcloud or WebUntis: both stay read-only. The notes
@@ -241,6 +241,8 @@ write them.
| `Der Server nimmt keine Änderungen an` | `NOTES_READONLY` is on | Remove it and recreate the container |
| Saves refused as a conflict, repeatedly | The note is being changed elsewhere — a sync tool, another device | Choose a version in the banner; if a sync tool keeps rewriting the file, it is fighting the app |
| `EACCES` for `/data/notes` in the logs | A bind-mounted directory the container's user cannot write | `sudo chown -R 1000:1000 /home/pi/Notizen` (match the image's user), then recreate |
+| The toolbar is there, the text stays plain | The browser blocked `editor.js` or `markdown.js` | Check the console; both must be served from `/app/`, and `app.js` must load as `type="module"` |
+| A note opens in the Markdown view by itself, with a hint | It holds formatting the formatted view cannot keep unchanged | Nothing is wrong and nothing was lost; edit it there, or simplify the note |
| Notes exist but `search` cannot find them | Only a full crawl reads them | `schulcloud refresh --force` |
| `search` finds a day note but names no subject | The lesson headings were rewritten past recognition | Keep `## 1. Deutsch …`; the leading number and the subject are what the index reads |
| `untis_lesson_topics` with a subject finds nothing | The subject code differs from what you typed | Check it against `untis_timetable`; the register uses the school's own codes |
diff --git a/docs/NOTES.md b/docs/NOTES.md
index 358844a..551712e 100644
--- a/docs/NOTES.md
+++ b/docs/NOTES.md
@@ -87,6 +87,41 @@ thing I open in a free period".
`add_note` — the save is refused and you are asked which version wins. It
never silently overwrites.
+### Writing, without typing Markdown
+
+The editor shows the note **formatted** — headings as headings, bold as bold,
+tables as tables — and the toolbar above it writes the Markdown. Nobody types
+`##` or `**` during a lesson.
+
+| Button | What it writes |
+| --- | --- |
+| `H2` | a lesson heading — the one that makes the lesson separately searchable |
+| `H3` | a subheading inside a lesson |
+| `F` `K` `S` | **fett**, _kursiv_, ~~durchgestrichen~~ (`Strg`/`Cmd` + B, I) |
+| `<>` | inline code (`Strg`/`Cmd` + E) |
+| `• —` `1. —` | bullet and numbered lists; nest them with Tab |
+| `☐` | a box to tick off |
+| `❝` | a quote — the teacher's exact wording |
+| `🔗` `▦` | a link (`Strg`/`Cmd` + K) and a table |
+| `MD` | the Markdown itself |
+
+`Enter` starts a new paragraph, `Shift+Enter` a new line in the same one. A
+paste from a web page or a PDF keeps its structure and loses its fonts, colours
+and anything else that is not in the list above — pasted HTML is converted to
+Markdown before it reaches the page, which is what keeps a copied page from
+bringing its script along.
+
+**The file is still Markdown.** `MD` shows it and lets you edit it directly,
+which is the way to write something the toolbar has no button for. There is no
+underline, because Markdown cannot store one — `F` or `K` instead.
+
+Opening a note may tidy it once: `*so*` becomes `_so_`, a table typed unevenly
+lines up. Nothing is rewritten until you actually change something, and a note
+whose formatting the view cannot hold unchanged **opens as Markdown** and says
+so rather than being quietly reduced. `test/app-markdown.test.ts` is what holds
+that promise up: every construct in this document goes in and comes back out
+unchanged.
+
**Einstellungen** holds the Schulcloud token: how long it has left, and the box
to paste a fresh `jwt` cookie into when it expires (the same thing `schulcloud
token set` and the older `/token` page do). It also shows the index's state and
diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs
index af5186f..cbdc422 100644
--- a/scripts/smoke.mjs
+++ b/scripts/smoke.mjs
@@ -726,10 +726,22 @@ console.log('\n== web app ==');
);
check('the shell holds no secret of its own', !shellText.includes(WEB_PASSWORD) && !shellText.includes(TOKEN));
- const assets = await Promise.all(
- ['app.js', 'app.css', 'icon.svg', 'manifest.webmanifest'].map((name) => fetch(`${root}/app/${name}`)),
- );
+ // Every file the shell asks for, including the two modules the editor is
+ // made of: a missing one leaves a page that loads and cannot type.
+ const assetNames = ['app.js', 'editor.js', 'markdown.js', 'app.css', 'icon.svg', 'manifest.webmanifest'];
+ const assets = await Promise.all(assetNames.map((name) => fetch(`${root}/app/${name}`)));
check('the app\'s assets are served', assets.every((response) => response.ok), assets.map((r) => r.status).join(' '));
+ check(
+ 'the editor\'s modules are served as JavaScript',
+ assets
+ .filter((_, index) => assetNames[index].endsWith('.js'))
+ .every((response) => /javascript/.test(response.headers.get('content-type') ?? '')),
+ assets.map((r) => r.headers.get('content-type')).join(' | '),
+ );
+ check(
+ 'the shell loads the app as a module, so its imports resolve',
+ /
+