How to: Work with the User Guide¶
This guide explains how to develop and maintain the SyRF User Guide, which provides end-user documentation for the SyRF platform.
Overview¶
The User Guide is a static documentation site written in Markdown with Jekyll-style front matter. Since spec 6H H4 the
published image is built with Starlight (Astro, user-guide/site/), to design D (below); the Jekyll build
(just-the-docs theme) still builds from the same pages until H5
retires it, as the cross-renderer reference and the rollback. It is:
- Published at: help.syrf.org.uk
- Location:
/user-guide/directory in the monorepo (pages);/user-guide/site/(the Starlight build) - Build: Starlight by default (
GUIDE_RENDERER=starlight);--build-arg GUIDE_RENDERER=jekyllbuilds Jekyll
Prerequisites¶
- Docker and docker-compose (for local development)
- Basic knowledge of Markdown
- Understanding of Jekyll front matter
Directory Structure¶
The user guide is organized into logical sections:
user-guide/
├── index.md # Home (Getting Started)
├── about.md # About CAMARADES & SyRF
├── getting-started/ # Introductory content
├── account/ # Account management
├── projects/ # Project management
│ └── membership/ # Nested membership section
├── studies/ # Study/reference management
├── stages/ # Screening and annotation
├── data-export/ # Data export guides
│ └── data-dictionary/ # Field definitions
├── whats-new/ # Release notes
└── figs/ # Images and screenshots
Navigation System¶
The just-the-docs theme uses YAML front matter to build the navigation sidebar.
Front Matter Fields¶
| Field | Required | Description |
|---|---|---|
title |
Yes | Page title in navigation |
nav_order |
Yes | Position in sidebar (lower = higher) |
parent |
For children | Title of parent page (exact match required) |
has_children |
For parents | Set to true if page has children |
layout |
Yes | Usually page, use home for landing pages |
permalink |
Optional | Custom URL path |
syrf |
Optional | The feature topics the page covers (syrf.topics). Jekyll ignores it; the content compiler, the in-app help links and the registry validator read it. See Guide topics |
Example: Parent Page¶
Example: Child Page¶
Example: Grandchild Page¶
Note: The parent value must exactly match the title of the parent page.
Adding Content¶
Creating a New Page¶
- Choose the right directory based on the content topic
- Create the markdown file with descriptive name
- Add front matter with correct parent/nav_order
- Write content using GitHub Flavored Markdown
- Add images to
figs/directory
Image References¶
Images are stored in figs/ and referenced with relative paths.
The pattern depends on the file's depth relative to the figs/ directory:
| File Location | Image Path |
|---|---|
Root level (index.md, about.md) |
figs/image.png |
First-level subdirectory (projects/join.md) |
../figs/image.png |
Nested subdirectory (projects/membership/inviting.md) |
../../figs/image.png |
Use standard Markdown image syntax: ![alt text] followed by (<relative-path>).
Internal Links¶
Use Jekyll's link tag for reliable cross-page linking.
The syntax is: [Link Text] followed by ({{ site.baseurl }}{% link path/to/page.md %}).
Or use relative HTML links: [Link Text] followed by (../path/to/page.html).
Moving, renaming or retiring a page¶
Guide URLs never break (spec 6H H1). user-guide/url-history.json lists every URL the guide has served since the
tree before the 2025-12-05 reorganisation: pages at their permalink or path, content files (figures, CSV files,
LICENSE-CC-BY) and the files the build generates. It is generated and append-only. A URL the guide stops serving
must be the from of an entry in user-guide/redirects.yaml, in the same change:
- from: /stage-settings.html # the old URL, as the history lists it
to: /stages/settings.html # the page that replaces it now (a served URL in the history)
reason: moved # moved | retired | alias
Then run pnpm generate (from the repository root). It appends any new URLs to the history and regenerates
user-guide/nginx/redirects.conf, the exact-match nginx 301s the image serves, with a relative Location. A page
also answers without .html, and a directory page as x/ and x, so one entry covers every form.
pnpm run validate:generated (pre-commit and CI) refuses, with the exact problem:
- a history URL that is no longer served and has no redirect (the message names it and the entry to add);
- a
fromthe guide still serves (a redirect never shadows a page); - a
tothat is absolute or external, has a query or fragment, is not in the history, is not served, or is itself redirected (no chains or loops; repoint the older entry at the final page); - an edited or shortened history (it is compared with the git base).
retired means there is no direct replacement and to is the nearest page; a redirect never claims the old page was
public. Build tooling that Jekyll happens to copy (README.md, GitVersion.yml, the shell scripts) is not part of
the URL contract.
Local Development¶
Using Docker (Recommended)¶
Visit http://localhost:4000 to see the site.
Using Ruby/Bundler¶
Current Navigation Structure¶
| nav_order | Section | Description |
|---|---|---|
| 1 | Getting Started | Home page and intro content |
| 2 | Account | Account creation and management |
| 3 | Projects | Project management (with Membership subsection) |
| 4 | Study Management | Uploading and managing references |
| 5 | Stages | Screening and annotation configuration |
| 6 | Data Export | Exporting project data |
| 7 | About Us | About CAMARADES and SyRF |
| 8 | What's New | Release notes and updates |
Best Practices¶
Content Guidelines¶
- Write for end users, not developers
- Use clear, simple language
- Include screenshots for complex UI interactions
- Link to related pages where helpful
- Keep pages focused on a single topic
Navigation Guidelines¶
- Maintain sequential
nav_orderwithin each section - Use descriptive but concise page titles
- Group related content under parent pages
- Don't nest more than 2 levels deep (parent → child → grandchild)
Image Guidelines¶
- Use PNG format for screenshots
- Include descriptive alt text for accessibility
- Crop images to show relevant UI elements
- Use consistent naming:
Fig_Section_Description.png
CI/CD Pipeline¶
The user guide is built and deployed automatically:
- Trigger: Push to
mainbranch with changes inuser-guide/ - Build: Starlight builds the static site (the image's default renderer since spec 6H H4)
- Check guide links (spec 6H H1): before the push, the image runs as two local containers and
.github/scripts/check-user-guide-links.shchecks that every history URL answers200or one relative301to a200, that every in-app help link (web templates,whats-new.json, help topics, runtime What's New and walkthrough bodies) answers200with its anchor, that the two containers give the same pages (self-parity), and thatuser-guide/test-seo-headers.shpasses. The reports are a workflow artefact and the job summary; broken links inside pages are listed as warnings. The same step then runs the accessibility checks (spec 6H H2, below) - Package: Docker image created and pushed to GHCR
- Deploy: ArgoCD syncs to Kubernetes cluster
- Access: Available at help.syrf.org.uk
The same gate also runs on pull requests that change the guide (#4596):
the Check user guide job in .github/workflows/pr-tests.yml stages the publication manifest, builds the image
locally (never pushed, no preview needed) and runs .github/scripts/check-user-guide-links.sh on it. It runs when a PR
changes user-guide/, e2e/user-guide/, e2e/playwright.user-guide.config.ts, e2e/package.json, pnpm-lock.yaml,
the guide gate scripts, the URL and parity tools, the help content compiler or its inputs (feature-definitions.yaml,
docs/content/protected/, docs/content/whats-new.yaml), the in-app link sources docs/content/walkthroughs.yaml,
whats-new.json and help-topics.generated.ts, or .dockerignore. A failure fails the required Test Summary, so a
page with a new accessibility finding, a broken link or a stale baseline entry cannot merge. The reports are the
user-guide-pr-gate-<run>-<attempt> artefact. Links added in web templates do not select the job; main's guide build
still checks them. The gate checks the image in its default renderer's mode (Starlight: theme files exempt,
zero-tolerance accessibility, the design and motion checks). The cross-renderer comparison with the Jekyll build runs in
the image build on main and labelled previews only.
To run the same gate on a local image (root and e2e tooling installed, image built as in the user-guide README):
pnpm install --frozen-lockfile --ignore-scripts --filter syrf --filter @syrf/e2e
bash .github/scripts/check-user-guide-links.sh syrf-user-guide:local "$(mktemp -d)"
Accessibility checks (spec 6H H2)¶
.github/scripts/check-user-guide-a11y.sh, the last stage of "Check guide links", runs
e2e/playwright.user-guide.config.ts in the pinned Playwright container (mcr.microsoft.com/playwright, the version
e2e locks, pinned by digest), joined to a guide container's network. For every page in user-guide/url-history.json
that the image serves (URLs that answer a redirect are skipped; H1 checks them), at 1280 and 390 px:
- axe-core (
@axe-core/playwright, exact version) with the WCAG 2.0, 2.1 and 2.2 A and AA tags; - keyboard: the first Tab stop is a skip link (
syrf-skip-link), every Tab stop shows a focus indicator and sits in the viewport (syrf-focus-visible, first 80 stops), focus never gets stuck (syrf-keyboard-trap), and on the home page search opens, shows results and closes by keyboard with focus back on the control (syrf-keyboard-search).
Light and dark both run when the guide offers a dark scheme (prefers-color-scheme: dark changes the home page). The
Jekyll theme (just-the-docs 0.3.3) loads only its light stylesheet, so the dark contexts record "skipped" today and
start checking automatically once a renderer offers dark (H3). Requests to other origins (the YouTube embeds) get a
local stub, so results never depend on the network.
Each finding is keyed by page, rule and target. user-guide/a11y-baseline.json records the findings on the Jekyll guide
when H2 landed (1,365: mostly theme colour contrast and unnamed navigation expanders, no skip link). Since #4594 the
template fixes those itself: user-guide/_includes/head_custom.html adds the skip link and names the navigation
expanders and the menu button, and user-guide/_sass/ sets the link and footer contrast and the search focus ring. The
baseline is down to 57 page-specific entries (code highlighting, target size, task-list labels, iframes and the 404
page), so a new page normally adds no finding. A finding that is
not listed fails the build with the page, rule and target; a listed entry that a complete run no longer finds also fails
until you delete it, so the baseline only shrinks. Fix a new finding rather than adding it. After fixing a listed
problem, delete its line (or regenerate the file, then review the diff: it must only remove lines):
GUIDE_A11Y_UPDATE_BASELINE=1 bash .github/scripts/check-user-guide-a11y.sh <running-guide-container> "$(mktemp -d)"
Some finding targets are positional (for example .nav-list-item:nth-child(3) > …), so adding, removing or
reordering a navigation item in an ordinary content change can move those selectors. The build then reports a new
finding (the selector at its new position) and a stale entry (the old one) for the same problem, and fails. This is
not a new accessibility regression: build the guide container, run the refresh command above, and review the diff.
Only selectors of problems that already existed may change position; any genuinely new finding must be fixed, not
baselined. Commit the regenerated user-guide/a11y-baseline.json with your content change.
The controls project proves the checks on every run: an unlabelled button and 3:1 text fail axe and pass once
fixed; a page without a skip link or focus indicator, a keyboard trap and a search that Escape does not close each
fail. The report (a11y-report.md and .json, per-page results and the Playwright HTML report) is in the
user-guide-parity-* artefact under a11y/ and in the job summary.
If Playwright stops without writing a11y-report.json, the script prints why from docker inspect: the exit code,
whether the kernel's OOM killer ended it (oomKilled=true, at the container's 3 GiB limit), or that the container was
removed while it ran. Exit 137 with no OOM kill means something else ran docker rm -f on it. Only the user-guide
build's residue step removes the check's containers and image. DOCKER_BUILD_JOB_KEY includes the service name, so
sibling builds do not share it; the guard stays as defence in depth because sibling builds on one host share the Docker
daemon and the guide image tags are per run (run 40243556961 failed when the docs build's clean-up ran mid-scan, before
the key carried the service).
The Starlight build of the guide (spec 6H H3; published since H4)¶
user-guide/site/ is a Starlight (Astro) build of the same pages, a pnpm workspace member (syrf-user-guide). It reads
the Markdown in user-guide/ in place (no copy) and writes every page at its Jekyll URL (a/b.md → a/b.html,
a/index.md → a/index.html, permalinks exact), so the URL history, the redirects and every in-app link work
unchanged. Since H4 it is the image's default renderer (ARG GUIDE_RENDERER=starlight, generated from
docs/architecture/dependency-map.yaml); the guide is not runtime-flagged, because the image is static and stateless
and every image passes the gates below before it is pushed.
What the build adds to plain Markdown, so both renderers show the same text, links, images and heading IDs:
src/guide/site.ts: Jekyll's publishing rules (which files are pages,permalink:,_config.ymlexclude:);src/guide/liquid.ts:{{ site.baseurl }}and{% link page.md %}only; any other Liquid fails the build;src/guide/remark-kramdown.tsandtypography.ts: kramdown's heading IDs (computed with the content compiler's own rules inguide-topics.ts, so help topics and the publication manifest keep their anchors;{#id}honoured), attribute lists ({: .note },{:target="_blank"},{: .fs-9 }),{:toc}, smart quotes and dashes, and1)text; figures stay at their own URLs (never/_astro/); an unknown construct fails the build;src/guide/nav.ts: the sidebar and each parent page's "Table of contents" fromparent,grand_parent,nav_orderandhas_children, by the Jekyll theme's rules (a page whoseparentnames no page is listed nowhere, except the What's New pages whoseparentis the missing "What's New!":PARENT_ALIASESlists them under What's New in the navigation only);src/guide/callouts.ts: a top-level paragraph (or blockquote) that starts with a bold Note: renders as a note callout; a paragraph of images is a framed figure; a table sits in a rounded frame that scrolls sideways. None of these changes the page's text, links or images;- the look, design D (Chris, 27-28 October 2026; plan §7.4 "H4 as specified"):
src/styles/syrf.cssand the component overrides insrc/components/(navy bar, navigation rail and phone drawer, the article on a 24 px sheet, the section badge, "On this page", previous and next cards, the partner footer, the purpose-made home page and the search view). Colours are the web app's tokens, light and dark; type is Roboto Flex, served from the image (@fontsource-variable/roboto-flex, its licence at/_guide/fonts/roboto-flex/OFL.txt); the brand images inpublic/_guide/brand/are copies of the web app's own assets (test/brand-assets.test.tskeeps them identical). Buttons move to Google's Compose Material 3 values (press morph on the DefaultEffects spring, state layers, the home button group's bounce on FastSpatial; none with reduced motion); - search is Pagefind's static index, shown in the search view (
/opens it; ↑↓ move, Enter opens, Esc closes); every indexed page carries a Pagefindsectionfilter, its navigation title and its crumb (src/components/PageTitle.astro).
Authors write pages as for Jekyll, except the home page: the published home (/) is the purpose-made
user-guide/site/src/components/Home.astro, not the body of user-guide/index.md, which only the Jekyll build renders
(and the parity gate does not compare the home's text, because it is a listed redesigned page). To change the home's
words or links, edit Home.astro and make the same change in index.md; user-guide/site/test/home-source.test.ts
pins index.md's body and fails on an edit to it until the test's copy is updated, and checks that Home.astro still
carries the body's subtitle, wiki sentence and links. To preview the Starlight build locally (Node only):
pnpm install --frozen-lockfile --filter syrf-user-guide
pnpm --filter syrf-user-guide run build # user-guide/site/dist/
pnpm --filter syrf-user-guide run dev # live preview
pnpm run validate:generated runs the site's unit tests (user-guide/site/test/, one per construct, plus every
page's heading IDs against the compiler, the callouts, the sections, the motion stylesheet and the brand copies).
The gates, all blocking (H4). On every guide image build, "Check guide links" checks the default (Starlight) image
in its Starlight mode: the URL history and in-app links (Jekyll theme files exempt), self-parity, headers, the
compiler's heading anchors, axe and keyboard checks with zero tolerance (no baseline; light and dark; 1280 and 390 px),
and the design Playwright project (e2e/user-guide/design.spec.ts for design D, motion.spec.ts for the button
motion). Then CI builds the Jekyll image of the same commit as the reference (never pushed) and runs
.github/scripts/check-user-guide-starlight.sh on the pair: the publication manifest and a cross-renderer comparison
of every history URL and in-app link. Differences the comparison may accept are listed with a reason in
user-guide/site/parity-exceptions.yaml: Jekyll theme files the Starlight build does not serve, three pages whose
malformed or escaped Markdown kramdown and CommonMark read differently, and the one redesigned page (the home page:
new layout and text, but every heading ID, link and image of the Jekyll home kept). An entry that stops matching fails,
so the list only holds real differences. The user-guide-parity-* artefact holds the reports of "Check guide links" at
its root (check/ and self-parity/ with parity-report.json and .md, a11y/ with a11y-report.json and .md,
per-page results and the Playwright report, anchors/anchors-report.json) and those of the Starlight step under
starlight/ (manifest.txt, and renderers/ for the cross-renderer parity-report.json and .md). To run both on
local images:
docker build -f user-guide/Dockerfile -t syrf-user-guide:local .
docker build -f user-guide/Dockerfile --build-arg GUIDE_RENDERER=jekyll -t syrf-user-guide:jekyll .
bash .github/scripts/check-user-guide-links.sh syrf-user-guide:local "$(mktemp -d)"
GUIDE_STARLIGHT_GATE_DONE=1 bash .github/scripts/check-user-guide-starlight.sh syrf-user-guide:jekyll syrf-user-guide:local "$(mktemp -d)"
Rollback to Jekyll (until H5)¶
Roll back with a forward PR (SyRF merges with merge commits, and H4 arrived as several commits, so do not revert commits). The PR changes the default renderer and puts the Starlight checks back to report-only; design D stays in the Starlight build, which keeps being built and checked:
docs/architecture/dependency-map.yaml:user-guide.starlight_build.default: "jekyll", thenpnpm generate, so the generateduser-guide/Dockerfilestarts withARG GUIDE_RENDERER=jekyll(and.generated-checksums.jsonfollows)..github/workflows/_docker-build.yml, the two steps after "Check guide links" (H3's text is onmainatdb3021ba83, steps "Load the Starlight guide image for the proof" and "Check the Starlight build of the guide"):- "Load the Jekyll guide image for cross-renderer parity" builds Starlight again:
build-args: GUIDE_RENDERER=starlight,tags: ${{ env.GUIDE_STARLIGHT_IMAGE }}(rename the job envGUIDE_JEKYLL_IMAGEtoGUIDE_STARLIGHT_IMAGE: syrf-user-guide-starlight:…, also in "Remove and verify job-owned residue"), andcontinue-on-error: true; - "Check the Starlight build of the guide":
continue-on-error: true; its run line becomescheck-user-guide-starlight.sh "$GUIDE_CHECK_IMAGE" "$GUIDE_STARLIGHT_IMAGE" …with noGUIDE_STARLIGHT_GATE_DONE=1(the checked image is Jekyll again, so the Starlight gate must run here), plus H3's error-to-warning rewrite and job-summary line. .github/scripts/test-user-guide-link-check-contract.sh: the line[ "$(grep -m1 -E '^(ARG|FROM) ' "$DOCKERFILE")" = 'ARG GUIDE_RENDERER=starlight' ]expectsjekyll; theblocking_contractfunction and its five mutants go back to H3'sreport_only_contractand its two mutants; the reference-step checks (load_jekyll,GUIDE_JEKYLL_IMAGE) go back to H3'sload_starlight/GUIDE_STARLIGHT_IMAGEchecks (all onmainatdb3021ba83).
Nothing else changes: check-user-guide-links.sh reads the default renderer from the Dockerfile, so "Check guide links"
checks the Jekyll image against user-guide/a11y-baseline.json with no edit; the design Playwright project runs only
in the Starlight (zero-tolerance) mode; pages, URLs, help topics and the publication manifest are untouched. The Jekyll
build is built in every guide image build until H5 (it is the cross-renderer reference), and the Jekyll image of H4's
own commit passed the Jekyll-mode gate on Bramble, so the rollback image is known to ship. Production takes the
rollback, like any guide release, through an owner-approved promotion.
Troubleshooting¶
Navigation Not Showing¶
- Verify
parentvalue exactly matches parent'stitle - Check
nav_orderis set - Ensure front matter delimiters (
---) are correct
Images Not Loading¶
- Verify correct relative path based on file location
- Check file exists in
figs/directory - Ensure filename case matches exactly (Linux is case-sensitive)
Build Failures¶
- Check Jekyll syntax in
_config.yml - Validate YAML front matter format
- Review GitHub Actions logs for specific errors
Related Documentation¶
- User Guide README - Quick reference for editors
- CI/CD Workflow - Build pipeline details
- just-the-docs Documentation - Theme documentation