diff --git a/README.md b/README.md index c893625..989980f 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,10 @@ Only `npm run release` can create a release tag. Stable `vX.Y.Z` tags (major, mi ## Android dynamic colors -Opt into Material You colors with light, dark, or system appearance. See +Opt into supported Android 12+ Material You colors with light, dark, or system appearance. +Read colors from `@rdlabo/capacitor-native-ui` and apply them with +`@rdlabo/ionic-theme-md3/dynamic-colors`; Native UI projection is not required. +Ionic palettes remain the fallback on other platforms. See [dynamic colors](docs/dynamic-colors.md) for setup, lifecycle, and role mapping. ## Color system diff --git a/demo/src/theme/dynamic-colors.ts b/demo/src/theme/dynamic-colors.ts index c32e2bd..fcb4626 100644 --- a/demo/src/theme/dynamic-colors.ts +++ b/demo/src/theme/dynamic-colors.ts @@ -2,8 +2,8 @@ import { signal } from '@angular/core'; import { Capacitor, registerPlugin } from '@capacitor/core'; import { enableDynamicColors, type ColorMode, type DynamicColorScheme, type DynamicColorTheme } from '../../../src/dynamic-colors'; -// Keep browser builds compatible with published plugins while the native API is released. -// Android requires the companion capacitor-plugin PR; use its local build to test it. +// The demo adapter keeps browser builds independent of the installed plugin exports. +// Android needs a native build with the getter (plugin 2.1.3+); apps should use the public root exports. const native = registerPlugin<{ getDynamicColorScheme(): Promise; addListener(name: 'dynamicColorSchemeChange', listener: () => void): Promise<{ remove(): Promise }>; diff --git a/docs/color-system.md b/docs/color-system.md index 403b782..dea1131 100644 --- a/docs/color-system.md +++ b/docs/color-system.md @@ -51,7 +51,9 @@ roles still follow Ionic. No separate registration or complete scheme is needed. Material You is one optional provider of these values. See [dynamic colors](dynamic-colors.md) for native acquisition and appearance control. The provider preserves Material's `on*` roles for MD3 components; Ionic named -colors still use Ionic's official contrast/shade/tint calculations. The native +colors use supplied `onPrimary`, `onSecondary`, `onTertiary` and `onError` for their +contrast values, with Ionic's contrast calculation as the per-role fallback. +Shade and tint retain Ionic's calculations. The native API also exposes optional fixed/inverse roles for custom component designs. For your own Sass components, import `utils/colors` from the package's exported @@ -64,32 +66,41 @@ pattern with an Ionic fallback. ## Component assignments +For ordinary surfaces, **base** means `surface-container-lowest` in light mode and +`surface-container-low` in dark mode. **Panel** means `surface-container-lowest` +in light mode and `surface-container-high` in dark mode. Missing roles retain the +component's Ionic background fallback. This keeps default light surfaces white; +`color="light"` is a separate palette selection (`surface-container-low` in light, +`surface-container-highest` in dark), not an alias for every default background. + The table lists optional Material role consumers. If a role is absent, the component retains the Ionic fallback described above. -| Component / variant | Background | Foreground / detail | -| -------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| filled button | primary | on-primary | -| tonal button (`md3-tonal`, solid fill) | secondary-container | on-secondary-container | -| elevated button (`md3-elevated`, solid fill) | surface-container-low | primary | -| outlined / text button | transparent | primary; outline border for outlined | -| card (default elevated) | surface-container-low | on-surface; on-surface-variant subtitle | -| filled card (`md3-filled`) | surface-container-highest | on-surface | -| outlined card (`md3-outlined`) | surface | on-surface; outline-variant border | -| FAB | primary-container | on-primary-container | -| chip / selected chip (`md3-selected`) | surface-container-low / secondary-container | on-surface-variant / on-secondary-container | -| content / menu / toolbar | surface-container | on-surface | -| ordinary list item | surface | on-surface | -| inset list item | surface-bright | on-surface; transparent 2px gap | -| navigation bar | surface-container | on-surface-variant; secondary-container selected indicator | -| segment | inherited surface | primary selected; on-surface-variant unselected | -| search field | surface-container-high | on-surface; on-surface-variant icon; Ionic placeholder behavior | -| switch, checkbox, radio, progress, slider | neutral surface or secondary-container when off/inactive; primary when selected | matching on-primary, outline, or on-surface-variant | -| text input | Ionic fill-state background | on-surface; primary focus; error invalid state | -| modal / popover | surface-container-low | Ionic text; nested components resolve their own roles | -| action sheet | surface-container-low | on-surface action buttons; on-surface-variant title | -| alert / loading / datetime / picker | surface-container-high | on-surface; primary actions | -| snackbar/toast | inverse-surface | inverse-on-surface; inverse-primary actions | +| Component / variant | Background | Foreground / detail | +| -------------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| filled button | primary | on-primary | +| tonal button (`md3-tonal`, solid fill) | secondary-container | on-secondary-container | +| elevated button (`md3-elevated`, solid fill) | surface-container-low | primary | +| outlined / text button | transparent | primary; outline border for outlined | +| card (default elevated) | Panel | on-surface; on-surface-variant subtitle | +| filled card (`md3-filled`) | surface-container-highest | on-surface | +| outlined card (`md3-outlined`) | surface | on-surface; outline-variant border | +| FAB | primary-container | on-primary-container | +| chip / selected chip (`md3-selected`) | surface-container-low / secondary-container | on-surface-variant / on-secondary-container | +| content / menu | Base | on-surface | +| toolbar | Panel | on-surface | +| list / ordinary list item | Panel | on-surface | +| inset list item | Panel; surface before Ionic fallback when panel role is absent | on-surface; transparent 2px gap | +| navigation bar | Panel | on-surface-variant; secondary-container selected indicator / on-secondary-container selected icon | +| segment | inherited surface | primary selected; on-surface-variant unselected | +| search field | surface-container-high; surface-container-highest inside an uncolored dark toolbar/item | on-surface; on-surface-variant icon; Ionic placeholder behavior | +| switch, checkbox, radio, progress, slider | neutral surface or secondary-container when off/inactive; primary when selected | matching on-primary, outline, or on-surface-variant | +| text input | Ionic fill-state background | on-surface; primary focus; error invalid state | +| modal / popover | Base | Ionic text; nested components resolve their own roles; select popover options share the overlay background | +| action sheet | Base | on-surface actions; primary preferred action; error destructive action; on-surface-variant title | +| alert | Base | on-surface; preferred primary/on-primary; destructive error/on-error; outlined cancel | +| loading / datetime / picker | surface-container-high | on-surface; primary actions/spinner | +| snackbar/toast | inverse-surface | inverse-on-surface; inverse-primary actions | The existing Ionic `color="..."` remains an explicit palette selection. These MD3 defaults do not replace it. `md3-disabled` opts an individual component out @@ -109,8 +120,8 @@ changes, optional-role removal, explicit colors, opt-outs, and a document with application JavaScript disabled. Android 17 Settings is the visual reference; Material guidelines supply supporting -information. When supplied, `surface-container` refines page/menu backgrounds and -`surface-bright` refines inset rows. Without those roles, Ionic backgrounds remain +information. The base/panel roles above refine ordinary surfaces without +replacing their Ionic color intent. Without those roles, Ionic backgrounds remain unchanged. Groups retain 20px outer corners and 2px gaps. The assignments above name optional roles; they do not imply generated surface colors for omitted roles. diff --git a/docs/dynamic-colors.md b/docs/dynamic-colors.md index 816271c..8b0cb71 100644 --- a/docs/dynamic-colors.md +++ b/docs/dynamic-colors.md @@ -1,16 +1,44 @@ # Android dynamic colors -Dynamic colors are opt-in. Android 12+ supplies light and dark Material color +Dynamic colors are supported and opt-in. Android 12+ supplies light and dark Material color schemes; this package converts their roles using the Ionic Docs color generator algorithms. Older Android, iOS, and Web use the existing Ionic palette and component color defaults. See [color system](color-system.md): Ionic palettes alone are sufficient; native Material roles only refine optional CSS tokens. -Import the experimental `getDynamicColorScheme` and `addDynamicColorListener` +Import `getDynamicColorScheme` and `addDynamicColorListener` APIs from the `@rdlabo/capacitor-native-ui` root entrypoint. No activation of Native UI projection is needed. The theme conversion helper remains available from `@rdlabo/ionic-theme-md3/dynamic-colors`. +## Setup + +Install the theme and the native provider in your Capacitor application: + +```sh +npm install @rdlabo/ionic-theme-md3 @rdlabo/capacitor-native-ui +npx cap sync android +npx cap run android +``` + +The plugin's root exports and native getter are included in 2.1.3; an older native +binary (for example 2.1.1) must be rebuilt, not just supplied with new JavaScript. +Use a theme release containing the mapping described below. The source guide can +include changes newer than the installed npm release; check its release notes. +The [plugin guide](https://github.com/webview-native-ui/capacitor-plugin/blob/main/docs/dynamic-colors.md) +documents availability, role types and native notifications. + +Follow the [theme installation](../README.md) for Ionic and theme stylesheet order. +Include the class-based dark palette in your global stylesheet: + +```scss +@import '@ionic/core/css/palettes/dark.class.css'; +``` + +For Ionic Angular, `@ionic/angular/css/palettes/dark.class.css` is equivalent. +Initialize once at application startup in the browser, after the DOM exists. +This helper writes to a document and must not run during server rendering. + ```ts import { getDynamicColorScheme, addDynamicColorListener } from '@rdlabo/capacitor-native-ui'; import { enableDynamicColors } from '@rdlabo/ionic-theme-md3/dynamic-colors'; @@ -28,11 +56,14 @@ theme.setMode('light'); theme.setMode('system'); await theme.refresh(); // Optional explicit wallpaper/palette refresh. + +// During application teardown, not immediately after initialization: await theme.destroy(); // Unsubscribe and restore original root properties/class. ``` -Import `@ionic/core/css/palettes/dark.class.css` (or the Ionic Angular equivalent) -in your global stylesheet. Do not also import `dark.system.css` or +## Appearance and lifecycle + +Do not also import `dark.system.css` or `dark.always.css`: this controller owns the `ion-palette-dark` class on `document.documentElement` and the root `color-scheme` property. Keep global palette overrides on that root; variables on body or individual components @@ -50,6 +81,19 @@ palette. Automatic refresh failures reach `onError`; an explicit `theme.refresh( Initialization errors reject and clean up. There may be one controller per document. Call `destroy()` on application teardown. +| Controller method | Behavior | +| ----------------- | ------------------------------------------------------------------------------------------------- | +| `setMode(mode)` | Selects `system`, `light` or `dark` synchronously using cached schemes; no native read. | +| `refresh()` | Reads again and applies the selected appearance; rejects on failure. | +| `destroy()` | Removes listeners and restores the original inline properties and dark class; safe to call again. | + +When `available` is false, the controller preserves/restores the application's +palette while still managing the selected appearance. An initialization failure +rejects the `enableDynamicColors()` promise and cleans up listeners; `onError` +handles automatic refresh errors, not initialization or explicit `refresh()` calls. +Do not have a second appearance controller write the same root class/properties. +The optional `document` option targets a specific browser document. + ## Mapping | Material role | Ionic token | @@ -83,21 +127,42 @@ An explicit `color="light"`, for example, keeps the Ionic light role while follo For conversion without DOM changes, use `materialToIonicColors(scheme)` for Ionic variables and `materialToMd3Colors(partialRoles)` for optional MD3 tokens. -Inputs are opaque `#RRGGBB` strings. No runtime color-library dependency is used. +Both conversion functions return `Record` CSS variable maps. +`materialToIonicColors` needs the six required `MaterialColorScheme` roles; +`materialToMd3Colors` accepts any subset of `MaterialColorRole` names and emits +only the supplied roles. Inputs are opaque `#RRGGBB` strings; invalid supplied +colors throw `TypeError`. Neither function writes to the DOM or calls the bridge. +No runtime color-library dependency is used. Algorithm attribution and the upstream license are in `THIRD_PARTY_NOTICES.md`. -## Demo and checks - -The demo includes System / Light / Dark controls and imports `dark.class.css`. -Browser builds use the published plugin dependency and a small typed bridge -adapter, so they do not depend on unreleased JavaScript exports. To verify Android -before the companion plugin release, build the sibling capacitor-plugin checkout, -install that local build in the demo, run Capacitor sync, and build Android. -Applications using the published plugin need a release containing the new APIs. - -Run `npm run test:dynamic-colors` after installing the demo's dependencies. -Reference fixtures were generated using the unmodified Ionic Docs `Color` class -at commit `019986b8ef86f7ad235ceefa34e3ac40e617b0ba`. +## Troubleshooting and verification + +- **No dynamic colors:** Android 12+ is required. On other platforms the Ionic + palette remains active; this is expected fallback behavior. +- **Native getter not implemented:** update the plugin, sync, rebuild and + reinstall the native app. Updating web assets alone is insufficient. +- **Dark mode conflicts:** use only the class-based dark palette and let one + controller own the root class. Check for body/component overrides that shadow + root variables. +- **A refresh fails:** automatic failures reach `onError` and retain the last + working palette. Catch explicit `refresh()` failures in the caller. +- **A component ignores a role:** check its explicit `color`, `md3-disabled`, + component CSS variables and the [consumer table](color-system.md#component-assignments). + +The demo includes System / Light / Dark controls. Its typed bridge adapter also +supports browser-only builds; Android still requires a native plugin containing +`getDynamicColorScheme`. The adapter is demo infrastructure, not the public import +pattern for applications. + +Run `npm run test:dynamic-colors` after installing root and demo dependencies. +Browser image tests use recorded Android colors, so they do not require an attached +device. Screenshot baseline updates run through the PR's `/update-screenshots` CI. + +The [foreground-pair change](https://github.com/webview-native-ui/ionic-theme-md3/pull/67) +was checked on a Pixel 8a running Android 17: four explicitly colored Ionic buttons +matched the native background/foreground values in both modes. Final Ionic 8 and 9 +CI each passed 34 unit tests and 265 E2E tests without flaky results. This is color-pair +verification, not full-device UI or wallpaper-change acceptance. ## Validation notes @@ -114,7 +179,7 @@ a separate test disables application JavaScript. The [2026-11-07 screenshot-update CI](https://github.com/webview-native-ui/ionic-theme-md3/actions/runs/42332891975) verified 141 tests on each of Ionic 8 and 9, including 84 light/dark screenshots. See [Ionic color inheritance review](validation/ionic-color-inheritance-2026-11-07.md) -for the latest color-policy correction and its browser-only verification scope. +for that historical color-policy correction and its browser-only verification scope. A local WebView measurement of the expanded API (30 sequential native reads, 1,000 warmed conversion iterations) gave approximately 9 ms median / 21 ms p90