diff --git a/README.md b/README.md
index e699d6d..cfb6d08 100644
--- a/README.md
+++ b/README.md
@@ -37,11 +37,10 @@ and [Thymeleaf](https://en.wikipedia.org/wiki/Thymeleaf) frontend.
- Language: Java 21
- Frameworks:
- - Java Framework used to build (Web) Applications and REST endpoints.
- - [Spring Boot](https://spring.io/projects/spring-boot) 3.x:
- - Thymeleaf
+ - [Spring Boot](https://spring.io/projects/spring-boot) 3.x: a Java Framework used to build (Web) Applications (and REST endpoints)
+ - [Thymeleaf](https://www.thymeleaf.org/index.html) a server-side Java template engine for Web applications
- Authentication and authorization framework:
- - [Spring Security](https://spring.io/projects/spring-security):
+ - [Spring Security](https://spring.io/projects/spring-security) 6.5.x: an authentication and access-control framework
- Databases:
- [PostgreSQL](https://www.postgresql.org/about/) version 17 (relational core)
- [MongoDB](https://www.mongodb.com/) version 8, provisioned (Docker) for
@@ -52,7 +51,7 @@ and [Thymeleaf](https://en.wikipedia.org/wiki/Thymeleaf) frontend.
- [Maven](https://maven.apache.org/what-is-maven.html)
- Containerization:
- [Podman](https://en.wikipedia.org/wiki/Podman) (preferred over [Docker](https://en.wikipedia.org/wiki/Docker_(software)))
- to containerize parts of the application as container images that can run as autonomous containers.
+ to containerize parts of the application as container images that can run as autonomous containers
### Frontend
diff --git a/docs/adr/0016-render-mermaid-diagrams-client-side.md b/docs/adr/0016-render-mermaid-diagrams-client-side.md
new file mode 100644
index 0000000..94ca91e
--- /dev/null
+++ b/docs/adr/0016-render-mermaid-diagrams-client-side.md
@@ -0,0 +1,109 @@
+# Render Mermaid diagrams client-side in lessons
+
+- Status: accepted
+- Date: 2026-07-28
+- Deciders: Eric Bouchut
+
+## Context and Problem Statement
+
+Instructors want diagrams in lessons, in the Mermaid fence syntax GitHub
+and Obsidian share. `commonmark-java` has no Mermaid extension and needs
+none: a mermaid fence is an ordinary fenced code block, and the renderer
+already lets `code class="language-mermaid"` survive sanitization (see
+[ADR-0013](0013-render-lesson-markdown-with-commonmark-java.md)). The real
+question is where the text-to-SVG transformation runs, given that the
+sanitization pipeline deliberately never lets SVG markup through and the
+render cache stores sanitized HTML by content hash.
+
+## Decision Drivers
+
+- ADR-0013's posture stays: no SVG, no script, no style crosses the
+ sanitizer, whatever the Markdown contains
+- Author familiarity: content pasted from GitHub or Obsidian should work
+- No new server-side infrastructure (no Node, no headless browser, no
+ extra container) for a feature of this size
+- Self-hosted and version-pinned assets, no CDN at page load (privacy,
+ supply-chain stability)
+- The project needs a legitimate, non-decorative client-side JavaScript
+ feature (DWWM competency CP4, dynamic interfaces)
+- Readers without JavaScript, and diagrams with syntax errors, must
+ degrade to something readable
+
+## Considered Options
+
+- Client-side rendering with the Mermaid library, loaded lazily on lesson
+ pages that contain a diagram
+- Server-side rendering with `mermaid-cli` (*Node* plus headless *Chromium* on
+ the server)
+- Server-side rendering through a self-hosted *Kroki* container, embedded
+ back as images
+- Do nothing: mermaid fences stay visible as source code
+
+## Decision Outcome
+
+Chosen: *"client-side rendering with the Mermaid library"*, because it is
+the only option that adds zero server infrastructure while keeping the
+sanitizer contract byte-identical: the server keeps emitting the fenced
+source as a sanitized code block (a contract pinned by a renderer test),
+and the browser upgrades it to SVG after the fact. The render cache is
+unaffected since cached HTML still contains only the code block.
+
+Implementation decisions that follow:
+
+- The library ships as the version-pinned `org.webjars.npm:mermaid`
+ dependency with wildcard exclusions (the *npm* `WebJar` otherwise drags 21
+ npm-mirror transitives). *Spring Boot* serves it from the classpath; no
+ *CDN* is involved. Lesson pages require authentication, so the existing
+ `anyRequest().authenticated()` rule already covers the *WebJar* path.
+- `lesson-mermaid.js` loads the 3.3 MB bundle lazily, only when the page
+ actually contains a `language-mermaid` block, and initializes Mermaid
+ with `startOnLoad: false` and `securityLevel: "strict"`.
+- Handing instructor-controlled text to a large rendering library is a
+ new client-side attack surface the server sanitizer cannot see. The
+ mitigations are the strict security level (labels escaped, click
+ callbacks disabled), the pinned self-hosted version, and keeping the
+ library off every page that has no diagram.
+- The original code block stays in the *DOM*, hidden behind a
+ "show diagram source" toggle (`aria-expanded`/`aria-controls`): it is
+ the no-JavaScript fallback, the syntax-error fallback, and the textual
+ alternative that accompanies the SVG. Diagrams follow the color scheme
+ (Mermaid `default`/`dark` themes) and re-render on scheme change.
+
+### Consequences
+
+- Good: GitHub/Obsidian-style mermaid fences just work; no house syntax
+- Good: sanitizer, allowlist, and render cache are untouched
+- Good: the project gains its first substantive JavaScript feature (CP4)
+ with progressive enhancement built in
+- Trade-off: diagram rendering costs client CPU and a 3.3 MB lazy asset
+ on diagram-bearing lessons; readers on very old browsers see source
+- Trade-off: a client-side dependency with a history of XSS advisories
+ must be kept current (pinned version, Dependabot watches the WebJar)
+- Trade-off: diagram accessibility depends on authors adding `accTitle`
+ and `accDescr` lines; the lesson form hints at this but cannot enforce it
+
+## Pros and Cons of the Options
+
+### Client-side Mermaid (chosen)
+
+- 👍 Zero server infrastructure; sanitizer and cache untouched
+- 👍 Lazy, self-hosted, version-pinned asset
+- 👎 New client-side attack surface, mitigated but real
+- 👎 3.3 MB library on diagram-bearing pages
+
+### mermaid-cli on the server
+
+- 👍 Clients get plain SVG images, no client-side surface
+- 👎 Node plus headless Chromium inside a Spring Boot deployment, the
+ heaviest possible dependency for the smallest feature
+
+### Self-hosted Kroki service
+
+- 👍 Clean HTTP contract, many diagram languages beyond Mermaid
+- 👎 One more container to run, secure, and monitor; cache invalidation
+ questions for regenerated images
+
+### Do nothing
+
+- 👍 No code
+- 👎 Pasted GitHub/Obsidian content shows raw diagram source forever
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 30f53a1..32c8f20 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -45,4 +45,5 @@ NNNN-short-title-in-kebab-case.md
| [0012](0012-publish-test-coverage-to-codecov.md) | Publish test coverage to Codecov | accepted |
| [0013](0013-render-lesson-markdown-with-commonmark-java.md) | Render lesson Markdown with commonmark-java, sanitized by jsoup | accepted |
| [0014](0014-demote-markdown-headings-in-lesson-rendering.md) | Demote Markdown headings one level in lesson rendering | accepted |
-| [0015](0015-render-lesson-alerts-with-commonmark-alerts.md) | Render lesson alerts with the commonmark-java alerts extension | accepted |
\ No newline at end of file
+| [0015](0015-render-lesson-alerts-with-commonmark-alerts.md) | Render lesson alerts with the commonmark-java alerts extension | accepted |
+| [0016](0016-render-mermaid-diagrams-client-side.md) | Render Mermaid diagrams client-side in lessons | accepted |
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index e28634d..2ff07ab 100644
--- a/pom.xml
+++ b/pom.xml
@@ -60,6 +60,7 @@
0.8.15
0.29.0
1.21.1
+ 11.15.0
@@ -154,6 +155,21 @@
jsoup
${jsoup.version}
+
+
+ org.webjars.npm
+ mermaid
+ ${mermaid.version}
+
+
+ *
+ *
+
+
+
org.springframework.boot
spring-boot-starter-cache
diff --git a/src/main/resources/static/css/base.css b/src/main/resources/static/css/base.css
index 4e016a5..0b562d2 100644
--- a/src/main/resources/static/css/base.css
+++ b/src/main/resources/static/css/base.css
@@ -802,3 +802,26 @@ textarea.form__input {
--alert-accent: var(--color-text-muted);
--alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M1.75 2.5h10.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5Zm4 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2.5 7.75v6a.75.75 0 0 1-1.5 0v-6a.75.75 0 0 1 1.5 0Z%22%2F%3E%3C%2Fsvg%3E");
}
+
+/* Mermaid diagrams (ADR-0016): the figure and toggle are injected
+ client-side by lesson-mermaid.js, so BEM classes are fine here,
+ unlike the sanitized Markdown output styled above. */
+.lesson-content .lesson-mermaid {
+ margin: 0 0 var(--space-2);
+ padding: var(--space-3);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+ overflow-x: auto; /* wide diagrams scroll here, never the page (RGAA 10.11) */
+ text-align: center;
+}
+
+.lesson-content .lesson-mermaid svg {
+ max-width: 100%;
+ height: auto;
+}
+
+.lesson-content .lesson-mermaid__toggle {
+ margin-bottom: var(--space-3);
+ font-size: var(--font-size-sm);
+}
diff --git a/src/main/resources/static/js/lesson-mermaid.js b/src/main/resources/static/js/lesson-mermaid.js
new file mode 100644
index 0000000..f4dc597
--- /dev/null
+++ b/src/main/resources/static/js/lesson-mermaid.js
@@ -0,0 +1,107 @@
+/*
+ * Upgrades mermaid fences in lesson content to SVG diagrams.
+ *
+ * The server deliberately ships diagrams as sanitized code blocks (see
+ * ADR-0016): this script lazily loads the self-hosted Mermaid bundle only
+ * when the page actually contains one, renders each block client-side,
+ * and keeps the original code block in the DOM behind a toggle as the
+ * no-JavaScript, syntax-error, and screen-reader fallback.
+ */
+(function () {
+ "use strict";
+
+ var sourceBlocks = document.querySelectorAll(
+ ".lesson-content pre > code.language-mermaid");
+ if (sourceBlocks.length === 0) {
+ return;
+ }
+
+ var darkScheme = window.matchMedia("(prefers-color-scheme: dark)");
+ var renderPass = 0;
+
+ loadMermaid().then(renderAll).catch(function () {
+ // The library failed to load: the styled source blocks stay visible.
+ });
+
+ darkScheme.addEventListener("change", function () {
+ if (window.mermaid) {
+ renderAll();
+ }
+ });
+
+ function loadMermaid() {
+ return new Promise(function (resolve, reject) {
+ var loader = document.querySelector("script[data-mermaid-src]");
+ var script = document.createElement("script");
+ script.src = loader.getAttribute("data-mermaid-src");
+ script.onload = resolve;
+ script.onerror = reject;
+ document.head.appendChild(script);
+ });
+ }
+
+ function renderAll() {
+ renderPass += 1;
+ window.mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: "strict",
+ theme: darkScheme.matches ? "dark" : "default"
+ });
+ sourceBlocks.forEach(function (code, index) {
+ renderBlock(code.parentElement, code, index);
+ });
+ }
+
+ function renderBlock(pre, code, index) {
+ // A fresh id per pass: Mermaid refuses to render into a used id, and
+ // a theme change re-renders every diagram.
+ var id = "lesson-mermaid-" + renderPass + "-" + index;
+ window.mermaid.render(id, code.textContent)
+ .then(function (result) {
+ ensureFigure(pre, index).innerHTML = result.svg;
+ })
+ .catch(function () {
+ // Invalid diagram source: the code block stays visible; drop the
+ // detached error element Mermaid leaves behind.
+ var artifact = document.getElementById("d" + id);
+ if (artifact) {
+ artifact.remove();
+ }
+ });
+ }
+
+ function ensureFigure(pre, index) {
+ var figureId = "lesson-mermaid-figure-" + index;
+ var figure = document.getElementById(figureId);
+ if (figure) {
+ return figure;
+ }
+
+ figure = document.createElement("figure");
+ figure.id = figureId;
+ figure.className = "lesson-mermaid";
+
+ if (!pre.id) {
+ pre.id = "lesson-mermaid-source-" + index;
+ }
+ pre.hidden = true;
+
+ var toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.className = "button button--ghost lesson-mermaid__toggle";
+ toggle.textContent = "Show diagram source";
+ toggle.setAttribute("aria-expanded", "false");
+ toggle.setAttribute("aria-controls", pre.id);
+ toggle.addEventListener("click", function () {
+ var expanded = toggle.getAttribute("aria-expanded") === "true";
+ toggle.setAttribute("aria-expanded", String(!expanded));
+ toggle.textContent =
+ expanded ? "Show diagram source" : "Hide diagram source";
+ pre.hidden = expanded;
+ });
+
+ pre.parentNode.insertBefore(figure, pre);
+ pre.parentNode.insertBefore(toggle, pre);
+ return figure;
+ }
+})();
diff --git a/src/main/resources/templates/courses/lesson.html b/src/main/resources/templates/courses/lesson.html
index 6ed6ee9..5116bbe 100644
--- a/src/main/resources/templates/courses/lesson.html
+++ b/src/main/resources/templates/courses/lesson.html
@@ -29,5 +29,11 @@ Lesson title
+
+
+