Docs Hub Source Web route
Source Web Engineering Reference

Overview

TL;DR: Source Web is Vodafone UK's private React component library — a monorepo of 476 packages used by every major Vodafone product team to build consistent, branded UIs.

lib-source-web is the single source of truth for UI components, themes, and utilities shared across Vodafone's digital products — including the Vodafone shop, My Vodafone (ecare), business products (ebu), VOXI, Three (vf3), and the content management system (scms).

All packages are published to a private Azure DevOps npm registry under three scopes: @source-web/* (shared core), @vfuk/* (VFUK-specific), and @vf/* (VF brand).

In plain English: Imagine every Vodafone website and app needs a "Buy Now" button. Rather than each team designing their own button from scratch, this library provides a single, tested, accessible, on-brand button that everyone shares. If the brand colours change, you update one place and every product picks it up automatically.
476Total Packages
276Core Packages
7Brand Themes
3,259TypeScript Files
1,223Test Files
React 18UI Framework
⚠️ These figures reflect the state of the codebase when this site was generated (June 2026). The code may have changed since — cross-check before relying on any numbers.

Guided Tour — Start Here

If you're new, read in this order. Each section builds on the last.

1
What is a Monorepo? — Understand why this repo is structured the way it is before touching any code. Everything else makes more sense after this.
2
Getting Started — Install dependencies and get the docsite running locally.
3
Nx — Smart Build System — Understand how Nx decides what to build, what to skip (caching), and how it runs commands across 476 packages efficiently.
4
Package Anatomy — Deep-dive into a single package (Button) to understand the file structure every component follows.
5
Theme System — Understand how Vodafone/Three/VOXI branding flows through every component without hardcoded colours.
6
Adding a New Component — Follow the end-to-end walkthrough: from pnpm packageGenerator to merged PR.

Persona Quick-Links

New Joiner

Install, run locally, and read the architecture overview.

Component Author

End-to-end walkthrough for creating and shipping a new component.

Product Team Engineer

Find your brand packages (ebu / ecare / shop / vf3 / voxi / scms).

Debugging

Non-obvious behaviours, known traps, and what not to do.

Getting Started

TL;DR: You need Node 22+, pnpm 10.33.2, and access to the private Azure DevOps npm registry. Then pnpm installpnpm start.

Prerequisites

  • Node.js ≥ 22 — the JavaScript runtime. Download from nodejs.org.
  • pnpm 10.33.2 — the package manager (like npm but faster and monorepo-aware). Install: npm install -g pnpm@10.33.2
  • Private registry access — all @source-web/*, @vfuk/*, @vf/* packages live on Azure DevOps, not public npm. Ask the team for .npmrc credentials.
In plain English: The registry is a private "app store" for internal packages. Without credentials, pnpm install will fail for anything with the @source-web prefix. Your .npmrc file holds the password — never commit it.

Installation

# 1. Clone the repo (via Azure DevOps)
git clone https://vfuk-digital@dev.azure.com/vfuk-digital/Digital/_git/lib-source-web
cd lib-source-web

# 2. Install all workspace dependencies (reads .npmrc for private registry)
pnpm install

# 3. Build every package in topological order, then start the docsite
pnpm start
# Docsite opens at http://127.0.0.1:6069/

→ See: package.json for full scripts list.

Faster daily workflow (after first install)

# Start just the docsite (no rebuild — assumes packages are already built)
pnpm start:docsite

# Build only packages changed since main branch
pnpm build:changes

# Run tests for changed packages only
pnpm test:preMerge

# Work on a single package
pnpm nx run @source-web/button:build
pnpm nx run @source-web/button:test
Tip: Always use :changes or :preMerge variants instead of :all. Running all 476 packages takes several minutes; running only affected packages takes seconds.

Command Cheat Sheet

TL;DR: The root package.json defines 70 scripts. Most days you only need a handful of these. This table is the one to bookmark.

Daily workflow

CommandWhat it doesWhen to use it
pnpm startBuilds every package, then starts the docsiteFirst run of the day, or after pulling changes that touch many packages
pnpm start:docsiteStarts the docsite only — no rebuildYou've already built and just want the docsite running (fastest)
pnpm build:changesBuilds only packages affected since origin/mainAfter making a change, before running tests — much faster than build:all
pnpm build:coreBuilds themes + source-provider + cypress-tools onlyQuick sanity check on the foundational packages everything else depends on
pnpm build:allBuilds every one of the 476 packagesRarely — full CI runs, or verifying a change with very wide blast radius
pnpm testRuns every vitest suite in the repoRarely — prefer test:preMerge
pnpm test:preMergeRuns vitest only for affected packagesBefore opening a PR
pnpm preMergeThe full local gate: affected build + all lint:* + testAlways run this before pushing — it mirrors what CI checks
pnpm nx run @source-web/<pkg>:buildBuilds a single packageFastest inner loop while actively editing one component
pnpm nx run @source-web/<pkg>:testRuns vitest for a single packageSame — tight feedback loop on one package

Linting (the four checks preMerge runs)

CommandToolChecks
pnpm lint:oxlintoxlintCode-quality rules (replaces ESLint)
pnpm lint:oxfmtoxfmtFormatting, run with --check (replaces Prettier)
pnpm lint:stylelintstylelintstyled-components CSS rules
pnpm lint:tstsc--noEmit per package — type errors
pnpm lint:depcheckdepcheckUnused or missing dependencies in each package.json

Generators & housekeeping

CommandWhat it doesWhen to use it
pnpm packageGeneratorInteractive wizard that scaffolds a new package (component, theme, icon, util)Always — never copy-paste an existing package
pnpm exampleGeneratorRegenerates a component's example files and the auto-generated UI/a11y test specs from themAny time you add or change a component's examples/ folder
pnpm autoVersionerInteractive Beachball changeset wizard — asks which packages changed, bump type, descriptionRequired before every PR that ships a code change
pnpm configureIconsRebuilds icon packages from source SVGsAfter adding/changing an icon SVG — this is CI-gated in pre-merge
pnpm markForDeprecationFlags a package as deprecated (adds metadata, does not remove it)Sunsetting an old component — the first step, not the last
pnpm deprecatePackagesProcesses packages already flagged for deprecationRun by the team owning the deprecation process — not typically run ad hoc
In plain English: Deprecation here is a two-step process, like marking a library book "withdrawn" before it's actually removed from the shelf. markForDeprecation tags it; deprecatePackages later acts on those tags. There's no single doc describing the full lifecycle between those two steps — if you're sunsetting a package, ask the Source Web team for the current process before running either command.

Environment Variable Reference

TL;DR: There's no .env.example in this repo. The library itself (not just the playground apps) reads about a dozen environment variables directly — mostly through small dedicated helper packages under packages/core/utils/. If one of these isn't set, the failure is often silent (a feature just behaves as if "off" rather than throwing).
In plain English: Environment variables are settings injected from outside the code — by your terminal, by the hosting platform, or by a CI pipeline — rather than hardcoded. Because there's no example file listing them, the only way to know they exist is to grep for process.env. or VFUK.env, which is exactly how this table was built.
VariableRead byControlsIf unset
ORG_IDorgResolver.tsResolves Vodafone (VFRED) vs Three (VFT03) org/brandingFalls back to VFRED (Vodafone) — see the Org Resolver section
ENVIRONMENTorgResolver.tsWhether the ORG_ID cookie override is honoured (only in non-production)Treated as non-production — cookie override is allowed
LOG_LEVELlogger.tsMinimum log level emitted by the shared loggerNo filtering applied — logger uses its internal default
NODE_ENVlogger.ts and othersStandard Node environment flag — gates verbose/dev-only outputTreated as non-production by anything checking !== 'production'
LAUNCH_DARKLY_SDK_KEYfeatureFlags/client.tsAuthenticates the LaunchDarkly feature-flag clientFeature flag client is never initialised — all flags resolve to their default (usually false) with no error
ASSET_LAMBDA_SERVICE_URLcontentAPIProxyMiddleware.tsBase URL the content-API proxy middleware forwards requests toFalls back to http://localhost:8000 — fine locally, broken in any deployed environment
AUTH_COOKIE_PREFIXshopLogOut.middleware.tsPrefix used when clearing shop auth cookies on logoutCookie name becomes "undefined_id_token" — logout silently fails to clear the real cookie
No .env.example exists in this repo. The table above was built by grepping process.env. and VFUK.env across packages/core — it does not include the many additional variables read by individual product playground apps (playgrounds/ecarePlayground, etc.), which have their own separate configuration surface outside the scope of this library doc.

What is a Monorepo?

TL;DR: A monorepo is a single Git repository that contains many independent packages. This one contains 476. They can each have their own version, tests, and build process, but they share code directly without publishing to npm first.
In plain English: Imagine a supermarket warehouse. Instead of each department (bakery, deli, produce) having their own separate building across town, they're all under one roof. They can borrow equipment from each other, share the same loading dock, and be managed by one security team — but each department still operates independently and keeps its own inventory.

How the workspace is structured

pnpm-workspace.yaml tells pnpm which folders are packages. Any folder with a package.json inside packages/**/** or docs/**/** is automatically part of the workspace.

lib-source-web/ ├── packages/ ← all 476 packages live here │ ├── core/ ← shared components (39 categories) │ │ ├── interactions/Button/ │ │ ├── forms/Input/ │ │ ├── themes/WS10/ │ │ └── utils/orgResolverHelpers/ │ ├── ebu/ ← Enterprise Business Unit packages │ ├── ecare/ ← Customer care / My Vodafone │ ├── shop/ ← E-commerce / shop.vodafone.co.uk │ ├── vf3/ ← Three (VF3) brand │ ├── voxi/ ← VOXI brand │ └── scms/ ← Smart Content Management System ├── tools/ ← generators and scripts (not published) ├── playgrounds/ ← test apps for real-bundler testing ├── docs/ ← documentation (this file!) ├── nx.json ← Nx build orchestrator config ├── pnpm-workspace.yaml ← declares which folders are packages └── package.json ← root scripts and shared devDependencies

Workspace dependencies — workspace:*

When one package depends on another in the same repo, it uses "workspace:*" as the version. This means "use the local copy, whatever version it's at." No publishing to npm required during development.

// Button's package.json — depends on Icon and Interaction from the same repo
"peerDependencies": {
  "@source-web/icon": "workspace:*",
  "@source-web/interaction": "workspace:*"
}
In plain English: workspace:* is like saying "borrow from the colleague next door" instead of "order from the supplier." Changes to @source-web/icon are immediately visible to Button without any publish step.

Package scopes explained

ScopeExampleMeaning
@source-web/*@source-web/buttonShared core component, usable by all product teams
@vfuk/*@vfuk/ebu-core-gate-keeperVFUK-specific, often product-team-owned
@vf/*@vf/utils-is-broadbandVF brand utilities, typically shop-related

Nx — Smart Build System

TL;DR: Nx is the orchestrator that runs tasks (build, test, lint) across all 476 packages in the right order, caches results so unchanged packages are skipped, and figures out which packages are "affected" by your changes.
In plain English: Imagine you have 476 LEGO sets to assemble. Some sets need pieces from other sets before they can be built. Nx is like a factory manager who knows the exact build order, remembers which sets are already finished (caching), and — when you change one piece — figures out exactly which sets need to be rebuilt without touching the finished ones.

The three things Nx does here

1. Topological build ordering

In nx.json, the build target has "dependsOn": ["^build"]. The ^ means "all of my dependencies must build first." So if Button depends on Icon and Interaction, Nx builds those before Button — automatically.

// nx.json
"build": {
  "dependsOn": ["^build"],   // ^ = upstream packages first
  "outputs": ["{projectRoot}/dist"],
  "cache": true
}

2. Computation caching

Every cacheable task (build, test, lint) stores its result. Next time you run the same task on unchanged files, Nx replays the cached result instantly — no re-running. Cache key = input files + env + command.

# First run: compiles all 476 packages (~5 min)
pnpm build:all

# Second run (nothing changed): replays cache (~5 sec)
pnpm build:all
# Output: "476 tasks from cache"

3. Affected detection

nx affected compares your branch against main and finds every package whose source files changed — plus every package that depends on those packages. Only those packages are rebuilt/tested.

# Only build packages changed since main (and their dependents)
pnpm build:changes

# Only run tests for affected packages
pnpm test:preMerge

# Run all pre-merge checks (build + lint + test) for affected only
pnpm preMerge
Why this matters: In a 476-package repo, running everything on every PR would take 20+ minutes. Affected detection brings that down to seconds for typical single-component PRs.

Key Nx commands

CommandWhat it does
pnpm nx run @source-web/button:buildBuild one specific package
pnpm nx run @source-web/button:testTest one specific package
pnpm nx run-many --target=build --parallel=8Build all packages, 8 at a time
pnpm nx affected --target=test --base=origin/mainTest only what changed vs main
pnpm nx graphOpen dependency graph visualiser in browser

Architecture & Request Flow

TL;DR: A product app wraps its component tree in SourceProvider (which injects the theme, i18n, and overlay context), imports components from built dist/ packages, and the theme flows down via React context to every component's styled-components.
flowchart LR A["🌐 Product App\n(shop/ecare/ebu)"] -->|"1 wraps tree"| B["⚙️ SourceProvider\n@source-web/source-provider"] B -->|"2 provides theme"| C["🎨 ThemeProvider\nstyled-components"] B -->|"3 provides i18n"| D["🌍 I18nextProvider\nreact-i18next"] B -->|"4 provides overlays"| E["🔲 OverlayProvider\n@source-web/overlay-controller"] C -->|"5 theme flows to"| F["🧩 Component\ne.g. Button"] F -->|"6 reads"| G["📋 Component Theme\nButton.theme.ts"] G -->|"7 tokens from"| H["🏷️ Brand Theme\n@source-web/theme-ws10"]

Walking through the diagram

  1. Product App wraps its tree in <SourceProvider theme={ws10Theme}>. This is the single setup step every consuming app must do.
  2. SourceProvider injects ThemeProvider (from styled-components) — this makes the theme object available to every styled component in the tree via React context.
  3. SourceProvider injects I18nextProvider — internationalisation (English, Italian, Spanish) for any component that uses translated strings.
  4. SourceProvider injects OverlayProvider — manages z-index stacking for modals, drawers, and flyouts.
  5. Theme flows down to components — any styled-component can read props.theme to get the active brand's colour tokens.
  6. Component reads its own theme file — e.g. Button.theme.ts maps abstract token keys (theme.color.primary1.default) to the component's visual properties (background, text, border colour).
  7. Brand theme provides the tokens@source-web/theme-ws10 defines what color.primary1.default actually is (Vodafone Red: #e60000). Swap to @source-web/theme-three and the same token becomes Three's brand colour.

How a component is consumed by a product team

// In a product app (e.g. shop.vodafone.co.uk)
import { SourceProvider } from '@source-web/source-provider'
import ws10Theme from '@source-web/theme-ws10'
import { Button } from '@source-web/button'

// 1. Wrap at app root
function App() {
  return (
    <SourceProvider theme={ws10Theme}>
      <MyRouter />
    </SourceProvider>
  )
}

// 2. Use components anywhere in the tree — theme is automatic
function CheckoutPage() {
  return <Button text="Buy Now" appearance="primary" onClick={handleBuy} />
}

→ See: SourceProvider.tsx

Package Anatomy — The Button Package

TL;DR: Every component package follows an identical structure: component + types + styled-components styles + theme file + examples + four test layers (unit, interaction, UI/visual, a11y). Config files all extend a central @source-web/style-guide.

Complete file tree

packages/core/interactions/Button/ ├── package.json ← name, version, scripts, deps, figma ref, theme tags ├── tsdown.config.ts ← bundler config (extends style-guide) ├── oxlint.config.ts ← linter config (extends style-guide) ├── oxfmt.config.ts ← formatter config (extends style-guide) ├── stylelint.config.ts ← CSS-in-JS linter config ├── tsconfig.json ← TypeScript config ├── cypress.config.ts ← E2E/visual test config ├── vite.config.ts ← Vite config for Cypress ├── manifest.json ← AUTO-GENERATED: props docs + example list (for docsite) ├── CHANGELOG.md ← AUTO-GENERATED: by beachball on release ├── dist/ ← AUTO-GENERATED: built output │ ├── index.mjs ← compiled ES Module bundle │ └── index.d.mts ← TypeScript type declarations └── src/ ├── index.ts ← public exports (only this file is shipped) ├── Button.tsx ← the React component ├── Button.types.ts ← TypeScript prop interface ├── styles/ │ ├── Button.style.ts ← styled-components (all visual CSS lives here) │ └── Button.style.types.ts ├── themes/ │ ├── Button.theme.ts ← maps theme tokens → component-specific values │ └── Button.theme.types.ts ├── examples/ ← live examples rendered in the docsite │ ├── 01_Appearances.example.tsx │ ├── 02_InverseAppearances.example.tsx │ └── … (07 total) └── tests/ ├── unit/Button.unit.spec.tsx ← vitest: logic tests ├── interaction/Button.interaction.spec.tsx ← Cypress: user interactions ├── ui/Button.ui.spec.tsx ← Cypress + Percy: visual snapshots └── a11y/Button.a11y.spec.tsx ← axe: accessibility checks

The six key source files explained

Button.tsx — the React component

The component file is intentionally thin. It only handles logic (which theme variant to use, whether it's loading, whether it's disabled) and delegates all visual rendering to the styled-component in Button.style.ts.

// Button.tsx (simplified)
const Button: FC<ButtonProps> = ({ appearance = 'primary', loading = false, text, icon, state, ... }) => {
  const buttonTheme = useLocalTheme('Button', defaultTheme, localTheme)
  // useLocalTheme: merges the brand theme with any component-level overrides

  return loading ? (
    <Styled.Button ...>
      <LoadingSpinner />           {/* spinner overlays the text */}
      <Styled.HiddenText>{text}</Styled.HiddenText>  {/* text hidden but readable by screen readers */}
    </Styled.Button>
  ) : (
    <Styled.Button ...>{text}</Styled.Button>
  )
}

→ See: Button.tsx

Button.types.ts — TypeScript prop interface

All props are typed here. The interface extends BaseProps (shared across all components) and Interactions (the base interactive element).

export interface ButtonProps extends BaseButtonWithIconProps, BaseProps<ButtonTheme, AppearanceKeys> {
  width?: 'auto' | 'full'    // auto (default) or full-width
  inverse?: boolean           // inverted colour scheme for dark backgrounds
}
// BaseProps adds: localTheme, dataAttributes, dataSelectorPrefix, id
Button.style.ts — all visual CSS (styled-components)

Every pixel of the component's appearance is defined here using styled-components template literals. The theme tokens flow in via props.theme (injected by ThemeProvider) and props.buttonTheme (the component's own theme object).

// Button.style.ts (simplified)
export const Button = styled(Interaction)<StyledButtonProps>((props) => {
  const themeAppearance = props.buttonTheme.appearance[props.appearance]
  return css`
    height: 44px;
    min-width: 152px;
    color: ${themeAppearance.color};              // from component theme
    background: ${themeAppearance.backgroundColor};
    border-radius: ${props.buttonTheme.borderRadius}; // from component theme
    ${respondTo.md(css`width: ${props.width === 'auto' ? 'auto' : '100%'}`)}
    &:hover { background: ${themeAppearance.hover.backgroundColor}; }
  `
})

respondTo.md() is a mixin from @source-web/mixins that generates a @media (min-width: 768px) block. See the Infrastructure Packages section.

Button.theme.ts — component-level theme defaults

This file maps generic brand tokens to Button-specific values. It's a plain function that takes the global Theme and returns a ButtonTheme. Different brand themes will have different theme.color.primary1.default values — the rest of this file doesn't need to change.

const defaultTheme = (theme: Theme): ButtonTheme => ({
  appearance: {
    primary: {
      color: theme.color.monochrome1.default,       // white text
      backgroundColor: theme.color.primary1.default, // brand red (e60000 for WS10)
      borderColor: theme.color.primary1.default,
      hover: { backgroundColor: theme.color.primary1.hover },
      // ... inverse variants, pressed states ...
    },
    secondary: { ... },
    alt1: { ... },   // ghost button (transparent bg, solid border)
    alt2: { ... },   // ghost button (red border)
  },
  borderRadius: theme.border.radius[2],
  fontWeight: theme.fontWeight[2],
})
package.json — the control file

The package.json is richer than a typical npm package. It includes metadata the docsite and generator tools use:

{
  "name": "@source-web/button",
  "version": "15.1.0",
  "type": "module",           // ESM-only
  "module": "dist/index.mjs", // the built file consuming apps import
  "types": "dist/index.d.mts",
  "figma": { "fileId": "w1DKMfwVkHuSKjZqhii4vc", "nodeId": "4824-264641" },
  "packageStatus": { "dev": "stable", "ux": "stable" },
  "tags": {
    "category": "interactions",
    "channel": "core",
    "themes": ["ws10", "voxi", "voxi3", "vf3"]  // which themes this component supports
  }
}

The tags.themes array is used by the UI tests to know which themes to snapshot the component under.

tsdown — The Bundler

TL;DR: tsdown compiles each package's TypeScript source into a single .mjs ES Module file plus .d.mts type declarations. It replaces older tools like Rollup/esbuild and is significantly faster because its core is written in Rust.
In plain English: A bundler is like a printing press. You write your book (TypeScript source), feed it into the press (tsdown), and out comes a finished product (dist/index.mjs) that anyone can read — even people who don't have the original manuscript tools. It also produces an index (.d.mts) listing exactly what's in the book.

The base config (shared by every package)

Every package's tsdown.config.ts is just two lines — it extends the central config from @source-web/style-guide:

// packages/core/interactions/Button/tsdown.config.ts
import { defineConfig } from 'tsdown'
import baseConfig from '@source-web/style-guide/tsdown'
export default defineConfig(baseConfig)

The base config itself lives at packages/core/tools/styleGuide/src/tsdown/index.ts:

// @source-web/style-guide/tsdown (the actual base)
const config: UserConfig = {
  attw: true,              // "Are The Types Wrong?" — validates exported types are correct
  dts: { tsgo: true },     // Generate .d.mts declarations using tsgo (faster than tsc)
  entry: { index: 'src/index.ts' },  // Single entry: always src/index.ts
  format: ['esm'],         // Output ES Modules only — no CommonJS, no UMD
  publint: true,           // Validate package.json exports before publish
}

What each option means

OptionWhat it doesWhy it matters
format: ['esm']Output is .mjs ES Module onlyModern bundlers (Vite, Next.js) prefer ESM; no legacy CJS baggage
dts: { tsgo: true }Generates .d.mts TypeScript declarations using tsgotsgo is a fast Go-based TypeScript checker — much faster than tsc for declarations
attw: trueRuns "Are The Types Wrong?" checkCatches broken type exports before publish — prevents consumers getting any types
publint: trueValidates package.json exports mapEnsures exports field points to real files that exist

Build output

dist/
├── index.mjs      ← the compiled component (what apps import)
└── index.d.mts    ← TypeScript types (what your editor uses for autocomplete)
Never edit dist/ manually. It's regenerated on every build and gitignored. Any manual changes will be overwritten.

Linting Stack

TL;DR: Four linters run on every package: oxlint (JavaScript/TypeScript rules), oxfmt (code formatting), stylelint (CSS inside styled-components), and tsc (TypeScript type checking). All configs extend the central @source-web/style-guide package.
In plain English: Linters are like grammar checkers for code. oxlint checks that you haven't written technically valid but logically wrong code. oxfmt makes sure everything is consistently indented and spaced. stylelint checks the CSS inside your components. tsc verifies all your TypeScript types are correct. Together they catch bugs before the tests even run.

The four linters

oxlint v1.67.0
Rust-based JavaScript/TypeScript linter. Replaces ESLint. Runs ~50–100× faster because it's compiled native code rather than JavaScript. Catches correctness bugs, React hook violations, accessibility issues, and unused imports.
# Run for the whole repo
pnpm lint:oxlint

# Run for one package
pnpm nx run @source-web/button:lint:oxlint

Each package config extends @source-web/style-guide/oxlint/react which enables: TypeScript rules, unicorn (code quality), React/JSX-a11y, jest-compatible test rules.

oxfmt v0.52.0
Rust-based code formatter. Replaces Prettier. Formats TypeScript, JSX, JSON, and imports consistently across all packages. Run fmt:check in CI; run fmt locally to auto-fix.
# Check formatting (CI — fails if code isn't formatted)
pnpm lint:oxfmt

# Auto-fix formatting (local — modifies files)
pnpm nx run @source-web/button:fmt
stylelint v16.25.0
Lints CSS written inside styled-components template literals. Catches invalid CSS properties, deprecated values, and enforces consistent CSS conventions. Uses postcss-styled-syntax to understand the styled-components syntax.
pnpm lint:stylelint
tsc (TypeScript compiler) v5.9
Runs tsc --noEmit — type-checks the code without generating any output files. This is the strictest check: if your types are wrong anywhere, this fails.
pnpm lint:ts
Rule: No @ts-ignore, @ts-expect-error, @ts-nocheck, or widening to any. Fix the underlying type instead.

depcheck — dependency hygiene

depcheck scans each package for dependencies that are declared in package.json but never actually imported, and imports that exist in the code but aren't declared. Keeps package.json accurate.

pnpm lint:depcheck

The central config hub: @source-web/style-guide

Every linting/formatting config in every package is a one-liner that extends @source-web/style-guide. Changing a rule in the style-guide propagates to all 476 packages automatically on next build.

// Any package's oxlint.config.ts — always this pattern
import { defineConfig } from 'oxlint'
import reactConfig from '@source-web/style-guide/oxlint/react'
export default defineConfig(reactConfig)

→ See: @source-web/style-guide package.json

Theme System

TL;DR: Themes are plain JavaScript objects containing design tokens (colours, spacing, borders, fonts). SourceProvider injects the chosen theme into React context. Every component reads tokens from that context via props.theme in styled-components — swap the theme object and the entire UI rebrands.
In plain English: Think of a theme as a paint-by-numbers template. The component is the outline (shapes, layout, spacing logic). The theme fills in the colours. Change the theme from WS10 (Vodafone red) to Three (Three's teal/purple) and the same Button component automatically paints itself in Three's colours — without changing a single line of the Button source code.

Theme packages

PackageBrandUsed by
@source-web/theme-ws10Vodafone WS10Main Vodafone products (shop, ecare)
@source-web/theme-threeThree MobileThree (VFT03) brand properties
@source-web/theme-vf3VF3Vodafone 3 (combined brand)
@source-web/theme-voxiVOXIVOXI sub-brand
@source-web/theme-voxi3VOXI3VOXI on Three network
@source-web/base-themeBase/FallbackFoundation inherited by all themes
@source-web/mock-themeTesting mockUnit/integration tests only

How tokens flow

// 1. Brand theme defines tokens
// @source-web/theme-ws10/src/constants/colors.ts
export const primary1 = { default: '#e60000', hover: '#bd0000', pressed: '#990000' }

// 2. Component theme maps tokens to visual properties
// Button.theme.ts
primary: {
  backgroundColor: theme.color.primary1.default, // '#e60000' for WS10
  hover: { backgroundColor: theme.color.primary1.hover } // '#bd0000'
}

// 3. Styled component reads at render time
// Button.style.ts
background: ${themeAppearance.backgroundColor}; // renders '#e60000'

The useLocalTheme hook

Components call useLocalTheme('Button', defaultTheme, localTheme). This merges the brand's global theme with the component's default theme, then applies any localTheme prop passed by the consumer. This lets a product team customise one button's appearance without overriding the global brand theme.

// Product team customising a specific button instance
<Button
  text="Special Offer"
  localTheme={(theme) => ({ appearance: { primary: { backgroundColor: theme.color.success.default }}})}
/>

Theming primitives (packages/core/theming/)

These packages define the shape of theme objects — the TypeScript interfaces that all brand themes must conform to:

PackageDefines
@source-web/theme-colorsColour palette token structure (primary, monochrome, success, etc.)
@source-web/theme-bordersBorder widths, radii, and styles
@source-web/theme-typographyFont families, weights, sizes, line heights
@source-web/theme-spacing-valuesSpacing scale (4px base unit)
@source-web/theme-elevationBox-shadow levels for depth
@source-web/theme-defaultsDefault values used when a brand doesn't override a token

Infrastructure Packages

These packages aren't UI components — they're the foundational building blocks every component depends on. They live in packages/core/misc/, packages/core/tools/, and packages/core/styles/.

@source-web/source-provider v15.1.0 core
TL;DR: The mandatory root wrapper for every consuming app. Injects theme, i18n, overlay, helmet, and global CSS in one component.
<SourceProvider theme={ws10Theme} baseAssetUrl="https://cdn.vodafone.co.uk/">
  <App />
</SourceProvider>

SourceProvider.tsx

@source-web/interaction core
TL;DR: The base interactive element. Automatically renders as <button>, <a>, or a custom router link depending on props. Button, Link, IconButton etc. all extend this.
// Interaction automatically picks the right HTML element:
// href="..." → renders as <a href="...">
// type="submit" → renders as <button type="submit">
// customRouterProps → renders as your router's Link component
@source-web/base-props core
TL;DR: The shared TypeScript interface that every component's props interface extends. Adds: localTheme, dataAttributes, dataSelectorPrefix, and id to every component.
interface ButtonProps extends BaseProps<ButtonTheme, AppearanceKeys> {
  // BaseProps adds: localTheme, dataAttributes, dataSelectorPrefix, id
  text: string
  appearance?: 'primary' | 'secondary' | 'alt1' | 'alt2'
}
@source-web/mixins core
TL;DR: CSS-in-JS utility functions used inside styled-components. These replace raw CSS calculations with semantic helpers.
import { respondTo, spacing, advancedSpacing, opacity, transition } from '@source-web/mixins'

// respondTo.md() → @media (min-width: 768px) { ... }
${respondTo.md(css`width: auto`)}

// spacing('margin', 3) → margin: 12px (3 × 4px base unit)
${spacing('margin', 3)}

// advancedSpacing('padding', [3, 5]) → padding: 12px 20px
${advancedSpacing('padding', [3, 5])}
@source-web/themes core
TL;DR: The abstract TypeScript type definitions for the Theme object, plus the useLocalTheme hook. Every brand theme must satisfy the Theme interface exported from here.
@source-web/style-guide v4.1.1 core
TL;DR: The central configuration hub. Exports shared configs for tsdown, oxlint, oxfmt, stylelint, vitest, Cypress, TypeScript, Prettier, and Vite. Every package's config file is a one-liner extending this.

Core Component Categories

All shared components live under packages/core/ and are organised into 39 functional categories. Every package in this folder is scoped @source-web/* and usable by all product teams.

CategoryWhat's in itExample packages
interactionsClickable elementsButton, Link, IconButton, Tag, InlineLink, SmallButton, FloatingChatbotButton
formsInput controlsAddressPicker, CalendarPicker, CheckboxList, ColorSelect, AdvancedSearch
overlaysModal/drawer layersBasketFlyoutTemplate, BottomTray, ErrorStatusModal, FilterFlyout
navigationNav and footerHeader, Footer, Breadcrumbs, IconStrip, MinFooter
cardsCard layoutsActionCard, CardBuilder, DiscoveryCard, ListBuilder
typographyText elementsHeading, Paragraph, Span, TextStack, PriceRiseText
notificationsAlerts and bannersAlertNotification, DecisionNotification, InlineNotification, FullWidthNotification
layoutStructural layoutSimpleGrid, Block, Divider, MatchMedia, BackgroundImageBlock
mediaImages and iconsIcon, IconWithBackground, Image, Video, YoutubeVideo
loadersLoading statesLoadingSpinner, Loader, SimpleSkeletonLoader, TextSkeletonLoader
tablesData tablesComparisonTable, DataTable, HorizontalTable, MatrixTable
tabsTab navigationTabs, FunctionalTabs
themesBrand theme objectsWS10, Three, VF3, VOXI, VOXI3, BaseTheme, mockTheme
themingTheme token primitivesColors, Borders, Typography, SpacingValues, Elevation, Defaults
miscFoundational utilitiesSourceProvider, BaseProps, HeadTags, RawHtmlWrapper, IconRuleSets
toolsDev tooling packagesstyle-guide, mixins, languagePacks, cypressTools, seo, helpers
utilsJS utility librariesorgResolverHelpers, featureFlags, browser, datadogTracer, middleware, logger
iconsIcon asset packagesBrandHiFiIcons, BrandLoFiIcons, SourceFlagIcons, SourceHiFiDarkIcons
bannersPromotional bannersAnimatedPartnerBanner, AppButtonBanner, DynamicBanner
carouselsScrollable contentBannerCarousel, CardCarousel, FunctionalCarousel, Gallery
steppersProgress indicatorsHorizontalStepper, Paginator, Timeline, VerticalStepper
hooksReact hooksuseGenesysChat
pagesPage templatesMinPageTemplate, StandardPageTemplate, DetailsPageWithMenu
collectionsList/grid containersCardGrid, CardList, DiscoveryGrid, AppStoreButtonList, IconSnippetList
dataVisualisationCharts and progressChart, HorizontalBarChart, ProgressBar

Adding a New Component

TL;DR: You cannot manually copy a package — you must run pnpm packageGenerator. It scaffolds all config files correctly. Then implement the component, run pnpm exampleGenerator, pnpm autoVersioner, and pnpm preMerge.

Scenario: Adding a Badge component

We want to create a new @source-web/badge-count component — a circular number badge (like a notification count) that sits on top of other UI elements.

This follows the exact same pattern as every existing component. The walkthrough below reflects how a component like @source-web/number-badge (which already exists) was created.

1
Run the package generator (user must do this — it's an interactive wizard)
pnpm packageGenerator

The generator asks these questions in sequence:

? Select the type of package        → Component
? What will the package be called?  → BadgeCount
? Component channel                 → core
? Component category                → badges
? Theme(s)                          → ws10, voxi, voxi3, vf3
? Does your component have a Figma URL? → Yes
? Enter Figma URL here:             → https://www.figma.com/file/...
? Publish as a pre-release?         → No

The generator creates packages/core/badges/BadgeCount/ with all config files, an empty component, and test stubs — pre-wired and ready.

Why can't you just copy an existing package? The generator writes correct relative paths in Cypress configs, wires up the Nx project graph, creates beachball change files, and sets the package name/scope correctly. Copy-paste misses all of this and causes subtle CI failures.
2
Implement the component

After the generator runs, you'll find scaffolded files. Fill them in following the Button pattern:

// src/BadgeCount.tsx
import { useLocalTheme } from '@source-web/themes'
import * as Styled from './styles/BadgeCount.style'
import defaultTheme from './themes/BadgeCount.theme'
import type { BadgeCountProps } from './BadgeCount.types'

const BadgeCount: FC<BadgeCountProps> = ({ count, appearance = 'primary', localTheme }) => {
  const theme = useLocalTheme('BadgeCount', defaultTheme, localTheme)
  return <Styled.Badge theme={theme} appearance={appearance}>{count}</Styled.Badge>
}
export default BadgeCount
// src/BadgeCount.types.ts
export interface BadgeCountProps extends BaseProps<BadgeCountTheme, AppearanceKeys> {
  count: number        // the number to display
  appearance?: 'primary' | 'secondary'
}
// src/styles/BadgeCount.style.ts
export const Badge = styled.span<StyledBadgeProps>((props) => css`
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: ${props.badgeTheme.appearance[props.appearance].backgroundColor};
  color: ${props.badgeTheme.appearance[props.appearance].color};
  font-size: 11px;
  font-weight: ${props.theme.fontWeight[2]};
`)
// src/themes/BadgeCount.theme.ts
const defaultTheme = (theme: Theme): BadgeCountTheme => ({
  appearance: {
    primary: {
      backgroundColor: theme.color.primary1.default,  // brand red
      color: theme.color.monochrome1.default,          // white
    },
    secondary: {
      backgroundColor: theme.color.monochrome5.default,
      color: theme.color.monochrome1.default,
    },
  },
})
3
Write examples

Add files in src/examples/ — numbered, each exporting a default component:

// src/examples/01_Appearances.example.tsx
const Appearances = () => (
  <>
    <BadgeCount count={5} appearance="primary" />
    <BadgeCount count={12} appearance="secondary" />
  </>
)
export default Appearances

After adding or changing examples, regenerate the example scaffolding:

pnpm exampleGenerator
# Select: @source-web/badge-count

This auto-generates the Cypress UI test file (tests/ui/BadgeCount.ui.spec.tsx) from your examples.

4
Build and test locally
# Build only this package
pnpm nx run @source-web/badge-count:build

# Run unit/integration tests
pnpm nx run @source-web/badge-count:test

# Open Cypress for visual/interaction tests
pnpm nx run @source-web/badge-count:cy:open
5
Create a changeset (required for all versioned changes)
pnpm autoVersioner
# Runs: beachball change --fetch
# Prompts for: change type (patch/minor/major) and description
# Creates a .json file in change/ — commit this with your PR
6
Run full pre-merge checks
pnpm preMerge
# Runs for affected packages only:
# build → lint:depcheck → fmt:check → lint:stylelint → lint:oxlint → lint:ts → test

All checks must pass before opening a PR.

Brand-Specific Packages — Overview

TL;DR: The six brand directories (ebu, ecare, shop, vf3, voxi, scms) contain packages that are specific to one product or team and are not shared via packages/core. They often depend on core packages but add business logic, API services, or brand-specific UI.
DirectoryScopeWhat it powersPackage count
packages/ebu/@vfuk/ebu-*Enterprise Business (B2B portal)~55
packages/ecare/@vfuk/ecare-*My Vodafone (customer self-service)~90
packages/shop/@vfuk/*, @vf/*shop.vodafone.co.uk (e-commerce)~32
packages/vf3/@source-web/vf3-*Vodafone 3 / Three brand UI~1
packages/voxi/@source-web/voxi-*VOXI sub-brand UI~2
packages/scms/@source-web/*, @vfuk/scms-*CMS integration (Contentful, Ninetailed)~5

ebu — Enterprise Business Unit

TL;DR: Packages for Vodafone's B2B (business-to-business) portal. Contains HATEOAS journey management, forms, page layouts, stores, and the "Fake DXL" server-side mock middleware for local development.
In plain English: The enterprise portal lets business customers manage their Vodafone accounts — ordering devices, managing contracts, etc. These packages provide the specific page layouts, data-fetching logic, and state management unique to that portal. "DXL" (Digital Experience Layer) is Vodafone's internal API gateway — the Fake DXL packages simulate it locally so devs don't need a live backend.
Key packages (55 total)
PackageWhat it does
@vfuk/ebu-core-journey-storeMobX store managing multi-step journey state (wizard/flow navigation)
@vfuk/ebu-core-gate-keeperRoute guard — checks auth/permissions before allowing page access
@vfuk/ebu-core-forms-form-generatorDynamic form builder driven by JSON Schema + @jsonforms/react
@vfuk/ebu-core-contentful-contextReact context providing CMS content from Contentful
@vfuk/ebu-core-page-wrapperStandard page wrapper with header, nav, and footer
@vfuk/ebu-core-fake-dxl-*8 packages providing a local mock of the DXL API gateway for dev/test
@vfuk/ebu-core-helpers-analytics-*Analytics event helpers for Tealium/page-view tracking
@source-web/adaptive-cardCard component that adapts its layout to CMS-driven content
@source-web/segment-switcherB2B/B2C segment toggle UI component
// Example: protecting a route with GateKeeper
import { GateKeeper } from '@vfuk/ebu-core-gate-keeper'
<GateKeeper requiredRole="ACCOUNT_MANAGER">
  <ManageContractsPage />
</GateKeeper>

ecare — Customer Care / My Vodafone

TL;DR: The largest brand package set (~90 packages). Powers My Vodafone — the self-service portal where customers check bills, manage SIMs, and contact support. Includes a full MobX store layer, analytics, middleware, SSR support, and reusable page components.
In plain English: Everything a Vodafone consumer customer sees when they log in to "My Vodafone" is built with these packages. They're quite different from the core design-system packages — they contain actual business logic, API calls, state management stores, and server-side rendering helpers, not just UI primitives.
Key package groups
GroupExample packagesPurpose
Storesecare-core-stores-navigation, -accounts, -journeyMobX state stores (navigation tree, account data, journey flows)
Servicesecare-core-services-request, -anonymous-sessionHTTP request layer + session management
Middlewareecare-core-server-journey-ssr-api-middlewareExpress/Next.js middleware for SSR journeys
Pagesecare-core-components-page-with-nav, -page-preloaderFull page layout templates
Helpersecare-core-helpers-formatters, -get-cookieUtility functions for data formatting and cookies
Utilsecare-core-utils-is-mva, -get-idm-public-tokenMVA (My Vodafone App) detection, IDM token management
// Example: using the page-with-nav layout
import PageWithNav from '@vfuk/ecare-core-components-page-with-nav'
export default function BillPage() {
  return <PageWithNav title="Your Bill"><BillDetails /></PageWithNav>
}

shop — E-commerce

TL;DR: Powers shop.vodafone.co.uk. Contains services for basket management, device inventory, auth, and a configurator for building phone+plan bundles. Also includes data mappers that transform API responses into component-friendly shapes.
Key packages (~32 total)
PackageWhat it does
@vfuk/utils-shop-basket-serviceAdd/remove items from the shopping basket, basket state
@vfuk/utils-shop-inventory-serviceQuery available devices and plans from the inventory API
@vfuk/utils-shop-auth-serviceAuthentication flow for shop checkout
@vfuk/utils-shop-configurator-serviceBuild a phone+plan bundle (the "configurator" journey)
@vfuk/utils-shop-configurator-storeMobX store for configurator state
@vfuk/utils-shop-device-card-mapperTransforms raw device API data → DeviceCard component props
@vfuk/utils-shop-plans-card-mapperTransforms plan API data → PlanCard component props
@vfuk/utils-shop-notifications-context-providerManages shop-wide notification banners
@vf/utils-is-broadbandDetects if the current product context is broadband vs mobile
@vf/utils-api-declarationsTypeScript type declarations for shop API response shapes
@source-web/header-parserTransforms CMS header data → Header component props
@source-web/footer-parserTransforms CMS footer data → Footer component props

vf3 — Vodafone 3 / Three Brand

TL;DR: Brand-specific UI components for the Vodafone 3 (combined Vodafone + Three) brand. Currently a single package providing the distinctive frame banner used on Three-branded pages.
@source-web/vf3-frame-banner vf3
A branded banner component specific to the VF3 visual identity. Used on pages targeting customers on the Three network under the Vodafone brand.
import { VF3FrameBanner } from '@source-web/vf3-frame-banner'
<VF3FrameBanner heading="Unlimited data" subheading="On Three's 5G network" />

VF3-Supported Components

The following core components explicitly declare "vf3" in their tags.themes — meaning they have been tested and styled against the Three/VF3 brand theme. Pass @source-web/theme-vf3 to SourceProvider and they will render in Three's visual language automatically.

In plain English: Each component in this list has been signed off for use on Three-branded pages. If a component is NOT in this list, it hasn't been validated for the Three brand and may look wrong or unstyled.
Interactions — buttons & links
PackageStatus
@source-web/buttonstable
@source-web/small-buttonstable
@source-web/icon-buttonstable
@source-web/inline-linkstable
@source-web/linkstable
@source-web/link-with-iconstable
@source-web/interactive-iconstable
@source-web/skip-linkstable
Banners & Promotional
PackageStatus
@source-web/vf3-frame-banner — Three-exclusive branded bannerstable
@source-web/dynamic-bannerstable
@source-web/standard-bannerstable
@source-web/partner-bannerstable
@source-web/partner-banner-applestable
Cards
PackageStatus
@source-web/action-cardstable
@source-web/card-builderstable
@source-web/discovery-cardstable
@source-web/pattern-cardstable
@source-web/promotional-cardstable
@source-web/quote-cardstable
@source-web/utility-cardstable
@source-web/newsfeed-cardunstable
Navigation
PackageStatus
@source-web/breadcrumbsstable
@source-web/footerstable
@source-web/icon-stripstable
@source-web/headerunstable
@source-web/min-headerunstable
@source-web/overflow-menuunstable
Notifications & Overlays
PackageStatus
@source-web/full-width-notificationstable
@source-web/inline-notificationstable
@source-web/state-notificationstable
@source-web/overlaystable
@source-web/overlay-controllerstable
@source-web/modalunstable
@source-web/notificationunstable
@source-web/simple-notificationunstable
Typography
PackageStatus
@source-web/headingstable
@source-web/paragraphstable
@source-web/spanstable
@source-web/text-stackstable
Layout & Structure
PackageStatus
@source-web/simple-gridstable
@source-web/dividerstable
@source-web/match-mediastable
@source-web/spacingstable
@source-web/blockunstable
Media & Icons
PackageStatus
@source-web/iconstable
@source-web/icon-with-backgroundstable
@source-web/imagestable
@source-web/brand-hifi-iconsstable
@source-web/brand-lofi-iconsstable
@source-web/source-system-iconsstable
@source-web/source-hifi-dark-iconsstable
@source-web/source-hifi-light-iconsstable
@source-web/source-payment-iconsstable
@source-web/uk-brand-iconsstable
@source-web/uk-system-iconsstable
@source-web/videounstable
@source-web/youtube-videounstable
Loaders, Animations & Progress
PackageStatus
@source-web/loaderstable
@source-web/loading-spinnerstable
@source-web/simple-skeleton-loaderstable
@source-web/text-skeleton-loaderstable
@source-web/animatestable
@source-web/animation-containerstable
@source-web/horizontal-stepperstable
Collections, Carousels & Content Blocks
PackageStatus
@source-web/banner-carouselstable
@source-web/card-carouselstable
@source-web/card-gridstable
@source-web/app-store-button-liststable
@source-web/image-snippet-liststable
@source-web/snippet-liststable
@source-web/snippetstable
@source-web/icon-snippetstable
@source-web/image-snippetstable
@source-web/content-blockstable
@source-web/content-block-with-imagestable
@source-web/content-block-with-videostable
@source-web/functional-content-blockstable
@source-web/card-listunstable
@source-web/functional-carousel — to be deprecatedunstable
Interface — accordions, tabs, avatars, tables & misc UI
PackageStatus
@source-web/accordionstable
@source-web/collapsible-containerstable
@source-web/tabsstable
@source-web/functional-tabsstable
@source-web/avatar-with-labelstable
@source-web/functional-avatarstable
@source-web/initials-avatarstable
@source-web/photo-avatarstable
@source-web/number-badgestable
@source-web/pillstable
@source-web/tooltipstable
@source-web/color-swatchstable
@source-web/containerstable
@source-web/date-countdownstable
@source-web/tablestable
@source-web/horizontal-bar-chartstable
@source-web/filter-and-sortunstable
@source-web/decision-treeunstable
@source-web/chartunstable

Updating an existing component for the Three brand — step by step

This walkthrough takes @source-web/number-badge (currently WS10-only) as the example and adds full Three/VF3 brand support. Every step applies equally to any other component.

The key idea: Most of the visual change happens automatically — you swap the theme object and tokens like primary1.default resolve to Three's #000000 instead of Vodafone's #E60000. The manual work is: declaring support, testing under the new theme, and adding a local theme override only if the component needs a structurally different look for Three.

The brand token differences you're working with

TokenWS10 (Vodafone)ThreeImpact
color.primary1.default#E60000 — red#000000 — blackPrimary button bg, badge fill, active states
color.secondary1.defaultdark grey#FF8474 — coralSecondary/accent colour
border.radius[2]6px10pxSlightly more rounded corners on Three
border.radius[3]24px — pill0px — flatThree doesn't use pill shapes
fonts.familyVodafone brand fontHelvetica NeueTypography throughout
1
Create an ADO work item and branch
# Branch naming follows ADO conventions
git checkout -b story/456789-number-badge-vf3-support
2
Add "vf3" to tags.themes in the component's package.json

This is the single source of truth the UI tests, docsite, and CI use to know which themes the component supports.

// packages/core/badges/NumberBadge/package.json  — before
"tags": {
  "category": "badges",
  "channel": "core",
  "themes": ["ws10", "voxi", "voxi3"]  // ← vf3 missing
}

// After
"tags": {
  "category": "badges",
  "channel": "core",
  "themes": ["ws10", "voxi", "voxi3", "vf3"]  // ← added
}
3
Add @source-web/theme-three as a dev + peer dependency

The component needs the Three theme as a devDependency (for local testing) and peerDependency (so consuming apps know they need to provide it).

// package.json — add to both devDependencies and peerDependencies
"devDependencies": {
  "@source-web/theme-three": "workspace:*",   // ← add
  "@source-web/theme-ws10": "workspace:*",
  ...
},
"peerDependencies": {
  "@source-web/theme-three": "workspace:*",   // ← add
  "@source-web/theme-ws10": "workspace:*",
  ...
}

Then run pnpm install from the repo root to link it.

4
Inspect the component's theme file and check whether a Three-specific override is needed

Open src/themes/NumberBadge.theme.ts and look at every token it uses. Ask: does a simple token swap give the right Three design, or does the component need a structurally different look on Three?

// NumberBadge.theme.ts — uses only abstract tokens
const defaultTheme = (theme: Theme): NumberBadgeTheme => ({
  appearance: {
    primary: {
      backgroundColor: theme.color.primary1.default,  // WS10: #E60000 | Three: #000000
      color: theme.color.monochrome1.default,          // both: white
    },
  },
  borderRadius: theme.border.radius[3],  // WS10: 24px (pill) | Three: 0px (flat!)
})
Problem found: border.radius[3] is 24px on WS10 (pill shape) but 0px on Three (flat rectangle). Three's design system uses border.radius[2] (10px) for badge shapes. This means the token swap alone produces the wrong result — a Three local theme override is needed.
5
Create a Three-specific local theme override in the Three theme package

Local theme overrides live in packages/core/themes/Three/src/localThemes/. Create a new file there:

// packages/core/themes/Three/src/localThemes/numberBadge.ts  (new file)
import type { NumberBadgeTheme } from '@source-web/number-badge'
import { colors } from '../constants'

const numberBadgeTheme: Partial<NumberBadgeTheme> = {
  // Override only the values that differ from the automatic token swap
  borderRadius: '10px',  // Three uses radius[2] for badges, not radius[3]
}

export default numberBadgeTheme

Then export it from the Three theme's localThemes/index.ts:

// packages/core/themes/Three/src/localThemes/index.ts
export { default as loaderTheme } from './loader'
export { default as numberBadgeTheme } from './numberBadge'  // ← add this line

And re-export from the Three theme's main index.ts so consuming apps can access it:

// packages/core/themes/Three/src/index.ts
export { numberBadgeTheme } from './localThemes'
6
Use the local theme override in the consuming app

In a Three-branded app, pass the local theme override when using the component:

import { numberBadgeTheme } from '@source-web/theme-three'
import { NumberBadge } from '@source-web/number-badge'

// The numberBadgeTheme is passed via localTheme prop,
// which useLocalTheme() merges on top of the default theme
<NumberBadge count={5} localTheme={() => numberBadgeTheme} />
Alternative: If the Three theme package sets this override globally (e.g. via a Theme extension), consuming apps don't need to pass localTheme manually — the override comes in automatically. Check whether the Three theme already provides this via its theme object structure.
7
Regenerate the example/test files for the new theme

Because tags.themes now includes "vf3", the UI test file needs to be regenerated to add the Three theme snapshot loop:

pnpm exampleGenerator
# Select: @source-web/number-badge
# This regenerates: src/tests/ui/NumberBadge.ui.spec.tsx

The regenerated file will now include vf3 in the tags.themes.forEach loop, meaning Percy will take snapshots of the badge under the Three theme and compare them on every future PR.

8
Run the component under the Three theme locally to verify it looks correct
# Open Cypress in interactive mode — you can see the component live
pnpm nx run @source-web/number-badge:cy:open

# Or build the component and start the docsite to preview
pnpm nx run @source-web/number-badge:build
pnpm start:docsite
# Navigate to NumberBadge in the docsite and switch to the Three theme

Check every example (Appearances, States, Inverse) against the Three design spec in Figma. Confirm the badge uses #000000 background, white text, and 10px border radius on Three.

9
Build the Three theme package to pick up the new local theme export

Because you added a new export to @source-web/theme-three, it needs a rebuild:

pnpm nx run @source-web/theme-three:build
pnpm nx run @source-web/number-badge:build
10
Create changesets for both changed packages
pnpm autoVersioner
# Creates two change files:
#   change/source-web-number-badge-abc123.json  → patch: "Add VF3/Three brand theme support"
#   change/source-web-theme-three-def456.json   → patch: "Add NumberBadge local theme override"
11
Run all pre-merge checks for affected packages
pnpm preMerge
# Nx detects both changed packages + their dependents
# Runs: build → lint:depcheck → fmt:check → lint:stylelint → lint:oxlint → lint:ts → test
# All must pass before opening the PR
12
Open the PR on Azure DevOps

Link the ADO work item. The CI pipeline will run Percy visual comparisons — approve the baseline Three theme snapshots on the first run (they'll be new images with no previous baseline to diff against).

voxi — VOXI Brand

TL;DR: UI components specific to the VOXI sub-brand (Vodafone's youth-focused SIM-only brand). Provides VOXI-styled banner components.
@source-web/voxi-banner voxi
Full-width promotional banner in VOXI visual style.
import { VoxiBanner } from '@source-web/voxi-banner'
<VoxiBanner heading="Endless social data" ctaText="Get VOXI" ctaHref="/voxi" />
@source-web/voxi-skinny-banner voxi
A compact inline-height banner variant for VOXI promotional messaging within a page.

scms — Smart Content Management System

TL;DR: Integration layer between the Vodafone CMS (Contentful + Ninetailed for personalisation) and Source Web components. These packages load content, map it to component props, and handle chat/support integrations.
PackageWhat it does
@source-web/contentful-rich-textRenders Contentful Rich Text field content as React components
@source-web/content-mapperMaps raw Contentful entries to Source Web component props shapes
@source-web/ninetailed-providerWraps Ninetailed (A/B testing + personalisation SDK) for use with Source Web
@source-web/nano-rep-loaderLazy-loads the NanoRep (virtual assistant) widget
@vfuk/scms-genesys-loaderLoads the Genesys chat widget (live agent chat)
// Example: rendering a Contentful Rich Text field
import { ContentfulRichText } from '@source-web/contentful-rich-text'
<ContentfulRichText document={contentfulEntry.fields.body} />

Utility Packages (packages/core/utils/)

TL;DR: Non-UI JavaScript utilities shared across all products. These are pure logic packages — no React, no styled-components. They handle things like org/brand detection, feature flags, environment variables, logging, and analytics.
PackageScopeWhat it does
orgResolverHelpers@vf/utils-org-resolver-helpersDetects the current org/brand (Vodafone vs Three) from env vars or cookie
featureFlags@source-web/feature-flagsReads feature flag values to enable/disable functionality at runtime
browser@source-web/browserSafe browser environment detection (window/document availability)
datadogTracer@source-web/datadog-tracerWrapper around Datadog browser logging/APM (Application Performance Monitoring) SDK
logger@source-web/loggerStructured logging utility (wraps console with log levels)
middleware@source-web/middlewareExpress-compatible server middleware utilities
settings@source-web/settingsApp-wide settings/config object management
createEnv@source-web/create-envType-safe environment variable validation and access
isEnvVarTrue@source-web/is-env-var-trueReads a string env var and returns a boolean ("true"true)
piiMaskDataAttributes@source-web/pii-mask-data-attributesGenerates data attributes that tell Datadog to mask PII (personally identifiable information) from session recordings
shellServer@source-web/shell-serverShared SSR (server-side rendering) shell server utilities
parsers@source-web/parsersGeneric data-parsing helpers
getters@source-web/gettersCommon getter functions (safe object property access)
services@source-web/servicesShared API service utilities (HTTP helpers, error handling)
isPerformanceCookieEnabled@source-web/is-performance-cookie-enabledChecks if the user has consented to performance cookies (GDPR)

Org Resolver — @vf/utils-org-resolver-helpers

TL;DR: The org resolver (formerly "brand resolver") is the single authoritative source for "are we Vodafone or Three right now?" It exports a small set of helpers that wrap one core function: resolveOrgId. Everything else — loading the right theme, naming content spaces, Tealium profile lookup, Boolean convenience checks — delegates to that function.
In plain English: After the Vodafone / Three merger, many apps run on shared infrastructure but need to look and behave differently depending on which org they serve. Rather than scattering if (org === 'three') logic everywhere, every team imports from this single package. You call one function and it returns either 'VFRED' (Vodafone) or 'VFT03' (Three). In production the answer comes from a server environment variable set at deploy time — the operator controls the org. In non-production environments (local dev, staging) a browser cookie can override the env var, so a developer can switch between orgs without touching config files or redeploying the app.
Renamed from brandResolver: the package used to live at packages/core/utils/brandResolver/ as @vf/utils-brand-resolver, keyed off a BRAND_ID env var/cookie. It has been renamed to orgResolverHelpers / @vf/utils-org-resolver-helpers, keyed off ORG_ID. The function/type names (resolveBrandIdresolveOrgId, BrandIDOrgID, BRANDSORGS) changed too — update any old imports.

Package location

packages/core/utils/orgResolverHelpers/
├── src/
│   ├── orgResolver/             ← core resolution logic
│   │   ├── orgResolver.ts
│   │   └── orgResolver.test.ts
│   ├── isThree/
│   │   ├── isThree.ts
│   │   └── isThree.test.ts
│   ├── isVodafone/
│   │   ├── isVodafone.ts
│   │   └── isVodafone.test.ts
│   ├── getOrgSpaceName/
│   │   ├── getOrgSpaceName.ts
│   │   └── getOrgSpaceName.test.ts
│   ├── getTealiumProfile/
│   │   ├── getTealiumProfile.ts
│   │   └── getTealiumProfile.test.ts
│   ├── loadOrgTheme/
│   │   ├── loadOrgTheme.ts
│   │   └── loadOrgTheme.test.ts
│   ├── constants/
│   │   ├── organisations.ts    ← ORGS, VALID_ORGS, DEFAULT_ORG
│   │   └── tealiumProfiles.ts  ← BASE_TEALIUM_PROFILE, TEALIUM_PROFILES
│   ├── types/
│   │   └── orgResolver.types.ts  ← OrgID, Req
│   └── index.ts                ← public API (all exports)

Constants — organisations.ts

The foundation everything else builds on. Two string constants that identify every valid org:

// src/constants/organisations.ts
import type { OrgID } from '../types/orgResolver.types'

const VODAFONE = 'VFRED'
const THREE = 'VFT03'

export const ORGS = {
  VODAFONE,
  THREE,
} as const

export const VALID_ORGS: string[] = Object.values(ORGS)
// → ['VFRED', 'VFT03']

export const DEFAULT_ORG: OrgID = 'VFRED'
// Falls back to Vodafone if nothing is set
Why these names? VFRED = Vodafone Red; VFT03 = Vodafone Three (03 is Three's MVNO code). These are the exact string values stored in the ORG_ID environment variable and cookie — they're not human display names, they're machine identifiers.

Types — orgResolver.types.ts

// src/types/orgResolver.types.ts

// The only two valid org identifiers in the system
export type OrgID = 'VFRED' | 'VFT03'

// A minimal request-like object — only the cookies bag is needed
// Matches the shape of an Express / Next.js / Node request
export interface Req {
  cookies: Record<string, string>
}

The Req interface is intentionally narrow. The resolver only reads req.cookies, so passing a full Express Request object works — but you could also pass { cookies: req.cookies } if your framework uses a different request shape.

Core function — resolveOrgId

This is the heart of the package. Everything else calls this function. Here is the complete source with annotations:

// src/orgResolver/orgResolver.ts
import getCookie from '@vf/utils-get-cookie'
import VFUK from '@vfuk/utils-get-env-variables'

import { VALID_ORGS, DEFAULT_ORG } from '../constants'
import type { OrgID, Req } from '../types/orgResolver.types'

const ORG_ID = 'ORG_ID'  // the key used for both the env var and the cookie

// Type guard — returns true only if the value is 'VFRED' or 'VFT03'
const isValidOrg = (value: unknown): value is OrgID =>
  typeof value === 'string' && (VALID_ORGS as readonly string[]).includes(value)

export const resolveOrgId = (req?: Req): OrgID => {
  // Step 1: Read env — VFUK is a Vodafone utility that works on both server and client
  const env = VFUK.env as Record<string, unknown> | undefined
  let orgIdValue = env?.[ORG_ID]

  // Step 2: Determine whether we are in a production environment
  const environment = env?.ENVIRONMENT
  const isProduction = typeof environment === 'string' && environment.startsWith('prod')
  // 'prod', 'prod1-green', 'prod2-blue' etc. all count as production

  // Step 3: In NON-PRODUCTION only — attempt cookie override
  if (!isProduction) {
    const cookieValue = getCookie(ORG_ID, req)
    // getCookie handles both server-side (reads from req.cookies) and
    // client-side (reads from document.cookie) via the req parameter
    if (isValidOrg(cookieValue)) orgIdValue = cookieValue
    // Only override if the cookie contains a KNOWN valid org string.
    // An invalid / garbage cookie is silently ignored.
  }

  // Step 4: Return the resolved value, or fall back to Vodafone (VFRED)
  return isValidOrg(orgIdValue) ? orgIdValue : DEFAULT_ORG
}

Resolution order (highest priority first)

PrioritySourceWhen it appliesExample value
1 (highest)ORG_ID cookieNon-production only (ENVIRONMENT does not start with "prod")VFT03
2VFUK.env.ORG_ID env varAlways (set by infrastructure at deploy time)VFRED
3 (fallback)Hardcoded defaultIf both above are absent or invalidVFRED

What counts as "production"?

isProduction is true when VFUK.env.ENVIRONMENT is a string starting with "prod". This catches all environment names that Vodafone's infrastructure uses for live deployments (prod, prod1-green, prod1-blue, prod2-green, etc.). Staging / integration environments like int1-blue, int2-green, and anything without an ENVIRONMENT variable set (i.e. local development) are treated as non-production.

Test behaviour (from orgResolver.test.ts)

ScenarioENVIRONMENTenv ORG_IDCookieResult
Nothing setVFRED (default)
Env onlyVFT03VFT03
Cookie overrides env (staging)int1-blueVFREDVFT03VFT03
Cookie overrides env (local dev)VFREDVFT03VFT03
Cookie ignored in prodprodVFREDVFT03VFRED
Cookie ignored in prod variantprod1-greenVFREDVFT03VFRED
Invalid cookie ignoredVFT03NOT_A_ORGVFT03 (env used)
Invalid env falls backWRONGVFRED (default)

Server-side vs client-side usage

// ── Client-side (runs in the browser) ───────────────────────────────────────
const org = resolveOrgId()
// getCookie reads document.cookie; no req needed

// ── Server-side (runs in Node.js / SSR) ─────────────────────────────────────
const org = resolveOrgId(req)
// Pass the incoming HTTP request so getCookie can read req.cookies
// The req object must conform to the Req interface: { cookies: Record<string, string> }
// A standard Express / Next.js / Fastify request already satisfies this

Setting the cookie for local org switching

// In Chrome DevTools → Application → Cookies, set:
// Name:  ORG_ID
// Value: VFT03        ← Three brand
// Value: VFRED        ← Vodafone brand (or delete the cookie to revert)

// Or from a browser console (non-production only):
document.cookie = 'ORG_ID=VFT03; path=/'

Convenience helper — isThree

A Boolean shorthand for the most common Three-org check. Internally calls resolveOrgId and compares the result.

// src/isThree/isThree.ts
import { ORGS } from '../constants'
import { resolveOrgId } from '../orgResolver'
import type { Req } from '../types/orgResolver.types'

export const isThree = (req?: Req): boolean => {
  return resolveOrgId(req) === ORGS.THREE  // 'VFT03'
}
// Usage
import { isThree } from '@vf/utils-org-resolver-helpers'

// Client-side conditional rendering
if (isThree()) {
  return <ThreeLogo />
}

// Server-side (pass req for cookie support in staging)
const showThreeNav = isThree(req)

The req parameter flows through to resolveOrgId unchanged — the same production/cookie rules apply.


Convenience helper — isVodafone

Mirror of isThree. True when resolveOrgId returns 'VFRED'.

// src/isVodafone/isVodafone.ts
import { ORGS } from '../constants'
import { resolveOrgId } from '../orgResolver'
import type { Req } from '../types/orgResolver.types'

export const isVodafone = (req?: Req): boolean => {
  return resolveOrgId(req) === ORGS.VODAFONE  // 'VFRED'
}
// Usage — equivalent to !isThree() but more readable in Vodafone-specific code paths
if (isVodafone()) {
  loadVodafoneAnalyticsTag()
}
When to use which: prefer isThree() when writing Three-specific branches (the new case), and isVodafone() when the code is explicitly Vodafone-only. Avoid !isThree() — it reads poorly and will break if a third org is ever added.

Space name helper — getOrgSpaceName

Vodafone uses "content spaces" — named buckets in the content management system (CMS) — to store copy, assets, and configuration. The space for Three content is always called 'three'. This helper maps any space name to its Three equivalent when running on the Three org.

// src/getOrgSpaceName/getOrgSpaceName.ts
import { ORGS } from '../constants'
import { resolveOrgId } from '../orgResolver'

export const getOrgSpaceName = (originalSpaceName: string): string => {
  return resolveOrgId() === ORGS.THREE ? 'three' : originalSpaceName
}
// Usage — content fetching layer
import { getOrgSpaceName } from '@vf/utils-org-resolver-helpers'

const spaceName = getOrgSpaceName('vodafone')
// → 'three'     when running on Three org
// → 'vodafone'  when running on Vodafone org

const content = await fetchContent(spaceName, pageId)

Note: this helper does not accept a req parameter — it only calls resolveOrgId() with no argument, so it always uses the client-side resolution path. Server-side CMS calls that need cookie support should call resolveOrgId(req) directly and build the space name manually.


Analytics helper — getTealiumProfile

New since the org-resolver rename: resolves the correct Tealium analytics profile for the active org, backed by the BASE_TEALIUM_PROFILE / TEALIUM_PROFILES constants in constants/tealiumProfiles.ts. Same resolution path as everything else — it delegates to resolveOrgId under the hood.


Async theme loader — loadOrgTheme

Dynamically imports the correct theme package at runtime using resolveOrgId. Avoids bundling both theme packages into every app — only the active org's theme is loaded.

// src/loadOrgTheme/loadOrgTheme.ts
import type { Theme } from '@source-web/themes'

import { ORGS } from '../constants'
import { resolveOrgId } from '../orgResolver'

// A lookup map of lazy imports — neither module is loaded until called
const themeImportAsync = {
  [ORGS.VODAFONE]: () => import('@source-web/theme-ws10')  as Promise<{ default: Theme }>,
  [ORGS.THREE]:    () => import('@source-web/theme-three') as Promise<{ default: Theme }>,
} as const

export const loadOrgTheme = async () => {
  const orgId = resolveOrgId()
  const themeModule = await themeImportAsync[orgId]()
  return themeModule.default  // the plain theme object, not the module wrapper
}
// Usage — app bootstrap (e.g. in Next.js _app.tsx or a top-level provider)
import { loadOrgTheme } from '@vf/utils-org-resolver-helpers'
import { SourceProvider } from '@source-web/source-provider'

export default function App({ Component, pageProps }) {
  const [theme, setTheme] = useState(null)

  useEffect(() => {
    loadOrgTheme().then(setTheme)
  }, [])

  if (!theme) return null  // or a skeleton / loading state

  return (
    <SourceProvider theme={theme}>
      <Component {...pageProps} />
    </SourceProvider>
  )
}

Why dynamic import instead of static?

Each theme package (@source-web/theme-ws10, @source-web/theme-three) contains hundreds of design tokens. A static import would bundle both into every app regardless of which org is active, doubling the theme payload for no benefit. The dynamic import pattern means the browser (or Node.js) only fetches the theme for the active org.


Public API — index.ts

Everything the package exports, in one place:

// src/index.ts
export { resolveOrgId as default, resolveOrgId } from './orgResolver'

export { isVodafone } from './isVodafone'
export { isThree } from './isThree'
export { getOrgSpaceName } from './getOrgSpaceName'
export { getTealiumProfile } from './getTealiumProfile'
export { loadOrgTheme } from './loadOrgTheme'

export { ORGS, VALID_ORGS, BASE_TEALIUM_PROFILE, TEALIUM_PROFILES } from './constants'

export type { OrgID, Req } from './types/orgResolver.types'

Which helper to use when

HelperUse when…Async?
resolveOrgId(req?)You need the raw org string, or you're on the server and want cookie supportNo
isThree(req?)Writing a Three-specific code path — most common use caseNo
isVodafone(req?)Writing a Vodafone-specific code path; avoids confusing !isThree()No
getOrgSpaceName(name)Constructing a CMS space name that should switch to 'three' on the Three orgNo
getTealiumProfile()Resolving the analytics profile for the active orgNo
loadOrgTheme()Bootstrapping a React app — load only the active org's theme objectYes (dynamic import)
ORGSComparing against org IDs without hardcoding strings — ORGS.THREE not 'VFT03'

Testing Strategy

TL;DR: Four test layers exist per component package — a11y, unit, interaction, UI — but only a11y and UI are filled in by the packageGenerator by default. Unit and interaction are scaffolded as empty stubs that the component author must write. Separately, vitest is used heavily (180+ files) for pure-logic packages under utils/ and helpers/ — that's where most real unit-test coverage actually lives, not inside component packages.
Don't assume a component has full test coverage just because the four files exist. Run cat src/tests/unit/<Name>.unit.spec.tsx on any component before relying on it as a "well-tested" example — many, including Button itself, ship with empty unit/interaction stubs:
// Button.unit.spec.tsx — as generated, never filled in
export const unitTests = (): void => {
  describe('Unit Tests', () => {
    // Write tests below this line
    // End tests above this line
  })
}

The four test layers

LayerToolFile patternWhat it testsFilled in by default?
Unitvitest (component pkgs) / Cypress stub*.unit.spec.tsxPure logic: helper functions, state calculations, conditional renderingNo — empty stub, author must write
InteractionCypress (component mode)*.interaction.spec.tsxUser events: click, hover, keyboard navigation, focus managementNo — empty stub, author must write
UI / VisualCypress + Percy*.ui.spec.tsxPixel-perfect screenshots across every supported theme — detects visual regressionsYes — auto-generated from examples/
AccessibilityCypress + axe-core*.a11y.spec.tsxWCAG 2.1 AA compliance: colour contrast, ARIA attributes, keyboard operabilityYes — auto-generated, runs against all themes

Where real vitest unit tests actually live

Pure-logic packages — anything under packages/core/utils/, helpers/, or a component's internal helpers/ subfolder — use plain *.test.ts files with real vitest assertions. This is where the bulk of genuine unit coverage in the repo lives (180+ files at last count), e.g. getCookie.test.ts, getAllFeatureFlags.test.ts. If you're looking for a good test-writing example, start here rather than in a component's tests/unit/ stub.

// getCookie.test.ts — a real vitest unit test
describe('getCookie', (): void => {
  it('returns the correct Cookie value server side, req exists', (): void => {
    const req = { cookies: { basketId: '14caa3b4-fa89-4b43-8264-28f29205ba6a' } }
    const basketId = getCookie('basketId', req)
    expect(basketId).toEqual('14caa3b4-fa89-4b43-8264-28f29205ba6a')
  })

  it("returns null if cookie doesn't exist", (): void => {
    vi.spyOn(isClient, 'default').mockReturnValueOnce(true)
    expect(getCookie('nonExistingCookie')).toEqual(null)
  })
})

How UI tests iterate across themes

The tags.themes array in each package.json tells the UI test which themes to snapshot. The test loops over them automatically:

// Button.ui.spec.tsx (auto-generated by exampleGenerator)
tags.themes.forEach((theme) => {       // ['ws10', 'voxi', 'voxi3', 'vf3']
  describe(`${theme} UI Tests`, () => {
    it('Should take a screenshot', () => {
      cy.vfMount(<Appearances />, { theme, inverse: false })
      cy.vfScreenshot({ pageName: 'Button', theme, testName: 'Appearances' })
      // Percy uploads the screenshot and diffs against baseline
    })
  })
})

Running tests

# All unit tests
pnpm test

# Affected unit tests only (use before PR)
pnpm test:preMerge

# Single package unit tests
pnpm nx run @source-web/button:test

# Open Cypress for a package (interactive mode)
pnpm nx run @source-web/button:cy:open

# Run Cypress in headless mode (CI)
pnpm nx run @source-web/button:cy:run

# Pre-check Cypress before running (checks if tests are needed)
pnpm cy:preRunner

Custom Cypress commands

@source-web/cypress-tools provides shared Cypress commands used in all component tests:

cy.vfMount(component, { theme: 'ws10', inverse: false })
// Mounts a component inside a SourceProvider with the specified theme

cy.vfScreenshot({ cssSelector: '#component-wrapper', pageName, theme, testName })
// Takes a Percy visual snapshot with structured naming

CI/CD pipeline — what actually gates a merge

The pre-merge pipeline lives at cicd/pre-merge/pre-merge-validator.yaml and runs as four sequential Azure DevOps stages:

Setup ← cache pnpm, install Node 22.18.0, run Beachball + Manypkg checks, pnpm build:core ↓ CheckBypass ← decides whether Cypress can be skipped for this PR ↓ Validate ← lint.yaml + tests.yaml + cypress-test.yaml run in parallel ↓ (this is the actual merge gate — all three must pass) Finalize ← Percy visual-diff build (only runs if Validate passed AND Cypress wasn't bypassed)
In plain English: Think of it like an airport security line with four checkpoints. You can't skip Setup (bag scan). Whether you go through the full Cypress lane or a fast-track lane depends on CheckBypass. But everyone — fast-track or not — must clear Validate (linting, unit tests, and whichever Cypress tests apply) before the gate opens. Percy only runs afterward, as a final visual sign-off, not a blocker for the others.

Practically: running pnpm preMerge locally before pushing mirrors the Validate stage — if it passes locally, the Validate stage should pass in CI too (Cypress visual/a11y specifics aside, since those need the CI environment to run against Percy's baseline).

Versioning & Release

TL;DR: Each package versions independently using Beachball. Every PR that changes shipped code must include a changeset file created by pnpm autoVersioner. CI then calls beachball bump to apply versions and publish.
In plain English: Beachball is a tool that manages version numbers for each package separately. When you change Button, only Button gets a new version — not Icon or Spinner. You create a "change file" (like a git commit message for the version) and Beachball reads all change files at release time to decide how to bump each version number.

Semantic versioning (semver) rules

Change typeVersion bumpWhen to use
patch1.0.0 → 1.0.1Bug fix, internal refactor — no API changes
minor1.0.0 → 1.1.0New feature, new prop — backwards compatible
major1.0.0 → 2.0.0Breaking change — removes/renames a prop
# Create a changeset (run after making your code changes)
pnpm autoVersioner
# Prompts: Which packages changed? What type (patch/minor/major)? Description?
# Creates: change/my-package-name-abc123.json
# Commit this file with your PR

# Applied automatically by CI on merge to main:
pnpm bump:packages  # beachball bump --yes
Never edit CHANGELOG.md or bump version numbers manually. Beachball manages both. Manual edits will conflict with the automated process.

Release internals — why your workspace dependency versions sometimes show x

The repo's beachball.config.js wires up two non-default hooks that run on every release. If you've ever wondered why a package's dependencies field shows something like "@source-web/button": "15.1.x" instead of an exact version, this is why:

  • prebump hook — before any version is bumped, it scans every local package and records which internal (@vf/*, @vfuk/*, @source-web/*) dependencies are currently on a prerelease version (e.g. 2.0.0-alpha.3).
  • postbump hook — after bumping, it does two things: resets any dependency that was a prerelease back to the stable version it remembers from prebump (so a release never accidentally ships pinned to an alpha), then runs packageDepsToAnyPatch(), which rewrites the exact patch digit of every internal dependency to x — e.g. 15.1.0 becomes 15.1.x.
In plain English: Imagine a recipe that says "use 2 cups of flour" but the kitchen actually has flour from any brand sold this month, not one specific bag. Pinning to 15.1.0 would mean "only this exact patch release works" — which would force every dependent package to bump every time a leaf dependency ships a patch, even when nothing meaningful changed. Allowing 15.1.x says "any patch release of 15.1 is fine," which is intentional flexibility, not a mistake.

→ See: beachball.config.js — look for packageDepsToAnyPatch, prebump, and postbump.

Contribution Flow

1
Discuss in Teams (Source Web Approvals channel) and create an ADO (Azure DevOps) work item before starting significant changes.
2
Branch from main using ADO naming conventions: story/123456-badge-component or bug/123456-button-focus-fix.
3
Make your changes. Follow the package anatomy pattern. Never silence TypeScript errors or use @ts-ignore.
4
Regenerate examples if they changed: pnpm exampleGenerator
5
Create a changeset: pnpm autoVersioner
6
Run pre-merge checks: pnpm preMerge — all must pass.
7
Open a PR on Azure DevOps. Link it to the ADO work item. Add reviewers from the Source Web team.

Gotchas & Traps

security
Never commit .npmrc
The .npmrc file contains authentication tokens for the private Azure DevOps npm registry. It is gitignored for this reason. If you accidentally commit it, rotate the token immediately via Azure DevOps → Artifacts → Connect to feed. The root package.json has a script exclude:npmrc that sets the gitignore flag via git update-index.
testing
The UI test files are auto-generated — don't edit them manually
Files like Button.ui.spec.tsx are generated by pnpm exampleGenerator. They start with /* Auto-generated file, do not edit */. If you change the examples and forget to re-run the generator, the test file will be stale and the CI visual tests may miss new examples or reference deleted ones.
correctness
Never copy-paste a package to create a new one
Always use pnpm packageGenerator. Copy-pasting leaves wrong package names in configs, incorrect Cypress path resolutions, and missing Nx project wiring. The generator is the only safe way to scaffold a new package.
correctness
pnpm lint:eslint and pnpm lint:prettier don't exist
The README still references these old scripts. They were replaced by pnpm lint:oxlint and pnpm lint:oxfmt. Running the old names gives a "script not found" error. The pnpm preMerge script uses the correct new names.
correctness
dist/ must exist before the docsite can use a package
The docsite uses Vite aliases to import component TypeScript directly, but only for packages listed in its alias map. If a brand-new package isn't in the alias map yet, the docsite will try to import from dist/ — which doesn't exist until you run pnpm nx run [pkg]:build first.
security
Brand cookie override only works in non-production
The ORG_ID cookie used by @vf/utils-org-resolver-helpers is only read when ENVIRONMENT does NOT start with "prod". In production, the env var always wins. This is intentional — you can't use a browser cookie to switch org/brand on a live production site.
performance
Don't run build:all or test on a PR branch
Running full build:all or test locally takes 5–20 minutes across all 476 packages. Use build:changes and test:preMerge instead — they only process packages affected by your changes, typically taking seconds to a few minutes.
testing
A package having a "unit test" file doesn't mean it has unit tests
packageGenerator scaffolds tests/unit/<Name>.unit.spec.tsx and tests/interaction/<Name>.interaction.spec.tsx as empty stubs — literally just a describe() block with a comment saying "Write tests below this line". Many packages, including the canonical Button example used throughout this site, never had these filled in. Real vitest unit-test coverage in this repo is concentrated in packages/core/utils/ and component helpers/ subfolders, not in component packages themselves. See the Testing Strategy section for the full breakdown.
build
Nx caching uses the legacy strategy on purpose
nx.json sets "useLegacyCache": true — a deliberate, explicit override of Nx's newer default caching behaviour, not an oversight. If you're debugging a caching-related build issue, don't "fix" this by removing the flag; check with the team why it was pinned before changing it, since later Nx versions changed cache invalidation semantics in ways that may not be compatible with how this repo's dependsOn: ["^build"] topological build chain is structured.
↑↓ navigate  ·  ↵ jump  ·  Esc close