@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.
- package/AGENTS.md +23 -1
- package/MIGRATION.md +23 -0
- package/docs/ai_patterns.md +34 -3
- package/docs/catalog.md +7 -1
- package/docs/composition.md +80 -5
- package/docs/data_entry.md +13 -0
- package/examples/tpl_item_list.tsx +39 -6
- package/examples/tpl_record.tsx +187 -6
- package/package.json +2 -1
- package/src/agent_run.tsx +8 -1
- package/src/agent_run_pane.tsx +57 -12
- package/src/choice_list.tsx +6 -3
- package/src/clarify.tsx +6 -2
- package/src/clarify_wizard.tsx +4 -2
- package/src/control_surface.ts +25 -0
- package/src/dialog.tsx +47 -8
- package/src/file_intake.ts +17 -2
- package/src/files_editor.tsx +1 -1
- package/src/floating_action_bar.tsx +33 -4
- package/src/locale.tsx +3 -0
- package/src/mime.ts +76 -0
- package/src/pressable_row.tsx +13 -9
- package/src/table_fit.ts +16 -4
package/src/file_intake.ts
CHANGED
|
@@ -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
|
|
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
|
});
|
package/src/files_editor.tsx
CHANGED
|
@@ -351,7 +351,7 @@ export function FilesEditorRemove() {
|
|
|
351
351
|
return (
|
|
352
352
|
<Button
|
|
353
353
|
title={labels.delete}
|
|
354
|
-
color="danger
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/pressable_row.tsx
CHANGED
|
@@ -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
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
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:
|
|
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:
|
|
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:
|
|
128
|
-
marginHorizontal: -
|
|
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
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
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
|