From bc2cb011bfdc76a2a7ab88400bafc31617529bf3 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Wed, 12 Aug 2026 21:22:13 +0200 Subject: [PATCH 01/11] docs(ARC-3848): add design spec for WCAG text colors on content-block backgrounds --- ...-08-12-arc-3848-wcag-text-colors-design.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-12-arc-3848-wcag-text-colors-design.md diff --git a/docs/superpowers/specs/2026-08-12-arc-3848-wcag-text-colors-design.md b/docs/superpowers/specs/2026-08-12-arc-3848-wcag-text-colors-design.md new file mode 100644 index 00000000..2399ff7f --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-arc-3848-wcag-text-colors-design.md @@ -0,0 +1,62 @@ +# ARC-3848: WCAG text colors on content-block background colors + +## Problem + +ARC-3795 added 10 tertiary background colors to hetarchief.be content blocks. Content +blocks that don't expose a configurable text-color field derive their text color from a +single shared "dark background" list (`GET_DARK_BACKGROUND_COLOR_OPTIONS` in +`ui/src/react-admin/modules/content-page/const/get-color-options.ts`). Anything on the +list gets forced white text; everything else defaults to black. + +That list is shared between AVO and hetarchief (ARCHIEF), but the two products have +different brand palettes. Checked against the design team's authoritative +`meemoo-hetarchief-kleurencombinaties.pdf` (attached to ARC-3848), the ARCHIEF palette has +two mismatches: + +- `OceanGreen` (#00C8AA) and `SeaGreen` (#009690) are currently forced to white text, but + the PDF shows black passes AA on both. +- `OldPink` / "Oud roze" (#9B6072), one of the ARC-3795 tertiary colors, currently defaults + to black text (not on the list), but the PDF requires white there for AA — this is the + actual gap the ticket exists to close. + +Every other ARCHIEF background color (White, Platinum, SkyBlue, and the other 9 tertiary +colors) already defaults correctly to black per the PDF. + +AVO's own use of `OceanGreen` etc. is governed by a different brand book and must not +change. + +## Design + +Split `GET_DARK_BACKGROUND_COLOR_OPTIONS` into: + +- `GET_DARK_BACKGROUND_COLOR_OPTIONS_AVO` — unchanged, current membership + (`SoftBlue, NightBlue, Teal, TealBright, OceanGreen, SeaGreen, Yellow, Black`). +- `GET_DARK_BACKGROUND_COLOR_OPTIONS_ARCHIEF` — `[Black, OldPink]`, per the PDF. + +Pick between them with the existing `isAvo()` helper +(`ui/src/react-admin/modules/shared/helpers/is-avo.ts`), mirroring the pattern already +used in `defaults.ts` for `BACKGROUND_COLOR_FIELD` / `FOREGROUND_COLOR_FIELD`. + +Update the two call sites: + +- `ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx` + (`hasDarkBg`) +- `ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.wrapper.tsx` + (`darkTabs`) + +## Out of scope + +- The meemoo-logo background (`CustomBackground.MeemooLogo`) — not a flat color, not + covered by the PDF, left unchanged. +- The black↔white gradient background (`GradientColor.BlackWhite`) — fades top-to-bottom, + no single correct text color, not covered by the PDF, left unchanged. +- AVO-only background colors (`SoftBlue`, `NightBlue`, `Teal`, `TealBright`, `Yellow`, + `Gray50`, etc.) — different brand book, out of scope for this ticket. + +## Testing + +Existing test setup uses vitest. No dedicated tests currently cover +`GET_DARK_BACKGROUND_COLOR_OPTIONS` or the two call sites; this change is small enough to +verify by reading the diff and, if time permits, a quick manual check in the ui demo app +(`npm run dev` in `ui/`) with a content block set to `OldPink`/`OceanGreen`/`SeaGreen` +backgrounds. From 3f28eeec7eaf504685dd93622e8f531e21da3d84 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Wed, 12 Aug 2026 21:32:26 +0200 Subject: [PATCH 02/11] fix(ARC-3848): give archief content blocks WCAG-correct auto text colors Split GET_DARK_BACKGROUND_COLOR_OPTIONS into an AVO variant (unchanged) and an archief variant sourced from the delivered colour list: only black and old pink backgrounds need white text, ocean green and sea green need black (the old shared list had that backwards). --- .../ContentBlockRenderer.tsx | 12 ++++--- .../BlockPageOverview.wrapper.tsx | 11 ++++-- .../content-page/const/get-color-options.ts | 34 +++++++++++++------ 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx index ebe4043b..5f1b9cfb 100644 --- a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx +++ b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx @@ -10,7 +10,11 @@ import { GENERATED_CONTENT_BLOCK_ANCHOR_PREFIX } from '~modules/content-page/con import type { ContentPageInfo } from '~modules/content-page/types/content-pages.types'; import { ContentPageWidth } from '~modules/content-page/types/content-pages.types'; import { generateSmartLink } from '~shared/components/SmartLink/SmartLink'; -import { GET_DARK_BACKGROUND_COLOR_OPTIONS } from '../../const/get-color-options'; +import { isAvo } from '~shared/helpers/is-avo'; +import { + GET_DARK_BACKGROUND_COLOR_OPTIONS_ARCHIEF, + GET_DARK_BACKGROUND_COLOR_OPTIONS_AVO, +} from '../../const/get-color-options'; import { Color, type ContentBlockConfig, @@ -122,9 +126,9 @@ const ContentBlockRenderer: FunctionComponent = ({ }; } - const hasDarkBg = GET_DARK_BACKGROUND_COLOR_OPTIONS().includes( - blockState?.backgroundColor || ('' as unknown as Color) - ); + const hasDarkBg = ( + isAvo() ? GET_DARK_BACKGROUND_COLOR_OPTIONS_AVO() : GET_DARK_BACKGROUND_COLOR_OPTIONS_ARCHIEF() + ).includes(blockState?.backgroundColor || ('' as unknown as Color)); const anchor = blockState?.anchor?.replaceAll(' ', '-') || GENERATED_CONTENT_BLOCK_ANCHOR_PREFIX + contentBlockConfig.id; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.wrapper.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.wrapper.tsx index 1702b146..7be73bae 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.wrapper.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.wrapper.tsx @@ -7,7 +7,10 @@ import { ContentItemStyle } from '~content-blocks/BlockPageOverview/BlockPageOve import { AdminConfigManager } from '~core/config/config.class'; import { BlockPageOverview } from '~modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview'; import type { PageOverviewWrapperProps } from '~modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.types'; -import { GET_DARK_BACKGROUND_COLOR_OPTIONS } from '~modules/content-page/const/get-color-options'; +import { + GET_DARK_BACKGROUND_COLOR_OPTIONS_ARCHIEF, + GET_DARK_BACKGROUND_COLOR_OPTIONS_AVO, +} from '~modules/content-page/const/get-color-options'; import { useGetContentPageByLanguageAndPath } from '~modules/content-page/hooks/use-get-content-page-by-language-and-path'; import { useGetContentPageLabelsByTypeAndIds } from '~modules/content-page/hooks/use-get-content-page-labels-by-type-and-ids'; import { useGetContentPageLabelsByTypeAndLabels } from '~modules/content-page/hooks/use-get-content-page-labels-by-type-and-labels'; @@ -15,6 +18,7 @@ import { useGetContentPagesForPageOverviewBlock } from '~modules/content-page/ho import type { ContentPageInfo } from '~modules/content-page/types/content-pages.types'; import { Locale } from '~modules/translations/translations.core.types'; import { ErrorView } from '~shared/components/error/ErrorView'; +import { isAvo } from '~shared/helpers/is-avo'; import { isHetArchief } from '~shared/helpers/is-hetarchief'; import { navigateFunc } from '~shared/helpers/navigate-fnc'; import { @@ -225,7 +229,10 @@ export const BlockPageOverviewWrapper: FunctionComponent SelectOption[] yellowOption(), ]; -export const GET_DARK_BACKGROUND_COLOR_OPTIONS: () => (Color | GradientColor | CustomBackground)[] = - () => [ - Color.SoftBlue, - Color.NightBlue, - Color.Teal, - Color.TealBright, - Color.OceanGreen, - Color.SeaGreen, - Color.Yellow, - Color.Black, - ]; +export const GET_DARK_BACKGROUND_COLOR_OPTIONS_AVO: () => ( + | Color + | GradientColor + | CustomBackground +)[] = () => [ + Color.SoftBlue, + Color.NightBlue, + Color.Teal, + Color.TealBright, + Color.OceanGreen, + Color.SeaGreen, + Color.Yellow, + Color.Black, +]; + +// Backgrounds that need white text to pass WCAG AA on hetarchief.be, per the color +// combinations design provided on https://meemoo.atlassian.net/browse/ARC-3848. +// Every other archief background color passes AA with the default black text. +export const GET_DARK_BACKGROUND_COLOR_OPTIONS_ARCHIEF: () => ( + | Color + | GradientColor + | CustomBackground +)[] = () => [Color.Black, Color.OldPink]; export const GET_FOREGROUND_COLOR_OPTIONS_AVO: () => SelectOption[] = () => [ { From cc8b67e66dd855c4c014d195f2116d7913132568 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Wed, 12 Aug 2026 22:21:07 +0200 Subject: [PATCH 03/11] feat(ARC-3848): add wcag contrast ratio helper --- .../shared/helpers/get-contrast-ratio.test.ts | 58 +++++++++++++++++ .../shared/helpers/get-contrast-ratio.ts | 62 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.test.ts create mode 100644 ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.ts diff --git a/ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.test.ts b/ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.test.ts new file mode 100644 index 00000000..537646a1 --- /dev/null +++ b/ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { getContrastRatio, WCAG_AA_CONTRAST_NORMAL_TEXT } from './get-contrast-ratio'; + +describe('getContrastRatio()', () => { + it('returns the maximum ratio for black on white', () => { + expect(getContrastRatio('#000000', '#FFFFFF')).toBeCloseTo(21, 5); + }); + + it('returns 1 for a color against itself', () => { + expect(getContrastRatio('#9B6072', '#9B6072')).toBeCloseTo(1, 5); + }); + + it('is symmetric, so argument order does not matter', () => { + expect(getContrastRatio('#9B6072', '#FFFFFF')).toEqual(getContrastRatio('#FFFFFF', '#9B6072')); + }); + + // Reference values from the WebAIM contrast checker. + it.each([ + ['#9B6072', 4.87], + ['#00C8AA', 2.13], + ['#009690', 3.64], + ['#64702B', 5.39], + ['#757575', 4.61], + ])('scores white text on %s at %f:1', (background, expected) => { + expect(getContrastRatio(background, '#FFFFFF')).toBeCloseTo(expected, 2); + }); + + it('accepts shorthand hex', () => { + expect(getContrastRatio('#fff', '#FFFFFF')).toBeCloseTo(1, 5); + expect(getContrastRatio('#000', '#ffffff')).toBeCloseTo(21, 5); + }); + + it('is case insensitive, since the palette mixes casing', () => { + expect(getContrastRatio('#c6c2e0', '#FFFFFF')).toEqual(getContrastRatio('#C6C2E0', '#FFFFFF')); + }); + + it('works without the leading hash', () => { + expect(getContrastRatio('000000', 'FFFFFF')).toBeCloseTo(21, 5); + }); + + // The color pickers can hold these, and none of them has a single luminance to compare against. + it.each([ + ['a gradient', 'linear-gradient(to top, #fff 0%, #000 100%)'], + ['the meemoo logo placeholder', ''], + ['the transparent keyword', 'TRANSPARENT'], + ['a css color name', 'rebeccapurple'], + ['an rgb() value', 'rgb(155, 96, 114)'], + ['a malformed hex', '#12345'], + ['an empty string', ''], + ])('returns null for %s', (_name, color) => { + expect(getContrastRatio(color, '#FFFFFF')).toBeNull(); + }); + + it('exposes the AA threshold for normal text', () => { + expect(WCAG_AA_CONTRAST_NORMAL_TEXT).toBe(4.5); + }); +}); diff --git a/ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.ts b/ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.ts new file mode 100644 index 00000000..892b3bf1 --- /dev/null +++ b/ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.ts @@ -0,0 +1,62 @@ +/** + * WCAG 2.1 contrast ratio between two colors, used to decide readable text colors on + * admin-picked background colors. https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio + */ + +/** WCAG 2.1 AA minimum contrast for normal-sized text (1.4.3). */ +export const WCAG_AA_CONTRAST_NORMAL_TEXT = 4.5; + +/** WCAG 2.1 AA minimum contrast for large text: >= 24px, or >= 18.66px bold (1.4.3). */ +export const WCAG_AA_CONTRAST_LARGE_TEXT = 3; + +/** + * Parses #RGB and #RRGGBB into 0-255 channels. Returns null for anything else, which includes the + * non-color values the color pickers can hold: Color.Transparent ("TRANSPARENT"), + * CustomBackground.MeemooLogo ("") and GradientColor values ("linear-gradient(...)"). + */ +function parseHexColor(color: string): [number, number, number] | null { + const hex = color.trim().replace(/^#/, ''); + + if (!/^([\da-f]{3}|[\da-f]{6})$/i.test(hex)) { + return null; + } + + const pairs = + hex.length === 3 + ? Array.from(hex, (channel) => channel + channel) + : (hex.match(/.{2}/g) as string[]); + + return pairs.map((pair) => Number.parseInt(pair, 16)) as [number, number, number]; +} + +/** Relative luminance per WCAG 2.1. https://www.w3.org/TR/WCAG21/#dfn-relative-luminance */ +function getRelativeLuminance([red, green, blue]: [number, number, number]): number { + const [r, g, b] = [red, green, blue].map((channel) => { + const srgb = channel / 255; + + return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; + }); + + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** + * Contrast ratio between two colors, from 1 (identical) to 21 (black on white). + * Returns null when either color is not a plain hex color, since a gradient or a pattern has no + * single luminance to compare against - the caller decides what to do with that. + */ +export function getContrastRatio(colorA: string, colorB: string): number | null { + const rgbA = parseHexColor(colorA); + const rgbB = parseHexColor(colorB); + + if (!rgbA || !rgbB) { + return null; + } + + const luminanceA = getRelativeLuminance(rgbA); + const luminanceB = getRelativeLuminance(rgbB); + const lighter = Math.max(luminanceA, luminanceB); + const darker = Math.min(luminanceA, luminanceB); + + return (lighter + 0.05) / (darker + 0.05); +} From f2c4b8d8129ad68b7668e8ba8272ee9c7ee6d1b6 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Wed, 12 Aug 2026 22:21:22 +0200 Subject: [PATCH 04/11] refactor(ARC-3848): compute archief text color from contrast instead of a list --- .../ContentBlockRenderer.tsx | 10 +- .../BlockPageOverview.wrapper.tsx | 14 +- .../const/get-color-options.test.ts | 146 ++++++++++++++++++ .../content-page/const/get-color-options.ts | 51 ++++-- 4 files changed, 188 insertions(+), 33 deletions(-) create mode 100644 ui/src/react-admin/modules/content-page/const/get-color-options.test.ts diff --git a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx index 5f1b9cfb..021a7916 100644 --- a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx +++ b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx @@ -10,11 +10,7 @@ import { GENERATED_CONTENT_BLOCK_ANCHOR_PREFIX } from '~modules/content-page/con import type { ContentPageInfo } from '~modules/content-page/types/content-pages.types'; import { ContentPageWidth } from '~modules/content-page/types/content-pages.types'; import { generateSmartLink } from '~shared/components/SmartLink/SmartLink'; -import { isAvo } from '~shared/helpers/is-avo'; -import { - GET_DARK_BACKGROUND_COLOR_OPTIONS_ARCHIEF, - GET_DARK_BACKGROUND_COLOR_OPTIONS_AVO, -} from '../../const/get-color-options'; +import { hasDarkBackground } from '../../const/get-color-options'; import { Color, type ContentBlockConfig, @@ -126,9 +122,7 @@ const ContentBlockRenderer: FunctionComponent = ({ }; } - const hasDarkBg = ( - isAvo() ? GET_DARK_BACKGROUND_COLOR_OPTIONS_AVO() : GET_DARK_BACKGROUND_COLOR_OPTIONS_ARCHIEF() - ).includes(blockState?.backgroundColor || ('' as unknown as Color)); + const hasDarkBg = hasDarkBackground(blockState?.backgroundColor); const anchor = blockState?.anchor?.replaceAll(' ', '-') || GENERATED_CONTENT_BLOCK_ANCHOR_PREFIX + contentBlockConfig.id; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.wrapper.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.wrapper.tsx index 7be73bae..b8b3c4ae 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.wrapper.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.wrapper.tsx @@ -7,10 +7,7 @@ import { ContentItemStyle } from '~content-blocks/BlockPageOverview/BlockPageOve import { AdminConfigManager } from '~core/config/config.class'; import { BlockPageOverview } from '~modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview'; import type { PageOverviewWrapperProps } from '~modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.types'; -import { - GET_DARK_BACKGROUND_COLOR_OPTIONS_ARCHIEF, - GET_DARK_BACKGROUND_COLOR_OPTIONS_AVO, -} from '~modules/content-page/const/get-color-options'; +import { hasDarkBackground } from '~modules/content-page/const/get-color-options'; import { useGetContentPageByLanguageAndPath } from '~modules/content-page/hooks/use-get-content-page-by-language-and-path'; import { useGetContentPageLabelsByTypeAndIds } from '~modules/content-page/hooks/use-get-content-page-labels-by-type-and-ids'; import { useGetContentPageLabelsByTypeAndLabels } from '~modules/content-page/hooks/use-get-content-page-labels-by-type-and-labels'; @@ -18,7 +15,6 @@ import { useGetContentPagesForPageOverviewBlock } from '~modules/content-page/ho import type { ContentPageInfo } from '~modules/content-page/types/content-pages.types'; import { Locale } from '~modules/translations/translations.core.types'; import { ErrorView } from '~shared/components/error/ErrorView'; -import { isAvo } from '~shared/helpers/is-avo'; import { isHetArchief } from '~shared/helpers/is-hetarchief'; import { navigateFunc } from '~shared/helpers/navigate-fnc'; import { @@ -227,13 +223,7 @@ export const BlockPageOverviewWrapper: FunctionComponent vi.fn<() => boolean>()); + +vi.mock('~shared/helpers/is-avo', () => ({ isAvo: isAvoMock })); + +// The option labels are translated, which needs an initialised AdminConfigManager. These tests are +// about colors, not labels, so the key stands in for the label. +vi.mock('~shared/helpers/translation-functions', () => ({ tText: (key: string) => key })); + +/** + * Every background row of meemoo-hetarchief-kleurencombinaties.pdf, the design meemoo delivered on + * https://meemoo.atlassian.net/browse/ARC-3848, as [name, background, prescribed text color]. + * + * This is the fixture the computed rule is asserted against, so the PDF stays the authority without + * anyone hand-maintaining a second list of colors in the source. + * + * One row is deliberately absent: Zink #ADADAD, which the PDF prescribes white text on even though + * white scores 2.24:1 there - below AA, and below even the 3:1 large-text threshold. It looks like + * an error in the PDF. It is not selectable as a background (foreground option only), so it changes + * nothing today; raised with design rather than encoded here. + */ +const KLEURENCOMBINATIES_PDF: [string, string, 'wit' | 'zwart'][] = [ + // Merk + ['Zwart', '#000000', 'wit'], + ['Wit', '#FFFFFF', 'zwart'], + ['Teal', '#00C8AA', 'zwart'], + // Functioneel + ['Grafiet', '#222222', 'wit'], + ['Inkt', '#303030', 'wit'], + ['Schaduw', '#505050', 'wit'], + ['Leisteen', '#666666', 'wit'], + ['Neutraal', '#757575', 'wit'], + ['Zilver', '#E6E6E6', 'zwart'], + ['Platinum', '#F8F8F8', 'zwart'], + ['Kers', '#D60039', 'wit'], + ['Jade', '#00857D', 'wit'], + ['Lagune', '#005F69', 'wit'], + // Secundair + ['Zeegroen', '#009690', 'zwart'], + ['Grasgroen', '#82E678', 'zwart'], + ['Azuur', '#28A0C8', 'zwart'], + // Tertiair + ['Lila', '#C6C2E0', 'zwart'], + ['Mosterd', '#EFCA6A', 'zwart'], + ['Koraal', '#E89B88', 'zwart'], + ['Baby blauw', '#8DDEE7', 'zwart'], + ['Blush', '#E694B3', 'zwart'], + ['Donker lila', '#A293AF', 'zwart'], + ['Mist', '#91A9A7', 'zwart'], + ['Sepia', '#EDD6C4', 'zwart'], + ['Mauve', '#9B6072', 'wit'], + ['Salie', '#B8BE9A', 'zwart'], + ['Terra', '#D1543A', 'zwart'], + ['Olijf', '#64702B', 'wit'], + ['Viool', '#432457', 'wit'], +]; + +describe('hasDarkBackground()', () => { + describe('on hetarchief', () => { + beforeEach(() => { + isAvoMock.mockReturnValue(false); + }); + + it.each(KLEURENCOMBINATIES_PDF)( + 'puts %s text on %s (%s), like the design', + (_name, background, textColor) => { + expect(hasDarkBackground(background)).toBe(textColor === 'wit'); + } + ); + + // Guards the two colors the old shared AVO list got backwards: white text scored 2.13:1 on + // ocean green and 3.64:1 on sea green, both unreadable. + it.each([Color.OceanGreen, Color.SeaGreen])('keeps black text on %s', (color) => { + expect(hasDarkBackground(color)).toBe(false); + }); + + it('puts white text on old pink, the tertiary color that needs it', () => { + expect(hasDarkBackground(Color.OldPink)).toBe(true); + }); + + // The point of computing instead of listing: every pickable background gets an answer, so a + // color added to the palette can never silently miss out on a text color ruling. + it('rules on every background the admin can pick', () => { + const undecidable: (Color | GradientColor | CustomBackground)[] = [ + Color.Transparent, + GradientColor.BlackWhite, + CustomBackground.MeemooLogo, + ]; + + // Note Color.Black is '#000' and Color.White is '#FFF', so shorthand hex has to work. + const needsWhiteText: string[] = [Color.Black, Color.OldPink]; + + const flatColors = GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF() + .map((option) => option.value) + .filter((value) => !undecidable.includes(value)); + + expect(flatColors.length).toBeGreaterThan(0); + + for (const color of flatColors) { + expect(hasDarkBackground(color), `wrong ruling for ${color}`).toBe( + needsWhiteText.includes(color) + ); + } + }); + }); + + describe('on avo', () => { + beforeEach(() => { + isAvoMock.mockReturnValue(true); + }); + + // AVO's palette predates the rule and does not follow it, so it stays a literal list and must + // not be recomputed - white on Color.Yellow is 1.2:1, but the AVO brand book asks for it. + it.each([Color.OceanGreen, Color.SeaGreen, Color.Yellow, Color.Black])( + 'keeps the legacy white text on %s', + (color) => { + expect(hasDarkBackground(color)).toBe(true); + } + ); + + it('keeps black text on white', () => { + expect(hasDarkBackground(Color.White)).toBe(false); + }); + }); + + describe('backgrounds without a single luminance', () => { + beforeEach(() => { + isAvoMock.mockReturnValue(false); + }); + + // None of these can be reduced to one contrast ratio, so they keep the default black text. + it.each([ + ['a gradient', GradientColor.BlackWhite], + ['the meemoo logo pattern', CustomBackground.MeemooLogo], + ['transparent', Color.Transparent], + ['no background at all', undefined], + ['an empty background', ''], + ])('keeps black text on %s', (_name, color) => { + expect(hasDarkBackground(color)).toBe(false); + }); + }); +}); diff --git a/ui/src/react-admin/modules/content-page/const/get-color-options.ts b/ui/src/react-admin/modules/content-page/const/get-color-options.ts index b88d9e80..6f304953 100644 --- a/ui/src/react-admin/modules/content-page/const/get-color-options.ts +++ b/ui/src/react-admin/modules/content-page/const/get-color-options.ts @@ -1,4 +1,6 @@ import type { SelectOption } from '@viaa/avo2-components'; +import { getContrastRatio, WCAG_AA_CONTRAST_NORMAL_TEXT } from '~shared/helpers/get-contrast-ratio'; +import { isAvo } from '~shared/helpers/is-avo'; import { tText } from '~shared/helpers/translation-functions'; import { AVO } from '~shared/types'; import { App } from '../../../../../scripts/translation.types'; @@ -169,11 +171,7 @@ export const GET_AVO_HERO_BACKGROUND_COLOR_OPTIONS: () => SelectOption[] yellowOption(), ]; -export const GET_DARK_BACKGROUND_COLOR_OPTIONS_AVO: () => ( - | Color - | GradientColor - | CustomBackground -)[] = () => [ +export const DARK_BACKGROUND_COLOR_OPTIONS_AVO: (Color | GradientColor | CustomBackground)[] = [ Color.SoftBlue, Color.NightBlue, Color.Teal, @@ -184,14 +182,41 @@ export const GET_DARK_BACKGROUND_COLOR_OPTIONS_AVO: () => ( Color.Black, ]; -// Backgrounds that need white text to pass WCAG AA on hetarchief.be, per the color -// combinations design provided on https://meemoo.atlassian.net/browse/ARC-3848. -// Every other archief background color passes AA with the default black text. -export const GET_DARK_BACKGROUND_COLOR_OPTIONS_ARCHIEF: () => ( - | Color - | GradientColor - | CustomBackground -)[] = () => [Color.Black, Color.OldPink]; +/** + * Whether text on this background color must be white to pass WCAG AA, following the rule meemoo + * set: black text on the color, unless white text on that color passes AA. + * https://meemoo.atlassian.net/browse/ARC-3848 + * + * Use this for every content block that renders text on an admin-picked color without offering a + * text color field of its own. + * + * The archief answer is computed rather than listed, so a new brand color is handled the moment it + * is added instead of silently defaulting to black - the drift between the palette and a + * hand-maintained list is what put unreadable white text on ocean green in the first place. The + * computation reproduces every background row of meemoo-hetarchief-kleurencombinaties.pdf, which + * get-color-options.test.ts asserts row by row. + * + * AVO keeps its literal list: its palette predates this rule and does not follow it (white on + * Color.Yellow is 1.2:1, nowhere near AA), so it must not be recomputed. + * + * Gradients, the meemoo logo pattern and Color.Transparent have no single luminance, so + * getContrastRatio returns null and they keep the default black text. + */ +export function hasDarkBackground( + color: Color | GradientColor | CustomBackground | string | undefined +): boolean { + if (!color) { + return false; + } + + if (isAvo()) { + return DARK_BACKGROUND_COLOR_OPTIONS_AVO.includes(color as Color); + } + + const whiteTextContrast = getContrastRatio(color, Color.White); + + return whiteTextContrast !== null && whiteTextContrast >= WCAG_AA_CONTRAST_NORMAL_TEXT; +} export const GET_FOREGROUND_COLOR_OPTIONS_AVO: () => SelectOption[] = () => [ { From 149e63416051f0b9c13fe5a68396499bd0ddf173 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Wed, 12 Aug 2026 22:21:31 +0200 Subject: [PATCH 05/11] fix(ARC-3848): set text color on blocks that pick their own color --- .../blocks/BlockHighlightText/BlockHighlightText.tsx | 11 ++++++++++- .../BlockOverviewThemesGroupSection.tsx | 9 ++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.tsx index 966db646..9be5233e 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.tsx @@ -3,6 +3,7 @@ import type { DefaultComponentProps } from '~modules/shared/types/components'; import './BlockHighligtText.scss'; import { Container } from '@viaa/avo2-components'; import clsx from 'clsx'; +import { hasDarkBackground } from '~modules/content-page/const/get-color-options.ts'; import { Color, ColorSelectGradientColors, @@ -29,6 +30,12 @@ export const BlockHighlightText: FunctionComponent = ({ highlightColor === CustomBackground.MeemooLogo ? Color.Transparent : ((ColorSelectGradientColors as Record)[highlightColor] ?? highlightColor); + // The text sits inside the highlighted box, so its WCAG text color follows the highlight color + // rather than the block background. A gradient highlight renders the box white and the meemoo + // logo renders it transparent (both below), and neither has a single luminance, so + // hasDarkBackground reports false for them and the text stays black - which is what those two + // backgrounds need. https://meemoo.atlassian.net/browse/ARC-3848 + const hasDarkHighlight = hasDarkBackground(highlightColor); return (
= ({ /> = ({ group, groupIndex, themes, bandColor }) => { const gridRef = useRef(null); const [bandHeight, setBandHeight] = useState(null); + const hasDarkBand = hasDarkBackground(bandColor); useLayoutEffect(() => { const gridEl = gridRef.current; @@ -134,7 +136,12 @@ export const BlockOverviewThemesGroupSection: FunctionComponent< {group.title && ( {group.title} From 23e183797d49b2f94be5bc8946f9d969efd2c13f Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Wed, 12 Aug 2026 22:21:32 +0200 Subject: [PATCH 06/11] docs(ARC-3848): update design spec for the computed contrast rule --- ...-08-12-arc-3848-wcag-text-colors-design.md | 94 ++++++++++++++----- 1 file changed, 73 insertions(+), 21 deletions(-) diff --git a/docs/superpowers/specs/2026-08-12-arc-3848-wcag-text-colors-design.md b/docs/superpowers/specs/2026-08-12-arc-3848-wcag-text-colors-design.md index 2399ff7f..f624ac52 100644 --- a/docs/superpowers/specs/2026-08-12-arc-3848-wcag-text-colors-design.md +++ b/docs/superpowers/specs/2026-08-12-arc-3848-wcag-text-colors-design.md @@ -27,22 +27,53 @@ change. ## Design -Split `GET_DARK_BACKGROUND_COLOR_OPTIONS` into: - -- `GET_DARK_BACKGROUND_COLOR_OPTIONS_AVO` — unchanged, current membership - (`SoftBlue, NightBlue, Teal, TealBright, OceanGreen, SeaGreen, Yellow, Black`). -- `GET_DARK_BACKGROUND_COLOR_OPTIONS_ARCHIEF` — `[Black, OldPink]`, per the PDF. - -Pick between them with the existing `isAvo()` helper -(`ui/src/react-admin/modules/shared/helpers/is-avo.ts`), mirroring the pattern already -used in `defaults.ts` for `BACKGROUND_COLOR_FIELD` / `FOREGROUND_COLOR_FIELD`. - -Update the two call sites: - -- `ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx` - (`hasDarkBg`) -- `ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.wrapper.tsx` - (`darkTabs`) +One predicate, `hasDarkBackground(color)`, replaces `GET_DARK_BACKGROUND_COLOR_OPTIONS`. It +picks per app with the existing `isAvo()` helper, so no call site repeats the switch. + +**Archief is computed, not listed.** The rule meemoo stated is mechanical, so +`getContrastRatio(color, white) >= 4.5` (a new +`ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.ts`) decides it. A +hand-maintained list is a second source of truth next to the palette, and the two drift — +that drift is exactly what put unreadable white text on ocean green (2.13:1). With the rule +computed, a colour added to the palette is handled the moment it is added. + +The computation reproduces **29 of the 30** background rows in +`meemoo-hetarchief-kleurencombinaties.pdf`. The PDF stays the authority: it lives in +`get-color-options.test.ts` as a fixture asserted row by row, rather than being hand-copied +into the source. + +**AVO keeps its literal list** (`DARK_BACKGROUND_COLOR_OPTIONS_AVO`, unchanged membership). +Its palette predates this rule and does not follow it — white on `Color.Yellow` is 1.2:1 — +so it must not be recomputed. + +Gradients, `CustomBackground.MeemooLogo` and `Color.Transparent` have no single luminance, +so `getContrastRatio` returns `null` and they keep black text. That is now an explicit +documented fallback rather than an accidental omission from a list. + +White text is applied with the existing `u-color-white` utility, the same mechanism +`ContentBlockRenderer` already uses, so no new per-block CSS was added. + +### Call sites + +The ticket scopes this to every content block that renders text on an admin-picked color +without offering a text color field of its own. Those are: + +- `ContentBlockRenderer.tsx` (`hasDarkBg`) — the generic block-level `backgroundColor`, + covers every block that uses the shared background field. +- `BlockPageOverview.wrapper.tsx` (`darkTabs`) — block-level `headerBackgroundColor`. +- `BlockHighlightText.tsx` — has its own `highlightColor` field in **component** state, so + `ContentBlockRenderer`'s `hasDarkBg` (which reads block state) never sees it. The text + sits inside the highlighted box, so the text color follows `highlightColor`, not the + block background. Gradients render that box white and the meemoo logo renders it + transparent, so both keep black text. +- `BlockOverviewThemesGroupSection.tsx` — the group title sits on the full-bleed band, + whose color comes from `GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF()[groupIndex]`. + Index 0 is `OldPink`, so the first group's title needs white. Only applied once the band + is measured; before that the title sits on the page background. + +`BlockHomepageBanner` also has a component-state color field (`bannerColor`), but it only +paints the decorative, `aria-hidden` pattern strips — its title and body text sit on the +page background — so it needs no text color rule. ## Out of scope @@ -53,10 +84,31 @@ Update the two call sites: - AVO-only background colors (`SoftBlue`, `NightBlue`, `Teal`, `TealBright`, `Yellow`, `Gray50`, etc.) — different brand book, out of scope for this ticket. +## Open questions + +- **The PDF prescribes white text on Zink #ADADAD, where white scores 2.24:1.** That fails + AA and fails even the 3:1 large-text threshold — the only row of the 30 that the stated + rule does not reproduce, so it looks like an error in the PDF. Zink is a foreground option + only, never a background, so nothing depends on it today. Needs a ruling from design. +- The ticket asks for a primary **and** a secondary text color per background; this + implements primary only. The PDF does list secondary colors (e.g. Zink/Teal on black), + so a follow-up may be needed. +- `SkyBlue` (#C3DDE6) and `LightBlue` (#BDDEE7) do not appear in the PDF at all (the + nearest entry is Baby blauw #8DDEE7). Computation puts black text on both, which is + clearly right, but they are unconfirmed by design. +- `ContentPageLabelChip` is currently hardcoded to white text (reverted in ARC-3818 pending + this color list). It is not a content block, so it stays out of this ticket, but it now + has the list it was waiting for — raised on ARC-3818. + ## Testing -Existing test setup uses vitest. No dedicated tests currently cover -`GET_DARK_BACKGROUND_COLOR_OPTIONS` or the two call sites; this change is small enough to -verify by reading the diff and, if time permits, a quick manual check in the ui demo app -(`npm run dev` in `ui/`) with a content block set to `OldPink`/`OceanGreen`/`SeaGreen` -backgrounds. +- `get-contrast-ratio.test.ts` — the WCAG formula against WebAIM reference values, shorthand + hex (`Color.Black` is `#000` and `Color.White` is `#FFF`), mixed casing (`Color.Lila` is + lowercase), and `null` for every non-hex value the pickers can hold. +- `get-color-options.test.ts` — all 30 PDF rows as a fixture, plus a test that walks every + option in `GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF()` and asserts a correct ruling for each, + so a colour added to the palette cannot silently miss out. AVO's list is asserted + unchanged. + +Beyond that, a manual check in the ui demo app (`npm run dev` in `ui/`) with a highlight +text block on `OldPink`/`SeaGreen` and a theme overview whose first group has a title. From 19e97227378c0d9ef59d2e6f798e71f270fb1c0e Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Wed, 12 Aug 2026 22:26:32 +0200 Subject: [PATCH 07/11] docs(ARC-3848): remove design spec --- ...-08-12-arc-3848-wcag-text-colors-design.md | 114 ------------------ 1 file changed, 114 deletions(-) delete mode 100644 docs/superpowers/specs/2026-08-12-arc-3848-wcag-text-colors-design.md diff --git a/docs/superpowers/specs/2026-08-12-arc-3848-wcag-text-colors-design.md b/docs/superpowers/specs/2026-08-12-arc-3848-wcag-text-colors-design.md deleted file mode 100644 index f624ac52..00000000 --- a/docs/superpowers/specs/2026-08-12-arc-3848-wcag-text-colors-design.md +++ /dev/null @@ -1,114 +0,0 @@ -# ARC-3848: WCAG text colors on content-block background colors - -## Problem - -ARC-3795 added 10 tertiary background colors to hetarchief.be content blocks. Content -blocks that don't expose a configurable text-color field derive their text color from a -single shared "dark background" list (`GET_DARK_BACKGROUND_COLOR_OPTIONS` in -`ui/src/react-admin/modules/content-page/const/get-color-options.ts`). Anything on the -list gets forced white text; everything else defaults to black. - -That list is shared between AVO and hetarchief (ARCHIEF), but the two products have -different brand palettes. Checked against the design team's authoritative -`meemoo-hetarchief-kleurencombinaties.pdf` (attached to ARC-3848), the ARCHIEF palette has -two mismatches: - -- `OceanGreen` (#00C8AA) and `SeaGreen` (#009690) are currently forced to white text, but - the PDF shows black passes AA on both. -- `OldPink` / "Oud roze" (#9B6072), one of the ARC-3795 tertiary colors, currently defaults - to black text (not on the list), but the PDF requires white there for AA — this is the - actual gap the ticket exists to close. - -Every other ARCHIEF background color (White, Platinum, SkyBlue, and the other 9 tertiary -colors) already defaults correctly to black per the PDF. - -AVO's own use of `OceanGreen` etc. is governed by a different brand book and must not -change. - -## Design - -One predicate, `hasDarkBackground(color)`, replaces `GET_DARK_BACKGROUND_COLOR_OPTIONS`. It -picks per app with the existing `isAvo()` helper, so no call site repeats the switch. - -**Archief is computed, not listed.** The rule meemoo stated is mechanical, so -`getContrastRatio(color, white) >= 4.5` (a new -`ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.ts`) decides it. A -hand-maintained list is a second source of truth next to the palette, and the two drift — -that drift is exactly what put unreadable white text on ocean green (2.13:1). With the rule -computed, a colour added to the palette is handled the moment it is added. - -The computation reproduces **29 of the 30** background rows in -`meemoo-hetarchief-kleurencombinaties.pdf`. The PDF stays the authority: it lives in -`get-color-options.test.ts` as a fixture asserted row by row, rather than being hand-copied -into the source. - -**AVO keeps its literal list** (`DARK_BACKGROUND_COLOR_OPTIONS_AVO`, unchanged membership). -Its palette predates this rule and does not follow it — white on `Color.Yellow` is 1.2:1 — -so it must not be recomputed. - -Gradients, `CustomBackground.MeemooLogo` and `Color.Transparent` have no single luminance, -so `getContrastRatio` returns `null` and they keep black text. That is now an explicit -documented fallback rather than an accidental omission from a list. - -White text is applied with the existing `u-color-white` utility, the same mechanism -`ContentBlockRenderer` already uses, so no new per-block CSS was added. - -### Call sites - -The ticket scopes this to every content block that renders text on an admin-picked color -without offering a text color field of its own. Those are: - -- `ContentBlockRenderer.tsx` (`hasDarkBg`) — the generic block-level `backgroundColor`, - covers every block that uses the shared background field. -- `BlockPageOverview.wrapper.tsx` (`darkTabs`) — block-level `headerBackgroundColor`. -- `BlockHighlightText.tsx` — has its own `highlightColor` field in **component** state, so - `ContentBlockRenderer`'s `hasDarkBg` (which reads block state) never sees it. The text - sits inside the highlighted box, so the text color follows `highlightColor`, not the - block background. Gradients render that box white and the meemoo logo renders it - transparent, so both keep black text. -- `BlockOverviewThemesGroupSection.tsx` — the group title sits on the full-bleed band, - whose color comes from `GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF()[groupIndex]`. - Index 0 is `OldPink`, so the first group's title needs white. Only applied once the band - is measured; before that the title sits on the page background. - -`BlockHomepageBanner` also has a component-state color field (`bannerColor`), but it only -paints the decorative, `aria-hidden` pattern strips — its title and body text sit on the -page background — so it needs no text color rule. - -## Out of scope - -- The meemoo-logo background (`CustomBackground.MeemooLogo`) — not a flat color, not - covered by the PDF, left unchanged. -- The black↔white gradient background (`GradientColor.BlackWhite`) — fades top-to-bottom, - no single correct text color, not covered by the PDF, left unchanged. -- AVO-only background colors (`SoftBlue`, `NightBlue`, `Teal`, `TealBright`, `Yellow`, - `Gray50`, etc.) — different brand book, out of scope for this ticket. - -## Open questions - -- **The PDF prescribes white text on Zink #ADADAD, where white scores 2.24:1.** That fails - AA and fails even the 3:1 large-text threshold — the only row of the 30 that the stated - rule does not reproduce, so it looks like an error in the PDF. Zink is a foreground option - only, never a background, so nothing depends on it today. Needs a ruling from design. -- The ticket asks for a primary **and** a secondary text color per background; this - implements primary only. The PDF does list secondary colors (e.g. Zink/Teal on black), - so a follow-up may be needed. -- `SkyBlue` (#C3DDE6) and `LightBlue` (#BDDEE7) do not appear in the PDF at all (the - nearest entry is Baby blauw #8DDEE7). Computation puts black text on both, which is - clearly right, but they are unconfirmed by design. -- `ContentPageLabelChip` is currently hardcoded to white text (reverted in ARC-3818 pending - this color list). It is not a content block, so it stays out of this ticket, but it now - has the list it was waiting for — raised on ARC-3818. - -## Testing - -- `get-contrast-ratio.test.ts` — the WCAG formula against WebAIM reference values, shorthand - hex (`Color.Black` is `#000` and `Color.White` is `#FFF`), mixed casing (`Color.Lila` is - lowercase), and `null` for every non-hex value the pickers can hold. -- `get-color-options.test.ts` — all 30 PDF rows as a fixture, plus a test that walks every - option in `GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF()` and asserts a correct ruling for each, - so a colour added to the palette cannot silently miss out. AVO's list is asserted - unchanged. - -Beyond that, a manual check in the ui demo app (`npm run dev` in `ui/`) with a highlight -text block on `OldPink`/`SeaGreen` and a theme overview whose first group has a title. From a73c44d77ae1bbac7f5821257329f64c5d5cdb65 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Thu, 13 Aug 2026 12:58:09 +0200 Subject: [PATCH 08/11] chore(ARC-3848): checkpoint WCAG text color work --- .../ContentBlockRenderer.tsx | 21 ++- .../BlockHighlightText/BlockHighlightText.tsx | 23 +-- .../BlockOverviewThemesGroupSection.tsx | 19 ++- .../const/background-text-colors.test.ts | 109 +++++++++++++ .../const/background-text-colors.ts | 142 +++++++++++++++++ .../const/get-color-options.test.ts | 146 ------------------ .../content-page/const/get-color-options.ts | 28 +--- .../shared/helpers/get-contrast-ratio.test.ts | 58 ------- .../shared/helpers/get-contrast-ratio.ts | 62 -------- .../shared/styles/utilities/_color.scss | 25 +++ 10 files changed, 326 insertions(+), 307 deletions(-) create mode 100644 ui/src/react-admin/modules/content-page/const/background-text-colors.test.ts create mode 100644 ui/src/react-admin/modules/content-page/const/background-text-colors.ts delete mode 100644 ui/src/react-admin/modules/content-page/const/get-color-options.test.ts delete mode 100644 ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.test.ts delete mode 100644 ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.ts diff --git a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx index 021a7916..13e97c21 100644 --- a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx +++ b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx @@ -2,7 +2,7 @@ import { Container, Spacer } from '@viaa/avo2-components'; import clsx from 'clsx'; import { kebabCase, noop, omit } from 'es-toolkit'; -import type { FunctionComponent, KeyboardEvent, RefObject } from 'react'; +import type { CSSProperties, FunctionComponent, KeyboardEvent, RefObject } from 'react'; import React, { useCallback, useEffect, useRef } from 'react'; import { AdminConfigManager } from '~core/config/config.class'; import { getCommonUser } from '~core/config/config.selectors.ts'; @@ -10,6 +10,10 @@ import { GENERATED_CONTENT_BLOCK_ANCHOR_PREFIX } from '~modules/content-page/con import type { ContentPageInfo } from '~modules/content-page/types/content-pages.types'; import { ContentPageWidth } from '~modules/content-page/types/content-pages.types'; import { generateSmartLink } from '~shared/components/SmartLink/SmartLink'; +import { + getBackgroundTextColors, + getBackgroundTextColorVariables, +} from '../../const/background-text-colors'; import { hasDarkBackground } from '../../const/get-color-options'; import { Color, @@ -123,6 +127,13 @@ const ContentBlockRenderer: FunctionComponent = ({ } const hasDarkBg = hasDarkBackground(blockState?.backgroundColor); + // The WCAG text colors design specified for this background, published as css variables so any + // text inside the block can pick the role it plays with u-text-primary / u-text-secondary / + // u-text-hyperlink. https://meemoo.atlassian.net/browse/ARC-3848 + const backgroundTextColors = getBackgroundTextColors(blockState?.backgroundColor); + const textColorVariables = getBackgroundTextColorVariables( + blockState?.backgroundColor + ) as CSSProperties; const anchor = blockState?.anchor?.replaceAll(' ', '-') || GENERATED_CONTENT_BLOCK_ANCHOR_PREFIX + contentBlockConfig.id; @@ -144,6 +155,7 @@ const ContentBlockRenderer: FunctionComponent = ({ ? Color.Transparent : blockState?.backgroundColor, ...(blockState?.headerBackgroundColor !== Color.Transparent ? { zIndex: 1 } : {}), + ...textColorVariables, }} data-anchor={anchor} ref={blockRef} @@ -159,11 +171,14 @@ const ContentBlockRenderer: FunctionComponent = ({ * to avoid overlapping a fixed header when we jump to this anchor * https://meemoo.atlassian.net/browse/AVO-3351 */} -
+
= ({ highlightColor === CustomBackground.MeemooLogo ? Color.Transparent : ((ColorSelectGradientColors as Record)[highlightColor] ?? highlightColor); - // The text sits inside the highlighted box, so its WCAG text color follows the highlight color - // rather than the block background. A gradient highlight renders the box white and the meemoo - // logo renders it transparent (both below), and neither has a single luminance, so - // hasDarkBackground reports false for them and the text stays black - which is what those two - // backgrounds need. https://meemoo.atlassian.net/browse/ARC-3848 - const hasDarkHighlight = hasDarkBackground(highlightColor); + // The text sits inside the highlighted box, so its WCAG text colors follow the box background + // rather than the block background: the highlight color, except for a gradient, which renders the + // box white (see --pattern-color below). The meemoo logo renders it transparent, which design + // specified no colors for, so that keeps the inherited text color. + // https://meemoo.atlassian.net/browse/ARC-3848 + const textBoxBackground = isGradient ? Color.White : patternColor; + const textColorVariables = getBackgroundTextColorVariables(textBoxBackground); + const hasTextColors = Object.keys(textColorVariables).length > 0; return (
= ({
+ />
= ({ group, groupIndex, themes, bandColor }) => { const gridRef = useRef(null); const [bandHeight, setBandHeight] = useState(null); - const hasDarkBand = hasDarkBackground(bandColor); + // The group title sits on the band, so it takes the design text colors for the band color. + // https://meemoo.atlassian.net/browse/ARC-3848 + const bandTextColorVariables = getBackgroundTextColorVariables(bandColor); + const hasBandTextColors = Object.keys(bandTextColorVariables).length > 0; useLayoutEffect(() => { const gridEl = gridRef.current; @@ -123,7 +126,10 @@ export const BlockOverviewThemesGroupSection: FunctionComponent< }; return ( -
+
{!!bandHeight && ( <>
{group.title} diff --git a/ui/src/react-admin/modules/content-page/const/background-text-colors.test.ts b/ui/src/react-admin/modules/content-page/const/background-text-colors.test.ts new file mode 100644 index 00000000..b67d8ee5 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/const/background-text-colors.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { Color, CustomBackground, GradientColor } from '../types/content-block.types'; +import { + BACKGROUND_TEXT_COLORS, + getBackgroundTextColors, + getBackgroundTextColorVariables, +} from './background-text-colors'; + +/** + * Every background row of meemoo-hetarchief-kleurencombinaties.pdf as + * [name, background, primary, secondary, hyperlink], so the record can be checked against the + * design document row by row. https://meemoo.atlassian.net/browse/ARC-3848 + */ +const KLEURENCOMBINATIES_PDF: [string, string, string, string?, string?][] = [ + // Merk + ['Zwart', '#000000', '#FFFFFF', '#ADADAD', '#00C8AA'], + ['Wit', '#FFFFFF', '#000000', '#757575', '#00857D'], + ['Teal', '#00C8AA', '#000000'], + // Functioneel + ['Grafiet', '#222222', '#FFFFFF', '#ADADAD', '#00C8AA'], + ['Inkt', '#303030', '#FFFFFF', '#ADADAD', '#00C8AA'], + ['Schaduw', '#505050', '#FFFFFF'], + ['Leisteen', '#666666', '#FFFFFF'], + ['Neutraal', '#757575', '#FFFFFF'], + ['Zink', '#ADADAD', '#FFFFFF'], + ['Zilver', '#E6E6E6', '#000000', '#666666', '#005F69'], + ['Platinum', '#F8F8F8', '#000000', '#666666', '#005F69'], + ['Kers', '#D60039', '#FFFFFF'], + ['Jade', '#00857D', '#FFFFFF', '#000000'], + ['Lagune', '#005F69', '#FFFFFF'], + // Secundair + ['Zeegroen', '#009690', '#000000'], + ['Grasgroen', '#82E678', '#000000'], + ['Azuur', '#28A0C8', '#000000'], + // Tertiair + ['Lila', '#C6C2E0', '#000000'], + ['Mosterd', '#EFCA6A', '#000000'], + ['Koraal', '#E89B88', '#000000'], + ['Baby blauw', '#8DDEE7', '#000000', undefined, '#005F69'], + ['Blush', '#E694B3', '#000000'], + ['Donker lila', '#A293AF', '#000000'], + ['Mist', '#91A9A7', '#000000'], + ['Sepia', '#EDD6C4', '#000000'], + ['Mauve', '#9B6072', '#FFFFFF'], + ['Salie', '#B8BE9A', '#000000'], + ['Terra', '#D1543A', '#000000'], + ['Olijf', '#64702B', '#FFFFFF'], + ['Viool', '#432457', '#FFFFFF'], +]; + +describe('getBackgroundTextColors()', () => { + it.each(KLEURENCOMBINATIES_PDF)( + 'matches the design for %s', + (_name, background, primary, secondary, hyperlink) => { + expect(getBackgroundTextColors(background)).toEqual({ + primary, + ...(secondary ? { secondary } : {}), + ...(hyperlink ? { hyperlink } : {}), + }); + } + ); + + it('holds every row of the design document and no extras', () => { + expect(Object.keys(BACKGROUND_TEXT_COLORS)).toHaveLength(KLEURENCOMBINATIES_PDF.length); + }); + + // Color.Black is '#000' and Color.White is '#FFF', and Color.Lila is lowercase, so lookups have + // to normalise rather than match the enum value verbatim. + it('accepts the shorthand and mixed casing the Color enum uses', () => { + expect(getBackgroundTextColors(Color.Black)?.primary).toBe('#FFFFFF'); + expect(getBackgroundTextColors(Color.White)?.primary).toBe('#000000'); + expect(getBackgroundTextColors(Color.Lila)?.primary).toBe('#000000'); + expect(getBackgroundTextColors(Color.OldPink)?.primary).toBe('#FFFFFF'); + }); + + // Design specified nothing for these, so blocks keep whatever they inherit. + it.each([ + ['transparent', Color.Transparent], + ['a gradient', GradientColor.BlackWhite], + ['the meemoo logo pattern', CustomBackground.MeemooLogo], + ['an AVO-only color', Color.SoftBlue], + ['no background', undefined], + ['an empty background', ''], + ])('has no colors for %s', (_name, background) => { + expect(getBackgroundTextColors(background)).toBeUndefined(); + }); +}); + +describe('getBackgroundTextColorVariables()', () => { + it('exposes all three roles when design specified all three', () => { + expect(getBackgroundTextColorVariables(Color.Black)).toEqual({ + '--bg-text-primary': '#FFFFFF', + '--bg-text-secondary': '#ADADAD', + '--bg-text-hyperlink': '#00C8AA', + }); + }); + + // Leaving them unset is what makes the utility classes fall back to the primary color. + it('omits the roles design did not specify', () => { + expect(getBackgroundTextColorVariables(Color.OldPink)).toEqual({ + '--bg-text-primary': '#FFFFFF', + }); + }); + + it('returns nothing for a background design specified no colors for', () => { + expect(getBackgroundTextColorVariables(GradientColor.BlackWhite)).toEqual({}); + }); +}); diff --git a/ui/src/react-admin/modules/content-page/const/background-text-colors.ts b/ui/src/react-admin/modules/content-page/const/background-text-colors.ts new file mode 100644 index 00000000..c7f264c0 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/const/background-text-colors.ts @@ -0,0 +1,142 @@ +/** + * The WCAG text colors per background color, exactly as delivered by design in + * meemoo-hetarchief-kleurencombinaties.pdf (attached to + * https://meemoo.atlassian.net/browse/ARC-3848). + * + * Every content block that renders text on an admin-picked background color, and does not offer a + * text color field of its own, takes its text colors from here. + * + * The PDF is the authority: do not derive these values, and do not "fix" a row that looks off - + * raise it with design instead. The columns map to `primary` (body text), `secondary` (muted text + * such as captions, subtitles and metadata) and `hyperlink` (the underlined link color). + */ + +/** Named colors from the PDF, so the rows below read like the design document. */ +const WIT = '#FFFFFF'; +const ZWART = '#000000'; +const ZINK = '#ADADAD'; +const TEAL = '#00C8AA'; +const NEUTRAAL = '#757575'; +const JADE = '#00857D'; +const LEISTEEN = '#666666'; +const LAGUNE = '#005F69'; + +/** + * White as the PDF writes it. Color.White is the shorthand '#FFF', so compare against this when + * checking whether a background got light text. + */ +export const TEXT_COLOR_WHITE = WIT; + +export interface BackgroundTextColors { + /** Body text. Always present. */ + primary: string; + /** Muted text: captions, subtitles, metadata. Absent when design specified no second color. */ + secondary?: string; + /** Underlined link text. Absent when design specified no link color. */ + hyperlink?: string; +} + +/** + * Keyed by background color, normalised to lowercase 6-digit hex - the Color enum mixes casing and + * shorthand (Color.Black is '#000', Color.Lila is '#c6c2e0'), so always look up through + * getBackgroundTextColors rather than indexing this directly. + */ +export const BACKGROUND_TEXT_COLORS: Record = { + // Merk + '#000000': { primary: WIT, secondary: ZINK, hyperlink: TEAL }, // Zwart + '#ffffff': { primary: ZWART, secondary: NEUTRAAL, hyperlink: JADE }, // Wit + '#00c8aa': { primary: ZWART }, // Teal + + // Functioneel + '#222222': { primary: WIT, secondary: ZINK, hyperlink: TEAL }, // Grafiet + '#303030': { primary: WIT, secondary: ZINK, hyperlink: TEAL }, // Inkt + '#505050': { primary: WIT }, // Schaduw + '#666666': { primary: WIT }, // Leisteen + '#757575': { primary: WIT }, // Neutraal + '#adadad': { primary: WIT }, // Zink - see the open question in the ticket, white is 2.24:1 here + '#e6e6e6': { primary: ZWART, secondary: LEISTEEN, hyperlink: LAGUNE }, // Zilver + '#f8f8f8': { primary: ZWART, secondary: LEISTEEN, hyperlink: LAGUNE }, // Platinum + '#d60039': { primary: WIT }, // Kers + // Jade lists a second color (Zwart) that is not underlined in the PDF, so it reads as secondary + // rather than a link color. Confirm with design. + '#00857d': { primary: WIT, secondary: ZWART }, // Jade + '#005f69': { primary: WIT }, // Lagune + + // Secundair + '#009690': { primary: ZWART }, // Zeegroen + '#82e678': { primary: ZWART }, // Grasgroen + '#28a0c8': { primary: ZWART }, // Azuur + + // Tertiair + '#c6c2e0': { primary: ZWART }, // Lila + '#efca6a': { primary: ZWART }, // Mosterd + '#e89b88': { primary: ZWART }, // Koraal + // Baby blauw's second color IS underlined in the PDF, so it is the link color, not secondary. + '#8ddee7': { primary: ZWART, hyperlink: LAGUNE }, // Baby blauw + '#e694b3': { primary: ZWART }, // Blush + '#a293af': { primary: ZWART }, // Donker lila + '#91a9a7': { primary: ZWART }, // Mist + '#edd6c4': { primary: ZWART }, // Sepia + '#9b6072': { primary: WIT }, // Mauve / "oud roze" + '#b8be9a': { primary: ZWART }, // Salie + '#d1543a': { primary: ZWART }, // Terra + '#64702b': { primary: WIT }, // Olijf + '#432457': { primary: WIT }, // Viool +}; + +/** + * Normalises a background color to the key format used above: lowercase 6-digit hex. + * Returns null for anything that is not a plain hex color, which includes Color.Transparent + * ('TRANSPARENT'), CustomBackground.MeemooLogo ('') and the GradientColor values. + */ +function normaliseHex(color: string): string | null { + const hex = color.trim().toLowerCase().replace(/^#/, ''); + + if (!/^([\da-f]{3}|[\da-f]{6})$/.test(hex)) { + return null; + } + + const expanded = + hex.length === 3 ? Array.from(hex, (channel) => channel + channel).join('') : hex; + + return `#${expanded}`; +} + +/** + * The WCAG text colors design specified for this background color, or undefined when the background + * is not a flat color from the palette (transparent, a gradient, the meemoo logo pattern) or is an + * AVO-only color, which follows its own brand book. + */ +export function getBackgroundTextColors( + color: string | undefined +): BackgroundTextColors | undefined { + if (!color) { + return undefined; + } + + const key = normaliseHex(color); + + return key ? BACKGROUND_TEXT_COLORS[key] : undefined; +} + +/** + * The design text colors for this background as css variables, to spread into a style prop. The + * u-text-primary / u-text-secondary / u-text-hyperlink classes read these, so any element inside + * can say which role its text plays instead of hardcoding a color. + * + * Returns an empty object when design specified nothing for this background, which leaves the + * variables unset and the utility classes falling back to `inherit`. + */ +export function getBackgroundTextColorVariables(color: string | undefined): Record { + const textColors = getBackgroundTextColors(color); + + if (!textColors) { + return {}; + } + + return { + '--bg-text-primary': textColors.primary, + ...(textColors.secondary ? { '--bg-text-secondary': textColors.secondary } : {}), + ...(textColors.hyperlink ? { '--bg-text-hyperlink': textColors.hyperlink } : {}), + }; +} diff --git a/ui/src/react-admin/modules/content-page/const/get-color-options.test.ts b/ui/src/react-admin/modules/content-page/const/get-color-options.test.ts deleted file mode 100644 index af93fb12..00000000 --- a/ui/src/react-admin/modules/content-page/const/get-color-options.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { Color, CustomBackground, GradientColor } from '../types/content-block.types'; -import { GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF, hasDarkBackground } from './get-color-options'; - -const isAvoMock = vi.hoisted(() => vi.fn<() => boolean>()); - -vi.mock('~shared/helpers/is-avo', () => ({ isAvo: isAvoMock })); - -// The option labels are translated, which needs an initialised AdminConfigManager. These tests are -// about colors, not labels, so the key stands in for the label. -vi.mock('~shared/helpers/translation-functions', () => ({ tText: (key: string) => key })); - -/** - * Every background row of meemoo-hetarchief-kleurencombinaties.pdf, the design meemoo delivered on - * https://meemoo.atlassian.net/browse/ARC-3848, as [name, background, prescribed text color]. - * - * This is the fixture the computed rule is asserted against, so the PDF stays the authority without - * anyone hand-maintaining a second list of colors in the source. - * - * One row is deliberately absent: Zink #ADADAD, which the PDF prescribes white text on even though - * white scores 2.24:1 there - below AA, and below even the 3:1 large-text threshold. It looks like - * an error in the PDF. It is not selectable as a background (foreground option only), so it changes - * nothing today; raised with design rather than encoded here. - */ -const KLEURENCOMBINATIES_PDF: [string, string, 'wit' | 'zwart'][] = [ - // Merk - ['Zwart', '#000000', 'wit'], - ['Wit', '#FFFFFF', 'zwart'], - ['Teal', '#00C8AA', 'zwart'], - // Functioneel - ['Grafiet', '#222222', 'wit'], - ['Inkt', '#303030', 'wit'], - ['Schaduw', '#505050', 'wit'], - ['Leisteen', '#666666', 'wit'], - ['Neutraal', '#757575', 'wit'], - ['Zilver', '#E6E6E6', 'zwart'], - ['Platinum', '#F8F8F8', 'zwart'], - ['Kers', '#D60039', 'wit'], - ['Jade', '#00857D', 'wit'], - ['Lagune', '#005F69', 'wit'], - // Secundair - ['Zeegroen', '#009690', 'zwart'], - ['Grasgroen', '#82E678', 'zwart'], - ['Azuur', '#28A0C8', 'zwart'], - // Tertiair - ['Lila', '#C6C2E0', 'zwart'], - ['Mosterd', '#EFCA6A', 'zwart'], - ['Koraal', '#E89B88', 'zwart'], - ['Baby blauw', '#8DDEE7', 'zwart'], - ['Blush', '#E694B3', 'zwart'], - ['Donker lila', '#A293AF', 'zwart'], - ['Mist', '#91A9A7', 'zwart'], - ['Sepia', '#EDD6C4', 'zwart'], - ['Mauve', '#9B6072', 'wit'], - ['Salie', '#B8BE9A', 'zwart'], - ['Terra', '#D1543A', 'zwart'], - ['Olijf', '#64702B', 'wit'], - ['Viool', '#432457', 'wit'], -]; - -describe('hasDarkBackground()', () => { - describe('on hetarchief', () => { - beforeEach(() => { - isAvoMock.mockReturnValue(false); - }); - - it.each(KLEURENCOMBINATIES_PDF)( - 'puts %s text on %s (%s), like the design', - (_name, background, textColor) => { - expect(hasDarkBackground(background)).toBe(textColor === 'wit'); - } - ); - - // Guards the two colors the old shared AVO list got backwards: white text scored 2.13:1 on - // ocean green and 3.64:1 on sea green, both unreadable. - it.each([Color.OceanGreen, Color.SeaGreen])('keeps black text on %s', (color) => { - expect(hasDarkBackground(color)).toBe(false); - }); - - it('puts white text on old pink, the tertiary color that needs it', () => { - expect(hasDarkBackground(Color.OldPink)).toBe(true); - }); - - // The point of computing instead of listing: every pickable background gets an answer, so a - // color added to the palette can never silently miss out on a text color ruling. - it('rules on every background the admin can pick', () => { - const undecidable: (Color | GradientColor | CustomBackground)[] = [ - Color.Transparent, - GradientColor.BlackWhite, - CustomBackground.MeemooLogo, - ]; - - // Note Color.Black is '#000' and Color.White is '#FFF', so shorthand hex has to work. - const needsWhiteText: string[] = [Color.Black, Color.OldPink]; - - const flatColors = GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF() - .map((option) => option.value) - .filter((value) => !undecidable.includes(value)); - - expect(flatColors.length).toBeGreaterThan(0); - - for (const color of flatColors) { - expect(hasDarkBackground(color), `wrong ruling for ${color}`).toBe( - needsWhiteText.includes(color) - ); - } - }); - }); - - describe('on avo', () => { - beforeEach(() => { - isAvoMock.mockReturnValue(true); - }); - - // AVO's palette predates the rule and does not follow it, so it stays a literal list and must - // not be recomputed - white on Color.Yellow is 1.2:1, but the AVO brand book asks for it. - it.each([Color.OceanGreen, Color.SeaGreen, Color.Yellow, Color.Black])( - 'keeps the legacy white text on %s', - (color) => { - expect(hasDarkBackground(color)).toBe(true); - } - ); - - it('keeps black text on white', () => { - expect(hasDarkBackground(Color.White)).toBe(false); - }); - }); - - describe('backgrounds without a single luminance', () => { - beforeEach(() => { - isAvoMock.mockReturnValue(false); - }); - - // None of these can be reduced to one contrast ratio, so they keep the default black text. - it.each([ - ['a gradient', GradientColor.BlackWhite], - ['the meemoo logo pattern', CustomBackground.MeemooLogo], - ['transparent', Color.Transparent], - ['no background at all', undefined], - ['an empty background', ''], - ])('keeps black text on %s', (_name, color) => { - expect(hasDarkBackground(color)).toBe(false); - }); - }); -}); diff --git a/ui/src/react-admin/modules/content-page/const/get-color-options.ts b/ui/src/react-admin/modules/content-page/const/get-color-options.ts index 6f304953..ae280ec5 100644 --- a/ui/src/react-admin/modules/content-page/const/get-color-options.ts +++ b/ui/src/react-admin/modules/content-page/const/get-color-options.ts @@ -1,10 +1,10 @@ import type { SelectOption } from '@viaa/avo2-components'; -import { getContrastRatio, WCAG_AA_CONTRAST_NORMAL_TEXT } from '~shared/helpers/get-contrast-ratio'; import { isAvo } from '~shared/helpers/is-avo'; import { tText } from '~shared/helpers/translation-functions'; import { AVO } from '~shared/types'; import { App } from '../../../../../scripts/translation.types'; import { Color, CustomBackground, GradientColor } from '../types/content-block.types'; +import { getBackgroundTextColors, TEXT_COLOR_WHITE } from './background-text-colors'; const transparentOption = () => ({ label: tText('admin/content-block/content-block___geen'), @@ -183,24 +183,12 @@ export const DARK_BACKGROUND_COLOR_OPTIONS_AVO: (Color | GradientColor | CustomB ]; /** - * Whether text on this background color must be white to pass WCAG AA, following the rule meemoo - * set: black text on the color, unless white text on that color passes AA. - * https://meemoo.atlassian.net/browse/ARC-3848 + * Whether this background needs light text, so blocks can pick a dark-background variant of their + * styling. On archief the answer comes from the design record in background-text-colors.ts; AVO + * keeps its own list, since its palette follows a different brand book. * - * Use this for every content block that renders text on an admin-picked color without offering a - * text color field of its own. - * - * The archief answer is computed rather than listed, so a new brand color is handled the moment it - * is added instead of silently defaulting to black - the drift between the palette and a - * hand-maintained list is what put unreadable white text on ocean green in the first place. The - * computation reproduces every background row of meemoo-hetarchief-kleurencombinaties.pdf, which - * get-color-options.test.ts asserts row by row. - * - * AVO keeps its literal list: its palette predates this rule and does not follow it (white on - * Color.Yellow is 1.2:1, nowhere near AA), so it must not be recomputed. - * - * Gradients, the meemoo logo pattern and Color.Transparent have no single luminance, so - * getContrastRatio returns null and they keep the default black text. + * Prefer getBackgroundTextColors() where you need the actual colors - this only answers "is it a + * dark background", not "which color is the text". https://meemoo.atlassian.net/browse/ARC-3848 */ export function hasDarkBackground( color: Color | GradientColor | CustomBackground | string | undefined @@ -213,9 +201,7 @@ export function hasDarkBackground( return DARK_BACKGROUND_COLOR_OPTIONS_AVO.includes(color as Color); } - const whiteTextContrast = getContrastRatio(color, Color.White); - - return whiteTextContrast !== null && whiteTextContrast >= WCAG_AA_CONTRAST_NORMAL_TEXT; + return getBackgroundTextColors(color)?.primary === TEXT_COLOR_WHITE; } export const GET_FOREGROUND_COLOR_OPTIONS_AVO: () => SelectOption[] = () => [ diff --git a/ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.test.ts b/ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.test.ts deleted file mode 100644 index 537646a1..00000000 --- a/ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { getContrastRatio, WCAG_AA_CONTRAST_NORMAL_TEXT } from './get-contrast-ratio'; - -describe('getContrastRatio()', () => { - it('returns the maximum ratio for black on white', () => { - expect(getContrastRatio('#000000', '#FFFFFF')).toBeCloseTo(21, 5); - }); - - it('returns 1 for a color against itself', () => { - expect(getContrastRatio('#9B6072', '#9B6072')).toBeCloseTo(1, 5); - }); - - it('is symmetric, so argument order does not matter', () => { - expect(getContrastRatio('#9B6072', '#FFFFFF')).toEqual(getContrastRatio('#FFFFFF', '#9B6072')); - }); - - // Reference values from the WebAIM contrast checker. - it.each([ - ['#9B6072', 4.87], - ['#00C8AA', 2.13], - ['#009690', 3.64], - ['#64702B', 5.39], - ['#757575', 4.61], - ])('scores white text on %s at %f:1', (background, expected) => { - expect(getContrastRatio(background, '#FFFFFF')).toBeCloseTo(expected, 2); - }); - - it('accepts shorthand hex', () => { - expect(getContrastRatio('#fff', '#FFFFFF')).toBeCloseTo(1, 5); - expect(getContrastRatio('#000', '#ffffff')).toBeCloseTo(21, 5); - }); - - it('is case insensitive, since the palette mixes casing', () => { - expect(getContrastRatio('#c6c2e0', '#FFFFFF')).toEqual(getContrastRatio('#C6C2E0', '#FFFFFF')); - }); - - it('works without the leading hash', () => { - expect(getContrastRatio('000000', 'FFFFFF')).toBeCloseTo(21, 5); - }); - - // The color pickers can hold these, and none of them has a single luminance to compare against. - it.each([ - ['a gradient', 'linear-gradient(to top, #fff 0%, #000 100%)'], - ['the meemoo logo placeholder', ''], - ['the transparent keyword', 'TRANSPARENT'], - ['a css color name', 'rebeccapurple'], - ['an rgb() value', 'rgb(155, 96, 114)'], - ['a malformed hex', '#12345'], - ['an empty string', ''], - ])('returns null for %s', (_name, color) => { - expect(getContrastRatio(color, '#FFFFFF')).toBeNull(); - }); - - it('exposes the AA threshold for normal text', () => { - expect(WCAG_AA_CONTRAST_NORMAL_TEXT).toBe(4.5); - }); -}); diff --git a/ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.ts b/ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.ts deleted file mode 100644 index 892b3bf1..00000000 --- a/ui/src/react-admin/modules/shared/helpers/get-contrast-ratio.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * WCAG 2.1 contrast ratio between two colors, used to decide readable text colors on - * admin-picked background colors. https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio - */ - -/** WCAG 2.1 AA minimum contrast for normal-sized text (1.4.3). */ -export const WCAG_AA_CONTRAST_NORMAL_TEXT = 4.5; - -/** WCAG 2.1 AA minimum contrast for large text: >= 24px, or >= 18.66px bold (1.4.3). */ -export const WCAG_AA_CONTRAST_LARGE_TEXT = 3; - -/** - * Parses #RGB and #RRGGBB into 0-255 channels. Returns null for anything else, which includes the - * non-color values the color pickers can hold: Color.Transparent ("TRANSPARENT"), - * CustomBackground.MeemooLogo ("") and GradientColor values ("linear-gradient(...)"). - */ -function parseHexColor(color: string): [number, number, number] | null { - const hex = color.trim().replace(/^#/, ''); - - if (!/^([\da-f]{3}|[\da-f]{6})$/i.test(hex)) { - return null; - } - - const pairs = - hex.length === 3 - ? Array.from(hex, (channel) => channel + channel) - : (hex.match(/.{2}/g) as string[]); - - return pairs.map((pair) => Number.parseInt(pair, 16)) as [number, number, number]; -} - -/** Relative luminance per WCAG 2.1. https://www.w3.org/TR/WCAG21/#dfn-relative-luminance */ -function getRelativeLuminance([red, green, blue]: [number, number, number]): number { - const [r, g, b] = [red, green, blue].map((channel) => { - const srgb = channel / 255; - - return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; - }); - - return 0.2126 * r + 0.7152 * g + 0.0722 * b; -} - -/** - * Contrast ratio between two colors, from 1 (identical) to 21 (black on white). - * Returns null when either color is not a plain hex color, since a gradient or a pattern has no - * single luminance to compare against - the caller decides what to do with that. - */ -export function getContrastRatio(colorA: string, colorB: string): number | null { - const rgbA = parseHexColor(colorA); - const rgbB = parseHexColor(colorB); - - if (!rgbA || !rgbB) { - return null; - } - - const luminanceA = getRelativeLuminance(rgbA); - const luminanceB = getRelativeLuminance(rgbB); - const lighter = Math.max(luminanceA, luminanceB); - const darker = Math.min(luminanceA, luminanceB); - - return (lighter + 0.05) / (darker + 0.05); -} diff --git a/ui/src/react-admin/modules/shared/styles/utilities/_color.scss b/ui/src/react-admin/modules/shared/styles/utilities/_color.scss index 93399a2e..149189bc 100644 --- a/ui/src/react-admin/modules/shared/styles/utilities/_color.scss +++ b/ui/src/react-admin/modules/shared/styles/utilities/_color.scss @@ -11,3 +11,28 @@ .u-color-gray-150 { color: colors.$color-gray-150 !important; } + +/* -------------------------------------------------------------------------- + WCAG text colors on a content block's background color + + The block sets --bg-text-primary / --bg-text-secondary / --bg-text-hyperlink from the design + record in background-text-colors.ts (see ContentBlockRenderer). These classes let a block tag + which role a piece of text plays, instead of hardcoding a color that then has to be overridden + per background. https://meemoo.atlassian.net/browse/ARC-3848 + + Each falls back to `inherit` so a block outside a colored background is unaffected, and + --bg-text-secondary / --bg-text-hyperlink fall back to the primary color when design specified + no separate value for that background. + -------------------------------------------------------------------------- */ + +.u-text-primary { + color: var(--bg-text-primary, inherit) !important; +} + +.u-text-secondary { + color: var(--bg-text-secondary, var(--bg-text-primary, inherit)) !important; +} + +.u-text-hyperlink { + color: var(--bg-text-hyperlink, var(--bg-text-primary, inherit)) !important; +} From 320949bbfe0bd15b1c243912c5cea59f0a5c0eb2 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Thu, 13 Aug 2026 22:01:14 +0200 Subject: [PATCH 09/11] fix(ARC-3848): align content block text colors with design --- ui/all-translations-het-archief.json | 10 +-- ui/all-translations-het-archief.sql | 6 +- .../ContentBlockRenderer.scss | 17 ++++ .../ContentBlockRenderer.tsx | 21 ++--- .../BlockContentPageMeta.tsx | 3 +- .../blocks/BlockHeading/BlockHeading.test.tsx | 18 ++++ .../blocks/BlockHeading/BlockHeading.tsx | 2 + .../BlockHetArchiefHeaderSearch.tsx | 4 +- .../BlockHetArchiefImageTextBackground.tsx | 9 +- .../BlockHighlightText/BlockHighlightText.tsx | 2 +- .../BlockHomepageBanner.tsx | 6 +- .../BlockImageGrid/BlockImageGrid.test.tsx | 33 ++++++++ .../blocks/BlockImageGrid/BlockImageGrid.tsx | 18 ++-- .../BlockImageTitleTextButton.tsx | 8 +- .../BlockMaintainersGrid.tsx | 12 ++- .../BlockOverviewNewspaperTitles.tsx | 1 + .../BlockOverviewThemesGroupSection.tsx | 2 +- .../BlockPageOverview/BlockPageOverview.scss | 15 ++++ .../BlockPageOverview/BlockPageOverview.tsx | 41 ++++++---- .../blocks/BlockQuote/BlockQuote.scss | 10 +++ .../BlockRichText/BlockRichText.test.tsx | 1 + .../blocks/BlockRichText/BlockRichText.tsx | 2 +- .../BlockTagsWithLink/BlockTagsWithLink.tsx | 7 +- .../BlockThemeReels/BlockThemeReelSection.tsx | 14 ++-- .../BlockVideoTitleTextButton.tsx | 12 ++- .../const/background-text-colors.test.ts | 82 ++++++++++++++++--- .../const/background-text-colors.ts | 55 +++++++++---- .../content-page/const/get-color-options.ts | 8 +- .../content-page/types/content-block.types.ts | 2 +- .../CopyrightAttribution.test.tsx | 18 ++++ .../CopyrightAttribution.tsx | 6 +- .../styles/utilities/_background-text.scss | 26 ++++++ .../shared/styles/utilities/_color.scss | 28 +------ ui/src/shared/translations/hetArchief/nl.json | 2 +- 34 files changed, 362 insertions(+), 139 deletions(-) create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockImageGrid/BlockImageGrid.test.tsx create mode 100644 ui/src/react-admin/modules/shared/components/CopyrightAttribution/CopyrightAttribution.test.tsx create mode 100644 ui/src/react-admin/modules/shared/styles/utilities/_background-text.scss diff --git a/ui/all-translations-het-archief.json b/ui/all-translations-het-archief.json index a5b3ba30..d7480ed5 100644 --- a/ui/all-translations-het-archief.json +++ b/ui/all-translations-het-archief.json @@ -1724,9 +1724,9 @@ "app": "HET_ARCHIEF", "component": "ADMIN_CORE", "location": "modules/content-page/const/content-block", - "key": "poederblauw", + "key": "babyblauw", "language": "nl", - "value": "Poederblauw", + "value": "Baby blauw", "value_type": "TEXT" }, { @@ -1734,9 +1734,9 @@ "app": "HET_ARCHIEF", "component": "ADMIN_CORE", "location": "modules/content-page/const/content-block", - "key": "poederblauw", + "key": "babyblauw", "language": "en", - "value": "Poederblauw", + "value": "Baby blauw", "value_type": "TEXT" }, { @@ -58539,4 +58539,4 @@ "value": "You do not have the right permissions to call this route", "value_type": "TEXT" } -] \ No newline at end of file +] diff --git a/ui/all-translations-het-archief.sql b/ui/all-translations-het-archief.sql index 427648aa..d5baef25 100644 --- a/ui/all-translations-het-archief.sql +++ b/ui/all-translations-het-archief.sql @@ -1563,6 +1563,8 @@ INSERT INTO app.translations ("component", "location", "key", "value", "value_ty INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/content-page-renderer/content-page-renderer', 'bewerk-pagina-tooltip', 'Edit page tooltip', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Edit page tooltip', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/date-picker/date-picker', 'datum-input-aria-label', 'Kies hier de datum', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Kies hier de datum', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/date-picker/date-picker', 'datum-input-aria-label', 'Specify the date here', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Specify the date here', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/content-block', 'babyblauw', 'Baby blauw', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Baby blauw', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/content-block', 'babyblauw', 'Baby blauw', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Baby blauw', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/content-block', 'bloesem-roze', 'Bloesem roze', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Bloesem roze', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/content-block', 'bloesem-roze', 'Bloesem roze', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Bloesem roze', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/content-block', 'honing-geel', 'Honing geel', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Honing geel', value_type = 'TEXT'; @@ -1583,8 +1585,6 @@ INSERT INTO app.translations ("component", "location", "key", "value", "value_ty INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/content-block', 'pistache-groen', 'Pistache groen', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Pistache groen', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/content-block', 'platinum', 'Platinum', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Platinum', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/content-block', 'platinum', 'Platinum', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Platinum', value_type = 'TEXT'; -INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/content-block', 'poederblauw', 'Poederblauw', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Poederblauw', value_type = 'TEXT'; -INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/content-block', 'poederblauw', 'Poederblauw', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Poederblauw', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/content-block', 'salie-groen', 'Salie groen', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Salie groen', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/content-block', 'salie-groen', 'Salie groen', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Salie groen', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/content-block', 'sky-blauw', 'Sky blauw', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Sky blauw', value_type = 'TEXT'; @@ -5860,4 +5860,4 @@ INSERT INTO app.translations ("component", "location", "key", "value", "value_ty INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('FRONTEND', 'pages/zoeken/index', 'zoek-pagina-seo-omschrijving', 'Je kan op deze website centraal en online zoeken doorheen beschrijvingen van materiaal van meer dan 140 aanbieders, zonder dat je je hoeft te verplaatsen.', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Je kan op deze website centraal en online zoeken doorheen beschrijvingen van materiaal van meer dan 140 aanbieders, zonder dat je je hoeft te verplaatsen.', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('FRONTEND', 'pages/zoeken/index', 'zoek-pagina-seo-omschrijving', 'You can search centrally and online throughout descriptions of materials from more than 140 organisations without having to move.', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'You can search centrally and online throughout descriptions of materials from more than 140 organisations without having to move.', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('FRONTEND', 'pages/zoeken/index', 'zoeken-pagina-titel', 'Search', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Search', value_type = 'TEXT'; -INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('FRONTEND', 'pages/zoeken/index', 'zoeken-pagina-titel', 'Zoeken', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Zoeken', value_type = 'TEXT'; \ No newline at end of file +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('FRONTEND', 'pages/zoeken/index', 'zoeken-pagina-titel', 'Zoeken', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Zoeken', value_type = 'TEXT'; diff --git a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.scss b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.scss index 4c64bae0..36fef751 100644 --- a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.scss +++ b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.scss @@ -1,4 +1,5 @@ @use "../../../shared/styles/settings/colors" as colors; +@use "../../../shared/styles/utilities/background-text" as background-text; .c-content-page-overview-block__header { opacity: 0; @@ -62,3 +63,19 @@ } } } + +/* -------------------------------------------------------------------------- + WCAG text colors on a content block's background color + + ContentBlockRenderer sets --bg-text-primary / --bg-text-secondary / --bg-text-hyperlink from + the design record. The wrapper applies primary text by inheritance; elements that have their own + color rule are tagged explicitly with one of the role classes below. Rich-text containers use + u-background-text-links because their generated anchors cannot receive a React class directly. + https://meemoo.atlassian.net/browse/ARC-3848 + + ContentBlockRenderer emits the shared mixin in client.css; the utility index emits it separately + in admin.css. All role rules are scoped to u-background-text-colors, so AVO remains untouched. + Secondary and link roles fall back to primary when the design specifies no separate role color. + -------------------------------------------------------------------------- */ + +@include background-text.background-text-roles; diff --git a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx index 13e97c21..8cbeb35e 100644 --- a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx +++ b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.tsx @@ -10,10 +10,7 @@ import { GENERATED_CONTENT_BLOCK_ANCHOR_PREFIX } from '~modules/content-page/con import type { ContentPageInfo } from '~modules/content-page/types/content-pages.types'; import { ContentPageWidth } from '~modules/content-page/types/content-pages.types'; import { generateSmartLink } from '~shared/components/SmartLink/SmartLink'; -import { - getBackgroundTextColors, - getBackgroundTextColorVariables, -} from '../../const/background-text-colors'; +import { getBackgroundTextColorVariables } from '../../const/background-text-colors'; import { hasDarkBackground } from '../../const/get-color-options'; import { Color, @@ -127,13 +124,14 @@ const ContentBlockRenderer: FunctionComponent = ({ } const hasDarkBg = hasDarkBackground(blockState?.backgroundColor); - // The WCAG text colors design specified for this background, published as css variables so any - // text inside the block can pick the role it plays with u-text-primary / u-text-secondary / - // u-text-hyperlink. https://meemoo.atlassian.net/browse/ARC-3848 - const backgroundTextColors = getBackgroundTextColors(blockState?.backgroundColor); + // The Archief text colors specified for this background, published as css variables so text + // inside the block can take the primary, secondary or hyperlink role. On AVO this helper returns + // no variables, preserving AVO's own brand-book behavior. + // https://meemoo.atlassian.net/browse/ARC-3848 const textColorVariables = getBackgroundTextColorVariables( blockState?.backgroundColor ) as CSSProperties; + const hasBackgroundTextColors = Object.keys(textColorVariables).length > 0; const anchor = blockState?.anchor?.replaceAll(' ', '-') || GENERATED_CONTENT_BLOCK_ANCHOR_PREFIX + contentBlockConfig.id; @@ -175,10 +173,9 @@ const ContentBlockRenderer: FunctionComponent = ({ = ({ // biome-ignore lint/suspicious/noExplicitAny: todo return (labelObj as any).link_to ? (
); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockHetArchiefImageTextBackground/BlockHetArchiefImageTextBackground.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockHetArchiefImageTextBackground/BlockHetArchiefImageTextBackground.tsx index 0adc663a..d7b56596 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockHetArchiefImageTextBackground/BlockHetArchiefImageTextBackground.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockHetArchiefImageTextBackground/BlockHetArchiefImageTextBackground.tsx @@ -99,7 +99,6 @@ export const BlockHetArchiefImageTextBackground: FunctionComponent< // Only reads it once the image has finished loading — reading mid-load measures // ~0px, which would shrink `.media` (and the image with it) with no way to // recover, since a later re-measure would just report that same self-inflicted size. - // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable const updateMediaMeasurements = useCallback(() => { const imgEl = imgRef.current; const measureEl = copyrightMeasureRef.current; @@ -172,6 +171,7 @@ export const BlockHetArchiefImageTextBackground: FunctionComponent< {heading} @@ -206,12 +206,7 @@ export const BlockHetArchiefImageTextBackground: FunctionComponent< > {image && (
- {imageAltText} + {imageAltText}
)}
= ({
= {title} - +
cleanup()); + +const ELEMENT = { + source: '/image.jpg', + title: 'Grid title', + text: 'Grid description', +}; + +describe(' text colors', () => { + it('uses background text roles when no foreground color was supplied', () => { + const { container } = render(); + + expect(container.querySelector('.c-block-grid__text-wrapper')).toHaveClass( + 'u-background-text-primary' + ); + expect(screen.getByText('Grid title')).toHaveClass('u-background-text-primary'); + expect(screen.getByText('Grid description')).toHaveClass('u-background-text-primary'); + }); + + it('preserves a caller-supplied foreground color', () => { + const { container } = render(); + const textWrapper = container.querySelector('.c-block-grid__text-wrapper'); + + expect(textWrapper).not.toHaveClass('u-background-text-primary'); + expect(textWrapper).toHaveStyle({ color: '#123456' }); + expect(screen.getByText('Grid title')).not.toHaveClass('u-background-text-primary'); + }); +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageGrid/BlockImageGrid.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockImageGrid/BlockImageGrid.tsx index 4d917ef6..a6c577e1 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageGrid/BlockImageGrid.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockImageGrid/BlockImageGrid.tsx @@ -24,7 +24,7 @@ export const BlockImageGrid: FunctionComponent = ({ textSize = 15, textMargin = 0, textWeight = 500, - textColor = '#2B414F', + textColor, horizontalMargin = 10, verticalMargin = 10, className, @@ -36,7 +36,7 @@ export const BlockImageGrid: FunctionComponent = ({ {element.textAbove && (
-

{element.textAbove}

+

{element.textAbove}

)} @@ -64,10 +64,10 @@ export const BlockImageGrid: FunctionComponent = ({ showIcon={element.copyrightIconVisible} />
{!!element.title && ( @@ -78,13 +78,15 @@ export const BlockImageGrid: FunctionComponent = ({ fontWeight: textWeight, }} > - {element.title} + + {element.title} + )} {!!element.text && ( -

{element.text}

+

{element.text}

)} {!!element.buttonLabel && ( diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.tsx index a87692da..d2682b50 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.tsx @@ -42,8 +42,8 @@ export const BlockImageTitleTextButton: FunctionComponent + className={clsx(className, 'u-background-text-links')} + /> ); } return text; @@ -60,8 +60,8 @@ export const BlockImageTitleTextButton: FunctionComponent
- {title &&

{title}

} - {renderText(subtitle, 'a-subtitle')} + {title &&

{title}

} + {renderText(subtitle, 'a-subtitle u-background-text-secondary')} {renderText(text)} {buttonLabel && ( diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockMaintainersGrid/BlockMaintainersGrid.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockMaintainersGrid/BlockMaintainersGrid.tsx index 2c8f7c2e..ff64838f 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockMaintainersGrid/BlockMaintainersGrid.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockMaintainersGrid/BlockMaintainersGrid.tsx @@ -36,11 +36,13 @@ export const BlockMaintainersGrid: FunctionComponent = {title} - {subtitle &&

{subtitle}

} + {subtitle &&

{subtitle}

}
{buttonLabel && (
- {buttonLabel} + + {buttonLabel} +
)}
@@ -60,11 +62,7 @@ export const BlockMaintainersGrid: FunctionComponent = } )} > - + ); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewNewspaperTitles/BlockOverviewNewspaperTitles.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewNewspaperTitles/BlockOverviewNewspaperTitles.tsx index 8e176a0e..d3dfc41f 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewNewspaperTitles/BlockOverviewNewspaperTitles.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewNewspaperTitles/BlockOverviewNewspaperTitles.tsx @@ -86,6 +86,7 @@ export const BlockOverviewNewspaperTitles: FC key={`newspaper-title-item__${item.title}`} > {group.title} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.scss index 7560b2ab..cf9a9686 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.scss @@ -55,6 +55,21 @@ } } + // The consuming Het Archief stylesheet has legacy `!important` foreground colors for these + // elements. Keep the semantic role classes authoritative without coupling the global utility to + // PageOverview's DOM structure. + .c-block-image-title-text-button .c-rich-text-editor__content { + h2.u-background-text-hyperlink, + h3.u-background-text-hyperlink { + color: var(--bg-text-hyperlink, var(--bg-text-primary)) !important; + } + + .a-subtitle.u-background-text-secondary, + .a-content-page__description.u-background-text-secondary { + color: var(--bg-text-secondary, var(--bg-text-primary)) !important; + } + } + .c-aspect-ratio-wrapper { background-size: cover; background-repeat: no-repeat; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.tsx index b388df08..342855d8 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockPageOverview/BlockPageOverview.tsx @@ -180,7 +180,12 @@ export const BlockPageOverview: FunctionComponent = ({ const getDescription = (page: ContentPageInfo) => { return showDescription && page.description ? ( - + ) : undefined; }; @@ -191,7 +196,7 @@ export const BlockPageOverview: FunctionComponent = ({ ); } @@ -286,15 +291,19 @@ export const BlockPageOverview: FunctionComponent = ({ value: page.path, } as ButtonAction, itemStyle === ContentItemStyle.NEWS_LIST ? ( -

{page.title}

+

{page.title}

) : ( -

{page.title}

+

{page.title}

), page.title )} - {showDate && renderText(formatDateString(dateString, page), 'a-subtitle')} + {showDate && + renderText( + formatDateString(dateString, page), + 'a-subtitle u-background-text-secondary' + )} { -
+
{renderText(getDescription(page))}
} @@ -356,17 +365,17 @@ export const BlockPageOverview: FunctionComponent = ({ ); }); - } else { - // Render all pages in a grid without section titles (unique pages only) - let pagesToShow = labelsToShow.flatMap((labelObj) => { - if (!(pagesByLabel[labelObj.id] || []).length) { - return []; - } - return pagesByLabel[labelObj.id]; - }); - pagesToShow = uniqBy(pagesToShow, (page) => page.id); - return renderGrid(pagesToShow); } + + // Render all pages in a grid without section titles (unique pages only) + let pagesToShow = labelsToShow.flatMap((labelObj) => { + if (!(pagesByLabel[labelObj.id] || []).length) { + return []; + } + return pagesByLabel[labelObj.id]; + }); + pagesToShow = uniqBy(pagesToShow, (page) => page.id); + return renderGrid(pagesToShow); } if (itemStyle === ContentItemStyle.ACCORDION) { // Ensure the focused page is not loaded twice on the same pagination page (ACCORDION) diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockQuote/BlockQuote.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockQuote/BlockQuote.scss index 9ac7ab48..feeb8014 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockQuote/BlockQuote.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockQuote/BlockQuote.scss @@ -1,3 +1,13 @@ .c-block-quote .c-quote { margin: 4rem 2rem; + + // Quote is provided by avo2-components, so its internal elements cannot receive our utility + // classes in React. Keep this third-party exception scoped to the block itself. + &__text { + color: var(--bg-text-primary) !important; + } + + &__author { + color: var(--bg-text-secondary, var(--bg-text-primary)) !important; + } } diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockRichText/BlockRichText.test.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockRichText/BlockRichText.test.tsx index 6454f2c2..a5cad4fd 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockRichText/BlockRichText.test.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockRichText/BlockRichText.test.tsx @@ -39,6 +39,7 @@ describe('', () => { const contentContainer = container.querySelector('.c-rich-text-editor__content'); expect(container.firstChild).toHaveClass(customClass); expect(contentContainer).not.toBeNull(); + expect(contentContainer).toHaveClass('u-background-text-links'); }); it('Should create multiple columns', () => { diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockRichText/BlockRichText.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockRichText/BlockRichText.tsx index 0d88565f..43e0d0d7 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockRichText/BlockRichText.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockRichText/BlockRichText.tsx @@ -84,7 +84,7 @@ export const BlockRichText: FunctionComponent = ({ = ({ !isNil(link) ? ( {label} ) : ( -

+

{label}

); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockThemeReels/BlockThemeReelSection.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockThemeReels/BlockThemeReelSection.tsx index bdaab9a3..2cfdcc69 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockThemeReels/BlockThemeReelSection.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockThemeReels/BlockThemeReelSection.tsx @@ -121,10 +121,12 @@ export const BlockThemeReelSection: FunctionComponent {title && ( - {title} + + {title} + )} {description && ( - + {description} )} @@ -136,9 +138,11 @@ export const BlockThemeReelSection: FunctionComponent
- {themeName} + + {themeName} + {isMobileWidth() && (description || themeDescription) && ( - + {description || themeDescription || ''} )} @@ -297,7 +301,7 @@ export const BlockThemeReelSection: FunctionComponent
- + {tText( 'modules/content-page/components/blocks/block-theme-reels/block-theme-reel-section___toon-alle-materialen-voor-dit-theme', {}, diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockVideoTitleTextButton/BlockVideoTitleTextButton.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockVideoTitleTextButton/BlockVideoTitleTextButton.tsx index 721e977a..2d810464 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockVideoTitleTextButton/BlockVideoTitleTextButton.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockVideoTitleTextButton/BlockVideoTitleTextButton.tsx @@ -45,13 +45,21 @@ export const BlockVideoTitleTextButton: FunctionComponent {title && (

- + {title}

)} {text && ( - + )}
diff --git a/ui/src/react-admin/modules/content-page/const/background-text-colors.test.ts b/ui/src/react-admin/modules/content-page/const/background-text-colors.test.ts index b67d8ee5..1b1d2012 100644 --- a/ui/src/react-admin/modules/content-page/const/background-text-colors.test.ts +++ b/ui/src/react-admin/modules/content-page/const/background-text-colors.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Color, CustomBackground, GradientColor } from '../types/content-block.types'; import { @@ -6,16 +6,22 @@ import { getBackgroundTextColors, getBackgroundTextColorVariables, } from './background-text-colors'; +import { GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF, hasDarkBackground } from './get-color-options'; + +const isAvoMock = vi.hoisted(() => vi.fn<() => boolean>()); + +vi.mock('~shared/helpers/is-avo', () => ({ isAvo: isAvoMock })); +vi.mock('~shared/helpers/translation-functions', () => ({ tText: (key: string) => key })); /** - * Every background row of meemoo-hetarchief-kleurencombinaties.pdf as - * [name, background, primary, secondary, hyperlink], so the record can be checked against the - * design document row by row. https://meemoo.atlassian.net/browse/ARC-3848 + * Every supported Archief background as [name, background, primary, secondary, hyperlink], based + * on meemoo-hetarchief-kleurencombinaties.pdf and the corrections confirmed by meemoo on + * ARC-3848. Sky blauw is the only selectable legacy color that is absent from the PDF. */ -const KLEURENCOMBINATIES_PDF: [string, string, string, string?, string?][] = [ +const EXPECTED_BACKGROUND_TEXT_COLORS: [string, string, string, string?, string?][] = [ // Merk ['Zwart', '#000000', '#FFFFFF', '#ADADAD', '#00C8AA'], - ['Wit', '#FFFFFF', '#000000', '#757575', '#00857D'], + ['Wit', '#FFFFFF', '#000000', '#666666', '#00857D'], ['Teal', '#00C8AA', '#000000'], // Functioneel ['Grafiet', '#222222', '#FFFFFF', '#ADADAD', '#00C8AA'], @@ -23,7 +29,7 @@ const KLEURENCOMBINATIES_PDF: [string, string, string, string?, string?][] = [ ['Schaduw', '#505050', '#FFFFFF'], ['Leisteen', '#666666', '#FFFFFF'], ['Neutraal', '#757575', '#FFFFFF'], - ['Zink', '#ADADAD', '#FFFFFF'], + ['Zink', '#ADADAD', '#000000'], ['Zilver', '#E6E6E6', '#000000', '#666666', '#005F69'], ['Platinum', '#F8F8F8', '#000000', '#666666', '#005F69'], ['Kers', '#D60039', '#FFFFFF'], @@ -47,10 +53,16 @@ const KLEURENCOMBINATIES_PDF: [string, string, string, string?, string?][] = [ ['Terra', '#D1543A', '#000000'], ['Olijf', '#64702B', '#FFFFFF'], ['Viool', '#432457', '#FFFFFF'], + // Not in the PDF; temporarily follows Baby blauw while meemoo decides whether it remains. + ['Sky blauw', '#C3DDE6', '#000000', undefined, '#005F69'], ]; describe('getBackgroundTextColors()', () => { - it.each(KLEURENCOMBINATIES_PDF)( + beforeEach(() => { + isAvoMock.mockReturnValue(false); + }); + + it.each(EXPECTED_BACKGROUND_TEXT_COLORS)( 'matches the design for %s', (_name, background, primary, secondary, hyperlink) => { expect(getBackgroundTextColors(background)).toEqual({ @@ -61,8 +73,10 @@ describe('getBackgroundTextColors()', () => { } ); - it('holds every row of the design document and no extras', () => { - expect(Object.keys(BACKGROUND_TEXT_COLORS)).toHaveLength(KLEURENCOMBINATIES_PDF.length); + it('holds every approved row and the temporary Sky blauw fallback, with no extras', () => { + expect(Object.keys(BACKGROUND_TEXT_COLORS)).toHaveLength( + EXPECTED_BACKGROUND_TEXT_COLORS.length + ); }); // Color.Black is '#000' and Color.White is '#FFF', and Color.Lila is lowercase, so lookups have @@ -72,12 +86,38 @@ describe('getBackgroundTextColors()', () => { expect(getBackgroundTextColors(Color.White)?.primary).toBe('#000000'); expect(getBackgroundTextColors(Color.Lila)?.primary).toBe('#000000'); expect(getBackgroundTextColors(Color.OldPink)?.primary).toBe('#FFFFFF'); + expect(getBackgroundTextColors(Color.BabyBlue)?.hyperlink).toBe('#005F69'); + expect(getBackgroundTextColors(Color.SkyBlue)).toEqual(getBackgroundTextColors(Color.BabyBlue)); + }); + + it('renders legacy Poederblauw as Baby blauw without keeping a separate palette record', () => { + expect(getBackgroundTextColors('#BDDEE7')).toEqual(getBackgroundTextColors(Color.BabyBlue)); + expect(BACKGROUND_TEXT_COLORS).not.toHaveProperty('#bddee7'); + }); + + it('has a ruling for every selectable flat Archief background', () => { + const backgroundsWithoutOneTextColor = [ + Color.Transparent, + GradientColor.BlackWhite, + CustomBackground.MeemooLogo, + ]; + const flatBackgrounds = GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF() + .map((option) => option.value) + .filter((value) => !backgroundsWithoutOneTextColor.includes(value)); + + for (const background of flatBackgrounds) { + expect( + getBackgroundTextColors(background), + `missing text colors for ${background}` + ).toBeDefined(); + } }); - // Design specified nothing for these, so blocks keep whatever they inherit. + // Design specified nothing for these, so blocks keep whatever they inherit. In particular, + // meemoo confirmed that BlackWhite must retain the existing per-block handling. it.each([ ['transparent', Color.Transparent], - ['a gradient', GradientColor.BlackWhite], + ['the separately handled black-white gradient', GradientColor.BlackWhite], ['the meemoo logo pattern', CustomBackground.MeemooLogo], ['an AVO-only color', Color.SoftBlue], ['no background', undefined], @@ -85,9 +125,27 @@ describe('getBackgroundTextColors()', () => { ])('has no colors for %s', (_name, background) => { expect(getBackgroundTextColors(background)).toBeUndefined(); }); + + describe('on AVO', () => { + beforeEach(() => { + isAvoMock.mockReturnValue(true); + }); + + it('does not apply an Archief record to a shared hex color', () => { + expect(getBackgroundTextColors(Color.OceanGreen)).toBeUndefined(); + expect(getBackgroundTextColorVariables(Color.OceanGreen)).toEqual({}); + }); + + it('keeps the existing AVO dark-background ruling', () => { + expect(hasDarkBackground(Color.OceanGreen)).toBe(true); + }); + }); }); describe('getBackgroundTextColorVariables()', () => { + beforeEach(() => { + isAvoMock.mockReturnValue(false); + }); it('exposes all three roles when design specified all three', () => { expect(getBackgroundTextColorVariables(Color.Black)).toEqual({ '--bg-text-primary': '#FFFFFF', diff --git a/ui/src/react-admin/modules/content-page/const/background-text-colors.ts b/ui/src/react-admin/modules/content-page/const/background-text-colors.ts index c7f264c0..9fcf8fff 100644 --- a/ui/src/react-admin/modules/content-page/const/background-text-colors.ts +++ b/ui/src/react-admin/modules/content-page/const/background-text-colors.ts @@ -1,14 +1,16 @@ +import { isAvo } from '~shared/helpers/is-avo'; + /** - * The WCAG text colors per background color, exactly as delivered by design in + * The WCAG text colors per background color, based on * meemoo-hetarchief-kleurencombinaties.pdf (attached to - * https://meemoo.atlassian.net/browse/ARC-3848). + * https://meemoo.atlassian.net/browse/ARC-3848) and meemoo's confirmed corrections. * * Every content block that renders text on an admin-picked background color, and does not offer a * text color field of its own, takes its text colors from here. * - * The PDF is the authority: do not derive these values, and do not "fix" a row that looks off - - * raise it with design instead. The columns map to `primary` (body text), `secondary` (muted text - * such as captions, subtitles and metadata) and `hyperlink` (the underlined link color). + * The columns map to `primary` (body text), `secondary` (muted text such as captions, subtitles + * and metadata) and `hyperlink` (the underlined link color). Differences from the PDF must be + * documented at the affected row. */ /** Named colors from the PDF, so the rows below read like the design document. */ @@ -16,11 +18,19 @@ const WIT = '#FFFFFF'; const ZWART = '#000000'; const ZINK = '#ADADAD'; const TEAL = '#00C8AA'; -const NEUTRAAL = '#757575'; const JADE = '#00857D'; const LEISTEEN = '#666666'; const LAGUNE = '#005F69'; +/** + * Backward-compatible aliases for colors that were selectable in previously saved content. + * Poederblauw was a typo for Baby blauw (`8` became `B`), so it must render exactly like Baby + * blauw without remaining a selectable or separately maintained palette entry. + */ +const LEGACY_BACKGROUND_COLOR_ALIASES: Record = { + '#bddee7': '#8ddee7', +}; + /** * White as the PDF writes it. Color.White is the shorthand '#FFF', so compare against this when * checking whether a background got light text. @@ -44,7 +54,8 @@ export interface BackgroundTextColors { export const BACKGROUND_TEXT_COLORS: Record = { // Merk '#000000': { primary: WIT, secondary: ZINK, hyperlink: TEAL }, // Zwart - '#ffffff': { primary: ZWART, secondary: NEUTRAAL, hyperlink: JADE }, // Wit + // Meemoo replaced Neutraal #757575 with Leisteen #666666 for muted text on white. + '#ffffff': { primary: ZWART, secondary: LEISTEEN, hyperlink: JADE }, // Wit '#00c8aa': { primary: ZWART }, // Teal // Functioneel @@ -53,7 +64,8 @@ export const BACKGROUND_TEXT_COLORS: Record = { '#505050': { primary: WIT }, // Schaduw '#666666': { primary: WIT }, // Leisteen '#757575': { primary: WIT }, // Neutraal - '#adadad': { primary: WIT }, // Zink - see the open question in the ticket, white is 2.24:1 here + // The PDF text originally listed white, but meemoo confirmed the visual is authoritative: black. + '#adadad': { primary: ZWART }, // Zink '#e6e6e6': { primary: ZWART, secondary: LEISTEEN, hyperlink: LAGUNE }, // Zilver '#f8f8f8': { primary: ZWART, secondary: LEISTEEN, hyperlink: LAGUNE }, // Platinum '#d60039': { primary: WIT }, // Kers @@ -82,6 +94,10 @@ export const BACKGROUND_TEXT_COLORS: Record = { '#d1543a': { primary: ZWART }, // Terra '#64702b': { primary: WIT }, // Olijf '#432457': { primary: WIT }, // Viool + + // Selectable legacy color that is not in the PDF. Until meemoo decides whether Sky blauw stays, + // it uses the confirmed Baby blauw text colors. + '#c3dde6': { primary: ZWART, hyperlink: LAGUNE }, // Sky blauw }; /** @@ -103,29 +119,34 @@ function normaliseHex(color: string): string | null { } /** - * The WCAG text colors design specified for this background color, or undefined when the background - * is not a flat color from the palette (transparent, a gradient, the meemoo logo pattern) or is an - * AVO-only color, which follows its own brand book. + * The Archief text colors specified for this background color, or undefined on AVO and when the + * background is not a flat color from the palette (transparent, a gradient or the meemoo logo + * pattern). Meemoo explicitly confirmed that the BlackWhite gradient must keep each block's + * existing, separately handled text styling. AVO follows its own brand book, including for hex + * values shared by both apps. */ export function getBackgroundTextColors( color: string | undefined ): BackgroundTextColors | undefined { - if (!color) { + if (!color || isAvo()) { return undefined; } - const key = normaliseHex(color); + const normalisedColor = normaliseHex(color); + const key = normalisedColor + ? (LEGACY_BACKGROUND_COLOR_ALIASES[normalisedColor] ?? normalisedColor) + : null; return key ? BACKGROUND_TEXT_COLORS[key] : undefined; } /** * The design text colors for this background as css variables, to spread into a style prop. The - * u-text-primary / u-text-secondary / u-text-hyperlink classes read these, so any element inside - * can say which role its text plays instead of hardcoding a color. + * u-background-text-* classes read these, so any element inside can say which role its text + * plays instead of hardcoding a color. * - * Returns an empty object when design specified nothing for this background, which leaves the - * variables unset and the utility classes falling back to `inherit`. + * Returns an empty object when design specified nothing for this background. The renderer then + * omits the u-background-text-colors wrapper, leaving the role classes inactive. */ export function getBackgroundTextColorVariables(color: string | undefined): Record { const textColors = getBackgroundTextColors(color); diff --git a/ui/src/react-admin/modules/content-page/const/get-color-options.ts b/ui/src/react-admin/modules/content-page/const/get-color-options.ts index ae280ec5..0f8319ed 100644 --- a/ui/src/react-admin/modules/content-page/const/get-color-options.ts +++ b/ui/src/react-admin/modules/content-page/const/get-color-options.ts @@ -98,9 +98,9 @@ const coralOption = () => ({ label: tText('modules/content-page/const/content-block___koraal-oranje', {}, [App.HET_ARCHIEF]), value: Color.Coral, }); -const lightBlueOption = () => ({ - label: tText('modules/content-page/const/content-block___poederblauw', {}, [App.HET_ARCHIEF]), - value: Color.LightBlue, +const babyBlueOption = () => ({ + label: tText('modules/content-page/const/content-block___babyblauw', {}, [App.HET_ARCHIEF]), + value: Color.BabyBlue, }); const sageOption = () => ({ label: tText('modules/content-page/const/content-block___salie-groen', {}, [App.HET_ARCHIEF]), @@ -140,7 +140,7 @@ export const GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF: () => SelectOption< lilaOption(), blossomPinkOption(), coralOption(), - lightBlueOption(), + babyBlueOption(), sageOption(), pistachioOption(), sandBeigeOption(), diff --git a/ui/src/react-admin/modules/content-page/types/content-block.types.ts b/ui/src/react-admin/modules/content-page/types/content-block.types.ts index f276f3ce..ff9381f5 100644 --- a/ui/src/react-admin/modules/content-page/types/content-block.types.ts +++ b/ui/src/react-admin/modules/content-page/types/content-block.types.ts @@ -101,7 +101,7 @@ export enum Color { Lila = '#c6c2e0', BlossomPink = '#E694B3', Coral = '#E89B88', - LightBlue = '#BDDEE7', + BabyBlue = '#8DDEE7', Sage = '#91A9A7', Pistachio = '#B8BE9A', SandBeige = '#EDD6C4', diff --git a/ui/src/react-admin/modules/shared/components/CopyrightAttribution/CopyrightAttribution.test.tsx b/ui/src/react-admin/modules/shared/components/CopyrightAttribution/CopyrightAttribution.test.tsx new file mode 100644 index 00000000..5e175eb6 --- /dev/null +++ b/ui/src/react-admin/modules/shared/components/CopyrightAttribution/CopyrightAttribution.test.tsx @@ -0,0 +1,18 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import React from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CopyrightAttribution } from './CopyrightAttribution'; + +afterEach(() => cleanup()); + +describe('', () => { + it('marks the annotation as secondary and the attribution text as primary', () => { + const { container } = render( + + ); + + expect(screen.getByText(/Photographer/)).toHaveClass('u-background-text-secondary'); + expect(screen.getByText('Collection')).toHaveClass('u-background-text-primary'); + expect(container.firstChild).toHaveClass('a-copyright-attribution'); + }); +}); diff --git a/ui/src/react-admin/modules/shared/components/CopyrightAttribution/CopyrightAttribution.tsx b/ui/src/react-admin/modules/shared/components/CopyrightAttribution/CopyrightAttribution.tsx index 31bed498..b0a32d63 100644 --- a/ui/src/react-admin/modules/shared/components/CopyrightAttribution/CopyrightAttribution.tsx +++ b/ui/src/react-admin/modules/shared/components/CopyrightAttribution/CopyrightAttribution.tsx @@ -27,7 +27,7 @@ export const CopyrightAttribution: FunctionComponent } return ( - + {showIcon && <>©} {title} ); @@ -36,7 +36,9 @@ export const CopyrightAttribution: FunctionComponent return (
{renderTitle()} - {text && {text}} + {text && ( + {text} + )}
); }; diff --git a/ui/src/react-admin/modules/shared/styles/utilities/_background-text.scss b/ui/src/react-admin/modules/shared/styles/utilities/_background-text.scss new file mode 100644 index 00000000..b3abf71a --- /dev/null +++ b/ui/src/react-admin/modules/shared/styles/utilities/_background-text.scss @@ -0,0 +1,26 @@ +/* Semantic text-role classes for content blocks whose foreground follows an editor-selected + background. Kept in a mixin so the same contract can be emitted by admin.css and client.css. */ +@mixin background-text-roles { + .u-background-text-colors { + color: var(--bg-text-primary) !important; + + .u-background-text-primary { + color: var(--bg-text-primary) !important; + } + + .u-background-text-secondary { + color: var(--bg-text-secondary, var(--bg-text-primary)) !important; + } + + .u-background-text-hyperlink, + .u-background-text-links a:not(.a-link__no-styles) { + color: var(--bg-text-hyperlink, var(--bg-text-primary)) !important; + } + + /* SmartLink deliberately removes link styling; it should inherit the semantic text role of its + contents instead of the application's legacy global black link override. */ + .a-link__no-styles { + color: inherit; + } + } +} diff --git a/ui/src/react-admin/modules/shared/styles/utilities/_color.scss b/ui/src/react-admin/modules/shared/styles/utilities/_color.scss index 149189bc..bc859edb 100644 --- a/ui/src/react-admin/modules/shared/styles/utilities/_color.scss +++ b/ui/src/react-admin/modules/shared/styles/utilities/_color.scss @@ -1,4 +1,5 @@ @use '../../styles/settings/colors' as colors; +@use './background-text' as background-text; /* ========================================================================== Utility: Color @@ -12,27 +13,6 @@ color: colors.$color-gray-150 !important; } -/* -------------------------------------------------------------------------- - WCAG text colors on a content block's background color - - The block sets --bg-text-primary / --bg-text-secondary / --bg-text-hyperlink from the design - record in background-text-colors.ts (see ContentBlockRenderer). These classes let a block tag - which role a piece of text plays, instead of hardcoding a color that then has to be overridden - per background. https://meemoo.atlassian.net/browse/ARC-3848 - - Each falls back to `inherit` so a block outside a colored background is unaffected, and - --bg-text-secondary / --bg-text-hyperlink fall back to the primary color when design specified - no separate value for that background. - -------------------------------------------------------------------------- */ - -.u-text-primary { - color: var(--bg-text-primary, inherit) !important; -} - -.u-text-secondary { - color: var(--bg-text-secondary, var(--bg-text-primary, inherit)) !important; -} - -.u-text-hyperlink { - color: var(--bg-text-hyperlink, var(--bg-text-primary, inherit)) !important; -} +// The complete utility index feeds admin.css. ContentBlockRenderer includes the same mixin in the +// public client bundle, whose style graph intentionally excludes this index. +@include background-text.background-text-roles; diff --git a/ui/src/shared/translations/hetArchief/nl.json b/ui/src/shared/translations/hetArchief/nl.json index 88621ca2..09f33fa8 100644 --- a/ui/src/shared/translations/hetArchief/nl.json +++ b/ui/src/shared/translations/hetArchief/nl.json @@ -784,6 +784,7 @@ "modules/content-page/components/content-page-renderer/content-page-renderer___bewerk-pagina": "Bewerk pagina", "modules/content-page/components/content-page-renderer/content-page-renderer___bewerk-pagina-tooltip": "Bewerk pagina tooltip", "modules/content-page/components/date-picker/date-picker___datum-input-aria-label": "Kies hier de datum", + "modules/content-page/const/content-block___babyblauw": "Baby blauw", "modules/content-page/const/content-block___bloesem-roze": "Bloesem roze", "modules/content-page/const/content-block___honing-geel": "Honing geel", "modules/content-page/const/content-block___koraal-oranje": "Koraal oranje", @@ -794,7 +795,6 @@ "modules/content-page/const/content-block___overgang-zwart-wit": "Overgang zwart wit", "modules/content-page/const/content-block___pistache-groen": "Pistache groen", "modules/content-page/const/content-block___platinum": "Platinum", - "modules/content-page/const/content-block___poederblauw": "Poederblauw", "modules/content-page/const/content-block___salie-groen": "Salie groen", "modules/content-page/const/content-block___sky-blauw": "Sky blauw", "modules/content-page/const/content-block___zand-beige": "Zand beige", From 351ccf364d00c2fb9db7e3b4511c029bbe71ac73 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Fri, 14 Aug 2026 09:52:00 +0200 Subject: [PATCH 10/11] fix(ARC-3848): address review feedback --- .../BlockHighlightText/BlockHighlightText.tsx | 6 ++---- .../blocks/BlockImageGrid/BlockImageGrid.test.tsx | 4 ++-- .../blocks/BlockImageGrid/BlockImageGrid.tsx | 6 ++---- .../BlockImageTitleTextButton.scss | 7 ------- .../BlockImageTitleTextButton.test.tsx | 8 +++++++- .../BlockImageTitleTextButton.tsx | 6 ++---- .../BlockOverviewThemesGroupSection.tsx | 7 +------ .../const/background-text-colors.test.ts | 5 ----- .../content-page/const/background-text-colors.ts | 14 +------------- 9 files changed, 17 insertions(+), 46 deletions(-) delete mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.scss diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.tsx index dc8f9aef..0f1656a2 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.tsx @@ -30,10 +30,8 @@ export const BlockHighlightText: FunctionComponent = ({ highlightColor === CustomBackground.MeemooLogo ? Color.Transparent : ((ColorSelectGradientColors as Record)[highlightColor] ?? highlightColor); - // The text sits inside the highlighted box, so its WCAG text colors follow the box background - // rather than the block background: the highlight color, except for a gradient, which renders the - // box white (see --pattern-color below). The meemoo logo renders it transparent, which design - // specified no colors for, so that keeps the inherited text color. + // Text colors follow the actual fill behind the content: gradients use the white content box, + // while the transparent meemoo logo variant keeps the outer block's inherited text color. // https://meemoo.atlassian.net/browse/ARC-3848 const textBoxBackground = isGradient ? Color.White : patternColor; const textColorVariables = getBackgroundTextColorVariables(textBoxBackground); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageGrid/BlockImageGrid.test.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockImageGrid/BlockImageGrid.test.tsx index bb7f92b6..5a01bd62 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageGrid/BlockImageGrid.test.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockImageGrid/BlockImageGrid.test.tsx @@ -18,8 +18,8 @@ describe(' text colors', () => { expect(container.querySelector('.c-block-grid__text-wrapper')).toHaveClass( 'u-background-text-primary' ); - expect(screen.getByText('Grid title')).toHaveClass('u-background-text-primary'); - expect(screen.getByText('Grid description')).toHaveClass('u-background-text-primary'); + expect(screen.getByText('Grid title')).not.toHaveClass('u-background-text-primary'); + expect(screen.getByText('Grid description')).not.toHaveClass('u-background-text-primary'); }); it('preserves a caller-supplied foreground color', () => { diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageGrid/BlockImageGrid.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockImageGrid/BlockImageGrid.tsx index a6c577e1..ebf2a9b3 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageGrid/BlockImageGrid.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockImageGrid/BlockImageGrid.tsx @@ -78,15 +78,13 @@ export const BlockImageGrid: FunctionComponent = ({ fontWeight: textWeight, }} > - - {element.title} - + {element.title} )} {!!element.text && ( -

{element.text}

+

{element.text}

)} {!!element.buttonLabel && ( diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.scss deleted file mode 100644 index 93753e05..00000000 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.scss +++ /dev/null @@ -1,7 +0,0 @@ -@use "../../../../shared/styles/settings/colors" as colors; - -.c-block-image-title-text-button { - .a-subtitle { - color: colors.$color-gray-400; - } -} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.test.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.test.tsx index 318982e1..138c0ea0 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.test.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.test.tsx @@ -15,6 +15,7 @@ const blockImageTitleTextButtonExample = ( imageSource="https://placeholder.com/1280x720.jpg" imageDescription="image showing the default dimensions on a grey background" title="Title" + subtitle="Subtitle" text={loremIpsumText} buttonLabel="Goto video" /> @@ -43,9 +44,14 @@ describe('', () => { }); it('Should render the text correctly', () => { - render(blockImageTitleTextButtonExample); + const { container } = render(blockImageTitleTextButtonExample); const pElement = screen.getByText(loremIpsumText); expect(pElement).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2 })).toHaveClass('u-background-text-primary'); + expect(container.querySelector('.u-background-text-secondary')).toHaveTextContent('Subtitle'); + expect( + container.querySelector('.u-background-text-primary.u-background-text-links') + ).toHaveTextContent(loremIpsumText); }); it('Should set the correct className', () => { diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.tsx index d2682b50..41531916 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockImageTitleTextButton/BlockImageTitleTextButton.tsx @@ -13,8 +13,6 @@ import type { FunctionComponent, ReactNode } from 'react'; import Html from '~shared/components/Html/Html'; import { SanitizePreset } from '~shared/helpers/sanitize/presets'; -import './BlockImageTitleTextButton.scss'; - export interface BlockImageTitleTextButtonProps extends DefaultProps { imageSource: string; imageDescription?: string; @@ -61,8 +59,8 @@ export const BlockImageTitleTextButton: FunctionComponent
{title &&

{title}

} - {renderText(subtitle, 'a-subtitle u-background-text-secondary')} - {renderText(text)} + {renderText(subtitle, 'u-background-text-secondary')} + {renderText(text, 'u-background-text-primary')} {buttonLabel && (