@lotics/ui 46.0.1 → 46.1.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.
@@ -186,6 +186,19 @@ row the screen copied into `useState` (`app-sdk` `docs/mutations.md` § A read m
186
186
  an in-flight write) — as does a write started by something other than an inline commit (a
187
187
  dialog save, a debounced autosave).
188
188
 
189
+ **A field counts its own committed value as current until `value` catches up.** A caller updates
190
+ its stored copy only when the write RETURNS, so for the length of that round trip the `value` prop
191
+ still reads PRE-EDIT. Anything that re-opens the field in that window — a keyboard focus restore,
192
+ a second tap, a click into the neighbouring control — must not re-arm from that stale prop, because
193
+ the next blur would commit it as a fresh edit and the field would silently UNDO the edit it just
194
+ saved. `useInlineEdit` holds what it last sent and treats that as the current value for both the
195
+ re-arm and the "unchanged?" test, so a trailing commit of an already-sent value is a no-op; it also
196
+ renders that value (`edit.current`), so the old number never flashes back mid-write. The held copy
197
+ is dropped the moment `value` moves — whether it echoes the edit back or takes a different value
198
+ entirely — so it can never mask a change the field did not make. Nothing to wire up. This is not
199
+ theoretical: re-arming from the stale prop put a fee back to its pre-edit amount ~3s after an
200
+ operator corrected it, and the irreversible document minted seconds later billed the wrong figure.
201
+
189
202
  ### Keyboard entry — type → Tab → type
190
203
 
191
204
  Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a closed
package/docs/templates.md CHANGED
@@ -87,6 +87,20 @@ intended behaviour when the job calls for one — the line between a choice and
87
87
  [composition.md](./composition.md) §"The form comes before the treatment", and it is drawn there
88
88
  for this too.
89
89
 
90
+ **A surface that is a FILTERED VIEW of another surface is not a separate subject.** The same test
91
+ that separates a tab from a lens decides whether a screen should exist at all, one altitude up: if
92
+ its rows are another screen's rows with a condition applied, it is a lens on that screen and belongs
93
+ in its toolbar. The tell is a screen that reads as disconnected while every part of it looks
94
+ correct — a costs screen whose rows are the bank ledger filtered to outflows, an "overdue" screen
95
+ whose rows are the register filtered by a date. Each one duplicates a subject and then drifts from
96
+ it, because two surfaces over one set of rows grow two ways of writing to them.
97
+
98
+ **And nothing should answer a question another surface already owns.** A register that starts
99
+ computing the P&L is building a second answer to a question the dashboard is for — the two will
100
+ disagree the first time either changes, and the reader has no way to know which is right. Where a
101
+ screen genuinely needs a figure that lives elsewhere, it states the figure and points; it does not
102
+ re-derive it.
103
+
90
104
  ### App-screen shapes
91
105
 
92
106
  **What earns a tab.** A band is a TAB when it changes what the screen IS — its columns, the act on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "46.0.1",
3
+ "version": "46.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -6,8 +6,19 @@ import { Svg, Path } from "react-native-svg";
6
6
  * takes a generic `Icon` — a phone call, an in-person event, a website, an
7
7
  * email that is not tied to one provider. Inventing a mark for those is how a
8
8
  * byline ends up claiming a channel the record does not carry.
9
+ *
10
+ * A CHANNEL earns a mark; a PRODUCT does not. The line is whether a business
11
+ * reaches people through it — a post, a message, a campaign — or merely uses it
12
+ * to work. So the ad platforms belong here beside the social surfaces, while a
13
+ * mailbox or a drive does not, and the two are easy to confuse because the same
14
+ * company often sells both.
15
+ *
16
+ * Every mark is a single-path CC0 silhouette taken from simple-icons, with that
17
+ * project's own hex. Never hand-write one: a recalled path renders something
18
+ * plausible and slightly wrong, which is the one defect a reader spots instantly
19
+ * and a test suite never does.
9
20
  */
10
- export const BRAND_NAMES = ["facebook", "zalo", "linkedin", "x", "google-meet"] as const;
21
+ export const BRAND_NAMES = ["facebook", "meta", "tiktok", "google-ads", "zalo", "linkedin", "x", "google-meet"] as const;
11
22
 
12
23
  export type BrandName = (typeof BRAND_NAMES)[number];
13
24
 
@@ -39,6 +50,25 @@ interface Mark {
39
50
  * in (`SwitchButton`'s `leading` slot).
40
51
  */
41
52
  const MARKS: Record<BrandName, Mark> = {
53
+ /** The company and its ad platform. `facebook` is the consumer surface a post goes
54
+ * on; `meta` is what bills the ad spend, and a row about money names the
55
+ * platform that took it. */
56
+ "meta": {
57
+ color: "#0467DF",
58
+ path: "M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z",
59
+ },
60
+ /** Short video, and for a Vietnamese seller increasingly the channel that outsells
61
+ * the one they budget for. */
62
+ "tiktok": {
63
+ color: "#000000",
64
+ path: "M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z",
65
+ },
66
+ /** Search and shopping ads. Distinct from `google-meet`, which is a meeting rather
67
+ * than a spend. */
68
+ "google-ads": {
69
+ color: "#4285F4",
70
+ path: "M3.9998 22.9291C1.7908 22.9291 0 21.1383 0 18.9293s1.7908-3.9998 3.9998-3.9998 3.9998 1.7908 3.9998 3.9998-1.7908 3.9998-3.9998 3.9998zm19.4643-6.0004L15.4632 3.072C14.3586 1.1587 11.9121.5028 9.9988 1.6074S7.4295 5.1585 8.5341 7.0718l8.0009 13.8567c1.1046 1.9133 3.5511 2.5679 5.4644 1.4646 1.9134-1.1046 2.568-3.5511 1.4647-5.4644zM7.5137 4.8438L1.5645 15.1484A4.5 4.5 0 0 1 4 14.4297c2.5597-.0075 4.6248 2.1585 4.4941 4.7148l3.2168-5.5723-3.6094-6.25c-.4499-.7793-.6322-1.6394-.5878-2.4784z",
71
+ },
42
72
  facebook: {
43
73
  color: "#0866FF",
44
74
  path: "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z",
@@ -37,6 +37,29 @@ export function useInlineEdit<T>(opts: {
37
37
  const [draft, setDraftState] = useState<T>(value);
38
38
  const [saving, setSaving] = useState(false);
39
39
  const [error, setError] = useState<string | null>(null);
40
+ const eq = useCallback((a: T, b: T) => (equals ? equals(a, b) : a === b), [equals]);
41
+
42
+ // What this field last handed to `onSave`, held until `value` catches up to it.
43
+ //
44
+ // `value` is the caller's stored copy, and a caller does not update it until its
45
+ // write returns — so for the length of that round trip the prop still reads the
46
+ // PRE-EDIT value. Anything that re-opens the field in that window (a focus
47
+ // restore, a second tap) would otherwise seed the draft from a value the user
48
+ // has already replaced, and the next blur would commit it as if it were a fresh
49
+ // edit: the field silently undoes the edit it just saved. In an app where the
50
+ // field holds an amount that is about to be invoiced, that writes the wrong
51
+ // number to a document nobody can re-issue.
52
+ //
53
+ // `before` is what `value` read at commit time. The moment `value` differs from
54
+ // it the parent has moved on — whether by echoing this edit back or by taking a
55
+ // different value from somewhere else — and the pending copy is dropped rather
56
+ // than masking a change the field did not make.
57
+ const pending = useRef<{ committed: T; before: T } | null>(null);
58
+ const held = pending.current;
59
+ const awaitingEcho = held !== null && eq(held.before, value);
60
+ if (held !== null && !awaitingEcho) pending.current = null;
61
+ /** The value the field must behave as if it holds — see `pending`. */
62
+ const current = awaitingEcho && held !== null ? held.committed : value;
40
63
  // Synchronous mirror of `editing`. The first exit (commit or cancel) flips it
41
64
  // false; any trailing call — e.g. the blur that fires as Escape unmounts the
42
65
  // input — is then a no-op, so the field commits/cancels exactly once.
@@ -58,11 +81,11 @@ export function useInlineEdit<T>(opts: {
58
81
  }, []);
59
82
 
60
83
  const begin = useCallback(() => {
61
- setDraftState(value);
84
+ setDraftState(current);
62
85
  setError(null);
63
86
  active.current = true;
64
87
  setEditing(true);
65
- }, [value]);
88
+ }, [current]);
66
89
 
67
90
  const cancel = useCallback(() => {
68
91
  if (!active.current) return;
@@ -75,8 +98,9 @@ export function useInlineEdit<T>(opts: {
75
98
  async (next?: T) => {
76
99
  if (!active.current) return;
77
100
  const candidate = next === undefined ? draft : next;
78
- const unchanged = equals ? equals(candidate, value) : candidate === value;
79
- if (unchanged) {
101
+ // Against `current`, not `value`: re-committing what this field already
102
+ // saved is a no-op, not an edit back to the stored copy.
103
+ if (eq(candidate, current)) {
80
104
  active.current = false;
81
105
  setEditing(false);
82
106
  return;
@@ -84,6 +108,7 @@ export function useInlineEdit<T>(opts: {
84
108
  active.current = false;
85
109
  setSaving(true);
86
110
  setError(null);
111
+ pending.current = { committed: candidate, before: value };
87
112
  try {
88
113
  // Registered so an action pressed in the SAME gesture (the press that caused
89
114
  // this blur) waits for the write instead of reading the pre-edit record —
@@ -92,6 +117,9 @@ export function useInlineEdit<T>(opts: {
92
117
  await trackCommit((async () => onSave(candidate))());
93
118
  setEditing(false);
94
119
  } catch (e) {
120
+ // Nothing was stored, so there is no echo to wait for — drop the pending
121
+ // copy or the field would keep reporting a value the caller never took.
122
+ pending.current = null;
95
123
  // Stay in edit mode so the entry isn't lost — show the error, re-arm.
96
124
  setError(e instanceof Error && e.message ? e.message : labels.saveError);
97
125
  active.current = true;
@@ -99,10 +127,13 @@ export function useInlineEdit<T>(opts: {
99
127
  setSaving(false);
100
128
  }
101
129
  },
102
- [draft, value, onSave, equals],
130
+ [draft, value, current, onSave, eq, labels.saveError],
103
131
  );
104
132
 
105
- return { editing, draft, setDraft, saving, error, begin, cancel, commit };
133
+ // `current` is what the field must SHOW as well as what it re-arms from: while a
134
+ // save is in flight the caller's `value` still reads pre-edit, and rendering that
135
+ // flashes the old number back at the operator mid-write.
136
+ return { editing, draft, setDraft, saving, error, begin, cancel, commit, current };
106
137
  }
107
138
 
108
139
  /**
@@ -57,7 +57,7 @@ export function InlineMarkdown(props: InlineMarkdownProps) {
57
57
  // ...but the ✓/✕ verbs still key off a REAL edit: at rest there is nothing
58
58
  // to accept and nothing to revert.
59
59
  editOpen={edit.editing}
60
- display={value}
60
+ display={edit.current}
61
61
  placeholder={placeholder}
62
62
  onBegin={edit.begin}
63
63
  controls="blur"
@@ -45,7 +45,9 @@ export function InlineNumberInput(props: InlineNumberInputProps) {
45
45
  void edit.commit();
46
46
  }, [controls, edit]);
47
47
 
48
- const display = format ? format(value) : value == null ? "" : String(value);
48
+ // `edit.current` rather than `value`: while a save is in flight the caller's
49
+ // copy still reads pre-edit, and showing that flashes the old number back.
50
+ const display = format ? format(edit.current) : edit.current == null ? "" : String(edit.current);
49
51
 
50
52
  return (
51
53
  <InlineEditFrame
@@ -158,7 +158,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
158
158
  // accept and nothing to revert, so rendering them would both claim
159
159
  // otherwise and hand the user two buttons that no-op.
160
160
  editOpen={edit.editing}
161
- display={value}
161
+ display={edit.current}
162
162
  placeholder={placeholder}
163
163
  onBegin={edit.begin}
164
164
  controls={controls}