@isoline-ui/native 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Isoline UI contributors
4
+ Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/NOTICE.md ADDED
@@ -0,0 +1,16 @@
1
+ # Source notices
2
+
3
+ The initial primitives were extracted with the owner's authorisation from the
4
+ UI code maintained for Yours, Inboki and Atlas. Product data, credentials,
5
+ artwork and feature code are not included.
6
+
7
+ The existing MIT notice for 650 Industries, Inc. (Expo) is retained in LICENSE.
8
+ Isoline-specific functional glyphs use simple original SVG paths. App-specific
9
+ Material Symbols icon collections and brand fonts are not bundled.
10
+
11
+ Runtime packages remain subject to their respective licences. Apps consuming
12
+ Isoline should regenerate their dependency licence notices after integration.
13
+
14
+ Android system-palette reading and dynamic color interpolation were extracted
15
+ with the owner's authorisation from Yours (pserrer/mail). The native glass
16
+ implementation is provided by the separately installed expo-glass-effect peer.
package/README.md CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @isoline-ui/native
2
+
3
+ Native system appearance for Isoline UI, distributed as an Expo module.
4
+
5
+ - Android 12+: reads the device's five Material You tonal palettes.
6
+ - iOS 26+: `MaterialSurface` uses native Liquid Glass through `expo-glass-effect`.
7
+ - Web: CSS backdrop-filter approximation with solid fallbacks for unsupported browsers, reduced transparency and forced colors.
8
+ - Unsupported devices, a missing Android bridge and reduced transparency use solid theme surfaces.
9
+
10
+ Install **both** Isoline archives from the same GitHub prerelease, then let Expo
11
+ select the glass dependency for the app's SDK:
12
+
13
+ ```sh
14
+ npm install https://github.com/isoline-ui/isoline-ui/releases/download/v0.1.0-alpha.2/isoline-ui-react-native-0.1.0-alpha.2.tgz https://github.com/isoline-ui/isoline-ui/releases/download/v0.1.0-alpha.2/isoline-ui-native-0.1.0-alpha.2.tgz
15
+ npx expo install expo-glass-effect
16
+ ```
17
+
18
+ Keep the UI package's existing NativeWind and native peer setup. This package
19
+ supports Expo SDK 57 and 58; do not force the SDK 58 glass version into SDK 57.
20
+
21
+ ```tsx
22
+ import { IsolineProvider, themes, Text } from '@isoline-ui/react-native';
23
+ import { MaterialSurface, useSystemTheme } from '@isoline-ui/native';
24
+
25
+ function AppContent() {
26
+ // Supply your app's persisted preference and its active light/dark scheme.
27
+ const theme = useSystemTheme(themes.teal.light, true);
28
+ return (
29
+ <IsolineProvider theme={theme}>
30
+ <MaterialSurface style={{ borderRadius: 24, padding: 20 }}>
31
+ <Text>A shared component with a platform material</Text>
32
+ </MaterialSurface>
33
+ </IsolineProvider>
34
+ );
35
+ }
36
+ ```
37
+
38
+ `useSystemTheme(base, enabled)` preserves typography and semantic error/success
39
+ colors. It refreshes the palette when the app becomes active and when system
40
+ appearance changes. The supplied base controls light/dark mode; preferences and
41
+ persistence remain app responsibilities. `getSystemPalette()` and
42
+ `useSystemPalette()` expose the validated raw palette for custom integrations.
43
+
44
+ `MaterialSurface` accepts View props plus `material: 'auto' | 'glass' | 'solid'`,
45
+ `fallbackColor`, `tintColor`, `glassStyle: 'regular' | 'clear'` and `interactive`.
46
+ Even explicit `glass` respects platform support and Reduce Transparency.
47
+ `interactive` enables native material interaction; add your own accessible
48
+ button to handle actions. Style glass with layout/radius/padding, avoiding opaque
49
+ backgrounds and opacity animations on the glass or its ancestors. Use it for
50
+ selected floating controls and navigation surfaces; it does not turn an entire
51
+ screen into a native toolbar, tab bar or SwiftUI control.
52
+
53
+ ## Native linking and builds
54
+
55
+ The npm name `@isoline-ui/native` is scoped; the Kotlin namespace
56
+ `isoline.modules.systemcolors` and bridge name `IsolineSystemColors` are separate.
57
+ Expo discovers the module from `expo-module.config.json` in the installed package.
58
+ The archive contains the Android Gradle project and Kotlin sources. No manual
59
+ `nativeModulesDir` entry is required for this dependency.
60
+
61
+ The new bridge name allows coexistence with Yours' old `ExpoSystemColors` during
62
+ migration. Once consumers switch to the shared hook, remove their unused local
63
+ module and palette/theme logic. Do not copy the new native module into `modules/`
64
+ as well as installing it.
65
+
66
+ After adding or changing native dependencies, rebuild the development client or
67
+ app (`npx expo run:android`, `npx expo run:ios`, or EAS Build). For projects that
68
+ maintain native folders manually, follow their existing Gradle/CocoaPods workflow.
69
+ An OTA update cannot add native code: use an appropriate Expo runtime version.
70
+ Expo Go does not contain the custom Isoline Android module and uses the fallback.
71
+
72
+ The showcase and CI test source types, palette mapping, installed-tarball
73
+ Autolinking and Metro exports. These checks do not establish device rendering,
74
+ accessibility behavior or native compilation; see `docs/native.md`.
75
+
76
+ ## Shared navigation surfaces
77
+
78
+ Register `surfaceComponent={MaterialSurface}` and
79
+ `surfaceGroupComponent={MaterialGroup}` on `IsolineProvider` using the optional
80
+ `@isoline-ui/native` adapters. Core UI works without Expo using solid fallbacks.
81
+
82
+ The shared `MaterialOptions` contract (`material`, `glassStyle`, `tintColor`,
83
+ `fallbackColor`, `interactive`) supports buttons/icon buttons, search, segmented
84
+ controls, pickers, floating/split actions, toolbars, navigation, tabs, popovers,
85
+ context menus and optional overlay surfaces. `TabBar` is controlled; routing
86
+ remains in the app. `Popover` anchors, flips and clamps a scrollable panel.
87
+ Context menus and pickers support disabled and selected options. Action menus
88
+ also support destructive actions.
89
+
90
+ Navigation and anchored menus default to automatic material. Form/action controls,
91
+ dialogs, sheets and snackbars default to solid; opt in per surface. Android stays
92
+ solid Material UI. iOS uses native glass where available and respects Reduce
93
+ Transparency. `SurfaceGroup` uses native `GlassContainer` to blend nearby glass
94
+ surfaces and can animate `gap`, respecting reduced motion. Browser groups animate
95
+ spacing only: CSS backdrop blur approximates glass without native merging or
96
+ refraction. Use a contrasting background to make translucency visible.
97
+
98
+ The new controls require native/device verification in consumer builds; browser
99
+ checks and Metro exports do not verify UIKit rendering or accessibility.
@@ -0,0 +1,18 @@
1
+ // Same layout as modules/expo-imap: expo-module-gradle-plugin applies the
2
+ // Kotlin plugin and configures publishing itself.
3
+ plugins {
4
+ id 'com.android.library'
5
+ id 'expo-module-gradle-plugin'
6
+ }
7
+
8
+ group = 'isoline.modules.systemcolors'
9
+ version = '0.1.0'
10
+
11
+ android {
12
+ namespace 'isoline.modules.systemcolors'
13
+
14
+ defaultConfig {
15
+ versionCode 1
16
+ versionName '0.1.0'
17
+ }
18
+ }
@@ -0,0 +1,123 @@
1
+ package isoline.modules.systemcolors
2
+
3
+ import android.annotation.TargetApi
4
+ import android.content.Context
5
+ import android.os.Build
6
+ import expo.modules.kotlin.modules.Module
7
+ import expo.modules.kotlin.modules.ModuleDefinition
8
+
9
+ /**
10
+ * The device's Material You tonal palettes — the colors Android 12+ derives
11
+ * from the wallpaper (or the user's chosen theme) and hands to every app that
12
+ * asks, which is where Gmail and the system apps get their colors from.
13
+ *
14
+ * Only the raw palettes cross the bridge. Which tone of which palette plays
15
+ * which role is decided in `src/dynamic-color.ts`, where it can be tested
16
+ * and changed over the air.
17
+ */
18
+ class IsolineSystemColorsModule : Module() {
19
+ override fun definition() = ModuleDefinition {
20
+ Name("IsolineSystemColors")
21
+
22
+ // Synchronous: a handful of resource lookups, and the first frame needs
23
+ // the answer to paint in the right colors rather than flash the default.
24
+ Function("getPalette") {
25
+ val context = appContext.reactContext ?: return@Function null
26
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return@Function null
27
+ readPalette(context)
28
+ }
29
+ }
30
+
31
+ @TargetApi(Build.VERSION_CODES.S)
32
+ private fun readPalette(context: Context): Map<String, List<String>> = mapOf(
33
+ "accent1" to tones(context, ACCENT1),
34
+ "accent2" to tones(context, ACCENT2),
35
+ "accent3" to tones(context, ACCENT3),
36
+ "neutral1" to tones(context, NEUTRAL1),
37
+ "neutral2" to tones(context, NEUTRAL2),
38
+ )
39
+
40
+ private fun tones(context: Context, ids: IntArray): List<String> =
41
+ ids.map { id -> String.format("#%06X", 0xFFFFFF and context.getColor(id)) }
42
+
43
+ private companion object {
44
+ // Each palette in the order of its stops: _0 (tone 100, white) through
45
+ // _1000 (tone 0, black). `PALETTE_TONES` in `src/dynamic-color.ts`
46
+ // mirrors this order.
47
+ val ACCENT1 = intArrayOf(
48
+ android.R.color.system_accent1_0,
49
+ android.R.color.system_accent1_10,
50
+ android.R.color.system_accent1_50,
51
+ android.R.color.system_accent1_100,
52
+ android.R.color.system_accent1_200,
53
+ android.R.color.system_accent1_300,
54
+ android.R.color.system_accent1_400,
55
+ android.R.color.system_accent1_500,
56
+ android.R.color.system_accent1_600,
57
+ android.R.color.system_accent1_700,
58
+ android.R.color.system_accent1_800,
59
+ android.R.color.system_accent1_900,
60
+ android.R.color.system_accent1_1000,
61
+ )
62
+ val ACCENT2 = intArrayOf(
63
+ android.R.color.system_accent2_0,
64
+ android.R.color.system_accent2_10,
65
+ android.R.color.system_accent2_50,
66
+ android.R.color.system_accent2_100,
67
+ android.R.color.system_accent2_200,
68
+ android.R.color.system_accent2_300,
69
+ android.R.color.system_accent2_400,
70
+ android.R.color.system_accent2_500,
71
+ android.R.color.system_accent2_600,
72
+ android.R.color.system_accent2_700,
73
+ android.R.color.system_accent2_800,
74
+ android.R.color.system_accent2_900,
75
+ android.R.color.system_accent2_1000,
76
+ )
77
+ val ACCENT3 = intArrayOf(
78
+ android.R.color.system_accent3_0,
79
+ android.R.color.system_accent3_10,
80
+ android.R.color.system_accent3_50,
81
+ android.R.color.system_accent3_100,
82
+ android.R.color.system_accent3_200,
83
+ android.R.color.system_accent3_300,
84
+ android.R.color.system_accent3_400,
85
+ android.R.color.system_accent3_500,
86
+ android.R.color.system_accent3_600,
87
+ android.R.color.system_accent3_700,
88
+ android.R.color.system_accent3_800,
89
+ android.R.color.system_accent3_900,
90
+ android.R.color.system_accent3_1000,
91
+ )
92
+ val NEUTRAL1 = intArrayOf(
93
+ android.R.color.system_neutral1_0,
94
+ android.R.color.system_neutral1_10,
95
+ android.R.color.system_neutral1_50,
96
+ android.R.color.system_neutral1_100,
97
+ android.R.color.system_neutral1_200,
98
+ android.R.color.system_neutral1_300,
99
+ android.R.color.system_neutral1_400,
100
+ android.R.color.system_neutral1_500,
101
+ android.R.color.system_neutral1_600,
102
+ android.R.color.system_neutral1_700,
103
+ android.R.color.system_neutral1_800,
104
+ android.R.color.system_neutral1_900,
105
+ android.R.color.system_neutral1_1000,
106
+ )
107
+ val NEUTRAL2 = intArrayOf(
108
+ android.R.color.system_neutral2_0,
109
+ android.R.color.system_neutral2_10,
110
+ android.R.color.system_neutral2_50,
111
+ android.R.color.system_neutral2_100,
112
+ android.R.color.system_neutral2_200,
113
+ android.R.color.system_neutral2_300,
114
+ android.R.color.system_neutral2_400,
115
+ android.R.color.system_neutral2_500,
116
+ android.R.color.system_neutral2_600,
117
+ android.R.color.system_neutral2_700,
118
+ android.R.color.system_neutral2_800,
119
+ android.R.color.system_neutral2_900,
120
+ android.R.color.system_neutral2_1000,
121
+ )
122
+ }
123
+ }
@@ -0,0 +1,4 @@
1
+ import * as React from 'react';
2
+ import type { MaterialGroupProps } from './material-types.ts';
3
+ /** Web animates layout only. It does not imitate native merging/refraction. */
4
+ export declare function MaterialGroup({ spacing, gap, animate, style, ...props }: MaterialGroupProps): React.JSX.Element;
@@ -0,0 +1,4 @@
1
+ import * as React from 'react';
2
+ import type { MaterialGroupProps } from './material-types.ts';
3
+ /** Native merging follows the smoothly changing layout, with a reduced-motion path. */
4
+ export declare function MaterialGroup({ spacing, gap, animate, style, ...props }: MaterialGroupProps): React.JSX.Element;
@@ -0,0 +1,4 @@
1
+ import * as React from 'react';
2
+ import type { MaterialSurfaceProps } from './material-types.ts';
3
+ export type { MaterialSurfaceProps } from './material-types.ts';
4
+ export declare function MaterialSurface({ material, fallbackColor, tintColor, glassStyle, interactive, style, ...props }: MaterialSurfaceProps): React.JSX.Element;
@@ -0,0 +1,4 @@
1
+ import * as React from 'react';
2
+ import type { MaterialSurfaceProps } from './material-types.ts';
3
+ export type { MaterialSurfaceProps } from './material-types.ts';
4
+ export declare function MaterialSurface({ material, fallbackColor, tintColor, glassStyle, interactive, style, ...props }: MaterialSurfaceProps): React.JSX.Element;
@@ -0,0 +1,5 @@
1
+ import * as React from 'react';
2
+ import type { MaterialSurfaceProps } from './material-types.ts';
3
+ export type { MaterialSurfaceProps } from './material-types.ts';
4
+ /** CSS approximation only: browsers cannot render Apple's native Liquid Glass. */
5
+ export declare function MaterialSurface({ material, fallbackColor, tintColor, glassStyle, interactive, style, ...props }: MaterialSurfaceProps): React.JSX.Element;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Material You: the app's color roles, taken from the device's own palette.
3
+ *
4
+ * Android 12+ derives five tonal palettes from the wallpaper (or a theme the
5
+ * user picked) — three accents and two neutrals — and every app that follows
6
+ * the system, Gmail included, draws its roles from them. The native side
7
+ * (`modules/expo-system-colors`) only hands the palettes over; this module
8
+ * decides which tone of which palette plays each role, so the mapping can be
9
+ * tested and changed over the air.
10
+ *
11
+ * Tones follow the Material 3 scheme for the accent roles. The surfaces take
12
+ * their cue from Gmail in the light scheme: the screen behind everything is
13
+ * tinted from the secondary palette, and the cards on it (thread rows,
14
+ * settings groups) stay near-white, so the wallpaper shows in the space
15
+ * around the content rather than in the content itself. Chips, fields and
16
+ * button groups (`surface-container-high`) take a tinted fill so they still
17
+ * read against a white card. The dark scheme keeps the app's own neutral
18
+ * tones, including its deliberate inversion (the background sits lighter
19
+ * than the containers on it, see `global.css`).
20
+ *
21
+ * Pure and free of `expo-*` imports so it runs under `node --test`.
22
+ */
23
+ /** Tone of each stop in a palette as Android reports it: `_0` (white) through `_1000` (black). */
24
+ export declare const PALETTE_TONES: readonly [100, 99, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0];
25
+ declare const PALETTE_KEYS: readonly ["accent1", "accent2", "accent3", "neutral1", "neutral2"];
26
+ type PaletteKey = (typeof PALETTE_KEYS)[number];
27
+ /** The device's tonal palettes, each a `#rrggbb` per stop in `PALETTE_TONES` order. */
28
+ export type SystemPalette = Readonly<Record<PaletteKey, readonly string[]>>;
29
+ /**
30
+ * Narrows what the native module returned to a usable palette, or `null`.
31
+ *
32
+ * Anything short of five complete palettes of valid colors is refused whole
33
+ * rather than patched: a half-read palette would paint some roles from the
34
+ * wallpaper and leave others unset.
35
+ */
36
+ export declare function parseSystemPalette(value: unknown): SystemPalette | null;
37
+ /** Whether two palettes hold the same colors, so a re-read that changed nothing re-renders nothing. */
38
+ export declare function samePalette(a: SystemPalette | null, b: SystemPalette | null): boolean;
39
+ /** One scheme's roles, both as `#rrggbb` and as the `H S% L%` triplet `global.css` variables hold. */
40
+ export interface DynamicRoles {
41
+ hex: Readonly<Record<string, string>>;
42
+ vars: Readonly<Record<string, string>>;
43
+ }
44
+ /**
45
+ * The role colors for one scheme. Cached per palette object: this runs on
46
+ * every render of every component that reads a theme color, and the palette
47
+ * only changes when the wallpaper does.
48
+ */
49
+ export declare function dynamicRoles(palette: SystemPalette, scheme: 'light' | 'dark'): DynamicRoles;
50
+ /**
51
+ * A palette's color at any tone, `#rrggbb`.
52
+ *
53
+ * Android reports thirteen stops, and several roles sit between them
54
+ * (the light background at 94, the dark one at 11). Material
55
+ * tone is CIE L*, so a tone between two stops is found by interpolating in
56
+ * CIELAB: L* lands exactly on the tone asked for, and chroma eases between
57
+ * the neighbours' — fading out toward black and white as it should.
58
+ */
59
+ export declare function paletteTone(stops: readonly string[], tone: number): string;
60
+ export type Rgb = [number, number, number];
61
+ export type Lab = [number, number, number];
62
+ export declare function hexToRgb(hex: string): Rgb;
63
+ export declare function hexToLab(hex: string): Lab;
64
+ /** `#rrggbb` to the `H S% L%` triplet a `global.css` variable holds, one decimal place each. */
65
+ export declare function hexToHslTriplet(hex: string): string;
66
+ /** `#rrggbb` to hue in degrees, saturation and lightness in percent, unrounded. */
67
+ export declare function hexToHsl(hex: string): [number, number, number];
68
+ export {};
@@ -0,0 +1,2 @@
1
+ /** Fail closed until accessibility preferences are known; newer events win. */
2
+ export declare function useNativeGlassAvailable(): boolean;
@@ -0,0 +1,5 @@
1
+ import type { IsolineTheme } from '@isoline-ui/react-native/theme';
2
+ import { type SystemPalette } from './dynamic-color.ts';
3
+ /** Refreshes when returning from wallpaper settings or changing system appearance. */
4
+ export declare function useSystemPalette(enabled?: boolean): SystemPalette | null;
5
+ export declare function useSystemTheme(base: IsolineTheme, enabled?: boolean): IsolineTheme;
@@ -0,0 +1,9 @@
1
+ export { MaterialSurface } from './MaterialSurface';
2
+ export type { MaterialSurfaceProps } from './material-types.ts';
3
+ export { getSystemPalette } from './system-colors.ts';
4
+ export { useSystemPalette, useSystemTheme } from './hooks.ts';
5
+ export { applySystemPalette } from './theme.ts';
6
+ export { parseSystemPalette, dynamicRoles, PALETTE_TONES } from './dynamic-color.ts';
7
+ export type { SystemPalette, DynamicRoles } from './dynamic-color.ts';
8
+ export { MaterialGroup } from './MaterialGroup';
9
+ export type { MaterialGroupProps } from './material-types.ts';
@@ -0,0 +1,5 @@
1
+ import type { IsolineSurfaceProps, IsolineSurfaceGroupProps } from '@isoline-ui/react-native';
2
+ export interface MaterialSurfaceProps extends IsolineSurfaceProps {
3
+ }
4
+ export interface MaterialGroupProps extends IsolineSurfaceGroupProps {
5
+ }
@@ -0,0 +1,3 @@
1
+ import { type SystemPalette } from './dynamic-color.ts';
2
+ /** Missing native code and unsupported Android versions use the supplied app theme. */
3
+ export declare function getSystemPalette(): SystemPalette | null;
@@ -0,0 +1,4 @@
1
+ import type { IsolineTheme } from '@isoline-ui/react-native/theme';
2
+ import { type SystemPalette } from './dynamic-color.ts';
3
+ /** Keeps brand typography and semantic error/success colors. */
4
+ export declare function applySystemPalette(base: IsolineTheme, palette: SystemPalette | null): IsolineTheme;
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["android"],
3
+ "android": {
4
+ "modules": ["isoline.modules.systemcolors.IsolineSystemColorsModule"]
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
- {
2
- "name": "@isoline-ui/native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@isoline-ui/native",
3
+ "version": "1.0.0",
4
+ "description": "Native system colors and platform materials for Isoline UI",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "react-native": "./src/index.ts",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "react-native": "./src/index.ts",
14
+ "default": "./src/index.ts"
15
+ },
16
+ "./theme": {
17
+ "types": "./dist/dynamic-color.d.ts",
18
+ "default": "./src/dynamic-color.ts"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "dist",
25
+ "android",
26
+ "expo-module.config.json",
27
+ "README.md",
28
+ "LICENSE",
29
+ "NOTICE.md"
30
+ ],
31
+ "sideEffects": false,
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/isoline-ui/isoline-ui.git",
35
+ "directory": "packages/native"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "peerDependencies": {
41
+ "@isoline-ui/react-native": "0.1.0-alpha.3",
42
+ "expo": ">=57 <59",
43
+ "expo-glass-effect": ">=57 <59",
44
+ "react": ">=19.2 <20",
45
+ "react-native": ">=0.86 <0.89",
46
+ "react-native-reanimated": ">=4.5.0 <5"
47
+ },
48
+ "scripts": {
49
+ "typecheck": "tsc --project tsconfig.json",
50
+ "build": "tsc --project tsconfig.build.json"
51
+ }
52
+ }
@@ -0,0 +1,33 @@
1
+ import * as React from 'react';
2
+ import { View } from 'react-native';
3
+ import { GlassContainer } from 'expo-glass-effect';
4
+ import Animated, {
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withSpring,
8
+ } from 'react-native-reanimated';
9
+ import { spatial, useReducedMotion } from '@isoline-ui/react-native';
10
+ import { useNativeGlassAvailable } from './glass-support.ios.ts';
11
+ import type { MaterialGroupProps } from './material-types.ts';
12
+ const AnimatedContainer = Animated.createAnimatedComponent(GlassContainer);
13
+ /** Native merging follows the smoothly changing layout, with a reduced-motion path. */
14
+ export function MaterialGroup({
15
+ spacing,
16
+ gap = 0,
17
+ animate = true,
18
+ style,
19
+ ...props
20
+ }: MaterialGroupProps) {
21
+ const supported = useNativeGlassAvailable();
22
+ const reduced = useReducedMotion();
23
+ const current = useSharedValue(gap);
24
+ React.useEffect(() => {
25
+ current.value = animate && !reduced ? withSpring(gap, spatial.default) : gap;
26
+ }, [gap, animate, reduced, current]);
27
+ const layout = useAnimatedStyle(() => ({ gap: current.value }));
28
+ return supported ? (
29
+ <AnimatedContainer spacing={spacing} {...props} style={[layout, style]} />
30
+ ) : (
31
+ <View {...props} style={[{ gap }, style]} />
32
+ );
33
+ }
@@ -0,0 +1,22 @@
1
+ import * as React from 'react';
2
+ import { Platform, View, type ViewStyle } from 'react-native';
3
+ import { useReducedMotion } from '@isoline-ui/react-native';
4
+ import type { MaterialGroupProps } from './material-types.ts';
5
+ /** Web animates layout only. It does not imitate native merging/refraction. */
6
+ export function MaterialGroup({
7
+ spacing,
8
+ gap,
9
+ animate = true,
10
+ style,
11
+ ...props
12
+ }: MaterialGroupProps) {
13
+ const reduced = useReducedMotion();
14
+ const motion =
15
+ Platform.OS === 'web'
16
+ ? ({
17
+ transitionProperty: 'gap',
18
+ transitionDuration: animate && !reduced ? '180ms' : '0ms',
19
+ } as ViewStyle)
20
+ : undefined;
21
+ return <View {...props} style={[gap !== undefined && { gap }, motion, style]} />;
22
+ }
@@ -0,0 +1,39 @@
1
+ import * as React from 'react';
2
+ import { View } from 'react-native';
3
+ import { GlassView } from 'expo-glass-effect';
4
+ import { useNativeGlassAvailable } from './glass-support.ios.ts';
5
+ import { useThemeColors, useIsolineTheme } from '@isoline-ui/react-native';
6
+ import type { MaterialSurfaceProps } from './material-types.ts';
7
+ export type { MaterialSurfaceProps } from './material-types.ts';
8
+
9
+ export function MaterialSurface({
10
+ material = 'auto',
11
+ fallbackColor,
12
+ tintColor,
13
+ glassStyle = 'regular',
14
+ interactive = false,
15
+ style,
16
+ ...props
17
+ }: MaterialSurfaceProps) {
18
+ const colors = useThemeColors();
19
+ const { scheme } = useIsolineTheme();
20
+ const supported = useNativeGlassAvailable();
21
+ if (material === 'solid' || !supported) {
22
+ return (
23
+ <View
24
+ {...props}
25
+ style={[{ backgroundColor: fallbackColor ?? colors.surfaceContainer }, style]}
26
+ />
27
+ );
28
+ }
29
+ return (
30
+ <GlassView
31
+ {...props}
32
+ style={style}
33
+ glassEffectStyle={glassStyle}
34
+ tintColor={tintColor}
35
+ isInteractive={interactive}
36
+ colorScheme={scheme}
37
+ />
38
+ );
39
+ }
@@ -0,0 +1,23 @@
1
+ import * as React from 'react';
2
+ import { View } from 'react-native';
3
+ import { useThemeColors } from '@isoline-ui/react-native';
4
+ import type { MaterialSurfaceProps } from './material-types.ts';
5
+ export type { MaterialSurfaceProps } from './material-types.ts';
6
+
7
+ export function MaterialSurface({
8
+ material,
9
+ fallbackColor,
10
+ tintColor,
11
+ glassStyle,
12
+ interactive,
13
+ style,
14
+ ...props
15
+ }: MaterialSurfaceProps) {
16
+ const colors = useThemeColors();
17
+ return (
18
+ <View
19
+ {...props}
20
+ style={[{ backgroundColor: fallbackColor ?? colors.surfaceContainer }, style]}
21
+ />
22
+ );
23
+ }
@@ -0,0 +1,62 @@
1
+ import * as React from 'react';
2
+ import { View, type ViewStyle } from 'react-native';
3
+ import { useThemeColors, useIsolineTheme } from '@isoline-ui/react-native';
4
+ import type { MaterialSurfaceProps } from './material-types.ts';
5
+ export type { MaterialSurfaceProps } from './material-types.ts';
6
+
7
+ /** CSS approximation only: browsers cannot render Apple's native Liquid Glass. */
8
+ export function MaterialSurface({
9
+ material = 'auto',
10
+ fallbackColor,
11
+ tintColor,
12
+ glassStyle = 'regular',
13
+ interactive,
14
+ style,
15
+ ...props
16
+ }: MaterialSurfaceProps) {
17
+ const colors = useThemeColors();
18
+ const { scheme } = useIsolineTheme();
19
+ const [supported, setSupported] = React.useState(false);
20
+ React.useEffect(() => {
21
+ const transparency = window.matchMedia('(prefers-reduced-transparency: reduce)');
22
+ const contrast = window.matchMedia('(forced-colors: active)');
23
+ const update = () =>
24
+ setSupported(
25
+ !transparency.matches &&
26
+ !contrast.matches &&
27
+ typeof CSS !== 'undefined' &&
28
+ (CSS.supports('backdrop-filter', 'blur(1px)') ||
29
+ CSS.supports('-webkit-backdrop-filter', 'blur(1px)')),
30
+ );
31
+ update();
32
+ transparency.addEventListener('change', update);
33
+ contrast.addEventListener('change', update);
34
+ return () => {
35
+ transparency.removeEventListener('change', update);
36
+ contrast.removeEventListener('change', update);
37
+ };
38
+ }, []);
39
+ const glass = material !== 'solid' && supported;
40
+ const glassStyleWeb = {
41
+ backgroundColor:
42
+ tintColor ??
43
+ (scheme === 'dark'
44
+ ? `rgba(28,28,32,${glassStyle === 'clear' ? 0.35 : 0.72})`
45
+ : `rgba(255,255,255,${glassStyle === 'clear' ? 0.35 : 0.72})`),
46
+ backdropFilter: 'blur(24px) saturate(160%)',
47
+ WebkitBackdropFilter: 'blur(24px) saturate(160%)',
48
+ borderWidth: 1,
49
+ borderColor: scheme === 'dark' ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.65)',
50
+ boxShadow: '0px 4px 20px rgba(0,0,0,0.12)',
51
+ } as ViewStyle;
52
+ return (
53
+ <View
54
+ {...props}
55
+ style={[
56
+ { backgroundColor: fallbackColor ?? colors.surfaceContainer },
57
+ glass && glassStyleWeb,
58
+ style,
59
+ ]}
60
+ />
61
+ );
62
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Material You: the app's color roles, taken from the device's own palette.
3
+ *
4
+ * Android 12+ derives five tonal palettes from the wallpaper (or a theme the
5
+ * user picked) — three accents and two neutrals — and every app that follows
6
+ * the system, Gmail included, draws its roles from them. The native side
7
+ * (`modules/expo-system-colors`) only hands the palettes over; this module
8
+ * decides which tone of which palette plays each role, so the mapping can be
9
+ * tested and changed over the air.
10
+ *
11
+ * Tones follow the Material 3 scheme for the accent roles. The surfaces take
12
+ * their cue from Gmail in the light scheme: the screen behind everything is
13
+ * tinted from the secondary palette, and the cards on it (thread rows,
14
+ * settings groups) stay near-white, so the wallpaper shows in the space
15
+ * around the content rather than in the content itself. Chips, fields and
16
+ * button groups (`surface-container-high`) take a tinted fill so they still
17
+ * read against a white card. The dark scheme keeps the app's own neutral
18
+ * tones, including its deliberate inversion (the background sits lighter
19
+ * than the containers on it, see `global.css`).
20
+ *
21
+ * Pure and free of `expo-*` imports so it runs under `node --test`.
22
+ */
23
+
24
+ /** Tone of each stop in a palette as Android reports it: `_0` (white) through `_1000` (black). */
25
+ export const PALETTE_TONES = [100, 99, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0] as const;
26
+
27
+ const PALETTE_KEYS = ['accent1', 'accent2', 'accent3', 'neutral1', 'neutral2'] as const;
28
+
29
+ type PaletteKey = (typeof PALETTE_KEYS)[number];
30
+
31
+ /** The device's tonal palettes, each a `#rrggbb` per stop in `PALETTE_TONES` order. */
32
+ export type SystemPalette = Readonly<Record<PaletteKey, readonly string[]>>;
33
+
34
+ const HEX_COLOR = /^#[0-9a-f]{6}$/i;
35
+
36
+ /**
37
+ * Narrows what the native module returned to a usable palette, or `null`.
38
+ *
39
+ * Anything short of five complete palettes of valid colors is refused whole
40
+ * rather than patched: a half-read palette would paint some roles from the
41
+ * wallpaper and leave others unset.
42
+ */
43
+ export function parseSystemPalette(value: unknown): SystemPalette | null {
44
+ if (typeof value !== 'object' || value === null) return null;
45
+ const palette: Partial<Record<PaletteKey, readonly string[]>> = {};
46
+ for (const key of PALETTE_KEYS) {
47
+ const tones: unknown = Reflect.get(value, key);
48
+ if (!Array.isArray(tones) || tones.length !== PALETTE_TONES.length) return null;
49
+ const colors: string[] = [];
50
+ for (const tone of tones) {
51
+ if (typeof tone !== 'string' || !HEX_COLOR.test(tone)) return null;
52
+ colors.push(tone.toLowerCase());
53
+ }
54
+ palette[key] = colors;
55
+ }
56
+ const { accent1, accent2, accent3, neutral1, neutral2 } = palette;
57
+ if (!accent1 || !accent2 || !accent3 || !neutral1 || !neutral2) return null;
58
+ return { accent1, accent2, accent3, neutral1, neutral2 };
59
+ }
60
+
61
+ /** Whether two palettes hold the same colors, so a re-read that changed nothing re-renders nothing. */
62
+ export function samePalette(a: SystemPalette | null, b: SystemPalette | null): boolean {
63
+ if (a === b) return true;
64
+ if (!a || !b) return false;
65
+ return PALETTE_KEYS.every((key) => a[key].every((color, index) => color === b[key][index]));
66
+ }
67
+
68
+ type Role = readonly [palette: PaletteKey, light: number, dark: number];
69
+
70
+ /**
71
+ * Every CSS variable `global.css` defines that the accent tints, plus the
72
+ * tertiary roles (which keep a fixed indigo under the presets but follow the
73
+ * wallpaper's third accent here, as they do in the system apps). Error and
74
+ * the triage colors stay fixed: they carry meaning, not decoration.
75
+ */
76
+ const ROLES: Record<string, Role> = {
77
+ '--background': ['accent2', 94, 11],
78
+ '--foreground': ['neutral1', 10, 90],
79
+ '--surface': ['accent2', 94, 11],
80
+ '--surface-variant': ['neutral2', 90, 25],
81
+ '--on-surface': ['neutral1', 10, 90],
82
+ '--on-surface-variant': ['neutral2', 30, 80],
83
+ '--surface-container-lowest': ['neutral1', 100, 6],
84
+ '--surface-container-low': ['neutral1', 99, 8],
85
+ '--surface-container': ['neutral1', 97, 13],
86
+ '--surface-container-high': ['accent2', 90, 17],
87
+ '--surface-container-highest': ['accent2', 87, 22],
88
+ '--card': ['neutral1', 99, 13],
89
+ '--card-foreground': ['neutral1', 10, 90],
90
+ '--popover': ['neutral1', 97, 17],
91
+ '--popover-foreground': ['neutral1', 10, 90],
92
+ '--primary': ['accent1', 40, 80],
93
+ '--primary-foreground': ['accent1', 100, 20],
94
+ '--primary-container': ['accent1', 90, 30],
95
+ '--on-primary-container': ['accent1', 10, 90],
96
+ '--secondary-container': ['accent2', 90, 30],
97
+ '--on-secondary-container': ['accent2', 10, 90],
98
+ '--tertiary': ['accent3', 40, 80],
99
+ '--on-tertiary': ['accent3', 100, 20],
100
+ '--tertiary-container': ['accent3', 90, 30],
101
+ '--on-tertiary-container': ['accent3', 10, 90],
102
+ '--muted': ['neutral2', 90, 25],
103
+ '--muted-foreground': ['neutral2', 30, 80],
104
+ '--accent': ['accent2', 90, 22],
105
+ '--accent-foreground': ['accent1', 10, 90],
106
+ '--outline': ['neutral2', 50, 60],
107
+ '--outline-variant': ['neutral2', 80, 30],
108
+ '--border': ['neutral2', 80, 22],
109
+ '--input': ['neutral2', 55, 45],
110
+ '--ring': ['accent1', 40, 80],
111
+ '--inverse-surface': ['neutral1', 20, 90],
112
+ '--inverse-on-surface': ['neutral1', 95, 20],
113
+ '--inverse-primary': ['accent1', 80, 40],
114
+ };
115
+
116
+ /** One scheme's roles, both as `#rrggbb` and as the `H S% L%` triplet `global.css` variables hold. */
117
+ export interface DynamicRoles {
118
+ hex: Readonly<Record<string, string>>;
119
+ vars: Readonly<Record<string, string>>;
120
+ }
121
+
122
+ const cache = new WeakMap<SystemPalette, { light?: DynamicRoles; dark?: DynamicRoles }>();
123
+
124
+ /**
125
+ * The role colors for one scheme. Cached per palette object: this runs on
126
+ * every render of every component that reads a theme color, and the palette
127
+ * only changes when the wallpaper does.
128
+ */
129
+ export function dynamicRoles(palette: SystemPalette, scheme: 'light' | 'dark'): DynamicRoles {
130
+ const entry = cache.get(palette) ?? {};
131
+ const cached = entry[scheme];
132
+ if (cached) return cached;
133
+
134
+ const hex: Record<string, string> = {};
135
+ const vars: Record<string, string> = {};
136
+ for (const [name, [key, light, dark]] of Object.entries(ROLES)) {
137
+ const color = paletteTone(palette[key], scheme === 'light' ? light : dark);
138
+ hex[name] = color;
139
+ vars[name] = hexToHslTriplet(color);
140
+ }
141
+ const roles = { hex, vars };
142
+ entry[scheme] = roles;
143
+ cache.set(palette, entry);
144
+ return roles;
145
+ }
146
+
147
+ /**
148
+ * A palette's color at any tone, `#rrggbb`.
149
+ *
150
+ * Android reports thirteen stops, and several roles sit between them
151
+ * (the light background at 94, the dark one at 11). Material
152
+ * tone is CIE L*, so a tone between two stops is found by interpolating in
153
+ * CIELAB: L* lands exactly on the tone asked for, and chroma eases between
154
+ * the neighbours' — fading out toward black and white as it should.
155
+ */
156
+ export function paletteTone(stops: readonly string[], tone: number): string {
157
+ const target = Math.min(100, Math.max(0, tone));
158
+ for (let index = 0; index < PALETTE_TONES.length - 1; index++) {
159
+ const upper = PALETTE_TONES[index];
160
+ const lower = PALETTE_TONES[index + 1];
161
+ if (target > upper || target < lower) continue;
162
+ if (target === upper) return stops[index];
163
+ if (target === lower) return stops[index + 1];
164
+ const t = (upper - target) / (upper - lower);
165
+ const from = hexToLab(stops[index]);
166
+ const to = hexToLab(stops[index + 1]);
167
+ return labToHex([target, from[1] + (to[1] - from[1]) * t, from[2] + (to[2] - from[2]) * t]);
168
+ }
169
+ return stops[stops.length - 1];
170
+ }
171
+
172
+ export type Rgb = [number, number, number];
173
+ export type Lab = [number, number, number];
174
+
175
+ export function hexToRgb(hex: string): Rgb {
176
+ const value = parseInt(hex.slice(1), 16);
177
+ return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
178
+ }
179
+
180
+ function rgbToHex([r, g, b]: Rgb): string {
181
+ const channel = (value: number) =>
182
+ Math.round(Math.min(255, Math.max(0, value)))
183
+ .toString(16)
184
+ .padStart(2, '0');
185
+ return `#${channel(r)}${channel(g)}${channel(b)}`;
186
+ }
187
+
188
+ // sRGB (D65) <-> CIELAB.
189
+ const WHITE: Rgb = [95.047, 100, 108.883];
190
+
191
+ function toLinear(channel: number): number {
192
+ const c = channel / 255;
193
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
194
+ }
195
+
196
+ function fromLinear(value: number): number {
197
+ const c = value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055;
198
+ return c * 255;
199
+ }
200
+
201
+ function labF(t: number): number {
202
+ return t > 216 / 24389 ? Math.cbrt(t) : ((24389 / 27) * t + 16) / 116;
203
+ }
204
+
205
+ function labFInverse(t: number): number {
206
+ return t ** 3 > 216 / 24389 ? t ** 3 : (116 * t - 16) / (24389 / 27);
207
+ }
208
+
209
+ export function hexToLab(hex: string): Lab {
210
+ const [r, g, b] = hexToRgb(hex).map(toLinear);
211
+ const x = (0.4124 * r + 0.3576 * g + 0.1805 * b) * 100;
212
+ const y = (0.2126 * r + 0.7152 * g + 0.0722 * b) * 100;
213
+ const z = (0.0193 * r + 0.1192 * g + 0.9505 * b) * 100;
214
+ const fx = labF(x / WHITE[0]);
215
+ const fy = labF(y / WHITE[1]);
216
+ const fz = labF(z / WHITE[2]);
217
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
218
+ }
219
+
220
+ function labToHex([l, a, b]: Lab): string {
221
+ const fy = (l + 16) / 116;
222
+ const x = labFInverse(fy + a / 500) * WHITE[0];
223
+ const y = labFInverse(fy) * WHITE[1];
224
+ const z = labFInverse(fy - b / 200) * WHITE[2];
225
+ const [xn, yn, zn] = [x / 100, y / 100, z / 100];
226
+ return rgbToHex([
227
+ fromLinear(3.2406 * xn - 1.5372 * yn - 0.4986 * zn),
228
+ fromLinear(-0.9689 * xn + 1.8758 * yn + 0.0415 * zn),
229
+ fromLinear(0.0557 * xn - 0.204 * yn + 1.057 * zn),
230
+ ]);
231
+ }
232
+
233
+ /** `#rrggbb` to the `H S% L%` triplet a `global.css` variable holds, one decimal place each. */
234
+ export function hexToHslTriplet(hex: string): string {
235
+ const round = (value: number) => Math.round(value * 10) / 10;
236
+ const [hue, saturation, lightness] = hexToHsl(hex);
237
+ return `${round(hue)} ${round(saturation)}% ${round(lightness)}%`;
238
+ }
239
+
240
+ /** `#rrggbb` to hue in degrees, saturation and lightness in percent, unrounded. */
241
+ export function hexToHsl(hex: string): [number, number, number] {
242
+ const [r, g, b] = hexToRgb(hex).map((channel) => channel / 255);
243
+ const max = Math.max(r, g, b);
244
+ const min = Math.min(r, g, b);
245
+ const lightness = (max + min) / 2;
246
+ const delta = max - min;
247
+ let hue = 0;
248
+ let saturation = 0;
249
+ if (delta > 0) {
250
+ saturation = delta / (1 - Math.abs(2 * lightness - 1));
251
+ if (max === r) hue = ((g - b) / delta) % 6;
252
+ else if (max === g) hue = (b - r) / delta + 2;
253
+ else hue = (r - g) / delta + 4;
254
+ hue *= 60;
255
+ if (hue < 0) hue += 360;
256
+ }
257
+ return [hue, saturation * 100, lightness * 100];
258
+ }
@@ -0,0 +1,25 @@
1
+ import * as React from 'react';
2
+ import { AccessibilityInfo } from 'react-native';
3
+ import { isLiquidGlassAvailable, isGlassEffectAPIAvailable } from 'expo-glass-effect';
4
+ /** Fail closed until accessibility preferences are known; newer events win. */
5
+ export function useNativeGlassAvailable() {
6
+ const [reduceTransparency, setReduceTransparency] = React.useState(true);
7
+ React.useEffect(() => {
8
+ let current = true;
9
+ const sub = AccessibilityInfo.addEventListener('reduceTransparencyChanged', (value) => {
10
+ current = false;
11
+ setReduceTransparency(value);
12
+ });
13
+ void AccessibilityInfo.isReduceTransparencyEnabled().then(
14
+ (value) => {
15
+ if (current) setReduceTransparency(value);
16
+ },
17
+ () => {},
18
+ );
19
+ return () => {
20
+ current = false;
21
+ sub.remove();
22
+ };
23
+ }, []);
24
+ return !reduceTransparency && isGlassEffectAPIAvailable() && isLiquidGlassAvailable();
25
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,38 @@
1
+ import * as React from 'react';
2
+ import { AppState, Appearance } from 'react-native';
3
+ import type { IsolineTheme } from '@isoline-ui/react-native/theme';
4
+ import { getSystemPalette } from './system-colors.ts';
5
+ import { samePalette, type SystemPalette } from './dynamic-color.ts';
6
+ import { applySystemPalette } from './theme.ts';
7
+
8
+ /** Refreshes when returning from wallpaper settings or changing system appearance. */
9
+ export function useSystemPalette(enabled = true): SystemPalette | null {
10
+ const [palette, setPalette] = React.useState<SystemPalette | null>(() =>
11
+ enabled ? getSystemPalette() : null,
12
+ );
13
+ React.useEffect(() => {
14
+ if (!enabled) {
15
+ setPalette(null);
16
+ return;
17
+ }
18
+ const refresh = () => {
19
+ const next = getSystemPalette();
20
+ setPalette((previous) => (samePalette(previous, next) ? previous : next));
21
+ };
22
+ refresh();
23
+ const app = AppState.addEventListener('change', (state) => {
24
+ if (state === 'active') refresh();
25
+ });
26
+ const appearance = Appearance.addChangeListener(refresh);
27
+ return () => {
28
+ app.remove();
29
+ appearance.remove();
30
+ };
31
+ }, [enabled]);
32
+ return enabled ? palette : null;
33
+ }
34
+
35
+ export function useSystemTheme(base: IsolineTheme, enabled = true): IsolineTheme {
36
+ const palette = useSystemPalette(enabled);
37
+ return React.useMemo(() => applySystemPalette(base, palette), [base, palette]);
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export { MaterialSurface } from './MaterialSurface';
2
+ export type { MaterialSurfaceProps } from './material-types.ts';
3
+ export { getSystemPalette } from './system-colors.ts';
4
+ export { useSystemPalette, useSystemTheme } from './hooks.ts';
5
+ export { applySystemPalette } from './theme.ts';
6
+ export { parseSystemPalette, dynamicRoles, PALETTE_TONES } from './dynamic-color.ts';
7
+ export type { SystemPalette, DynamicRoles } from './dynamic-color.ts';
8
+ export { MaterialGroup } from './MaterialGroup';
9
+ export type { MaterialGroupProps } from './material-types.ts';
@@ -0,0 +1,3 @@
1
+ import type { IsolineSurfaceProps, IsolineSurfaceGroupProps } from '@isoline-ui/react-native';
2
+ export interface MaterialSurfaceProps extends IsolineSurfaceProps {}
3
+ export interface MaterialGroupProps extends IsolineSurfaceGroupProps {}
@@ -0,0 +1,17 @@
1
+ import { requireOptionalNativeModule } from 'expo';
2
+ import { Platform } from 'react-native';
3
+ import { parseSystemPalette, type SystemPalette } from './dynamic-color.ts';
4
+
5
+ interface SystemColorsModule {
6
+ getPalette(): unknown;
7
+ }
8
+ /** Missing native code and unsupported Android versions use the supplied app theme. */
9
+ export function getSystemPalette(): SystemPalette | null {
10
+ if (Platform.OS !== 'android') return null;
11
+ try {
12
+ const module = requireOptionalNativeModule<SystemColorsModule>('IsolineSystemColors');
13
+ return parseSystemPalette(module?.getPalette());
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { IsolineTheme } from '@isoline-ui/react-native/theme';
2
+ import { dynamicRoles, type SystemPalette } from './dynamic-color.ts';
3
+
4
+ /** Keeps brand typography and semantic error/success colors. */
5
+ export function applySystemPalette(
6
+ base: IsolineTheme,
7
+ palette: SystemPalette | null,
8
+ ): IsolineTheme {
9
+ if (!palette) return base;
10
+ return {
11
+ ...base,
12
+ name: `${base.name}-system`,
13
+ tokens: { ...base.tokens, ...dynamicRoles(palette, base.scheme).vars },
14
+ };
15
+ }