diff --git a/demo/e2e/device-colors.ts b/demo/e2e/device-colors.ts index b54c887..934af89 100644 --- a/demo/e2e/device-colors.ts +++ b/demo/e2e/device-colors.ts @@ -86,8 +86,7 @@ export const expectDeviceColorPairs = async (page: Page, mode: 'light' | 'dark') ['card-outlined', roles.surface, roles.onSurface], ['fab', roles.primaryContainer, roles.onPrimaryContainer], ['chip-selected', roles.secondaryContainer, roles.onSecondaryContainer], - // Explicit Ionic danger keeps Ionic's contrast choice rather than Material onError. - ['danger', roles.error, mode === 'light' ? '#ffffff' : '#000000'], + ['danger', roles.error, roles.onError], ]; const rgb = (hex: string) => `rgb(${[1, 3, 5].map((offset) => parseInt(hex.slice(offset, offset + 2), 16)).join(', ')})`; for (const [id, background, foreground] of pairs) { diff --git a/demo/e2e/material-foregrounds.spec.ts b/demo/e2e/material-foregrounds.spec.ts new file mode 100644 index 0000000..6e5a863 --- /dev/null +++ b/demo/e2e/material-foregrounds.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; +import { mockDeviceColors, expectDeviceColors, expectDeviceColorPairs } from './device-colors'; +import scheme from './fixtures/android17-device-colors.json'; + +for (const mode of ['light', 'dark'] as const) { + test(`${mode}: explicit Ionic colors render native foreground pairs`, async ({ page }, testInfo) => { + await mockDeviceColors(page, mode); + await page.setViewportSize({ width: 800, height: 1000 }); + await page.goto('/main/index/color-palette'); + await expectDeviceColors(page, mode); + await expectDeviceColorPairs(page, mode); + await page.evaluate(() => { + const section = document.createElement('section'); + section.id = 'native-foreground-pairs'; + section.style.cssText = 'position:fixed;inset:80px 20px auto;z-index:10000;padding:20px;background:var(--ion-background-color)'; + for (const color of ['primary', 'secondary', 'tertiary', 'danger']) { + const row = document.createElement('div'); + for (const tag of ['ion-button', 'ion-card']) { + const el = document.createElement(tag); + el.setAttribute('color', color); + el.textContent = `${tag}: ${color}`; + row.append(el); + } + section.append(row); + } + document.body.append(section); + }); + const rgb = (hex: string) => `rgb(${[1, 3, 5].map((offset) => parseInt(hex.slice(offset, offset + 2), 16)).join(', ')})`; + for (const [name, base, on] of [ + ['primary', 'primary', 'onPrimary'], + ['secondary', 'secondary', 'onSecondary'], + ['tertiary', 'tertiary', 'onTertiary'], + ['danger', 'error', 'onError'], + ] as const) { + for (const tag of ['ion-button', 'ion-card']) { + const el = page.locator(`#native-foreground-pairs ${tag}[color=${name}]`); + await expect(el).toHaveClass(/hydrated/); + await expect + .poll(() => + el.evaluate((el) => { + const style = getComputedStyle(el.shadowRoot?.querySelector('[part=native]') ?? el); + return { background: style.backgroundColor, foreground: style.color }; + }), + ) + .toEqual({ background: rgb(scheme[mode][base]), foreground: rgb(scheme[mode][on]) }); + } + } + await page.locator('#native-foreground-pairs').screenshot({ path: testInfo.outputPath(`foreground-pairs-${mode}.png`) }); + }); +} diff --git a/demo/e2e/neutral-palette.spec.ts b/demo/e2e/neutral-palette.spec.ts index c83829a..ba09d20 100644 --- a/demo/e2e/neutral-palette.spec.ts +++ b/demo/e2e/neutral-palette.spec.ts @@ -71,8 +71,10 @@ test('dark device search fields remain distinct from toolbar panels', async ({ p await mockDeviceColors(page, 'dark'); await page.goto('/main/index/searchbar'); await expectDeviceColors(page, 'dark'); - const fields = page.locator('app-searchbar ion-toolbar:not(.ion-color) ion-searchbar .searchbar-input'); - expect(await fields.count()).toBeGreaterThan(0); + const searchbars = page.locator('app-searchbar ion-toolbar:not(.ion-color) ion-searchbar'); + await expect(searchbars.first()).toBeVisible(); + const fields = searchbars.locator('.searchbar-input'); + await expect(fields).toHaveCount(await searchbars.count()); const rgb = (hex: string) => `rgb(${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(', ')})`; for (const field of await fields.all()) { await expect(field).toHaveCSS('background-color', rgb(scheme.dark.surfaceContainerHighest)); diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-button-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/device-button-dark.png index 42e4235..c629b67 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-button-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-button-dark.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-button-light.png b/demo/e2e/screenshot.spec.ts-snapshots/device-button-light.png index 26a1825..fe0c537 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-button-light.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-button-light.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-card-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/device-card-dark.png index 2c10301..b00feb7 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-card-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-card-dark.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-card-light.png b/demo/e2e/screenshot.spec.ts-snapshots/device-card-light.png index d32b50d..ee33e72 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-card-light.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-card-light.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-color-palette-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/device-color-palette-dark.png index c047a51..7cc6baf 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-color-palette-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-color-palette-dark.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-color-palette-light.png b/demo/e2e/screenshot.spec.ts-snapshots/device-color-palette-light.png index 3e319e5..9472274 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-color-palette-light.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-color-palette-light.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-date-and-time-pickers-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/device-date-and-time-pickers-dark.png index 6a6ddaa..650bea6 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-date-and-time-pickers-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-date-and-time-pickers-dark.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-date-and-time-pickers-light.png b/demo/e2e/screenshot.spec.ts-snapshots/device-date-and-time-pickers-light.png index 5fbc0e8..69b42e7 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-date-and-time-pickers-light.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-date-and-time-pickers-light.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-modal-card-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/device-modal-card-dark.png index dbdad75..9626788 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-modal-card-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-modal-card-dark.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-modal-card-light.png b/demo/e2e/screenshot.spec.ts-snapshots/device-modal-card-light.png index c623bf0..dcedb12 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-modal-card-light.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-modal-card-light.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-modal-normal-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/device-modal-normal-dark.png index ee17452..d6769de 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-modal-normal-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-modal-normal-dark.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-modal-normal-light.png b/demo/e2e/screenshot.spec.ts-snapshots/device-modal-normal-light.png index f1a4efd..f168536 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-modal-normal-light.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-modal-normal-light.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-segment-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/device-segment-dark.png index dbf420c..12d739b 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-segment-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-segment-dark.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-segment-light.png b/demo/e2e/screenshot.spec.ts-snapshots/device-segment-light.png index b822971..1059cfc 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-segment-light.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-segment-light.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-select-alert-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/device-select-alert-dark.png index ecb223e..c240b00 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-select-alert-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-select-alert-dark.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-select-alert-light.png b/demo/e2e/screenshot.spec.ts-snapshots/device-select-alert-light.png index b6c921d..5843e79 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-select-alert-light.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-select-alert-light.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-select-modal-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/device-select-modal-dark.png index 6670450..a165aef 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-select-modal-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-select-modal-dark.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-select-modal-light.png b/demo/e2e/screenshot.spec.ts-snapshots/device-select-modal-light.png index 1cb64e6..c3f5e0b 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-select-modal-light.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-select-modal-light.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-toolbar-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/device-toolbar-dark.png index 822c0c1..69eeb7d 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-toolbar-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-toolbar-dark.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/device-toolbar-light.png b/demo/e2e/screenshot.spec.ts-snapshots/device-toolbar-light.png index dd1aff4..005a089 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/device-toolbar-light.png and b/demo/e2e/screenshot.spec.ts-snapshots/device-toolbar-light.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/segment-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/segment-dark.png index 5c73580..8dbf47e 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/segment-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/segment-dark.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toggle-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/toggle-dark.png index e9d0c1d..c9b064b 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toggle-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/toggle-dark.png differ diff --git a/demo/playwright.config.ts b/demo/playwright.config.ts index cec349b..8f6460e 100644 --- a/demo/playwright.config.ts +++ b/demo/playwright.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env['CI'], + failOnFlakyTests: !!process.env['CI'], retries: process.env['CI'] ? 2 : 0, reporter: process.env['CI'] ? [['list'], ['html']] : 'html', use: { diff --git a/demo/src/theme/dynamic-colors.spec.ts b/demo/src/theme/dynamic-colors.spec.ts index 87e44c7..9c9d592 100644 --- a/demo/src/theme/dynamic-colors.spec.ts +++ b/demo/src/theme/dynamic-colors.spec.ts @@ -48,6 +48,53 @@ describe('Ionic Docs reference fixtures (019986b8)', () => { }); }); +describe('optional Material foreground pairs', () => { + for (const mode of ['light', 'dark'] as const) { + it(`${mode}: preserves all four device foregrounds and their RGB values`, () => { + const scheme = deviceScheme[mode]; + const result = materialToIonicColors(scheme); + for (const [name, role] of [ + ['primary', 'onPrimary'], + ['secondary', 'onSecondary'], + ['tertiary', 'onTertiary'], + ['danger', 'onError'], + ] as const) { + expect(result[`--ion-color-${name}-contrast`]).toBe(scheme[role].toLowerCase()); + expect(result[`--ion-color-${name}-contrast-rgb`]).toBe( + [1, 3, 5].map((offset) => parseInt(scheme[role].slice(offset, offset + 2), 16)).join(','), + ); + } + }); + } + it('changes only supplied foreground pairs, leaving other Ionic values intact', () => { + const original = materialToIonicColors(light); + const result = materialToIonicColors({ ...light, onSecondary: '#AbCdEf' }); + expect(result).toEqual({ + ...original, + '--ion-color-secondary-contrast': '#abcdef', + '--ion-color-secondary-contrast-rgb': '171,205,239', + }); + }); + it('rejects invalid supplied foregrounds instead of silently replacing them', () => { + for (const role of ['onPrimary', 'onSecondary', 'onTertiary', 'onError']) { + expect(() => materialToIonicColors({ ...light, [role]: 'invalid' })).toThrow(TypeError); + } + }); + it('restores computed contrast when optional roles disappear after refresh', async () => { + let snapshot: DynamicColorScheme = { ...available(), available: true, light: { ...light, onPrimary: '#fff0ff' }, dark }; + theme = await enableDynamicColors({ getScheme: async () => snapshot }); + expect(document.documentElement.style.getPropertyValue('--ion-color-primary-contrast')).toBe('#fff0ff'); + snapshot = available(); + await theme.refresh(); + expect(document.documentElement.style.getPropertyValue('--ion-color-primary-contrast')).toBe( + materialToIonicColors(light)['--ion-color-primary-contrast'], + ); + expect(document.documentElement.style.getPropertyValue('--ion-color-primary-contrast-rgb')).toBe( + materialToIonicColors(light)['--ion-color-primary-contrast-rgb'], + ); + }); +}); + describe('dynamic theme lifecycle', () => { it('switches cached light/dark schemes without native calls and follows system only in system mode', async () => { let snapshot = available(); @@ -218,7 +265,7 @@ describe('dynamic theme lifecycle', () => { }); expect(() => materialToMd3Colors({ outline: 'invalid' })).toThrow(TypeError); }); - it('uses native role pairs while keeping the Ionic generator output intact', async () => { + it('uses native role pairs for both Material and Ionic foregrounds', async () => { theme = await enableDynamicColors({ getScheme: async () => ({ available: true, @@ -229,7 +276,7 @@ describe('dynamic theme lifecycle', () => { }); const style = document.documentElement.style; expect(style.getPropertyValue('--md-sys-color-on-primary')).toBe('#ffffee'); - expect(style.getPropertyValue('--ion-color-primary-contrast')).toBe('#ffffff'); + expect(style.getPropertyValue('--ion-color-primary-contrast')).toBe('#ffffee'); expect(style.getPropertyValue('--md-sys-color-primary-container')).toBe('#aabbcc'); theme.setMode('dark'); expect(style.getPropertyValue('--md-sys-color-primary-container')).toBe(''); diff --git a/docs/dynamic-colors.md b/docs/dynamic-colors.md index 4326547..816271c 100644 --- a/docs/dynamic-colors.md +++ b/docs/dynamic-colors.md @@ -65,9 +65,11 @@ There may be one controller per document. Call `destroy()` on application teardo The native bridge supplies the supported Material color roles for both modes; the original six fields remain the compatibility inputs and additional roles are optional in the TypeScript contract. Named colors generate base, RGB, contrast, -contrast RGB, shade and tint. Contrast -uses Ionic's black/white contrast-ratio comparison, rather than Material's `on*` -roles. MD3 components use the supplied `on*` role when present. Background and text generate separate step-50 through step-950 tokens; +contrast RGB, shade and tint. Primary, secondary, tertiary and danger use the +supplied `onPrimary`, `onSecondary`, `onTertiary` and `onError` roles for contrast +and contrast RGB. Missing roles fall back individually to Ionic's black/white +contrast-ratio comparison. Shade/tint generation and the light/medium/dark palettes +retain the Ionic calculation. MD3 components also use the supplied `on*` role when present. Background and text generate separate step-50 through step-950 tokens; legacy `--ion-color-step-*` aliases are also supplied. Success and warning retain their existing Ionic values. Light, medium, and dark follow the device neutral palette; their RGB, contrast, shade and tint values are generated together. The light base uses surfaceContainerLowest (white), while the dark base uses surfaceContainerLow. Older six-role schemes fall back to surface for background/light and a surface/onSurface midpoint for medium. Default light surfaces remain white: Content, Menu, Toolbar, TabBar, Card, lists/items and Modal/Popover/Alert/ActionSheet. In dark mode, pages and overlay shells use surfaceContainerLow, while Toolbar, TabBar, Card and lists/items use surfaceContainerHigh. The named light palette uses surfaceContainerHighest in dark mode, preserving the order of page, panel and light-colored surfaces instead of flattening them all to black. These roles resolve at each component so local overrides continue to work. Without optional roles, existing Ionic background variables remain the fallback. diff --git a/src/dynamic-colors.ts b/src/dynamic-colors.ts index c744a50..8a40ea1 100644 --- a/src/dynamic-colors.ts +++ b/src/dynamic-colors.ts @@ -84,10 +84,16 @@ const luminance = (value: RGB): number => { return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; }; -/** Matches Ionic Docs generateColor and generateSteppedColors (see THIRD_PARTY_NOTICES.md). */ +/** Uses Ionic Docs generators, with supplied Material on-colors overriding contrast (see THIRD_PARTY_NOTICES.md). */ export const materialToIonicColors = (scheme: MaterialColorScheme): IonicColorVariables => { const variables: IonicColorVariables = {}; const dark = luminance(rgb(scheme.surface)) < luminance(rgb(scheme.onSurface)); + const foregrounds: Record = { + primary: scheme.onPrimary, + secondary: scheme.onSecondary, + tertiary: scheme.onTertiary, + danger: scheme.onError, + }; for (const [name, value] of Object.entries({ primary: scheme.primary, secondary: scheme.secondary, @@ -99,7 +105,9 @@ export const materialToIonicColors = (scheme: MaterialColorScheme): IonicColorVa })) { const base = rgb(value); const light = luminance(base); - const contrast: RGB = (light + 0.05) / 0.05 >= 1.05 / (light + 0.05) ? [0, 0, 0] : [255, 255, 255]; + const foreground = foregrounds[name]; + const contrast: RGB = + foreground !== undefined ? rgb(foreground) : (light + 0.05) / 0.05 >= 1.05 / (light + 0.05) ? [0, 0, 0] : [255, 255, 255]; const prefix = `--ion-color-${name}`; variables[prefix] = hex(base); variables[`${prefix}-rgb`] = base.join(',');