Skip to content

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=jekyll builds 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

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

---
title: Projects
has_children: true
nav_order: 3
layout: page
---

Example: Child Page

---
title: Create a Project
parent: Projects
has_children: false
nav_order: 2
layout: page
---

Example: Grandchild Page

---
title: Members & Groups
parent: Membership
has_children: false
nav_order: 1
layout: page
---

Note: The parent value must exactly match the title of the parent page.

Adding Content

Creating a New Page

  1. Choose the right directory based on the content topic
  2. Create the markdown file with descriptive name
  3. Add front matter with correct parent/nav_order
  4. Write content using GitHub Flavored Markdown
  5. 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>).

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 from the guide still serves (a redirect never shadows a page);
  • a to that 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

cd user-guide
docker-compose up

Visit http://localhost:4000 to see the site.

Using Ruby/Bundler

cd user-guide
bundle install
bundle exec jekyll serve

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
  • Maintain sequential nav_order within 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:

  1. Trigger: Push to main branch with changes in user-guide/
  2. Build: Starlight builds the static site (the image's default renderer since spec 6H H4)
  3. Check guide links (spec 6H H1): before the push, the image runs as two local containers and .github/scripts/check-user-guide-links.sh checks that every history URL answers 200 or one relative 301 to a 200, that every in-app help link (web templates, whats-new.json, help topics, runtime What's New and walkthrough bodies) answers 200 with its anchor, that the two containers give the same pages (self-parity), and that user-guide/test-seo-headers.sh passes. 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)
  4. Package: Docker image created and pushed to GHCR
  5. Deploy: ArgoCD syncs to Kubernetes cluster
  6. 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.yml exclude:);
  • src/guide/liquid.ts: {{ site.baseurl }} and {% link page.md %} only; any other Liquid fails the build;
  • src/guide/remark-kramdown.ts and typography.ts: kramdown's heading IDs (computed with the content compiler's own rules in guide-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, and 1) 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" from parent, grand_parent, nav_order and has_children, by the Jekyll theme's rules (a page whose parent names no page is listed nowhere, except the What's New pages whose parent is the missing "What's New!": PARENT_ALIASES lists 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.css and the component overrides in src/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 in public/_guide/brand/ are copies of the web app's own assets (test/brand-assets.test.ts keeps 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 Pagefind section filter, 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:

  1. docs/architecture/dependency-map.yaml: user-guide.starlight_build.default: "jekyll", then pnpm generate, so the generated user-guide/Dockerfile starts with ARG GUIDE_RENDERER=jekyll (and .generated-checksums.json follows).
  2. .github/workflows/_docker-build.yml, the two steps after "Check guide links" (H3's text is on main at db3021ba83, steps "Load the Starlight guide image for the proof" and "Check the Starlight build of the guide"):
  3. "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 env GUIDE_JEKYLL_IMAGE to GUIDE_STARLIGHT_IMAGE: syrf-user-guide-starlight:…, also in "Remove and verify job-owned residue"), and continue-on-error: true;
  4. "Check the Starlight build of the guide": continue-on-error: true; its run line becomes check-user-guide-starlight.sh "$GUIDE_CHECK_IMAGE" "$GUIDE_STARLIGHT_IMAGE" … with no GUIDE_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.
  5. .github/scripts/test-user-guide-link-check-contract.sh: the line [ "$(grep -m1 -E '^(ARG|FROM) ' "$DOCKERFILE")" = 'ARG GUIDE_RENDERER=starlight' ] expects jekyll; the blocking_contract function and its five mutants go back to H3's report_only_contract and its two mutants; the reference-step checks (load_jekyll, GUIDE_JEKYLL_IMAGE) go back to H3's load_starlight / GUIDE_STARLIGHT_IMAGE checks (all on main at db3021ba83).

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

  • Verify parent value exactly matches parent's title
  • Check nav_order is 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