diff --git a/packages/site/assets/focus-example-backgrounds.png b/packages/site/assets/focus-example-backgrounds.png deleted file mode 100644 index 2c2c70061..000000000 Binary files a/packages/site/assets/focus-example-backgrounds.png and /dev/null differ diff --git a/packages/site/assets/focus-example-input.png b/packages/site/assets/focus-example-input.png deleted file mode 100644 index eaf65a7b4..000000000 Binary files a/packages/site/assets/focus-example-input.png and /dev/null differ diff --git a/packages/site/assets/focus-example-radios.png b/packages/site/assets/focus-example-radios.png deleted file mode 100644 index 30c1d7290..000000000 Binary files a/packages/site/assets/focus-example-radios.png and /dev/null differ diff --git a/packages/site/scripts/foundation-styles-data.js b/packages/site/scripts/foundation-styles-data.js new file mode 100644 index 000000000..f274214d7 --- /dev/null +++ b/packages/site/scripts/foundation-styles-data.js @@ -0,0 +1,1015 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const settingsDirectory = path.join(__dirname, '../../toolkit/core/settings'); +const toolsDirectory = path.join(__dirname, '../../toolkit/core/tools'); +const objectsDirectory = path.join(__dirname, '../../toolkit/core/objects'); + +const files = { + tokensCore: path.join(settingsDirectory, '_tokens-core.scss'), + tokensBreakpoint: path.join(settingsDirectory, '_tokens-breakpoint.scss'), + tokensStatic: path.join(settingsDirectory, '_tokens-static.scss'), + breakpoints: path.join(settingsDirectory, '_breakpoints.scss'), + mixins: path.join(toolsDirectory, '_mixins.scss'), + grid: path.join(toolsDirectory, '_grid.scss'), + spacingTool: path.join(toolsDirectory, '_spacing.scss'), + typographyUtilities: path.join( + __dirname, + '../../toolkit/core/utilities/_typography.scss', + ), + mainWrapper: path.join(objectsDirectory, '_main-wrapper.scss'), +}; + +const typographyRows = [ + { + id: 'heading-xl', + label: 'heading-xl', + previewText: 'Page heading', + className: 'ofh-heading-xl', + scaleKey: 'heading-xl', + notes: 'Default h1 style.', + }, + { + id: 'heading-lg', + label: 'heading-lg', + previewText: 'Section heading', + className: 'ofh-heading-lg', + scaleKey: 'heading-lg', + notes: 'Default h2 style.', + }, + { + id: 'heading-md', + label: 'heading-md', + previewText: 'Subsection heading', + className: 'ofh-heading-md', + scaleKey: 'heading-md', + notes: 'Default h3 style.', + }, + { + id: 'heading-sm', + label: 'heading-sm', + previewText: 'Support heading', + className: 'ofh-heading-sm', + scaleKey: 'heading-sm', + notes: 'Default h4 style.', + }, + { + id: 'heading-xs', + label: 'heading-xs', + previewText: 'Small heading', + className: 'ofh-heading-xs', + scaleKey: 'heading-xs', + notes: 'Used for h5, h6, and the smallest heading override.', + }, + { + id: 'lead', + label: 'lead-md', + previewText: 'Use lead text once to introduce a page.', + className: 'ofh-body-l', + scaleKey: 'lead-md', + notes: 'Used by .ofh-body-l and .ofh-lede-text.', + }, + { + id: 'paragraph', + label: 'paragraph-md', + previewText: 'Use body copy for most written content.', + className: 'ofh-body-m', + scaleKey: 'paragraph-md', + notes: 'Default paragraph style.', + }, + { + id: 'paragraph-small', + label: 'paragraph-sm', + previewText: 'Use small body copy sparingly.', + className: 'ofh-body-s', + scaleKey: 'paragraph-sm', + notes: 'Secondary or supporting text.', + }, + { + id: 'list', + label: 'list-md', + previewText: 'List item', + previewHtml: '', + className: 'ofh-list', + scaleKey: 'list-md', + notes: 'Bulleted and numbered lists use this responsive list token.', + }, + { + id: 'list-small', + label: 'list-sm', + previewText: 'List item', + previewHtml: '', + className: 'ofh-body-s', + scaleKey: 'list-sm', + notes: 'Smaller list token for more compact supporting content.', + }, +]; + +const semanticColourGroups = [ + { + title: 'Text', + items: [ + '$ofh-color-foreground-primary', + '$ofh-color-foreground-secondary', + '$ofh-color-foreground-brand-blue-navy', + '$ofh-color-foreground-primary-inverted', + ], + }, + { + title: 'Links', + items: [ + '$ofh-color-foreground-link-default', + '$ofh-color-foreground-link-hover', + '$ofh-color-foreground-link-visited', + '$ofh-color-foreground-link-active', + ], + }, + { + title: 'Breadcrumb navigation links', + items: [ + '$ofh-color-foreground-breadcrumb-default', + '$ofh-color-foreground-breadcrumb-hover', + '$ofh-color-foreground-breadcrumb-visited', + '$ofh-color-foreground-breadcrumb-active', + ], + }, + { + title: 'Focus state', + items: [ + '$ofh-color-border-feedback-focus', + '$ofh-color-border-feedback-focus-inverted', + '$ofh-color-foreground-brand-blue-navy', + ], + }, + { + title: 'Button', + items: [ + '$ofh-color-background-button-default', + '$ofh-color-background-button-hover', + '$ofh-color-background-button-active', + '$ofh-color-background-button-default-inverted', + '$ofh-color-background-button-hover-inverted', + '$ofh-color-background-button-active-inverted', + ], + }, + { + title: 'Contextual colours', + items: [ + '$ofh-color-feedback-error-2-main', + '$ofh-color-feedback-success-2-main', + '$ofh-color-feedback-warning-2-main', + '$ofh-color-feedback-info-2-main', + ], + }, + { + title: 'Borders', + items: [ + '$ofh-color-border-primary', + '$ofh-color-border-secondary', + '$ofh-color-border-brand', + ], + }, + { + title: 'Input fields', + items: [ + '$ofh-color-border-input-default', + '$ofh-color-border-input-active', + '$ofh-color-background-primary', + ], + }, + { + title: 'Page background', + items: [ + '$ofh-color-background-primary', + '$ofh-color-background-secondary', + '$ofh-color-background-secondary-blue', + '$ofh-color-background-secondary-yellow', + '$ofh-color-background-secondary-grey', + ], + }, +]; + +const paletteColourGroups = [ + { + title: 'Greyscale', + items: [ + '$ofh-color-greyscale-black', + '$ofh-color-greyscale-1', + '$ofh-color-greyscale-2', + '$ofh-color-greyscale-3', + '$ofh-color-greyscale-4', + '$ofh-color-greyscale-5', + '$ofh-color-greyscale-6', + '$ofh-color-greyscale-7', + '$ofh-color-greyscale-white', + ], + }, + { + title: 'Brand blue navy', + items: [ + '$ofh-color-brand-blue-navy-1', + '$ofh-color-brand-blue-navy-2', + '$ofh-color-brand-blue-navy-3-main', + '$ofh-color-brand-blue-navy-4', + '$ofh-color-brand-blue-navy-5', + '$ofh-color-brand-blue-navy-6', + ], + }, + { + title: 'Brand blue royal', + items: [ + '$ofh-color-brand-blue-royal-1', + '$ofh-color-brand-blue-royal-2', + '$ofh-color-brand-blue-royal-3-main', + '$ofh-color-brand-blue-royal-4', + '$ofh-color-brand-blue-royal-5', + '$ofh-color-brand-blue-royal-6', + ], + }, + { + title: 'Brand blue aqua', + items: [ + '$ofh-color-brand-blue-aqua-1', + '$ofh-color-brand-blue-aqua-2', + '$ofh-color-brand-blue-aqua-3-main', + '$ofh-color-brand-blue-aqua-4', + '$ofh-color-brand-blue-aqua-5', + '$ofh-color-brand-blue-aqua-6', + ], + }, + { + title: 'Brand green teal', + items: [ + '$ofh-color-brand-green-teal-1', + '$ofh-color-brand-green-teal-2', + '$ofh-color-brand-green-teal-3-main', + '$ofh-color-brand-green-teal-4', + '$ofh-color-brand-green-teal-5', + '$ofh-color-brand-green-teal-6', + ], + }, + { + title: 'Brand green lime', + items: [ + '$ofh-color-brand-green-lime-1', + '$ofh-color-brand-green-lime-2', + '$ofh-color-brand-green-lime-3-main', + '$ofh-color-brand-green-lime-4', + '$ofh-color-brand-green-lime-5', + '$ofh-color-brand-green-lime-6', + ], + }, + { + title: 'Brand yellow', + items: [ + '$ofh-color-brand-yellow-1', + '$ofh-color-brand-yellow-2', + '$ofh-color-brand-yellow-3-main', + '$ofh-color-brand-yellow-4', + '$ofh-color-brand-yellow-5', + '$ofh-color-brand-yellow-6', + ], + }, + { + title: 'Brand orange', + items: [ + '$ofh-color-brand-orange-1', + '$ofh-color-brand-orange-2', + '$ofh-color-brand-orange-3-main', + '$ofh-color-brand-orange-4', + '$ofh-color-brand-orange-5', + '$ofh-color-brand-orange-6', + ], + }, + { + title: 'Brand red', + items: [ + '$ofh-color-brand-red-1', + '$ofh-color-brand-red-2', + '$ofh-color-brand-red-3-main', + '$ofh-color-brand-red-4', + '$ofh-color-brand-red-5', + '$ofh-color-brand-red-6', + ], + }, + { + title: 'Feedback', + items: [ + '$ofh-color-feedback-error-1', + '$ofh-color-feedback-error-2-main', + '$ofh-color-feedback-error-3', + '$ofh-color-feedback-error-4', + '$ofh-color-feedback-success-1', + '$ofh-color-feedback-success-2-main', + '$ofh-color-feedback-success-3', + '$ofh-color-feedback-success-4', + '$ofh-color-feedback-warning-1', + '$ofh-color-feedback-warning-2-main', + '$ofh-color-feedback-warning-3', + '$ofh-color-feedback-warning-4', + '$ofh-color-feedback-info-1', + '$ofh-color-feedback-info-2-main', + '$ofh-color-feedback-info-3', + '$ofh-color-feedback-info-4', + '$ofh-color-feedback-interactive-1', + '$ofh-color-feedback-interactive-2', + '$ofh-color-feedback-interactive-3-main', + '$ofh-color-feedback-interactive-4', + '$ofh-color-feedback-interactive-5', + ], + }, + { + title: 'Neutral backgrounds', + items: [ + '$ofh-color-backgrounds-grey', + '$ofh-color-backgrounds-blue', + '$ofh-color-backgrounds-yellow', + ], + }, +]; + +const indexCards = [ + { + title: 'Colour', + href: '/design-system/styles/colour', + summary: 'Semantic colour tokens for UI work and the core palette they are built from.', + }, + { + title: 'Focus state', + href: '/design-system/styles/focus-state', + summary: 'Focus colour guidance and implementation patterns for accessible interactive states.', + }, + { + title: 'Icons', + href: '/design-system/styles/icons', + summary: 'Material icon inventory plus the fixed and responsive sizing rules used by the toolkit.', + }, + { + title: 'Layout', + href: '/design-system/styles/layout', + summary: 'Breakpoints, containers, content widths, grid widths, and page wrapper spacing.', + }, + { + title: 'Page template', + href: '/design-system/styles/page-template', + summary: 'The shared page shell, template blocks, and default content-page and transactional layouts.', + }, + { + title: 'Spacing', + href: '/design-system/styles/spacing', + summary: + 'The horizontal and vertical responsive spacing scales, static size tokens, and spacing utility classes.', + }, + { + title: 'Typography', + href: '/design-system/styles/typography', + summary: 'Responsive type styles, headline hierarchy, body sizes, and override utilities.', + }, +]; + +function readFile(filepath) { + return fs.readFileSync(filepath, 'utf8'); +} + +function stripBlockComments(text) { + return text.replace(/\/\*[\s\S]*?\*\//g, ''); +} + +function splitStatements(text) { + const statements = []; + let current = ''; + let depth = 0; + let quote = null; + let inLineComment = false; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + const nextCharacter = text[index + 1]; + + if (inLineComment) { + if (character === '\n') { + inLineComment = false; + } + continue; + } + + if (!quote && character === '/' && nextCharacter === '/') { + inLineComment = true; + index += 1; + continue; + } + + if (quote) { + current += character; + if (character === quote && text[index - 1] !== '\\') { + quote = null; + } + continue; + } + + if (character === '\'' || character === '"') { + quote = character; + current += character; + continue; + } + + if (character === '(') { + depth += 1; + } else if (character === ')') { + depth -= 1; + } + + current += character; + + if (character === ';' && depth === 0) { + const statement = current.trim(); + if (statement) { + statements.push(statement.slice(0, -1).trim()); + } + current = ''; + } + } + + const trailingStatement = current.trim(); + if (trailingStatement) { + statements.push(trailingStatement); + } + + return statements; +} + +function splitTopLevel(value, delimiter) { + const parts = []; + let current = ''; + let depth = 0; + let quote = null; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + + if (quote) { + current += character; + if (character === quote && value[index - 1] !== '\\') { + quote = null; + } + continue; + } + + if (character === '\'' || character === '"') { + quote = character; + current += character; + continue; + } + + if (character === '(') { + depth += 1; + } else if (character === ')') { + depth -= 1; + } + + if (character === delimiter && depth === 0) { + if (current.trim()) { + parts.push(current.trim()); + } + current = ''; + continue; + } + + current += character; + } + + if (current.trim()) { + parts.push(current.trim()); + } + + return parts; +} + +function findTopLevelCharacter(value, target) { + let depth = 0; + let quote = null; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + + if (quote) { + if (character === quote && value[index - 1] !== '\\') { + quote = null; + } + continue; + } + + if (character === '\'' || character === '"') { + quote = character; + continue; + } + + if (character === '(') { + depth += 1; + } else if (character === ')') { + depth -= 1; + } else if (character === target && depth === 0) { + return index; + } + } + + return -1; +} + +function isWrappedMap(value) { + if (!value.startsWith('(') || !value.endsWith(')')) { + return false; + } + + let depth = 0; + let quote = null; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + + if (quote) { + if (character === quote && value[index - 1] !== '\\') { + quote = null; + } + continue; + } + + if (character === '\'' || character === '"') { + quote = character; + continue; + } + + if (character === '(') { + depth += 1; + } else if (character === ')') { + depth -= 1; + + if (depth === 0 && index !== value.length - 1) { + return false; + } + } + } + + return depth === 0; +} + +function normaliseExpression(value) { + return value.replace(/\s*!default\s*$/u, '').trim(); +} + +function parseKey(value) { + const trimmedValue = value.trim(); + + if ( + (trimmedValue.startsWith('\'') && trimmedValue.endsWith('\'')) || + (trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) + ) { + return trimmedValue.slice(1, -1); + } + + return trimmedValue; +} + +function parseExpression(value) { + const trimmedValue = normaliseExpression(value); + + if (isWrappedMap(trimmedValue)) { + const innerValue = trimmedValue.slice(1, -1); + const entries = splitTopLevel(innerValue, ','); + const parsedMap = {}; + + entries.forEach((entry) => { + const separatorIndex = findTopLevelCharacter(entry, ':'); + if (separatorIndex === -1) { + return; + } + + const key = parseKey(entry.slice(0, separatorIndex)); + const entryValue = entry.slice(separatorIndex + 1); + parsedMap[key] = parseExpression(entryValue); + }); + + return { + type: 'map', + value: parsedMap, + }; + } + + if (/^\$[\w-]+$/u.test(trimmedValue)) { + return { + type: 'reference', + value: trimmedValue, + }; + } + + return { + type: 'literal', + value: trimmedValue, + }; +} + +function parseVariables(filepath) { + const content = stripBlockComments(readFile(filepath)); + const statements = splitStatements(content); + const variables = {}; + + statements.forEach((statement) => { + const match = statement.match(/^(\$[\w-]+)\s*:\s*([\s\S]+)$/u); + + if (!match) { + return; + } + + variables[match[1]] = parseExpression(match[2]); + }); + + return variables; +} + +function resolveExpression(expression, registry, seen = new Set()) { + if (!expression) { + return null; + } + + if (expression.type === 'literal') { + return expression.value; + } + + if (expression.type === 'map') { + return Object.fromEntries( + Object.entries(expression.value).map(([key, value]) => [ + key, + resolveExpression(value, registry, seen), + ]), + ); + } + + if (expression.type === 'reference') { + if (seen.has(expression.value) || !registry[expression.value]) { + return expression.value; + } + + const nextSeen = new Set(seen); + nextSeen.add(expression.value); + + return resolveExpression(registry[expression.value], registry, nextSeen); + } + + return null; +} + +function toDisplayHex(value) { + if (!value || !value.startsWith('#')) { + return value; + } + + const shortHexMatch = value.match(/^#([\da-f])([\da-f])([\da-f])$/iu); + + if (shortHexMatch) { + return `#${shortHexMatch[1]}${shortHexMatch[1]}${shortHexMatch[2]}${shortHexMatch[2]}${shortHexMatch[3]}${shortHexMatch[3]}`.toUpperCase(); + } + + return value.toUpperCase(); +} + +function directSourceToken(token, registry) { + const entry = registry[token]; + + if (!entry) { + return token; + } + + if (entry.type === 'reference') { + return entry.value; + } + + return token; +} + +function getTokenValue(token, registry) { + return resolveExpression(registry[token], registry, new Set([token])); +} + +function needsSwatchBorder(hexValue) { + if (!hexValue || !hexValue.startsWith('#')) { + return false; + } + + const fullHex = toDisplayHex(hexValue).replace('#', ''); + const red = Number.parseInt(fullHex.slice(0, 2), 16); + const green = Number.parseInt(fullHex.slice(2, 4), 16); + const blue = Number.parseInt(fullHex.slice(4, 6), 16); + const luminance = (red * 299 + green * 587 + blue * 114) / 1000; + + return luminance >= 220; +} + +function toBreakpointSummary(scaleEntry, property) { + const mobileValue = property ? scaleEntry.null[property] : scaleEntry.null; + const tabletValue = property + ? (scaleEntry.tablet || scaleEntry.null)[property] + : scaleEntry.tablet || scaleEntry.null; + const desktopValue = property + ? (scaleEntry.desktop || scaleEntry.tablet || scaleEntry.null)[property] + : scaleEntry.desktop || scaleEntry.tablet || scaleEntry.null; + + return { + mobile: mobileValue, + tablet: tabletValue, + desktop: desktopValue, + }; +} + +function buildTypographyData(registry) { + const typographyScale = getTokenValue('$ofh-typography-responsive-scale', registry); + const utilityScale = getTokenValue('$ofh-typography-utility-scale', registry); + + const rows = typographyRows.map((row) => { + const scale = typographyScale[row.scaleKey]; + const fontSize = toBreakpointSummary(scale, 'font-size'); + const lineHeight = toBreakpointSummary(scale, 'line-height'); + + return { + ...row, + token: row.scaleKey, + mobile: fontSize.mobile, + tablet: fontSize.tablet, + desktop: fontSize.desktop, + lineHeight: formatTriplet(lineHeight), + }; + }); + + const byId = Object.fromEntries(rows.map((row) => [row.id, row])); + const utilityRows = Object.keys(utilityScale) + .map((size) => Number.parseInt(size, 10)) + .sort((first, second) => second - first) + .map((size) => { + const scale = utilityScale[size]; + const fontSize = toBreakpointSummary(scale, 'font-size'); + const lineHeight = toBreakpointSummary(scale, 'line-height'); + + return { + id: `utility-${size}`, + label: `${size}`, + previewText: 'Sample text', + className: `ofh-u-font-size-${size}`, + mobile: fontSize.mobile, + tablet: fontSize.tablet, + desktop: fontSize.desktop, + lineHeight: formatTriplet(lineHeight), + notes: + fontSize.tablet === fontSize.desktop + ? 'Tablet and desktop share the same value.' + : 'Utility collapses on smaller screens.', + }; + }); + + return { + rows, + byId, + utilityRows, + }; +} + +function buildSpacingData(registry) { + const horizontalScale = getTokenValue('$ofh-space-horizontal-responsive-scale', registry); + const verticalScale = getTokenValue('$ofh-space-vertical-responsive-scale', registry); + const orderedSteps = Object.keys(horizontalScale) + .map((step) => Number.parseInt(step, 10)) + .sort((first, second) => first - second); + + const toRows = (scale, utilityPrefix) => + orderedSteps.map((step) => { + const values = toBreakpointSummary(scale[step]); + + return { + step, + mobile: values.mobile, + tablet: values.tablet, + desktop: values.desktop, + staticToken: `$ofh-size-${step}`, + utilityExample: `${utilityPrefix}${step}`, + }; + }); + + const byKey = Object.fromEntries( + orderedSteps.map((step) => [ + step, + { + step, + staticToken: `$ofh-size-${step}`, + horizontal: toBreakpointSummary(horizontalScale[step]), + vertical: toBreakpointSummary(verticalScale[step]), + }, + ]), + ); + + return { + horizontalScale: toRows(horizontalScale, 'ofh-u-margin-right-'), + verticalScale: toRows(verticalScale, 'ofh-u-margin-bottom-'), + byKey, + }; +} + +function buildLayoutData(registry) { + const mqBreakpoints = getTokenValue('$mq-breakpoints', registry); + const gridWidths = getTokenValue('$_ofh-grid-widths', registry); + const contentMaxWidth = getTokenValue('$ofh-width-content-max', registry); + const readingWidth = extractReadingWidth(); + const mainWrapper = buildMainWrapperData(registry); + + const breakpoints = [ + { + label: 'mobile', + token: '$mq-breakpoints.mobile', + value: mqBreakpoints.mobile, + note: 'Baseline viewport width for the smallest supported layouts.', + }, + { + label: 'tablet', + token: '$mq-breakpoints.tablet', + value: mqBreakpoints.tablet, + note: 'First responsive breakpoint used by the toolkit.', + }, + { + label: 'desktop', + token: '$mq-breakpoints.desktop', + value: mqBreakpoints.desktop, + note: 'Default grid breakpoint for multi-column layouts.', + }, + { + label: 'large-desktop', + token: '$mq-breakpoints.large-desktop', + value: mqBreakpoints['large-desktop'], + note: 'Used for wider navigation and layout changes.', + }, + ]; + + const widths = [ + { + id: 'fluid', + label: 'Fluid container', + className: 'ofh-width-container-fluid', + value: '100%', + visualPercent: 100, + note: 'Spans the viewport with responsive gutters.', + }, + { + id: 'content', + label: 'Content max width', + className: 'ofh-width-container', + value: contentMaxWidth, + visualPercent: 87.3, + note: 'Used by the default fixed-width container.', + }, + { + id: 'reading', + label: 'Reading width', + className: 'ofh-u-reading-width', + value: readingWidth, + visualPercent: 64, + note: 'Constrains text to around 70 to 80 characters per line.', + }, + ]; + + const gridColumns = Object.entries(gridWidths).map(([key, value]) => ({ + label: key.replace(/-/gu, ' '), + className: `ofh-grid-column-${key}`, + value, + visualPercent: Number.parseFloat(value), + })); + + return { + breakpoints, + breakpointsById: Object.fromEntries( + breakpoints.map((breakpoint) => [breakpoint.label, breakpoint]), + ), + widths, + widthsById: Object.fromEntries(widths.map((width) => [width.id, width])), + gridColumns, + mainWrapper, + }; +} + +function extractReadingWidth() { + const content = stripBlockComments(readFile(files.mixins)); + const match = content.match(/@mixin reading-width\(\)\s*\{\s*max-width:\s*([^;]+);/u); + + return match ? match[1].trim() : '44em'; +} + +function buildMainWrapperData(registry) { + const content = stripBlockComments(readFile(files.mainWrapper)); + const verticalScale = getTokenValue('$ofh-space-vertical-responsive-scale', registry); + + const mixinNames = [ + ['govuk-main-wrapper', '.ofh-main-wrapper', 'Base page wrapper for main content.'], + ['govuk-main-wrapper--l', '.ofh-main-wrapper--l', 'Add to .ofh-main-wrapper for extra top spacing.'], + ['govuk-main-wrapper--s', '.ofh-main-wrapper--s', 'Add to .ofh-main-wrapper for tighter transactional spacing.'], + ]; + + return mixinNames.map(([mixinName, className, note]) => { + const blockMatch = content.match(new RegExp(`@mixin ${mixinName}\\s*\\{([\\s\\S]*?)\\}`, 'u')); + const block = blockMatch ? blockMatch[1] : ''; + const paddingCalls = [...block.matchAll(/@include ofh-responsive-padding\((\d+),\s*'([^']+)'\);/gu)]; + const topCall = paddingCalls.find((call) => call[2] === 'top'); + const bottomCall = paddingCalls.find((call) => call[2] === 'bottom'); + + return { + className, + top: formatSpacingTriplet(topCall ? verticalScale[topCall[1]] : null), + bottom: formatSpacingTriplet(bottomCall ? verticalScale[bottomCall[1]] : null), + note, + }; + }); +} + +function formatTriplet(values) { + return `D ${values.desktop} / T ${values.tablet} / M ${values.mobile}`; +} + +function formatSpacingTriplet(scaleEntry) { + if (!scaleEntry) { + return 'Inherits base spacing'; + } + + return formatTriplet(toBreakpointSummary(scaleEntry)); +} + +function buildIconData(registry) { + const iconScale = getTokenValue('$ofh-iconography-responsive-scale', registry); + const sizeScale = Object.keys(iconScale) + .map((size) => Number.parseInt(size, 10)) + .sort((first, second) => first - second) + .map((size) => { + const values = toBreakpointSummary(iconScale[size]); + + return { + size, + iconName: 'Search', + mobile: values.mobile, + tablet: values.tablet, + desktop: values.desktop, + fixedClass: `.ofh-icon--${size}`, + responsiveMixin: `@include ofh-iconography-responsive(${size})`, + notes: size === 32 ? 'The responsive 32 token collapses to 24px on mobile and tablet.' : 'Fixed classes stay at this size at every breakpoint.', + }; + }); + + return { + sizeScale, + }; +} + +function buildColourGroups(groups, registry) { + return groups.map((group) => ({ + title: group.title, + items: group.items.map((token) => { + const value = getTokenValue(token, registry); + const hex = toDisplayHex(value); + + return { + token, + sourceToken: directSourceToken(token, registry), + hex, + useBorder: needsSwatchBorder(hex), + }; + }), + })); +} + +function buildColourData(registry) { + return { + semanticGroups: buildColourGroups(semanticColourGroups, registry), + paletteGroups: buildColourGroups(paletteColourGroups, registry), + }; +} + +function buildRegistry() { + return { + ...parseVariables(files.tokensCore), + ...parseVariables(files.tokensBreakpoint), + ...parseVariables(files.tokensStatic), + ...parseVariables(files.breakpoints), + ...parseVariables(files.grid), + ...parseVariables(files.spacingTool), + ...parseVariables(files.typographyUtilities), + }; +} + +function buildFoundationStyles() { + const registry = buildRegistry(); + + return { + typography: buildTypographyData(registry), + spacing: buildSpacingData(registry), + layout: buildLayoutData(registry), + icons: buildIconData(registry), + colour: buildColourData(registry), + indexCards, + }; +} + +module.exports = { + buildFoundationStyles, +}; diff --git a/packages/site/styles/app/_app.scss b/packages/site/styles/app/_app.scss index 61b8df72d..122b20874 100755 --- a/packages/site/styles/app/_app.scss +++ b/packages/site/styles/app/_app.scss @@ -8,6 +8,7 @@ @import 'example-callout'; @import 'featured-list'; @import 'footer'; +@import 'foundation-showcase'; @import 'health-az'; @import 'homepage'; @import 'images'; diff --git a/packages/site/styles/app/_code-highlight.scss b/packages/site/styles/app/_code-highlight.scss index 7b919b270..2929f6278 100644 --- a/packages/site/styles/app/_code-highlight.scss +++ b/packages/site/styles/app/_code-highlight.scss @@ -4,6 +4,7 @@ p code, td code { background-color: $ofh-color-greyscale-6; color: $ofh-color-feedback-error-2-main; + font-size: 1rem; padding: 2px $ofh-size-8; word-break: break-word; } diff --git a/packages/site/styles/app/_colour-swatch.scss b/packages/site/styles/app/_colour-swatch.scss index ef9640b23..dddc4d48e 100644 --- a/packages/site/styles/app/_colour-swatch.scss +++ b/packages/site/styles/app/_colour-swatch.scss @@ -3,23 +3,12 @@ ========================================================================== */ /** - * Custom table for the colour palette + * Colour table enhancements */ .app-colour-list { margin-top: 0; - - th, - td { - @include ofh-typography-responsive('paragraph-sm'); - - border: 0; - } - - .app-colour-list__column--name code, - .app-colour-list__column--colour code { - white-space: nowrap; - } + width: 100%; } .app-colour-list__swatch { @@ -27,44 +16,31 @@ border: 1px solid transparent; border-radius: 50%; display: inline-block; + flex: 0 0 auto; height: $ofh-size-32; - margin-right: $ofh-size-4; - vertical-align: middle; width: $ofh-size-32; +} - @include mq($until: tablet) { - height: $ofh-size-24; - left: 0; - position: absolute; - width: $ofh-size-24; - } +.app-colour-list__token { + align-items: center; + display: flex; + gap: $ofh-size-8; } -.app-colour-list__column { +.app-colour-list__token-cell, +.app-colour-list__source, +.app-colour-list__hex { vertical-align: middle; - - @include mq($until: tablet) { - display: block; - position: relative; - } } -.app-colour-list__row { - .app-colour-list__column { - @include ofh-responsive-padding(8); - - // Use a dedicated row tint so light swatches (for example #e8e8e8 and #ffffff) - // remain distinguishable against the documentation surface. - background-color: $ofh-color-background-secondary-grey; - } - - .app-colour-list__column--colour code { - background-color: transparent; - } +.app-colour-list__token-cell code, +.app-colour-list__source code { + overflow-wrap: anywhere; + white-space: normal; } -.app-colour-list__column--name { - font-weight: normal; +.app-colour-list__hex code { + white-space: nowrap; } .app-colour-list__swatch--border { diff --git a/packages/site/styles/app/_container.scss b/packages/site/styles/app/_container.scss index c5870bcea..250a5dfb1 100644 --- a/packages/site/styles/app/_container.scss +++ b/packages/site/styles/app/_container.scss @@ -3,7 +3,7 @@ ========================================================================== */ // Container width variable -$app-container-size: 1100px; +$app-container-size: 1280px; .app-width-container, .app-breadcrumb .ofh-width-container { diff --git a/packages/site/styles/app/_foundation-showcase.scss b/packages/site/styles/app/_foundation-showcase.scss new file mode 100644 index 000000000..4abc712f9 --- /dev/null +++ b/packages/site/styles/app/_foundation-showcase.scss @@ -0,0 +1,145 @@ +/* ========================================================================== + #FOUNDATION SHOWCASE + ========================================================================== */ + +.app-foundation-showcase__table-wrap { + margin-bottom: $ofh-size-32; + overflow-x: auto; +} + +.app-foundation-showcase__caption { + margin-bottom: $ofh-size-8; +} + +.app-foundation-showcase__table { + max-width: none; + min-width: max(100%, 680px); + width: max-content; + + code { + white-space: nowrap; + } +} + +.app-component-reading-width .app-foundation-showcase__table { + max-width: none; +} + +.app-foundation-showcase__row-heading { + min-width: 140px; +} + +.app-foundation-showcase__preview-cell { + min-width: 240px; +} + +.app-foundation-showcase__notes-header, +.app-foundation-showcase__notes-cell { + min-width: 200px; +} + +.app-foundation-showcase__line-height-header, +.app-foundation-showcase__line-height-cell { + min-width: 160px; +} + +.app-foundation-showcase__preview { + display: block; + margin-bottom: 0 !important; /* stylelint-disable-line declaration-no-important */ + margin-top: 0 !important; /* stylelint-disable-line declaration-no-important */ +} + +.app-foundation-showcase__preview--html > * { + margin-bottom: 0 !important; /* stylelint-disable-line declaration-no-important */ + margin-top: 0 !important; /* stylelint-disable-line declaration-no-important */ +} + +.app-foundation-showcase__preview--icon { + align-items: center; + display: inline-flex; +} + +.app-foundation-showcase__inline-list { + display: inline-block; + padding-left: 20px; +} + +.app-foundation-showcase__spacing-bars { + align-items: flex-end; + display: flex; + gap: $ofh-size-8; + min-height: 72px; +} + +.app-foundation-showcase__spacing-bar { + border-radius: 999px; + display: inline-block; + width: 14px; +} + +.app-foundation-showcase__spacing-bar--mobile { + background-color: $ofh-color-brand-blue-royal-3-main; +} + +.app-foundation-showcase__spacing-bar--tablet { + background-color: $ofh-color-brand-blue-aqua-2; +} + +.app-foundation-showcase__spacing-bar--desktop { + background-color: $ofh-color-brand-green-teal-2; +} + +.app-foundation-showcase__width-track { + background-color: $ofh-color-background-secondary-grey; + border-radius: $ofh-radius-24; + max-width: 320px; + min-width: 180px; + overflow: hidden; +} + +.app-foundation-showcase__width-fill { + background: linear-gradient( + 90deg, + $ofh-color-brand-blue-royal-2 0%, + $ofh-color-brand-blue-aqua-2 100% + ); + display: block; + height: $ofh-size-24; +} + +.app-foundation-showcase__colour-group + .app-foundation-showcase__colour-group { + margin-top: $ofh-size-48; +} + +.app-foundation-showcase__cards { + display: grid; + gap: $ofh-size-24; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + list-style: none; + margin: 0; + padding: 0; +} + +.app-foundation-showcase__cards > li { + height: 100%; +} + +.app-foundation-showcase__landing-card { + height: 100%; +} + +.app-foundation-showcase__example-heading { + margin-top: $ofh-size-48; +} + +.app-foundation-showcase__example-section + .app-foundation-showcase__example-section { + margin-top: $ofh-size-40; +} + +.app-foundation-showcase__icon-gallery-table { + table-layout: fixed; +} + +.app-foundation-showcase__icon-gallery-size-col { + width: 96px; +} diff --git a/packages/site/styles/design-example-overrides.scss b/packages/site/styles/design-example-overrides.scss index df2711a48..f62b4455f 100644 --- a/packages/site/styles/design-example-overrides.scss +++ b/packages/site/styles/design-example-overrides.scss @@ -55,3 +55,10 @@ body { padding: 20px; text-align: center; } + +.app-focus-state__button-row { + align-items: flex-start; + display: flex; + flex-wrap: wrap; + gap: $ofh-size-16 $ofh-size-24; +} diff --git a/packages/site/views/_data/foundationStyles.js b/packages/site/views/_data/foundationStyles.js new file mode 100644 index 000000000..90aa50f22 --- /dev/null +++ b/packages/site/views/_data/foundationStyles.js @@ -0,0 +1,7 @@ +const { + buildFoundationStyles, +} = require('../../scripts/foundation-styles-data'); + +module.exports = function foundationStylesData() { + return buildFoundationStyles(); +}; diff --git a/packages/site/views/design-system/styles/_partials/colour-group.njk b/packages/site/views/design-system/styles/_partials/colour-group.njk new file mode 100644 index 000000000..faafec4d1 --- /dev/null +++ b/packages/site/views/design-system/styles/_partials/colour-group.njk @@ -0,0 +1,32 @@ +
+

{{ group.title }}

+
+ + + + + + + + + + {% for item in group.items %} + + + + + + {% endfor %} + +
TokenSource tokenHex value
+ + + {{ item.token }} + + + {{ item.sourceToken }} + + {{ item.hex }} +
+
+
diff --git a/packages/site/views/design-system/styles/_partials/showcase-matrix.njk b/packages/site/views/design-system/styles/_partials/showcase-matrix.njk new file mode 100644 index 000000000..75fcebd26 --- /dev/null +++ b/packages/site/views/design-system/styles/_partials/showcase-matrix.njk @@ -0,0 +1,77 @@ +{% from "icon/macro.njk" import icon %} + +
+ + {% if caption %} + + {% endif %} + + + + + {% if showClassName %} + + {% endif %} + {% if showToken %} + + {% endif %} + + + + {% if showLineHeight %} + + {% endif %} + {% if showFixedClass %} + + {% endif %} + {% if showResponsiveMixin %} + + {% endif %} + {% if showNotes %} + + {% endif %} + + + + {% for row in rows %} + + + + {% if showClassName %} + + {% endif %} + {% if showToken %} + + {% endif %} + + + + {% if showLineHeight %} + + {% endif %} + {% if showFixedClass %} + + {% endif %} + {% if showResponsiveMixin %} + + {% endif %} + {% if showNotes %} + + {% endif %} + + {% endfor %} + +
{{ caption }}
{{ firstColumnLabel or "Style" }}Preview{{ classColumnLabel or "Class" }}TokenDesktopTabletMobileLine heightFixed classResponsive mixinNotes
{{ row.label }} + {% if row.previewHtml %} +
{{ row.previewHtml | safe }}
+ {% elseif row.iconName %} +
+ {{ icon({ "name": row.iconName, "size": row.size, "title": row.iconName }) }} +
+ {% else %} + + {{ row.previewText }} + + {% endif %} +
{{ row.className }}{{ row.token }}{{ row.desktop }}{{ row.tablet }}{{ row.mobile }}{{ row.lineHeight }}{{ row.fixedClass }}{{ row.responsiveMixin }}{{ row.notes }}
+
diff --git a/packages/site/views/design-system/styles/_partials/showcase-scale.njk b/packages/site/views/design-system/styles/_partials/showcase-scale.njk new file mode 100644 index 000000000..514ab9de6 --- /dev/null +++ b/packages/site/views/design-system/styles/_partials/showcase-scale.njk @@ -0,0 +1,61 @@ +
+ + {% if caption %} + + {% endif %} + + + {% if variant == "spacing" %} + + + + + + + + {% else %} + + + + + {% if showNotes is not defined or showNotes %} + + {% endif %} + {% endif %} + + + + {% for item in items %} + + {% if variant == "spacing" %} + + + + + + + + {% else %} + + + + + {% if showNotes is not defined or showNotes %} + + {% endif %} + {% endif %} + + {% endfor %} + +
{{ caption }}
Spacing unitResponsive previewDesktopTabletMobileStatic tokenUtility exampleReferenceVisualValue{{ classColumnLabel or "Class or token" }}Notes
{{ item.step }} + + {{ item.desktop }}{{ item.tablet }}{{ item.mobile }}{{ item.staticToken }}{{ item.utilityExample }}{{ item.label }} + + {{ item.value }}{{ item.className }}{{ item.note }}
+
diff --git a/packages/site/views/design-system/styles/colour/index.njk b/packages/site/views/design-system/styles/colour/index.njk index 35dfa60ab..96bd41eed 100644 --- a/packages/site/views/design-system/styles/colour/index.njk +++ b/packages/site/views/design-system/styles/colour/index.njk @@ -4,6 +4,7 @@ {% set subSection = "Foundation styles" %} {% set dateUpdated = "March 2026" %} {% set backlog_issue_id = "2" %} +{% set hideContact = "true" %} {% extends "app-layout.njk" %} @@ -30,7 +31,7 @@ @@ -39,9 +40,7 @@
  • Icons
  • -
  • Layout - -
  • +
  • Layout
  • Page template
  • @@ -54,916 +53,51 @@ {% endblock %} - - - - {% block bodyContent %}

    Using colour

    Our colours help people to recognise our services.

    -

    We also use colour to help users prioritise and differentiate information - for example we use:

    +

    We also use colour to help users prioritise and differentiate information. For example we use:

    Main colours

    -

    Use the Sass variables rather than the hexadecimal (hex) colour values. For example, use $ofh-color-foreground-primary rather than #1B1B1B. This means that your product will always use the most recent colour palette whenever you update.

    - -

    Only use the Sass variables in the context they are designed for. If you want to use a colour for something else, use the extended colours. For example, if you wanted to use red in a graph you should use $ofh-color-brand-red-3-main rather than $ofh-color-feedback-error-2-main.

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -

    - Text -

    -
    - - $ofh-color-foreground-primary - - #1B1B1B -
    - - $ofh-color-foreground-secondary - - #494949 -
    - - $ofh-color-foreground-brand-blue-navy - - #011D4B -
    - - $ofh-color-foreground-primary-inverted - - #FFFFFF -
    -

    - Links -

    -
    - - $ofh-color-foreground-link-default - - #0053B3 -
    - - $ofh-color-foreground-link-hover - - #002D61 -
    - - $ofh-color-foreground-link-visited - - #330072 -
    - - $ofh-color-foreground-link-active - - #002D61 -
    -

    - Breadcrumb navigation links -

    -
    - - $ofh-color-foreground-breadcrumb-default - - #011D4B -
    - - $ofh-color-foreground-breadcrumb-hover - - #20354B -
    - - $ofh-color-foreground-breadcrumb-visited - - #330072 -
    - - $ofh-color-foreground-breadcrumb-active - - #0C68A9 -
    -

    - Focus state -

    -
    - - $ofh-color-border-feedback-focus - - #0053B3 -
    - - $ofh-color-foreground-brand-blue-navy - - #011D4B -
    -

    - Button -

    -
    - - $ofh-color-background-button-default - - #0053B3 -
    - - $ofh-color-background-button-hover - - #002D61 -
    - - $ofh-color-background-button-active - - #002D61 -
    - - $ofh-color-background-button-default-inverted - - #FFFFFF -
    - - $ofh-color-background-button-hover-inverted - - #E8E8E8 -
    - - $ofh-color-background-button-active-inverted - - #D1D1D1 -
    -

    - Contextual colours -

    -
    - - $ofh-color-feedback-error-2-main - - #B60000 -
    - - $ofh-color-feedback-success-2-main - - #00725F -
    -

    - Borders -

    -
    - - $ofh-color-border-primary - - #D1D1D1 -
    -

    - Input fields -

    -
    - - $ofh-color-border-input-default - - #494949 -
    - - $ofh-color-border-input-active - - #1B1B1B -
    - - $ofh-color-background-primary - - #FFFFFF -
    - -

    Page background colour

    -

    The default page background is $ofh-color-background-primary (#FFFFFF).

    -

    Use background tint tokens such as $ofh-color-background-secondary, $ofh-color-background-secondary-blue, $ofh-color-background-secondary-yellow and $ofh-color-background-secondary-grey to create contrast between sections and content blocks.

    +

    Use the semantic Sass variables rather than hardcoded hexadecimal values. For example, use $ofh-color-foreground-primary rather than #1B1B1B.

    +

    Only use semantic variables in the context they are designed for. The source token column shows which core palette token the semantic alias resolves to today, but you should still use the semantic name in your product code.

    -

    Colour palette

    -

    Avoid using the palette colours if there is a Sass variable that is designed for your context. For example, if you are styling the error state of a component you should use the $ofh-color-feedback-error-2-main Sass variable rather than $ofh-color-brand-red-3-main.

    + {% for group in foundationStyles.colour.semanticGroups %} + {% include "design-system/styles/_partials/colour-group.njk" %} + {% endfor %} - - +

    Core colours

    +

    Use the core palette only when a semantic token is not designed for your context, for example charts, illustrations, or more bespoke editorial surfaces.

    +

    If you are styling a product UI state like an error, link, focus ring, or button, prefer the semantic token instead of the palette token that happens to resolve to the same hex value.

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - $ofh-color-brand-blue-navy-3-main - - #011D4B -
    - - $ofh-color-brand-blue-royal-3-main - - #108BE2 -
    - - $ofh-color-brand-blue-aqua-3-main - - #A4E7E2 -
    - - $ofh-color-brand-green-teal-3-main - - #00A08A -
    - - $ofh-color-brand-green-lime-3-main - - #A2F5AC -
    - - $ofh-color-brand-yellow-3-main - - #FFC62C -
    - - $ofh-color-brand-orange-3-main - - #FF7800 -
    - - $ofh-color-brand-red-3-main - - #F34848 -
    - - $ofh-color-greyscale-black - - #000000 -
    - - $ofh-color-greyscale-1 - - #1B1B1B -
    - - $ofh-color-greyscale-2 - - #494949 -
    - - $ofh-color-greyscale-3 - - #767676 -
    - - $ofh-color-greyscale-4 - - #A4A4A4 -
    - - $ofh-color-greyscale-5 - - #D1D1D1 -
    - - $ofh-color-greyscale-6 - - #E8E8E8 -
    - - $ofh-color-greyscale-7 - - #F4F4F4 -
    - - $ofh-color-greyscale-white - - #FFFFFF -
    - -

    Extended colours

    -

    Our extended colour palette is derived from our core brand colour palette. You can use these colours for graphical elements like illustrations, graphs and charts. Your elements will then fit with the other Design System components and patterns and will help people to recognise our services.

    -

    Avoid using the extended palette colours for components like text, links and input fields where there is a Sass variable that is designed for your component or context.

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - $ofh-color-brand-blue-navy-1 - - #000E26 -
    - - $ofh-color-brand-blue-navy-2 - - #011638 -
    - - $ofh-color-brand-blue-navy-4 - - #405578 -
    - - $ofh-color-brand-blue-navy-5 - - #808EA5 -
    - - $ofh-color-brand-blue-navy-6 - - #C3C9D2 -
    - - $ofh-color-brand-blue-royal-1 - - #084571 -
    - - $ofh-color-brand-blue-royal-2 - - #0C68A9 -
    - - $ofh-color-brand-blue-royal-4 - - #4CA8E9 -
    - - $ofh-color-brand-blue-royal-5 - - #87C5F1 -
    - - $ofh-color-brand-blue-royal-6 - - #C6DFF0 -
    - - $ofh-color-brand-blue-aqua-1 - - #527371 -
    - - $ofh-color-brand-blue-aqua-2 - - #7BADA9 -
    - - $ofh-color-brand-blue-aqua-4 - - #BBEDE9 -
    - - $ofh-color-brand-blue-aqua-5 - - #D1F3F1 -
    - - $ofh-color-brand-blue-aqua-6 - - #E4F1F0 -
    - - $ofh-color-brand-green-teal-1 - - #005045 -
    - - $ofh-color-brand-green-teal-2 - - #007867 -
    - - $ofh-color-brand-green-teal-4 - - #40B8A8 -
    - - $ofh-color-brand-green-teal-5 - - #80CFC4 -
    - - $ofh-color-brand-green-teal-6 - - #C7EAE5 -
    - - $ofh-color-brand-green-lime-1 - - #517B56 -
    - - $ofh-color-brand-green-lime-2 - - #7AB881 -
    - - $ofh-color-brand-green-lime-4 - - #B9F8C1 -
    - - $ofh-color-brand-green-lime-5 - - #D0FAD5 -
    - - $ofh-color-brand-green-lime-6 - - #E3F4E5 -
    - - $ofh-color-brand-yellow-1 - - #806316 -
    - - $ofh-color-brand-yellow-2 - - #BF9421 -
    - - $ofh-color-brand-yellow-4 - - #FFD461 -
    - - $ofh-color-brand-yellow-5 - - #FFE296 -
    - - $ofh-color-brand-yellow-6 - - #FFEFC4 -
    - - $ofh-color-brand-orange-1 - - #803C00 -
    - - $ofh-color-brand-orange-2 - - #BF5A00 -
    - - $ofh-color-brand-orange-4 - - #FF9A40 -
    - - $ofh-color-brand-orange-5 - - #FFBC80 -
    - - $ofh-color-brand-orange-6 - - #F6DBC3 -
    - - $ofh-color-brand-red-1 - - #7A2424 -
    - - $ofh-color-brand-red-2 - - #B63636 -
    - - $ofh-color-brand-red-4 - - #F67676 -
    - - $ofh-color-brand-red-5 - - #F9A3A3 -
    - - $ofh-color-brand-red-6 - - #F4D1D1 -
    - - $ofh-color-backgrounds-blue - - #F2F5FA -
    - - $ofh-color-backgrounds-yellow - - #F8F5EF -
    - - $ofh-color-backgrounds-grey - - #F8F8F8 -
    + {% for group in foundationStyles.colour.paletteGroups %} + {% include "design-system/styles/_partials/colour-group.njk" %} + {% endfor %}

    Accessibility

    -

    Do not rely on colour to convey meaning, for example, an instruction. To communicate with people who are visually impaired or have difficulty telling the difference between colours, you may need to: -

    -

    +

    Do not rely on colour alone to convey meaning, for example in an instruction or status message.

    +

    To communicate with people who are visually impaired or have difficulty telling the difference between colours, you may need to:

    + +

    Colour contrast

    You must make sure that the contrast ratio of text and interactive elements and components like buttons meet the contrast minimum for AA of the Web Content Accessibility Guidelines (WCAG 2.1).

    -

    This helps people with colour vision deficiency (colour blindness). They find it difficult to tell the difference between certain colours, often shades of red, yellow and green.

    +

    This helps people with colour vision deficiency, colour blindness. They find it difficult to tell the difference between certain colours, often shades of red, yellow, and green.

    AA

    The contrast ratio should be at least:

    @@ -972,7 +106,7 @@

    Test colour contrast with people of all abilities.

    diff --git a/packages/site/views/design-system/styles/focus-state/buttons-inverted/index.njk b/packages/site/views/design-system/styles/focus-state/buttons-inverted/index.njk new file mode 100644 index 000000000..52c6f3d5c --- /dev/null +++ b/packages/site/views/design-system/styles/focus-state/buttons-inverted/index.njk @@ -0,0 +1,20 @@ +{% from 'button/macro.njk' import button %} + +
    +
    + {{ button({ + "text": "Find my location", + "type": "button", + "classes": "ofh-button--ghost-inverted", + "attributes": { + "id": "focus-state-button-inverted-target" + } + }) }} + + {{ button({ + "text": "Cancel", + "type": "button", + "classes": "ofh-button--text-inverted" + }) }} +
    +
    diff --git a/packages/site/views/design-system/styles/focus-state/buttons/index.njk b/packages/site/views/design-system/styles/focus-state/buttons/index.njk new file mode 100644 index 000000000..52243ae1d --- /dev/null +++ b/packages/site/views/design-system/styles/focus-state/buttons/index.njk @@ -0,0 +1,33 @@ +{% from 'button/macro.njk' import button %} + +
    +
    +
    + {{ button({ + "text": "Save and continue", + "type": "button", + "classes": "ofh-button--contained", + "attributes": { + "id": "focus-state-button-target" + } + }) }} + + {{ button({ + "text": "Review answers", + "type": "button", + "classes": "ofh-button--outlined" + }) }} + {{ button({ + "text": "Skip for now", + "type": "button", + "classes": "ofh-button--ghost" + }) }} + + {{ button({ + "text": "Cancel", + "type": "button", + "classes": "ofh-button--text" + }) }} +
    +
    +
    diff --git a/packages/site/views/design-system/styles/focus-state/choices/index.njk b/packages/site/views/design-system/styles/focus-state/choices/index.njk new file mode 100644 index 000000000..31c98d525 --- /dev/null +++ b/packages/site/views/design-system/styles/focus-state/choices/index.njk @@ -0,0 +1,50 @@ +{% from 'radios/macro.njk' import radios %} +{% from 'checkboxes/macro.njk' import checkboxes %} + +
    +
    +
    + {{ radios({ + "idPrefix": "focus-state-radios", + "name": "focus-state-radios", + "fieldset": { + "legend": { + "text": "How would you like to hear from us?", + "classes": "ofh-fieldset__legend--m" + } + }, + "items": [ + { + "value": "email", + "text": "Email" + }, + { + "value": "phone", + "text": "Phone" + } + ] + }) }} + + {{ checkboxes({ + "idPrefix": "focus-state-checkboxes", + "name": "focus-state-checkboxes", + "fieldset": { + "legend": { + "text": "Which updates would you like?", + "classes": "ofh-fieldset__legend--m" + } + }, + "items": [ + { + "value": "research", + "text": "Research news" + }, + { + "value": "appointments", + "text": "Appointment reminders" + } + ] + }) }} +
    +
    +
    diff --git a/packages/site/views/design-system/styles/focus-state/index.njk b/packages/site/views/design-system/styles/focus-state/index.njk index 325c47d4c..bb508bb84 100644 --- a/packages/site/views/design-system/styles/focus-state/index.njk +++ b/packages/site/views/design-system/styles/focus-state/index.njk @@ -1,9 +1,10 @@ {% set pageTitle = "Focus state" %} -{% set pageDescription = "Use these focus state styles to let users know which element they’re on and that they can interact with it." %} +{% set pageDescription = "Current focus treatments for links, form controls and buttons in the OFH toolkit." %} {% set pageSection = "Design system" %} {% set subSection = "Foundation styles" %} -{% set dateUpdated = "October 2019" %} +{% set dateUpdated = "March 2026" %} {% set backlog_issue_id = "193" %} +{% set hideContact = "true" %} {% extends "app-layout.njk" %} @@ -14,53 +15,198 @@ {% block bodyContent %}

    What is a focus state?

    -

    Some people use keyboards or other devices to navigate through a page by jumping from 1 interactive element to the next. The focus state lets users know which element they’re currently on and that it's ready for them to interact with.

    +

    Focus states show people which interactive element is currently active when they move through a page using a keyboard or another assistive technology.

    +

    The toolkit applies focus styles through shared mixins and tokens so links, form controls and buttons behave consistently.

    -

    Our focus state styles

    -

    We've followed the GOV.UK Design System's approach to focus state styles.

    -

    Like GOV.UK, we use a combination of yellow and black to make sure we meet the Web Content Accessibility Guidelines (WCAG) 2.1 level AA non-text contrast on any background colour on the NHS website.

    -

    The yellow has a high contrast with dark backgrounds and the thick black border has a high contrast against light backgrounds.

    +

    Current focus tokens

    +

    Our default focus ring uses $ofh-color-border-feedback-focus, which resolves to the current interactive blue token. On dark surfaces, inverted button variants use $ofh-color-border-feedback-focus-inverted, which resolves to white.

    +

    This page replaces the older yellow-and-black guidance that no longer matches the current OFH toolkit.

    - -

    When links are focused, they have a yellow background with a black bottom border. This helps the focused link stand out from the rest of the content on the page.

    - A focused link against different NHS.UK background colours +

    Focus treatments in the toolkit

    + {{ table({ + responsive: true, + panel: false, + caption: "Focus treatments in the OFH toolkit", + head: [ + { + text: "Mixin or token" + }, + { + text: "Use for" + }, + { + text: "Current treatment" + } + ], + rows: [ + [ + { + header: "Mixin or token", + html: "ofh-focused-text" + }, + { + header: "Use for", + text: "Links, action links, details summaries and similar text-led controls" + }, + { + header: "Current treatment", + text: "3px focus outline using the default focus token with a 1px offset" + } + ], + [ + { + header: "Mixin or token", + html: "ofh-focused-input" + }, + { + header: "Use for", + text: "Inputs, selects and textareas" + }, + { + header: "Current treatment", + text: "Active input border plus a 4px focus outline offset by 4px" + } + ], + [ + { + header: "Mixin or token", + html: "ofh-focused-radio" + }, + { + header: "Use for", + text: "Radio controls" + }, + { + header: "Current treatment", + text: "Active control border plus a 4px focus outline offset by 4px" + } + ], + [ + { + header: "Mixin or token", + html: "ofh-focused-checkbox" + }, + { + header: "Use for", + text: "Checkbox controls" + }, + { + header: "Current treatment", + text: "Active control border plus a 4px focus outline offset by 4px" + } + ], + [ + { + header: "Mixin or token", + html: "ofh-focused-button" + }, + { + header: "Use for", + text: "Buttons and button-like interactive controls on light surfaces" + }, + { + header: "Current treatment", + text: "4px default focus outline offset by 4px" + } + ], + [ + { + header: "Mixin or token", + html: "ofh-focused-button-inverted" + }, + { + header: "Use for", + text: "Inverted button variants on dark surfaces" + }, + { + header: "Current treatment", + text: "4px inverted focus outline offset by 4px" + } + ] + ] + }) }} -

    Form input focus state style

    -

    When form inputs are focused, they have a yellow outline and a thick black border. If the element already has a border, the border gets thicker.

    - A text input labelled "What is your name?". The example shows the text input both unfocused and focused. -

    Radios and checkboxes use the same style.

    - Yes and no radio options to answer the question "Do you know your NHS number?". In this example, the "No" option is focused. +

    Live examples

    +

    Click into an example, then use Tab inside it to move through the interactive elements and inspect the focus treatment.

    -

    Making focus states accessible for extended and modified components

    -

    If you’ve extended or modified components in the NHS digital service manual, you can use service manual styles to make the focus states of these components accessible.

    -

    How you make focus states accessible depends on whether the component is:

    - +
    +

    Text-based interactive elements

    +

    Use ofh-focused-text for body links, action links, details summaries, pagination links and similar text-led controls.

    + {{ designExample({ + group: "styles", + item: "focus-state", + itemTitle: "focus state", + type: "text", + showCode: false + }) }} +
    + +
    +

    Inputs, selects and textareas

    +

    Use ofh-focused-input for input-based controls. The toolkit combines an active input border with the default focus outline so the control remains visible on light surfaces.

    + {{ designExample({ + group: "styles", + item: "focus-state", + itemTitle: "focus state", + type: "inputs", + showCode: false + }) }} +
    -

    Make focusable text accessible

    -

    If you use Sass, you should include the ofh-focused-text mixin in your component's :focus selector if that component is focusable text. For example, if the component is a link in body text, or the details component:

    -
    .app-component:focus {
    -  @include ofh-focused-text;
    -}
    +
    +

    Radios and checkboxes

    +

    Use ofh-focused-radio and ofh-focused-checkbox for selection controls rather than rebuilding the ring and border treatment manually.

    + {{ designExample({ + group: "styles", + item: "focus-state", + itemTitle: "focus state", + type: "choices", + showCode: false + }) }} +
    -

    Make other focusable elements accessible

    -

    If you use Sass, you can use 3 NHS.UK frontend variables if your component has a background colour or border. For example, a text input or checkbox.

    -

    The 3 Sass variables are:

    +
    +

    Buttons on light surfaces

    +

    Use ofh-focused-button for the standard button variants. This is also the right treatment for other button-like controls on light surfaces.

    + {{ designExample({ + group: "styles", + item: "focus-state", + itemTitle: "focus state", + type: "buttons", + showCode: false + }) }} +
    + +
    +

    Inverted buttons on dark surfaces

    +

    Use ofh-focused-button-inverted for inverted button variants. These controls use the inverted focus token so the ring remains visible on dark backgrounds.

    + {{ designExample({ + group: "styles", + item: "focus-state", + itemTitle: "focus state", + type: "buttons-inverted", + showCode: false + }) }} +
    + +

    When building new components

    +

    Use the mixin that matches the control type instead of rebuilding focus styles from raw values. That keeps the focus treatment aligned with the toolkit and the current design token model.

    -

    Use these variables in your components instead of numeric values for the background, text and widths.

    - -

    If you do not use Sass

    -

    To make a component’s focus state accessible without using Sass, you can:

    +

    If you are not using Sass

    +

    Use the compiled CSS from the equivalent toolkit component as the source of truth. If you need to reproduce the treatment directly, use the current focus tokens from the toolkit rather than the older values previously documented on this page.

    {% endblock %} diff --git a/packages/site/views/design-system/styles/focus-state/inputs/index.njk b/packages/site/views/design-system/styles/focus-state/inputs/index.njk new file mode 100644 index 000000000..415e33dd8 --- /dev/null +++ b/packages/site/views/design-system/styles/focus-state/inputs/index.njk @@ -0,0 +1,49 @@ +{% from 'input/macro.njk' import input %} +{% from 'select/macro.njk' import select %} +{% from 'textarea/macro.njk' import textarea %} + +
    +
    + {{ input({ + "label": { + "text": "Email address" + }, + "id": "focus-state-input-target", + "name": "focus-state-input-target" + }) }} + + {{ select({ + "id": "focus-state-select", + "name": "focus-state-select", + "label": { + "text": "Preferred contact method" + }, + "items": [ + { + "value": "email", + "text": "Email" + }, + { + "value": "phone", + "text": "Phone", + "selected": true + }, + { + "value": "text", + "text": "Text message" + } + ] + }) }} + + {{ textarea({ + "name": "focus-state-textarea", + "id": "focus-state-textarea", + "label": { + "text": "Additional detail" + }, + "hint": { + "text": "Use this field to describe anything else we should know." + } + }) }} +
    +
    diff --git a/packages/site/views/design-system/styles/focus-state/text/index.njk b/packages/site/views/design-system/styles/focus-state/text/index.njk new file mode 100644 index 000000000..2953216f5 --- /dev/null +++ b/packages/site/views/design-system/styles/focus-state/text/index.njk @@ -0,0 +1,21 @@ +{% from 'action-link/macro.njk' import actionLink %} +{% from 'details/macro.njk' import details %} + +
    +
    +

    Read our screening guidance before you continue.

    + + {{ actionLink({ + "text": "Start your questionnaire", + "href": "#" + }) }} + + {{ details({ + "text": "How to find your NHS number", + "HTML": " +

    An NHS number is a 10 digit number, like 485 777 3456.

    +

    You can find your NHS number on prescriptions, test results and appointment letters.

    + " + }) }} +
    +
    diff --git a/packages/site/views/design-system/styles/icons/index.njk b/packages/site/views/design-system/styles/icons/index.njk index 3ca952d1e..e79c83295 100644 --- a/packages/site/views/design-system/styles/icons/index.njk +++ b/packages/site/views/design-system/styles/icons/index.njk @@ -4,6 +4,7 @@ {% set subSection = "Foundation styles" %} {% set dateUpdated = "March 2026" %} {% set backlog_issue_id = "4" %} +{% set hideContact = "true" %} {% extends "app-layout.njk" %} {% from "icon/macro.njk" import icon %} @@ -24,31 +25,111 @@

    Example: {% raw %}{{ icon({ "name": "Search", "size": 24 }) }}{% endraw %}

    {{ materialIcons.total }} Material icons are currently available in the toolkit sprite.

    +

    Responsive size behaviour

    +

    Icons can be sized in 2 different ways. Use a fixed class when the icon should stay the same size at every breakpoint, or use the responsive mixin when the icon should follow the responsive iconography scale.

    +

    If you render the shared icon primitive without passing a size or adding a size class, it defaults to 24px. Some components override that base behaviour in their own CSS, so treat 24px as the icon primitive default rather than a guarantee for every component-level icon.

    +

    The fixed classes .ofh-icon--16, .ofh-icon--24, and .ofh-icon--32 are backwards compatible and always render at the size you pick.

    +

    Responsive icon sizes are opt-in and come from $ofh-iconography-responsive-scale via @include ofh-iconography-responsive(...).

    + +
    + + + + + + + + + + + + {% for row in foundationStyles.icons.sizeScale %} + + + + + + + {% endfor %} + +
    Fixed icon sizes
    Fixed classPreviewRendered sizeBehaviour
    {{ row.fixedClass }} +
    + {{ icon({ "name": row.iconName, "size": row.size, "title": row.iconName }) }} +
    +
    {{ row.size }}pxAlways {{ row.size }}px.
    +
    + +
    + + + + + + + + + + + + + + {% for row in foundationStyles.icons.sizeScale %} + + + + + + + + + {% endfor %} + +
    Responsive icon scale
    Responsive mixinPreviewDesktopTabletMobileBehaviour
    {{ row.responsiveMixin }} +
    + {{ icon({ "name": row.iconName, "size": row.size, "title": row.iconName }) }} +
    +
    {{ row.desktop }}{{ row.tablet }}{{ row.mobile }} + {% if row.mobile == row.tablet and row.tablet == row.desktop %} + {{ row.desktop }} at every breakpoint. + {% elseif row.mobile == row.tablet %} + {{ row.desktop }} desktop, {{ row.tablet }} tablet/mobile. + {% else %} + {{ row.desktop }} desktop, {{ row.tablet }} tablet, {{ row.mobile }} mobile. + {% endif %} +
    +
    + {% for group in materialIcons.iconsByCategory %}

    {{ group.category }}

    - - +
    +
    + + + + + + + - - - - - + + + + - + {% for item in group.icons %} - - - - - - + + + + + {% endfor %} + {% endfor %}

    Legacy icon files have been removed. Use the Material icon macro and sprite for all icon usage.

    @@ -66,10 +147,10 @@ -

    Other icons needs an explanation. They include:

    +

    Other icons need an explanation. They include:

    Using SVG classes for icons

    -

    We use scalable vector graphics (SVG) for icons, rather than images such as PNG. SVG are code snippets that you can drop directly into the HTML.

    -

    SVG icons are sharp, flexible, and load quickly - and you can control how they appear, for example their colour, with style sheets (CSS).

    +

    We use scalable vector graphics, SVG, for icons rather than images such as PNG. SVGs are sharp, flexible, and load quickly. You can also control how they appear with CSS.

    If you're using a server side or templating language, include icons with the shared macro, for example {% raw %}{{ icon({ "name": "Search", "size": 24 }) }}{% endraw %}.

    The icon macro renders consistent .ofh-icon markup that references the Material sprite symbols.

    Legacy PNG fallbacks are not provided as part of this implementation.

    @@ -88,7 +168,7 @@

    If you have any research or experience to share, please get in touch on the NHS.UK service manual Slack workspace or email us at service-manual@nhs.net.

    Research

    -

    These icons seem to be universally recognisable. When we tested them, we found that people understood them. We tested them in context - not on their own, but in components on a full page.

    +

    These icons seem to be universally recognisable. When we tested them, we found that people understood them. We tested them in context, not on their own, but in components on a full page.

    Read more about the benefits of SVG icons: