@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.
@@ -0,0 +1,322 @@
1
+ /**
2
+ * The rendering of an AI answer: a walk over the typed blocks that
3
+ * `parseMarkdown` produces. The whole grammar is in the parser, tested dry;
4
+ * here there is only style.
5
+ *
6
+ * Every default style can be overridden by name through `styles`.
7
+ */
8
+ const { React, h, RN, LinearGradient } = require('./runtime');
9
+ const { GlassButton } = require('./GlassButton');
10
+ const { parseMarkdown, parseInline, inlinesToText } = require('../logic/markdown');
11
+ const { measureColumns } = require('../logic/tableColumns');
12
+ const { withAlpha } = require('../logic/color');
13
+
14
+ const hairline = RN.StyleSheet.hairlineWidth;
15
+
16
+ const DEFAULT_STYLES = {
17
+ stack: { gap: 9 },
18
+ body: { color: '#1d2440', fontSize: 15, lineHeight: 22 },
19
+ plain: { color: '#ffffff', fontWeight: '500' },
20
+ bold: { fontWeight: '700', color: '#0d1235' },
21
+ italic: { fontStyle: 'italic' },
22
+ link: { color: '#1f4dcc', textDecorationLine: 'underline' },
23
+ inlineCode: { fontWeight: '500', fontSize: 13.5, color: '#315bd4', backgroundColor: 'rgba(59,108,240,0.10)' },
24
+ heading: { color: '#0d1235', fontWeight: '800', fontSize: 17, lineHeight: 24, letterSpacing: -0.3 },
25
+ headingSmall: { fontSize: 15, lineHeight: 21 },
26
+ list: { gap: 5 },
27
+ listItem: { flexDirection: 'row', alignItems: 'flex-start', gap: 7 },
28
+ listMarker: { minWidth: 15, color: '#1f4dcc', fontWeight: '700', fontSize: 14, lineHeight: 22 },
29
+ listBody: { flex: 1 },
30
+ code: {
31
+ overflow: 'hidden',
32
+ borderRadius: 12,
33
+ backgroundColor: 'rgba(13,18,53,0.04)',
34
+ borderWidth: hairline,
35
+ borderColor: 'rgba(13,18,53,0.08)'
36
+ },
37
+ codeBar: {
38
+ height: 34,
39
+ paddingLeft: 12,
40
+ paddingRight: 4,
41
+ flexDirection: 'row',
42
+ alignItems: 'center',
43
+ justifyContent: 'space-between',
44
+ gap: 8
45
+ },
46
+ codeLanguage: { color: 'rgba(13,18,53,0.45)', fontWeight: '700', fontSize: 10, letterSpacing: 0.6 },
47
+ codeText: { paddingHorizontal: 12, paddingBottom: 11, color: '#26304c', fontWeight: '500', fontSize: 12.5, lineHeight: 19 },
48
+ /* THE TABLE TOOK THE SCREEN'S HEIGHT. A one-row table stretched a thousand
49
+ points tall on an iPhone. Not the table growing: the container of a
50
+ horizontal ScrollView aligns its children with `stretch` on the vertical
51
+ axis. `alignItems: flex-start` gives the table back its own height, and
52
+ `alignSelf: flex-start` stops the scroller claiming more room than it
53
+ fills. */
54
+ tableFrame: { position: 'relative', alignSelf: 'flex-start', maxWidth: '100%' },
55
+ tableVeil: { position: 'absolute', top: 0, bottom: 0, right: 0, width: 28 },
56
+ tableScroll: { paddingVertical: 2, alignItems: 'flex-start' },
57
+ tableScrollView: { flexGrow: 0, alignSelf: 'flex-start' },
58
+ /* A CLEAN TABLE, NOT A GRID. A rounded frame, a rule between EVERY column
59
+ and a painted header were four strokes and two colours for three
60
+ numbers — the eye followed the grid instead of the data. What remains:
61
+ one stroke under the header, a light rule between rows, nothing around.
62
+ Columns separate by space. */
63
+ table: { overflow: 'hidden' },
64
+ /* `stretch`: the boxes of a row take the tallest one's height, or rows
65
+ go out of line as soon as one cell wraps. */
66
+ tableRow: { flexDirection: 'row', alignItems: 'stretch', borderTopWidth: hairline, borderTopColor: 'rgba(13,18,53,0.07)' },
67
+ tableHeader: { borderTopWidth: 0, borderBottomWidth: hairline, borderBottomColor: 'rgba(13,18,53,0.22)' },
68
+ /* The width lives on the BOX, not the text: a wide Text lets its content
69
+ float in the room it gets, a wide box frames it. The 12 + 12 padding is
70
+ the `padding` the column measure assumes. */
71
+ tableCellBox: { paddingHorizontal: 12, paddingVertical: 10, justifyContent: 'flex-start' },
72
+ tableHeaderText: {
73
+ color: 'rgba(13,18,53,0.52)',
74
+ fontWeight: '600',
75
+ fontSize: 11,
76
+ lineHeight: 15,
77
+ letterSpacing: 0.3,
78
+ textTransform: 'uppercase'
79
+ },
80
+ tableCell: { color: '#0d1235', fontWeight: '500', fontSize: 13.5, lineHeight: 19 },
81
+ /* A fully numeric column reads right-aligned, in tabular digits — or "1"
82
+ and "8" differ in width and the column ripples from row to row. */
83
+ tableCellNumber: { textAlign: 'right', fontVariant: ['tabular-nums'] },
84
+ quote: { paddingLeft: 11, paddingVertical: 2, borderLeftWidth: 2, borderLeftColor: 'rgba(59,108,240,0.35)' },
85
+ quoteText: { color: 'rgba(13,18,53,0.7)', fontStyle: 'italic' }
86
+ };
87
+
88
+ function openLink(href) {
89
+ Promise.resolve(RN.Linking.openURL(href)).catch(() => {});
90
+ }
91
+
92
+ const DEFAULT_VEIL = 'rgba(255,255,255,0.92)';
93
+ /* The default context lets `MarkdownTable` render on its own, outside a
94
+ MarkdownView. */
95
+ const Ctx = React.createContext({ styles: DEFAULT_STYLES, onLinkPress: openLink, tableVeilColor: DEFAULT_VEIL });
96
+
97
+ function MarkdownView({
98
+ content,
99
+ plain = false,
100
+ styles: overrides,
101
+ onLinkPress = openLink,
102
+ onCopyCode,
103
+ copyLabel,
104
+ copyIcon,
105
+ copiedIcon,
106
+ fontScale,
107
+ tableVeilColor = DEFAULT_VEIL,
108
+ testID
109
+ }) {
110
+ const styles = React.useMemo(() => {
111
+ const merged = { ...DEFAULT_STYLES };
112
+ for (const key of Object.keys(overrides || {})) merged[key] = [DEFAULT_STYLES[key], overrides[key]];
113
+ return merged;
114
+ }, [overrides]);
115
+ const blocks = React.useMemo(() => (plain ? [] : parseMarkdown(content)), [content, plain]);
116
+
117
+ /* A person's own message is not Markdown: they typed what they wanted to
118
+ see. Their stars and dashes are not taken away. */
119
+ if (plain) return h(RN.Text, { testID, style: [styles.body, styles.plain] }, content);
120
+ if (!blocks.length) return null;
121
+
122
+ const context = { styles, onLinkPress, onCopyCode, copyLabel, copyIcon, copiedIcon, fontScale, tableVeilColor };
123
+ return h(
124
+ Ctx.Provider,
125
+ { value: context },
126
+ h(RN.View, { testID, style: styles.stack }, blocks.map((block, index) => h(Block, { key: index, block })))
127
+ );
128
+ }
129
+
130
+ function Block({ block }) {
131
+ const { styles } = React.useContext(Ctx);
132
+ switch (block.kind) {
133
+ case 'heading':
134
+ return h(
135
+ RN.Text,
136
+ { accessibilityRole: 'header', style: [styles.heading, block.level >= 3 ? styles.headingSmall : null] },
137
+ h(Inlines, { inlines: block.inlines })
138
+ );
139
+ case 'bullets':
140
+ case 'numbers':
141
+ return h(
142
+ RN.View,
143
+ { style: styles.list },
144
+ block.items.map((item, index) =>
145
+ h(
146
+ RN.View,
147
+ { key: index, style: [styles.listItem, { marginLeft: item.depth * 14 }] },
148
+ h(RN.Text, { style: styles.listMarker }, block.kind === 'numbers' ? `${block.start + index}.` : '•'),
149
+ h(RN.Text, { style: [styles.body, styles.listBody] }, h(Inlines, { inlines: item.inlines }))
150
+ )
151
+ )
152
+ );
153
+ case 'code':
154
+ return h(CodeBlock, { language: block.language, text: block.text });
155
+ case 'table':
156
+ return h(MarkdownTable, { header: block.header, rows: block.rows });
157
+ case 'quote':
158
+ return h(
159
+ RN.View,
160
+ { style: styles.quote },
161
+ h(RN.Text, { style: [styles.body, styles.quoteText] }, h(Inlines, { inlines: block.inlines }))
162
+ );
163
+ default:
164
+ return h(RN.Text, { style: styles.body }, h(Inlines, { inlines: block.inlines }));
165
+ }
166
+ }
167
+
168
+ function Inlines({ inlines }) {
169
+ const { styles, onLinkPress } = React.useContext(Ctx);
170
+ return h(
171
+ React.Fragment,
172
+ null,
173
+ inlines.map((inline, index) => {
174
+ if (inline.kind === 'bold') return h(RN.Text, { key: index, style: styles.bold }, inline.text);
175
+ if (inline.kind === 'italic') return h(RN.Text, { key: index, style: styles.italic }, inline.text);
176
+ if (inline.kind === 'code') return h(RN.Text, { key: index, style: styles.inlineCode }, inline.text);
177
+ if (inline.kind === 'link') {
178
+ return h(
179
+ RN.Text,
180
+ { key: index, accessibilityRole: 'link', style: styles.link, onPress: () => onLinkPress(inline.href) },
181
+ inline.text
182
+ );
183
+ }
184
+ return h(RN.Text, { key: index }, inline.text);
185
+ })
186
+ );
187
+ }
188
+
189
+ /* A plain-text cell stays a string: it inherits its cell's style directly,
190
+ right alignment of numbers included. Only marked cells (bold, link…) go
191
+ through the inline renderer. */
192
+ function cellContent(inlines) {
193
+ if (!inlines || !inlines.length) return '';
194
+ if (inlines.every((inline) => inline.kind === 'text')) return inlinesToText(inlines);
195
+ return h(Inlines, { inlines });
196
+ }
197
+
198
+ /* A code block copies ALONE: in an answer that explains then shows, copying
199
+ the whole bubble means trimming the clipboard by hand. The copy itself
200
+ (clipboard, haptics) is the caller's. */
201
+ function CodeBlock({ language, text }) {
202
+ const { styles, onCopyCode, copyLabel, copyIcon, copiedIcon } = React.useContext(Ctx);
203
+ const [copied, setCopied] = React.useState(false);
204
+ const timer = React.useRef(null);
205
+ React.useEffect(() => () => clearTimeout(timer.current), []);
206
+ const copy = async () => {
207
+ await onCopyCode(text);
208
+ setCopied(true);
209
+ clearTimeout(timer.current);
210
+ timer.current = setTimeout(() => setCopied(false), 1600);
211
+ };
212
+ return h(
213
+ RN.View,
214
+ { style: styles.code },
215
+ h(
216
+ RN.View,
217
+ { style: styles.codeBar },
218
+ h(RN.Text, { style: styles.codeLanguage }, language || 'code'),
219
+ onCopyCode && copyIcon
220
+ ? h(
221
+ GlassButton,
222
+ { accessibilityLabel: copyLabel, onPress: () => void copy().catch(() => {}), size: 26 },
223
+ copied && copiedIcon ? copiedIcon : copyIcon
224
+ )
225
+ : null
226
+ ),
227
+ h(RN.ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false }, h(RN.Text, { style: styles.codeText }, text))
228
+ );
229
+ }
230
+
231
+ /**
232
+ * A table, and not three lists side by side. Each column's width is computed
233
+ * ONCE for the whole table (`measureColumns`), then imposed on the header and
234
+ * on every row: the only way a value falls under its title.
235
+ */
236
+ function MarkdownTable({ header, rows }) {
237
+ const { styles, fontScale, tableVeilColor } = React.useContext(Ctx);
238
+ const [contentWidth, setContentWidth] = React.useState(0);
239
+ const [viewWidth, setViewWidth] = React.useState(0);
240
+ const [moreOnRight, setMoreOnRight] = React.useState(true);
241
+ /* Two points of slack: a one-pixel difference comes from density rounding,
242
+ not from hidden content. */
243
+ const overflows = contentWidth - viewWidth > 2;
244
+ /* BOLD IN A CELL. Cells were shown raw: "**Starter**" came out with its
245
+ stars. They go through the paragraph renderer, and columns are measured
246
+ on the VISIBLE text, without the marks. */
247
+ const head = React.useMemo(() => header.map(parseInline), [header]);
248
+ const body = React.useMemo(() => rows.map((row) => row.map(parseInline)), [rows]);
249
+ const { widths, numeric } = React.useMemo(
250
+ () =>
251
+ measureColumns(
252
+ head.map(inlinesToText),
253
+ body.map((row) => row.map(inlinesToText)),
254
+ {
255
+ /* THE REAL ROOM. The table fills the measured width; 300 only
256
+ serves before the first measure. Wider than that, it scrolls. */
257
+ targetWidth: viewWidth > 0 ? viewWidth : 300,
258
+ fontScale: fontScale ?? RN.PixelRatio.getFontScale(),
259
+ /* On the real pixel grid, or the hairlines render as two blurry greys. */
260
+ round: (value) => RN.PixelRatio.roundToNearestPixel(value)
261
+ }
262
+ ),
263
+ [head, body, viewWidth, fontScale]
264
+ );
265
+
266
+ const row = (cells, key, textStyle, extra) =>
267
+ h(
268
+ RN.View,
269
+ { key, style: [styles.tableRow, extra] },
270
+ widths.map((width, index) =>
271
+ h(
272
+ RN.View,
273
+ { key: index, style: [styles.tableCellBox, { width }] },
274
+ h(RN.Text, { style: [textStyle, numeric[index] ? styles.tableCellNumber : null] }, cellContent(cells[index]))
275
+ )
276
+ )
277
+ );
278
+
279
+ return h(
280
+ RN.View,
281
+ { style: styles.tableFrame },
282
+ h(
283
+ RN.ScrollView,
284
+ {
285
+ horizontal: true,
286
+ /* The native indicator stays: on an overflowing table it is the only
287
+ confirmation that the gesture does something. */
288
+ showsHorizontalScrollIndicator: true,
289
+ style: styles.tableScrollView,
290
+ contentContainerStyle: styles.tableScroll,
291
+ scrollEventThrottle: 16,
292
+ onScroll: ({ nativeEvent }) => {
293
+ const rest = nativeEvent.contentSize.width - nativeEvent.contentOffset.x - nativeEvent.layoutMeasurement.width;
294
+ setMoreOnRight(rest > 2);
295
+ },
296
+ onContentSizeChange: (width) => setContentWidth(width),
297
+ onLayout: ({ nativeEvent }) => setViewWidth(nativeEvent.layout.width)
298
+ },
299
+ h(
300
+ RN.View,
301
+ { style: styles.table },
302
+ row(head, 'header', styles.tableHeaderText, styles.tableHeader),
303
+ body.map((cells, index) => row(cells, index, styles.tableCell, null))
304
+ )
305
+ ),
306
+ /* THE VEIL THAT SAYS THERE IS MORE. A column cut by the screen edge was
307
+ read as an EMPTY column: nothing said the table scrolls. This gradient
308
+ shows while something remains on the right and fades at the end. It
309
+ takes no room and catches no finger. */
310
+ overflows && moreOnRight
311
+ ? h(LinearGradient, {
312
+ pointerEvents: 'none',
313
+ colors: [withAlpha(tableVeilColor, 0), tableVeilColor],
314
+ start: { x: 0, y: 0.5 },
315
+ end: { x: 1, y: 0.5 },
316
+ style: styles.tableVeil
317
+ })
318
+ : null
319
+ );
320
+ }
321
+
322
+ module.exports = { MarkdownView, MarkdownTable, MARKDOWN_STYLES: DEFAULT_STYLES };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * THE content card: flat, pale, in its own colour — and the tappable card,
3
+ * glass on iOS, pale elsewhere.
4
+ *
5
+ * The colour maths and the reasons (no outline, no shadow, opaque fill) are
6
+ * in logic/paleCard.js. The same rendering on iOS and Android, because there
7
+ * is nothing either platform could composite differently: no shadow (it would
8
+ * make the card "float", and Android draws it as a grey halo), no blur, no
9
+ * glass.
10
+ */
11
+ const { h, RN, LinearGradient, getGlassMode, useScheme } = require('./runtime');
12
+ const { GlassSurface } = require('./GlassSurface');
13
+ const { PALE_CARD_RADIUS, paleCardColors } = require('../logic/paleCard');
14
+ const { parseColor } = require('../logic/color');
15
+
16
+ const DEFAULT_PAGE = { light: '#ffffff', dark: '#000000' };
17
+ const DEFAULT_INK = { light: '#0d1235', dark: '#ffffff' };
18
+
19
+ /**
20
+ * The card's colours for the current scheme — also for a pale background
21
+ * that is not a card (a banner, a chip).
22
+ */
23
+ function usePaleCardColors({ tint, pageBackground, ink, scheme: schemeOverride } = {}) {
24
+ const scheme = useScheme(schemeOverride);
25
+ return paleCardColors({
26
+ scheme,
27
+ pageBackground: pageBackground || DEFAULT_PAGE[scheme],
28
+ ink: ink || DEFAULT_INK[scheme],
29
+ tint
30
+ });
31
+ }
32
+
33
+ function PaleCard({ children, tint, pageBackground, ink, scheme, style, ...rest }) {
34
+ const { fill, top, bottom } = usePaleCardColors({ tint, pageBackground, ink, scheme });
35
+ return h(
36
+ RN.View,
37
+ { ...rest, style: [{ borderRadius: PALE_CARD_RADIUS, overflow: 'hidden', backgroundColor: fill }, style] },
38
+ top !== bottom
39
+ ? h(LinearGradient, {
40
+ pointerEvents: 'none',
41
+ colors: [top, bottom],
42
+ start: { x: 0, y: 0 },
43
+ end: { x: 1, y: 1 },
44
+ style: RN.StyleSheet.absoluteFill
45
+ })
46
+ : null,
47
+ children
48
+ );
49
+ }
50
+
51
+ /**
52
+ * A card you TOUCH: a suggestion, a recipient in a list.
53
+ *
54
+ * Glass left the content of the screens for pale cards — then came back on
55
+ * iOS, but only on what is touched. Never on a search bar, a field, or a card
56
+ * that only shows. On iOS: Apple's glass, interactive (it reacts to the
57
+ * finger), with no white tint added — only a trace of the accent. Elsewhere:
58
+ * the pale card, unchanged; Android has no such material and its imitation
59
+ * came out grey.
60
+ *
61
+ * The card does not handle the press itself: wrap it in (or put inside it)
62
+ * the Pressable that fits the row.
63
+ */
64
+ function TappableCard({ children, tint, pageBackground, ink, scheme, style }) {
65
+ if (getGlassMode() === 'native') {
66
+ const accent = tint && parseColor(tint);
67
+ return h(
68
+ GlassSurface,
69
+ {
70
+ interactive: true,
71
+ glassStyle: 'regular',
72
+ /* 8 % of the accent: a touch of colour in the glass, not a coat. */
73
+ tintColor: accent ? `rgba(${accent.r},${accent.g},${accent.b},0.08)` : undefined,
74
+ scheme,
75
+ style: [{ borderRadius: PALE_CARD_RADIUS, overflow: 'hidden' }, style]
76
+ },
77
+ children
78
+ );
79
+ }
80
+ return h(PaleCard, { tint, pageBackground, ink, scheme, style }, children);
81
+ }
82
+
83
+ module.exports = { PaleCard, TappableCard, usePaleCardColors };