Overview
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).
Guided Tour — Start Here
If you're new, read in this order. Each section builds on the last.
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
pnpm install → pnpm 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.npmrccredentials.
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
:changes or :preMerge variants instead of :all. Running all 476 packages takes several minutes; running only affected packages takes seconds.Command Cheat Sheet
package.json defines 70 scripts. Most days you only need a handful of these. This table is the one to bookmark.Daily workflow
| Command | What it does | When to use it |
|---|---|---|
pnpm start | Builds every package, then starts the docsite | First run of the day, or after pulling changes that touch many packages |
pnpm start:docsite | Starts the docsite only — no rebuild | You've already built and just want the docsite running (fastest) |
pnpm build:changes | Builds only packages affected since origin/main | After making a change, before running tests — much faster than build:all |
pnpm build:core | Builds themes + source-provider + cypress-tools only | Quick sanity check on the foundational packages everything else depends on |
pnpm build:all | Builds every one of the 476 packages | Rarely — full CI runs, or verifying a change with very wide blast radius |
pnpm test | Runs every vitest suite in the repo | Rarely — prefer test:preMerge |
pnpm test:preMerge | Runs vitest only for affected packages | Before opening a PR |
pnpm preMerge | The full local gate: affected build + all lint:* + test | Always run this before pushing — it mirrors what CI checks |
pnpm nx run @source-web/<pkg>:build | Builds a single package | Fastest inner loop while actively editing one component |
pnpm nx run @source-web/<pkg>:test | Runs vitest for a single package | Same — tight feedback loop on one package |
Linting (the four checks preMerge runs)
| Command | Tool | Checks |
|---|---|---|
pnpm lint:oxlint | oxlint | Code-quality rules (replaces ESLint) |
pnpm lint:oxfmt | oxfmt | Formatting, run with --check (replaces Prettier) |
pnpm lint:stylelint | stylelint | styled-components CSS rules |
pnpm lint:ts | tsc | --noEmit per package — type errors |
pnpm lint:depcheck | depcheck | Unused or missing dependencies in each package.json |
Generators & housekeeping
| Command | What it does | When to use it |
|---|---|---|
pnpm packageGenerator | Interactive wizard that scaffolds a new package (component, theme, icon, util) | Always — never copy-paste an existing package |
pnpm exampleGenerator | Regenerates a component's example files and the auto-generated UI/a11y test specs from them | Any time you add or change a component's examples/ folder |
pnpm autoVersioner | Interactive Beachball changeset wizard — asks which packages changed, bump type, description | Required before every PR that ships a code change |
pnpm configureIcons | Rebuilds icon packages from source SVGs | After adding/changing an icon SVG — this is CI-gated in pre-merge |
pnpm markForDeprecation | Flags a package as deprecated (adds metadata, does not remove it) | Sunsetting an old component — the first step, not the last |
pnpm deprecatePackages | Processes packages already flagged for deprecation | Run by the team owning the deprecation process — not typically run ad hoc |
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
.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).process.env. or VFUK.env, which is exactly how this table was built.| Variable | Read by | Controls | If unset |
|---|---|---|---|
ORG_ID | orgResolver.ts | Resolves Vodafone (VFRED) vs Three (VFT03) org/branding | Falls back to VFRED (Vodafone) — see the Org Resolver section |
ENVIRONMENT | orgResolver.ts | Whether the ORG_ID cookie override is honoured (only in non-production) | Treated as non-production — cookie override is allowed |
LOG_LEVEL | logger.ts | Minimum log level emitted by the shared logger | No filtering applied — logger uses its internal default |
NODE_ENV | logger.ts and others | Standard Node environment flag — gates verbose/dev-only output | Treated as non-production by anything checking !== 'production' |
LAUNCH_DARKLY_SDK_KEY | featureFlags/client.ts | Authenticates the LaunchDarkly feature-flag client | Feature flag client is never initialised — all flags resolve to their default (usually false) with no error |
ASSET_LAMBDA_SERVICE_URL | contentAPIProxyMiddleware.ts | Base URL the content-API proxy middleware forwards requests to | Falls back to http://localhost:8000 — fine locally, broken in any deployed environment |
AUTH_COOKIE_PREFIX | shopLogOut.middleware.ts | Prefix used when clearing shop auth cookies on logout | Cookie name becomes "undefined_id_token" — logout silently fails to clear the real cookie |
.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?
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.
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:*"
}
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
| Scope | Example | Meaning |
|---|---|---|
@source-web/* | @source-web/button | Shared core component, usable by all product teams |
@vfuk/* | @vfuk/ebu-core-gate-keeper | VFUK-specific, often product-team-owned |
@vf/* | @vf/utils-is-broadband | VF brand utilities, typically shop-related |
Nx — Smart Build System
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
Key Nx commands
| Command | What it does |
|---|---|
pnpm nx run @source-web/button:build | Build one specific package |
pnpm nx run @source-web/button:test | Test one specific package |
pnpm nx run-many --target=build --parallel=8 | Build all packages, 8 at a time |
pnpm nx affected --target=test --base=origin/main | Test only what changed vs main |
pnpm nx graph | Open dependency graph visualiser in browser |
Architecture & Request Flow
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.Walking through the diagram
- Product App wraps its tree in
<SourceProvider theme={ws10Theme}>. This is the single setup step every consuming app must do. - SourceProvider injects ThemeProvider (from styled-components) — this makes the theme object available to every styled component in the tree via React context.
- SourceProvider injects I18nextProvider — internationalisation (English, Italian, Spanish) for any component that uses translated strings.
- SourceProvider injects OverlayProvider — manages z-index stacking for modals, drawers, and flyouts.
- Theme flows down to components — any styled-component can read
props.themeto get the active brand's colour tokens. - Component reads its own theme file — e.g.
Button.theme.tsmaps abstract token keys (theme.color.primary1.default) to the component's visual properties (background, text, border colour). - Brand theme provides the tokens —
@source-web/theme-ws10defines whatcolor.primary1.defaultactually is (Vodafone Red:#e60000). Swap to@source-web/theme-threeand 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
@source-web/style-guide.Complete file tree
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
.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.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
| Option | What it does | Why it matters |
|---|---|---|
format: ['esm'] | Output is .mjs ES Module only | Modern bundlers (Vite, Next.js) prefer ESM; no legacy CJS baggage |
dts: { tsgo: true } | Generates .d.mts TypeScript declarations using tsgo | tsgo is a fast Go-based TypeScript checker — much faster than tsc for declarations |
attw: true | Runs "Are The Types Wrong?" check | Catches broken type exports before publish — prevents consumers getting any types |
publint: true | Validates package.json exports map | Ensures 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)
Linting Stack
@source-web/style-guide package.The four linters
# 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.
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
postcss-styled-syntax to understand the styled-components syntax.pnpm lint:stylelint
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
@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)
Theme System
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.Theme packages
| Package | Brand | Used by |
|---|---|---|
@source-web/theme-ws10 | Vodafone WS10 | Main Vodafone products (shop, ecare) |
@source-web/theme-three | Three Mobile | Three (VFT03) brand properties |
@source-web/theme-vf3 | VF3 | Vodafone 3 (combined brand) |
@source-web/theme-voxi | VOXI | VOXI sub-brand |
@source-web/theme-voxi3 | VOXI3 | VOXI on Three network |
@source-web/base-theme | Base/Fallback | Foundation inherited by all themes |
@source-web/mock-theme | Testing mock | Unit/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:
| Package | Defines |
|---|---|
@source-web/theme-colors | Colour palette token structure (primary, monochrome, success, etc.) |
@source-web/theme-borders | Border widths, radii, and styles |
@source-web/theme-typography | Font families, weights, sizes, line heights |
@source-web/theme-spacing-values | Spacing scale (4px base unit) |
@source-web/theme-elevation | Box-shadow levels for depth |
@source-web/theme-defaults | Default 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/.
<SourceProvider theme={ws10Theme} baseAssetUrl="https://cdn.vodafone.co.uk/">
<App />
</SourceProvider>
<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
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'
}
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])}
Theme object, plus the useLocalTheme hook. Every brand theme must satisfy the Theme interface exported from here.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.
| Category | What's in it | Example packages |
|---|---|---|
| interactions | Clickable elements | Button, Link, IconButton, Tag, InlineLink, SmallButton, FloatingChatbotButton |
| forms | Input controls | AddressPicker, CalendarPicker, CheckboxList, ColorSelect, AdvancedSearch |
| overlays | Modal/drawer layers | BasketFlyoutTemplate, BottomTray, ErrorStatusModal, FilterFlyout |
| navigation | Nav and footer | Header, Footer, Breadcrumbs, IconStrip, MinFooter |
| cards | Card layouts | ActionCard, CardBuilder, DiscoveryCard, ListBuilder |
| typography | Text elements | Heading, Paragraph, Span, TextStack, PriceRiseText |
| notifications | Alerts and banners | AlertNotification, DecisionNotification, InlineNotification, FullWidthNotification |
| layout | Structural layout | SimpleGrid, Block, Divider, MatchMedia, BackgroundImageBlock |
| media | Images and icons | Icon, IconWithBackground, Image, Video, YoutubeVideo |
| loaders | Loading states | LoadingSpinner, Loader, SimpleSkeletonLoader, TextSkeletonLoader |
| tables | Data tables | ComparisonTable, DataTable, HorizontalTable, MatrixTable |
| tabs | Tab navigation | Tabs, FunctionalTabs |
| themes | Brand theme objects | WS10, Three, VF3, VOXI, VOXI3, BaseTheme, mockTheme |
| theming | Theme token primitives | Colors, Borders, Typography, SpacingValues, Elevation, Defaults |
| misc | Foundational utilities | SourceProvider, BaseProps, HeadTags, RawHtmlWrapper, IconRuleSets |
| tools | Dev tooling packages | style-guide, mixins, languagePacks, cypressTools, seo, helpers |
| utils | JS utility libraries | orgResolverHelpers, featureFlags, browser, datadogTracer, middleware, logger |
| icons | Icon asset packages | BrandHiFiIcons, BrandLoFiIcons, SourceFlagIcons, SourceHiFiDarkIcons |
| banners | Promotional banners | AnimatedPartnerBanner, AppButtonBanner, DynamicBanner |
| carousels | Scrollable content | BannerCarousel, CardCarousel, FunctionalCarousel, Gallery |
| steppers | Progress indicators | HorizontalStepper, Paginator, Timeline, VerticalStepper |
| hooks | React hooks | useGenesysChat |
| pages | Page templates | MinPageTemplate, StandardPageTemplate, DetailsPageWithMenu |
| collections | List/grid containers | CardGrid, CardList, DiscoveryGrid, AppStoreButtonList, IconSnippetList |
| dataVisualisation | Charts and progress | Chart, HorizontalBarChart, ProgressBar |
Adding a New Component
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.
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.
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,
},
},
})
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.
# 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
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
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
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.| Directory | Scope | What it powers | Package 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
Key packages (55 total)
| Package | What it does |
|---|---|
@vfuk/ebu-core-journey-store | MobX store managing multi-step journey state (wizard/flow navigation) |
@vfuk/ebu-core-gate-keeper | Route guard — checks auth/permissions before allowing page access |
@vfuk/ebu-core-forms-form-generator | Dynamic form builder driven by JSON Schema + @jsonforms/react |
@vfuk/ebu-core-contentful-context | React context providing CMS content from Contentful |
@vfuk/ebu-core-page-wrapper | Standard 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-card | Card component that adapts its layout to CMS-driven content |
@source-web/segment-switcher | B2B/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
Key package groups
| Group | Example packages | Purpose |
|---|---|---|
| Stores | ecare-core-stores-navigation, -accounts, -journey | MobX state stores (navigation tree, account data, journey flows) |
| Services | ecare-core-services-request, -anonymous-session | HTTP request layer + session management |
| Middleware | ecare-core-server-journey-ssr-api-middleware | Express/Next.js middleware for SSR journeys |
| Pages | ecare-core-components-page-with-nav, -page-preloader | Full page layout templates |
| Helpers | ecare-core-helpers-formatters, -get-cookie | Utility functions for data formatting and cookies |
| Utils | ecare-core-utils-is-mva, -get-idm-public-token | MVA (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
Key packages (~32 total)
| Package | What it does |
|---|---|
@vfuk/utils-shop-basket-service | Add/remove items from the shopping basket, basket state |
@vfuk/utils-shop-inventory-service | Query available devices and plans from the inventory API |
@vfuk/utils-shop-auth-service | Authentication flow for shop checkout |
@vfuk/utils-shop-configurator-service | Build a phone+plan bundle (the "configurator" journey) |
@vfuk/utils-shop-configurator-store | MobX store for configurator state |
@vfuk/utils-shop-device-card-mapper | Transforms raw device API data → DeviceCard component props |
@vfuk/utils-shop-plans-card-mapper | Transforms plan API data → PlanCard component props |
@vfuk/utils-shop-notifications-context-provider | Manages shop-wide notification banners |
@vf/utils-is-broadband | Detects if the current product context is broadband vs mobile |
@vf/utils-api-declarations | TypeScript type declarations for shop API response shapes |
@source-web/header-parser | Transforms CMS header data → Header component props |
@source-web/footer-parser | Transforms CMS footer data → Footer component props |
vf3 — Vodafone 3 / Three 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.
Interactions — buttons & links
| Package | Status |
|---|---|
@source-web/button | stable |
@source-web/small-button | stable |
@source-web/icon-button | stable |
@source-web/inline-link | stable |
@source-web/link | stable |
@source-web/link-with-icon | stable |
@source-web/interactive-icon | stable |
@source-web/skip-link | stable |
Banners & Promotional
| Package | Status |
|---|---|
@source-web/vf3-frame-banner — Three-exclusive branded banner | stable |
@source-web/dynamic-banner | stable |
@source-web/standard-banner | stable |
@source-web/partner-banner | stable |
@source-web/partner-banner-apple | stable |
Cards
| Package | Status |
|---|---|
@source-web/action-card | stable |
@source-web/card-builder | stable |
@source-web/discovery-card | stable |
@source-web/pattern-card | stable |
@source-web/promotional-card | stable |
@source-web/quote-card | stable |
@source-web/utility-card | stable |
@source-web/newsfeed-card | unstable |
Navigation
| Package | Status |
|---|---|
@source-web/breadcrumbs | stable |
@source-web/footer | stable |
@source-web/icon-strip | stable |
@source-web/header | unstable |
@source-web/min-header | unstable |
@source-web/overflow-menu | unstable |
Notifications & Overlays
| Package | Status |
|---|---|
@source-web/full-width-notification | stable |
@source-web/inline-notification | stable |
@source-web/state-notification | stable |
@source-web/overlay | stable |
@source-web/overlay-controller | stable |
@source-web/modal | unstable |
@source-web/notification | unstable |
@source-web/simple-notification | unstable |
Typography
| Package | Status |
|---|---|
@source-web/heading | stable |
@source-web/paragraph | stable |
@source-web/span | stable |
@source-web/text-stack | stable |
Layout & Structure
| Package | Status |
|---|---|
@source-web/simple-grid | stable |
@source-web/divider | stable |
@source-web/match-media | stable |
@source-web/spacing | stable |
@source-web/block | unstable |
Media & Icons
| Package | Status |
|---|---|
@source-web/icon | stable |
@source-web/icon-with-background | stable |
@source-web/image | stable |
@source-web/brand-hifi-icons | stable |
@source-web/brand-lofi-icons | stable |
@source-web/source-system-icons | stable |
@source-web/source-hifi-dark-icons | stable |
@source-web/source-hifi-light-icons | stable |
@source-web/source-payment-icons | stable |
@source-web/uk-brand-icons | stable |
@source-web/uk-system-icons | stable |
@source-web/video | unstable |
@source-web/youtube-video | unstable |
Loaders, Animations & Progress
| Package | Status |
|---|---|
@source-web/loader | stable |
@source-web/loading-spinner | stable |
@source-web/simple-skeleton-loader | stable |
@source-web/text-skeleton-loader | stable |
@source-web/animate | stable |
@source-web/animation-container | stable |
@source-web/horizontal-stepper | stable |
Collections, Carousels & Content Blocks
| Package | Status |
|---|---|
@source-web/banner-carousel | stable |
@source-web/card-carousel | stable |
@source-web/card-grid | stable |
@source-web/app-store-button-list | stable |
@source-web/image-snippet-list | stable |
@source-web/snippet-list | stable |
@source-web/snippet | stable |
@source-web/icon-snippet | stable |
@source-web/image-snippet | stable |
@source-web/content-block | stable |
@source-web/content-block-with-image | stable |
@source-web/content-block-with-video | stable |
@source-web/functional-content-block | stable |
@source-web/card-list | unstable |
@source-web/functional-carousel — to be deprecated | unstable |
Interface — accordions, tabs, avatars, tables & misc UI
| Package | Status |
|---|---|
@source-web/accordion | stable |
@source-web/collapsible-container | stable |
@source-web/tabs | stable |
@source-web/functional-tabs | stable |
@source-web/avatar-with-label | stable |
@source-web/functional-avatar | stable |
@source-web/initials-avatar | stable |
@source-web/photo-avatar | stable |
@source-web/number-badge | stable |
@source-web/pill | stable |
@source-web/tooltip | stable |
@source-web/color-swatch | stable |
@source-web/container | stable |
@source-web/date-countdown | stable |
@source-web/table | stable |
@source-web/horizontal-bar-chart | stable |
@source-web/filter-and-sort | unstable |
@source-web/decision-tree | unstable |
@source-web/chart | unstable |
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.
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
| Token | WS10 (Vodafone) | Three | Impact |
|---|---|---|---|
color.primary1.default | #E60000 — red | #000000 — black | Primary button bg, badge fill, active states |
color.secondary1.default | dark grey | #FF8474 — coral | Secondary/accent colour |
border.radius[2] | 6px | 10px | Slightly more rounded corners on Three |
border.radius[3] | 24px — pill | 0px — flat | Three doesn't use pill shapes |
fonts.family | Vodafone brand font | Helvetica Neue | Typography throughout |
# Branch naming follows ADO conventions
git checkout -b story/456789-number-badge-vf3-support
"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
}
@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.
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!)
})
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.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'
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} />
localTheme manually — the override comes in automatically. Check whether the Three theme already provides this via its theme object structure.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.
# 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.
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
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"
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
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
import { VoxiBanner } from '@source-web/voxi-banner'
<VoxiBanner heading="Endless social data" ctaText="Get VOXI" ctaHref="/voxi" />
scms — Smart Content Management System
| Package | What it does |
|---|---|
@source-web/contentful-rich-text | Renders Contentful Rich Text field content as React components |
@source-web/content-mapper | Maps raw Contentful entries to Source Web component props shapes |
@source-web/ninetailed-provider | Wraps Ninetailed (A/B testing + personalisation SDK) for use with Source Web |
@source-web/nano-rep-loader | Lazy-loads the NanoRep (virtual assistant) widget |
@vfuk/scms-genesys-loader | Loads 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/)
| Package | Scope | What it does |
|---|---|---|
orgResolverHelpers | @vf/utils-org-resolver-helpers | Detects the current org/brand (Vodafone vs Three) from env vars or cookie |
featureFlags | @source-web/feature-flags | Reads feature flag values to enable/disable functionality at runtime |
browser | @source-web/browser | Safe browser environment detection (window/document availability) |
datadogTracer | @source-web/datadog-tracer | Wrapper around Datadog browser logging/APM (Application Performance Monitoring) SDK |
logger | @source-web/logger | Structured logging utility (wraps console with log levels) |
middleware | @source-web/middleware | Express-compatible server middleware utilities |
settings | @source-web/settings | App-wide settings/config object management |
createEnv | @source-web/create-env | Type-safe environment variable validation and access |
isEnvVarTrue | @source-web/is-env-var-true | Reads a string env var and returns a boolean ("true" → true) |
piiMaskDataAttributes | @source-web/pii-mask-data-attributes | Generates data attributes that tell Datadog to mask PII (personally identifiable information) from session recordings |
shellServer | @source-web/shell-server | Shared SSR (server-side rendering) shell server utilities |
parsers | @source-web/parsers | Generic data-parsing helpers |
getters | @source-web/getters | Common getter functions (safe object property access) |
services | @source-web/services | Shared API service utilities (HTTP helpers, error handling) |
isPerformanceCookieEnabled | @source-web/is-performance-cookie-enabled | Checks if the user has consented to performance cookies (GDPR) |
Org Resolver — @vf/utils-org-resolver-helpers
resolveOrgId. Everything else — loading the right theme, naming content spaces, Tealium profile lookup, Boolean convenience checks — delegates to that function.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.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 (resolveBrandId → resolveOrgId, BrandID → OrgID, BRANDS → ORGS) 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
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)
| Priority | Source | When it applies | Example value |
|---|---|---|---|
| 1 (highest) | ORG_ID cookie | Non-production only (ENVIRONMENT does not start with "prod") | VFT03 |
| 2 | VFUK.env.ORG_ID env var | Always (set by infrastructure at deploy time) | VFRED |
| 3 (fallback) | Hardcoded default | If both above are absent or invalid | VFRED |
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)
| Scenario | ENVIRONMENT | env ORG_ID | Cookie | Result |
|---|---|---|---|---|
| Nothing set | — | — | — | VFRED (default) |
| Env only | — | VFT03 | — | VFT03 |
| Cookie overrides env (staging) | int1-blue | VFRED | VFT03 | VFT03 |
| Cookie overrides env (local dev) | — | VFRED | VFT03 | VFT03 |
| Cookie ignored in prod | prod | VFRED | VFT03 | VFRED |
| Cookie ignored in prod variant | prod1-green | VFRED | VFT03 | VFRED |
| Invalid cookie ignored | — | VFT03 | NOT_A_ORG | VFT03 (env used) |
| Invalid env falls back | — | WRONG | — | VFRED (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()
}
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
| Helper | Use when… | Async? |
|---|---|---|
resolveOrgId(req?) | You need the raw org string, or you're on the server and want cookie support | No |
isThree(req?) | Writing a Three-specific code path — most common use case | No |
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 org | No |
getTealiumProfile() | Resolving the analytics profile for the active org | No |
loadOrgTheme() | Bootstrapping a React app — load only the active org's theme object | Yes (dynamic import) |
ORGS | Comparing against org IDs without hardcoding strings — ORGS.THREE not 'VFT03' | — |
Testing Strategy
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.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
| Layer | Tool | File pattern | What it tests | Filled in by default? |
|---|---|---|---|---|
| Unit | vitest (component pkgs) / Cypress stub | *.unit.spec.tsx | Pure logic: helper functions, state calculations, conditional rendering | No — empty stub, author must write |
| Interaction | Cypress (component mode) | *.interaction.spec.tsx | User events: click, hover, keyboard navigation, focus management | No — empty stub, author must write |
| UI / Visual | Cypress + Percy | *.ui.spec.tsx | Pixel-perfect screenshots across every supported theme — detects visual regressions | Yes — auto-generated from examples/ |
| Accessibility | Cypress + axe-core | *.a11y.spec.tsx | WCAG 2.1 AA compliance: colour contrast, ARIA attributes, keyboard operability | Yes — 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:
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
pnpm autoVersioner. CI then calls beachball bump to apply versions and publish.Semantic versioning (semver) rules
| Change type | Version bump | When to use |
|---|---|---|
| patch | 1.0.0 → 1.0.1 | Bug fix, internal refactor — no API changes |
| minor | 1.0.0 → 1.1.0 | New feature, new prop — backwards compatible |
| major | 1.0.0 → 2.0.0 | Breaking 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
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:
prebumphook — 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).postbumphook — after bumping, it does two things: resets any dependency that was a prerelease back to the stable version it remembers fromprebump(so a release never accidentally ships pinned to an alpha), then runspackageDepsToAnyPatch(), which rewrites the exact patch digit of every internal dependency tox— e.g.15.1.0becomes15.1.x.
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
main using ADO naming conventions: story/123456-badge-component or bug/123456-button-focus-fix.@ts-ignore.pnpm exampleGeneratorpnpm autoVersionerpnpm preMerge — all must pass.Gotchas & Traps
.npmrc.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.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.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.pnpm lint:eslint and pnpm lint:prettier don't existpnpm 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.dist/ must exist before the docsite can use a packagedist/ — which doesn't exist until you run pnpm nx run [pkg]:build first.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.build:all or test on a PR branchbuild: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.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.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.