@lotics/ui 12.1.1 → 13.7.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.
@@ -1,5 +1,5 @@
1
1
  import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
2
- import { Pressable, ScrollView, View } from "react-native";
2
+ import { ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { colors } from "@lotics/ui/colors";
5
5
  import { Button } from "@lotics/ui/button";
@@ -12,6 +12,7 @@ import type { PickerOption } from "@lotics/ui/picker";
12
12
  import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
13
13
  import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
14
14
  import { Callout, CalloutText } from "@lotics/ui/callout";
15
+ import { Inset } from "@lotics/ui/inset";
15
16
  import { Section, SectionHeading, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
16
17
  import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
17
18
  import { MenuButton } from "@lotics/ui/menu_button";
@@ -58,10 +59,7 @@ import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
58
59
  import { Finding, FindingComparison } from "@lotics/ui/finding";
59
60
  import { FormTextInput } from "@lotics/ui/form_text_input";
60
61
  import { CheckboxInput } from "@lotics/ui/checkbox_input";
61
- import { TextLink } from "@lotics/ui/text_link";
62
- import { PressableRow } from "@lotics/ui/pressable_row";
63
- import { useFocusRing } from "@lotics/ui/use_focus_ring";
64
- import { FOCUS_RING } from "@lotics/ui/control_surface";
62
+ import { LinkedRecordBox } from "@lotics/ui/linked_record_box";
65
63
  import { RadioPicker } from "@lotics/ui/radio_picker";
66
64
  import { SearchInput } from "@lotics/ui/search_input";
67
65
  import { useSelection } from "@lotics/ui/use_selection";
@@ -159,7 +157,27 @@ interface StageTask {
159
157
  stage: Desk;
160
158
  done: boolean;
161
159
  assignee: string | null;
160
+ /** ISO date (`""` = no due). The row shows it in a `variant="cell"` date field —
161
+ * no calendar glyph, coloured by urgency (`tone`), press to edit. */
162
+ due: string;
162
163
  }
164
+
165
+ // A due date N days from today — keeps the seeded urgency states (overdue / soon /
166
+ // later) correct whenever the gallery is opened.
167
+ const dueIn = (n: number): string => {
168
+ const d = new Date();
169
+ d.setDate(d.getDate() + n);
170
+ return d.toISOString().slice(0, 10);
171
+ };
172
+ // The urgency tone for a due date, fed to the cell's `tone`: red past due, amber within
173
+ // 3 days, else muted — the date text itself is the signal (no separate dot). Done → muted.
174
+ const dueTone = (due: string, done: boolean): "danger" | "warning" | "muted" => {
175
+ if (!due || done) return "muted";
176
+ const today = new Date();
177
+ today.setHours(0, 0, 0, 0);
178
+ const days = Math.round((new Date(due).getTime() - today.getTime()) / 86_400_000);
179
+ return days < 0 ? "danger" : days <= 3 ? "warning" : "muted";
180
+ };
163
181
  // Common-but-OPTIONAL tasks per desk — in a real app, the record type's
164
182
  // configured checklist. A NEW record starts with an EMPTY list (tasks truly
165
183
  // vary); these commons wait as dismissible suggestion CHIPS under each desk
@@ -180,13 +198,13 @@ const newTaskId = () => `t_${(taskSeq += 1)}`;
180
198
 
181
199
  // The in-flight demo record's existing checklist (a NEW record gets []).
182
200
  const TASK_SEEDS: StageTask[] = [
183
- { id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01" },
184
- { id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01" },
185
- { id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null },
186
- { id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02" },
187
- { id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null },
188
- { id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03" },
189
- { id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null },
201
+ { id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10) },
202
+ { id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "" },
203
+ { id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2) },
204
+ { id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1) },
205
+ { id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null, due: dueIn(6) },
206
+ { id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03", due: "" },
207
+ { id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null, due: dueIn(14) },
190
208
  ];
191
209
 
192
210
  // The assignable roster — an app feeds `useMembers()` here.
@@ -569,28 +587,6 @@ const FEE_COLUMNS: TableColumn[] = [
569
587
  const feeOverdue = (f: Fee): boolean => !f.paid && f.due !== "" && new Date(f.due) < new Date();
570
588
 
571
589
 
572
- /** The keyboard DOOR of a pressable linked-record box: an EMPTY absolutely-
573
- * positioned sibling BENEATH the box's content — a button must not contain
574
- * the box's interactive descendants (the `PressableRow`/`TableRow` law), so
575
- * the door carries the tab stop, the accessible name and the focus ring;
576
- * mouse presses ride the `PressableRow` surface, and the interior verbs
577
- * lift above the door via `zIndex: 1`. */
578
- function BoxDoor({ label, onPress }: { label: string; onPress: () => void }) {
579
- const { focusVisible, focusProps } = useFocusRing();
580
- return (
581
- <Pressable
582
- accessibilityRole="button"
583
- accessibilityLabel={label}
584
- onPress={onPress}
585
- {...focusProps}
586
- style={[
587
- { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, borderRadius: 10 },
588
- focusVisible ? { boxShadow: FOCUS_RING } : null,
589
- ]}
590
- />
591
- );
592
- }
593
-
594
590
  /** The file-capable comment editor injected via `renderEditForm` — text + the
595
591
  * comment's attachments (each removable), committed together (the DEFAULT
596
592
  * edit form is text-only; the product injects its own equivalent). */
@@ -627,12 +623,13 @@ const DOC_COLUMNS: TableColumn[] = [
627
623
  // folds behind "Show all forms". (The Documents section above is the INTAKE
628
624
  // register — received files; this registry is what the record can PRODUCE.)
629
625
  // READINESS: a form declares which RECORD FIELDS it reads (`needs`). The
630
- // fields' HOME stays their DATA section (the colocation law); every
631
- // not-ready row shows its mark AND the explicit "Add missing fields"
632
- // trigger, which toggles a TRANSIENT fill editor under the row (draft →
633
- // "Save fields" commits onto the record and the panel collapses) repair
634
- // in place, never scrolling off hunting for the field. The hint is each
635
- // field's consumer line, shown in the panel too.
626
+ // fields' HOME stays their DATA section (the colocation law). Warning and fix
627
+ // are CO-LOCATED on the row: a not-ready row's mark NAMES what's missing (muted
628
+ // at rest so gaps show without checking, warning once checked), and the fix sits
629
+ // right under it in the `expansion` an `Inset` (a FORM surface, never a Callout,
630
+ // which is an ARIA alert) with the editors, saved onto the record in place. Drafts
631
+ // are keyed BY FIELD, so a field two checked forms share is entered once and one
632
+ // Save resolves both. A ready form stays silent.
636
633
  type NeedKey = "delivery_address" | "commodity_code" | "hazard_class";
637
634
  const NEEDS: Record<NeedKey, { label: string; hint: string }> = {
638
635
  delivery_address: { label: "Delivery address", hint: "The consignee's dock — printed on the delivery note" },
@@ -1039,21 +1036,11 @@ export function TplRecord() {
1039
1036
  const isEligible = (f: SetForm) => f.id !== "c3" || hazardousGoods;
1040
1037
  const checkedForms = ALL_FORMS.filter((f) => isEligible(f) && chosenForms.has(f.id));
1041
1038
  const blockingNeeds = [...new Set(checkedForms.flatMap(missingOf))];
1042
- // the fill panels' DRAFTS — keyed by field, SHARED between panels needing
1043
- // the same field; Save commits onto the record (the panel then collapses
1044
- // because the need resolved, and the field's home row shows the value)
1039
+ // the per-row fills' DRAFTS — keyed by field, SHARED between the rows of forms that
1040
+ // need the same field, so filling it under one form resolves it for all; Save commits
1041
+ // onto the record and the field's home row shows the value (the need resolves, the
1042
+ // row's mark clears, the fill drops away).
1045
1043
  const [fillDrafts, setFillDrafts] = useState<Record<string, string>>({});
1046
- // which rows have their fill editor OPEN — toggled ONLY by the row's
1047
- // explicit "Add missing fields" trigger, never as a side effect of
1048
- // checking (an undiscoverable expansion is no affordance)
1049
- const [openFill, setOpenFill] = useState<ReadonlySet<string>>(new Set());
1050
- const toggleFillOpen = (fid: string) =>
1051
- setOpenFill((prev) => {
1052
- const next = new Set(prev);
1053
- if (next.has(fid)) next.delete(fid);
1054
- else next.add(fid);
1055
- return next;
1056
- });
1057
1044
  const needSetters: Record<NeedKey, (v: string) => void> = { delivery_address: setDeliveryAddress, commodity_code: setCommodityCode, hazard_class: setHazardClass };
1058
1045
  const saveNeeds = (keys: NeedKey[]) => {
1059
1046
  for (const k of keys) needSetters[k]((fillDrafts[k] ?? "").trim());
@@ -1125,11 +1112,11 @@ export function TplRecord() {
1125
1112
  const addTask = () => {
1126
1113
  const label = newTask.trim();
1127
1114
  if (!label) return;
1128
- setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null }]);
1115
+ setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null, due: "" }]);
1129
1116
  setNewTask("");
1130
1117
  };
1131
1118
  const addSuggested = (desk: Desk, label: string) =>
1132
- setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null }]);
1119
+ setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null, due: "" }]);
1133
1120
  const removeTask = (tid: string) => setTasks((prev) => prev.filter((t) => t.id !== tid));
1134
1121
  const moveTask = (tid: string, desk: Desk) =>
1135
1122
  setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, stage: desk } : t)));
@@ -1138,6 +1125,8 @@ export function TplRecord() {
1138
1125
  const [dismissed, setDismissed] = useState<string[]>([]);
1139
1126
  const assignTask = (tid: string, member: string | null) =>
1140
1127
  setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, assignee: member } : t)));
1128
+ const dueTask = (tid: string, due: string) =>
1129
+ setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, due } : t)));
1141
1130
  // The Task-list grammar: a clearable Group-by + filter chips derive the
1142
1131
  // sections; empty groups drop (except desks — the journey stays visible).
1143
1132
  const [taskGroup, setTaskGroup] = useState<"desk" | "assignee" | "status" | null>("desk");
@@ -1539,7 +1528,7 @@ export function TplRecord() {
1539
1528
  {/* CLASSIFICATION — the right-input-per-field law, worked. A field
1540
1529
  gets the control its SHAPE wants, not a default text box:
1541
1530
  · ≤5 exclusive options the user should SEE → RadioPicker
1542
- (column when options carry descriptions, row when short)
1531
+ (a stacked column of full-width choices)
1543
1532
  · a longer closed list → InlineSelect (rich options carry a
1544
1533
  description line — it shows in the resting row too)
1545
1534
  · a pick from a big REGISTRY → InlineSelect searchable (the
@@ -1585,7 +1574,6 @@ export function TplRecord() {
1585
1574
  <DetailRow label="Insurance">
1586
1575
  <RadioPicker
1587
1576
  accessibilityLabel="Insurance"
1588
- direction="row"
1589
1577
  value={insurance}
1590
1578
  onValueChange={setInsurance}
1591
1579
  options={[
@@ -1808,7 +1796,7 @@ export function TplRecord() {
1808
1796
  desk's rows fully editable (the stage gates the handoff
1809
1797
  and Billing, never task editing — planning ahead on a
1810
1798
  later desk is normal work) */}
1811
- <Checklist trailingWidth={156}>
1799
+ <Checklist trailingWidth={152}>
1812
1800
  {g.items.map((t) => {
1813
1801
  const next = nextDeskOf(t.stage);
1814
1802
  const menuItems: ActionMenuItem[] = [
@@ -1828,19 +1816,21 @@ export function TplRecord() {
1828
1816
  />
1829
1817
  }
1830
1818
  trailing={
1831
- <InlineMemberSelect
1832
- background="transparent"
1833
- members={TEAM}
1834
- value={t.assignee}
1835
- onSave={(m) => assignTask(t.id, m)}
1836
- placeholder="Assign…"
1837
- accessibilityLabel={`Assignee · ${t.label}`}
1838
- />
1819
+ // Due + assignee as COMPACT grid cells (`variant="cell"`): the due a
1820
+ // urgency-coloured date (no calendar glyph), the assignee a bare AVATAR
1821
+ // (`avatarOnly` — no name/chevron, a dashed add-ghost when unset). Both
1822
+ // hug within the list's `trailingWidth` so the title never overlaps.
1823
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
1824
+ <View style={{ width: 104 }}>
1825
+ <InlineDatePicker variant="cell" tone={dueTone(t.due, t.done)} value={t.due || null} onSave={(v) => dueTask(t.id, v)} onClear={() => dueTask(t.id, "")} placeholder="Due…" locale="en-US" accessibilityLabel={`Due · ${t.label}`} />
1826
+ </View>
1827
+ <InlineMemberSelect variant="cell" avatarOnly members={TEAM} value={t.assignee} onSave={(m) => assignTask(t.id, m)} accessibilityLabel={`Assignee · ${t.label}`} />
1828
+ </View>
1839
1829
  }
1840
1830
  menu={{ items: menuItems, accessibilityLabel: `Task options: ${t.label}` }}
1841
1831
  >
1842
1832
  <InlineTextInput
1843
- background="transparent"
1833
+ variant="cell"
1844
1834
  value={t.label}
1845
1835
  onSave={(v) => renameTask(t.id, v)}
1846
1836
  struck={t.done}
@@ -1965,50 +1955,28 @@ export function TplRecord() {
1965
1955
  </SectionHeading>
1966
1956
  {customer ? (
1967
1957
  <View style={{ gap: 10 }}>
1968
- {/* the LINKED-record REFERENCE IMPLEMENTATION (no wrapper
1969
- component; copy and customize): a BORDERED box scoping the
1970
- other record's data identity, then the facts stacked
1971
- VERTICALLY (legible at any width, never squeezed), the verbs
1972
- visible inside (destructive LEFT in danger, go-to RIGHT).
1973
- The WHOLE box presses open the detail drawer — built on
1974
- `PressableRow` (role-less surface, hover wash spans nested
1975
- controls) + the `BoxDoor` sibling: NEVER role="button" on a
1976
- container with interactive descendants (invalid HTML). */}
1977
- <PressableRow
1978
- onPress={() => setCustomerOpen(true)}
1979
- style={{ flexDirection: "column", alignItems: "stretch", borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, paddingHorizontal: 16, paddingVertical: 16, gap: 12 }}
1980
- >
1981
- <BoxDoor label={`${customer.name} — details`} onPress={() => setCustomerOpen(true)} />
1982
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
1983
- <View style={{ width: 34, height: 34, borderRadius: 8, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
1984
- <Icon name="building-2" size={17} color={colors.zinc[600]} />
1985
- </View>
1986
- <View style={{ flex: 1, minWidth: 0 }}>
1987
- <Text size="sm" weight="medium" numberOfLines={1}>{customer.name}</Text>
1988
- <Text size="xs" color="muted" tabular>{customer.code}</Text>
1989
- </View>
1990
- </View>
1991
- <View style={{ gap: 6 }}>
1992
- {[
1993
- { label: "Tax ID", value: customer.taxId || "—" },
1994
- { label: "Contact", value: customer.contact || "—" },
1995
- { label: "City", value: customer.city || "—" },
1996
- ].map((f) => (
1997
- <View key={f.label} style={{ flexDirection: "row", gap: 10 }}>
1998
- <Text size="sm" color="muted" style={{ width: 80 }}>{f.label}</Text>
1999
- <Text size="sm" style={{ flex: 1 }}>{f.value}</Text>
2000
- </View>
2001
- ))}
2002
- </View>
2003
- {/* the action row, INSIDE the box: destructive on the LEFT,
2004
- the go-to verb on the RIGHT (the box itself = details);
2005
- zIndex lifts the verbs above the door's hit area */}
2006
- <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8, zIndex: 1 }}>
2007
- <Button title="Remove" color="danger" onPress={() => setCustomerId(null)} />
2008
- <View style={{ flex: 1 }} />
2009
- <Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the customer's page */ }} />
2010
- </View>
2011
- </PressableRow>
1958
+ {/* The linked-record REFERENCE: the whole box is the keyboard door to the
1959
+ customer's detail (the a11y contract lives in `LinkedRecordBox`); the verbs
1960
+ ride insidedestructive Remove LEFT, go-to Open record RIGHT. */}
1961
+ <LinkedRecordBox
1962
+ icon="building-2"
1963
+ name={customer.name}
1964
+ subtitle={customer.code}
1965
+ facts={[
1966
+ { label: "Tax ID", value: customer.taxId || "—" },
1967
+ { label: "Contact", value: customer.contact || "—" },
1968
+ { label: "City", value: customer.city || "—" },
1969
+ ]}
1970
+ doorLabel={`${customer.name} — details`}
1971
+ onOpen={() => setCustomerOpen(true)}
1972
+ actions={
1973
+ <>
1974
+ <Button title="Remove" color="danger" onPress={() => setCustomerId(null)} />
1975
+ <View style={{ flex: 1 }} />
1976
+ <Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the customer's page */ }} />
1977
+ </>
1978
+ }
1979
+ />
2012
1980
  {/* invalid STATE is a Callout CO-LOCATED with what it describes —
2013
1981
  it lives on the customer card, not floating at a section top */}
2014
1982
  {!taxIdValid ? (
@@ -2022,7 +1990,17 @@ export function TplRecord() {
2022
1990
  <Combobox
2023
1991
  options={customerOptions}
2024
1992
  onValueChange={onPickCustomer}
2025
- getOptionDescription={(o) => (o.data ? `${o.data.code} · ${o.data.city}` : undefined)}
1993
+ renderOptionContent={(o) => (
1994
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
1995
+ <View style={{ width: 28, height: 28, borderRadius: 7, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
1996
+ <Icon name="building-2" size={14} color={colors.zinc[600]} />
1997
+ </View>
1998
+ <View style={{ flex: 1, minWidth: 0 }}>
1999
+ <Text size="sm" weight="medium" numberOfLines={1}>{o.label}</Text>
2000
+ {o.data ? <Text size="xs" color="muted">{[o.data.city, o.data.taxId ? `Tax ID ${o.data.taxId}` : "No tax ID"].filter(Boolean).join(" · ")}</Text> : null}
2001
+ </View>
2002
+ </View>
2003
+ )}
2026
2004
  reflectSelection={false}
2027
2005
  allowCustom
2028
2006
  customOptionPlacement="top"
@@ -2200,32 +2178,27 @@ export function TplRecord() {
2200
2178
  alignment drifts. controlWidth 24 = CheckboxInput. */}
2201
2179
  <Checklist controlWidth={24}>
2202
2180
  {shown.map((f) => {
2203
- // READINESS per row, DISCOVERABLE: every not-ready row
2204
- // shows its mark (what's missing — warning ink once
2205
- // checked, because it now blocks) AND the explicit
2206
- // trigger beside the label a colored TextLink (an
2207
- // inline row action; a Button here outweighs the row)
2208
- // toggling the TRANSIENT fill editor in the row's
2209
- // `expansion`. Save commits onto the record fields
2210
- // (their home rows show the values) and the panel
2211
- // collapses resolved; mark + trigger vanish = ready. A
2212
- // ready form stays SILENT. Checking never expands
2213
- // anything by itself.
2181
+ // READINESS per row, CO-LOCATED: a not-ready row's mark NAMES what's
2182
+ // missing (muted at restdiscoverable without checking; warning once
2183
+ // checked, it now blocks), and the FIX sits right under it in the
2184
+ // `expansion` an `Inset` (a FORM surface, never a Callout, which is an
2185
+ // ARIA alert) with the editor for each gap, saved onto the record in
2186
+ // place. Drafts are keyed BY FIELD, so a field two checked forms share
2187
+ // is entered once and one Save resolves both. A ready form stays SILENT.
2214
2188
  const missing = missingOf(f);
2215
2189
  const checked = chosenForms.has(f.id);
2216
- const filling = missing.length > 0 && openFill.has(f.id);
2217
2190
  return (
2218
2191
  <ChecklistRow
2219
2192
  key={f.id}
2220
2193
  control={<CheckboxInput accessibilityLabel={f.label} checked={checked} onChange={(on) => toggleForm(f.id, on)} />}
2221
- meta={missing.length > 0 && !filling ? (
2222
- <View style={{ flexDirection: "row", alignItems: "center", gap: 5 }}>
2223
- <Icon name="circle-alert" size={12} color={colors.amber[500]} />
2224
- <Text size="xs" color={checked ? "warning" : "muted"}>{`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")}`}</Text>
2194
+ meta={missing.length > 0 ? (
2195
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 5 }}>
2196
+ <Icon name="circle-alert" size={13} color={checked ? colors.amber[500] : colors.zinc[400]} />
2197
+ <Text size="xs" color={checked ? "warning" : "muted"} style={{ flexShrink: 1 }}>{`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")}`}</Text>
2225
2198
  </View>
2226
2199
  ) : undefined}
2227
- expansion={filling ? (
2228
- <View style={{ backgroundColor: colors.zinc[50], borderRadius: 10, padding: 14, gap: 12, maxWidth: 460 }}>
2200
+ expansion={checked && missing.length > 0 ? (
2201
+ <Inset>
2229
2202
  <Text size="xs" color="muted">These values save onto the order and unlock the form.</Text>
2230
2203
  {missing.map((k) => (
2231
2204
  <FormTextInput
@@ -2237,31 +2210,18 @@ export function TplRecord() {
2237
2210
  accessibilityLabel={NEEDS[k].label}
2238
2211
  />
2239
2212
  ))}
2240
- <View style={{ flexDirection: "row", justifyContent: "flex-end", gap: 8 }}>
2241
- {/* secondary — the section keeps ONE primary
2242
- (Create); disabled while a draft is empty */}
2243
- <Button title="Cancel" color="muted" onPress={() => toggleFillOpen(f.id)} />
2213
+ <View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
2244
2214
  <Button
2245
2215
  title="Save fields"
2246
2216
  color="secondary"
2247
- disabled={missing.some((k) => !(fillDrafts[k] ?? "").trim())}
2248
- onPress={() => { saveNeeds(missing); toggleFillOpen(f.id); }}
2217
+ disabled={missing.every((k) => !(fillDrafts[k] ?? "").trim())}
2218
+ onPress={() => saveNeeds(missing)}
2249
2219
  />
2250
2220
  </View>
2251
- </View>
2221
+ </Inset>
2252
2222
  ) : undefined}
2253
2223
  >
2254
- <View style={{ flexDirection: "row", alignItems: "center", columnGap: 12, rowGap: 2, flexWrap: "wrap" }}>
2255
- <Text size="sm" style={{ flexShrink: 1 }}>{f.label}</Text>
2256
- {/* the trigger hides while ITS panel is open —
2257
- the panel's Cancel is the closer; a toggle
2258
- verb above an open editor is noise */}
2259
- {missing.length > 0 && !filling ? (
2260
- <TextLink size="xs" onPress={() => toggleFillOpen(f.id)} style={{ color: colors.blue[600] }}>
2261
- Add missing fields
2262
- </TextLink>
2263
- ) : null}
2264
- </View>
2224
+ <Text size="sm" style={{ flexShrink: 1 }}>{f.label}</Text>
2265
2225
  </ChecklistRow>
2266
2226
  );
2267
2227
  })}
@@ -2291,11 +2251,10 @@ export function TplRecord() {
2291
2251
  </Subsection>
2292
2252
  <Subsection>
2293
2253
  <View style={{ gap: 12 }}>
2294
- {/* the CONSEQUENCE, co-located, once (the gating law's
2295
- degraded-but-valid case Create stays ENABLED and the
2296
- press confirms; the repair lives in the rows' fill
2297
- panels above). An empty pick stays silently disabled —
2298
- self-evident. */}
2254
+ {/* the CONSEQUENCE, co-located, once the gating law's degraded-but-valid
2255
+ case (Create stays ENABLED, missing fields print blank, the press
2256
+ confirms); the REPAIR lives co-located in each not-ready row's fill above.
2257
+ An empty pick stays silently disabled — self-evident. */}
2299
2258
  {blockingNeeds.length > 0 ? (
2300
2259
  <Callout tone="warning">
2301
2260
  <CalloutText>
@@ -2443,41 +2402,25 @@ export function TplRecord() {
2443
2402
  {siblings.length > 0 ? (
2444
2403
  <View style={{ gap: 16 }}>
2445
2404
  {siblings.map((sb) => (
2446
- <PressableRow
2405
+ <LinkedRecordBox
2447
2406
  key={sb.id}
2448
- onPress={() => setSiblingOpen(sb.id)}
2449
- style={{ flexDirection: "column", alignItems: "stretch", borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, paddingHorizontal: 16, paddingVertical: 16, gap: 12 }}
2450
- >
2451
- <BoxDoor label={`${sb.code} — details`} onPress={() => setSiblingOpen(sb.id)} />
2452
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
2453
- <View style={{ width: 34, height: 34, borderRadius: 8, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
2454
- <Icon name="file-text" size={17} color={colors.zinc[600]} />
2455
- </View>
2456
- <View style={{ flex: 1, minWidth: 0 }}>
2457
- <Text size="sm" weight="medium" numberOfLines={1}>{sb.code}</Text>
2458
- <Text size="xs" color="muted">{`${sb.desk} record`}</Text>
2459
- </View>
2460
- </View>
2461
- <View style={{ gap: 6 }}>
2462
- {[
2463
- { label: "Assignee", value: sb.assignee || "—" },
2464
- { label: "Created", value: sb.at },
2465
- { label: "Note", value: sb.note || "—" },
2466
- ].map((f) => (
2467
- <View key={f.label} style={{ flexDirection: "row", gap: 10 }}>
2468
- <Text size="sm" color="muted" style={{ width: 80 }}>{f.label}</Text>
2469
- <Text size="sm" style={{ flex: 1 }}>{f.value}</Text>
2470
- </View>
2471
- ))}
2472
- </View>
2473
- {/* the action row, INSIDE the box, the go-to verb on the
2474
- RIGHT (the box itself = details; nothing destructive
2475
- here — the handoff mark stays); zIndex lifts it above
2476
- the door's hit area */}
2477
- <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8, justifyContent: "flex-end", zIndex: 1 }}>
2478
- <Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the sibling's page */ }} />
2479
- </View>
2480
- </PressableRow>
2407
+ icon="file-text"
2408
+ name={sb.code}
2409
+ subtitle={`${sb.desk} record`}
2410
+ facts={[
2411
+ { label: "Assignee", value: sb.assignee || "" },
2412
+ { label: "Created", value: sb.at },
2413
+ { label: "Note", value: sb.note || "—" },
2414
+ ]}
2415
+ doorLabel={`${sb.code} details`}
2416
+ onOpen={() => setSiblingOpen(sb.id)}
2417
+ actions={
2418
+ <>
2419
+ <View style={{ flex: 1 }} />
2420
+ <Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the sibling's page */ }} />
2421
+ </>
2422
+ }
2423
+ />
2481
2424
  ))}
2482
2425
  </View>
2483
2426
  ) : null}
@@ -16,7 +16,6 @@ import { InlineTextInput } from "@lotics/ui/inline_text_input";
16
16
  import { InlineMemberSelect } from "@lotics/ui/inline_member_select";
17
17
  import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
18
18
  import { InlineSelect } from "@lotics/ui/inline_select";
19
- import { Select } from "@lotics/ui/select";
20
19
  import { TextInputField } from "@lotics/ui/text_input_field";
21
20
  import { ActionMenu } from "@lotics/ui/action_menu";
22
21
  import { Alert } from "@lotics/ui/alert";
@@ -25,7 +24,7 @@ import { Popover, PopoverTrigger, PopoverContent } from "@lotics/ui/popover";
25
24
  import { FilesEditor } from "@lotics/ui/files_editor";
26
25
  import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
27
26
  import { DataGrid, gridRowStyle, type DataGridColumn, type DataGridGroup } from "@lotics/ui/data_grid";
28
- import { CONTROL_RADIUS, HOVER_BORDER } from "@lotics/ui/control_surface";
27
+ import { CONTROL_RADIUS } from "@lotics/ui/control_surface";
29
28
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
30
29
  import { Composer } from "@lotics/ui/composer";
31
30
  import { AgentRun, type AgentRunItem } from "@lotics/ui/agent_run";
@@ -265,10 +264,10 @@ export function TplTaskBoard() {
265
264
  }
266
265
 
267
266
  const propertyCols: DataGridColumn<Task>[] = [
268
- { key: "assignee", label: "Assignee", width: 160, sortable: true, cell: (t) => <InlineMemberSelect background="transparent" members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} onClear={() => patch(t.id, { ownerId: null })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
269
- { key: "due", label: "Due", width: 140, sortable: true, cell: (t) => <InlineDatePicker background="transparent" value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} onClear={() => patch(t.id, { due: null })} placeholder="No date" accessibilityLabel="Due date" /> },
270
- { key: "status", label: "Status", width: 130, sortable: true, cell: (t) => <InlineSelect background="transparent" value={t.status} options={STATUS_OPTIONS} onSave={(s) => patch(t.id, { status: s })} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" /> },
271
- { key: "tags", label: "Tags", width: 168, cell: (t) => <Select multi searchable allowCustom value={t.tags.map((tag) => tag.value)} onValueChange={(next) => patch(t.id, { tags: next.map(tagOf) })} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} style={{ borderColor: "transparent" }} placeholder="Add tags" accessibilityLabel="Tags" /> },
267
+ { key: "assignee", label: "Assignee", width: 160, sortable: true, cell: (t) => <InlineMemberSelect variant="cell" members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} onClear={() => patch(t.id, { ownerId: null })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
268
+ { key: "due", label: "Due", width: 140, sortable: true, cell: (t) => <InlineDatePicker variant="cell" value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} onClear={() => patch(t.id, { due: null })} placeholder="No date" accessibilityLabel="Due date" /> },
269
+ { key: "status", label: "Status", width: 130, sortable: true, cell: (t) => <InlineSelect variant="cell" value={t.status} options={STATUS_OPTIONS} onSave={(s) => patch(t.id, { status: s })} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" /> },
270
+ { key: "tags", label: "Tags", width: 168, cell: (t) => <InlineSelect multi searchable allowCustom variant="cell" value={t.tags.map((tag) => tag.value)} onSave={(next) => patch(t.id, { tags: next.map(tagOf) })} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} placeholder="Add tags" accessibilityLabel="Tags" /> },
272
271
  ];
273
272
  const filesCol: DataGridColumn<Task> = {
274
273
  key: "files", label: "Files", width: 100, sortable: false,
@@ -295,7 +294,7 @@ export function TplTaskBoard() {
295
294
  const visibleProps = propertyCols.filter((c) => c.key !== groupBy);
296
295
  const addCols = [...visibleProps, filesCol, actionCol, menuCol];
297
296
  const columns: DataGridColumn<Task>[] = [
298
- { key: "title", label: "Task", sortable: true, cell: (t) => <InlineTextInput background="transparent" value={t.title} onSave={(v) => patch(t.id, { title: v })} struck={t.status === "done"} accessibilityLabel="Title" /> },
297
+ { key: "title", label: "Task", sortable: true, cell: (t) => <InlineTextInput variant="cell" value={t.title} onSave={(v) => patch(t.id, { title: v })} struck={t.status === "done"} accessibilityLabel="Title" /> },
299
298
  ...addCols,
300
299
  ];
301
300
 
@@ -436,12 +435,12 @@ function TaskProposal({ proposal, onEdit }: { proposal: Proposal; onEdit: (id: s
436
435
  <View style={{ gap: 10 }}>
437
436
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
438
437
  <View style={{ flex: 1 }}>
439
- <InlineTextInput background="transparent" value={proposal.title} onSave={(v) => onEdit(proposal.id, { title: v })} accessibilityLabel="Task title" />
438
+ <InlineTextInput variant="cell" value={proposal.title} onSave={(v) => onEdit(proposal.id, { title: v })} accessibilityLabel="Task title" />
440
439
  </View>
441
440
  <View style={{ paddingTop: 10 }}><Confidence level={proposal.confidence} /></View>
442
441
  </View>
443
442
  <View style={{ width: 220 }}>
444
- <InlineDatePicker background="transparent" value={proposal.due} optionalTime onSave={(v) => onEdit(proposal.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" />
443
+ <InlineDatePicker variant="cell" value={proposal.due} optionalTime onSave={(v) => onEdit(proposal.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" />
445
444
  </View>
446
445
  <View style={styles.source}><Text size="xs" color="muted">{proposal.source}</Text></View>
447
446
  </View>
@@ -528,10 +527,10 @@ function TaskAddRow({ preset, columns, onAdd }: { preset: Partial<Task>; columns
528
527
  // Files / action / ⋯ act on a task that already exists, so the add row leaves
529
528
  // those columns empty — the widths are still reserved so the columns stay aligned.
530
529
  const editor = (key: string) =>
531
- key === "assignee" ? <InlineMemberSelect background="transparent" members={MEMBERS} value={ownerId} onSave={setOwnerId} placeholder="Assignee" accessibilityLabel="Assignee" />
532
- : key === "due" ? <InlineDatePicker background="transparent" value={due} optionalTime onSave={setDue} placeholder="No date" accessibilityLabel="Due date" />
533
- : key === "status" ? <InlineSelect background="transparent" value={status} options={STATUS_OPTIONS} onSave={setStatus} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" />
534
- : key === "tags" ? <Select multi searchable allowCustom value={tags.map((tag) => tag.value)} onValueChange={(next) => setTags(next.map(tagOf))} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} style={{ borderColor: "transparent" }} placeholder="Add tags" accessibilityLabel="Tags" />
530
+ key === "assignee" ? <InlineMemberSelect variant="cell" members={MEMBERS} value={ownerId} onSave={setOwnerId} placeholder="Assignee" accessibilityLabel="Assignee" />
531
+ : key === "due" ? <InlineDatePicker variant="cell" value={due} optionalTime onSave={setDue} placeholder="No date" accessibilityLabel="Due date" />
532
+ : key === "status" ? <InlineSelect variant="cell" value={status} options={STATUS_OPTIONS} onSave={setStatus} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" />
533
+ : key === "tags" ? <InlineSelect multi searchable allowCustom variant="cell" value={tags.map((tag) => tag.value)} onSave={(next) => setTags(next.map(tagOf))} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} placeholder="Add tags" accessibilityLabel="Tags" />
535
534
  : null;
536
535
 
537
536
  return (
@@ -556,18 +555,17 @@ const styles = StyleSheet.create({
556
555
  ring: { width: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: colors.zinc[300], borderStyle: "dashed" },
557
556
  input: { borderWidth: 0, backgroundColor: "transparent" },
558
557
  addField: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8, paddingRight: 4 },
559
- // Match the inline-editor cells' resting box (INLINE_CONTROL_HEIGHT = 40) so the
560
- // files field lines up with the others and that height affords a larger thumbnail.
561
- // Hover = the control-surface BORDER reveal, the same language as the
562
- // inline-editor cells beside these never a background wash of its own.
563
- clip: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS, borderWidth: 1, borderColor: "transparent", cursor: "pointer" },
564
- clipHover: { borderColor: HOVER_BORDER },
558
+ // Match the CELL height (40) so the files field lines up with the others — and that
559
+ // height affords a larger thumbnail. Hover = the background-tint WASH (zinc-100), the
560
+ // standard CELL language (same as the variant="cell" editors beside these).
561
+ clip: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS, cursor: "pointer" },
562
+ clipHover: { backgroundColor: colors.zinc[100] },
565
563
  clipPreview: { flexDirection: "row", alignItems: "center", gap: 6 },
566
564
  summary: { flexDirection: "row", alignItems: "center", gap: 4 },
567
565
  summaryDot: { width: 8, height: 8, borderRadius: 999 },
568
566
  result: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40 },
569
- // Fills the cell (height + width) so the link's hit area matches the inline cells.
570
- action: { justifyContent: "center", minHeight: 40, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS, borderWidth: 1, borderColor: "transparent", cursor: "pointer" },
571
- actionHover: { borderColor: HOVER_BORDER },
567
+ // Fills the cell (height + width) so the link's hit area matches the cells; wash hover.
568
+ action: { justifyContent: "center", minHeight: 40, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS, cursor: "pointer" },
569
+ actionHover: { backgroundColor: colors.zinc[100] },
572
570
  source: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
573
571
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "12.1.1",
3
+ "version": "13.7.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -36,7 +36,6 @@
36
36
  "./use_selection": "./src/use_selection.ts",
37
37
  "./use_section_nav": "./src/use_section_nav.ts",
38
38
  "./ledger": "./src/ledger.tsx",
39
- "./inline_tag_select": "./src/inline_tag_select.tsx",
40
39
  "./use_selection_mode": "./src/use_selection_mode.ts",
41
40
  "./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
42
41
  "./uploading_thumbnail": "./src/uploading_thumbnail.tsx",
@@ -120,6 +119,8 @@
120
119
  "./card_select_item": "./src/card_select_item.tsx",
121
120
  "./badge": "./src/badge.tsx",
122
121
  "./callout": "./src/callout.tsx",
122
+ "./inset": "./src/inset.tsx",
123
+ "./linked_record_box": "./src/linked_record_box.tsx",
123
124
  "./inline_edit": "./src/inline_edit.tsx",
124
125
  "./inline_static": "./src/inline_static.tsx",
125
126
  "./inline_text_input": "./src/inline_text_input.tsx",