@lotics/ui 5.8.0 → 5.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.
Files changed (104) hide show
  1. package/AGENTS.md +184 -56
  2. package/examples/tpl_approvals.tsx +3 -3
  3. package/examples/tpl_convert.tsx +11 -23
  4. package/examples/tpl_dossier.tsx +5 -4
  5. package/examples/tpl_order.tsx +1 -1
  6. package/examples/tpl_pipeline.tsx +400 -0
  7. package/examples/tpl_ratedesk.tsx +1 -1
  8. package/examples/tpl_record.tsx +6 -5
  9. package/examples/tpl_shifts.tsx +7 -5
  10. package/examples/tpl_task_board.tsx +473 -0
  11. package/examples/tpl_tasks.tsx +440 -0
  12. package/package.json +19 -6
  13. package/src/accordion.tsx +1 -1
  14. package/src/agent_progress.tsx +1 -1
  15. package/src/agent_run.tsx +1 -1
  16. package/src/breakdown.tsx +2 -2
  17. package/src/button.tsx +12 -3
  18. package/src/calendar/calendar_view.tsx +10 -10
  19. package/src/calendar/month_view.tsx +30 -11
  20. package/src/calendar/time_grid_view.tsx +25 -7
  21. package/src/card_select_item.tsx +11 -13
  22. package/src/check_circle.tsx +88 -0
  23. package/src/checkbox_input.tsx +5 -1
  24. package/src/chip.tsx +1 -1
  25. package/src/chip_group.tsx +1 -1
  26. package/src/column_filter.tsx +3 -2
  27. package/src/combobox.tsx +84 -219
  28. package/src/confidence.tsx +14 -3
  29. package/src/control_surface.ts +22 -7
  30. package/src/data_grid.tsx +159 -0
  31. package/src/date_calendar.tsx +46 -23
  32. package/src/date_field.tsx +10 -2
  33. package/src/date_filter.tsx +5 -9
  34. package/src/date_picker.tsx +78 -26
  35. package/src/date_picker_value.ts +11 -0
  36. package/src/date_range_filter_field.tsx +6 -4
  37. package/src/drawer.tsx +1 -1
  38. package/src/file_dropzone.tsx +5 -0
  39. package/src/file_gallery_modal.tsx +227 -139
  40. package/src/file_grid.tsx +184 -0
  41. package/src/file_preview.web.tsx +349 -12
  42. package/src/file_preview_types.ts +49 -0
  43. package/src/file_row.tsx +118 -0
  44. package/src/file_rows.tsx +109 -0
  45. package/src/file_thumbnail.tsx +132 -81
  46. package/src/file_thumbnail_grid.tsx +135 -55
  47. package/src/files_editor.tsx +232 -0
  48. package/src/filter_chip.tsx +25 -12
  49. package/src/focus_ring_pressable.tsx +34 -0
  50. package/src/form_picker.tsx +3 -3
  51. package/src/gantt/gantt_view.tsx +25 -7
  52. package/src/heatmap.tsx +5 -1
  53. package/src/icon_button.tsx +14 -3
  54. package/src/image_gallery.tsx +8 -14
  55. package/src/index.css +8 -12
  56. package/src/inline_date_picker.tsx +11 -11
  57. package/src/inline_edit.tsx +22 -6
  58. package/src/inline_member_select.tsx +1 -1
  59. package/src/inline_select.tsx +9 -8
  60. package/src/inline_text_input.tsx +4 -1
  61. package/src/link.tsx +11 -1
  62. package/src/link_button.tsx +1 -1
  63. package/src/list_item.tsx +2 -2
  64. package/src/member_select.tsx +9 -4
  65. package/src/menu_button.tsx +2 -2
  66. package/src/menu_list_item.tsx +2 -2
  67. package/src/number_input.tsx +11 -4
  68. package/src/option_list.tsx +211 -0
  69. package/src/pdfjs_worker.d.ts +5 -0
  70. package/src/peek.tsx +1 -1
  71. package/src/picker.tsx +24 -213
  72. package/src/pressable_highlight.tsx +42 -23
  73. package/src/radio_picker.tsx +2 -2
  74. package/src/range_slider.tsx +35 -7
  75. package/src/react_native.d.ts +3 -0
  76. package/src/scroll_to_bottom.tsx +1 -1
  77. package/src/section.tsx +7 -2
  78. package/src/segmented_control.tsx +2 -2
  79. package/src/select.tsx +299 -0
  80. package/src/sort_header.tsx +1 -1
  81. package/src/sources.tsx +1 -1
  82. package/src/spreadsheet_view.tsx +43 -17
  83. package/src/status_grid.tsx +4 -3
  84. package/src/stepper.tsx +1 -1
  85. package/src/switch.tsx +5 -1
  86. package/src/switch_button.tsx +1 -1
  87. package/src/switcher.tsx +1 -1
  88. package/src/table.tsx +5 -1
  89. package/src/tabs.tsx +2 -2
  90. package/src/text_input_field.tsx +15 -4
  91. package/src/time_picker.tsx +11 -4
  92. package/src/timeline.tsx +1 -1
  93. package/src/uploading_thumbnail.tsx +212 -0
  94. package/src/use_focus_ring.ts +80 -0
  95. package/src/use_hover.ts +26 -0
  96. package/src/use_list_keyboard_nav.test.ts +71 -0
  97. package/src/use_list_keyboard_nav.ts +52 -5
  98. package/src/use_option_list.test.ts +193 -0
  99. package/src/use_option_list.ts +354 -0
  100. package/src/command_menu.tsx +0 -205
  101. package/src/hover_action.tsx +0 -54
  102. package/src/picker_menu.tsx +0 -355
  103. package/src/tag_input.tsx +0 -203
  104. package/src/time_field.tsx +0 -297
@@ -0,0 +1,109 @@
1
+ // FileRows — the common "show a list of files" surface, batteries included: each
2
+ // file is a FileRow whose whole-row press opens the built-in full-screen
3
+ // FileGalleryModal, with a ⋯ menu (Download · Open in new tab · Remove) on the
4
+ // trailing edge. Pure kit composition (FileRow + ActionMenu + FileGalleryModal) —
5
+ // reach for the pieces directly only when you need a different shape.
6
+
7
+ import { useState } from "react";
8
+ import { View } from "react-native";
9
+ import { Alert } from "./alert";
10
+ import { FileRow } from "./file_row";
11
+ import { ActionMenu, type ActionMenuItem } from "./action_menu";
12
+ import { FileGalleryModal } from "./file_gallery_modal";
13
+ import { resolveMime } from "./file_badge";
14
+ import { downloadFileFromUrl } from "./download";
15
+ import { resolveGalleryLabels, type GalleryLabels } from "./file_preview_types";
16
+ import type { DisplayFile } from "./file_thumbnail";
17
+
18
+ export interface FileRowsProps {
19
+ files: DisplayFile[];
20
+ /** Secondary line per file. Default: the file-type label (e.g. "PDF"). */
21
+ meta?: (file: DisplayFile) => string | undefined;
22
+ /** Adds a (confirmed) "Remove" to each ⋯ menu and the gallery toolbar. The host
23
+ * drops the file from its own state. */
24
+ onRemove?: (file: DisplayFile) => void;
25
+ /** Open a file outside the app (a new tab on the frontend, the SDK's
26
+ * `openExternal` in a sandboxed app). Adds "Open in new tab". */
27
+ onOpenExternal?: (file: DisplayFile) => void;
28
+ /** Override the Download action (default: fetch + save the URL). The host
29
+ * frontend passes its own to send `credentials` for auth-gated proxy URLs. */
30
+ onDownload?: (file: DisplayFile) => void;
31
+ /** Reported when a previewed file fails to render (host wires its logger). */
32
+ onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
33
+ /** Translated chrome — Download / Remove / Open-external / gallery + confirm. */
34
+ labels?: Partial<GalleryLabels>;
35
+ /** Gap between rows. Default 8. */
36
+ gap?: number;
37
+ /** Credentials mode for the gallery's preview fetches — `"include"` for the
38
+ * host's auth-gated proxy URLs, omitted for an app's presigned URLs. */
39
+ credentials?: RequestCredentials;
40
+ }
41
+
42
+ /**
43
+ * A file LIST that just works: tap a row to preview it full-screen, ⋯ for quick
44
+ * actions. The default surface for "here are some files" — saved attachments, a
45
+ * record's documents, a message's files. For an ADD/upload surface use `FileGrid`;
46
+ * for bare square tiles use `FileThumbnailGrid`.
47
+ */
48
+ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, onError, labels, gap = 8, credentials }: FileRowsProps) {
49
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
50
+ const l = resolveGalleryLabels(labels);
51
+
52
+ if (files.length === 0) return null;
53
+
54
+ const download = (file: DisplayFile) => {
55
+ if (onDownload) {
56
+ onDownload(file);
57
+ return;
58
+ }
59
+ void downloadFileFromUrl(file.url, file.filename, { credentials });
60
+ };
61
+
62
+ const confirmRemove = (file: DisplayFile) => {
63
+ if (!onRemove) return;
64
+ Alert.alert(l.removeConfirmTitle, l.removeConfirmMessage(file.filename), [
65
+ { text: l.cancel, style: "cancel" },
66
+ { text: l.remove, style: "destructive", onPress: () => onRemove(file) },
67
+ ]);
68
+ };
69
+
70
+ return (
71
+ <View style={{ gap }}>
72
+ {files.map((file, index) => {
73
+ const items: ActionMenuItem[] = [
74
+ { key: "download", label: l.download, icon: "download", onPress: () => download(file) },
75
+ ];
76
+ if (onOpenExternal) {
77
+ items.push({ key: "open", label: l.openExternal, icon: "external-link", onPress: () => onOpenExternal(file) });
78
+ }
79
+ if (onRemove) {
80
+ items.push({ key: "remove", label: l.remove, icon: "trash", danger: true, onPress: () => confirmRemove(file) });
81
+ }
82
+ return (
83
+ <FileRow
84
+ key={file.id}
85
+ name={file.filename}
86
+ meta={meta ? meta(file) : resolveMime(file.mimeType).label}
87
+ mimeType={file.mimeType}
88
+ onPress={() => setActiveIndex(index)}
89
+ trailing={<ActionMenu accessibilityLabel={`${l.actions}: ${file.filename}`} items={items} />}
90
+ />
91
+ );
92
+ })}
93
+ <FileGalleryModal
94
+ files={files}
95
+ activeIndex={activeIndex}
96
+ onIndexChange={setActiveIndex}
97
+ onDownload={onDownload}
98
+ onOpenExternal={onOpenExternal}
99
+ credentials={credentials}
100
+ onRemove={onRemove ? (fileId) => {
101
+ const file = files.find((f) => f.id === fileId);
102
+ if (file) onRemove(file);
103
+ } : undefined}
104
+ onError={onError}
105
+ labels={labels}
106
+ />
107
+ </View>
108
+ );
109
+ }
@@ -2,9 +2,12 @@ import { FileBadge, resolveMime } from "./file_badge";
2
2
  import { fontFamilySemiBold } from "./text_utils";
3
3
  import { Text } from "./text";
4
4
  import { Icon, type IconName } from "./icon";
5
+ import { IconButton } from "./icon_button";
5
6
  import { Checkbox } from "./checkbox";
6
7
  import { ActivityIndicator } from "./activity_indicator";
7
8
  import { colors } from "./colors";
9
+ import { FOCUS_RING } from "./control_surface";
10
+ import { useFocusRing } from "./use_focus_ring";
8
11
  import { useCallback, useState } from "react";
9
12
  import {
10
13
  GestureResponderEvent,
@@ -70,6 +73,8 @@ interface FileThumbnailProps {
70
73
  /** While true, dims the thumbnail with a spinner — the file is still uploading
71
74
  * (the local preview shows immediately; this marks it as in-flight). */
72
75
  uploading?: boolean;
76
+ /** Marks the file as a document TEMPLATE — adds a "TMPL" badge (any file type). */
77
+ isTemplate?: boolean;
73
78
  }
74
79
 
75
80
  /**
@@ -77,7 +82,7 @@ interface FileThumbnailProps {
77
82
  * Works with any file source via DisplayFile interface.
78
83
  */
79
84
  export function FileThumbnail(props: FileThumbnailProps) {
80
- const { file, size, onPress, onLongPress, onRemove, selected, uploading } = props;
85
+ const { file, size, onPress, onLongPress, onRemove, selected, uploading, isTemplate } = props;
81
86
 
82
87
  const rootStyle =
83
88
  size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
@@ -101,6 +106,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
101
106
  <DocumentBadge
102
107
  mimeType={file.mimeType}
103
108
  size={size}
109
+ isTemplate={isTemplate}
104
110
  onPress={onPress ?? (() => Linking.openURL(file.url))}
105
111
  onLongPress={onLongPress}
106
112
  />
@@ -126,6 +132,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
126
132
  mimeType={file.mimeType}
127
133
  filename={file.filename}
128
134
  size={size}
135
+ isTemplate={isTemplate}
129
136
  onPress={onPress ?? (() => Linking.openURL(file.url))}
130
137
  onLongPress={onLongPress}
131
138
  />
@@ -157,7 +164,7 @@ const uploadingOverlayStyles = StyleSheet.create({
157
164
  alignItems: "center",
158
165
  justifyContent: "center",
159
166
  backgroundColor: "rgba(24,24,27,0.42)",
160
- borderRadius: 8,
167
+ borderRadius: 10,
161
168
  },
162
169
  });
163
170
 
@@ -178,9 +185,10 @@ interface DocumentBadgeProps {
178
185
  */
179
186
  export function DocumentBadge(props: DocumentBadgeProps) {
180
187
  const { mimeType, isTemplate, onPress, onLongPress } = props;
188
+ const { focusVisible, focusProps } = useFocusRing();
181
189
 
182
190
  return (
183
- <Pressable onPress={onPress} onLongPress={onLongPress}>
191
+ <Pressable onPress={onPress} onLongPress={onLongPress} {...focusProps} style={focusVisible ? { boxShadow: FOCUS_RING, borderRadius: 6 } : undefined}>
184
192
  <FileBadge mimeType={mimeType} isTemplate={isTemplate} />
185
193
  </Pressable>
186
194
  );
@@ -190,42 +198,47 @@ interface DocumentCardProps {
190
198
  mimeType: string;
191
199
  filename: string;
192
200
  size?: number;
201
+ isTemplate?: boolean;
193
202
  onPress?: () => void;
194
203
  onLongPress?: () => void;
195
204
  overlay?: React.ReactNode;
196
205
  }
197
206
 
198
207
  /**
199
- * Document preview card: placeholder lines (future: file preview) + footer with filename and type label.
208
+ * Document tile: the file-type badge centered with the filename directly below
209
+ * it (no divider, no footer box). The badge scales with the tile and carries the
210
+ * type, color, and the TMPL marker. For readable filenames in a list, reach for
211
+ * `FileRow` (horizontal) instead.
200
212
  */
201
213
  export function DocumentCard(props: DocumentCardProps) {
202
- const { mimeType, filename, size, onPress, onLongPress, overlay } = props;
203
- const { label, color } = resolveMime(mimeType);
214
+ const { mimeType, filename, size, isTemplate, onPress, onLongPress, overlay } = props;
204
215
 
205
216
  const sizeStyle =
206
217
  size !== undefined
207
218
  ? { width: size, height: size }
208
219
  : { width: "100%" as const, height: "100%" as const };
209
220
 
221
+ // Badge takes ~40% of the tile, clamped so tiny/huge tiles stay tasteful.
222
+ const badgeSize = size !== undefined ? Math.max(30, Math.min(60, Math.round(size * 0.4))) : 40;
223
+ const { focusVisible, focusProps } = useFocusRing();
224
+
210
225
  return (
211
- <Pressable style={[styles.documentCard, sizeStyle]} onPress={onPress} onLongPress={onLongPress}>
212
- <View style={styles.documentCardBody}>
213
- <View style={styles.placeholderLines}>
214
- <View style={styles.placeholderLine} />
215
- <View style={styles.placeholderLine} />
216
- <View style={[styles.placeholderLine, { width: "60%" }]} />
217
- <View style={styles.placeholderLine} />
218
- <View style={[styles.placeholderLine, { width: "40%" }]} />
219
- </View>
220
- </View>
221
- <View style={styles.documentCardFooter}>
222
- <Text size="xs" numberOfLines={1} color="zinc-500" userSelect="none">
223
- {filename}
224
- </Text>
225
- <View style={[styles.typeLabel, { backgroundColor: color }]}>
226
- <RNText style={styles.typeLabelText}>{label}</RNText>
227
- </View>
228
- </View>
226
+ <Pressable
227
+ style={(state) => [
228
+ styles.documentTile,
229
+ sizeStyle,
230
+ (state as { hovered?: boolean }).hovered && styles.tileHovered,
231
+ state.pressed && styles.tilePressed,
232
+ focusVisible && { boxShadow: FOCUS_RING },
233
+ ]}
234
+ onPress={onPress}
235
+ onLongPress={onLongPress}
236
+ {...focusProps}
237
+ >
238
+ <FileBadge mimeType={mimeType} size={badgeSize} isTemplate={isTemplate} />
239
+ <Text size="xs" numberOfLines={1} color="zinc-500" style={styles.documentTileName} userSelect="none">
240
+ {filename}
241
+ </Text>
229
242
  {overlay}
230
243
  </Pressable>
231
244
  );
@@ -257,14 +270,16 @@ export function MediaCard(props: MediaCardProps) {
257
270
  : { width: "100%" as const, height: "100%" as const };
258
271
 
259
272
  const isCompact = size !== undefined && size <= COMPACT_THUMBNAIL_SIZE;
273
+ const { focusVisible, focusProps } = useFocusRing();
260
274
 
261
275
  if (isCompact) {
262
276
  const iconSize = Math.max(12, Math.round(size * 0.55));
263
277
  return (
264
278
  <Pressable
265
- style={[styles.mediaCardCompact, sizeStyle, { backgroundColor: color }]}
279
+ style={({ pressed }) => [styles.mediaCardCompact, sizeStyle, { backgroundColor: color }, pressed && styles.pressedDim, focusVisible && { boxShadow: FOCUS_RING }]}
266
280
  onPress={onPress}
267
281
  onLongPress={onLongPress}
282
+ {...focusProps}
268
283
  >
269
284
  <Icon name={icon} size={iconSize} color={colors.white} />
270
285
  {overlay}
@@ -276,21 +291,26 @@ export function MediaCard(props: MediaCardProps) {
276
291
  const backdrop = iconSize + 20;
277
292
 
278
293
  return (
279
- <Pressable style={[styles.documentCard, sizeStyle]} onPress={onPress} onLongPress={onLongPress}>
280
- <View style={[styles.mediaCardBody, { backgroundColor: color }]}>
281
- <View style={[styles.mediaIconBackdrop, { width: backdrop, height: backdrop, borderRadius: backdrop / 2 }]}>
282
- <Icon name={icon} size={iconSize} color={colors.white} />
283
- </View>
284
- </View>
285
- <View style={styles.documentCardFooter}>
286
- <Text size="xs" numberOfLines={1} color="zinc-500" userSelect="none">
287
- {filename}
288
- </Text>
289
- <View style={[styles.typeLabel, { backgroundColor: color }]}>
290
- <RNText style={styles.typeLabelText}>{label}</RNText>
291
- </View>
292
- </View>
293
- {overlay}
294
+ <Pressable style={[styles.documentCard, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]} onPress={onPress} onLongPress={onLongPress} {...focusProps}>
295
+ {(state) => (
296
+ <>
297
+ <View style={[styles.mediaCardBody, { backgroundColor: color }]}>
298
+ <View style={[styles.mediaIconBackdrop, { width: backdrop, height: backdrop, borderRadius: backdrop / 2 }]}>
299
+ <Icon name={icon} size={iconSize} color={colors.white} />
300
+ </View>
301
+ </View>
302
+ <View style={styles.documentCardFooter}>
303
+ <Text size="xs" numberOfLines={1} color="zinc-500" userSelect="none">
304
+ {filename}
305
+ </Text>
306
+ <View style={[styles.typeLabel, { backgroundColor: color }]}>
307
+ <RNText style={styles.typeLabelText}>{label}</RNText>
308
+ </View>
309
+ </View>
310
+ {overlay}
311
+ <TileScrim hovered={(state as { hovered?: boolean }).hovered} pressed={state.pressed} />
312
+ </>
313
+ )}
294
314
  </Pressable>
295
315
  );
296
316
  }
@@ -311,6 +331,7 @@ interface ImageThumbnailProps {
311
331
  function ImageThumbnail(props: ImageThumbnailProps) {
312
332
  const { file, size, onPress, onLongPress } = props;
313
333
  const [useFallback, setUseFallback] = useState(false);
334
+ const { focusVisible, focusProps } = useFocusRing();
314
335
 
315
336
  // Use thumbnail if available, fallback to full URL
316
337
  const url = !useFallback && file.thumbnailUrl ? file.thumbnailUrl : file.url;
@@ -321,22 +342,31 @@ function ImageThumbnail(props: ImageThumbnailProps) {
321
342
  : { width: "100%" as const, height: "100%" as const };
322
343
 
323
344
  return (
324
- <Pressable
325
- onPress={onPress}
326
- onLongPress={onLongPress}
327
- style={[styles.imageThumbnail, sizeStyle]}
328
- >
329
- <Image
330
- source={{ uri: url }}
331
- style={styles.image}
332
- resizeMode="cover"
333
- onError={() => setUseFallback(true)}
334
- />
345
+ <Pressable onPress={onPress} onLongPress={onLongPress} {...focusProps} style={[styles.imageThumbnail, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]}>
346
+ {(state) => (
347
+ <>
348
+ <Image
349
+ source={{ uri: url }}
350
+ style={styles.image}
351
+ resizeMode="cover"
352
+ onError={() => setUseFallback(true)}
353
+ />
354
+ <TileScrim hovered={(state as { hovered?: boolean }).hovered} pressed={state.pressed} />
355
+ </>
356
+ )}
335
357
  </Pressable>
336
358
  );
337
359
  }
338
360
 
339
- export function RemoveButton({ onPress }: { onPress: () => void }) {
361
+ /** Hover/press feedback for image and media tiles: a subtle darkening scrim
362
+ * ON TOP of the image/colored body (a bg wash would sit under it). Renders
363
+ * nothing at rest, so it never tints the tile until pointed at. */
364
+ function TileScrim({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) {
365
+ if (!hovered && !pressed) return null;
366
+ return <View style={[styles.tileScrim, pressed && styles.tileScrimPressed]} pointerEvents="none" />;
367
+ }
368
+
369
+ export function RemoveButton({ onPress, label = "Remove" }: { onPress: () => void; label?: string }) {
340
370
  const handlePress = useCallback(
341
371
  (e: GestureResponderEvent) => {
342
372
  e.stopPropagation();
@@ -345,9 +375,9 @@ export function RemoveButton({ onPress }: { onPress: () => void }) {
345
375
  [onPress],
346
376
  );
347
377
  return (
348
- <Pressable onPress={handlePress} style={styles.removeButton}>
349
- <Icon name="x" size={14} color={colors.zinc[700]} />
350
- </Pressable>
378
+ <View style={styles.removeButtonAnchor}>
379
+ <IconButton icon="x" size="sm" elevated accessibilityLabel={label} onPress={handlePress} />
380
+ </View>
351
381
  );
352
382
  }
353
383
 
@@ -370,16 +400,53 @@ function SelectionOverlay({ selected }: { selected: boolean }) {
370
400
 
371
401
  const styles = StyleSheet.create({
372
402
  documentCard: {
373
- borderRadius: 8,
403
+ borderRadius: 10,
374
404
  backgroundColor: colors.white,
375
405
  borderWidth: 1,
376
406
  borderColor: colors.zinc["200"],
377
407
  overflow: "hidden",
378
408
  },
379
- documentCardBody: {
380
- flex: 1,
409
+ documentTile: {
410
+ borderRadius: 10,
411
+ backgroundColor: colors.white,
412
+ borderWidth: 1,
413
+ borderColor: colors.zinc["200"],
414
+ overflow: "hidden",
415
+ alignItems: "center",
381
416
  justifyContent: "center",
382
- padding: 10,
417
+ gap: 8,
418
+ paddingHorizontal: 8,
419
+ paddingVertical: 10,
420
+ ...({ transitionDuration: "0.1s", transitionProperty: "background-color" } as object),
421
+ },
422
+ documentTileName: {
423
+ alignSelf: "stretch",
424
+ textAlign: "center",
425
+ },
426
+ // Hover/press feedback: doc tiles darken their white bg (wash); image/media
427
+ // tiles get a `TileScrim` ON TOP (a bg wash would sit under the image/body).
428
+ tileHovered: {
429
+ backgroundColor: colors.zinc["50"],
430
+ },
431
+ tilePressed: {
432
+ backgroundColor: colors.zinc["100"],
433
+ },
434
+ tileScrim: {
435
+ position: "absolute",
436
+ top: 0,
437
+ left: 0,
438
+ right: 0,
439
+ bottom: 0,
440
+ borderRadius: 10,
441
+ backgroundColor: "rgba(24, 24, 27, 0.06)",
442
+ ...({ transitionDuration: "0.1s", transitionProperty: "background-color" } as object),
443
+ },
444
+ tileScrimPressed: {
445
+ backgroundColor: "rgba(24, 24, 27, 0.12)",
446
+ },
447
+ // Compact media badges (≤32px) are too small for a scrim — a quick opacity dim reads.
448
+ pressedDim: {
449
+ opacity: 0.85,
383
450
  },
384
451
  mediaCardBody: {
385
452
  flex: 1,
@@ -387,7 +454,7 @@ const styles = StyleSheet.create({
387
454
  alignItems: "center",
388
455
  },
389
456
  mediaCardCompact: {
390
- borderRadius: 6,
457
+ borderRadius: 7,
391
458
  alignItems: "center",
392
459
  justifyContent: "center",
393
460
  overflow: "hidden",
@@ -397,14 +464,6 @@ const styles = StyleSheet.create({
397
464
  justifyContent: "center",
398
465
  backgroundColor: "rgba(255, 255, 255, 0.22)",
399
466
  },
400
- placeholderLines: {
401
- gap: 4,
402
- },
403
- placeholderLine: {
404
- height: 2,
405
- borderRadius: 1,
406
- backgroundColor: colors.zinc["200"],
407
- },
408
467
  documentCardFooter: {
409
468
  gap: 2,
410
469
  paddingHorizontal: 8,
@@ -427,25 +486,17 @@ const styles = StyleSheet.create({
427
486
  letterSpacing: 0.3,
428
487
  },
429
488
  imageThumbnail: {
430
- borderRadius: 8,
489
+ borderRadius: 10,
431
490
  overflow: "hidden",
432
491
  },
433
492
  image: {
434
493
  width: "100%",
435
494
  height: "100%",
436
495
  },
437
- removeButton: {
496
+ removeButtonAnchor: {
438
497
  position: "absolute",
439
- top: -6,
440
- right: -6,
441
- width: 22,
442
- height: 22,
443
- alignItems: "center",
444
- justifyContent: "center",
445
- borderRadius: 999,
446
- borderWidth: 1,
447
- borderColor: colors.border,
448
- backgroundColor: colors.zinc["50"],
498
+ top: -8,
499
+ right: -8,
449
500
  zIndex: 1,
450
501
  },
451
502
  selectionOverlay: {
@@ -454,7 +505,7 @@ const styles = StyleSheet.create({
454
505
  left: 0,
455
506
  right: 0,
456
507
  bottom: 0,
457
- borderRadius: 8,
508
+ borderRadius: 10,
458
509
  zIndex: 1,
459
510
  },
460
511
  selectionOverlaySelected: {