Writing Documentation
This page is about the documentation site itself: where a page belongs, how to structure it, how to name what the
reader sees on screen, and how to keep screenshots and screencasts from going stale. It applies to every page under
documentation/docs/, whichever audience it is written for.
Each rule below states why it exists. Most of them exist because the opposite was tried first and broke something.
Where a page belongs
Documentation lives under documentation/docs/, split by audience: admin/, instructor/, student/,
developer/, about/. Write for the audience of the folder. An instructor page explains what an instructor
configures; a student page explains what a student sees and does. When both need to know about the same feature,
write two pages and link them, rather than one page that describes the other side's screens.
A new page is only reachable once it is listed in the matching documentation/sidebar-*.ts. Add it there and link
it from the related pages in the same change.
Screenshots and diagrams go into the nearest assets/ directory, in a subdirectory named after the page or the
feature. Do not copy an asset into a second audience's tree so that a second page can use it — either the two pages
should link to each other, or one of them should not be showing that screen at all.
Frontmatter and the page title
Every page carries three frontmatter keys, and no H1 of its own:
---
id: exam-timeline
title: Timeline of an Artemis Online Exam
sidebar_label: Exam Timeline
---
idis what the sidebar file and other pages refer to. Keep it equal to the filename without its extension.titleis the browser tab title, the on-page heading, the search result heading, and the name the page is known by.sidebar_labelis the entry in the navigation tree. It is usually shorter than the title, because the sidebar is narrow and the surrounding category already supplies context.
Do not write an H1 in the body. Docusaurus renders one from title whenever the body has no top-level heading
of its own: DocItemContent in @docusaurus/theme-classic synthesises the heading when contentTitle is
undefined. Writing the title once means it cannot drift out of sync with the tab, the sidebar entry and the search
result — which is exactly what happens when the same page states it twice.
The body therefore opens with its first paragraph, below any component imports. Every heading you write in the body
is an H2 or deeper. Two <h1> elements on one page break the document outline that screen readers and search
engines rely on, and a body H1 next to a frontmatter title is the way that happens by accident.
Headings and anchors
A heading's anchor is derived from its text. ## Configure Grading becomes #configure-grading. Two
consequences follow.
Do not number headings manually. ## 1. Creation and Configuration produces the anchor
#1-creation-and-configuration, and ### 1.6 Manage Student Exams produces #16-manage-student-exams. Every link
to that section then encodes a section number, so inserting one section silently redirects a dozen links to the
wrong place — silently, because the anchors stay syntactically valid and the build cannot tell that the meaning
moved. Let the heading say what the section is about and let the sidebar supply the ordering. Numbered steps
belong in an ordered list, not in headings.
To pin an anchor, put it in an MDX comment. Sometimes a heading needs an id its own text would not produce —
keeping an inbound link alive after a rewrite, most often. The documented Docusaurus form, {#custom-id}, cannot be
used here: MDX reads the braces as a JSX expression and the build fails with Could not parse expression with acorn. Wrapping it in a comment works, and is what this repository already does:
### Naming Conventions for GitHub Pull Requests {/* #pr-naming-conventions */}
That renders as <h3 id="pr-naming-conventions">Naming Conventions for GitHub Pull Requests</h3>. Use it to
preserve an existing anchor, not to invent a shorter one — a heading whose id has nothing to do with its text is
hard to find again.
Two further rules:
- Do not skip heading levels. The body starts at H2, so an H4 directly under an H2 leaves a hole in the outline.
- Keep heading text unique within a page. Repeated text produces
#the-headingand#the-heading-1, and neither link tells the reader which one it goes to. Nesting a heading inside an identically named parent is the common version of this mistake.
Naming what the reader sees
Write the name of a control in bold, exactly as the interface spells it.
Check Enable manual assessment, then set the Release Date of Results.
Do not put control names in backticks. Backticks mean code, so `Generate individual exams` reads as an
identifier rather than a button, and a reader looking for that string in the interface does not know whether the
capitalisation is real.
Backticks are for what actually is code: file and directory paths, class and method names, configuration keys, commands, and literal values a reader types or a build produces.
The translation file is the authority for a label, not your memory of the screen. Before naming a control, check
it in src/main/webapp/i18n/en/*.json. Labels get reworded, and documentation that names a control the interface no
longer has costs a reader more time than no documentation at all. The same check applies to whether a control exists
at all: several fields are hidden depending on configuration — anything gated on isLocalCIEnabled, for example,
does not appear on an instance running Integrated Code Lifecycle — so say which setup a control belongs to.
Do not leak internal vocabulary into instructor and student pages. Students have no concept named "VC server" or "build plan"; they have repositories and builds. Naming a component precisely is a separate obligation that applies to every page — see Terminology.
Screenshots
Use the Image component rather than a raw <img> tag, so that every figure gets the same frame, sizing and
caption treatment:
import Image, {ImageSize} from "../../../src/components/Image/Image";
import configureGrading from './assets/exams/configure-grading.png';
<Image src={configureGrading} alt="Configure Grading page" size={ImageSize.large}
caption="Test case configuration on the Configure Grading page" />
The number of ../ segments depends on how deep the page sits, so copy the import from a neighbouring page in the
same directory rather than from here.
Crop to the subject. A figure about one panel should show that panel, not the whole browser window with the navigation bar and the course sidebar around it. The chrome adds pixels, dates the screenshot faster than its content does, and pushes the thing being explained down to a corner. Capture the element rather than cropping by pixel coordinates where you can — pixel crops cut off text as soon as anything reflows.
Do not photograph a button. A cropped picture of a labelled control, embedded inline in a sentence, has to be recaptured on every restyle, is invisible to the site's search index, carries a light background into dark mode, and tells a reader less than the label written in bold does. Write the label instead.
The inline prop on Image exists for the genuine case: an icon that carries no text of its own, such as the hint
icon next to a form field. A control that has a name is named.
Capture with realistic data. A screenshot of an empty table, a course called test, or a student named
student1 teaches the reader nothing about what they will see. Populate the local instance with plausible course,
exercise and participant data first. An older screenshot with real content in it is worth more than a fresh one that
is empty — if a recapture would come out emptier than what is already there, leave the old one and note why.
Retire what you replace. When a screenshot stops being referenced, delete the file. Unreferenced assets accumulate invisibly; they are never noticed until someone measures.
Generated screenshots
A screenshot that can be rendered again from the demo course does not have to be recaptured by hand.
documentation/screenshots/manifest.json describes such a screenshot, and src/test/playwright/docs-screenshots renders every entry in a
light theme, in English and with reduced motion. An entry has these fields:
| Field | Meaning |
|---|---|
image, pages | The image below documentation/docs and the pages that show it. |
user | A demo user: student, tutor, instructor, or participant. The participant is a student whom no other image shows, so that an entry can start an exercise or a quiz without changing the pages of the others. |
route | The route, with {courseId} for the demo course and {exercise:Title}, {exam:Title} or {lecture:Title} for the id of the item of the demo course with that title. |
viewport, deviceScaleFactor | The size of the browser window and its pixel density. |
selector or clip | A crop of the page: the element that the image shows (a CSS selector), or a box {x, y, width, height} in CSS pixels. Without either, the image is the whole window. A page that scrolls inside the window shows only the part that fits, so a selector of a tall element needs a viewport that is tall enough. |
padding | With selector: CSS pixels around the element, so that the border of a field at its edge is not cut off. |
waitFor | A CSS selector that has to be visible before the image is taken, for content that loads late. |
steps | What happens before the image is taken, in order: fill ({selector, value}), click, clickIfPresent (for an action that exists only the first time, such as starting an exercise), check, select ({selector, label}), upload ({selector, file}, a file below the repository root, such as one of documentation/screenshots/fixtures) and waitFor. A selector is a Playwright selector, so text=Start exercise works. |
hide | CSS selectors of elements that cover the content, such as a sticky footer. They become invisible and keep their space. |
mask, maskSelector | The data-testid values or CSS selectors of elements with content that changes on every run. They are painted over. |
threshold, note | A share of changed pixels (0 to 1) that is tolerated for this image alone, and the note that says why the image varies, such as a graph with a random layout. |
source | The paths of the components and translations that the image shows. --stale lists the image when one of them changed after it. |
Before an image is taken, the renderer postpones the passkey reminder, skips the first-visit introduction of the competency management, hides the development ribbon and the footer (they show the instance, not the page), waits for chart animations, and replaces relative time stamps such as "Today, 12:10" and "5 minutes ago" by constants. The time of day of an absolute date such as "Oct 28, 2026 15:35" is replaced by 10:00, because it is the minute of the seeding. The day stays: it belongs to the demo data, which is created relative to the day of the run, so images with dates and the calendar differ a little from one day to the next, which the threshold of the comparison tolerates.
One command. ./supporting_scripts/e2e/render-docs-screenshots.sh starts a PostgreSQL container, a server with the demo profile and the client
on ports of its own (18080, 19000, 17921 and 15432, changeable with DOCS_SHOTS_*_PORT; give a second checkout that renders at the same time its own DOCS_SHOTS_DB_CONTAINER), renders the entries, compares the images with the
checked-in ones and stops everything it started. It never stops a process that it did not start, so it can run next to a development environment.
It needs Docker, Java 25 and Node. Options:
--updatereplaces the checked-in images with the rendered ones. Look at them withgit statusand the image diff of your tool, and commit what you keep.--only calendar-month-desktop,student-faqrenders and compares only these entries.--browser hostuses the Playwright browsers of the machine. The defaultcontainerruns the browser in a pinned Playwright image, so the text of an image is rendered the same way on every machine. Images that you update on another browser differ from the ones that others render.--keepleaves the stack running for repeated renderings, and--stopstops it.--stopstops only the processes of this checkout on the ports of the script and the database container that carries the label of this checkout. Anything else on those ports is reported and left alone.
The server listens on the loopback interface only, and its admin account has a random password for each run. In the default container mode the client listens
on all network interfaces for the duration of the run, because the browser container reaches it through the address of the host, and its proxy reaches a demo
server with known demo logins; the script prints a warning, and --browser host avoids the exposure (the images then differ slightly in the text rendering).
Comparison. python3 supporting_scripts/check_docs_screenshots.py --compare build/docs-screenshots --all prints the similarity of every image.
A size that differs by up to two pixels is compared on the area that both images have, and an image counts as different when more than 1 percent of the
pixels (--threshold) differ. --stale lists the images whose source changed after the image.
To add a screenshot, add an entry with the viewport of the image, render it with --only <id> --update (the manifest check needs the image to exist, so it
cannot run before this step for a new image), look at the image, check the manifest with python3 supporting_scripts/check_docs_screenshots.py, and
commit it. The check requires that every listed page imports the image by its path, so a page that only mentions the file name fails it.
When to render. The screenshots are rendered on demand, on your machine, not by a scheduled job. Run the script after a bigger change of the interface
and before a release, look at the changed images (a changed demo course is not a change of the page), render again with --update, and commit the images
that you keep. The script needs Docker, JDK 25 and Node and takes about eight minutes: three to start the stack and four to render the images. The weekly
docs-maintenance.yml needs no stack and lists the images whose source files changed after the image (--stale) in an issue of its own, which tells you
which images to look at first.
The manifest covers 78 images of the student and instructor guides. They show the one demo course, "Introduction to Software Engineering", so
the same users, exercises, lectures and exam run through the whole documentation. An image has to show what the text around it says. A screenshot
does not have to reproduce the older hand-made image pixel by pixel, but it has to show the same screen and the controls that the text names. When the
demo course cannot show them (an iPad app, the exercise list during an exam, the test cases of a programming exercise), the image
stays manual and gets no entry, and when only the description is wrong, such as the type of a grading key or the number of questions in a list, the
description is corrected in the same change. A page that the demo course cannot fill yet may get a small extension of the demo seeding instead.
The script keeps the repositories of the demo programming exercise below build/docs-screenshots-stack, so a repository of an earlier run cannot
keep the exercise from being seeded.
Screens that need data the demo course does not have (a programming exercise with builds, a started exam)
are not part of the manifest; such screenshots stay manual and keep the rules above.
Screencasts
Embed TUM-Live recordings with the TumLiveVideo component, passing the bare recording id:
import TumLiveVideo from "../../../src/components/TumLiveVideo/TumLiveVideo";
<TumLiveVideo src="67223" title="Quiz Training Mode Tutorial" height={400} />
The component turns a bare id into https://tum.live/w/artemisintro/<id>/PRES and passes a full URL through
unchanged. Prefer the bare id: it is the short form, it is what the component's own documentation shows, and it does
not pin the page to a hostname.
The suffix names a view — PRES for the presentation, CAM for the camera, COMB for the two combined. It makes
no difference to any recording the Artemis documentation embeds, because each of those is published with a combined
view only and TUM-Live serves what exists. Do not read that as a reason to trust a URL you have not opened: the
site answers HTTP 200 for any recording id and any suffix, invented ones included, so a status code says nothing
about whether either is real.
To find out how old a screencast is, open its TUM-Live page — the recording date and time sit under the title. The
manifest the page loads carries the same date in its filename, artemisintro_2026_04_13_10_00COMB.mp4, which is
the more convenient form when checking many recordings at once.
A stale screencast is worse than none. A reader who follows a video through an interface that no longer looks like that loses the time twice: once watching, once working out which parts still apply. When a recording no longer matches the product, re-record it or remove the embed. Do not leave it in place next to screenshots that contradict it.
Callouts, tables and separators
Use the Callout component for anything set apart from the flow, with the variant that matches the message —
info, tip, success, warning or danger:
<Callout variant={CalloutVariant.warning}>
Checkout paths can only be changed while the exercise is being created.
</Callout>
Reserve warning and danger for consequences a reader cannot undo. A page where every third paragraph is a
warning has no warnings.
Use a table when the reader is comparing values across a fixed set of things — languages against supported
features, settings against their effects. Markdown tables are enough for that. Reach for raw <table> markup only
when you need rowSpan or colSpan, which Markdown cannot express.
Do not separate sections with --- horizontal rules. Headings already separate sections, and the rules add a second
inconsistent visual rhythm on top of them.
Prose over bullet lists
A bullet list is for things that genuinely are a list: options, steps, requirements, alternatives. It is not a way to avoid writing sentences. Three bullets that are three sentences of one argument read worse than the paragraph, because the reader has to reconstruct the connection between them that the bullets threw away.
Write in the present tense, describing what the reader sees and does. Do not reference pull requests, issues or commits, and do not describe a feature relative to a previous release — the reader is looking at the version that is deployed, not at a changelog.
Links
Link across sections and audiences with an absolute site path:
See [Integrated Code Lifecycle](/instructor/integrations/integrated-code-lifecycle).
Relative links resolve against the page's own directory, so they break the moment a page moves into a
subdirectory — ../assessment-grading/plagiarism-check starts pointing one level too deep and the build fails.
Relative links between two pages in the same directory are fine and are the exception.
onBrokenLinks, onBrokenAnchors and onBrokenMarkdownLinks are all set to throw, so the build catches a link
to a page or an anchor that does not exist. It cannot catch two things: a link whose target moved but whose anchor
still resolves somewhere (see the numbering rule above), and a placeholder ](#), which is syntactically a valid
link to the top of the page. Do not leave placeholders behind.
When a feature has both an instructor and a student page, link each from the other. An instructor deciding how to configure something needs to see what the student will get, and a student reading about a limit needs to know who sets it.
Page size and landing pages
Once a page passes roughly 300 lines, check whether it is still about one thing. A long reference page — a table of supported languages, a catalogue of analysis rules — is fine at any length, because a reader arrives looking up one row. A long task page is not, because a reader arrives with one task and has to scroll past five others.
Split the second kind into a category: a landing page at the original URL, and focused pages beneath it. Splitting later is more expensive than it looks, because every inbound link and in-product help URL has to keep resolving — so check the anchors before moving text, not after.
A category landing page opens with one or two sentences saying what the area is for, followed by a table of the pages beneath it:
| Page | Description |
|------|-------------|
| [Create an Exercise](/instructor/exercises/programming-exercise/create-an-exercise) | The creation form field by field, simple and advanced mode, and import |
| [Write Code and Tests](/instructor/exercises/programming-exercise/write-code-and-tests) | Repositories, the problem statement, and the supported test frameworks |
The description says what the reader will find, not what the page is called again.
Checking your work
Build the site before opening a pull request:
cd documentation
pnpm install --frozen-lockfile
pnpm run build
The build is the only check for broken links and anchors, and it is strict: a single bad link fails it. Run
pnpm run start while writing to preview a page, but run the full build before pushing — start tolerates things
build rejects.
The terminology check runs over documentation too, and it only sees tracked files, so stage a new page before running it:
git add documentation/docs/<audience>/<your-page>.mdx
python3 supporting_scripts/check_terminology.py
Keeping the documentation up to date
Documentation goes stale when the code changes and nobody changes the page. Most of what an audit finds is of a few kinds: a moved or deleted file, a renamed button or menu entry, a changed default, a removed setup, and a screenshot of an old user interface. The following habits prevent them.
- Change the page in the same pull request as the code. A pull request that changes a configuration key or its default,
a deployment file under
deployment/docker/, a command or a Gradle task, a user-facing label or the navigation, or the export format of a file has to update the pages that mention it. Search the documentation for the old name before you open the pull request. - Verify before you write. Take a label from
src/main/webapp/i18n/en/, a default from theapplication-*.ymlfile, and a version fromgradle.propertiesorpackage.json. Do not copy them from another page. - Do not hard-code what is generated. Prefer a link to the source of truth, or a generated page, over a copied list or version number. If a page must carry a snapshot, such as a measurement, put the date next to it.
- Mark what is going away. Code that exists only for a feature that is scheduled for removal carries the marker
REMOVE_IN_ARTEMIS_11followed by the name of the feature. Do the same for a page or a section, so that the removal pull request can find it. - Recapture screenshots in the same change that restyles the screen. A screenshot of an old interface is worse than none. Name the file after what it shows, so that a search for the screen finds every image of it.
Checks keep these habits honest. They only report what they can prove, so they do not replace reading the page next to the code.
| Check | What it proves | Where it runs |
|---|---|---|
python3 supporting_scripts/check_docs_references.py | Every page is in its sidebar. Inline code names an existing repository path, an existing class and, for Class.method(), an existing method. Properties (artemis.*), environment variables (ARTEMIS_*) and the keys of a YAML example are defined in the configuration or the source. A bold label after a verb such as "Click" is a text of the English translation files. | Every pull request (docs-checks) |
python3 supporting_scripts/check_docs_consistency.py | The numbers, commands and examples next to the names are true. Versions (Java, Node, Angular, Spring Boot, PostgreSQL, Gradle) match the build files. Defaults of artemis.* properties match the YAML. Commands (pnpm run, ./gradlew, Spring profiles, paths in shell examples) exist. Routes of the client pages and /api/ paths consist of segments that the route definitions and mappings contain. Images are referenced, have alt text and stay within the size budget. Examples in JSON parse, shell passes bash -n, YAML has no tabs. Removal markers (REMOVE_IN_ARTEMIS_<n>) are gone once that version is reached. Glossary: no page uses a word of the Avoid column of the glossary. | Every pull request (docs-checks) |
python3 supporting_scripts/check_docs_freshness.py --validate | Every page has a valid last_reviewed date in its front matter. | Every pull request (docs-checks) |
python3 supporting_scripts/generate_architecture_diagrams.py --check | The UML diagrams of the system design are what their generator draws. | Every pull request (docs-checks) |
python3 supporting_scripts/check_docs_freshness.py --impact | Lists the pages that mention a file or a class that the pull request changes, and the edited pages that kept an old review date. It is advice and never fails. | The summary of the docs-checks job |
python3 supporting_scripts/check_docs_screenshots.py | Every entry of the screenshot manifest names an existing image that its pages show, a known user, a route with a known placeholder, and existing source paths. | Every pull request (docs-checks) |
docs-maintenance.yml | Opens an issue for every page that was not reviewed for a year, with the owner from .github/CODEOWNERS of each page, one for external links that no longer work, and one for screenshots whose component or translations changed after the image. | Weekly |
Paths that are expected to be missing in a checkout, such as build output or a file the reader creates, are exempt through git ignore rules. Every other exception is an entry of an allowlist at the top of the script, and needs a reason. The check reads the whole tree, so a pull request that renames a class or a property fails on the page that still names the old one, although it does not touch the page.
Review dates. last_reviewed: YYYY-MM-DD in the front matter of a page is the day on which someone compared the page with the code and found it correct.
Whoever changes a page and checks it updates the date, and a new page starts with the date of its creation.
Do not update the date for a change that did not involve that comparison, because the weekly list of stale pages relies on it.
Glossary. The glossary defines the terms of Artemis. Use its term, and when a word is a common wrong name for a term, add it to the Avoid column of that term: the consistency check then fails on every page that uses it. The column is the one place where the preferred vocabulary is decided.
Image format. Screenshots stay PNG, and the check enforces a size budget instead of a format. Lossy WebP blurs the small text of a screenshot, and lossless WebP saves only a moderate share of the size. Converting would also rewrite every reference and keep both formats in the history of the repository. Crop a screenshot to the area that matters, and the file stays small.
Diagrams. Edit supporting_scripts/generate_architecture_diagrams.py, run it, and commit the generated SVG files together with the change.
A diagram that was edited by hand fails the check.
Audit every area (admin, developer, instructor, student, about) against the code regularly, for example once per major release. Compare labels and defaults with the i18n files and the configuration, check that the pages for removed or deprecated features say so, and look at the screenshots of the screens that changed. The list of stale pages tells you where to start.
Related
- Glossary: the terms of Artemis and the words to avoid for them.
- Terminology — naming components instead of layers, and the check that enforces it.
- Language — inclusive, diversity-sensitive and appreciative language.
- Work with AI — how the agent skills in
skills/relate to these guidelines, and which ones to update when a convention changes.