@astratra/native-ui 0.1.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 +21 -0
- package/README.md +370 -0
- package/package.json +64 -0
- package/src/components/Chevron.js +31 -0
- package/src/components/CollapsibleHeader.js +253 -0
- package/src/components/FloatingPagination.js +135 -0
- package/src/components/GlassButton.js +117 -0
- package/src/components/GlassSurface.js +61 -0
- package/src/components/MarkdownView.js +322 -0
- package/src/components/PaleCard.js +83 -0
- package/src/components/TabBar.js +346 -0
- package/src/components/runtime.js +88 -0
- package/src/components/useCollapsingBar.js +55 -0
- package/src/index.d.ts +229 -0
- package/src/index.js +20 -0
- package/src/logic/anchorQuestion.js +41 -0
- package/src/logic/collapse.js +77 -0
- package/src/logic/collapsibleHeader.js +103 -0
- package/src/logic/color.js +68 -0
- package/src/logic/glass.js +151 -0
- package/src/logic/index.d.ts +203 -0
- package/src/logic/index.js +16 -0
- package/src/logic/markdown.js +229 -0
- package/src/logic/paleCard.js +66 -0
- package/src/logic/tabBar.js +104 -0
- package/src/logic/tableColumns.js +149 -0
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @astratra/native-ui — the mobile interface kit.
|
|
3
|
+
*
|
|
4
|
+
* The React Native types are NOT imported: react-native is a peer, and this
|
|
5
|
+
* repository does not install it. Styles and animated values are typed
|
|
6
|
+
* loosely here (`NativeStyle`, `SharedNumber`) — the app's own types flow
|
|
7
|
+
* through untouched.
|
|
8
|
+
*/
|
|
9
|
+
import type { ComponentType, ReactElement, ReactNode } from 'react';
|
|
10
|
+
import type { ColorScheme, GlassMode } from './logic';
|
|
11
|
+
|
|
12
|
+
export * from './logic';
|
|
13
|
+
|
|
14
|
+
/** A React Native style, style array or registered style. */
|
|
15
|
+
export type NativeStyle = unknown;
|
|
16
|
+
/** A Reanimated shared value holding a number (0..1 for `collapse`). */
|
|
17
|
+
export interface SharedNumber {
|
|
18
|
+
value: number;
|
|
19
|
+
}
|
|
20
|
+
/** The scroll event React Native hands to `onScroll`. */
|
|
21
|
+
export interface ScrollEventLike {
|
|
22
|
+
nativeEvent: { contentOffset: { y: number } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The glass this device renders, decided once for the app. */
|
|
26
|
+
export function getGlassMode(): GlassMode;
|
|
27
|
+
|
|
28
|
+
/* ──────────────────────────────── Glass ─────────────────────────────── */
|
|
29
|
+
|
|
30
|
+
export interface GlassSurfaceProps {
|
|
31
|
+
children?: ReactNode;
|
|
32
|
+
style?: NativeStyle;
|
|
33
|
+
/** Declared as for Apple's glass; off iOS it is painted at Apple's real weight. */
|
|
34
|
+
tintColor?: string;
|
|
35
|
+
glassStyle?: 'regular' | 'clear';
|
|
36
|
+
/** A button (keeps its shadow, gets the frost) rather than a container. */
|
|
37
|
+
interactive?: boolean;
|
|
38
|
+
scheme?: ColorScheme;
|
|
39
|
+
testID?: string;
|
|
40
|
+
}
|
|
41
|
+
export function GlassSurface(props: GlassSurfaceProps): ReactElement;
|
|
42
|
+
|
|
43
|
+
export interface GlassGroupProps {
|
|
44
|
+
children?: ReactNode;
|
|
45
|
+
spacing?: number;
|
|
46
|
+
style?: NativeStyle;
|
|
47
|
+
[prop: string]: unknown;
|
|
48
|
+
}
|
|
49
|
+
export function GlassGroup(props: GlassGroupProps): ReactElement;
|
|
50
|
+
|
|
51
|
+
export interface GlassButtonProps {
|
|
52
|
+
children?: ReactNode;
|
|
53
|
+
onPress?: () => void;
|
|
54
|
+
onLongPress?: () => void;
|
|
55
|
+
accessibilityLabel?: string;
|
|
56
|
+
/** Diameter of a circle, height of a pill. */
|
|
57
|
+
size?: number;
|
|
58
|
+
pill?: boolean;
|
|
59
|
+
/** Solid colour of the main action: tinted glass, light content. */
|
|
60
|
+
tint?: string;
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
selected?: boolean;
|
|
63
|
+
scheme?: ColorScheme;
|
|
64
|
+
style?: NativeStyle;
|
|
65
|
+
testID?: string;
|
|
66
|
+
}
|
|
67
|
+
export function GlassButton(props: GlassButtonProps): ReactElement;
|
|
68
|
+
|
|
69
|
+
/* ─────────────────────────────── Cards ──────────────────────────────── */
|
|
70
|
+
|
|
71
|
+
export interface PaleCardProps {
|
|
72
|
+
children?: ReactNode;
|
|
73
|
+
/** Accent colour for the current scheme; absent = the neutral card. */
|
|
74
|
+
tint?: string | null;
|
|
75
|
+
/** Defaults to white / black. */
|
|
76
|
+
pageBackground?: string;
|
|
77
|
+
/** The theme's text colour, used by the neutral card. */
|
|
78
|
+
ink?: string;
|
|
79
|
+
scheme?: ColorScheme;
|
|
80
|
+
style?: NativeStyle;
|
|
81
|
+
[prop: string]: unknown;
|
|
82
|
+
}
|
|
83
|
+
export function PaleCard(props: PaleCardProps): ReactElement;
|
|
84
|
+
export function TappableCard(props: Omit<PaleCardProps, 'tint'> & { tint?: string }): ReactElement;
|
|
85
|
+
export function usePaleCardColors(input?: {
|
|
86
|
+
tint?: string | null;
|
|
87
|
+
pageBackground?: string;
|
|
88
|
+
ink?: string;
|
|
89
|
+
scheme?: ColorScheme;
|
|
90
|
+
}): { fill: string; top: string; bottom: string };
|
|
91
|
+
|
|
92
|
+
export function Chevron(props: {
|
|
93
|
+
direction?: 'left' | 'right' | 'up' | 'down';
|
|
94
|
+
color?: string;
|
|
95
|
+
size?: number;
|
|
96
|
+
strokeWidth?: number;
|
|
97
|
+
}): ReactElement;
|
|
98
|
+
|
|
99
|
+
/* ──────────────────────────── Floating bars ─────────────────────────── */
|
|
100
|
+
|
|
101
|
+
export function useCollapsingBar(): { collapse: SharedNumber; onScroll: (event: ScrollEventLike) => void };
|
|
102
|
+
/** The shared fold style (translate + scale toward the bottom). */
|
|
103
|
+
export function useCollapseTransform(collapse?: SharedNumber): NativeStyle;
|
|
104
|
+
|
|
105
|
+
export interface FloatingPaginationProps {
|
|
106
|
+
page: number;
|
|
107
|
+
totalPages: number;
|
|
108
|
+
canPrevious: boolean;
|
|
109
|
+
canNext: boolean;
|
|
110
|
+
onPrevious: () => void;
|
|
111
|
+
onNext: () => void;
|
|
112
|
+
previousLabel: string;
|
|
113
|
+
nextLabel: string;
|
|
114
|
+
collapse?: SharedNumber;
|
|
115
|
+
/** Distance from the bottom when a footer button already sits there. */
|
|
116
|
+
bottom?: number;
|
|
117
|
+
bottomInset?: number;
|
|
118
|
+
onHaptic?: () => void;
|
|
119
|
+
previousIcon?: ReactNode;
|
|
120
|
+
nextIcon?: ReactNode;
|
|
121
|
+
scheme?: ColorScheme;
|
|
122
|
+
testID?: string;
|
|
123
|
+
}
|
|
124
|
+
export function FloatingPagination(props: FloatingPaginationProps): ReactElement;
|
|
125
|
+
|
|
126
|
+
export interface TabIconState {
|
|
127
|
+
color: string;
|
|
128
|
+
size: number;
|
|
129
|
+
strokeWidth: number;
|
|
130
|
+
active: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface TabBarTab {
|
|
134
|
+
key: string;
|
|
135
|
+
label: string;
|
|
136
|
+
badge?: number;
|
|
137
|
+
icon?: (state: TabIconState) => ReactNode;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface TabBarColors {
|
|
141
|
+
active: string;
|
|
142
|
+
idle: string;
|
|
143
|
+
pill: string;
|
|
144
|
+
badge: string;
|
|
145
|
+
badgeRing: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface TabBarProps<T extends TabBarTab = TabBarTab> {
|
|
149
|
+
tabs: readonly T[];
|
|
150
|
+
activeKey: string;
|
|
151
|
+
onSelect: (tab: T) => void;
|
|
152
|
+
/** Raised in the middle, never takes the pill. */
|
|
153
|
+
centerKey?: string;
|
|
154
|
+
renderCenter?: (input: { tab: T; size: number }) => ReactNode;
|
|
155
|
+
collapse?: SharedNumber;
|
|
156
|
+
bottomInset?: number;
|
|
157
|
+
onHaptic?: () => void;
|
|
158
|
+
/** Delay before `onSelect`, so the pill's slide is seen. */
|
|
159
|
+
openDelay?: number;
|
|
160
|
+
/** e.g. expo-router's useFocusEffect, when the bar is rendered inside each screen. */
|
|
161
|
+
useFocusEffect?: (effect: () => void | (() => void)) => void;
|
|
162
|
+
colors?: Partial<TabBarColors>;
|
|
163
|
+
scheme?: ColorScheme;
|
|
164
|
+
testID?: string;
|
|
165
|
+
}
|
|
166
|
+
export function TabBar<T extends TabBarTab>(props: TabBarProps<T>): ReactElement;
|
|
167
|
+
|
|
168
|
+
/* ────────────────────────── Collapsible header ──────────────────────── */
|
|
169
|
+
|
|
170
|
+
export interface CollapsibleHeaderApi {
|
|
171
|
+
scrollY: SharedNumber;
|
|
172
|
+
threshold: SharedNumber;
|
|
173
|
+
onScroll: (event: ScrollEventLike) => void;
|
|
174
|
+
onTitleLayout: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
|
175
|
+
onListTitleLayout: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
|
176
|
+
measureTitle: (node: unknown) => void;
|
|
177
|
+
originRef: { current: unknown };
|
|
178
|
+
}
|
|
179
|
+
export function useCollapsibleHeader(options?: { topInset?: number }): CollapsibleHeaderApi;
|
|
180
|
+
|
|
181
|
+
export interface CollapsibleHeaderProps {
|
|
182
|
+
title: string;
|
|
183
|
+
scrollY: SharedNumber;
|
|
184
|
+
threshold: SharedNumber;
|
|
185
|
+
/** Fixed on the left: usually a back GlassButton. */
|
|
186
|
+
leading?: ReactNode;
|
|
187
|
+
/** Fixed on the right. */
|
|
188
|
+
actions?: ReactNode;
|
|
189
|
+
topInset?: number;
|
|
190
|
+
gutter?: number;
|
|
191
|
+
pageBackground?: string;
|
|
192
|
+
titleColor?: string;
|
|
193
|
+
/** e.g. @react-native-masked-view/masked-view, for the iOS blur's fade. */
|
|
194
|
+
MaskedView?: ComponentType<{ style?: NativeStyle; maskElement: ReactElement; children?: ReactNode }>;
|
|
195
|
+
scheme?: ColorScheme;
|
|
196
|
+
testID?: string;
|
|
197
|
+
}
|
|
198
|
+
export function CollapsibleHeader(props: CollapsibleHeaderProps): ReactElement;
|
|
199
|
+
|
|
200
|
+
export interface CollapsibleScreenProps extends Omit<CollapsibleHeaderProps, 'scrollY' | 'threshold'> {
|
|
201
|
+
largeTitle?: ReactNode;
|
|
202
|
+
children?: ReactNode;
|
|
203
|
+
contentContainerStyle?: NativeStyle;
|
|
204
|
+
scrollProps?: { onScroll?: (event: ScrollEventLike) => void; [prop: string]: unknown };
|
|
205
|
+
}
|
|
206
|
+
export function CollapsibleScreen(props: CollapsibleScreenProps): ReactElement;
|
|
207
|
+
|
|
208
|
+
/* ───────────────────────────── AI answers ───────────────────────────── */
|
|
209
|
+
|
|
210
|
+
export interface MarkdownViewProps {
|
|
211
|
+
content: string;
|
|
212
|
+
/** The person's own message: shown as typed. */
|
|
213
|
+
plain?: boolean;
|
|
214
|
+
/** Style overrides by name (body, heading, tableCell, …), merged with the defaults. */
|
|
215
|
+
styles?: Record<string, NativeStyle>;
|
|
216
|
+
onLinkPress?: (href: string) => void;
|
|
217
|
+
/** Copies one code block; the button shows only with a copyIcon. */
|
|
218
|
+
onCopyCode?: (text: string) => void | Promise<void>;
|
|
219
|
+
copyLabel?: string;
|
|
220
|
+
copyIcon?: ReactNode;
|
|
221
|
+
copiedIcon?: ReactNode;
|
|
222
|
+
/** Defaults to PixelRatio.getFontScale(). */
|
|
223
|
+
fontScale?: number;
|
|
224
|
+
tableVeilColor?: string;
|
|
225
|
+
testID?: string;
|
|
226
|
+
}
|
|
227
|
+
export function MarkdownView(props: MarkdownViewProps): ReactElement | null;
|
|
228
|
+
export function MarkdownTable(props: { header: string[]; rows: string[][] }): ReactElement;
|
|
229
|
+
export const MARKDOWN_STYLES: Readonly<Record<string, NativeStyle>>;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @astratra/native-ui — the mobile interface kit: Liquid Glass on iOS, honest
|
|
3
|
+
* surfaces on Android, bars that fold on scroll, AI answers rendered clean.
|
|
4
|
+
*
|
|
5
|
+
* This entry loads react-native and the Expo modules. The pure rules alone
|
|
6
|
+
* live at `@astratra/native-ui/logic`, importable from plain Node.
|
|
7
|
+
*/
|
|
8
|
+
module.exports = {
|
|
9
|
+
...require('./logic'),
|
|
10
|
+
...require('./components/GlassSurface'),
|
|
11
|
+
...require('./components/GlassButton'),
|
|
12
|
+
...require('./components/PaleCard'),
|
|
13
|
+
...require('./components/Chevron'),
|
|
14
|
+
...require('./components/useCollapsingBar'),
|
|
15
|
+
...require('./components/FloatingPagination'),
|
|
16
|
+
...require('./components/TabBar'),
|
|
17
|
+
...require('./components/CollapsibleHeader'),
|
|
18
|
+
...require('./components/MarkdownView'),
|
|
19
|
+
getGlassMode: require('./components/runtime').getGlassMode
|
|
20
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The question at the top, the answer below — the way two people talk.
|
|
3
|
+
*
|
|
4
|
+
* When a message is sent to an assistant, the thread scrolls up to put the
|
|
5
|
+
* question at the top of the screen, and the answer is written in all the
|
|
6
|
+
* room left below. Before, the thread followed the bottom while the answer
|
|
7
|
+
* streamed: every line received made the screen jump, and nothing could be
|
|
8
|
+
* read calmly.
|
|
9
|
+
*
|
|
10
|
+
* For the question to be ABLE to reach the top even when the answer is short,
|
|
11
|
+
* there must be room under it: a reserve, which shrinks as the answer grows.
|
|
12
|
+
* Both computations live here, without React.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The air left above the question once at the top, in points. */
|
|
16
|
+
const ANCHOR_MARGIN = 12;
|
|
17
|
+
|
|
18
|
+
/** Where to scroll to put the question at the top of the thread. */
|
|
19
|
+
function anchorOffset(anchorY) {
|
|
20
|
+
return Math.max(0, anchorY - ANCHOR_MARGIN);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The reserve to leave under the question so it can rise to the top.
|
|
25
|
+
*
|
|
26
|
+
* `contentHeight` is the height of the REAL content, reserve excluded — read
|
|
27
|
+
* at the reserve's own position. The first version started from the total
|
|
28
|
+
* scroll height, reserve included, minus the current reserve: but that
|
|
29
|
+
* measure arrived one frame after the reserve it contained, so each new
|
|
30
|
+
* reserve skewed the next one, and the question fell back to the middle of
|
|
31
|
+
* the screen once the answer was done.
|
|
32
|
+
*
|
|
33
|
+
* With this, the maximum scroll is EXACTLY the question's position, whether
|
|
34
|
+
* the answer grows or shrinks: nothing pushes it back down. As soon as the
|
|
35
|
+
* answer fills the screen, the reserve drops to zero.
|
|
36
|
+
*/
|
|
37
|
+
function reserveBelowQuestion({ viewportHeight, contentHeight, anchorY }) {
|
|
38
|
+
return Math.max(0, Math.round(viewportHeight - (contentHeight - anchorOffset(anchorY))));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { ANCHOR_MARGIN, anchorOffset, reserveBelowQuestion };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a floating bar folds away and when it comes back — the Instagram rule.
|
|
3
|
+
*
|
|
4
|
+
* Scroll down into the page, the bar shrinks; scroll back up, it returns to
|
|
5
|
+
* full size. The decision lives here, without React, to be tested alone; the
|
|
6
|
+
* `useCollapsingBar` hook only feeds it scroll offsets.
|
|
7
|
+
*
|
|
8
|
+
* Two guards. A finger tremor (less than COLLAPSE_THRESHOLD points) changes
|
|
9
|
+
* nothing. And at the very top of the page the bar is always full: iOS's
|
|
10
|
+
* rubber-band bounce at the top must not fold it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Minimal travel, in points, for a direction to count. */
|
|
14
|
+
const COLLAPSE_THRESHOLD = 8;
|
|
15
|
+
/** Below this scroll offset the bar stays full. */
|
|
16
|
+
const TOP_ZONE = 24;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One step of the decision.
|
|
20
|
+
*
|
|
21
|
+
* `anchor` is where the direction was last decided: the offset is compared to
|
|
22
|
+
* IT, not to the previous frame, so that a slow scroll (one point per frame)
|
|
23
|
+
* still ends up counting. Comparing frame to frame, a slow reader could scroll
|
|
24
|
+
* a whole page without the bar ever moving.
|
|
25
|
+
*
|
|
26
|
+
* @param {{collapsed: boolean, anchor: number}} state
|
|
27
|
+
* @param {number} y The scroll offset.
|
|
28
|
+
*/
|
|
29
|
+
function followScroll(state, y) {
|
|
30
|
+
if (y <= TOP_ZONE) return { collapsed: false, anchor: Math.max(0, y) };
|
|
31
|
+
const travel = y - state.anchor;
|
|
32
|
+
if (travel > COLLAPSE_THRESHOLD) return { collapsed: true, anchor: y };
|
|
33
|
+
if (travel < -COLLAPSE_THRESHOLD) return { collapsed: false, anchor: y };
|
|
34
|
+
return state;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The state a screen starts in. */
|
|
38
|
+
const INITIAL_COLLAPSE = Object.freeze({ collapsed: false, anchor: 0 });
|
|
39
|
+
|
|
40
|
+
/* ── Bar size ─────────────────────────────────────────────────────────────
|
|
41
|
+
Tuned on a standard iPhone (390 points wide). On a large phone the bar
|
|
42
|
+
looked tiny: it grows with the screen width — height, icons, labels,
|
|
43
|
+
together, not in length — up to 12 % more. Never smaller. */
|
|
44
|
+
const REFERENCE_WIDTH = 390;
|
|
45
|
+
const MAX_BAR_SCALE = 1.12;
|
|
46
|
+
|
|
47
|
+
function barScale(screenWidth) {
|
|
48
|
+
const width = Number(screenWidth);
|
|
49
|
+
if (!Number.isFinite(width)) return 1;
|
|
50
|
+
return Math.min(MAX_BAR_SCALE, Math.max(1, width / REFERENCE_WIDTH));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Folded, a bar shrinks toward the bottom to 84 % and drops 6 points: its
|
|
54
|
+
labels fade, its icons stay — it can still be tapped. */
|
|
55
|
+
const COLLAPSED_SCALE = 0.84;
|
|
56
|
+
const COLLAPSED_DROP = 6;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where a floating bottom element sits: above the gesture bar / home
|
|
60
|
+
* indicator, never glued to the bottom edge.
|
|
61
|
+
*/
|
|
62
|
+
function floatingBottomOffset(bottomInset) {
|
|
63
|
+
return Math.max(18, (Number(bottomInset) || 0) + 8);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
COLLAPSE_THRESHOLD,
|
|
68
|
+
TOP_ZONE,
|
|
69
|
+
INITIAL_COLLAPSE,
|
|
70
|
+
followScroll,
|
|
71
|
+
REFERENCE_WIDTH,
|
|
72
|
+
MAX_BAR_SCALE,
|
|
73
|
+
barScale,
|
|
74
|
+
COLLAPSED_SCALE,
|
|
75
|
+
COLLAPSED_DROP,
|
|
76
|
+
floatingBottomOffset
|
|
77
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The collapsible page header, in numbers.
|
|
3
|
+
*
|
|
4
|
+
* Scrolling down a page used to take everything away — the title, the back
|
|
5
|
+
* button — and the reader no longer knew where they were. The rule now:
|
|
6
|
+
*
|
|
7
|
+
* - the back button and the action buttons stay FIXED, always there;
|
|
8
|
+
* - the large title scrolls away with the page;
|
|
9
|
+
* - once it has gone, a small bar fades in at the top: the content slides
|
|
10
|
+
* under it, blurred, and the page name is written in its centre.
|
|
11
|
+
*
|
|
12
|
+
* Under the bar, a FADE and not a line: the bar's background overflows by a
|
|
13
|
+
* few points and dies out there, so content disappears under it instead of
|
|
14
|
+
* being cut clean — a straight edge read as a rule drawn across the screen.
|
|
15
|
+
*/
|
|
16
|
+
const { parseColor } = require('./color');
|
|
17
|
+
|
|
18
|
+
/** The bar's height, below the status bar. */
|
|
19
|
+
const HEADER_BAR_HEIGHT = 52;
|
|
20
|
+
/** The fade-in happens over these points of scroll, just before the threshold. */
|
|
21
|
+
const HEADER_FADE_RUN = 28;
|
|
22
|
+
/**
|
|
23
|
+
* Length of the fade under the bar, per platform.
|
|
24
|
+
*
|
|
25
|
+
* Android is shorter and lighter: without a blur, a solid band under the
|
|
26
|
+
* title "weighed too much". Its background lets the content show (94 % then
|
|
27
|
+
* 80 % at the bar's bottom edge) and dies out over a shorter band.
|
|
28
|
+
*/
|
|
29
|
+
const HEADER_BOTTOM_FADE = Object.freeze({ ios: 26, android: 16 });
|
|
30
|
+
/** Before the title has been measured, the bar appears after this offset. */
|
|
31
|
+
const HEADER_DEFAULT_THRESHOLD = 72;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The scroll offset at which the bar has fully appeared: when the bottom of
|
|
35
|
+
* the large title passes under the bar. The content starts BELOW the bar, so
|
|
36
|
+
* the bar's own height comes off — and the status-bar inset too, when the
|
|
37
|
+
* scrolling content carries it as padding instead of a safe-area view. Never
|
|
38
|
+
* below 40: a page with a tiny title would otherwise show the bar at the
|
|
39
|
+
* first pixel of scroll.
|
|
40
|
+
*
|
|
41
|
+
* @param {number} titleBottom Bottom of the title, in content coordinates.
|
|
42
|
+
* @param {number} [topInset] Inset padded INSIDE the scroll content, if any.
|
|
43
|
+
*/
|
|
44
|
+
function headerThreshold(titleBottom, topInset = 0) {
|
|
45
|
+
return Math.max(40, titleBottom - HEADER_BAR_HEIGHT - topInset);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* The two fade functions carry the 'worklet' directive: the header's animated
|
|
50
|
+
* styles call them on the UI thread, where only worklets can run. In Node the
|
|
51
|
+
* directive is an inert string.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** Input/output ranges for the bar's opacity. */
|
|
55
|
+
function headerBarFade(threshold) {
|
|
56
|
+
'worklet';
|
|
57
|
+
return { input: [threshold - HEADER_FADE_RUN, threshold], output: [0, 1] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Input ranges for the small title: it starts later than the bar and rises
|
|
62
|
+
* 6 points as it appears, so it lands rather than blinks.
|
|
63
|
+
*/
|
|
64
|
+
function headerTitleFade(threshold) {
|
|
65
|
+
'worklet';
|
|
66
|
+
return {
|
|
67
|
+
opacity: { input: [threshold - HEADER_FADE_RUN / 2, threshold + 8], output: [0, 1] },
|
|
68
|
+
translateY: { input: [threshold - HEADER_FADE_RUN, threshold + 8], output: [6, 0] }
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function headerBottomFade(platform) {
|
|
73
|
+
return platform === 'android' ? HEADER_BOTTOM_FADE.android : HEADER_BOTTOM_FADE.ios;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Gradient stops: solid down to the bar's bottom edge, then dying out in the overflow. */
|
|
77
|
+
function headerGradientStops(barHeight, fadeLength) {
|
|
78
|
+
return [0, barHeight / (barHeight + fadeLength), 1];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Android's background: the page colour, fading the same way. Its blur came
|
|
83
|
+
* out "dirty"; a clean gradient stays clean. Accepts any colour notation —
|
|
84
|
+
* appending hex alpha digits to a colour only works if it was `#rrggbb`.
|
|
85
|
+
*/
|
|
86
|
+
function androidHeaderColors(pageBackground) {
|
|
87
|
+
const c = parseColor(pageBackground) || { r: 255, g: 255, b: 255 };
|
|
88
|
+
const rgba = (a) => `rgba(${c.r},${c.g},${c.b},${a})`;
|
|
89
|
+
return [rgba(0.94), rgba(0.8), rgba(0)];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
HEADER_BAR_HEIGHT,
|
|
94
|
+
HEADER_FADE_RUN,
|
|
95
|
+
HEADER_BOTTOM_FADE,
|
|
96
|
+
HEADER_DEFAULT_THRESHOLD,
|
|
97
|
+
headerThreshold,
|
|
98
|
+
headerBarFade,
|
|
99
|
+
headerTitleFade,
|
|
100
|
+
headerBottomFade,
|
|
101
|
+
headerGradientStops,
|
|
102
|
+
androidHeaderColors
|
|
103
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour arithmetic, without a colour library.
|
|
3
|
+
*
|
|
4
|
+
* Every surface of the kit mixes a tint into a page colour, or re-weights the
|
|
5
|
+
* alpha of a tint. Doing it here, on plain strings, is what lets a test check
|
|
6
|
+
* the result to the digit instead of judging a screenshot.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reads `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb()` or `rgba()`.
|
|
11
|
+
* Returns `{ r, g, b, a }`, or null for anything else (a named colour, a
|
|
12
|
+
* typo) — the caller decides what an unreadable colour falls back to.
|
|
13
|
+
*/
|
|
14
|
+
function parseColor(value) {
|
|
15
|
+
if (typeof value !== 'string') return null;
|
|
16
|
+
const v = value.trim().toLowerCase();
|
|
17
|
+
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(v);
|
|
18
|
+
if (short) {
|
|
19
|
+
const [r, g, b] = [short[1], short[2], short[3]].map((c) => parseInt(c + c, 16));
|
|
20
|
+
return { r, g, b, a: 1 };
|
|
21
|
+
}
|
|
22
|
+
const long = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/.exec(v);
|
|
23
|
+
if (long) {
|
|
24
|
+
const [r, g, b] = [long[1], long[2], long[3]].map((c) => parseInt(c, 16));
|
|
25
|
+
return { r, g, b, a: long[4] ? parseInt(long[4], 16) / 255 : 1 };
|
|
26
|
+
}
|
|
27
|
+
const fn = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(v);
|
|
28
|
+
if (fn) {
|
|
29
|
+
return { r: Number(fn[1]), g: Number(fn[2]), b: Number(fn[3]), a: fn[4] === undefined ? 1 : Number(fn[4]) };
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toHex({ r, g, b }) {
|
|
35
|
+
return `#${[r, g, b]
|
|
36
|
+
.map((c) =>
|
|
37
|
+
Math.round(Math.min(255, Math.max(0, c)))
|
|
38
|
+
.toString(16)
|
|
39
|
+
.padStart(2, '0')
|
|
40
|
+
)
|
|
41
|
+
.join('')}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `tint` laid over `background` at `proportion`, returned OPAQUE.
|
|
46
|
+
*
|
|
47
|
+
* Opaque on purpose: a page often paints a pattern (a grid, a gradient) under
|
|
48
|
+
* its content, and a translucent card lets that pattern show through as a
|
|
49
|
+
* faint checkerboard. The alpha of either input is ignored.
|
|
50
|
+
*/
|
|
51
|
+
function mixColors(background, tint, proportion) {
|
|
52
|
+
const base = parseColor(background) || { r: 255, g: 255, b: 255 };
|
|
53
|
+
const colour = parseColor(tint) || base;
|
|
54
|
+
return toHex({
|
|
55
|
+
r: base.r + (colour.r - base.r) * proportion,
|
|
56
|
+
g: base.g + (colour.g - base.g) * proportion,
|
|
57
|
+
b: base.b + (colour.b - base.b) * proportion
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The same colour at another alpha, whatever notation it came in. */
|
|
62
|
+
function withAlpha(color, alpha) {
|
|
63
|
+
const c = parseColor(color);
|
|
64
|
+
if (!c) return color;
|
|
65
|
+
return `rgba(${c.r},${c.g},${c.b},${Number(alpha.toFixed(3))})`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { parseColor, mixColors, withAlpha };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The glass rules, decided without react-native so they test dry.
|
|
3
|
+
*
|
|
4
|
+
* WHICH GLASS. The device's glass mode comes from `resolveGlassMode` in
|
|
5
|
+
* `@astratra/native` — one rule for the whole Astratra stack, not a second
|
|
6
|
+
* copy here. This kit only ever asks one question of it: is the answer
|
|
7
|
+
* `'native'` (Apple's own Liquid Glass material)?
|
|
8
|
+
*
|
|
9
|
+
* Everything else — Android, an iPhone below iOS 26, the web — gets a VISIBLE
|
|
10
|
+
* SURFACE, not an imitation. That was learnt the hard way. Android was first
|
|
11
|
+
* given its native backdrop blur (`dimezisBlurView`, the `'blur'` answer):
|
|
12
|
+
* the blur worked, but it imitated a material that does not exist on Android.
|
|
13
|
+
* It captures the view hierarchy and re-blurs it every frame, and the result
|
|
14
|
+
* looked neither like Apple's glass nor like Material — a dense, opaque grey,
|
|
15
|
+
* obvious on the tab bar and around every round button. Android says "surface
|
|
16
|
+
* resting on the page" with a fill, a radius and an elevation; that is what
|
|
17
|
+
* it gets.
|
|
18
|
+
*/
|
|
19
|
+
const { parseColor } = require('./color');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The real weight Apple gives a tint.
|
|
23
|
+
*
|
|
24
|
+
* Measured to the pixel on the same screens, iOS vs Android: a `GlassView`
|
|
25
|
+
* tinted white at 0.4 leaves a light background... unchanged — 240,242,251
|
|
26
|
+
* inside as outside. Apple does not PAINT the tint; it modulates its frosting
|
|
27
|
+
* with it, and on light backgrounds almost nothing of it reaches the eye.
|
|
28
|
+
* Painting the tint as declared on Android gave panels visibly milkier than
|
|
29
|
+
* the same screens on iOS — THE visible difference between the two builds.
|
|
30
|
+
* A quarter of the declared weight gets close to Apple's rendering while
|
|
31
|
+
* keeping the surface visible at all.
|
|
32
|
+
*/
|
|
33
|
+
const TINT_WEIGHT = 0.25;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The frosting of Apple's INTERACTIVE glass.
|
|
37
|
+
*
|
|
38
|
+
* Unlike a container, a button has matter: measured on a back button, iOS
|
|
39
|
+
* renders it LIGHTER than the background (+7) where a container stays
|
|
40
|
+
* neutral. The shadow a button declares shows through a translucent fill (on
|
|
41
|
+
* both systems — CALayer and Android's boxShadow both draw it under the whole
|
|
42
|
+
* surface), so the frosting must cover it too; hence this weight, calibrated
|
|
43
|
+
* to the pixel against iOS.
|
|
44
|
+
*/
|
|
45
|
+
const INTERACTIVE_FROST = 0.4;
|
|
46
|
+
|
|
47
|
+
/** Style keys that draw a shadow, on either platform. */
|
|
48
|
+
const SHADOW_KEYS = ['boxShadow', 'shadowColor', 'shadowOpacity', 'shadowRadius', 'shadowOffset', 'elevation'];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The tint a caller asked the glass for, at the weight Apple really gives it.
|
|
52
|
+
*
|
|
53
|
+
* Only a TRANSLUCENT tint is re-weighted. An opaque colour (`#3b6cf0`) is not
|
|
54
|
+
* a glass tint, it is paint: the caller wants that colour, and quartering it
|
|
55
|
+
* would turn a primary action into a pale ghost of itself.
|
|
56
|
+
*/
|
|
57
|
+
function tintAtAppleWeight(color, interactive) {
|
|
58
|
+
const c = parseColor(color);
|
|
59
|
+
if (!c || !(c.a < 1)) return color;
|
|
60
|
+
const composed = interactive ? 1 - (1 - INTERACTIVE_FROST) * (1 - c.a) : c.a * TINT_WEIGHT;
|
|
61
|
+
return `rgba(${c.r},${c.g},${c.b},${composed.toFixed(3)})`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The surface style off Apple's glass, with iOS's shadow rule applied.
|
|
66
|
+
*
|
|
67
|
+
* On iOS, CALayer computes a shadow from the view's ALPHA silhouette: an
|
|
68
|
+
* almost transparent glass container casts practically nothing — a panel with
|
|
69
|
+
* a declared shadow shows NO outline on iOS. Android's boxShadow always draws
|
|
70
|
+
* the full rectangle: every container came out ringed by a ghost frame that
|
|
71
|
+
* does not exist on iOS. So a container's shadow is removed; a button's —
|
|
72
|
+
* whose frosting gives it a real fill — is kept, as iOS shows it.
|
|
73
|
+
*
|
|
74
|
+
* Takes an already-flattened style and returns a new object.
|
|
75
|
+
*/
|
|
76
|
+
function surfaceStyleOffApple(flatStyle, interactive) {
|
|
77
|
+
const style = { ...(flatStyle || {}) };
|
|
78
|
+
if (!interactive) for (const key of SHADOW_KEYS) delete style[key];
|
|
79
|
+
return style;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The default tint of an untinted glass button.
|
|
84
|
+
*
|
|
85
|
+
* On iOS 0.14 of white is enough: Apple's glass adds its own matter on top.
|
|
86
|
+
* Off Apple's glass there is no such layer — at the same value the button
|
|
87
|
+
* would vanish. It needs a real fill.
|
|
88
|
+
*/
|
|
89
|
+
function glassButtonTint({ mode, selected = false } = {}) {
|
|
90
|
+
if (mode === 'native') return selected ? 'rgba(255,255,255,0.35)' : 'rgba(255,255,255,0.14)';
|
|
91
|
+
return selected ? 'rgba(255,255,255,0.98)' : 'rgba(255,255,255,0.9)';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The matter of a glass button: an object caught in an ice cube.
|
|
96
|
+
*
|
|
97
|
+
* The glass needs DEPTH — a highlight at the top, a denser foot — and what it
|
|
98
|
+
* carries must come out sharp on top instead of being dimmed by a veil.
|
|
99
|
+
*
|
|
100
|
+
* Returns `{ glassStyle, highlight, glow }`, and deliberately nothing else:
|
|
101
|
+
* no stroke. A white hairline at the top and an ink outline around read as
|
|
102
|
+
* "lines that show it's plastic ice". On a real iPhone icon nothing rings the
|
|
103
|
+
* glass — the light is INSIDE it.
|
|
104
|
+
*/
|
|
105
|
+
function glassButtonMaterial({ mode, scheme = 'light', tinted = false } = {}) {
|
|
106
|
+
const dark = scheme === 'dark';
|
|
107
|
+
/* `regular` frosts AND slightly darkens what it covers: on a white page it
|
|
108
|
+
made the buttons grey and dull on a real iPhone. `clear` keeps the lens
|
|
109
|
+
and its reflections without the veil. In dark mode the frosting is what
|
|
110
|
+
separates the button from the night background. A tinted button carries
|
|
111
|
+
its own colour: `clear` lets it through without washing it out. */
|
|
112
|
+
const glassStyle = tinted || !dark ? 'clear' : 'regular';
|
|
113
|
+
|
|
114
|
+
/* The top catches the light, the bottom thickens: this gradient is what
|
|
115
|
+
reads as a domed volume rather than a flat disc. On a solid colour the
|
|
116
|
+
highlight is stronger — it has to show on blue. */
|
|
117
|
+
const highlight = tinted
|
|
118
|
+
? ['rgba(255,255,255,0.34)', 'rgba(255,255,255,0)', 'rgba(0,0,0,0.10)']
|
|
119
|
+
: dark
|
|
120
|
+
? ['rgba(255,255,255,0.16)', 'rgba(255,255,255,0)', 'rgba(0,0,0,0.22)']
|
|
121
|
+
: ['rgba(255,255,255,0.70)', 'rgba(255,255,255,0)', 'rgba(13,18,53,0.05)'];
|
|
122
|
+
|
|
123
|
+
/* The glow: iOS casts the shadow of a view WITHOUT a fill onto the
|
|
124
|
+
silhouette of its content — the icon, the letters. A light halo around a
|
|
125
|
+
dark icon lifts it off the glass; a dark shadow under a light icon (tinted
|
|
126
|
+
button, dark mode) makes it pop. Android draws the whole rectangle
|
|
127
|
+
instead: that would be a grey halo around every button. */
|
|
128
|
+
const glow =
|
|
129
|
+
mode !== 'native'
|
|
130
|
+
? null
|
|
131
|
+
: tinted || dark
|
|
132
|
+
? {
|
|
133
|
+
shadowColor: '#000000',
|
|
134
|
+
shadowOpacity: dark && !tinted ? 0.45 : 0.28,
|
|
135
|
+
shadowRadius: 2,
|
|
136
|
+
shadowOffset: { width: 0, height: 1 }
|
|
137
|
+
}
|
|
138
|
+
: { shadowColor: '#ffffff', shadowOpacity: 0.95, shadowRadius: 2.5, shadowOffset: { width: 0, height: 0 } };
|
|
139
|
+
|
|
140
|
+
return { glassStyle, highlight, glow };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
TINT_WEIGHT,
|
|
145
|
+
INTERACTIVE_FROST,
|
|
146
|
+
SHADOW_KEYS,
|
|
147
|
+
tintAtAppleWeight,
|
|
148
|
+
surfaceStyleOffApple,
|
|
149
|
+
glassButtonTint,
|
|
150
|
+
glassButtonMaterial
|
|
151
|
+
};
|