From 414d854079a205cf52ad238dfecc0a0902a8848b Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Thu, 9 Jul 2026 17:04:48 -0400 Subject: [PATCH 1/3] [EuiIllustration] Add adaptive light-dark illustration variant Generate a single `adaptive` SVG per asset by merging the `light`/`dark` pair, rewriting differing colors into CSS `light-dark()` so a single string recolors via the ancestor `color-scheme` with no runtime SVG swap. Assets whose `light`/`dark` markup is not structurally identical keep `light`/`dark` only. The adaptive SVG is also emitted as `@elastic/eui-illustrations/svgs/.adaptive.svg` for ``/CSS consumers. `EuiIllustration` now renders the adaptive SVG when present, setting `color-scheme` from the active EUI theme, and falls back to the discrete `light`/`dark` markup otherwise. --- .../upcoming/adaptive_illustrations.md | 1 + .../illustration/illustration.stories.tsx | 167 +++++++++++++++- .../illustration/illustration.test.tsx | 45 +++++ .../components/illustration/illustration.tsx | 29 ++- packages/illustrations/README.md | 36 +++- .../upcoming/adaptive_illustrations.md | 1 + packages/illustrations/package.json | 6 +- packages/illustrations/scripts/generate.js | 187 +++++++++++++++++- packages/illustrations/src/types.ts | 7 + .../display/illustrations/index.mdx | 42 +++- 10 files changed, 503 insertions(+), 18 deletions(-) create mode 100644 packages/eui/changelogs/upcoming/adaptive_illustrations.md create mode 100644 packages/illustrations/changelogs/upcoming/adaptive_illustrations.md diff --git a/packages/eui/changelogs/upcoming/adaptive_illustrations.md b/packages/eui/changelogs/upcoming/adaptive_illustrations.md new file mode 100644 index 00000000000..1613f093b1a --- /dev/null +++ b/packages/eui/changelogs/upcoming/adaptive_illustrations.md @@ -0,0 +1 @@ +- Updated `EuiIllustration` to render the color-mode-adaptive SVG when an asset provides one, setting `color-scheme` from the active theme so its colors resolve via CSS `light-dark()`. It falls back to the discrete `light`/`dark` markup otherwise. diff --git a/packages/eui/src/components/illustration/illustration.stories.tsx b/packages/eui/src/components/illustration/illustration.stories.tsx index 9095a22f457..d2199149413 100644 --- a/packages/eui/src/components/illustration/illustration.stories.tsx +++ b/packages/eui/src/components/illustration/illustration.stories.tsx @@ -16,6 +16,10 @@ import { hideAllStorybookControls } from '../../../.storybook/utils'; import { useEuiTheme } from '../../services'; import { EuiButton } from '../button'; import { EuiEmptyPrompt } from '../empty_prompt'; +import { EuiFlexGroup, EuiFlexItem } from '../flex'; +import { EuiPanel } from '../panel'; +import { EuiSpacer } from '../spacer'; +import { EuiText } from '../text'; import { EuiIllustration, EuiIllustrationProps, @@ -59,7 +63,7 @@ export const Playground: Story = { if (fullWidth) props.push('fullWidth'); return `import { ${type} } from '@elastic/eui-illustrations'; - + `; }, }, @@ -120,6 +124,52 @@ export const EmptyPrompt: Story = { ), }; +const ADAPTIVE_SNIPPET = `import { useEuiTheme } from '@elastic/eui'; +import { shoppingCart } from '@elastic/eui-illustrations'; + +// One string. The ancestor \`color-scheme\` picks which \`light-dark()\` value +// applies. Pin it (\`light\`/\`dark\`), follow the OS \`prefers-color-scheme\` +// (\`light dark\`), or mirror the EUI theme (\`EuiProvider\`). +const PROVIDER_SCHEME = 'EuiProvider'; +const SYSTEM_SCHEME = 'system'; +const schemes = ['light', 'dark', PROVIDER_SCHEME, SYSTEM_SCHEME] as const; + +const AdaptiveIllustrations = () => { + const { colorMode } = useEuiTheme(); + const providerScheme = colorMode === 'DARK' ? 'dark' : 'light'; + + const resolveScheme = (scheme) => { + if (scheme === PROVIDER_SCHEME) return providerScheme; + if (scheme === SYSTEM_SCHEME) return 'light dark'; + return scheme; + }; + + return schemes.map((scheme) => ( +
+ )); +};`; + +/** + * Most assets ship a single \`adaptive\` SVG whose colors resolve via CSS + * \`light-dark()\`. \`EuiIllustration\` sets \`color-scheme\` from the EUI theme; + * this story sets it manually so the same string renders pinned \`light\`, + * pinned \`dark\`, following \`EuiProvider\`, and following the OS + * (\`light dark\`, via \`prefers-color-scheme\`) at once. + * \`aerospace\` has no \`adaptive\` variant and cannot adapt. + */ +export const Adaptive: Story = { + parameters: { + vrt: { skip: true }, + codeSnippet: { snippet: ADAPTIVE_SNIPPET }, + ...hideAllStorybookControls, + }, + render: () => , +}; + /** * VRT only */ @@ -146,6 +196,121 @@ export const SizingFullWidth: Story = { * Helpers */ +// Sentinels resolved to real CSS in `AdaptiveExample`: `EuiProvider` to the +// live theme color mode (a module-level const can't read `useEuiTheme()`), and +// `system` to `light dark` (the value that follows `prefers-color-scheme`). +const PROVIDER_SCHEME = 'EuiProvider'; +const SYSTEM_SCHEME = 'system'; + +const ADAPTIVE_COLOR_SCHEMES = [ + { scheme: 'light', label: 'color-scheme: light' }, + { scheme: 'dark', label: 'color-scheme: dark' }, + { scheme: PROVIDER_SCHEME, label: 'color-scheme: EuiProvider' }, + { scheme: SYSTEM_SCHEME, label: 'color-scheme: system' }, +] as const; + +const AdaptiveCard = ({ + label, + illustration, + colorScheme, +}: { + label: string; + illustration: EuiIllustrationSource; + colorScheme: string; +}) => ( + + + {label} + + +
+ +); + +const AdaptiveExample = () => { + const { colorMode } = useEuiTheme(); + const providerScheme = colorMode === 'DARK' ? 'dark' : 'light'; + + const resolveScheme = (scheme: string) => { + if (scheme === PROVIDER_SCHEME) return providerScheme; + if (scheme === SYSTEM_SCHEME) return 'light dark'; + return scheme; + }; + const resolveLabel = (scheme: string, label: string) => { + if (scheme === PROVIDER_SCHEME) return `${label} (${providerScheme})`; + if (scheme === SYSTEM_SCHEME) return `${label} (light dark)`; + return label; + }; + + return ( + + + +

+ One shopping-cart.adaptive string, rendered under + several color-scheme values. No theme change or + re-render — CSS light-dark() does the work. The{' '} + EuiProvider card mirrors what{' '} + EuiIllustration does: it follows the EUI color mode + (toggle the theme in the Storybook toolbar). The system{' '} + card resolves to color-scheme: light dark, following + the OS/browser prefers-color-scheme instead, regardless + of the EUI theme. +

+
+ + + {ADAPTIVE_COLOR_SCHEMES.map(({ scheme, label }) => ( + + + + ))} + +
+ + + +

+ aerospace has no adaptive variant, so it + falls back to the discrete light markup and does not + respond to color-scheme (shown following{' '} + EuiProvider). +

+
+ + + + +
+
+ ); +}; + /** * Fixture SVG for VRT. Uses a fixed width smaller than the parent container * so VRT snapshots can verify sizing without depending on `@elastic/eui-illustrations`. diff --git a/packages/eui/src/components/illustration/illustration.test.tsx b/packages/eui/src/components/illustration/illustration.test.tsx index 2fc672c3c61..9edf6fdbc70 100644 --- a/packages/eui/src/components/illustration/illustration.test.tsx +++ b/packages/eui/src/components/illustration/illustration.test.tsx @@ -65,6 +65,51 @@ describe('EuiIllustration', () => { }); }); + describe('adaptive', () => { + const adaptiveIllustration: EuiIllustrationSource = { + ...illustration, + adaptive: '', + }; + + const originalCSS = global.CSS; + const stubCSS = (supported: boolean) => { + // jsdom's `CSS.supports` cannot evaluate `light-dark()`, so stub it. + global.CSS = { supports: () => supported } as unknown as typeof CSS; + }; + + beforeEach(() => stubCSS(true)); + afterAll(() => { + global.CSS = originalCSS; + }); + + it('prefers the adaptive SVG when light-dark() is supported', () => { + const { container } = render( + + ); + + expect( + container.querySelector('[data-mode="adaptive"]') + ).toBeInTheDocument(); + expect( + container.querySelector('[data-mode="light"]') + ).not.toBeInTheDocument(); + }); + + it('falls back to the discrete variant when light-dark() is unsupported', () => { + stubCSS(false); + const { container } = render( + + ); + + expect( + container.querySelector('[data-mode="light"]') + ).toBeInTheDocument(); + expect( + container.querySelector('[data-mode="adaptive"]') + ).not.toBeInTheDocument(); + }); + }); + describe('accessibility', () => { it('defaults the accessible label to the illustration title', () => { const { container } = render(); diff --git a/packages/eui/src/components/illustration/illustration.tsx b/packages/eui/src/components/illustration/illustration.tsx index 834341be097..1398fec08a8 100644 --- a/packages/eui/src/components/illustration/illustration.tsx +++ b/packages/eui/src/components/illustration/illustration.tsx @@ -33,6 +33,14 @@ export interface EuiIllustrationSource { readonly light: string; /** Trusted SVG markup for the dark color mode. Inlined verbatim — see the interface's security note. */ readonly dark: string; + /** + * Trusted single-SVG markup whose colors resolve via CSS `light-dark()`, + * driven by the `color-scheme` this component sets from the active + * `colorMode`. Preferred when present and supported; otherwise the component + * falls back to {@link light}/{@link dark}. Inlined verbatim — see the + * interface's security note. + */ + readonly adaptive?: string; } export type EuiIllustrationProps = Omit< @@ -58,11 +66,21 @@ export type EuiIllustrationProps = Omit< fullWidth?: boolean; }; +/** + * Whether the runtime can resolve CSS `light-dark()`. Defaults to `true` when + * `CSS` is unavailable (SSR) so the adaptive markup is chosen consistently on + * the server and on modern clients, avoiding a hydration mismatch. + */ +const supportsLightDark = () => + typeof CSS === 'undefined' || + (CSS.supports?.('color', 'light-dark(#000, #fff)') ?? true); + export const EuiIllustration: FunctionComponent = ({ type, alt, className, fullWidth = true, + style, ...rest }) => { const { colorMode } = useEuiTheme(); @@ -70,7 +88,15 @@ export const EuiIllustration: FunctionComponent = ({ const classes = classNames('euiIllustration', className); const cssStyles = [styles.euiIllustration, fullWidth && styles.fullWidth]; - const svg = colorMode === 'DARK' ? type.dark : type.light; + const isDark = colorMode === 'DARK'; + const useAdaptive = type.adaptive != null && supportsLightDark(); + const svg = useAdaptive ? type.adaptive! : isDark ? type.dark : type.light; + + // Pins `color-scheme` so the adaptive SVG's `light-dark()` colors follow the + // EUI color mode rather than the OS preference. + const inlineStyle = useAdaptive + ? { colorScheme: isDark ? 'dark' : 'light', ...style } + : style; const isDecorative = alt === ''; const a11yProps = isDecorative @@ -81,6 +107,7 @@ export const EuiIllustration: FunctionComponent = ({ (light + dark)"] --> B["src/svgs
name.light.svg + name.dark.svg"] - B --> C["yarn generate
(SVGO optimize + codegen)"] - C --> D["src/generated/*.ts
{ id, title, light, dark }"] - D --> E["build → lib/(cjs|esm) + types"] - E --> F["EuiIllustration picks light/dark
from useEuiTheme().colorMode"] + B --> C["yarn generate
(SVGO optimize + merge)"] + C --> D["src/generated/*.ts
{ id, title, light, dark, adaptive? }"] + C --> G["src/generated/svgs/name.adaptive.svg"] + D --> E["build → lib/(cjs|esm) + types + lib/svgs"] + E --> F["EuiIllustration inlines adaptive
and sets color-scheme from colorMode"] ``` Each generated module satisfies: @@ -31,9 +31,24 @@ type EuiIllustrationSource = { title: string; // 'Dashboard' light: string; // optimized SVG markup dark: string; // optimized SVG markup + adaptive?: string; // single SVG, colors via CSS light-dark() }; ``` +### Adaptive variant + +`generate` merges each `light`/`dark` pair into one SVG: colors that differ between the two are rewritten to `light-dark(, )` inline styles, shared colors are left as-is. The result adapts to the active [`color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme) rather than requiring the consumer to swap files. `EuiIllustration` sets `color-scheme` from the EUI theme so the illustration follows the in-app toggle rather than the OS. + +Merging relies on the two files being structurally identical (same elements, only colors differ). When they diverge — different element counts or tag order — `adaptive` is skipped for that illustration (it keeps `light`/`dark` only) and `generate` logs which ones. Re-export both variants from the same artwork to enable adaptive output. + +The adaptive SVG is also written to `src/generated/svgs/.adaptive.svg` (published to `lib/svgs`) for `` / CSS-background consumers; that file pins `color-scheme: light dark` on its root so it follows the OS preference across an `` boundary: + +```tsx +import dashboard from '@elastic/eui-illustrations/svgs/dashboard.adaptive.svg'; + +Dashboards; +``` + ## Consuming ### With `@elastic/eui` @@ -49,11 +64,18 @@ import { dashboard } from '@elastic/eui-illustrations'; ### Without `@elastic/eui` -The modules are plain data. Pick a mode and inline the markup yourself: +The modules are plain data. Prefer `adaptive` (when present) and let CSS pick the color mode via `color-scheme`, or pick a discrete mode yourself: ```tsx import { dashboard } from '@elastic/eui-illustrations'; +// Adaptive: one inlined SVG, colors follow the ancestor `color-scheme`. +; + +// Or select a discrete variant. const svg = isDarkMode ? dashboard.dark : dashboard.light; ; ``` diff --git a/packages/illustrations/changelogs/upcoming/adaptive_illustrations.md b/packages/illustrations/changelogs/upcoming/adaptive_illustrations.md new file mode 100644 index 00000000000..87cc9988ed4 --- /dev/null +++ b/packages/illustrations/changelogs/upcoming/adaptive_illustrations.md @@ -0,0 +1 @@ +- Added an `adaptive` variant to illustration assets: a single SVG whose colors resolve at runtime via CSS `light-dark()`, generated by merging the `light`/`dark` pair. It is also emitted as `@elastic/eui-illustrations/svgs/.adaptive.svg` for ``/CSS consumers. Illustrations whose `light`/`dark` files are not structurally identical keep `light`/`dark` only. diff --git a/packages/illustrations/package.json b/packages/illustrations/package.json index 017e3e68c77..be14706d6a1 100644 --- a/packages/illustrations/package.json +++ b/packages/illustrations/package.json @@ -18,14 +18,16 @@ "import": "./lib/esm/index.js", "require": "./lib/cjs/index.js", "default": "./lib/cjs/index.js" - } + }, + "./svgs/*": "./lib/svgs/*" }, "scripts": { - "build": "yarn build:clean && yarn generate && yarn build:compile && yarn build:compile:esm && yarn build:types", + "build": "yarn build:clean && yarn generate && yarn build:compile && yarn build:compile:esm && yarn build:types && yarn build:svgs", "build-pack": "yarn build && npm pack", "build:clean": "rimraf dist/ lib/", "build:compile": "NODE_ENV=production babel src --out-dir=lib/cjs --extensions .ts --ignore \"**/*.test.ts\"", "build:compile:esm": "tsc --project ./tsconfig.esm.json", + "build:svgs": "node -e \"require('fs').cpSync('src/generated/svgs','lib/svgs',{recursive:true,force:true})\"", "build:types": "NODE_ENV=production tsc --project tsconfig.types.json", "generate": "node scripts/generate.js", "lint": "yarn generate && tsc --noEmit", diff --git a/packages/illustrations/scripts/generate.js b/packages/illustrations/scripts/generate.js index ef66b1db833..877220fb089 100644 --- a/packages/illustrations/scripts/generate.js +++ b/packages/illustrations/scripts/generate.js @@ -27,6 +27,7 @@ const { optimize } = require('svgo'); const svgsDir = path.resolve(__dirname, '../src/svgs'); const outputDir = path.resolve(__dirname, '../src/generated'); +const svgOutputDir = path.join(outputDir, 'svgs'); const SVG_FILE = /^(.+)\.(light|dark)\.svg$/; @@ -38,6 +39,31 @@ const svgoConfig = { ], }; +/** + * SVGO can rewrite `light-dark()` inline styles (via `minifyStyles`/csso), so it + * is disabled for the adaptive pass. The rest of `preset-default` still runs. + */ +const adaptiveSvgoConfig = { + multipass: true, + plugins: [ + { + name: 'preset-default', + params: { overrides: { removeViewBox: false, minifyStyles: false } }, + }, + 'removeDimensions', + ], +}; + +/** CSS color-bearing properties paired between the light and dark variants. */ +const COLOR_PROPS = [ + 'fill', + 'stroke', + 'stop-color', + 'flood-color', + 'lighting-color', + 'color', +]; + /** `data-viz`/`data_viz` -> `dataViz` for a valid JS identifier. */ const toCamelCase = (name) => name.replace(/[-_](.)/g, (_, char) => char.toUpperCase()); @@ -61,6 +87,140 @@ const optimizeSvg = (filePath) => { return data; }; +/** Parse an SVG into SVGO's XAST without applying any transforms. */ +const parseSvg = (raw) => { + let root; + optimize(raw, { + plugins: [{ name: 'capture-ast', fn: (parsed) => ((root = parsed), {}) }], + }); + return root; +}; + +/** Depth-first, pre-order list of element nodes. */ +const collectElements = (node, acc = []) => { + if (node.type === 'element') acc.push(node); + for (const child of node.children ?? []) collectElements(child, acc); + return acc; +}; + +const parseStyle = (value) => { + const declarations = new Map(); + for (const declaration of (value ?? '').split(';')) { + const separator = declaration.indexOf(':'); + if (separator === -1) continue; + const prop = declaration.slice(0, separator).trim(); + if (prop) declarations.set(prop, declaration.slice(separator + 1).trim()); + } + return declarations; +}; + +const serializeStyle = (declarations) => + Array.from(declarations, ([prop, value]) => `${prop}:${value}`).join(';'); + +const normalizeColor = (value) => + value == null ? value : value.trim().toLowerCase(); + +/** + * Pairs light elements to dark elements positionally. Same-artwork exports keep + * a stable element order, so a matching tag-name sequence is a reliable signal + * that colors can be paired 1:1 (coordinates may drift between re-exports). + * Returns `null` when the trees diverge (different element count or tag + * sequence) — the signal that the pair cannot be safely auto-merged and + * adaptive output should be skipped in favor of the discrete light/dark pair. + */ +const pairElements = (lightElements, darkElements) => { + if (lightElements.length !== darkElements.length) return null; + + const pairs = []; + for (let index = 0; index < lightElements.length; index++) { + const lightNode = lightElements[index]; + const darkNode = darkElements[index]; + if (lightNode.name !== darkNode.name) return null; + pairs.push([lightNode, darkNode]); + } + return pairs; +}; + +const colorValue = (node, prop, style) => + node.attributes[prop] ?? style.get(prop); + +/** + * Rewrites every color that differs between the light and dark variants into a + * `light-dark(light, dark)` inline style on the light tree, leaving shared + * colors untouched. Mutates `lightRoot`. Returns `false` (leaving the tree + * untouched) when the pair cannot be safely merged. + */ +const mergeColorModes = (lightRoot, darkRoot) => { + const pairs = pairElements( + collectElements(lightRoot), + collectElements(darkRoot) + ); + if (!pairs) return false; + + for (const [lightNode, darkNode] of pairs) { + const lightStyle = parseStyle(lightNode.attributes.style); + const darkStyle = parseStyle(darkNode.attributes.style); + let changed = false; + + for (const prop of COLOR_PROPS) { + const lightValue = colorValue(lightNode, prop, lightStyle); + const darkValue = colorValue(darkNode, prop, darkStyle); + if (lightValue == null || darkValue == null) continue; + // Idempotent: never re-wrap an already-merged value into `light-dark()`. + if (/^light-dark\(/i.test(lightValue.trim())) continue; + if (normalizeColor(lightValue) === normalizeColor(darkValue)) continue; + + lightStyle.set(prop, `light-dark(${lightValue}, ${darkValue})`); + delete lightNode.attributes[prop]; + changed = true; + } + + if (changed) lightNode.attributes.style = serializeStyle(lightStyle); + } + return true; +}; + +/** + * Merges a light/dark pair into a single optimized SVG whose colors respond to + * the active `color-scheme` via `light-dark()`. Returns `null` when the pair + * cannot be safely merged (see {@link pairElements}). + * + * The inline flavor omits `color-scheme` so the container controls it (e.g. + * `EuiIllustration` from the EUI theme). The file flavor pins + * `color-scheme: light dark` on the root so ``/CSS-background consumers + * follow the OS preference, which cannot be reached across an `` boundary. + */ +const buildAdaptiveSvg = (lightPath, darkPath) => { + const darkRoot = parseSvg(fs.readFileSync(darkPath, 'utf8')); + let merged = true; + + // Merge colors in a single pass. The merge plugin is not idempotent under + // SVGO's `multipass`, so it must not run in the optimizing pass below. + const { data: mergedSvg } = optimize(fs.readFileSync(lightPath, 'utf8'), { + multipass: false, + path: lightPath, + plugins: [ + { + name: 'merge-color-modes', + fn: (root) => ((merged = mergeColorModes(root, darkRoot)), {}), + }, + ], + }); + + if (!merged) return null; + + const { data: inline } = optimize(mergedSvg, { + ...adaptiveSvgoConfig, + path: lightPath, + }); + + const file = inline.replace(/^( { if (!fs.existsSync(svgsDir)) { throw new Error(`Missing SVG source directory: ${svgsDir}`); @@ -101,12 +261,17 @@ const collectIllustrations = () => { continue; } + const adaptive = buildAdaptiveSvg(modes.light, modes.dark); + if (!adaptive) skippedAdaptive.push(name); + illustrations.push({ id: name, exportName, title: toTitle(name), light: optimizeSvg(modes.light), dark: optimizeSvg(modes.dark), + adaptive: adaptive?.inline, + adaptiveFile: adaptive?.file, }); } @@ -121,7 +286,7 @@ const AUTOGEN_HEADER = '// AUTO-GENERATED by scripts/generate.js — do not edit.\n' + '// Add SVGs to src/svgs (see the script header for layouts) and run `yarn generate`.\n'; -const renderModule = ({ exportName, id, title, light, dark }) => +const renderModule = ({ exportName, id, title, light, dark, adaptive }) => `${AUTOGEN_HEADER} import type { EuiIllustrationSource } from '../types'; @@ -129,7 +294,9 @@ export const ${exportName}: EuiIllustrationSource = { id: ${JSON.stringify(id)}, title: ${JSON.stringify(title)}, light: ${JSON.stringify(light)}, - dark: ${JSON.stringify(dark)}, + dark: ${JSON.stringify(dark)},${ + adaptive ? `\n adaptive: ${JSON.stringify(adaptive)},` : '' + } }; `; @@ -161,20 +328,32 @@ const main = () => { fs.rmSync(outputDir, { recursive: true, force: true }); fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(svgOutputDir, { recursive: true }); for (const illustration of illustrations) { fs.writeFileSync( path.join(outputDir, `${illustration.id}.ts`), renderModule(illustration) ); + if (illustration.adaptiveFile) { + fs.writeFileSync( + path.join(svgOutputDir, `${illustration.id}.adaptive.svg`), + illustration.adaptiveFile + ); + } } fs.writeFileSync(path.join(outputDir, 'index.ts'), renderIndex(illustrations)); - const names = illustrations.map(({ id }) => id).join(', ') || '(none)'; + const adaptiveCount = illustrations.length - skippedAdaptive.length; console.log( - `Generated ${illustrations.length} illustration(s) into src/generated: ${names}` + `Generated ${illustrations.length} illustration(s) into src/generated (${adaptiveCount} adaptive).` ); + if (skippedAdaptive.length) { + console.warn( + `Skipped adaptive output for ${skippedAdaptive.length} illustration(s) whose light/dark files are not structurally identical (they keep light/dark only): ${skippedAdaptive.join(', ')}` + ); + } }; main(); diff --git a/packages/illustrations/src/types.ts b/packages/illustrations/src/types.ts index 88411067c09..dfc797a7e3f 100644 --- a/packages/illustrations/src/types.ts +++ b/packages/illustrations/src/types.ts @@ -25,4 +25,11 @@ export type EuiIllustrationSource = Readonly<{ light: string; /** Optimized SVG markup for the dark color mode. */ dark: string; + /** + * A single optimized SVG whose colors resolve at runtime via CSS + * `light-dark()`, driven by the active `color-scheme`. Absent when the + * `light`/`dark` files are not structurally identical and cannot be safely + * auto-merged; consumers must fall back to {@link light}/{@link dark}. + */ + adaptive?: string; }>; diff --git a/packages/website/docs/components/display/illustrations/index.mdx b/packages/website/docs/components/display/illustrations/index.mdx index 9b73e1e6014..e8034fc9dc5 100644 --- a/packages/website/docs/components/display/illustrations/index.mdx +++ b/packages/website/docs/components/display/illustrations/index.mdx @@ -4,8 +4,10 @@ keywords: [EuiIllustration, illustrations, svg] # Illustrations -**EuiIllustration** renders theme-adaptive SVGs. Each illustration ships a `light` and a `dark` variant -and the component inlines the one that matches the current theme. +**EuiIllustration** renders theme-adaptive SVGs. Most illustrations ship a single `adaptive` variant whose colors +resolve via CSS [`light-dark()`](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/light-dark); the component +inlines it and sets `color-scheme` from the active theme so a single asset recolors in place on toggle. Assets also carry +discrete `light`/`dark` variants, used as a fallback. SVGs are sourced from [`@elastic/eui-illustrations`](https://github.com/elastic/eui/tree/main/packages/illustrations), a standalone package that is **independently versioned and published** from `@elastic/eui`. This means the catalog @@ -13,7 +15,7 @@ can grow and change without an `@elastic/eui` release. ## Component -Pass an illustration asset to the `type` prop. The component picks the correct color-mode variant automatically, +Pass an illustration asset to the `type` prop. The component adapts to the current color mode automatically, so try toggling the theme in the top toolbar. import { dashboard } from '@elastic/eui-illustrations'; @@ -38,6 +40,40 @@ The accessible label defaults to the illustration's `title`. Provide a more spec or pass `alt=""` to mark it as purely decorative (it will be hidden from assistive technology). ::: +## Adaptive color modes + +Most illustrations expose a single `adaptive` SVG whose colors resolve with CSS [`light-dark()`](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/light-dark). The **same markup** renders every color mode depending on the ancestor `color-scheme` — no theme switch or re-render required. **EuiIllustration** sets `color-scheme` from the EUI theme for you; below it is set manually. `light` and `dark` pin the mode, while `light dark` follows the OS/browser preference (try toggling your system dark mode): + + + ```tsx + import React from 'react'; + import { dashboard } from '@elastic/eui-illustrations'; + import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; + import { css } from '@emotion/react'; + + export default () => ( + + {(['light', 'dark', 'light dark'] as const).map((scheme) => ( + + + color-scheme: {scheme} + +
+ + ))} + + ); + ``` + + ## All illustrations Search the full catalog exported from `@elastic/eui-illustrations`. Select an illustration to reveal the code. From 1c5b4ce0b3b3101cb5be76e86493b8e662e7c3bc Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Thu, 9 Jul 2026 17:10:37 -0400 Subject: [PATCH 2/3] [Chore] Rename changelogs to PR number --- .../changelogs/upcoming/{adaptive_illustrations.md => 9797.md} | 0 .../changelogs/upcoming/{adaptive_illustrations.md => 9797.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/eui/changelogs/upcoming/{adaptive_illustrations.md => 9797.md} (100%) rename packages/illustrations/changelogs/upcoming/{adaptive_illustrations.md => 9797.md} (100%) diff --git a/packages/eui/changelogs/upcoming/adaptive_illustrations.md b/packages/eui/changelogs/upcoming/9797.md similarity index 100% rename from packages/eui/changelogs/upcoming/adaptive_illustrations.md rename to packages/eui/changelogs/upcoming/9797.md diff --git a/packages/illustrations/changelogs/upcoming/adaptive_illustrations.md b/packages/illustrations/changelogs/upcoming/9797.md similarity index 100% rename from packages/illustrations/changelogs/upcoming/adaptive_illustrations.md rename to packages/illustrations/changelogs/upcoming/9797.md From 8f353ce354677d5d50de330523c36bdeda4f5598 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Thu, 9 Jul 2026 17:49:40 -0500 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/illustration/illustration.stories.tsx | 12 +++++------- .../eui/src/components/illustration/illustration.tsx | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/eui/src/components/illustration/illustration.stories.tsx b/packages/eui/src/components/illustration/illustration.stories.tsx index d2199149413..40ec299933a 100644 --- a/packages/eui/src/components/illustration/illustration.stories.tsx +++ b/packages/eui/src/components/illustration/illustration.stories.tsx @@ -299,13 +299,11 @@ const AdaptiveExample = () => {

- - - + ); diff --git a/packages/eui/src/components/illustration/illustration.tsx b/packages/eui/src/components/illustration/illustration.tsx index 1398fec08a8..0ae6df249ea 100644 --- a/packages/eui/src/components/illustration/illustration.tsx +++ b/packages/eui/src/components/illustration/illustration.tsx @@ -73,7 +73,7 @@ export type EuiIllustrationProps = Omit< */ const supportsLightDark = () => typeof CSS === 'undefined' || - (CSS.supports?.('color', 'light-dark(#000, #fff)') ?? true); + CSS.supports?.('color', 'light-dark(#000, #fff)') === true; export const EuiIllustration: FunctionComponent = ({ type,