@isoline-ui/native 0.0.0-stage → 0.1.0-alpha.3
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 +22 -0
- package/NOTICE.md +16 -0
- package/README.md +99 -3
- package/android/build.gradle +18 -0
- package/android/src/main/java/isoline/modules/systemcolors/IsolineSystemColorsModule.kt +123 -0
- package/dist/MaterialGroup.d.ts +4 -0
- package/dist/MaterialGroup.ios.d.ts +4 -0
- package/dist/MaterialSurface.d.ts +4 -0
- package/dist/MaterialSurface.ios.d.ts +4 -0
- package/dist/MaterialSurface.web.d.ts +5 -0
- package/dist/dynamic-color.d.ts +68 -0
- package/dist/glass-support.ios.d.ts +2 -0
- package/dist/hooks.d.ts +5 -0
- package/dist/index.d.ts +9 -0
- package/dist/material-types.d.ts +5 -0
- package/dist/system-colors.d.ts +3 -0
- package/dist/theme.d.ts +4 -0
- package/expo-module.config.json +6 -0
- package/package.json +52 -6
- package/src/MaterialGroup.ios.tsx +33 -0
- package/src/MaterialGroup.tsx +22 -0
- package/src/MaterialSurface.ios.tsx +39 -0
- package/src/MaterialSurface.tsx +23 -0
- package/src/MaterialSurface.web.tsx +62 -0
- package/src/dynamic-color.ts +258 -0
- package/src/glass-support.ios.ts +25 -0
- package/src/hooks.ts +38 -0
- package/src/index.ts +9 -0
- package/src/material-types.ts +3 -0
- package/src/system-colors.ts +17 -0
- package/src/theme.ts +15 -0
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
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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 {};
|
package/dist/hooks.d.ts
ADDED
|
@@ -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;
|
package/dist/index.d.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';
|
package/dist/theme.d.ts
ADDED
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@isoline-ui/native",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@isoline-ui/native",
|
|
3
|
+
"version": "0.1.0-alpha.3",
|
|
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,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
|
+
}
|