@astratra/native-ui 0.1.1 → 0.2.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/README.md CHANGED
@@ -3,7 +3,8 @@
3
3
  Le kit d'interface mobile : **le verre liquide d'Apple sur iOS, une surface
4
4
  visible sur Android**, des boutons en verre, des cartes pâles, des barres qui se
5
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.
6
+ repliable, le rendu propre des réponses d'une IA, et les images : à leurs
7
+ propres proportions, une place qui attend, une visionneuse plein écran.
7
8
 
8
9
  Pour React Native / Expo. Pas d'étape de build, pas de JSX : le source est du
9
10
  CommonJS écrit avec `createElement`, comme `@astratra/react`, et Metro le prend
@@ -350,6 +351,141 @@ Avec cette réserve, le défilement maximal vaut exactement la position de la
350
351
  question, que la réponse s'allonge ou raccourcisse ; elle tombe à zéro dès que la
351
352
  réponse remplit l'écran.
352
353
 
354
+ # Les images
355
+
356
+ Trois composants et leurs règles pures (`logic/picture.js`). Aucun ne construit
357
+ d'adresse ni n'ajoute d'en-tête : la source vient de l'application, en général
358
+ de `createPictureSources().resolve` de `@astratra/native`, **seul** à décider si
359
+ le jeton de la personne part avec (jamais vers une autre origine).
360
+
361
+ ## AutoRatioImage
362
+
363
+ ```js
364
+ import { Image } from 'expo-image';
365
+ import { AutoRatioImage } from '@astratra/native-ui';
366
+
367
+ h(AutoRatioImage, {
368
+ load: () => pictures.resolve(card.url), // ou `source` déjà prête
369
+ sourceKey: card.url,
370
+ ImageComponent: Image, // Image de React Native par défaut
371
+ imageProps: { cachePolicy: 'disk' },
372
+ accessibilityLabel: title,
373
+ accessibilityHint: t('viewer.openHint'),
374
+ loadingLabel: t('picture.loading'),
375
+ renderError: ({ retry }) => h(GlassButton, { onPress: retry, accessibilityLabel: t('retry') }, icon),
376
+ onPress: () => setViewing(0)
377
+ });
378
+ ```
379
+
380
+ **Libre sur la page** : pas de cadre, coins arrondis, rien de coupé. Un carré
381
+ (ou `initialRatio`) tant que l'image n'est pas connue, puis **ses propres
382
+ proportions** — bornées entre 1/5 et 5 (`minRatio`, `maxRatio`) : une bande de
383
+ 1 × 10 000 ne fait pas une vue de dix mille points de haut, elle est montrée
384
+ entière dans la boîte bornée.
385
+
386
+ **Elle monte doucement** une fois là — d'un rien plus petite et transparente
387
+ à sa place, 700 ms, sortie en douceur — au lieu d'apparaître d'un coup.
388
+ **Réduire les animations** : d'un coup. En attendant, la place tenue porte le
389
+ reflet d'`ImageShimmer` (`placeholder` pour en mettre une autre, `null` pour
390
+ aucune).
391
+
392
+ **Un échec n'est jamais un indicateur qui tourne pour toujours.** Une source
393
+ qui échoue ou ne résout rien, une image qui ne se décode pas : l'emplacement
394
+ d'erreur de l'application (`renderError({ retry })`). L'ancienne version
395
+ laissait une promesse rejetée sans suite — la roue tournait indéfiniment.
396
+ **Une nouvelle image repart de zéro** : l'ancienne gardait la forme et l'échec
397
+ de la précédente quand l'adresse changeait.
398
+
399
+ **Touchable** (`onPress`) : un bouton-image, désactivé tant que l'image n'est
400
+ pas là, qui s'enfonce sur un ressort comme `GlassButton` (Réduire les
401
+ animations : il s'assombrit seulement). Sans `onPress`, une simple image
402
+ annoncée comme telle. Les deux événements de chargement sont lus (expo-image
403
+ et React Native) ; `fit` devient `contentFit` ou `resizeMode` selon le
404
+ composant.
405
+
406
+ ## ImageShimmer
407
+
408
+ La place d'une image qu'on prépare : la forme aux proportions attendues, plate,
409
+ sans contour, où passe une lumière douce de gauche à droite (1,6 s). Un seul
410
+ élément pour le lecteur d'écran, une barre de progression avec les mots de
411
+ l'application (`accessibilityLabel`), et une légende discrète facultative
412
+ (`caption`). **Réduire les animations** : la lumière ne bouge pas. Elle
413
+ s'arrête quand la place disparaît.
414
+
415
+ ### Les images en route
416
+
417
+ ```js
418
+ import { reducePicturesArriving, picturesArrivingCount, PICTURES_ARRIVING_EMPTY } from '@astratra/native-ui/logic';
419
+
420
+ const [arriving, dispatch] = useReducer(reducePicturesArriving, PICTURES_ARRIVING_EMPTY);
421
+ // step 'draw' commencé → dispatch({ type: 'started', id })
422
+ // step terminé → dispatch({ type: 'finished', id, ok })
423
+ // l'image est arrivée → dispatch({ type: 'arrived', id? })
424
+ // fin du flux → dispatch({ type: 'ended' })
425
+ Array.from({ length: picturesArrivingCount(arriving) }, (_, i) => h(ImageShimmer, { key: i, … }));
426
+ ```
427
+
428
+ La première version comptait les étapes « dessiner » en cours : le compte
429
+ tombait à zéro dès la fin de l'étape, alors que l'image arrivait dans un
430
+ événement suivant — la place disparaissait, le texte remontait, puis l'image le
431
+ repoussait. Ici une tâche finie **garde sa place jusqu'à l'arrivée de l'image** ;
432
+ seuls un échec ou la fin du flux la rendent. Un événement qui ne change rien
433
+ rend **le même objet** (React ne redessine pas). `countRunningSteps(steps, tool)`
434
+ donne l'ancien compte pour une application qui garde des étapes.
435
+
436
+ ## ImageViewer
437
+
438
+ ```js
439
+ h(ImageViewer, {
440
+ pictures, // [{ key, title?, accessibilityLabel?, … }]
441
+ start: viewing, // null : fermée
442
+ onClose: () => setViewing(null),
443
+ resolveSource: (picture) => pictures.resolve(picture.url),
444
+ ImageComponent: Image,
445
+ labels: { close: t('close'), share: t('share'), details: t('details'),
446
+ counter: (n, total) => t('counter', { n, total }), zoomHint: t('zoomHint') },
447
+ icons: { close: xmark, share: shareIcon, details: info, detailsActive: infoFill, failed: photo, notice: check },
448
+ onShare: (picture) => sharePicture(picture),
449
+ actions: [{ key: 'save', label: t('save'), icon: download, onPress: async (p) => (await save(p), t('saved')) }],
450
+ onActionError: (error, key) => Alert.alert(t('failed')),
451
+ renderDetails: (picture) => h(Details, { picture }),
452
+ insets: useSafeAreaInsets(),
453
+ onHaptic: (kind) => haptics[kind]()
454
+ });
455
+ ```
456
+
457
+ Comme Photos : du noir autour, des commandes rondes en verre qu'un appui cache
458
+ et remontre (la barre d'état avec, et le lecteur d'écran ne les atteint plus
459
+ tant qu'elles sont cachées).
460
+
461
+ - **Glisser de côté** passe d'une image à l'autre ; le compteur (« 3 sur 12 »,
462
+ les mots de l'application) suit, `onIndexChange` est appelé.
463
+ - **Pincer ou toucher deux fois** zoome là où l'on touche, au plus ×4 ; deux
464
+ fois encore pour revenir. Zoomé, le doigt déplace l'image et les pages ne
465
+ tournent plus. Sur **iOS** le zoom est natif (ScrollView qui zoome, son rebond,
466
+ sa décélération). La ScrollView d'**Android** ne zoome pas — l'ancienne
467
+ version n'y avait tout simplement pas de pincement — : Android reçoit le sien,
468
+ deux doigts autour du point entre eux, un doigt pour se déplacer, et au
469
+ relâcher l'image revient dans ses bornes. Un toucher unique attend 260 ms
470
+ pour ne pas être le premier d'un double.
471
+ - **Tirer vers le bas** ferme : le noir s'efface sur une demi-hauteur, l'image
472
+ rétrécit jusqu'à 80 %. Au-delà de 120 points ou assez vite, elle part ;
473
+ sinon elle revient sur un ressort. Deux doigts ne tirent jamais.
474
+ - **En haut** : fermer, le compteur, les détails si l'application en donne
475
+ (`renderDetails`, qui remplace alors le titre). **En bas** : le titre, puis
476
+ partager (`onShare`) et les actions de l'application. Une action peut rendre
477
+ un court message (« Enregistrée ») montré un instant et annoncé au lecteur
478
+ d'écran ; un échec va à `onActionError` avec sa clé et l'image. Pendant
479
+ qu'une action tourne, les autres attendent.
480
+ - **Réduire les animations** : pas de fondu, pas de ressort, pas de
481
+ rétrécissement ; la visionneuse s'ouvre et se ferme d'un coup, le zoom par
482
+ double toucher n'est pas animé.
483
+ - Une **rotation** remet la liste sur la même image ; la dernière image retirée
484
+ ferme la visionneuse ; le retour Android et le geste d'échappement de
485
+ VoiceOver ferment aussi.
486
+
487
+ Aucun texte, aucune icône, aucun chargement en dur : tout vient des props.
488
+
353
489
  ---
354
490
 
355
491
  # Tests
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@astratra/native-ui",
3
- "version": "0.1.1",
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.",
3
+ "version": "0.2.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, a Markdown renderer for AI answers, and pictures: natural-ratio images revealed gently, a shimmer placeholder, and a full-screen viewer with pinch, double-tap and pull-to-close. Pure rules exported separately and tested in plain Node.",
5
5
  "engines": {
6
6
  "node": ">=20"
7
7
  },
@@ -38,14 +38,16 @@
38
38
  "liquid-glass",
39
39
  "ui",
40
40
  "tab-bar",
41
- "markdown"
41
+ "markdown",
42
+ "image",
43
+ "image-viewer"
42
44
  ],
43
45
  "license": "MIT",
44
46
  "publishConfig": {
45
47
  "access": "public"
46
48
  },
47
49
  "dependencies": {
48
- "@astratra/native": "^0.2.0"
50
+ "@astratra/native": "^0.3.0"
49
51
  },
50
52
  "peerDependencies": {
51
53
  "expo-blur": ">=14",
@@ -0,0 +1,237 @@
1
+ /**
2
+ * A picture free on the page, in its own proportions: no frame around it, its
3
+ * corners rounded, nothing cut off. Until it is known it holds a place (a
4
+ * square by default, or the shape the app expects) where a soft light passes;
5
+ * once there, it takes its own shape and comes up gently — from a hair smaller
6
+ * and transparent — rather than all at once. "Reduce Motion": at once.
7
+ *
8
+ * The source is given ready (`source`), or fetched (`load`, with a
9
+ * `sourceKey` naming the picture) — typically `@astratra/native`'s
10
+ * `createPictureSources().resolve`, which decides whether the person's token
11
+ * goes with it. This component never builds an address nor adds a header.
12
+ *
13
+ * A failure — a load that rejects, a picture that does not decode — leaves the
14
+ * place with the app's error slot (`renderError({ retry })`); nothing spins
15
+ * forever.
16
+ *
17
+ * The image component is injected: React Native's Image by default, or
18
+ * expo-image's (`ImageComponent={Image}` with its caching props in
19
+ * `imageProps`). Both load events are read.
20
+ */
21
+ const { React, h, RN, Reanimated, AnimatedView, useReducedMotion, useScheme } = require('./runtime');
22
+ const { ImageShimmer } = require('./ImageShimmer');
23
+ const { PICTURE_REVEAL, naturalRatio, loadedSize, revealStyle } = require('../logic/picture');
24
+ const { PALE_CARD_RADIUS } = require('../logic/paleCard');
25
+
26
+ const { Easing, useAnimatedStyle, useSharedValue, withSpring, withTiming } = Reanimated;
27
+
28
+ /* The surface shown only while the picture is not there (loading, failed). */
29
+ const DEFAULT_SURFACE = { light: '#eef0f5', dark: '#1c1c1e' };
30
+
31
+ /* The press, like the kit's glass button: the finger sinks the picture on a
32
+ spring without bounce, the release brings it back with a hair of overshoot. */
33
+ const PRESSED_SCALE = 0.98;
34
+ const PRESS_IN = { stiffness: 600, damping: 2 * Math.sqrt(600), mass: 1 };
35
+ const RELEASE = { stiffness: 380, damping: 20, mass: 1 };
36
+
37
+ function AutoRatioImage({
38
+ source,
39
+ load,
40
+ sourceKey,
41
+ ImageComponent,
42
+ imageProps,
43
+ fit = 'contain',
44
+ initialRatio = 1,
45
+ minRatio,
46
+ maxRatio,
47
+ borderRadius = PALE_CARD_RADIUS,
48
+ surfaceColor,
49
+ placeholder,
50
+ loadingLabel,
51
+ renderError,
52
+ accessibilityLabel,
53
+ accessibilityHint,
54
+ onPress,
55
+ onLongPress,
56
+ onLoad,
57
+ onError,
58
+ onRatio,
59
+ revealMs = PICTURE_REVEAL.durationMs,
60
+ scheme: schemeOverride,
61
+ style,
62
+ testID
63
+ }) {
64
+ const scheme = useScheme(schemeOverride);
65
+ const reduceMotion = useReducedMotion();
66
+ const Picture = ImageComponent || RN.Image;
67
+ const limits = { min: minRatio, max: maxRatio };
68
+ const startRatio = naturalRatio(null, { fallback: initialRatio, ...limits });
69
+ const key = sourceKey !== undefined && sourceKey !== null ? String(sourceKey) : source && source.uri ? source.uri : null;
70
+
71
+ const [resolved, setResolved] = React.useState(null);
72
+ const [phase, setPhase] = React.useState('resolving');
73
+ const [ratio, setRatio] = React.useState(startRatio);
74
+ const [attempt, setAttempt] = React.useState(0);
75
+ const shown = useSharedValue(0);
76
+ const press = useSharedValue(0);
77
+
78
+ /* A new picture starts over: its own place, its own reveal. The first
79
+ version kept the previous picture's shape and failure. */
80
+ const latest = React.useRef({ load, source, onError });
81
+ latest.current = { load, source, onError };
82
+ React.useEffect(() => {
83
+ let live = true;
84
+ shown.value = 0;
85
+ setRatio(startRatio);
86
+ setResolved(null);
87
+ const fail = (error) => {
88
+ if (!live) return;
89
+ setPhase('failed');
90
+ if (latest.current.onError) latest.current.onError(error);
91
+ };
92
+ if (typeof latest.current.load === 'function') {
93
+ setPhase('resolving');
94
+ Promise.resolve()
95
+ .then(() => latest.current.load())
96
+ .then((next) => {
97
+ if (!live) return;
98
+ if (next && typeof next.uri === 'string' && next.uri) {
99
+ setResolved(next);
100
+ setPhase('loading');
101
+ } else {
102
+ fail(null);
103
+ }
104
+ }, fail);
105
+ } else if (latest.current.source && latest.current.source.uri) {
106
+ setResolved(latest.current.source);
107
+ setPhase('loading');
108
+ } else {
109
+ setPhase('failed');
110
+ }
111
+ return () => {
112
+ live = false;
113
+ };
114
+ /* The picture is named by its key: a new object for the same key is the
115
+ same picture, and must not start the reveal over. */
116
+ }, [key, attempt]);
117
+
118
+ const retry = React.useCallback(() => setAttempt((count) => count + 1), []);
119
+
120
+ const handleLoad = (event) => {
121
+ const size = loadedSize(event);
122
+ if (size) {
123
+ const next = naturalRatio(size, { fallback: initialRatio, ...limits });
124
+ setRatio(next);
125
+ if (onRatio) onRatio(next);
126
+ }
127
+ shown.value = reduceMotion ? 1 : withTiming(1, { duration: revealMs, easing: Easing.out(Easing.cubic) });
128
+ setPhase('loaded');
129
+ if (onLoad) onLoad(size);
130
+ };
131
+
132
+ const handleError = (event) => {
133
+ setPhase('failed');
134
+ if (onError) onError(event && event.nativeEvent ? event.nativeEvent.error : event);
135
+ };
136
+
137
+ const reveal = useAnimatedStyle(() => revealStyle(shown.value));
138
+ const pressed = useAnimatedStyle(() => ({
139
+ transform: [{ scale: reduceMotion ? 1 : 1 - (1 - PRESSED_SCALE) * press.value }],
140
+ opacity: reduceMotion ? 1 - 0.25 * press.value : 1
141
+ }));
142
+
143
+ const loaded = phase === 'loaded';
144
+ const failed = phase === 'failed';
145
+ const surface = surfaceColor || DEFAULT_SURFACE[scheme];
146
+ const fitProps = Picture === RN.Image ? { resizeMode: fit } : { contentFit: fit };
147
+
148
+ const waiting =
149
+ placeholder !== undefined
150
+ ? placeholder
151
+ : h(ImageShimmer, {
152
+ ratio,
153
+ borderRadius: 0,
154
+ colors: { base: surface },
155
+ accessibilityLabel: loadingLabel,
156
+ scheme,
157
+ style: RN.StyleSheet.absoluteFill
158
+ });
159
+
160
+ const content = failed
161
+ ? h(
162
+ RN.View,
163
+ { style: [RN.StyleSheet.absoluteFill, { alignItems: 'center', justifyContent: 'center' }] },
164
+ typeof renderError === 'function' ? renderError({ retry }) : null
165
+ )
166
+ : [
167
+ loaded ? null : h(React.Fragment, { key: 'waiting' }, waiting),
168
+ resolved
169
+ ? h(
170
+ AnimatedView,
171
+ { key: `picture-${attempt}`, pointerEvents: 'none', style: [RN.StyleSheet.absoluteFill, reveal] },
172
+ h(Picture, {
173
+ ...fitProps,
174
+ ...(imageProps || {}),
175
+ source: resolved,
176
+ onLoad: handleLoad,
177
+ onError: handleError,
178
+ accessibilityIgnoresInvertColors: true,
179
+ style: { width: '100%', height: '100%' }
180
+ })
181
+ )
182
+ : null
183
+ ];
184
+
185
+ const frame = {
186
+ aspectRatio: ratio,
187
+ borderRadius,
188
+ overflow: 'hidden',
189
+ backgroundColor: loaded ? 'transparent' : surface
190
+ };
191
+
192
+ if (typeof onPress !== 'function' && typeof onLongPress !== 'function') {
193
+ return h(
194
+ RN.View,
195
+ {
196
+ testID,
197
+ /* Failed, the frame stops grouping: the app's retry button inside
198
+ must be reachable by the screen reader on its own. */
199
+ accessible: !failed,
200
+ accessibilityRole: 'image',
201
+ accessibilityLabel,
202
+ accessibilityState: { busy: !loaded && !failed },
203
+ style: [frame, style]
204
+ },
205
+ content
206
+ );
207
+ }
208
+
209
+ return h(
210
+ AnimatedView,
211
+ { style: [pressed, style] },
212
+ h(
213
+ RN.Pressable,
214
+ {
215
+ testID,
216
+ accessible: !failed,
217
+ accessibilityRole: 'imagebutton',
218
+ accessibilityLabel,
219
+ accessibilityHint,
220
+ accessibilityState: { disabled: !loaded, busy: !loaded && !failed },
221
+ disabled: !loaded,
222
+ onPress,
223
+ onLongPress,
224
+ onPressIn: () => {
225
+ press.value = withSpring(1, PRESS_IN);
226
+ },
227
+ onPressOut: () => {
228
+ press.value = withSpring(0, RELEASE);
229
+ },
230
+ style: frame
231
+ },
232
+ content
233
+ )
234
+ );
235
+ }
236
+
237
+ module.exports = { AutoRatioImage };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The place a picture will take while it is being made or fetched: a rounded
3
+ * shape at the picture's proportions, where a soft light passes left to right,
4
+ * and an optional quiet caption beneath.
5
+ *
6
+ * The screen reader hears one element, a progress bar with the app's label
7
+ * ("Drawing the picture…"). "Reduce Motion": the light does not move — the
8
+ * shape stays, still.
9
+ */
10
+ const { React, h, RN, LinearGradient, Reanimated, AnimatedView, useReducedMotion, useScheme } = require('./runtime');
11
+ const { SHIMMER_PASS_MS, shimmerTranslate } = require('../logic/picture');
12
+ const { PALE_CARD_RADIUS } = require('../logic/paleCard');
13
+
14
+ /* Destructured once: a worklet must capture what it calls itself. */
15
+ const { Easing, cancelAnimation, useAnimatedStyle, useSharedValue, withRepeat, withTiming } = Reanimated;
16
+
17
+ /* A flat ground with no outline, and a light only a little brighter: the
18
+ placeholder is a presence, not a signal. The app's own theme passes its
19
+ colours in `colors`. */
20
+ const DEFAULT_COLORS = {
21
+ light: { base: '#eef0f5', light: 'rgba(255,255,255,0.85)', caption: 'rgba(60,60,67,0.6)' },
22
+ dark: { base: '#1c1c1e', light: 'rgba(255,255,255,0.08)', caption: 'rgba(235,235,245,0.6)' }
23
+ };
24
+
25
+ function ImageShimmer({
26
+ ratio = 1,
27
+ borderRadius = PALE_CARD_RADIUS,
28
+ colors,
29
+ accessibilityLabel,
30
+ caption,
31
+ captionStyle,
32
+ passMs = SHIMMER_PASS_MS,
33
+ scheme: schemeOverride,
34
+ style,
35
+ testID
36
+ }) {
37
+ const scheme = useScheme(schemeOverride);
38
+ const palette = { ...DEFAULT_COLORS[scheme], ...(colors || {}) };
39
+ const reduceMotion = useReducedMotion();
40
+ const position = useSharedValue(0);
41
+
42
+ React.useEffect(() => {
43
+ if (reduceMotion) {
44
+ cancelAnimation(position);
45
+ position.value = 0;
46
+ return undefined;
47
+ }
48
+ position.value = withRepeat(withTiming(1, { duration: passMs, easing: Easing.inOut(Easing.quad) }), -1, false);
49
+ return () => cancelAnimation(position);
50
+ }, [reduceMotion, passMs, position]);
51
+
52
+ const light = useAnimatedStyle(() => ({ transform: [{ translateX: shimmerTranslate(position.value) }] }));
53
+
54
+ return h(
55
+ RN.View,
56
+ {
57
+ testID,
58
+ accessible: true,
59
+ accessibilityRole: 'progressbar',
60
+ accessibilityLabel,
61
+ accessibilityState: { busy: true },
62
+ style: [{ gap: 8 }, style]
63
+ },
64
+ h(
65
+ RN.View,
66
+ {
67
+ style: {
68
+ aspectRatio: ratio > 0 ? ratio : 1,
69
+ borderRadius,
70
+ overflow: 'hidden',
71
+ backgroundColor: palette.base
72
+ }
73
+ },
74
+ reduceMotion
75
+ ? null
76
+ : h(
77
+ AnimatedView,
78
+ { pointerEvents: 'none', style: [RN.StyleSheet.absoluteFill, light] },
79
+ h(LinearGradient, {
80
+ colors: ['transparent', palette.light, 'transparent'],
81
+ start: { x: 0, y: 0.35 },
82
+ end: { x: 1, y: 0.65 },
83
+ style: RN.StyleSheet.absoluteFill
84
+ })
85
+ )
86
+ ),
87
+ caption ? h(RN.Text, { style: [{ fontSize: 13, color: palette.caption }, captionStyle] }, caption) : null
88
+ );
89
+ }
90
+
91
+ module.exports = { ImageShimmer };