@lotics/ui 47.4.1 → 47.5.1

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 CHANGED
@@ -14,11 +14,11 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
14
14
 
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
- | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
17
+ | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — incl. `just_arrived` (`useJustArrived` + `TableRow`'s `justArrived`: the one-shot wash a row wears when it LANDS, so a realtime push is visible rather than one row of two hundred silently changing) — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
18
  | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW, the `FilesEditor` COMPOUND for a whole section, plus the three-way file INTAKE), stage gates, and the commit-on-blur vs action-press ordering law. |
19
19
  | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values; findings, provenance, confidence; **after the run** — a stored record that fills up from several writers; the whole run in a dialog, **stopping**; **review surfaces compose from atoms** — `DiffValue`, `DiffMark`, `useChangeSet`. |
20
- | [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), **a width floor answers can this be READ and never can this be operated** (the operating question passes a row whose every string is already cut), **a status column earns its slot by carrying a REASON rather than a state** (the floor asks for the fact to be visible, not for a badge), **a row subject's supporting line takes a REFERENCE and never a sentence** (an explanation truncates to the clause the reader already knew), and where a commit sits. |
21
- | [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then the probes, each naming what to COLLECT and the SIGNATURE in the numbers — including the truncation sweep (a row whose halves both measure fine and whose text is cut) the summary-vs-facet check (a strip that keeps describing the unfiltered set) and the derivable-column test (a status badge its neighbour already answers). Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
20
+ | [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), **a width floor answers can this be READ and never can this be operated** (the operating question passes a row whose every string is already cut), **a status column earns its slot by carrying a REASON rather than a state** (the floor asks for the fact to be visible, not for a badge), **a row subject's supporting line takes a REFERENCE and never a sentence** (an explanation truncates to the clause the reader already knew), **a list stays fast by keeping IDENTITY rather than memoizing** (key by the record not the index; `memo` is the last resort and does nothing against a freshly-built prop), and where a commit sits. |
21
+ | [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then the probes, each naming what to COLLECT and the SIGNATURE in the numbers — including the truncation sweep (a row whose halves both measure fine and whose text is cut) the summary-vs-facet check (a strip that keeps describing the unfiltered set) and the derivable-column test (a status badge its neighbour already answers) and the cross-FRAME check (what the screen destroys when a row arrives — the one defect that exists in no single frame). Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
22
22
  | [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
23
23
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
24
24
 
package/docs/catalog.md CHANGED
@@ -298,6 +298,15 @@ by size.)
298
298
  ordinary case, not an exotic one; compose a hand-designed
299
299
  card pile (`PressableRow` + your own hierarchy) over `useScreenSize` only when a screen
300
300
  deserves a better mobile shape than the automatic stack.
301
+ **A row that LANDS while someone is looking takes `justArrived`**, paired with
302
+ `useJustArrived(ids, scope)` from `@lotics/ui/just_arrived` — the push that delivers it is
303
+ otherwise invisible, because the list is one row longer and nothing says which one. Pass the
304
+ hook's set, never a hand-rolled diff: it owns the two rules that make the naive version
305
+ unusable — a FIRST render is not an arrival (or a register opening on two hundred rows flashes
306
+ all of them), and a `scope` change reseeds silently (or picking a status flashes every row the
307
+ filter swapped in). WRONG PLACE: it is not a way to mark a row you want the reader to notice —
308
+ that is `marked`, or a badge in a cell. The question that decides it is *did this row exist a
309
+ moment ago*; if the answer does not change on its own, this is not the prop.
301
310
  - **Inline-managed grouped table** (MODERATE — hundreds, low-thousands — you MANAGE in view)
302
311
  — the `DataGrid` primitive: a grouped, sortable grid whose cells are LIVE inline editors
303
312
  (ANY field — a column is `{ key, label, width?, sortable?, cell: (item) => ReactNode }`,
@@ -2294,7 +2303,13 @@ component rather than showing it at zero.
2294
2303
  filename; pass `accessibilityLabel` to say what pressing it DOES instead. The per-surface
2295
2304
  pieces are exported for a hand-rolled layout: `DocumentBadge` (a bare pressable badge —
2296
2305
  its `size` is the square SLOT side, like every other tile here, and the badge is fitted to
2297
- it), `DocumentCard`, `MediaCard`, `RemoveButton`.
2306
+ it), `DocumentCard`, `MediaCard`, `RemoveButton`. **`toDisplayFile(cell)` is how a file
2307
+ reaches any of them** — it takes a `FileCell` (a record read's `files` entry: `id`,
2308
+ `filename`, `mime_type`, `url`, optional `thumbnail_url`) and returns the `DisplayFile`
2309
+ every component here expects. Reach for it rather than writing the five-line mapping: the
2310
+ hand-written one is where a `mime_type`/`mimeType` slip becomes a tile that silently
2311
+ renders as a generic document. It returns a fresh object per call by design — see
2312
+ composition.md §"A list stays fast" for why an identity cache is the wrong answer.
2298
2313
  - **`file_thumbnail_grid`** — **`diffs`** (a `ReadonlyMap<string, DiffKind>`, keyed like
2299
2314
  `selectedIds`) marks each tile, so a grid under review is the SAME grid with one more map
2300
2315
  passed in rather than a different component the operator has never seen.
@@ -2083,6 +2083,35 @@ most common, because it survives every other check: each piece is defensible alo
2083
2083
  question "what does this tell me that the screen does not already say" catches decoration wearing
2084
2084
  an information costume.
2085
2085
 
2086
+ ## A list stays fast by keeping IDENTITY, not by memoizing
2087
+
2088
+ The expensive thing a screen can do is not re-render — it is REPLACE. Re-running a
2089
+ component produces no DOM work at all when the output matches; React's diff absorbs it. A
2090
+ 15-row register with 58 pictures, re-rendered against equal data, writes **nothing**. The
2091
+ same register with one row inserted, when identity is lost, writes **271 mutations and
2092
+ rebuilds 89 nodes** — and every rebuilt `Image` starts empty and paints blank until a
2093
+ frame later, which is the flicker people describe as "the images reload".
2094
+
2095
+ So the rule is about identity:
2096
+
2097
+ - **Key a row by the record, never by its position.** `key={i}` is correct only for a list
2098
+ that cannot reorder or grow. The moment something can be inserted, an index key hands
2099
+ every row a different slot and React rebuilds all of them. This applies to any wrapper
2100
+ you put around a mapped child too — a container keyed by index destroys the identity its
2101
+ child was carrying.
2102
+ - **Don't define a component inside another component's render.** Its type changes every
2103
+ render, so its whole subtree remounts.
2104
+ - **Don't swap a list for a placeholder on refetch.** `loading ? <Skeleton/> : <Rows/>`
2105
+ belongs to the FIRST load only — gate it on having no rows yet, never on a request being
2106
+ in flight, or every background refresh unmounts the content and remounts it.
2107
+
2108
+ **Reach for `memo` last, and only with a measurement.** It prevents a re-render, which is
2109
+ the cheap half, and it silently does nothing when a prop is a fresh object each render —
2110
+ which is the normal case, since rows come from a `useMemo` over query results. Where a list
2111
+ is genuinely large enough to hurt, window the rows; that removes the work rather than
2112
+ comparing it. `toDisplayFile` returns a new object per call for exactly this reason: an
2113
+ identity cache would exist to enable a memo that measurement does not justify.
2114
+
2086
2115
  ## `accessibilityState` never reaches the DOM — write `aria-*` yourself
2087
2116
 
2088
2117
  React Native Web maps a subset of RN's accessibility props and **silently drops
package/docs/reviewing.md CHANGED
@@ -595,6 +595,34 @@ a second copy free to disagree — check it against the FIELD, not against itsel
595
595
 
596
596
  → [data_entry.md](./data_entry.md) §"A picker over a select FIELD keeps the field's colours".
597
597
 
598
+ ### 9c. What the screen does when the data MOVES — the cross-frame check
599
+ Every probe above reads ONE rendered frame. This defect does not exist in a frame: the screen is
600
+ correct before, correct after, and destroys itself in between. A register whose rows are correct
601
+ either side of an insert can still have rebuilt every row to get there, and each rebuilt picture
602
+ paints EMPTY for a frame before it repaints — which is what people report as "the images reload".
603
+
604
+ **Collect:** stamp the nodes, move the data, count the survivors.
605
+
606
+ ```js
607
+ document.querySelectorAll("img").forEach((el, i) => { el.__s = i; });
608
+ // …now insert a row / reorder / let a refetch land…
609
+ const imgs = [...document.querySelectorAll("img")];
610
+ ({ total: imgs.length, survived: imgs.filter((e) => e.__s !== undefined).length })
611
+ ```
612
+
613
+ **The signature** is survivors far below the rows that were already there — a row that only MOVED
614
+ should keep its node. Only genuinely new rows may be fresh. A `MutationObserver` gives the same
615
+ answer as a magnitude: a re-render against equal data should write NOTHING, and one insert should
616
+ write about one row's worth.
617
+
618
+ Two causes, both invisible to every other probe. A container keying its wrapped children by
619
+ POSITION overrides the caller's correct keys, so an insert shifts every child into a different slot
620
+ and React rebuilds all of them — the caller cannot see it or fix it from outside. And a region
621
+ gated on a REQUEST (`loading ? <Spinner/> : <Rows/>`) rather than on having nothing yet unmounts
622
+ its whole subtree on each background refresh.
623
+
624
+ → [composition.md](./composition.md) §"A list stays fast by keeping IDENTITY, not by memoizing".
625
+
598
626
  ### 10. Microcopy
599
627
  - **No punctuation doing a word's job.** ` · ` is banned outright — it claims a relationship while
600
628
  refusing to name it, and it is the loudest templated-metadata tell there is. If a mark cannot be
package/docs/testing.md CHANGED
@@ -70,3 +70,26 @@ and the window are the right ones. From the dragged element,
70
70
  Then check **both** halves: re-snapshot for the optimistic move, and re-read the
71
71
  record to confirm the mutation actually persisted. An optimistic move that never
72
72
  reached the server looks identical on screen.
73
+
74
+ ## Assert what an update KEEPS, not only what it renders
75
+
76
+ A test that reads the output passes either way when a list rebuilds itself: the same rows are on
77
+ screen, so nothing about the text is wrong. What changed is that every node was replaced — and a
78
+ replaced `Image` paints empty before it repaints.
79
+
80
+ Hold the node and compare it after the update:
81
+
82
+ ```tsx
83
+ const { rerender, getByTestId } = render(board(["a", "b"]));
84
+ const before = getByTestId("b");
85
+ rerender(board(["new", "a", "b"]));
86
+ expect(getByTestId("b")).toBe(before); // MOVED, not rebuilt
87
+ ```
88
+
89
+ State the budget as DOM WRITES, never as a render count. Re-running a component is close to free —
90
+ React's diff absorbs it — so counting renders fails a healthy tree and pushes the next author into
91
+ memoising leaves that cost nothing. A `MutationObserver` around a `rerender` with equal data should
92
+ see zero. And assert the opposite too: an insert MUST write, or a green suite only proves the
93
+ observer was blind.
94
+
95
+ → [composition.md](./composition.md) §"A list stays fast by keeping IDENTITY, not by memoizing".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "47.4.1",
3
+ "version": "47.5.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
@@ -159,6 +159,7 @@
159
159
  "./inset": "./src/inset.tsx",
160
160
  "./interaction_modality": "./src/interaction_modality.ts",
161
161
  "./json_panel": "./src/json_panel.tsx",
162
+ "./just_arrived": "./src/just_arrived.ts",
162
163
  "./keyboard": "./src/keyboard.ts",
163
164
  "./kpi_card": "./src/kpi_card.tsx",
164
165
  "./kpi_strip": "./src/kpi_strip.tsx",
@@ -72,6 +72,47 @@ export interface DisplayFile {
72
72
  previewUrl?: string;
73
73
  }
74
74
 
75
+ /**
76
+ * The wire shape of a file cell — what a record read hands back, before it is
77
+ * anything a component can render.
78
+ *
79
+ * Structural, not imported: the kit stays free of platform packages, and this
80
+ * names only the five fields the conversion reads.
81
+ */
82
+ export interface FileCell {
83
+ id: string;
84
+ filename: string;
85
+ mime_type: string;
86
+ url: string;
87
+ thumbnail_url?: string;
88
+ }
89
+
90
+ /**
91
+ * A file cell → the shape every display component here takes.
92
+ *
93
+ * It lives in the kit because it was living in every app instead: each one
94
+ * re-declared the same five-line mapping, so a field added here reached none of
95
+ * them and a `mime_type`/`mimeType` slip was a per-app bug. One definition, on
96
+ * the side that owns the type.
97
+ *
98
+ * It returns a NEW object per call, deliberately. An identity cache here would
99
+ * let callers memoize on reference, but a re-render that changes nothing
100
+ * already writes nothing to the DOM (`table_render_budget.test.tsx` pins it),
101
+ * so the cache would buy a fraction of a cheap operation and cost a map that
102
+ * grows for the life of the tab — presigned urls rotate, so its keys never stop
103
+ * accumulating. If a list ever outgrows that, the answer is windowing the rows,
104
+ * not remembering the objects.
105
+ */
106
+ export function toDisplayFile(f: FileCell): DisplayFile {
107
+ return {
108
+ id: f.id,
109
+ filename: f.filename,
110
+ mimeType: f.mime_type,
111
+ url: f.url,
112
+ thumbnailUrl: f.thumbnail_url,
113
+ };
114
+ }
115
+
75
116
  // =============================================================================
76
117
  // FileThumbnail - Main display component
77
118
  // =============================================================================
@@ -485,6 +526,11 @@ function ImageThumbnail(props: ImageThumbnailProps) {
485
526
  const a11yLabel = props.accessibilityLabel ?? file.filename;
486
527
  const [useFallback, setUseFallback] = useState(false);
487
528
  const { focusVisible, focusProps } = useFocusRing();
529
+ // STABLE, not an inline arrow. `onError` sits in react-native-web's image
530
+ // load effect's dependency array, so a fresh callback each render tears the
531
+ // pending request down and issues a new `Image()` — re-fetching a picture
532
+ // that never changed, on every render of the row it sits in.
533
+ const handleError = useCallback(() => setUseFallback(true), []);
488
534
 
489
535
  // Use thumbnail if available, fallback to full URL
490
536
  const url = !useFallback && file.thumbnailUrl ? file.thumbnailUrl : file.url;
@@ -502,7 +548,7 @@ function ImageThumbnail(props: ImageThumbnailProps) {
502
548
  source={{ uri: url }}
503
549
  style={styles.image}
504
550
  resizeMode="cover"
505
- onError={() => setUseFallback(true)}
551
+ onError={handleError}
506
552
  />
507
553
  <TileScrim hovered={(state as { hovered?: boolean }).hovered} pressed={state.pressed} />
508
554
  </>
@@ -68,7 +68,12 @@ export function ImageGallery({
68
68
  // Per-image rotation, shared with the zoom modal so a turn survives opening it.
69
69
  const rotation = useImageRotation();
70
70
 
71
- if (loading) return <ActivityIndicator />;
71
+ // FIRST load only. Gating the spinner on the request rather than on having
72
+ // nothing to show swaps the whole gallery for it on every revalidation —
73
+ // unmounting each <Image>, which then remounts empty and repaints. A caller
74
+ // passing `loading={isValidating}` is doing the ordinary thing; the component
75
+ // has to survive it.
76
+ if (loading && images.length === 0) return <ActivityIndicator />;
72
77
  if (images.length === 0) return <Text size="sm" color="muted">{emptyText ?? locale.imageGallery.empty}</Text>;
73
78
 
74
79
  const idx = Math.min(selected, images.length - 1);
@@ -0,0 +1,70 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ /** Shared so an unchanged result keeps its identity and re-renders nothing. */
4
+ const EMPTY: ReadonlySet<string> = new Set<string>();
5
+
6
+ /**
7
+ * Which ids are new, given what was on screen before — the rule, without React.
8
+ *
9
+ * `before` is `null` when nothing has been seen yet, and that case is the whole
10
+ * reason this is a named function: **a first render is never an arrival.** A
11
+ * register opening on two hundred rows has two hundred ids it has not seen, and
12
+ * the naive diff flashes all of them — both wrong (nothing arrived, the page
13
+ * loaded) and the moment a highlight is least wanted.
14
+ *
15
+ * A removal reports nothing. A row leaving is not a row arriving, and the two
16
+ * share a diff only if you write it carelessly.
17
+ */
18
+ export function arrivalsSince(
19
+ before: ReadonlySet<string> | null,
20
+ ids: readonly string[],
21
+ ): string[] {
22
+ if (before === null) return [];
23
+ return ids.filter((id) => !before.has(id));
24
+ }
25
+
26
+ /**
27
+ * Which of these ids are NEW since the last render — the set a register passes
28
+ * to `TableRow`'s `justArrived`.
29
+ *
30
+ * A row landing while someone is looking at the screen is the case the realtime
31
+ * push exists for, and until something marks it the push is invisible: the list
32
+ * is one row longer and nothing says which one. Every register wants the same
33
+ * diff, so it lives here rather than in each app.
34
+ *
35
+ * **`scope` is what the ids are a list OF** — the filter, the search, the page.
36
+ * Without it a register cannot tell twelve rows ARRIVING from a filter change
37
+ * that replaced the list, because both are a large diff, and it would flash the
38
+ * whole page every time someone picked a status. Change the scope and the
39
+ * baseline reseeds silently: nothing arrived, the reader asked a different
40
+ * question. Pass a string that changes exactly when the QUESTION does.
41
+ *
42
+ * An EMPTY render never seeds the baseline either: a list rendering `[]` while
43
+ * its query is in flight would otherwise make its real first page look like an
44
+ * arrival.
45
+ *
46
+ * Ids leave the set on the next render that carries them, so the highlight is a
47
+ * one-shot — re-rendering never restarts an animation that already played.
48
+ */
49
+ export function useJustArrived(ids: readonly string[], scope = ""): ReadonlySet<string> {
50
+ const seen = useRef<Set<string> | null>(null);
51
+ const seenScope = useRef(scope);
52
+ const [fresh, setFresh] = useState<ReadonlySet<string>>(EMPTY);
53
+
54
+ // Keyed on the ids' CONTENT: an array literal is a new reference every render,
55
+ // so depending on the array itself would re-run this forever.
56
+ const key = ids.join(",");
57
+
58
+ useEffect(() => {
59
+ if (ids.length === 0) return;
60
+ const sameQuestion = seenScope.current === scope;
61
+ seenScope.current = scope;
62
+ const added = arrivalsSince(sameQuestion ? seen.current : null, ids);
63
+ seen.current = new Set(ids);
64
+ setFresh(added.length > 0 ? new Set(added) : EMPTY);
65
+ // `ids` is covered by `key`, which is its content rather than its identity.
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ }, [key, scope]);
68
+
69
+ return fresh;
70
+ }
package/src/list.tsx CHANGED
@@ -15,7 +15,10 @@ export function List(props: ListProps) {
15
15
  return (
16
16
  <View style={style}>
17
17
  {childArray.map((child, index) => (
18
- <React.Fragment key={index}>
18
+ // The child's own key, never its position: a filtered list changes
19
+ // membership on every keystroke, and an index key rebuilds every
20
+ // surviving row (see composition.md §"A list stays fast").
21
+ <React.Fragment key={React.isValidElement(child) ? child.key : index}>
19
22
  {child}
20
23
  {index < childArray.length - 1 && <Divider />}
21
24
  </React.Fragment>
@@ -1,4 +1,4 @@
1
- import { Children, Fragment } from "react";
1
+ import { Children, Fragment, isValidElement } from "react";
2
2
  import { View, type StyleProp, type ViewStyle } from "react-native";
3
3
  import { Divider } from "./divider";
4
4
 
@@ -17,9 +17,11 @@ function DividedStack({ children, divided = true, gap, style }: StackProps & { g
17
17
  return (
18
18
  <View style={[{ gap }, style]}>
19
19
  {blocks.map((block, i) => (
20
- // Children.toArray keys are stable for conditional lists; the fragment
21
- // pairs each block with the hairline that separates it from the last.
22
- <Fragment key={`sec_${i}`}>
20
+ // Children.toArray keys are stable for conditional lists so USE
21
+ // them. `toArray` drops nulls, so a conditional section appearing
22
+ // shifts every later index and an index key would remount all of
23
+ // them, including any field mid-edit.
24
+ <Fragment key={isValidElement(block) ? block.key : `sec_${i}`}>
23
25
  {divided && i > 0 ? <Divider /> : null}
24
26
  {block}
25
27
  </Fragment>
package/src/sequence.tsx CHANGED
@@ -62,7 +62,11 @@ export function Sequence({ children }: SequenceProps) {
62
62
  // which the rail runs through.
63
63
  <View>
64
64
  {items.map((child, index) => (
65
- <SequenceItemContext.Provider key={index} value={{ index, count: items.length }}>
65
+ // The child's own key, never its position. This list REORDERS by
66
+ // design, and a Provider keyed by index makes "move up" tear both
67
+ // items down — losing the text a person was part-way through typing
68
+ // in them (see composition.md §"A list stays fast").
69
+ <SequenceItemContext.Provider key={child.key ?? index} value={{ index, count: items.length }}>
66
70
  {child}
67
71
  </SequenceItemContext.Provider>
68
72
  ))}
package/src/stack.tsx CHANGED
@@ -28,7 +28,10 @@ export function Stack(props: StackProps) {
28
28
  children,
29
29
  (child, index) =>
30
30
  child && (
31
- <View key={index}>
31
+ // NO key: `Children.map` already composes the child's own key
32
+ // onto what it returns. Adding one here overrides that with the
33
+ // position and remounts the list whenever it changes.
34
+ <View>
32
35
  {child}
33
36
  {useSeparator && index < React.Children.count(children) - 1 && (
34
37
  <Separator padding={separatorPadding} />
package/src/table.tsx CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  createContext,
3
3
  useContext,
4
+ useEffect,
5
+ useRef,
4
6
  useState,
5
7
  Children,
6
8
  cloneElement,
@@ -8,7 +10,7 @@ import {
8
10
  type ReactNode,
9
11
  type ReactElement,
10
12
  } from "react";
11
- import { StyleSheet, View, type ViewStyle } from "react-native";
13
+ import { Animated, StyleSheet, View, type ViewStyle } from "react-native";
12
14
  import { Text } from "./text";
13
15
  import { colors, solid, type ColorName } from "./colors";
14
16
  import { PressableRow } from "./pressable_row";
@@ -16,6 +18,13 @@ import { PressDoor } from "./press_door";
16
18
  import { DetailRow } from "./detail_row";
17
19
  import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
18
20
  import { COLUMN_GAP, ROW_GUTTER, ROW_HEIGHT, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
21
+
22
+ /** How long an arrival wash sits at full strength before fading, and how long
23
+ * the fade takes. Together they are the window a reader has to notice the row —
24
+ * short enough that a busy register does not shimmer, long enough to survive a
25
+ * glance away. */
26
+ const ARRIVAL_HOLD_MS = 900;
27
+ const ARRIVAL_FADE_MS = 700;
19
28
  import { ROW_WASH_BLEED } from "./control_surface";
20
29
 
21
30
  /**
@@ -272,11 +281,15 @@ export function Table(props: TableProps) {
272
281
  </View>
273
282
  )}
274
283
  <View style={styles.body}>
275
- {rows.map((row, i) => {
276
- return (
277
- <View key={i}>{row}</View>
278
- );
279
- })}
284
+ {/* Keyed by the ROW's own key, never its index. `Children.toArray`
285
+ derives these from the caller's `key`, so they track the record
286
+ rather than the position. Keyed by index, inserting a row at the
287
+ top shifts every row into a different wrapper: React then sees a
288
+ new child key inside each one and remounts the whole list, which
289
+ destroys every `<Image>` and repaints the register blank. */}
290
+ {rows.map((row, i) => (
291
+ <View key={row.key ?? i}>{row}</View>
292
+ ))}
280
293
  </View>
281
294
  </View>
282
295
  </TableContext.Provider>
@@ -347,8 +360,9 @@ export function TableGroup(props: TableGroupProps) {
347
360
  ) : null}
348
361
  </View>
349
362
  <View style={styles.body}>
363
+ {/* The row's own key, for the reason given in the wide variant above. */}
350
364
  {rows.map((row, i) => (
351
- <View key={i}>{row}</View>
365
+ <View key={row.key ?? i}>{row}</View>
352
366
  ))}
353
367
  </View>
354
368
  </View>
@@ -359,6 +373,21 @@ export type TableRowProps = {
359
373
  selected?: boolean;
360
374
  /** Part of a multi-select set — a resting blue tint (the open/hover wash overrides it). */
361
375
  marked?: boolean;
376
+ /**
377
+ * This row LANDED — it was not here a moment ago. Plays a wash that fades out
378
+ * once, so a reader looking at the register when the push arrives can see
379
+ * which row is the new one.
380
+ *
381
+ * Pair it with `useJustArrived`, which owns the diff and the rule that a first
382
+ * render is never an arrival. Passing it per-row from a hand-rolled diff is
383
+ * how a register ends up flashing all two hundred rows on load.
384
+ *
385
+ * It is an OVERLAY, not a fifth interaction state: `backgroundColor` on this
386
+ * row already has one owner (hover / selected / marked / pressed), and a
387
+ * second writer would make the two agree only by luck. So an arrival composes
388
+ * with all of them — a new row you are hovering shows both.
389
+ */
390
+ justArrived?: boolean;
362
391
  /** Outside the door, BEFORE the cells (a selection checkbox) — width = Table `leading`. */
363
392
  leading?: ReactNode;
364
393
  /**
@@ -452,8 +481,46 @@ export type TableRowProps = {
452
481
  * the remaining cells as label-over-value blocks — same door, same wash, same
453
482
  * slots, different geometry.
454
483
  */
484
+ /**
485
+ * The one-shot wash a row wears when it has just landed.
486
+ *
487
+ * OPACITY, never `backgroundColor` — that property belongs to the row's four
488
+ * interaction states, and a second writer would make them agree only by accident
489
+ * (see `pressable_row`). Fading an overlay composes with all four instead, and
490
+ * opacity is what `useNativeDriver` can animate off the JS thread, which matters
491
+ * on the surface most likely to be rendering two hundred rows.
492
+ *
493
+ * `accent_wash` is the token the kit reserves for ATTENTION — a drop target
494
+ * lighting up — as distinct from the neutral grounds that say which record is
495
+ * open. A row that arrived while you were looking at it is exactly that.
496
+ *
497
+ * It renders nothing until it has something to say, so an ordinary register pays
498
+ * no extra view per row, and it never takes a press.
499
+ */
500
+ function ArrivalWash({ on }: { on: boolean }) {
501
+ const fade = useRef(new Animated.Value(0)).current;
502
+ const [live, setLive] = useState(false);
503
+
504
+ useEffect(() => {
505
+ if (!on) return;
506
+ setLive(true);
507
+ fade.setValue(1);
508
+ // Held, THEN faded: a wash that begins mid-fade reads as a rendering
509
+ // artefact rather than as something arriving.
510
+ Animated.sequence([
511
+ Animated.delay(ARRIVAL_HOLD_MS),
512
+ Animated.timing(fade, { toValue: 0, duration: ARRIVAL_FADE_MS, useNativeDriver: true }),
513
+ ]).start(({ finished }) => {
514
+ if (finished) setLive(false);
515
+ });
516
+ }, [on, fade]);
517
+
518
+ if (!live) return null;
519
+ return <Animated.View pointerEvents="none" style={[styles.arrival, { opacity: fade }]} />;
520
+ }
521
+
455
522
  export function TableRow(props: TableRowProps) {
456
- const { onPress, selected, marked, accessibilityLabel, leading, ordinal, trailing, action, minHeight, detail, expanded, children } = props;
523
+ const { onPress, selected, marked, justArrived, accessibilityLabel, leading, ordinal, trailing, action, minHeight, detail, expanded, children } = props;
457
524
  const ctx = useContext(TableContext);
458
525
  if (!ctx) throw new Error("TableRow must be used within a Table");
459
526
  // Read AFTER the context check so an airy register does not need every row to
@@ -522,6 +589,7 @@ export function TableRow(props: TableRowProps) {
522
589
 
523
590
  const rowSurface = (
524
591
  <PressableRow onPress={onPress} selected={selected || showDetail} marked={marked} style={styles.row}>
592
+ <ArrivalWash on={justArrived === true} />
525
593
  {!ctx.stacked ? <LeadGutter ordinal={ordinal}>{leading}</LeadGutter> : null}
526
594
  {/* The door hit-tests above in-flow content; the cells/slots lift back above it
527
595
  via zIndex 1 (see `PressDoor`), so the door gets only the keyboard. Radius
@@ -597,6 +665,15 @@ export function TableCell(props: TableCellProps) {
597
665
  }
598
666
 
599
667
  const styles = StyleSheet.create({
668
+ arrival: {
669
+ position: "absolute",
670
+ top: 0,
671
+ left: 0,
672
+ right: 0,
673
+ bottom: 0,
674
+ borderRadius: 12,
675
+ backgroundColor: colors.accent_wash,
676
+ },
600
677
  // The expanded detail. Indented to the identity column's text edge so it reads
601
678
  // as belonging to the row above rather than as a new row, and given the row's
602
679
  // own gutter so its content lines up with the cells it explains.