Update the "What's New" home-page feed¶
The home page of the SyRF web app shows a permanent "What's New" section: one list of changes, grouped by calendar month, newest first. The two newest months are open; every older month is a collapsed row (the month, its change titles and a count) that expands to show its changes. Static release notes and runtime feature releases render identically. The static part is driven by a JSON feed shipped with the web app. You edit its reviewed YAML source, docs/content/whats-new.yaml, and regenerate the JSON: updating it for a new release is a content-only change, no Angular code required.
The same YAML file also holds the runtime What's New entries for features released at runtime (runtime:, see below). Those are never written to the public JSON feed: they go only into the server help bundle. Their guide links come from the guide pages themselves: each page declares the feature topics it covers in its front matter (see Guide topics).
Files¶
| File | Purpose |
|---|---|
| docs/content/whats-new.yaml | The reviewed source. Edit this for each release. |
| src/services/web/src/assets/data/whats-new.json | The feed the web app fetches (assets/data/whats-new.json). Generated from the YAML; never edit it by hand. |
| src/services/web/src/assets/data/whats-new.schema.json | JSON Schema for the generated feed. The validator checks the feed against it in pre-commit and CI. |
| src/services/web/src/app/info/home/whats-new/ | The Angular component that renders the feed. You only touch this if the shape of the data needs to change. |
| src/charts/syrf-common/scripts/compile-release-content.ts | The content compiler. It builds the help bundles from the guide pages' syrf.topics front matter and puts the runtime: entries into the server bundle (schema 2), never into public output. |
| src/charts/syrf-common/scripts/guide-topics.ts | Reads and checks the syrf.topics front matter of user-guide/ and docs/content/protected/ pages, for the compiler, the generator and the validator. |
| src/services/web/src/app/generated/help-topics.generated.ts | Generated: each public topic's feature, title, guide page and section, for in-app help links. Never edit it by hand. |
Feed shape¶
The source is an object with an updates array, newest first, and an optional runtime array (next section). Each update has id, date and title, plus either highlights[] with userGuidePath, or body with an optional userGuidePath, and an optional featureId. Either shape may come at any position:
- Highlights update: requires
highlights(1-6 items, each withtitleandbody) anduserGuidePath. Each highlight is shown as one change: the highlight's title as the heading, its body as the sentence, and the update's date and guide link ("Read the guide"). - Body update: requires
body(Markdown);userGuidePathis optional. It is shown as one change: the update'stitleand the body's opening prose. A leading heading-like line (such as "New Feature: Contact Form", with no closing punctuation) is skipped, a sentence that introduces a list (ending in a colon) is dropped, and a second short paragraph is added while both stay within 160 characters; the rest stays behind "Read the guide". Nothing is cut mid-sentence. Without auserGuidePaththere is nowhere for the rest to go, so the whole body shows. Write the opening paragraph so it stands alone. featureId(optional): a feature ID fromsrc/charts/syrf-common/feature-definitions.yaml. It links the announcement to the feature for the registry validator and is stripped from the shipped JSON. A feature with aruntime:entry must not also have one here (see Announce a feature one way only).
Every change shows its date, "Read the guide" where there is a guide link, and nothing else: static changes have no "New to you", no audience chip, no "Show me how" and no "Hide" (those come from runtime releases).
updates:
- id: 2026-09-example-update
date: 1st September 2026
title: Example update
userGuidePath: whats-new/whats-new-example.html
highlights:
- title: A short heading for the change
body: One or two sentences in user-facing language.
- id: 2026-04
date: 28th April 2026
title: April 2026
userGuidePath: whats-new/whats-new-april-2026.html
body: |-
One paragraph that stands alone: it is what the home page shows.
Multi-line bodies use a block scalar (`|-`), which keeps the line breaks.
The generator writes the JSON with $schema first and the same field order, so the shipped bytes change only when the content does. It adds one field to each update, straight after date: isoDate, the machine form of date, so the browser can merge this feed with runtime entries in date order.
date is human-readable, in the form <ordinal> <FullMonth> <YYYY> (for example 28th April 2026), or <FullMonth> <YYYY> for a month-only release. It is parsed strictly: the ordinal suffix must match the day (1st, 2nd, 3rd, 11th, 22nd) and the day must exist in that month. 18th August 2026 becomes isoDate: 2026-08-18; August 2026 becomes 2026-08. Anything else fails generation with the exact form expected. Never write isoDate yourself; the generator refuses it.
Validated in pre-commit and CI.
pnpm run validate:generated(theValidate Generated Codelane, which runs for any change to the YAML, the feed, its schema, auser-guide/page or adocs/content/protected/page) checks that the JSON was regenerated from the YAML, that it matcheswhats-new.schema.json(each update is a highlights update or a body update), that everyuserGuidePathnames a published user-guide page, that everyfeatureIdexists and that no feature is announced both ways. The component skips an update it cannot read and shows the rest.
Runtime What's New entries (feature releases)¶
A feature released at runtime (6D release Apply, communication.whatsNew: announce, the Release default) announces itself through a runtime: entry, not through updates. The entry is compiled into the server help bundle only; the API discloses it only to people the feature has been released to (6G PR B). It is never in whats-new.json, the public bundle or any web asset.
runtime:
- id: project-membership-disable
featureId: project-membership-disable
title: Disable a project membership
body: |-
Project administrators can now disable a member's access to a project without removing them, under *Project Settings > Membership > Current Members*. The member's groups and past review work are kept, and the membership can be re-enabled at any time.
topicId: project-membership-status
| Field | Rule |
|---|---|
id |
A stable lower-case ID, unique among runtime entries (usually the feature ID). |
featureId |
A feature in src/charts/syrf-common/feature-definitions.yaml whose resolved communication.whatsNew is announce. A quiet feature has no entry. |
stages |
Optional: LimitedRelease, GeneralRelease, or both (the default). At most one entry per feature and stage. |
title |
A short heading, 1-200 characters. |
body |
Markdown, 1-4000 characters. No images, no raw HTML (including <...> autolinks), and links only to https:// addresses or to user-guide pages by path or permalink. No bare http:// or www. address. |
topicId |
One of the feature's topicIds. "Read the guide" opens that topic's public guide page and section, taken from the page that declares the topic in its front matter (see Guide topics). |
There is no date: the date shown is the day the release was confirmed in that environment, so staging and production each show their own.
Where people see it (6G PR D). The home page What's New merges runtime entries with updates in date order (the static isoDate against the confirmed day in Europe/London) and groups them by month with the static changes: each runtime entry is one change in the month of its release day, in the same format. Signed in, a person sees their personalised entries and the public ones (deduplicated by announcement); signed out, only public entries (general release), with no "New to you" and no "Hide". An entry released with no recorded date goes last, in an "Undated" month. A feature that needs a project permission (such as project-membership-disable) appears instead in a "New in this project" card on that project's overview (pending owner decision D36; built to recommendation A). Each entry shows its date ("19th August 2026", Europe/London), "For project administrators" when the feature's audience says so (D57), "New to you" until the person has seen it (in every environment since D60 A, 26 Oct 2026, which retired the "Rehearsal" label), "Read the guide" (the topicId's page and section) and "Hide", which hides it for that person only. The body is rendered as untrusted Markdown. Details: What's New in the app.
Write plainly, in UK English, for the people who will use the feature: what they can now do and where.
Announce a feature one way only¶
A runtime release announces itself through its runtime: entry. Do not also add an updates entry for the same feature: the home page lists both kinds as one list of changes, so the same change would show twice (once to everyone, once to the people it is released to). Generation and validation refuse an updates entry whose featureId also has a runtime: entry, naming both:
docs/content/whats-new.yaml: updates[0] ("2026-10-membership") and runtime[0] ("membership-disable") both announce feature "membership-disable"; announce a feature one way only: keep the runtime entry for a runtime release and remove the featureId (or the update) from updates
Keep the runtime entry for a runtime release. Use updates for anything shipped without a runtime release.
When it is needed. The registry validator warns while the feature is Developing and fails once it is past Developing if an announcing feature has no entry. Release Apply refuses an announced release whose feature and stage have no entry in the verified server bundle (WhatsNewEntryMissing); applying quietly with a reason is the escape. See Applying a release.
Check it: pnpm run validate:generated runs the validator (feature, topic, stages, one entry per feature and stage, body rules) and the compiler tests, including a byte scan proving no runtime title reaches public output.
Guide topics: link a guide page to a feature¶
A feature's guide content is linked to it through the guide page's own front matter. There is no separate list to keep in step: the content compiler, the generated help-topics.generated.ts and the registry validator all read the pages. Jekyll ignores the syrf key, so the published page does not change.
---
title: Members & Groups
parent: Membership
layout: page
syrf:
topics:
- featureId: project-membership-disable
topicId: project-membership-status
section: disabling-and-re-enabling-a-membership # optional: a heading anchor on this page
publication: PublicReleased # optional in user-guide/ (the default there)
workflowVersions: []
---
| Field | Rule |
|---|---|
featureId |
A feature in src/charts/syrf-common/feature-definitions.yaml. |
topicId |
One of that feature's topicIds. Declare a topic once per page. A public topic has one public page, so its help link opens one place. |
section |
Optional. The anchor of a heading on this page, as the guide site generates it from the rendered heading (kramdown's GFM rules: link, code and emphasis markup and images drop out, then lower case, punctuation dropped, spaces as hyphens, and a repeated heading gets -1, -2; ## Disabling and Re-enabling a Membership gives disabling-and-re-enabling-a-membership, and a heading whose text is a link labelled Foo gives foo). Headings inside fenced code blocks do not count. A heading with an HTML entity other than &, <, >, ", ', or a numeric one cannot be named (give it an explicit {#id} instead). Guide links open the page at that heading. |
publication |
PublicReleased (the default in user-guide/) or PublicPreview for a page in user-guide/. Draft, PrivatePilot or Retired (required) for a page in docs/content/protected/. |
workflowVersions |
Required. The feature's compatibleWorkflowVersions this page covers ([] for none). Release Apply checks that the reviewed versions are covered. |
audienceId |
Protected pages only, and required there: the feature's own featureId (the people the feature is available to) until targeted audiences exist (6F). |
historicalPublication |
Only with publication: Retired, and only PrivatePilot: nothing records which revisions were public, so a retired page cannot claim it was (D40). |
Where pages live. A public page lives in user-guide/, which the public guide site publishes; that is the evidence for a public class. A restricted page (a draft or a private pilot) lives in docs/content/protected/, which nothing publishes; it uses the same front matter plus audienceId. A restricted declaration in user-guide/ is refused, and so is a public one outside it.
What is derived. Each declaration becomes one bundle entry: its variant ID is the page path (projects/membership/members-groups.md gives projects-membership-members-groups), its title is the section heading (or the page title without a section) and its text is the page without its front matter. The generator writes the public topics to src/services/web/src/app/generated/help-topics.generated.ts (topicId to feature, title, guide page and anchor); restricted topics never enter the web bundle.
Refusals. Each names the page and the declaration (user-guide/<page>: syrf.topics[<n>]: …): an unknown feature or a topic the feature does not own, a section that is not a heading on the page, the same topic declared twice on a page, a restricted page in user-guide/, a public class outside it, an undeclared workflow version, a missing or foreign audience, an unknown field, and a page with published: false.
In the app. A public topic is linked from the web app with the syrf-help-topic component (src/services/web/src/app/shared/help-topic/): <syrf-help-topic topicId="project-membership-status" />. It reads the page, section and title from help-topics.generated.ts and opens the public guide in a new tab through the userGuideUrl pipe, so no guide URL is written by hand. Where the topic belongs to a feature it adds a small chip from the availability client: "Available in this project" (or "Available to you" outside a project) when the feature is available, "Currently unavailable" when it is stopped. It shows no chip when availability is unknown, when the feature is not in the person's availability answer (no permission, releases off, signed out) or before the first answer, and the chip never names the feature. The membership page uses it for "Disabling and Re-enabling a Membership".
After editing a page's syrf block, its section heading or its title, run pnpm generate. pnpm run validate:generated fails with "differs from the syrf.topics front matter of user-guide/ pages; run pnpm generate" until help-topics.generated.ts matches the pages. Editing the rest of a page needs nothing.
Compile the bundles (offline; no publication or release state changes), from the repository root:
It reads user-guide/, docs/content/protected/ and docs/content/whats-new.yaml, and takes no manifest. Beside the public bundle it writes publication.json, the public topics with their page, section and revision, which the user-guide image serves at /syrf-publication.json (spec 6G.P; see Audience-separated content artifacts).
Walkthroughs: a guided tour for a feature (build-time source)¶
A feature can have a short in-app walkthrough. Its reviewed source is docs/content/walkthroughs.yaml (schemaVersion: 1, then walkthroughs: [{ id, featureId, title, steps: [{ anchor, title, body, placement }] }]). pnpm generate writes it to src/services/web/src/app/generated/walkthroughs.generated.ts (never edit that by hand). The web app shows it with the tour component in src/services/web/src/app/shared/tour/ (6G.4 W2; see "How the launcher behaves" below).
To add one:
- Put
data-tour-id="<anchor>"on each element a step points at, in a template undersrc/services/web/src/app/. The attribute changes no behaviour. The validator looks for the literal textdata-tour-id="<anchor>". - Add the walkthrough to
walkthroughs.yaml: 1 to 8 steps;placementisabove,below,startorend; abodyis plain text or inline Markdown on one line, at most 400 characters, with no image and no HTML (links must behttps://). - Set
communication.walkthroughId: <id>on the feature insrc/charts/syrf-common/feature-definitions.yaml. The link is checked both ways: the walkthrough'sfeatureIdmust name a feature that points back at it, and a feature'swalkthroughIdmust name an existing walkthrough. - Add the page the anchors live on to
WALKTHROUGH_ROUTESinsrc/services/web/src/app/shared/tour/walkthrough-routes.ts(a function of the project in context that returns router commands, or null where the page cannot be reached, such as a project page from the home page). The YAML has no route field;walkthrough-routes.spec.tsfails for a generated walkthrough with no entry. What's New "Show me how" uses it. - Run
pnpm generatefrom the repository root, thenpnpm run validate:generated.
An anchor must be unique: the same data-tour-id in two templates (or twice in one) is refused, because a tour would resolve whichever element comes first. An anchor may sit inside an @if block, so a step's element can be absent when the page renders (membership-enable-button renders only for a disabled member, membership-disable-button only while the flag is on). The tour component (W2) must skip a step whose anchor is missing and must not start when step 1's anchor is missing; the validator only proves the attribute exists in a template.
How the launcher behaves¶
WalkthroughLauncherService (registered at bootstrap in main.ts) reads only the browser availability client (FeatureAvailabilityService, 6E.4). It decides nothing about availability and does not wait for the in-app notice, so it works where NotifyInApp delivery is disabled.
- When it starts by itself. On a page where the walkthrough's step 1 anchor is rendered, it starts the walkthrough of a feature that is
availablein the current context, whoseannouncementis non-null and neitherseennordismissedby this person. A quiet or imported release has no announcement, so its walkthrough never starts by itself. While such a feature exists the launcher checks the page after each render, so an anchor inside an@ifthat appears late (the members table after its data loads) still starts it; with none it registers nothing. - Once. Closing it (Close, Esc or navigating away) or finishing it (Done) records
seenthroughPUT /api/account/announcements/{id}/state(6E.3), so a reload or a second tab does not show it again. It also starts at most once per walkthrough per page load, even if that write fails. - Replay. What's New "Show me how" appears on an entry only when the feature has a walkthrough whose page can be opened from there (
walkthrough-routes.ts; today that means the project "New in this project" card, because the membership page needs a project). It opens that page and replays the walkthrough when step 1 renders (waiting up to 10 seconds), and records nothing: neitherseennordismissed. - The card (
TourStepComponent) is a Material 3 dialog card in a CDK overlay next to the step's element, which is scrolled into view and ringed (.syrf-tour-target,src/global-styles/_tour.scss). On a compact window (narrower than 600px) and for an anchor taller than half the viewport, the card sits at the bottom of the viewport instead, with the anchor scrolled to the middle. It shows the walkthrough title, the step title and body (inline Markdown, rendereduntrusted), "Step 2 of 4", Back, Next or Done, and Close. It hasrole="dialog"and a label, traps Tab (cdkTrapFocus), closes on Esc, returns focus to the element focused before it opened and reads each step throughLiveAnnouncer. Underprefers-reduced-motion: reducethe card does not move in and scrolling is instant. - Missing anchors. A step whose anchor is not on the page when the tour starts is skipped and not counted in "Step n of m". When step 1's anchor is missing the tour does not start. Where an anchor repeats at run time (a button on every table row), the first visible, enabled one is used, then the first visible one.
- Flag decision. Not flagged: with releases switched off availability is empty, so nothing ever starts and no "Show me how" appears.
Walkthrough text ships in the public JavaScript (owner decision D41 A), so the validator refuses a walkthrough for a feature whose help topics are all restricted (every syrf.topics declaration for it is in docs/content/protected/, so it has no public guide topic). A feature with no declared topic at all counts as public UI and may have one.
Release workflow¶
When cutting a new release:
- Open
docs/content/whats-new.yaml. - Insert the new release at the top of
updateswith a freshid(e.g.2026-05). Earlier entries stay as they are: highlights and body updates may sit anywhere in the list. - Set
userGuidePathto the What's New page in the user guide (e.g.whats-new/whats-new-may-2026.html): its path with.htmlor.md, or its front-matterpermalink:if it has one. No leading slash, no scheme: theUserGuideUrlPipeprepends the environment-appropriate base URL (production vs. staging vs. preview). - Add 1-4
highlights, one per change. Keep eachtitleshort (one sentence, no trailing full stop: it is the change's heading) and eachbodyto one or two sentences in user-facing language. Do not add a feature that has aruntime:entry. - Regenerate and check, from the repository root:
- Commit the YAML, the regenerated
whats-new.jsonand.generated-checksums.json. The feed ships on the next web deployment.
Hiding the section¶
Set updates to an empty list in docs/content/whats-new.yaml and regenerate:
The section renders nothing for signed-out visitors and for anyone with no runtime entries. A signed-in person with a runtime entry still sees the section with that entry. Useful between releases or during a quiet period.
Rolling back¶
Because the feed is just a static asset, rolling back is as simple as reverting the commit. No database migration, no cache invalidation; users pick up the previous feed on their next page load.
Design decisions¶
- Why a JSON feed, not a hard-coded component? Non-engineers (e.g. product owner, designer) can update the home-page release section by editing one file in the repo. The bar to ship a release note is a PR, not an Angular build.
- Why YAML as the source? Multi-line release prose reads and reviews better as YAML block text than as escaped JSON strings, and the source can carry build-time links (
featureId) that the browser never needs. The JSON stays the runtime format, so the web app and its fetch path are unchanged. - Why runtime entries in the same file but never in the JSON? One place to write What's New text, linked to the feature by
featureIdand to the guide bytopicId. A runtime release may reach only some people, so its text must not be in a public asset before the server decides who may see it. - Why static asset, not an API endpoint? A new service is overkill for one file that changes ~monthly. Serving from
/assets/data/whats-new.jsonuses the same edge-cached path as the web app itself, so there's zero extra infrastructure. - Why one
updatesarray? The home page shows one list of changes grouped by month, and every update feeds it the same way (one change per highlight, or one for a body), so there is no separate latest-release block to keep in step with an archive farther down. - Why no loading spinner? If the fetch is slow or fails the section simply stays hidden. The home page is not meant to be blocked by release notes that are nice to have; an invisible failure mode is the correct failure mode here.
Testing your changes¶
The generator, compiler, guide-topic and validator tests cover isoDate parsing, the runtime list staying out of the public feed, every runtime-entry rule and every guide-topic refusal:
pnpm exec tsx --test src/charts/syrf-common/scripts/generate-feature-definitions.test.ts \
src/charts/syrf-common/scripts/compile-release-content.test.ts src/charts/syrf-common/scripts/guide-topics.test.ts \
scripts/feature-registry.test.ts
Unit tests cover grouping by month, the open and collapsed months, highlights and body updates as changes, the opening-prose summary, runtime entries (audience, "New to you", Hide, dedup, signed out) and error handling:
cd src/services/web
pnpm exec ng test --no-watch --include="src/app/info/home/whats-new/**/*.spec.ts"
For a local visual check, run the dev server and inspect the home page:
Changing the feed shape¶
If a release genuinely needs something the current schema can't express (an image, a link list, a carousel of multiple releases) update in this order:
- Update
whats-new.schema.jsonwith the new shape. - Update the TypeScript interfaces in
whats-new.component.tsto match. - Update the template and tests.
- Only then change
docs/content/whats-new.yamland regenerate. If the new shape needs a field the generator should not ship (likefeatureId), strip it inrenderWhatsNewinsrc/charts/syrf-common/scripts/generate-feature-definitions.ts.
Keep the feed backwards-compatible where practical — old feeds served from CDN caches should still render without crashing the home page.