@lotics/ui 29.2.0 → 30.0.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.
@@ -13,11 +13,13 @@
13
13
 
14
14
  import type { ReactNode } from "react";
15
15
  import type { StyleProp, ViewStyle } from "react-native";
16
+ import { resolveMimeType } from "./mime";
16
17
 
17
18
  /** The filter every intake path applies to what arrived. */
18
19
  export interface FileIntakeFilter {
19
20
  /** Native `accept` filter (e.g. `"application/pdf,image/*"`) — matched against
20
- * the file's MIME type, or its extension for a `.ext` pattern. */
21
+ * the file's MIME type (falling back to what its extension says when the file
22
+ * declares none), or against its filename for a `.ext` pattern. */
21
23
  accept?: string;
22
24
  /** Keep more than one file. Default true. */
23
25
  multiple?: boolean;
@@ -79,15 +81,28 @@ export interface FileDropTargetProps extends FileIntakeFilter {
79
81
  * Does a file pass a native `accept` list? Each comma-separated pattern is an
80
82
  * extension (`.pdf`), a wildcard type (`image/*`) or an exact MIME
81
83
  * (`application/pdf`); an empty/absent list accepts everything.
84
+ *
85
+ * A file whose declared type says NOTHING is judged by its extension instead.
86
+ * The OS clipboard is the case that forces this: copy a file in
87
+ * Finder/Explorer and paste it, and the browser hands over real bytes and a
88
+ * real filename while declaring `""` — or, just as often,
89
+ * `application/octet-stream`, the same non-answer wearing the shape of one.
90
+ * Reading either as a MIME mismatch asserts "this is not a PDF" from data that
91
+ * never said so, and the file is dropped; because a paste carrying nothing
92
+ * acceptable is deliberately left alone, that drop is invisible.
93
+ *
94
+ * `resolveMimeType` owns which declarations count as uninformative, so this
95
+ * matches on the same type the SERVER will store for the file.
82
96
  */
83
97
  export function matchesAccept(file: File, accept: string | undefined): boolean {
84
98
  if (!accept) return true;
85
99
  const patterns = accept.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
86
100
  if (patterns.length === 0) return true;
87
- const mime = file.type.toLowerCase();
88
101
  const name = file.name.toLowerCase();
102
+ const mime = resolveMimeType(file.type, name);
89
103
  return patterns.some((p) => {
90
104
  if (p.startsWith(".")) return name.endsWith(p);
105
+ if (!mime) return false;
91
106
  if (p.endsWith("/*")) return mime.startsWith(p.slice(0, -1));
92
107
  return mime === p;
93
108
  });
@@ -351,7 +351,7 @@ export function FilesEditorRemove() {
351
351
  return (
352
352
  <Button
353
353
  title={labels.delete}
354
- color="danger-secondary"
354
+ color="danger"
355
355
  disabled={selected.length === 0}
356
356
  onPress={() => confirmRemove(selected, () => { for (const f of selected) onRemove(f.id); exit(); })}
357
357
  />
@@ -33,9 +33,20 @@ export function FloatingActionBar(props: FloatingActionBarProps) {
33
33
  return (
34
34
  <View pointerEvents="box-none" style={styles.wrap}>
35
35
  <Card style={styles.bar}>
36
- <Text size="sm" weight="semibold" tabular>{`${count} ${label}`}</Text>
37
- <Button title={clearLabel} color="muted" onPress={onClear} />
38
- {children}
36
+ {/* TWO GROUPS, not one row of peers. The count and Clear are the bar's
37
+ own STATE what is picked, and how to un-pick it; they act on the
38
+ selection, not on the records. `children` are the ACTS. A single gap
39
+ across all of them made four buttons read as four equal things, and
40
+ the emphasis ladder between them (muted / secondary / primary, which
41
+ encodes destructive vs benign vs promoted) then looked like
42
+ arbitrary variety rather than a ladder. Grouping states the kind
43
+ difference; the ladder is then read WITHIN the acts, where it means
44
+ something. */}
45
+ <View style={styles.state}>
46
+ <Text size="sm" weight="semibold" tabular>{`${count} ${label}`}</Text>
47
+ <Button title={clearLabel} color="muted" onPress={onClear} />
48
+ </View>
49
+ <View style={styles.acts}>{children}</View>
39
50
  </Card>
40
51
  </View>
41
52
  );
@@ -48,13 +59,31 @@ const styles = StyleSheet.create({
48
59
  left: 0,
49
60
  right: 0,
50
61
  alignItems: "center",
62
+ // Keeps the bar off the screen edges at any width — and, with the bar's
63
+ // `maxWidth`, is what bounds it to the viewport on a phone.
64
+ paddingHorizontal: 16,
51
65
  zIndex: 20,
52
66
  },
53
67
  bar: {
54
68
  flexDirection: "row",
55
69
  alignItems: "center",
56
- gap: 12,
70
+ justifyContent: "center",
71
+ // A count plus an escape plus three verbs is ~520px of content — wider than
72
+ // a phone. Unbounded it simply hung off both edges, taking the promoted act
73
+ // with it, and no host could fix that without re-authoring the bar. So it
74
+ // WRAPS, and the grouping decides where: `state` and `acts` are each a
75
+ // nowrap row, so the only break available is the boundary between them —
76
+ // the line splits exactly where the meaning does (what is picked / what to
77
+ // do with it), never mid-ladder with Download orphaned from Use AI.
78
+ flexWrap: "wrap",
79
+ maxWidth: "100%",
80
+ // The biggest boundary in the bar — state | acts — gets the most air. The
81
+ // two groups below sit on the kit's action gap. Doubles as the row gap once
82
+ // wrapped, which is the same boundary seen vertically.
83
+ gap: 20,
57
84
  paddingVertical: 10,
58
85
  paddingHorizontal: 16,
59
86
  },
87
+ state: { flexDirection: "row", alignItems: "center", gap: 8 },
88
+ acts: { flexDirection: "row", alignItems: "center", gap: 8 },
60
89
  });
package/src/locale.tsx CHANGED
@@ -193,6 +193,7 @@ export interface LoticsLocale {
193
193
  output: string;
194
194
  awaiting: string;
195
195
  retry: string;
196
+ stop: string;
196
197
  tools: Record<string, string>;
197
198
  steps: (n: number) => string;
198
199
  };
@@ -341,6 +342,7 @@ export const en: LoticsLocale = {
341
342
  output: "Output",
342
343
  awaiting: "Awaiting",
343
344
  retry: "Retry",
345
+ stop: "Stop",
344
346
  tools: {
345
347
  query_records: "Searching records",
346
348
  get_record: "Reading a record",
@@ -493,6 +495,7 @@ export const vi: LoticsLocale = {
493
495
  output: "Kết quả",
494
496
  awaiting: "Chờ duyệt",
495
497
  retry: "Thử lại",
498
+ stop: "Dừng",
496
499
  tools: {
497
500
  query_records: "Đang tìm dữ liệu",
498
501
  get_record: "Đang đọc một bản ghi",
package/src/mime.ts CHANGED
@@ -52,3 +52,79 @@ export function isPreviewableMimeType(mimeType: string): boolean {
52
52
  isDocxMimeType(mimeType)
53
53
  );
54
54
  }
55
+
56
+ /** A MIME type without its parameters (`text/csv; charset=utf-8` → `text/csv`). */
57
+ function normalizeMimeType(mimeType: string): string {
58
+ return mimeType.split(";")[0]!.trim().toLowerCase();
59
+ }
60
+
61
+ /**
62
+ * Types that carry no information — a sender that declined to classify the
63
+ * bytes. The empty string is only the most obvious one: an OS clipboard or drag
64
+ * routinely hands a file over as `application/octet-stream` instead, which is
65
+ * the same statement made in a way that LOOKS like an answer.
66
+ */
67
+ const GENERIC_MIME_TYPES: ReadonlySet<string> = new Set([
68
+ "",
69
+ "application/octet-stream",
70
+ "binary/octet-stream",
71
+ "application/binary",
72
+ "application/unknown",
73
+ ]);
74
+
75
+ /**
76
+ * Extension → MIME, for the document and image formats an intake surface names.
77
+ * A `Map`, not an object literal, so a file named `x.constructor` looks up a
78
+ * miss rather than `Object.prototype`'s member.
79
+ */
80
+ const MIME_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
81
+ ["pdf", "application/pdf"],
82
+ ["png", "image/png"],
83
+ ["jpg", "image/jpeg"],
84
+ ["jpeg", "image/jpeg"],
85
+ ["gif", "image/gif"],
86
+ ["webp", "image/webp"],
87
+ ["avif", "image/avif"],
88
+ ["bmp", "image/bmp"],
89
+ ["svg", "image/svg+xml"],
90
+ ["tif", "image/tiff"],
91
+ ["tiff", "image/tiff"],
92
+ ["heic", "image/heic"],
93
+ ["heif", "image/heif"],
94
+ ["csv", "text/csv"],
95
+ ["tsv", "text/tab-separated-values"],
96
+ ["txt", "text/plain"],
97
+ ["xml", "application/xml"],
98
+ ["json", "application/json"],
99
+ ["zip", "application/zip"],
100
+ ["xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
101
+ ["xls", "application/vnd.ms-excel"],
102
+ ["docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
103
+ ["doc", "application/msword"],
104
+ ]);
105
+
106
+ /** What a filename's extension says the file is, or `""` when it says nothing. */
107
+ function mimeTypeFromFileName(name: string): string {
108
+ const dot = name.lastIndexOf(".");
109
+ // `dot <= 0` also rejects a dotfile (`.pdf` is a NAME, not an extension);
110
+ // `length - 1` rejects a trailing dot.
111
+ if (dot <= 0 || dot === name.length - 1) return "";
112
+ return MIME_BY_EXTENSION.get(name.slice(dot + 1).toLowerCase()) ?? "";
113
+ }
114
+
115
+ /**
116
+ * The type to TREAT content as, given what its sender declared and what it is
117
+ * called. An honestly-declared type is never second-guessed — only an
118
+ * uninformative one is resolved, and only from the filename, falling back to
119
+ * the declaration when the extension is unrecognized too.
120
+ *
121
+ * Mirrors `resolveMimeType` in `@lotics/shared/file_type`, which is what the
122
+ * server stores. The kit cannot import it (`primitives_purity.test.ts` keeps
123
+ * `src/` free of `@lotics/shared`), so `mime_parity.test.ts` asserts the two
124
+ * agree instead of trusting that they will.
125
+ */
126
+ export function resolveMimeType(declaredMimeType: string, filename: string): string {
127
+ const declared = normalizeMimeType(declaredMimeType);
128
+ if (!GENERIC_MIME_TYPES.has(declared)) return declared;
129
+ return mimeTypeFromFileName(filename) || declared;
130
+ }
@@ -1,4 +1,5 @@
1
1
  import { ReactNode, Ref, useCallback, useState } from "react";
2
+ import { ROW_WASH_BLEED } from "./control_surface";
2
3
  import { Pressable, StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
4
  import { colors } from "./colors";
4
5
  import { pressSelectedText } from "./press_selection";
@@ -108,23 +109,26 @@ const styles = StyleSheet.create({
108
109
  // the text it had no business covering.
109
110
  ...({ cursor: "pointer", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
110
111
  },
111
- // THE register row — FULL-WIDTH rounded highlight. The hover/selected wash
112
- // spans the entire row (so the pressable area === the row, never narrower than
113
- // the dividers around it) and rounds (radius 10); content sits on the 20px
114
- // column gutter so a Table header + cells align. The Divider between rows (the
115
- // Table renders it) is the resting separation; this wash is the hover state.
112
+ // THE register row — FULL-WIDTH rounded highlight that BLEEDS past the content
113
+ // (`ROW_WASH_BLEED`, net-zero margin+padding), so the row's content sits on the
114
+ // container's own edge and the wash still has breathing room. It used to pad 20
115
+ // WITHOUT the negative margin, which put every table's first column 20px right
116
+ // of the headings above it and made `SummaryLine` call sites hand-pad 20 to
117
+ // catch up. `inset` below has always done it correctly; this is the same rule.
116
118
  register: {
117
119
  borderRadius: 10,
118
- paddingHorizontal: 20,
120
+ paddingHorizontal: ROW_WASH_BLEED,
121
+ marginHorizontal: -ROW_WASH_BLEED,
119
122
  },
120
123
  // Square, full-bleed — lines up with the Dividers between register rows. Legacy.
121
124
  bleed: {
122
- paddingHorizontal: 20,
125
+ paddingHorizontal: ROW_WASH_BLEED,
126
+ marginHorizontal: -ROW_WASH_BLEED,
123
127
  },
124
128
  // Rounded, pulled in from the edge — matches the Accordion's inset rows.
125
129
  inset: {
126
130
  borderRadius: 8,
127
- paddingHorizontal: 8,
128
- marginHorizontal: -8,
131
+ paddingHorizontal: ROW_WASH_BLEED,
132
+ marginHorizontal: -ROW_WASH_BLEED,
129
133
  },
130
134
  });
package/src/table_fit.ts CHANGED
@@ -30,10 +30,22 @@ export interface TableFitColumn {
30
30
  /** Gap between the header band's / a row's children — `table.tsx` styles consume
31
31
  * this, so the fit math and the rendered geometry can't drift. */
32
32
  export const COLUMN_GAP = 14;
33
- /** The register's horizontal gutter — mirrors `PressableRow`'s register variant
34
- * (`paddingHorizontal: 20`), the one geometry this file models but doesn't own.
35
- * `table.tsx` consumes it for the header band and static rows. */
36
- export const ROW_GUTTER = 20;
33
+ /**
34
+ * The register's CONTENT gutter zero, because the washed row bleeds instead.
35
+ *
36
+ * It was 20, mirroring `PressableRow`'s register variant, and every unwashed
37
+ * part of a table (the header band, static rows, an expanded detail) padded by
38
+ * the same 20 purely to line up with the washed row's inset content. That put a
39
+ * table's first column 20px right of the section heading above it with nothing
40
+ * visible in the gap. The wash now grows outward (`ROW_WASH_BLEED`) rather than
41
+ * pushing content in, so every part of a table sits on its container's edge and
42
+ * the five `table.tsx` sites that read this constant follow automatically.
43
+ *
44
+ * Kept as a named constant rather than deleted: the header band, the rows and
45
+ * the fit math must agree, and a literal repeated at five sites is how they
46
+ * stopped agreeing before.
47
+ */
48
+ export const ROW_GUTTER = 0;
37
49
  const ROW_H_PADDING = ROW_GUTTER * 2;
38
50
  /** Fit-math width a flexible column needs to stay usable — below this the flex
39
51
  * column is crushed to ellipsis soup, so it counts as this wide when deciding