Skip to main content

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​

ScriptWhat it does
npm run devDeletes .next, rebuilds the docs search index, then runs next dev --turbopack
npm run buildRebuilds the search index and runs typecheck before next build (output: 'standalone')
npm run typechecktsc --noEmit
npm run linteslint . — the whole client, even when given paths
npm run lint:deprecationsUses of @deprecated APIs (type-aware, slower)
npm testJest; npm run test:docs runs the docs suite on its own config
npm run generate:import-templatesRegenerates 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 typeOpenAPIGenerated typeMeaning
LocalDateformat: datestring ("2026-09-28")A day on the calendar — a sprint's planned start, a target date
Instantformat: date-timeDateA 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, YYYY by default).
  • parseCalendarDate(value) for date math or a DatePicker; it is local midnight of that day.
  • toCalendarDate(dayjsOrDate) to send a picked day back to the API, and todayCalendarDate() for today.
  • compareCalendarDates to sort, calendarDaysBetween to count days, and addCalendarDays to 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') }), or rangeErrorsOn('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:

  1. Would I share this? → URL query param
  2. Should it outlive the browser? → localStorage, owned by a Context or slice
  3. Should it survive leaving the page and coming back? → Redux slice, keyed by record id
  4. 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 — AppSideNav returns null and navigation moves to the header Drawer
  • !screens.sm (<576px) is the extra-small cut used by PageTitle for stacking
  • ResponsiveFlex handles 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. WaydGrid renders 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:

FilePurpose
src/app/manifest.tsWeb app manifest (name, icons, screenshots, theme color)
src/app/sw.tsService worker source (precaching + runtime caching)
src/app/serwist/[path]/route.tsTurbopack route handler that builds and serves the SW
src/config/theme/theme-constants.tsShared 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 service worker is not registered

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, or slate (mist and slate are 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​

FilePurpose
src/config/theme/wayd-light-theme.tsxWayd theme — light mode
src/config/theme/wayd-dark-theme.tsxWayd theme — dark mode
src/config/theme/wayd-mist-theme.tsxWayd theme — mist mode (alternate light)
src/config/theme/wayd-slate-theme.tsxWayd theme — slate mode (alternate dark)
src/config/theme/cartoon-theme.tsxCartoon theme config
src/config/theme/shadcn-theme.tsxShadcn-inspired theme config
src/config/theme/glass-theme.tsxGlass theme config
src/config/theme/geek-theme.tsxGeek theme config
src/config/theme/illustration-theme.tsxIllustration theme config
src/config/theme/theme-registry.tsxTHEME_METADATA catalog, useThemeRegistry, legacy-selection migration
src/config/theme/theme-preset.tsShared AppThemeConfig contract
src/config/theme/theme-constants.tsBrand color (COLOR_PRIMARY) — single source of truth
src/components/contexts/theme/theme-context.tsxThemeProvider, mergeThemeConfig, chart theme wiring
src/components/contexts/theme/types.d.tsThemeId, ThemeMode, ThemeContextType
src/app/_components/theme-manager-drawer.tsxUser-facing theme manager UI

Adding a new theme​

  1. Create src/config/theme/my-theme.tsx exporting an AppThemeConfig hook (one per supported mode)
  2. Add 'my-theme' to the ThemeId union in types.d.ts
  3. Add its entry to THEME_METADATA (label, defaultMode, supported modes) and wire the preset hook(s) into useThemeRegistry in theme-registry.tsx
  4. If the theme needs identity-specific CSS (backgrounds, scrollbars), key it off [data-theme='my-theme'] in styles/globals.css; mode-generic rules already apply via [data-mode='...']
  5. Set behavior.allowsPrimaryOverride explicitly 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​

  1. Create the mode's AppThemeConfig hook (e.g. a dark variant of Glass)
  2. Add the mode to the theme's modes list in THEME_METADATA and its config to useThemeRegistry

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.

  • useCompactAlgorithm is always eligible.
  • colorPrimary is only applied when the active theme sets behavior.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.

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.

Check additions against the overlay case

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​

LibraryPurpose
Ant DesignUI component library
TanStack TableData grids (via the WaydGrid house component)
Redux ToolkitState management
react-markdownMarkdown rendering
rehype-sanitizeSanitizes raw HTML in user-supplied markdown (see above)
dayjsDate formatting
oidc-client-tsOIDC authentication (Entra ID and generic providers)