@lotics/ui 1.8.0 → 1.10.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/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
7
7
  "./colors": "./src/colors.ts",
8
+ "./mime": "./src/mime.ts",
9
+ "./download": "./src/download.ts",
10
+ "./file_badge": "./src/file_badge.tsx",
11
+ "./file_thumbnail": "./src/file_thumbnail.tsx",
12
+ "./file_gallery_modal": "./src/file_gallery_modal.tsx",
8
13
  "./pagination": "./src/pagination.tsx",
9
14
  "./bar_chart": "./src/bar_chart.tsx",
10
15
  "./line_chart": "./src/line_chart.tsx",
@@ -14,6 +19,7 @@
14
19
  "./trend_chip": "./src/trend_chip.tsx",
15
20
  "./section_card": "./src/section_card.tsx",
16
21
  "./kpi_card": "./src/kpi_card.tsx",
22
+ "./ring_gauge": "./src/ring_gauge.tsx",
17
23
  "./alert_row": "./src/alert_row.tsx",
18
24
  "./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
19
25
  "./legend_item": "./src/legend_item.tsx",
@@ -0,0 +1,36 @@
1
+ // Pure web file-download primitive. Uses fetch+blob+anchor because
2
+ // `window.open(url, "_blank")` is silently dropped by sandboxed iframes
3
+ // (no `allow-popups`), and direct anchor navigation without the `download`
4
+ // attribute doesn't trigger a save dialog for inline MIME types (HTML,
5
+ // JSON, PDFs, etc.).
6
+ //
7
+ // `cache: "no-store"` avoids a CORS cache collision: if a prior <img>
8
+ // loaded the same URL without an Origin header, the cached response
9
+ // (missing Access-Control-Allow-Origin) gets reused for the fetch and
10
+ // fails. Bypassing the cache forces a fresh CORS-aware request.
11
+ //
12
+ // No React Native / @lotics/shared imports — kept pure so both the host
13
+ // frontend and sandboxed custom-code apps can consume via the per-file
14
+ // export without dragging the wider UI surface.
15
+
16
+ export async function downloadFileFromUrl(url: string, filename: string): Promise<void> {
17
+ if (!url) throw new Error("downloadFileFromUrl: empty url");
18
+
19
+ const response = await fetch(url, { cache: "no-store" });
20
+ if (!response.ok) {
21
+ throw new Error(`File download failed: ${response.status} ${response.statusText}`);
22
+ }
23
+
24
+ const blob = await response.blob();
25
+ const blobUrl = URL.createObjectURL(blob);
26
+ try {
27
+ const link = document.createElement("a");
28
+ link.href = blobUrl;
29
+ link.download = filename;
30
+ document.body.appendChild(link);
31
+ link.click();
32
+ link.remove();
33
+ } finally {
34
+ URL.revokeObjectURL(blobUrl);
35
+ }
36
+ }
@@ -0,0 +1,160 @@
1
+ import { Text, View } from "react-native";
2
+ import { fontFamilySemiBold } from "./text_utils";
3
+ import { Icon, type IconName } from "./icon";
4
+ import { isVideoMimeType, isAudioMimeType } from "./mime";
5
+
6
+ const VIDEO_COLOR = "#ea580c";
7
+ const AUDIO_COLOR = "#db2777";
8
+
9
+ const MIME_MAP: Record<string, { label: string; color: string }> = {
10
+ "application/pdf": { label: "PDF", color: "#ef4444" },
11
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { label: "XLSX", color: "#16a34a" },
12
+ "application/vnd.ms-excel": { label: "XLS", color: "#16a34a" },
13
+ "text/csv": { label: "CSV", color: "#16a34a" },
14
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { label: "DOCX", color: "#2563eb" },
15
+ "application/msword": { label: "DOC", color: "#2563eb" },
16
+ "image/png": { label: "PNG", color: "#7c3aed" },
17
+ "image/jpeg": { label: "JPG", color: "#7c3aed" },
18
+ "video/mp4": { label: "MP4", color: VIDEO_COLOR },
19
+ "video/webm": { label: "WEBM", color: VIDEO_COLOR },
20
+ "video/quicktime": { label: "MOV", color: VIDEO_COLOR },
21
+ "audio/mpeg": { label: "MP3", color: AUDIO_COLOR },
22
+ "audio/wav": { label: "WAV", color: AUDIO_COLOR },
23
+ "audio/x-wav": { label: "WAV", color: AUDIO_COLOR },
24
+ "audio/mp4": { label: "M4A", color: AUDIO_COLOR },
25
+ "audio/x-m4a": { label: "M4A", color: AUDIO_COLOR },
26
+ "audio/ogg": { label: "OGG", color: AUDIO_COLOR },
27
+ };
28
+
29
+ const DEFAULT_BADGE = { label: "FILE", color: "#71717a" };
30
+
31
+ export function resolveMime(mimeType: string): { label: string; color: string } {
32
+ return MIME_MAP[mimeType] ?? DEFAULT_BADGE;
33
+ }
34
+
35
+ const DEFAULT_SIZE = 26;
36
+
37
+ interface FileBadgeProps {
38
+ mimeType: string;
39
+ /** Base width in pixels. Height, radii, font, and padding scale proportionally. Default: 26 */
40
+ size?: number;
41
+ /** Show a "TMPL" overlay to distinguish templates from regular files. */
42
+ isTemplate?: boolean;
43
+ }
44
+
45
+ /**
46
+ * Two-tone file badge: white top, colored bottom with label.
47
+ * Derives label and color from mime type.
48
+ */
49
+ function getMediaIcon(mimeType: string): IconName | undefined {
50
+ if (isVideoMimeType(mimeType)) return "play";
51
+ if (isAudioMimeType(mimeType)) return "music";
52
+ return undefined;
53
+ }
54
+
55
+ export function FileBadge({ mimeType, size = DEFAULT_SIZE, isTemplate }: FileBadgeProps) {
56
+ const { label, color } = resolveMime(mimeType);
57
+ const scale = size / DEFAULT_SIZE;
58
+ const height = Math.round(32 * scale);
59
+ const radius = Math.round(4 * scale);
60
+ const bottomHeight = Math.round(14 * scale);
61
+ const fontSize = Math.round(7 * scale);
62
+ const lineHeight = Math.round(10 * scale);
63
+ const paddingTop = Math.round(4 * scale);
64
+ const paddingH = Math.round(3 * scale);
65
+ const lineGap = Math.round(3 * scale);
66
+ const lineThickness = Math.max(1, Math.round(1.5 * scale));
67
+ const tmplFontSize = Math.max(5, Math.round(5.5 * scale));
68
+ const tmplPadH = Math.round(2 * scale);
69
+ const tmplPadV = Math.max(1, Math.round(1 * scale));
70
+ const tmplRadius = Math.round(2 * scale);
71
+ const mediaIcon = getMediaIcon(mimeType);
72
+ const mediaIconSize = Math.max(10, Math.round(14 * scale));
73
+
74
+ return (
75
+ <View style={{
76
+ width: size,
77
+ height,
78
+ borderRadius: radius,
79
+ overflow: "hidden",
80
+ shadowColor: "#000",
81
+ shadowOffset: { width: 0, height: 1 },
82
+ shadowOpacity: 0.08,
83
+ shadowRadius: 3,
84
+ elevation: 2,
85
+ }}>
86
+ <View style={{
87
+ flex: 1,
88
+ backgroundColor: mediaIcon !== undefined ? color : "white",
89
+ borderWidth: 1,
90
+ borderBottomWidth: 0,
91
+ borderColor: "rgba(0,0,0,0.08)",
92
+ borderTopLeftRadius: radius,
93
+ borderTopRightRadius: radius,
94
+ paddingTop: mediaIcon !== undefined ? 0 : paddingTop,
95
+ paddingHorizontal: mediaIcon !== undefined ? 0 : paddingH,
96
+ gap: mediaIcon !== undefined ? 0 : lineGap,
97
+ alignItems: mediaIcon !== undefined ? "center" : undefined,
98
+ justifyContent: mediaIcon !== undefined ? "center" : undefined,
99
+ }}>
100
+ {mediaIcon !== undefined ? (
101
+ <Icon name={mediaIcon} size={mediaIconSize} color="white" />
102
+ ) : (
103
+ <>
104
+ <View style={{ height: lineThickness, borderRadius: 1, backgroundColor: "rgba(0,0,0,0.08)" }} />
105
+ <View style={{ height: lineThickness, borderRadius: 1, backgroundColor: "rgba(0,0,0,0.08)" }} />
106
+ <View style={{ height: lineThickness, borderRadius: 1, backgroundColor: "rgba(0,0,0,0.08)", width: "60%" }} />
107
+ </>
108
+ )}
109
+ {isTemplate && (
110
+ <View style={{
111
+ position: "absolute",
112
+ top: 0,
113
+ left: 0,
114
+ right: 0,
115
+ bottom: 0,
116
+ alignItems: "center",
117
+ justifyContent: "center",
118
+ }}>
119
+ <View style={{
120
+ backgroundColor: "white",
121
+ borderRadius: tmplRadius,
122
+ paddingHorizontal: tmplPadH,
123
+ paddingVertical: tmplPadV,
124
+ shadowColor: "#000",
125
+ shadowOffset: { width: 0, height: 0.5 },
126
+ shadowOpacity: 0.1,
127
+ shadowRadius: 1,
128
+ elevation: 1,
129
+ }}>
130
+ <Text style={{
131
+ fontFamily: fontFamilySemiBold,
132
+ fontSize: tmplFontSize,
133
+ fontWeight: "700",
134
+ color: "#374151",
135
+ letterSpacing: 0.2,
136
+ }}>TMPL</Text>
137
+ </View>
138
+ </View>
139
+ )}
140
+ </View>
141
+ <View style={{
142
+ height: bottomHeight,
143
+ alignItems: "center",
144
+ justifyContent: "center",
145
+ backgroundColor: color,
146
+ borderBottomLeftRadius: radius,
147
+ borderBottomRightRadius: radius,
148
+ }}>
149
+ <Text style={{
150
+ fontFamily: fontFamilySemiBold,
151
+ fontSize,
152
+ fontWeight: "700",
153
+ color: "white",
154
+ letterSpacing: 0.3,
155
+ lineHeight,
156
+ }}>{label}</Text>
157
+ </View>
158
+ </View>
159
+ );
160
+ }
@@ -0,0 +1,188 @@
1
+ // FileGalleryModal — pure fullscreen image preview with prev/next nav + ESC close.
2
+ // Pure primitive: takes DisplayFile[] only, no Lotics domain coupling. Frontend
3
+ // has a richer modal in `frontend/components/file_gallery.tsx` that handles
4
+ // xlsx/docx/pdf inline rendering — leave that one for Lotics-specific previews
5
+ // and use this primitive when only images need previewing.
6
+
7
+ import { useCallback, useEffect, useRef } from "react";
8
+ import {
9
+ Image,
10
+ Modal,
11
+ Pressable,
12
+ StyleSheet,
13
+ View,
14
+ } from "react-native";
15
+ import { Text } from "./text";
16
+ import { Icon } from "./icon";
17
+ import { colors } from "./colors";
18
+ import { isImageMimeType } from "./mime";
19
+ import type { DisplayFile } from "./file_thumbnail";
20
+
21
+ export interface FileGalleryModalProps {
22
+ files: DisplayFile[];
23
+ /** Index of the file currently shown. When null, the modal is closed. */
24
+ activeIndex: number | null;
25
+ onIndexChange: (next: number | null) => void;
26
+ /**
27
+ * Optional caption suffix shown under the image. Useful for hint text like
28
+ * "ESC để đóng · ←/→ để chuyển". When omitted, just shows filename + count.
29
+ */
30
+ captionHint?: string;
31
+ }
32
+
33
+ /**
34
+ * Image-focused fullscreen preview. Non-image MIME types render as a simple
35
+ * "{filename} · cannot preview" card — callers needing rich preview should use
36
+ * a Lotics-specific gallery instead.
37
+ */
38
+ export function FileGalleryModal(props: FileGalleryModalProps) {
39
+ const { files, activeIndex, onIndexChange, captionHint } = props;
40
+ const visible = activeIndex !== null;
41
+ const overlayRef = useRef<View | null>(null);
42
+
43
+ const close = useCallback(() => onIndexChange(null), [onIndexChange]);
44
+ const next = useCallback(() => {
45
+ if (activeIndex === null) return;
46
+ onIndexChange(Math.min(activeIndex + 1, files.length - 1));
47
+ }, [activeIndex, files.length, onIndexChange]);
48
+ const prev = useCallback(() => {
49
+ if (activeIndex === null) return;
50
+ onIndexChange(Math.max(activeIndex - 1, 0));
51
+ }, [activeIndex, onIndexChange]);
52
+
53
+ // Keyboard navigation (web only — RN native handles via Modal accessibility).
54
+ useEffect(() => {
55
+ if (!visible || typeof window === "undefined") return;
56
+ function onKey(e: KeyboardEvent) {
57
+ if (e.key === "Escape") close();
58
+ else if (e.key === "ArrowRight") next();
59
+ else if (e.key === "ArrowLeft") prev();
60
+ }
61
+ window.addEventListener("keydown", onKey);
62
+ return () => window.removeEventListener("keydown", onKey);
63
+ }, [visible, close, next, prev]);
64
+
65
+ if (!visible || activeIndex === null) return null;
66
+ const file = files[activeIndex];
67
+ if (!file) return null;
68
+
69
+ const total = files.length;
70
+ const caption = captionHint
71
+ ? `${file.filename} · ${activeIndex + 1} / ${total} · ${captionHint}`
72
+ : `${file.filename} · ${activeIndex + 1} / ${total}`;
73
+
74
+ return (
75
+ <Modal visible transparent onRequestClose={close} animationType="fade">
76
+ <View style={styles.overlay} ref={overlayRef}>
77
+ <Pressable
78
+ accessibilityRole="button"
79
+ accessibilityLabel="Close"
80
+ onPress={close}
81
+ style={StyleSheet.absoluteFill}
82
+ />
83
+
84
+ {isImageMimeType(file.mimeType) ? (
85
+ <Image
86
+ source={{ uri: file.url }}
87
+ style={styles.image}
88
+ resizeMode="contain"
89
+ />
90
+ ) : (
91
+ <View style={styles.unsupportedCard}>
92
+ <Text size="md" style={styles.unsupportedText}>
93
+ {file.filename}
94
+ </Text>
95
+ <Text size="sm" color="muted">
96
+ Cannot preview this file type in the gallery.
97
+ </Text>
98
+ </View>
99
+ )}
100
+
101
+ {activeIndex > 0 ? (
102
+ <Pressable
103
+ onPress={prev}
104
+ accessibilityRole="button"
105
+ accessibilityLabel="Previous"
106
+ style={[styles.navButton, styles.navLeft]}
107
+ >
108
+ <Icon name="chevron-left" size={28} color={colors.white} />
109
+ </Pressable>
110
+ ) : null}
111
+
112
+ {activeIndex < total - 1 ? (
113
+ <Pressable
114
+ onPress={next}
115
+ accessibilityRole="button"
116
+ accessibilityLabel="Next"
117
+ style={[styles.navButton, styles.navRight]}
118
+ >
119
+ <Icon name="chevron-right" size={28} color={colors.white} />
120
+ </Pressable>
121
+ ) : null}
122
+
123
+ <View style={styles.captionWrap} pointerEvents="none">
124
+ <View style={styles.captionPill}>
125
+ <Text size="sm" style={styles.captionText}>{caption}</Text>
126
+ </View>
127
+ </View>
128
+ </View>
129
+ </Modal>
130
+ );
131
+ }
132
+
133
+ const styles = StyleSheet.create({
134
+ overlay: {
135
+ flex: 1,
136
+ backgroundColor: "rgba(0, 0, 0, 0.88)",
137
+ justifyContent: "center",
138
+ alignItems: "center",
139
+ },
140
+ image: {
141
+ width: "92%",
142
+ height: "85%",
143
+ },
144
+ unsupportedCard: {
145
+ backgroundColor: colors.white,
146
+ borderRadius: 8,
147
+ padding: 24,
148
+ maxWidth: 480,
149
+ gap: 8,
150
+ alignItems: "center",
151
+ },
152
+ unsupportedText: {
153
+ fontWeight: "600",
154
+ },
155
+ navButton: {
156
+ position: "absolute",
157
+ top: "50%",
158
+ transform: [{ translateY: -28 }],
159
+ width: 56,
160
+ height: 56,
161
+ borderRadius: 28,
162
+ backgroundColor: "rgba(0, 0, 0, 0.4)",
163
+ justifyContent: "center",
164
+ alignItems: "center",
165
+ },
166
+ navLeft: {
167
+ left: 16,
168
+ },
169
+ navRight: {
170
+ right: 16,
171
+ },
172
+ captionWrap: {
173
+ position: "absolute",
174
+ bottom: 24,
175
+ left: 0,
176
+ right: 0,
177
+ alignItems: "center",
178
+ },
179
+ captionPill: {
180
+ backgroundColor: "rgba(0, 0, 0, 0.7)",
181
+ paddingHorizontal: 12,
182
+ paddingVertical: 6,
183
+ borderRadius: 6,
184
+ },
185
+ captionText: {
186
+ color: colors.white,
187
+ },
188
+ });
@@ -0,0 +1,437 @@
1
+ import { FileBadge, resolveMime } from "./file_badge";
2
+ import { fontFamilySemiBold } from "./text_utils";
3
+ import { Text } from "./text";
4
+ import { Icon, type IconName } from "./icon";
5
+ import { Checkbox } from "./checkbox";
6
+ import { colors } from "./colors";
7
+ import { useCallback, useState } from "react";
8
+ import {
9
+ GestureResponderEvent,
10
+ Image,
11
+ Text as RNText,
12
+ View,
13
+ Pressable,
14
+ StyleSheet,
15
+ Linking,
16
+ } from "react-native";
17
+ import { isImageMimeType, isVideoMimeType, isAudioMimeType } from "./mime";
18
+
19
+ export const THUMBNAIL_SIZE = 96;
20
+ export const COMPACT_THUMBNAIL_SIZE = 32;
21
+
22
+ /**
23
+ * Returns the icon name for playable media (video/audio) so a MediaCard can
24
+ * render a play/music glyph instead of document placeholder lines. Returns
25
+ * undefined for non-media files, which should fall back to DocumentCard.
26
+ */
27
+ export function getMediaIcon(mimeType: string): IconName | undefined {
28
+ if (isVideoMimeType(mimeType)) return "play";
29
+ if (isAudioMimeType(mimeType)) return "music";
30
+ return undefined;
31
+ }
32
+
33
+ // =============================================================================
34
+ // Core Display Type
35
+ // =============================================================================
36
+
37
+ /**
38
+ * Normalized file format for display components.
39
+ * All file sources (database, chat messages, uploads) convert to this.
40
+ */
41
+ export interface DisplayFile {
42
+ id: string;
43
+ filename: string;
44
+ mimeType: string;
45
+ /** Direct URL to the file */
46
+ url: string;
47
+ /** Optional thumbnail URL for images */
48
+ thumbnailUrl?: string;
49
+ /** Preview URL for legacy Excel files (.xls converted to .xlsx server-side) */
50
+ previewUrl?: string;
51
+ }
52
+
53
+ // =============================================================================
54
+ // FileThumbnail - Main display component
55
+ // =============================================================================
56
+
57
+ interface FileThumbnailProps {
58
+ file: DisplayFile;
59
+ /** Explicit pixel size. When omitted, fills container (width: "100%", aspectRatio: 1). */
60
+ size?: number;
61
+ /** Called when thumbnail is pressed */
62
+ onPress?: () => void;
63
+ /** Called when thumbnail is long-pressed */
64
+ onLongPress?: () => void;
65
+ /** Called when the completed file is removed */
66
+ onRemove?: () => void;
67
+ /** When defined, renders a selection overlay with a checkbox. */
68
+ selected?: boolean;
69
+ }
70
+
71
+ /**
72
+ * Displays a file as a square thumbnail.
73
+ * Works with any file source via DisplayFile interface.
74
+ */
75
+ export function FileThumbnail(props: FileThumbnailProps) {
76
+ const { file, size, onPress, onLongPress, onRemove, selected } = props;
77
+
78
+ const rootStyle =
79
+ size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
80
+
81
+ if (isImageMimeType(file.mimeType)) {
82
+ return (
83
+ <View style={rootStyle}>
84
+ <ImageThumbnail file={file} size={size} onPress={onPress} onLongPress={onLongPress} />
85
+ {onRemove && <RemoveButton onPress={onRemove} />}
86
+ {selected !== undefined && <SelectionOverlay selected={selected} />}
87
+ </View>
88
+ );
89
+ }
90
+
91
+ const mediaIcon = getMediaIcon(file.mimeType);
92
+
93
+ if (size !== undefined && size <= COMPACT_THUMBNAIL_SIZE && mediaIcon === undefined) {
94
+ return (
95
+ <View style={rootStyle}>
96
+ <DocumentBadge
97
+ mimeType={file.mimeType}
98
+ size={size}
99
+ onPress={onPress ?? (() => Linking.openURL(file.url))}
100
+ onLongPress={onLongPress}
101
+ />
102
+ {onRemove && <RemoveButton onPress={onRemove} />}
103
+ </View>
104
+ );
105
+ }
106
+
107
+ return (
108
+ <View style={rootStyle}>
109
+ {mediaIcon !== undefined ? (
110
+ <MediaCard
111
+ mimeType={file.mimeType}
112
+ filename={file.filename}
113
+ icon={mediaIcon}
114
+ size={size}
115
+ onPress={onPress ?? (() => Linking.openURL(file.url))}
116
+ onLongPress={onLongPress}
117
+ />
118
+ ) : (
119
+ <DocumentCard
120
+ mimeType={file.mimeType}
121
+ filename={file.filename}
122
+ size={size}
123
+ onPress={onPress ?? (() => Linking.openURL(file.url))}
124
+ onLongPress={onLongPress}
125
+ />
126
+ )}
127
+ {onRemove && <RemoveButton onPress={onRemove} />}
128
+ {selected !== undefined && <SelectionOverlay selected={selected} />}
129
+ </View>
130
+ );
131
+ }
132
+
133
+ // =============================================================================
134
+ // Base Components (exported for direct use when needed)
135
+ // =============================================================================
136
+
137
+ interface DocumentBadgeProps {
138
+ mimeType: string;
139
+ size?: number;
140
+ isTemplate?: boolean;
141
+ onPress?: () => void;
142
+ onLongPress?: () => void;
143
+ }
144
+
145
+ /**
146
+ * Compact two-tone file badge with document lines and colored label.
147
+ */
148
+ export function DocumentBadge(props: DocumentBadgeProps) {
149
+ const { mimeType, isTemplate, onPress, onLongPress } = props;
150
+
151
+ return (
152
+ <Pressable onPress={onPress} onLongPress={onLongPress}>
153
+ <FileBadge mimeType={mimeType} isTemplate={isTemplate} />
154
+ </Pressable>
155
+ );
156
+ }
157
+
158
+ interface DocumentCardProps {
159
+ mimeType: string;
160
+ filename: string;
161
+ size?: number;
162
+ onPress?: () => void;
163
+ onLongPress?: () => void;
164
+ overlay?: React.ReactNode;
165
+ }
166
+
167
+ /**
168
+ * Document preview card: placeholder lines (future: file preview) + footer with filename and type label.
169
+ */
170
+ export function DocumentCard(props: DocumentCardProps) {
171
+ const { mimeType, filename, size, onPress, onLongPress, overlay } = props;
172
+ const { label, color } = resolveMime(mimeType);
173
+
174
+ const sizeStyle =
175
+ size !== undefined
176
+ ? { width: size, height: size }
177
+ : { width: "100%" as const, height: "100%" as const };
178
+
179
+ return (
180
+ <Pressable style={[styles.documentCard, sizeStyle]} onPress={onPress} onLongPress={onLongPress}>
181
+ <View style={styles.documentCardBody}>
182
+ <View style={styles.placeholderLines}>
183
+ <View style={styles.placeholderLine} />
184
+ <View style={styles.placeholderLine} />
185
+ <View style={[styles.placeholderLine, { width: "60%" }]} />
186
+ <View style={styles.placeholderLine} />
187
+ <View style={[styles.placeholderLine, { width: "40%" }]} />
188
+ </View>
189
+ </View>
190
+ <View style={styles.documentCardFooter}>
191
+ <Text size="xs" numberOfLines={1} color="zinc-500" userSelect="none">
192
+ {filename}
193
+ </Text>
194
+ <View style={[styles.typeLabel, { backgroundColor: color }]}>
195
+ <RNText style={styles.typeLabelText}>{label}</RNText>
196
+ </View>
197
+ </View>
198
+ {overlay}
199
+ </Pressable>
200
+ );
201
+ }
202
+
203
+ interface MediaCardProps {
204
+ mimeType: string;
205
+ filename: string;
206
+ /** Icon rendered in the body — typically "play" for video or "music" for audio. */
207
+ icon: IconName;
208
+ size?: number;
209
+ onPress?: () => void;
210
+ onLongPress?: () => void;
211
+ overlay?: React.ReactNode;
212
+ }
213
+
214
+ /**
215
+ * Playable-media preview card: colored body with a large icon (play/music) +
216
+ * footer with filename and type label. Visual counterpart to DocumentCard
217
+ * for videos and audio files.
218
+ */
219
+ export function MediaCard(props: MediaCardProps) {
220
+ const { mimeType, filename, size, onPress, onLongPress, overlay, icon } = props;
221
+ const { label, color } = resolveMime(mimeType);
222
+
223
+ const sizeStyle =
224
+ size !== undefined
225
+ ? { width: size, height: size }
226
+ : { width: "100%" as const, height: "100%" as const };
227
+
228
+ const isCompact = size !== undefined && size <= COMPACT_THUMBNAIL_SIZE;
229
+
230
+ if (isCompact) {
231
+ const iconSize = Math.max(12, Math.round(size * 0.55));
232
+ return (
233
+ <Pressable
234
+ style={[styles.mediaCardCompact, sizeStyle, { backgroundColor: color }]}
235
+ onPress={onPress}
236
+ onLongPress={onLongPress}
237
+ >
238
+ <Icon name={icon} size={iconSize} color={colors.white} />
239
+ {overlay}
240
+ </Pressable>
241
+ );
242
+ }
243
+
244
+ const iconSize = size !== undefined ? Math.max(20, Math.min(Math.round(size * 0.4), 56)) : 44;
245
+ const backdrop = iconSize + 20;
246
+
247
+ return (
248
+ <Pressable style={[styles.documentCard, sizeStyle]} onPress={onPress} onLongPress={onLongPress}>
249
+ <View style={[styles.mediaCardBody, { backgroundColor: color }]}>
250
+ <View style={[styles.mediaIconBackdrop, { width: backdrop, height: backdrop, borderRadius: backdrop / 2 }]}>
251
+ <Icon name={icon} size={iconSize} color={colors.white} />
252
+ </View>
253
+ </View>
254
+ <View style={styles.documentCardFooter}>
255
+ <Text size="xs" numberOfLines={1} color="zinc-500" userSelect="none">
256
+ {filename}
257
+ </Text>
258
+ <View style={[styles.typeLabel, { backgroundColor: color }]}>
259
+ <RNText style={styles.typeLabelText}>{label}</RNText>
260
+ </View>
261
+ </View>
262
+ {overlay}
263
+ </Pressable>
264
+ );
265
+ }
266
+
267
+ // =============================================================================
268
+ // Internal Components (exported so upload-aware wrappers in @lotics/ui-internal
269
+ // can reuse them without duplication)
270
+ // =============================================================================
271
+
272
+ interface ImageThumbnailProps {
273
+ file: DisplayFile;
274
+ /** Explicit pixel size. When omitted, fills container. */
275
+ size?: number;
276
+ onPress?: () => void;
277
+ onLongPress?: () => void;
278
+ }
279
+
280
+ function ImageThumbnail(props: ImageThumbnailProps) {
281
+ const { file, size, onPress, onLongPress } = props;
282
+ const [useFallback, setUseFallback] = useState(false);
283
+
284
+ // Use thumbnail if available, fallback to full URL
285
+ const url = !useFallback && file.thumbnailUrl ? file.thumbnailUrl : file.url;
286
+
287
+ const sizeStyle =
288
+ size !== undefined
289
+ ? { width: size, height: size }
290
+ : { width: "100%" as const, height: "100%" as const };
291
+
292
+ return (
293
+ <Pressable
294
+ onPress={onPress}
295
+ onLongPress={onLongPress}
296
+ style={[styles.imageThumbnail, sizeStyle]}
297
+ >
298
+ <Image
299
+ source={{ uri: url }}
300
+ style={styles.image}
301
+ resizeMode="cover"
302
+ onError={() => setUseFallback(true)}
303
+ />
304
+ </Pressable>
305
+ );
306
+ }
307
+
308
+ export function RemoveButton({ onPress }: { onPress: () => void }) {
309
+ const handlePress = useCallback(
310
+ (e: GestureResponderEvent) => {
311
+ e.stopPropagation();
312
+ onPress();
313
+ },
314
+ [onPress],
315
+ );
316
+ return (
317
+ <Pressable onPress={handlePress} style={styles.removeButton}>
318
+ <Icon name="x" size={14} color={colors.zinc[700]} />
319
+ </Pressable>
320
+ );
321
+ }
322
+
323
+ function SelectionOverlay({ selected }: { selected: boolean }) {
324
+ return (
325
+ <View
326
+ style={[styles.selectionOverlay, selected && styles.selectionOverlaySelected]}
327
+ pointerEvents="none"
328
+ >
329
+ <View style={styles.selectionCheckbox}>
330
+ <Checkbox checked={selected} />
331
+ </View>
332
+ </View>
333
+ );
334
+ }
335
+
336
+ // =============================================================================
337
+ // Styles
338
+ // =============================================================================
339
+
340
+ const styles = StyleSheet.create({
341
+ documentCard: {
342
+ borderRadius: 8,
343
+ backgroundColor: colors.white,
344
+ borderWidth: 1,
345
+ borderColor: colors.zinc["200"],
346
+ overflow: "hidden",
347
+ },
348
+ documentCardBody: {
349
+ flex: 1,
350
+ justifyContent: "center",
351
+ padding: 10,
352
+ },
353
+ mediaCardBody: {
354
+ flex: 1,
355
+ justifyContent: "center",
356
+ alignItems: "center",
357
+ },
358
+ mediaCardCompact: {
359
+ borderRadius: 6,
360
+ alignItems: "center",
361
+ justifyContent: "center",
362
+ overflow: "hidden",
363
+ },
364
+ mediaIconBackdrop: {
365
+ alignItems: "center",
366
+ justifyContent: "center",
367
+ backgroundColor: "rgba(255, 255, 255, 0.22)",
368
+ },
369
+ placeholderLines: {
370
+ gap: 4,
371
+ },
372
+ placeholderLine: {
373
+ height: 2,
374
+ borderRadius: 1,
375
+ backgroundColor: colors.zinc["200"],
376
+ },
377
+ documentCardFooter: {
378
+ gap: 2,
379
+ paddingHorizontal: 8,
380
+ paddingVertical: 6,
381
+ borderTopWidth: 1,
382
+ backgroundColor: colors.zinc['50'],
383
+ borderTopColor: colors.zinc["200"],
384
+ alignItems: "flex-start",
385
+ },
386
+ typeLabel: {
387
+ borderRadius: 3,
388
+ paddingHorizontal: 4,
389
+ paddingVertical: 1,
390
+ },
391
+ typeLabelText: {
392
+ fontFamily: fontFamilySemiBold,
393
+ fontSize: 8,
394
+ fontWeight: "700",
395
+ color: "white",
396
+ letterSpacing: 0.3,
397
+ },
398
+ imageThumbnail: {
399
+ borderRadius: 8,
400
+ overflow: "hidden",
401
+ },
402
+ image: {
403
+ width: "100%",
404
+ height: "100%",
405
+ },
406
+ removeButton: {
407
+ position: "absolute",
408
+ top: -6,
409
+ right: -6,
410
+ width: 22,
411
+ height: 22,
412
+ alignItems: "center",
413
+ justifyContent: "center",
414
+ borderRadius: 999,
415
+ borderWidth: 1,
416
+ borderColor: colors.border,
417
+ backgroundColor: colors.zinc["50"],
418
+ zIndex: 1,
419
+ },
420
+ selectionOverlay: {
421
+ position: "absolute",
422
+ top: 0,
423
+ left: 0,
424
+ right: 0,
425
+ bottom: 0,
426
+ borderRadius: 8,
427
+ zIndex: 1,
428
+ },
429
+ selectionOverlaySelected: {
430
+ backgroundColor: "rgba(0, 0, 0, 0.15)",
431
+ },
432
+ selectionCheckbox: {
433
+ position: "absolute",
434
+ bottom: 4,
435
+ right: 4,
436
+ },
437
+ });
package/src/mime.ts ADDED
@@ -0,0 +1,15 @@
1
+ // Tiny MIME helpers inlined so `@lotics/ui` stays free of @lotics/shared
2
+ // (enforced by primitives_purity.test.ts). The richer MIME taxonomy lives
3
+ // in `@lotics/shared/file_type` for Lotics-coupled code.
4
+
5
+ export function isImageMimeType(mimeType: string): boolean {
6
+ return mimeType.toLowerCase().startsWith("image/");
7
+ }
8
+
9
+ export function isVideoMimeType(mimeType: string): boolean {
10
+ return mimeType.toLowerCase().startsWith("video/");
11
+ }
12
+
13
+ export function isAudioMimeType(mimeType: string): boolean {
14
+ return mimeType.toLowerCase().startsWith("audio/");
15
+ }
@@ -0,0 +1,72 @@
1
+ import { View } from "react-native";
2
+ import { colors } from "./colors";
3
+ import { Text } from "./text";
4
+
5
+ export interface RingGaugeProps {
6
+ /** Progress value, 0–100. Clamped to that range. */
7
+ value: number;
8
+ /** Short label under the ring. */
9
+ label: string;
10
+ /** Optional one-line context under the label. */
11
+ caption?: string;
12
+ /** Diameter in px. Default 128. */
13
+ size?: number;
14
+ /** Ring stroke width in px. Default 12. */
15
+ thickness?: number;
16
+ /** Arc color. Default teal accent. */
17
+ color?: string;
18
+ }
19
+
20
+ /**
21
+ * Circular progress gauge — an at-a-glance ring for a 0–100% metric
22
+ * (on-time rate, SLA compliance, win rate). The percentage leads in the
23
+ * center; the track behind it shows the remaining-to-100 context. Pairs
24
+ * with `KPICard` (figures) — use a ring when the number IS a ratio to 100.
25
+ *
26
+ * Implementation: native HTML `<svg>` (like `Sparkline`) — Vite can't
27
+ * resolve react-native-svg's native paths, and the `<View>` wrapper
28
+ * preserves the RN layout surface. The arc starts at 12 o'clock
29
+ * (`rotate(-90)`) and grows clockwise via `strokeDasharray`.
30
+ */
31
+ export function RingGauge(props: RingGaugeProps) {
32
+ const { value, label, caption, size = 140, thickness = 10, color = colors.teal[600] } = props;
33
+ const clamped = Math.max(0, Math.min(100, value));
34
+ const center = size / 2;
35
+ const radius = (size - thickness) / 2;
36
+ const circumference = 2 * Math.PI * radius;
37
+ const dash = (clamped / 100) * circumference;
38
+
39
+ return (
40
+ <View style={{ alignItems: "center", gap: 12 }}>
41
+ <View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
42
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ position: "absolute" }}>
43
+ <circle cx={center} cy={center} r={radius} fill="none" stroke={colors.zinc[200]} strokeWidth={thickness} />
44
+ <circle
45
+ cx={center}
46
+ cy={center}
47
+ r={radius}
48
+ fill="none"
49
+ stroke={color}
50
+ strokeWidth={thickness}
51
+ strokeLinecap="round"
52
+ strokeDasharray={`${dash} ${circumference}`}
53
+ transform={`rotate(-90 ${center} ${center})`}
54
+ />
55
+ </svg>
56
+ <Text size="xxl" weight="semibold">
57
+ {Math.round(clamped)}%
58
+ </Text>
59
+ </View>
60
+ <View style={{ alignItems: "center", gap: 2 }}>
61
+ <Text size="sm" weight="medium">
62
+ {label}
63
+ </Text>
64
+ {caption ? (
65
+ <Text size="xs" color="muted">
66
+ {caption}
67
+ </Text>
68
+ ) : null}
69
+ </View>
70
+ </View>
71
+ );
72
+ }
package/src/tokens.ts CHANGED
@@ -3,9 +3,16 @@
3
3
  * Lotics surfaces (parent app, custom_code iframe apps, browser extension).
4
4
  *
5
5
  * The colors come from `colors.ts` (re-exported below) — same module
6
- * the parent app's React Native components consume via
7
- * `colors.zinc[900]` / `colors.background` / etc. The iframe runtime
8
- * emits these same values as CSS variables via {@link getCssVariables}.
6
+ * BOTH the parent app's and custom-code apps' components consume via
7
+ * `colors.zinc[900]` / `colors.background` / etc. Custom-code apps use
8
+ * `@lotics/ui` components directly (rendered through react-native-web; the
9
+ * starter scaffold wires the alias — see `docs/apps.md`), so they self-theme
10
+ * via the same `colors` module with no CSS variables involved.
11
+ *
12
+ * {@link getCssVariables} is an OPT-IN helper that serializes these tokens to
13
+ * `--lotics-*` CSS variables for apps that hand-roll plain DOM/CSS instead of
14
+ * using `@lotics/ui` components. Nothing injects them automatically — the
15
+ * runtime does NOT emit them, and the component path does not need them.
9
16
  *
10
17
  * **CSS variable naming mirrors the parent's TS references 1:1**:
11
18
  * colors.zinc[900] → var(--lotics-zinc-900)
@@ -86,8 +93,11 @@ export const radius = {
86
93
 
87
94
  /**
88
95
  * Emit a CSS string defining all design tokens as `:root` custom
89
- * properties. Used by the iframe runtime so plain-DOM components can
90
- * consume the same palette as the parent app's React Native components.
96
+ * properties. OPT-IN: an app that hand-rolls plain DOM/CSS (instead of
97
+ * using `@lotics/ui` components, which self-theme via the `colors` module)
98
+ * can inject this to consume the same palette. Nothing calls it
99
+ * automatically — there is no runtime that emits it; the component path
100
+ * doesn't need it.
91
101
  *
92
102
  * Variable names mirror `colors` 1:1 — palette entries (zinc, red,
93
103
  * etc.) emit as `--lotics-<name>-<shade>`, top-level entries (black,