@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Astratra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,370 @@
1
+ # @astratra/native-ui
2
+
3
+ Le kit d'interface mobile : **le verre liquide d'Apple sur iOS, une surface
4
+ visible sur Android**, des boutons en verre, des cartes pâles, des barres qui se
5
+ replient au défilement, une barre d'onglets façon Instagram, un en-tête
6
+ repliable, et le rendu propre des réponses d'une IA.
7
+
8
+ Pour React Native / Expo. Pas d'étape de build, pas de JSX : le source est du
9
+ CommonJS écrit avec `createElement`, comme `@astratra/react`, et Metro le prend
10
+ tel quel.
11
+
12
+ ```sh
13
+ npm install @astratra/native-ui
14
+ npx expo install expo-glass-effect expo-blur expo-linear-gradient react-native-reanimated
15
+ ```
16
+
17
+ Dépendances en `peerDependencies` : `react`, `react-native`,
18
+ `react-native-reanimated`, `expo-glass-effect`, `expo-blur`,
19
+ `expo-linear-gradient`. Une seule dépendance réelle : `@astratra/native`, dont
20
+ la règle `resolveGlassMode` décide du verre — elle n'est pas recopiée ici.
21
+
22
+ Rien n'est écrit en dur : ni texte (les libellés sont des props), ni icône (ce
23
+ sont des nœuds ou des fonctions passés par l'application), ni navigation, ni
24
+ retour haptique (`onHaptic`).
25
+
26
+ ## Deux entrées
27
+
28
+ | Import | Contenu | Charge react-native ? |
29
+ | --- | --- | --- |
30
+ | `@astratra/native-ui` | les composants **et** toutes les règles | oui |
31
+ | `@astratra/native-ui/logic` | les règles seules, fonctions pures | non — Node pur |
32
+
33
+ La logique est séparée des composants pour se tester à sec : ce que fait un
34
+ composant se décide dans une fonction pure (quand replier, quelle largeur de
35
+ colonne, quel poids de teinte), le composant ne fait que la rendre.
36
+
37
+ ```js
38
+ // Un serveur, un script, un test : aucune dépendance mobile.
39
+ const { parseMarkdown, measureColumns } = require('@astratra/native-ui/logic');
40
+ ```
41
+
42
+ ---
43
+
44
+ # Le verre
45
+
46
+ ## La règle : le verre d'Apple sur iOS, une surface visible ailleurs
47
+
48
+ `getGlassMode()` interroge `resolveGlassMode` de `@astratra/native`, une fois
49
+ pour toute l'application. Seule la réponse `'native'` (Liquid Glass, iOS 26+)
50
+ donne le `GlassView` d'Apple.
51
+
52
+ Partout ailleurs — Android, un iPhone plus ancien — le kit rend une **surface
53
+ visible**, pas une imitation. Android avait d'abord reçu son flou natif
54
+ (`dimezisBlurView`) : il fonctionnait, mais imitait un matériau qui n'existe pas
55
+ chez lui, et chaque surface sortait d'un gris dense et opaque. Android dit
56
+ « surface posée » avec un fond, un rayon, une élévation : c'est ce qu'il reçoit.
57
+
58
+ **Le poids réel de la teinte.** Mesuré au pixel : un `GlassView` teinté de blanc
59
+ à 0,4 laisse un fond clair… inchangé. Apple ne peint pas la teinte, il en module
60
+ son dépoli. La peindre telle quelle sur Android donnait des panneaux nettement
61
+ plus laiteux qu'iOS. Hors du verre d'Apple, une teinte translucide est donc
62
+ peinte au **quart** de son poids (`tintAtAppleWeight`) ; un bouton (verre
63
+ interactif) reçoit en plus le givre d'Apple, `1 − 0,6 × (1 − α)`. Une couleur
64
+ opaque n'est pas une teinte mais de la peinture : elle est laissée telle quelle.
65
+
66
+ **L'ombre suit la règle d'iOS.** iOS calcule l'ombre sur la silhouette alpha :
67
+ un conteneur presque transparent n'en projette aucune. Android dessine toujours
68
+ le rectangle plein — un cadre fantôme autour de chaque panneau. Hors du verre
69
+ d'Apple, un conteneur perd donc son ombre (`surfaceStyleOffApple`) ; un bouton,
70
+ dont le givre est un vrai fond, la garde.
71
+
72
+ ## GlassSurface, GlassGroup
73
+
74
+ ```js
75
+ import { GlassSurface, GlassGroup } from '@astratra/native-ui';
76
+
77
+ <GlassSurface tintColor="rgba(255,255,255,0.4)" style={{ borderRadius: 24, padding: 16 }}>
78
+ <Text>Panneau flottant</Text>
79
+ </GlassSurface>
80
+
81
+ <GlassGroup spacing={8} style={{ flexDirection: 'row' }}>
82
+ <GlassButton …/>
83
+ <GlassButton …/>
84
+ </GlassGroup>
85
+ ```
86
+
87
+ `GlassGroup` est un `GlassContainer` sur iOS (les verres voisins fusionnent) ;
88
+ ailleurs, l'espacement devient un `gap` — un `View` nu collait les éléments.
89
+
90
+ ## GlassButton
91
+
92
+ Un objet pris dans un glaçon : le verre a un reflet en haut, un pied plus dense,
93
+ et **aucun trait** autour — un filet blanc et un contour d'encre se lisaient
94
+ comme « de la glace en plastique ». Trois couches, dans cet ordre : la teinte,
95
+ le reflet, le contenu. Le contenu passe au-dessus du reflet, sinon le voile
96
+ éteint l'icône.
97
+
98
+ ```js
99
+ import { GlassButton } from '@astratra/native-ui';
100
+
101
+ <GlassButton accessibilityLabel={t('back')} onPress={router.back}>
102
+ <BackIcon />
103
+ </GlassButton>
104
+
105
+ <GlassButton pill tint="#3b6cf0" accessibilityLabel={t('send')} onPress={send} size={40}>
106
+ <Text style={{ color: '#fff' }}>{t('send')}</Text>
107
+ </GlassButton>
108
+ ```
109
+
110
+ Sur une page claire, le verre est `clear` (le `regular` rendait les boutons gris
111
+ et ternes sur un vrai iPhone) ; en sombre, `regular`. La lueur qui détache
112
+ l'icône n'existe que sous le verre d'Apple : Android en ferait un halo gris.
113
+
114
+ ## PaleCard, TappableCard
115
+
116
+ Le verre sur les cartes du **contenu** a été jugé laid sur un vrai téléphone :
117
+ reflets, flou, ombre flottante. La règle : le verre reste à ce qui flotte et à
118
+ ce qu'on touche ; le contenu reçoit des cartes pâles, plates, de leur couleur —
119
+ un mélange **opaque** de la teinte dans le fond de page, en léger dégradé, sans
120
+ filet ni ombre.
121
+
122
+ ```js
123
+ import { PaleCard, TappableCard, usePaleCardColors } from '@astratra/native-ui';
124
+
125
+ <PaleCard tint={colors.accent} pageBackground={colors.background} ink={colors.text}>
126
+ <Text>Contenu</Text>
127
+ </PaleCard>
128
+
129
+ // Ce qu'on touche : verre interactif sur iOS, carte pâle ailleurs.
130
+ <Pressable onPress={open}>
131
+ <TappableCard tint={colors.accent}>
132
+ <Text>Une suggestion</Text>
133
+ </TappableCard>
134
+ </Pressable>
135
+
136
+ // Le même fond pâle pour autre chose qu'une carte.
137
+ const { fill } = usePaleCardColors({ tint: colors.accent });
138
+ ```
139
+
140
+ ---
141
+
142
+ # Les barres qui flottent
143
+
144
+ ## useCollapsingBar
145
+
146
+ La règle d'Instagram : on descend, la barre rapetisse ; on remonte, elle revient.
147
+ Un tremblement de moins de 8 points ne compte pas, et tout en haut de la page la
148
+ barre est toujours pleine (le rebond d'iOS ne la replie pas). Le sens est
149
+ comparé au dernier point de décision, pas à l'image précédente : un défilement
150
+ lent finit par compter.
151
+
152
+ ```js
153
+ import { useCollapsingBar, TabBar } from '@astratra/native-ui';
154
+
155
+ const { collapse, onScroll } = useCollapsingBar();
156
+
157
+ <ScrollView onScroll={onScroll} scrollEventThrottle={16}>…</ScrollView>
158
+ <TabBar collapse={collapse} … />
159
+ ```
160
+
161
+ `collapse` glisse de 0 (pleine) à 1 (repliée) sur le fil de l'interface, sans
162
+ rerendre l'écran. Avec « Réduire les animations », la barre ne bouge pas.
163
+
164
+ ## TabBar
165
+
166
+ La pastille active glisse jusqu'à l'onglet touché, et la page ne s'ouvre qu'une
167
+ fois la pastille presque arrivée (`openDelay`, 180 ms) : ouverte dans la même
168
+ image, elle couvrait la barre et le glissement n'était jamais vu. Un onglet
169
+ central (`centerKey`) monte au-dessus de la barre et **ne prend jamais la
170
+ pastille** : il ouvre. La barre se replie au défilement et grossit jusqu'à 12 %
171
+ sur les grands écrans, jamais en dessous de sa taille de référence. Verre du
172
+ Dock sur iOS, carte claire sur Android.
173
+
174
+ ```js
175
+ import { TabBar } from '@astratra/native-ui';
176
+ import { useFocusEffect } from 'expo-router';
177
+ import * as Haptics from 'expo-haptics';
178
+
179
+ const tabs = [
180
+ { key: 'home', label: t('tabs.home'), icon: ({ color, size }) => <HomeIcon color={color} size={size} /> },
181
+ { key: 'inbox', label: t('tabs.inbox'), badge: unread, icon: … },
182
+ { key: 'assistant', label: t('tabs.assistant'), icon: … },
183
+ { key: 'more', label: t('tabs.more'), icon: … }
184
+ ];
185
+
186
+ <TabBar
187
+ tabs={tabs}
188
+ activeKey="home"
189
+ centerKey="assistant"
190
+ renderCenter={({ size }) => <AssistantOrb size={size} />}
191
+ onSelect={(tab) => router.navigate(`/${tab.key}`)}
192
+ collapse={collapse}
193
+ bottomInset={insets.bottom}
194
+ onHaptic={() => Haptics.selectionAsync()}
195
+ useFocusEffect={useFocusEffect}
196
+ />
197
+ ```
198
+
199
+ `useFocusEffect` : quand la barre est rendue dans chaque écran, c'est au retour
200
+ sur l'écran que la pastille doit revenir à l'onglet de l'écran. Sans lui, elle se
201
+ recale quand `activeKey` change.
202
+
203
+ ## FloatingPagination
204
+
205
+ La pagination d'une liste en pilule flottante, au même endroit et avec le même
206
+ repli que la barre d'onglets. Réserver `paginationReserve(insets.bottom)` sous la
207
+ liste, pour que sa dernière ligne passe au-dessus de la pilule.
208
+
209
+ ```js
210
+ import { FloatingPagination, paginationReserve } from '@astratra/native-ui';
211
+
212
+ <FlatList contentContainerStyle={{ paddingBottom: paginationReserve(insets.bottom) }} … />
213
+ <FloatingPagination
214
+ page={page}
215
+ totalPages={pages}
216
+ canPrevious={page > 1}
217
+ canNext={page < pages}
218
+ onPrevious={() => setPage(page - 1)}
219
+ onNext={() => setPage(page + 1)}
220
+ previousLabel={t('previous_page')}
221
+ nextLabel={t('next_page')}
222
+ collapse={collapse}
223
+ bottomInset={insets.bottom}
224
+ />
225
+ ```
226
+
227
+ `bottom` la pose au-dessus d'un bouton déjà fixé au pied de la page.
228
+
229
+ ## CollapsibleScreen, CollapsibleHeader, useCollapsibleHeader
230
+
231
+ En descendant dans une page, tout partait — le titre, le retour — et on ne
232
+ savait plus où l'on était. Désormais le bouton retour et les actions restent
233
+ fixes, le grand titre défile, et dès qu'il est passé une petite barre floutée
234
+ apparaît avec le nom de la page. Sous la barre, **un fondu et non une ligne**.
235
+ Sur iOS, le flou du système ; sur Android, un dégradé de la couleur de la page,
236
+ plus léger et plus court (son flou sortait « sale »).
237
+
238
+ ```js
239
+ import { CollapsibleScreen, GlassButton } from '@astratra/native-ui';
240
+ import MaskedView from '@react-native-masked-view/masked-view';
241
+
242
+ <CollapsibleScreen
243
+ title={t('schools.title')}
244
+ largeTitle={<Text style={styles.largeTitle}>{t('schools.title')}</Text>}
245
+ leading={<GlassButton accessibilityLabel={t('back')} onPress={router.back}><BackIcon /></GlassButton>}
246
+ actions={<GlassButton accessibilityLabel={t('add')} onPress={add}><PlusIcon /></GlassButton>}
247
+ topInset={insets.top}
248
+ pageBackground={colors.background}
249
+ MaskedView={MaskedView}
250
+ >
251
+ {rows}
252
+ </CollapsibleScreen>
253
+ ```
254
+
255
+ `MaskedView` est facultatif : sans lui, le flou d'iOS s'arrête au bord de la
256
+ barre et c'est la couleur de la page qui porte le fondu.
257
+
258
+ Pour une liste virtualisée, poser l'en-tête soi-même :
259
+
260
+ ```js
261
+ const header = useCollapsibleHeader({ topInset: insets.top });
262
+
263
+ <FlatList
264
+ onScroll={header.onScroll}
265
+ scrollEventThrottle={16}
266
+ contentContainerStyle={{ paddingTop: insets.top + HEADER_BAR_HEIGHT }}
267
+ ListHeaderComponent={<View onLayout={header.onListTitleLayout}>{largeTitle}</View>}
268
+ …
269
+ />
270
+ <CollapsibleHeader title={title} scrollY={header.scrollY} threshold={header.threshold} topInset={insets.top} />
271
+ ```
272
+
273
+ ---
274
+
275
+ # Les réponses d'une IA
276
+
277
+ ## MarkdownView
278
+
279
+ Un modèle écrit du Markdown ; l'écran l'affichait brut, étoiles comprises.
280
+ `parseMarkdown` est une fonction pure (aucune bibliothèque de rendu Markdown ne
281
+ se teste à sec ni ne garantit React 19), et `MarkdownView` n'est qu'un parcours
282
+ de ses blocs.
283
+
284
+ ```js
285
+ import { MarkdownView } from '@astratra/native-ui';
286
+ import * as Clipboard from 'expo-clipboard';
287
+
288
+ <MarkdownView
289
+ content={message.text}
290
+ plain={message.mine}
291
+ onCopyCode={(code) => Clipboard.setStringAsync(code)}
292
+ copyLabel={t('copy')}
293
+ copyIcon={<CopyIcon />}
294
+ copiedIcon={<CheckIcon />}
295
+ styles={{ body: { color: colors.text } }}
296
+ />
297
+ ```
298
+
299
+ Ce que l'analyseur garantit, chaque règle née d'un défaut vu à l'écran :
300
+
301
+ - **une liste numérotée continue** malgré les puces glissées sous chaque étape —
302
+ le modèle écrit « 1. » partout, l'écran affichait « 1. 1. 1. » ;
303
+ - **les traits `---` sont ignorés** — ils s'affichaient en trois tirets ;
304
+ - une étoile perdue ne met pas la suite du message en gras ;
305
+ - le tiret bas d'un identifiant (`final_grade`) n'est pas de l'italique ;
306
+ - un bloc de code sans clôture s'arrête en fin de message, sans rien avaler ;
307
+ - le message de la personne elle-même (`plain`) reste tel qu'elle l'a tapé.
308
+
309
+ ## Les tableaux : measureColumns
310
+
311
+ Chaque cellule était un texte libre : React Native répartissait la place rangée
312
+ par rangée, et l'en-tête ne tombait plus en face de ses valeurs. Une colonne a
313
+ désormais **une** largeur, calculée une fois pour tout le tableau :
314
+
315
+ - mesurée sur le **texte visible** (« **Starter** » sans ses étoiles) ;
316
+ - jamais sous son mot le plus long — un mot n'est jamais coupé au milieu ;
317
+ - plafonnée, pour qu'une phrase revienne à la ligne au lieu d'étirer la colonne ;
318
+ - agrandie avec la **taille de texte choisie dans les réglages du téléphone** ;
319
+ - arrondie sur la grille de pixels de l'écran, sinon les filets bavent ;
320
+ - étirée au prorata jusqu'à la largeur disponible ;
321
+ - alignée à droite quand toutes ses valeurs sont des nombres (montants en toute
322
+ devise, pourcentages, notes sur 20).
323
+
324
+ ```js
325
+ const { widths, numeric } = measureColumns(header, rows, {
326
+ targetWidth: 320,
327
+ fontScale: PixelRatio.getFontScale(),
328
+ round: PixelRatio.roundToNearestPixel
329
+ });
330
+ ```
331
+
332
+ Un voile en dégradé signale qu'un tableau trop large continue à droite : sans
333
+ lui, une colonne coupée par le bord se lisait comme une colonne vide.
334
+
335
+ ## La question en haut, la réponse dessous
336
+
337
+ Quand on envoie un message, le fil remonte pour poser la question en haut de
338
+ l'écran, et la réponse s'écrit dessous — au lieu de suivre le bas pendant
339
+ qu'elle arrive, ce qui faisait sauter l'écran à chaque ligne.
340
+
341
+ ```js
342
+ import { anchorOffset, reserveBelowQuestion } from '@astratra/native-ui';
343
+
344
+ // La réserve sous la question, mesurée sur le VRAI contenu (réserve exclue).
345
+ const reserve = reserveBelowQuestion({ viewportHeight, contentHeight, anchorY });
346
+ listRef.current.scrollToOffset({ offset: anchorOffset(anchorY) });
347
+ ```
348
+
349
+ Avec cette réserve, le défilement maximal vaut exactement la position de la
350
+ question, que la réponse s'allonge ou raccourcisse ; elle tombe à zéro dès que la
351
+ réponse remplit l'écran.
352
+
353
+ ---
354
+
355
+ # Tests
356
+
357
+ ```sh
358
+ npx jest packages/native-ui
359
+ ```
360
+
361
+ - **La logique pure** se teste en Node, sans rien simuler.
362
+ - **Les composants** sont montés avec `react-dom` dans `jsdom`, sur des doublures
363
+ des modules natifs (`test/rn.js`) qui rendent chaque composant hôte en élément
364
+ portant ses props. On vérifie ce que le doigt et le lecteur d'écran
365
+ rencontrent — rôles, états, libellés, ordre des couches, ce qu'un appui
366
+ déclenche — sur iOS 26 et sur Android. Jamais des pixels : un rendu sur
367
+ appareil reste à faire.
368
+ - `jsdom` 30 dépend d'un module ES seul, que le chargeur de jest ne sait pas
369
+ lire : il est chargé par le `require` natif de Node (Node ≥ 22.3 pour les
370
+ tests).
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@astratra/native-ui",
3
+ "version": "0.1.0",
4
+ "description": "Mobile UI kit for React Native / Expo: Apple's Liquid Glass on iOS and a calibrated visible surface on Android, glass buttons, pale cards, bars that fold on scroll, an Instagram-style tab bar, a collapsible header, and a Markdown renderer for AI answers. Pure rules exported separately and tested in plain Node.",
5
+ "engines": {
6
+ "node": ">=20"
7
+ },
8
+ "main": "src/index.js",
9
+ "types": "src/index.d.ts",
10
+ "type": "commonjs",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./src/index.d.ts",
14
+ "default": "./src/index.js"
15
+ },
16
+ "./logic": {
17
+ "types": "./src/logic/index.d.ts",
18
+ "default": "./src/logic/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "src",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "test": "jest"
28
+ },
29
+ "jest": {
30
+ "testMatch": [
31
+ "**/__tests__/**/*.test.js"
32
+ ]
33
+ },
34
+ "keywords": [
35
+ "astratra",
36
+ "react-native",
37
+ "expo",
38
+ "liquid-glass",
39
+ "ui",
40
+ "tab-bar",
41
+ "markdown"
42
+ ],
43
+ "license": "MIT",
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "dependencies": {
48
+ "@astratra/native": "^0.2.0"
49
+ },
50
+ "peerDependencies": {
51
+ "expo-blur": ">=14",
52
+ "expo-glass-effect": ">=0.1",
53
+ "expo-linear-gradient": ">=14",
54
+ "react": ">=18",
55
+ "react-native": ">=0.76",
56
+ "react-native-reanimated": ">=3.16"
57
+ },
58
+ "devDependencies": {
59
+ "@testing-library/react": "^16.3.2",
60
+ "jest": "30.4.2",
61
+ "jsdom": "^30.0.1",
62
+ "react-dom": "^19.2.8"
63
+ }
64
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A chevron drawn with two borders — no SVG dependency for an arrow.
3
+ *
4
+ * The corner of a square (its left and bottom borders), turned 45°, points
5
+ * left; turned 225°, right. The rounded ends of an SVG stroke are lost, which
6
+ * at 10 points nobody sees.
7
+ */
8
+ const { h, RN } = require('./runtime');
9
+
10
+ const ROTATION = { left: '45deg', right: '225deg', up: '135deg', down: '-45deg' };
11
+
12
+ function Chevron({ direction = 'left', color = '#0d1235', size = 9, strokeWidth = 2.4 }) {
13
+ return h(RN.View, {
14
+ pointerEvents: 'none',
15
+ style: {
16
+ width: size,
17
+ height: size,
18
+ borderLeftWidth: strokeWidth,
19
+ borderBottomWidth: strokeWidth,
20
+ borderColor: color,
21
+ borderRadius: 1,
22
+ /* The corner sits at the visual centre only once shifted toward the
23
+ open side by a quarter of its diagonal. */
24
+ marginLeft: direction === 'left' ? size / 4 : 0,
25
+ marginRight: direction === 'right' ? size / 4 : 0,
26
+ transform: [{ rotate: ROTATION[direction] || ROTATION.left }]
27
+ }
28
+ });
29
+ }
30
+
31
+ module.exports = { Chevron };