Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions demo/e2e/device-colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
50 changes: 50 additions & 0 deletions demo/e2e/material-foregrounds.spec.ts
Original file line number Diff line number Diff line change
@@ -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`) });
});
}
6 changes: 4 additions & 2 deletions demo/e2e/neutral-palette.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/device-button-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/device-button-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/device-card-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/device-card-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/device-modal-card-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/device-segment-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/device-segment-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/device-select-alert-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/device-select-modal-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/device-toolbar-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/device-toolbar-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/segment-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/toggle-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions demo/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
51 changes: 49 additions & 2 deletions demo/src/theme/dynamic-colors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -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('');
Expand Down
8 changes: 5 additions & 3 deletions docs/dynamic-colors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions src/dynamic-colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> = {
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,
Expand All @@ -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(',');
Expand Down
Loading