Frontend Development
The Wayd frontend is built with Next.js 16 (App Router), React 19, TypeScript, and Ant Design.
Project Structure
wayd.web.reactclient/
src/
app/ # Next.js App Router pages and layouts
components/ # Shared components
common/ # Generic reusable components
contexts/ # React context providers (auth, theme, messaging)
hoc/ # Higher-order components (feature flags)
config/theme/ # Theme presets and the theme registry
hooks/ # Custom React hooks
lib/ # Docs viewer plugins (remark/rehype)
services/ # API clients and services
wayd-api.ts # NSwag-generated TypeScript client
clients.ts # Client factory functions
store/ # Redux Toolkit store and slices
utils/ # Helpers (calendar dates, problem details, colors)
styles/globals.css # Global styles; components use CSS modules beside them
scripts/ # Search-index and import-template generators
Scripts and environment
| Script | What it does |
|---|---|
npm run dev | Deletes .next, rebuilds the docs search index, then runs next dev --turbopack |
npm run build | Rebuilds the search index and runs typecheck before next build (output: 'standalone') |
npm run typecheck | tsc --noEmit |
npm run lint | eslint . — the whole client, even when given paths |
npm run lint:deprecations | Uses of @deprecated APIs (type-aware, slower) |
npm test | Jest; npm run test:docs runs the docs suite on its own config |
npm run generate:import-templates | Regenerates the CSV import templates from the API spec |
NEXT_PUBLIC_API_BASE_URL is the API's address. Aspire sets it; for npm run dev on its own, put it in a gitignored .env.local (NEXT_PUBLIC_API_BASE_URL='http://localhost:5150'). It is inlined at build time, so a change needs .next cleared — npm run dev does that. The Docker image is built with the placeholder APP_NEXT_PUBLIC_API_BASE_URL, which entrypoint.sh replaces with the container's NEXT_PUBLIC_API_BASE_URL at start and refuses to start without.
next.config.mjs turns on the React Compiler (reactCompiler: true) — see the client's AGENTS.md before adding useMemo or useCallback. It also holds the permanent redirects for moved routes and the frame-blocking headers on /oauth/consent.
API Calls
Always use the NSwag-generated typed client:
import { getWorkItemsClient } from '@/src/services/clients'
const client = await getWorkItemsClient()
const workItems = await client.getList(workspaceId)
Never use authenticatedFetch() directly unless the generated client doesn't have the method yet.
Dates and instants
The generated types follow the two kinds of time the API publishes:
| API type | OpenAPI | Generated type | Meaning |
|---|---|---|---|
LocalDate | format: date | string ("2026-09-28") | A day on the calendar — a sprint's planned start, a target date |
Instant | format: date-time | Date | A moment — when something was started, completed or released |
The client generates interfaces only and never parses a response, so both arrive as strings at runtime. A
Date-typed instant is safe to hand to dayjs(...); a calendar date is not a moment and must never be
treated as one. Use the helpers in src/utils/calendar-date.ts (exported from @/src/utils):
formatCalendarDate(value, format?)to show one, as written, with no zone conversion (MMM D, YYYYby default).parseCalendarDate(value)for date math or aDatePicker; it is local midnight of that day.toCalendarDate(dayjsOrDate)to send a picked day back to the API, andtodayCalendarDate()for today.compareCalendarDatesto sort,calendarDaysBetweento count days, andaddCalendarDaysto step.calendarDateInZone(instant, timeZone)for the day an instant falls on in a team's zone.startOfCalendarDate(value)for epoch milliseconds on a time axis, such as the timeline.
daysRemaining, dayOfPeriod, percentageElapsed and formatInstantInZone live in src/utils/dates.ts, exported from the same barrel.
Never new Date(calendarDate) or dayjs.utc(calendarDate). new Date("2026-09-28") is UTC midnight,
which is the previous day anywhere west of UTC. Instants are shown in the viewer's zone; where a record also
has a team time zone that can differ from the viewer's, label which zone a time is in.
wayd-api.ts gets the string type from an override of the generator's interface template,
Wayd.Web.Api/NSwagTemplates/Interface.liquid: NSwag's single dateTimeType setting covers both formats.
An array of calendar dates (List<LocalDate>, such as a sprint's holidays) is typed string[] the
same way. The template cannot see an array's item format, so an array of instants would be typed
string[] too; the API publishes none, and its elements would arrive as strings regardless.
Form Validation Errors
Every form marks the fields the API's validation names. A request that fails validation returns 422
with an errors object keyed by request property ({ "End": ["..."] }). Put each message on its field
so the person sees exactly what to fix, and show a short toast as well. Any other failure — a 400 from a
domain rule, which belongs to no single field — shows its detail in the toast.
} catch (error) {
const apiError = toApiError(error)
if (apiError.status === 422 && apiError.errors) {
form.setFields(toFormErrors(apiError.errors))
messageApi.error('Correct the validation error(s) to continue.')
} else {
messageApi.error(apiError.detail ?? 'Failed to save the widget.')
}
return false
}
useModalForm passes the form instance to onSubmit as its second argument for this.
Read the error with toApiError, not isApiError. The generated client returns the problem details
only for a status the endpoint documents. Most endpoints do not document 422, so their validation
failures arrive as an ApiException with the body in response, and isApiError(error) ? error : {}
finds no errors — the form falls back to a toast and marks nothing. toApiError reads both.
toFormErrors matches a key to a field of the same name, whatever its casing. When a field is named
differently from the request property it sets, pass fieldFor to say where the error goes:
- A date range picker holding a required start and end sends two properties from one field. Use
rangeErrorsOn:toFormErrors(apiError.errors, { fieldFor: rangeErrorsOn('dates') }), orrangeErrorsOn('plannedRange', 'plannedStart', 'plannedEnd')for other property names. - A list of rows (
iterations[2].end) returns a name path to that row's field, such as['iterations', 2, 'dates'].
A form that keeps its values in component state rather than antd fields — one picker per sprint, one
rating per criterion — uses errorsByField to get the messages by its own names, holds them in state,
shows them through the Form.Item's validateStatus and help, and clears one when its value changes.
To test one, wrap the submitting click in act (await act(() => userEvent.click(save))): the error
state is set after the request settles, and outside act that update is never rendered.
Styling
Use Ant Design theme tokens. Never hardcode colors.
Prefer CSS variables in CSS modules:
/* good */
.header { color: var(--ant-color-primary); }
/* bad */
.header { color: #1677ff; }
Only use theme.useToken() when you need token values in JavaScript logic.
State Management
Where a value lives is decided by how long it should survive, applied in this order:
- Would I share this? → URL query param
- Should it outlive the browser? →
localStorage, owned by a Context or slice - Should it survive leaving the page and coming back? → Redux slice, keyed by record id
- Otherwise →
useState
Most state is (4). Every persisted value has exactly one owner — a Context or a slice — never a component calling useLocalStorageState directly.
See Record Pages for the full rules, including how to model dialogs and what not to put in Redux.
Responsive
Every page must work at every width — this is a requirement, checked before opening a PR, not an enhancement.
Use Ant Design's useBreakpoint. The app's conventions:
!screens.md(<768px) is the mobile cut —AppSideNavreturnsnulland navigation moves to the header Drawer!screens.sm(<576px) is the extra-small cut used byPageTitlefor stackingResponsiveFlexhandles the common "stack on small screens" case
Three rules that apply to every page:
- Horizontal scroll belongs to the element, never the page. Wide grids, timelines and tables scroll inside their own container; the page body never scrolls sideways.
- Hit targets ≥44px on anything tappable.
- Virtualized content needs a measurable height.
WaydGridrenders zero rows in an unconstrained<Flex vertical>parent — keep it a block-level child.
Testing
Use the browser device toolbar (Ctrl/Cmd+Shift+M). In development a breakpoint chip in the bottom-right corner shows the active Ant Design breakpoint — BreakpointIndicator, mounted from the root layout behind a NODE_ENV check.
Test at 1440 / 1100 / 834 / 390, and at the boundaries either side of any rule you rely on — bugs live at 575/576 and 767/768, not at 390.
See Record Pages for the full per-slot behaviour and checklist.
Record & List Pages
Every entity's list and detail page follows one structure — identity slot, section rail, record facts, URL-addressable sections, and nested error boundaries.
See Record Pages before building a page for a new entity.
Filter Bars
A filter bar is the row of small toggle buttons above a list — statuses, roles, personas. Two rules hold across every one of them:
Unselected buttons are dashed. Selected ones keep a solid border. The dash is the primary selection cue, and it is deliberately not a color: the PPM statuses roll up to four lifecycle categories, and the not-started grey is close enough to an unselected button that colour alone cannot separate them — measured in the browser, a selected grey chip differed from an unselected one by a 10/255 background step. A dash also survives for a colourblind reader, who would otherwise have only that step to go on.
Colour means identity, never state. Where a button has a status colour, it wears the same
background, border and text its tag uses in the Status column (getLifecycleCategoryStatusSurface),
so the bar and the grid speak the same vocabulary. Unselected buttons drop that colour entirely.
Where a filter has no colour of its own — roles, or the states on roadmaps and strategic themes —
the selected button keeps antd's primary rather than borrowing a meaning it does not carry.
<Button
size="small"
color={isSelected && !surface ? 'primary' : 'default'}
variant="outlined"
style={
isSelected
? surface
? {
backgroundColor: surface.background,
borderColor: surface.border,
color: surface.text,
}
: undefined
: { borderStyle: 'dashed' }
}
>
Key the dash on selection, not on whether a colour was found — a filter with no colour would otherwise render its selected button dashed, marking the active one as off.
PpmFilterBar (src/app/ppm/_components/ppm-filter-bar.tsx) is the shared implementation behind the
projects, programs, strategic initiatives, roadmaps and strategic themes bars; the My Projects
dashboard and story-map persona bars have their own markup and follow the same rules.
Data Grids
All data grids use the WaydGrid house component (built on TanStack Table) — never Ant Design's Table or raw TanStack Table. It provides a consistent toolbar, Excel-style column filters, sorting, column sizing/visibility/pinning/reordering, row virtualization, CSV export, opt-in column-layout persistence, and (opt-in) tree mode, drag-and-drop, and inline editing.
See WaydGrid for the full usage guide — props, column meta, how to enable/disable each feature, and persistence.
PWA (Progressive Web App)
The client app has a web app manifest and a Serwist service worker build.
Key files:
| File | Purpose |
|---|---|
src/app/manifest.ts | Web app manifest (name, icons, screenshots, theme color) |
src/app/sw.ts | Service worker source (precaching + runtime caching) |
src/app/serwist/[path]/route.ts | Turbopack route handler that builds and serves the SW |
src/config/theme/theme-constants.ts | Shared brand color used by manifest and light theme |
public/icons/ | PWA icons (192x192, 512x512) |
public/screenshots/ | Install dialog screenshots (desktop and mobile) |
Tooling: Serwist (@serwist/turbopack) — handles service worker compilation and precache manifest injection via a Next.js route handler.
Caching strategy: sw.ts precaches the build and uses Serwist's defaultCache for runtime caching, with skipWaiting, clientsClaim and navigation preload on.
The route serves the worker, but nothing in the client registers it: @serwist/turbopack registers only through SerwistProvider (@serwist/turbopack/react), which no layout mounts. ReleaseCleanup (src/components/release-cleanup.tsx, in the root layout) also unregisters every service worker and clears Cache Storage once per browser per RELEASE_MARKER_VALUE. Until a provider is mounted, the manifest is served but nothing is cached offline.
Theming
Theming has two orthogonal axes:
- Theme — a visual identity (shape language, typography, surface treatments): Wayd (the default), Cartoon, Shadcn, Glass, Geek, and Illustration.
- Mode — a color-scheme variant of a theme:
light,mist,dark, orslate(mistandslateare the Wayd theme's alternate light and dark; Wayd supports all four). Each theme declares which modes it supports; single-mode themes (e.g. Geek is dark-only) simply declare one.
On top of that, users can customize density and (for eligible themes) primary color. All theming is built on Ant Design's ThemeConfig and ConfigProvider; a mode maps to an algorithm + token seed, exactly like Ant Design's darkAlgorithm/compactAlgorithm model.
The active selection is exposed on <html> as two attributes: data-theme (identity) and data-mode (color scheme). Global CSS keys mode-dependent rules (e.g. scrollbars) off data-mode and identity-dependent rules (e.g. background textures) off data-theme.
Theme files
| File | Purpose |
|---|---|
src/config/theme/wayd-light-theme.tsx | Wayd theme — light mode |
src/config/theme/wayd-dark-theme.tsx | Wayd theme — dark mode |
src/config/theme/wayd-mist-theme.tsx | Wayd theme — mist mode (alternate light) |
src/config/theme/wayd-slate-theme.tsx | Wayd theme — slate mode (alternate dark) |
src/config/theme/cartoon-theme.tsx | Cartoon theme config |
src/config/theme/shadcn-theme.tsx | Shadcn-inspired theme config |
src/config/theme/glass-theme.tsx | Glass theme config |
src/config/theme/geek-theme.tsx | Geek theme config |
src/config/theme/illustration-theme.tsx | Illustration theme config |
src/config/theme/theme-registry.tsx | THEME_METADATA catalog, useThemeRegistry, legacy-selection migration |
src/config/theme/theme-preset.ts | Shared AppThemeConfig contract |
src/config/theme/theme-constants.ts | Brand color (COLOR_PRIMARY) — single source of truth |
src/components/contexts/theme/theme-context.tsx | ThemeProvider, mergeThemeConfig, chart theme wiring |
src/components/contexts/theme/types.d.ts | ThemeId, ThemeMode, ThemeContextType |
src/app/_components/theme-manager-drawer.tsx | User-facing theme manager UI |
Adding a new theme
- Create
src/config/theme/my-theme.tsxexporting anAppThemeConfighook (one per supported mode) - Add
'my-theme'to theThemeIdunion intypes.d.ts - Add its entry to
THEME_METADATA(label,defaultMode, supportedmodes) and wire the preset hook(s) intouseThemeRegistryintheme-registry.tsx - If the theme needs identity-specific CSS (backgrounds, scrollbars), key it off
[data-theme='my-theme']instyles/globals.css; mode-generic rules already apply via[data-mode='...'] - Set
behavior.allowsPrimaryOverrideexplicitly in the theme config
The theme select and mode control in theme-manager-drawer.tsx are driven by THEME_METADATA, so no UI change is needed.
Adding a mode to an existing theme
- Create the mode's
AppThemeConfighook (e.g. a dark variant of Glass) - Add the mode to the theme's
modeslist inTHEME_METADATAand its config touseThemeRegistry
The mode control appears automatically for any theme with more than one mode.
User theme overrides
Per-user overrides (colorPrimary, useCompactAlgorithm) are stored as JSON in the Preferences column on Identity.Users via UserThemeConfig. They're loaded from GET /api/user-management/profiles/theme on mount and saved debounced (500ms) via PUT on change.
mergeThemeConfig in theme-context.tsx layers these overrides on top of the base theme.
useCompactAlgorithmis always eligible.colorPrimaryis only applied when the active theme setsbehavior.allowsPrimaryOverride = true.
When switching themes from the Theme Manager, any custom primary override is cleared so the newly selected theme starts from its own default primary color. Switching modes within a theme keeps user overrides — it's an appearance flip, not an identity change.
Theme context
Consume the theme context via useTheme():
const {
currentTheme, // ThemeId, e.g. 'wayd'
currentMode, // ThemeMode: 'light' | 'mist' | 'dark' | 'slate'
availableModes, // modes supported by the current theme
setCurrentTheme, // keeps the current mode when the new theme supports it
setCurrentMode, // ignored if the current theme doesn't support the mode
token,
userThemeConfig,
setUserThemeConfig,
} = useTheme()
The selection persists in localStorage under the appTheme key as { theme, mode }. Legacy flat values (a bare theme/mode name string from before the theme × mode split) are normalized transparently on read — a mode name maps to the Wayd theme in that mode; other names map to that theme in its default mode.
Links
Two link styles, chosen by link density:
- Sparse links — a link surrounded by static text (prose, metadata rows, "View all"): use the standard blue link (
Typography.Link/<Link>). Color is the affordance. - Dense links — entity titles in lists, cards, and grids where nearly every item navigates: use
EntityLink(src/components/common/entity-link.tsx). It renders in the default text color with semibold weight; hover and keyboard focus reveal the affordance (primary color + underline). The surrounding collection already implies interactivity, so painting every title blue is noise.
Rule of thumb: is this link surrounded by other links? EntityLink. Surrounded by static text? blue.
In grids, keep the entity key column as a standard blue link (the row's anchor) and use quiet styling for name/title columns. Reserve the chain icon for "copy link" affordances and the external-link icon for navigation that leaves Wayd — icons are not link indicators.
Rendering user markdown
User-supplied markdown (project/portfolio descriptions, business cases, risk descriptions, health-check notes) renders through MarkdownRenderer (src/components/common/markdown/). It enables rehypeRaw, so raw HTML embedded in that text is parsed — which means it must be sanitized before it reaches the DOM.
The plugin order matters and is load-bearing:
rehypePlugins={[
rehypeRaw, // parses raw HTML
[rehypeSanitize, sanitizeSchema] // must come AFTER rehypeRaw
]}
Sanitizing before rehypeRaw leaves the raw HTML unparsed at that point, so it passes through untouched — the ordering is the control, not the presence of the plugin.
sanitizeSchema (sanitize-schema.ts) is the default rehype-sanitize schema plus <u>, which the renderer maps to antd's underline Text. The default schema keeps GFM tables, task lists and language-* code fences intact while dropping <iframe>, <form>, <base>, <meta>, <style>, event-handler attributes and the style attribute.
Anything added to tagNames or attributes must be checked against clickjacking. An attacker who can set style on any element can cover the page with a full-viewport overlay — that is same-origin, so no CSP prevents it. sanitize-schema.test.ts covers this case explicitly; keep it passing.
The docs viewer is a separate pipeline (next-mdx-remote / MDXRemote) rendering repo-authored content, and does not go through MarkdownRenderer.
Writing docs for both renderers
The docs/ folder is rendered by Docusaurus and by the in-app docs viewer, so write only syntax both understand. Admonitions are :::note, :::tip, :::info, :::warning, :::caution and :::danger; a title goes in brackets — :::warning[Before you start]. The older :::warning Before you start form renders as raw text on both: the docs site's future.v4 flag turns off the MDX 1 compatibility that read it, and the in-app viewer (remark-directive plus src/lib/remark-docs-admonitions.ts) never supported it.
Key Libraries
| Library | Purpose |
|---|---|
| Ant Design | UI component library |
| TanStack Table | Data grids (via the WaydGrid house component) |
| Redux Toolkit | State management |
| react-markdown | Markdown rendering |
| rehype-sanitize | Sanitizes raw HTML in user-supplied markdown (see above) |
| dayjs | Date formatting |
| oidc-client-ts | OIDC authentication (Entra ID and generic providers) |