@lotics/ui 27.9.0 → 27.10.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 CHANGED
@@ -42,8 +42,10 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
42
42
  `PipelineStage`, over `stepper`) — worked in `examples/tpl_record.tsx` § Progress, which it took
43
43
  over FROM a per-desk checklist. Rendering positions as a checklist forces every row to carry
44
44
  every control and never says where the record sits; rendering work items as a pipeline implies
45
- an order that is not there. A long ladder whose stages each own one value goes dense with
46
- `PipelineStage.trailing` rather than a hand-rolled title row — see `docs/catalog.md`.
45
+ an order that is not there. A ladder is TICKED `PipelineStage.onToggle` makes the marker the
46
+ completion control (never a checkbox beside it), and the stamped date rides the title row via
47
+ `trailing` as a `DateStamp`: muted text, no frame, click to correct. One ladder per record,
48
+ never a handoff run beside a milestone run — see `docs/catalog.md`.
47
49
  - **`Badge` = STATUS only; supporting detail is the muted second line.** A type / category /
48
50
  attribute / count is not a status — it belongs under its identity as `size="xs" color="muted"`,
49
51
  never a second chip. A chip beside a name reads as its PEER (a colored one reads louder),
package/docs/catalog.md CHANGED
@@ -1003,6 +1003,15 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1003
1003
 
1004
1004
  ### Tasks & checklists
1005
1005
 
1006
+ - **`date_stamp`** — `DateStamp`: a recorded date shown QUIETLY and correctable — small muted
1007
+ text at rest, no frame, no icon, no control band; underlined on hover; the press opens the
1008
+ kit's `DatePickerPanel`. For a date the SYSTEM writes and a person only occasionally
1009
+ overrides (a milestone ticked, a status flipped). NOT `InlineDatePicker` with its surface
1010
+ removed: that is a form FIELD — calendar icon, control band, typed `DateField`, em-dash when
1011
+ empty — and a ladder of fields is a form with invisible borders. Reach for `InlineDatePicker`
1012
+ when a person is expected to ENTER the date, and this when the system already knows it.
1013
+ Empty renders a thin rule, not a placeholder; read-only (omit `onChange`) renders text alone.
1014
+ Worked screen: `examples/tpl_record.tsx` § Progress.
1006
1015
  - **`check_circle`** — `CheckCircle`: the completion ring — `state="none" | "partial" | "done"`,
1007
1016
  springing to a filled check when done, distinct from the square checkbox; the
1008
1017
  task/to-do/checklist control. `partial` half-fills it, for the row that is underway but not
@@ -1261,7 +1270,24 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1261
1270
  one rhythm whatever the value is. An ACT still belongs in `PipelineActions`.
1262
1271
  Worked screen: `examples/tpl_record.tsx` § Progress — the desks (Sales → Operations →
1263
1272
  Accounting) ARE the stages, each owning the facts it stamps and, on the live one, the act
1264
- that leaves it. A `PipelineStage` is deliberately NOT pressable: its body holds the controls.
1273
+ that leaves it. A `PipelineStage` body is deliberately NOT pressable: it holds the controls.
1274
+ **`PipelineStage.onToggle`** is the one exception and lives on the MARKER — the node becomes a
1275
+ pressable `CheckCircle` instead of a status dot, for a ladder the reader TICKS OFF rather than
1276
+ only reads. Reach for it before putting a checkbox in `trailing`: that lands a second completion
1277
+ affordance beside a ring that already looks like one, and the reader has to guess which is real.
1278
+ **Which of the two shapes you want**: if finishing a step must capture anything beyond "it
1279
+ happened" — who took it, a reason — it is a HANDOFF, and the act belongs in `PipelineActions`
1280
+ with the marker only reporting. If it does not, it is a MILESTONE and the marker IS the act.
1281
+ ONE ladder per record, never two. A rung that needs more than a tick (a dialog, an owner, a
1282
+ reason) renders it in its BODY — the tick and the act coexist on the same stage, which is what
1283
+ a real pipeline looks like. Splitting them into a handoff run and a milestone run puts "where
1284
+ is this" in two places the reader has to watch together. Worked screen:
1285
+ `examples/tpl_record.tsx` § Progress — tickable markers, the stamped date on the title row as a
1286
+ `bare` `InlineDatePicker` (reads as text, border on hover, click to edit), and the live stage
1287
+ still carrying its owner, its note and the act that leaves it. The date stays EDITABLE on
1288
+ purpose: a tick always writes today, so inert text would make a ladder filled in after the
1289
+ fact — every imported record — impossible to enter correctly. An unstamped rung shows no
1290
+ placeholder; its empty ring already says "not yet".
1265
1291
  - **`stepper`** — `Stepper` + `Step` — done/current/upcoming/warning/complete progress on a
1266
1292
  track (horizontal) or spine (vertical); compound `<Step status>children` OR data
1267
1293
  `steps[]`+`current`; **navigable** via `Step.onPress` (both orientations — the whole step
@@ -1,5 +1,5 @@
1
1
  import { Fragment, useEffect, useRef, useState } from "react";
2
- import { ScrollView, View } from "react-native";
2
+ import { Pressable, 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";
@@ -16,6 +16,9 @@ import { Inset } from "@lotics/ui/inset";
16
16
  import { Section, SectionHeading, SectionHeadingMeta, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
17
17
  import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
18
18
  import { Pipeline, PipelineActions, PipelineField, PipelineNote, PipelineStage } from "@lotics/ui/pipeline";
19
+ import { DateStamp } from "@lotics/ui/date_stamp";
20
+ import { TextLink } from "@lotics/ui/text_link";
21
+ import { MemberChip } from "@lotics/ui/member_chip";
19
22
  import { TaskCaption, TaskDetail, TaskItem, TaskList, TaskStatus, TaskTitle } from "@lotics/ui/task";
20
23
  import { MenuButton } from "@lotics/ui/menu_button";
21
24
  import { Popover, PopoverContent } from "@lotics/ui/popover";
@@ -158,12 +161,36 @@ const DESKS: { key: Desk; label: string }[] = [
158
161
  { key: "operations", label: "Operations" },
159
162
  { key: "accounting", label: "Accounting" },
160
163
  ];
164
+ /**
165
+ * The PROGRESS ladder. Not the same list as `DESKS`: a desk is a place the record
166
+ * SITS and owns facts (who took it, the handoff that leaves it), while most of a
167
+ * run is milestones that own nothing at all — they happened, on a day, and that
168
+ * is the whole record. A ladder built only from desks hides the majority of what
169
+ * a reader wants to see, and one that gives every step an owner field asks four
170
+ * questions nobody has answers to.
171
+ *
172
+ * `desk` marks the steps that carry facts and the handoff act. The rest are a
173
+ * tick and a date.
174
+ */
175
+ const STEPS: { key: string; label: string; desk?: Desk }[] = [
176
+ { key: "sales", label: "Sales", desk: "sales" },
177
+ { key: "quoted", label: "Quote sent" },
178
+ { key: "operations", label: "Operations", desk: "operations" },
179
+ { key: "collected", label: "Collected" },
180
+ { key: "cleared", label: "Customs cleared" },
181
+ { key: "delivered", label: "Delivered" },
182
+ { key: "accounting", label: "Accounting", desk: "accounting" },
183
+ { key: "pod", label: "POD received" },
184
+ ];
185
+
161
186
  const STAGES: { key: Stage; label: string }[] = [
162
187
  ...DESKS,
163
188
  { key: "closed", label: "Closed" },
164
189
  ];
165
190
  const stageOf = (st: Stage) => STAGES.find((x) => x.key === st) ?? STAGES[0];
166
191
 
192
+
193
+
167
194
  // How long the live desk has held the record — the PipelineStage meta line.
168
195
  // Prose, derived: the date itself is the stage's own editable field.
169
196
  function heldFor(since: string): string | undefined {
@@ -1257,8 +1284,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1257
1284
  // Each desk owns two facts about its own custody: when it took the record and
1258
1285
  // who holds it. Stored per desk so a passed desk keeps them editable — a date
1259
1286
  // typed wrong must not become unreachable the moment the record moves on.
1260
- const [deskSince, setDeskSince] = useState<Record<Desk, string>>({ sales: "2026-06-15", operations: "", accounting: "" });
1261
- const [deskOwner, setDeskOwner] = useState<Record<Desk, string | null>>({ sales: "mem_01", operations: null, accounting: null });
1287
+ const [deskSince, setDeskSince] = useState<Record<string, string>>({ sales: "2026-06-15" });
1288
+ // Which passed stage has been opened for editing. A stage BEHIND the live one
1289
+ // shows its facts and nothing else — the ladder is read far more often than it
1290
+ // is corrected, and a column of live editors on history invites changing it by
1291
+ // accident. "Edit" is the deliberate step that turns one stage back into a form.
1292
+ //
1293
+ // ONE key, so opening a second stage closes the first. That is the intent, not a
1294
+ // limitation: the editors commit as you leave them, so nothing is lost by the
1295
+ // close, and two open stages would put two versions of the same ladder on screen
1296
+ // — one being read, one being changed — with no cue for which is which.
1297
+ const [editingDesk, setEditingDesk] = useState("");
1298
+ const [deskOwner, setDeskOwner] = useState<Record<string, string | null>>({ sales: "mem_01" });
1262
1299
 
1263
1300
  const customerOptions: PickerOption<string, Customer>[] = customers.map((c) => ({
1264
1301
  value: c.id,
@@ -1461,6 +1498,8 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1461
1498
  { key: "receipt", label: "Delivery receipt", icon: "log-in" },
1462
1499
  { key: "danger", label: "Danger zone", icon: "circle-alert" },
1463
1500
  ] as const;
1501
+ // Kept in step with `SECTIONS` above by hand — a key added there and forgotten
1502
+ // here type-errors at every `nav.register` call, which is the cheap failure.
1464
1503
  const nav = useSectionNav(["general", "files", "transport", "progress", "fees", "billing", "docset", "receipt", "danger"] as const);
1465
1504
  // ONE record, ONE page. A section is a place you SCROLL to, never a
1466
1505
  // destination you swap to: routing a record was tried here and lost, because
@@ -2321,31 +2360,126 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2321
2360
  <SectionHeadingTitle description="One desk owns the record at a time. The live desk carries the handoff.">Progress</SectionHeadingTitle>
2322
2361
  </SectionHeading>
2323
2362
  <Pipeline accessibilityLabel="Desk handoff progress">
2324
- {DESKS.map((d, i) => {
2325
- const status: "done" | "current" | "upcoming" = i < deskIndex ? "done" : i === deskIndex ? "current" : "upcoming";
2363
+ {/* The ladder reads its own STAMPS, and nothing else. Driving `status`
2364
+ off the record's `stage` while the tick wrote a date left two
2365
+ sources of truth for one position: the ring could never change
2366
+ state, so pressing it did nothing visible — no fill, no spring, no
2367
+ movement — which is what "the progress is broken" looks like.
2368
+ A rung is done when IT carries a date, independently of its
2369
+ neighbours, so any rung can be ticked and un-ticked on its own. */}
2370
+ {STEPS.map((d, i) => {
2371
+ const stampedAt = deskSince[d.key] ?? "";
2372
+ const firstOpen = STEPS.findIndex((x) => !deskSince[x.key]);
2373
+ const status: "done" | "current" | "upcoming" = stampedAt ? "done" : i === firstOpen ? "current" : "upcoming";
2326
2374
  const owner = deskOwner[d.key];
2327
2375
  return (
2328
2376
  <PipelineStage
2329
2377
  key={d.key}
2330
2378
  status={status}
2331
2379
  title={d.label}
2332
- // Prose the reader can't set — how long it has sat here. The DATE
2333
- // is the field below; repeating it up here would say it twice.
2334
- meta={status === "current" ? heldFor(deskSince[d.key]) : undefined}
2380
+ // Prose the reader can't set — how long it has sat here.
2381
+ meta={status === "current" ? heldFor(stampedAt) : undefined}
2382
+ // The tick IS the stamp: pressing the ring records today, pressing
2383
+ // it again clears the stage. ANY ring takes a press (`Pipeline`'s
2384
+ // default) because this ladder is a RECORD — entering three stages
2385
+ // that already happened must not cost three presses plus three
2386
+ // date corrections.
2387
+ onToggle={(next) => {
2388
+ if (next) {
2389
+ // Ticking AHEAD fills the gap. A ladder is a run, not a set
2390
+ // of independent boxes: leaving 3 and 4 blank because someone
2391
+ // ticked 5 renders "done, blank, blank, done", which claims a
2392
+ // record reached the end without passing through the middle.
2393
+ // Every rung at or below the one pressed that has no stamp
2394
+ // gets today — the day this is being recorded, which is the
2395
+ // same thing a single tick already means. Each one is then
2396
+ // one click away from its real date, and the ones that
2397
+ // already carry a date are left exactly as they are.
2398
+ const today = new Date().toISOString().slice(0, 10);
2399
+ setDeskSince((prev) => {
2400
+ const filled = { ...prev };
2401
+ STEPS.slice(0, i + 1).forEach((x) => { if (!filled[x.key]) filled[x.key] = today; });
2402
+ return filled;
2403
+ });
2404
+ return;
2405
+ }
2406
+ // Un-ticking walks the record BACK, and everything the stages
2407
+ // above it recorded goes with it — their dates and the facts
2408
+ // they own. That is destructive and invisible from the ring,
2409
+ // so it is named before it happens: which stages, and that it
2410
+ // is their data too, not just their dates. A stage with
2411
+ // nothing above it needs no warning — there is nothing to lose.
2412
+ const above = STEPS.slice(i + 1).filter((x) => deskSince[x.key] || deskOwner[x.key]);
2413
+ const revert = () =>
2414
+ STEPS.slice(i).forEach((x) => {
2415
+ setDeskSince((prev) => ({ ...prev, [x.key]: "" }));
2416
+ setDeskOwner((prev) => ({ ...prev, [x.key]: "" }));
2417
+ });
2418
+ if (above.length === 0) { revert(); return; }
2419
+ Alert.alert(
2420
+ `Reopen ${d.label}?`,
2421
+ `${above.map((x) => x.label).join(" and ")} ${above.length > 1 ? "are" : "is"} recorded after it. Reopening clears their dates and everything they hold — owner included. This can't be undone.`,
2422
+ [
2423
+ { text: "Cancel", style: "cancel" },
2424
+ { text: "Reopen and clear", style: "destructive", onPress: revert },
2425
+ ],
2426
+ );
2427
+ }}
2428
+ // The date rides the title's own row and READS as text — `bare`
2429
+ // shows nothing at rest and fades its border in on hover, so the
2430
+ // ladder stays a column of dates to scan rather than a column of
2431
+ // controls, and the one that needs correcting is still one click.
2432
+ //
2433
+ // That click is how a stamp gets BACKDATED, which the tick alone
2434
+ // cannot do: ticking always writes today, and a ladder filled in
2435
+ // after the fact is full of days that already passed. Rendering
2436
+ // the date as inert text would have cost exactly that.
2437
+ //
2438
+ // An unstamped rung shows nothing — its empty ring already says
2439
+ // "not yet", and a "Not recorded" placeholder on every rung ahead
2440
+ // says the same thing again in more words.
2441
+ // What the stage HOLDS, beside its name: the date it was
2442
+ // stamped, and any value it owns that has actually been set.
2443
+ // A stage's facts are otherwise only visible once you read down
2444
+ // into its body, so a ladder of five says nothing but five
2445
+ // names — the reader has to open each to learn anything. Only
2446
+ // values that EXIST are shown; an empty one would print a label
2447
+ // with nothing after it on every rung ahead.
2448
+ // The stage's own line carries what is ABOUT the stage — when it
2449
+ // happened, and the way into changing it — while the body holds
2450
+ // what it CONTAINS. Edit sat under the values, which put the verb
2451
+ // below the thing it acts on and moved it down every time a stage
2452
+ // gained a field.
2453
+ trailing={
2454
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 10 }}>
2455
+ <DateStamp
2456
+ value={stampedAt || null}
2457
+ onChange={(iso) => setDeskSince((prev) => ({ ...prev, [d.key]: iso }))}
2458
+ locale="en-GB"
2459
+ accessibilityLabel={"Date for " + d.label}
2460
+ />
2461
+ {/* Edit ⇄ Done, one control in one place. It is a text LINK
2462
+ and not a button on purpose: the inline editors commit on
2463
+ their own (blur, Enter, a pick), so nothing is pending when
2464
+ you press this — it only puts the stage back to reading.
2465
+ A `Button` would promise a SAVE that already happened and
2466
+ make a reader wonder what they lose by navigating away. */}
2467
+ {d.desk && status !== "upcoming" && status !== "current" ? (
2468
+ <Pressable
2469
+ onPress={() => setEditingDesk(editingDesk === d.key ? "" : d.key)}
2470
+ accessibilityRole="button"
2471
+ accessibilityLabel={`${editingDesk === d.key ? "Done editing" : "Edit"} ${d.label}`}
2472
+ >
2473
+ <TextLink size="xs">{editingDesk === d.key ? "Done" : "Edit"}</TextLink>
2474
+ </Pressable>
2475
+ ) : null}
2476
+ </View>
2477
+ }
2335
2478
  >
2336
2479
  {/* A reached desk owns its own facts — and keeps owning them
2337
2480
  once the record has moved on. */}
2338
- {i <= deskIndex ? (
2339
- <>
2340
- <PipelineField label="Taken on" maxWidth={200}>
2341
- <InlineDatePicker
2342
- value={deskSince[d.key] || null}
2343
- onSave={(v) => setDeskSince((p) => ({ ...p, [d.key]: v ?? "" }))}
2344
- onClear={() => setDeskSince((p) => ({ ...p, [d.key]: "" }))}
2345
- placeholder="Not recorded"
2346
- accessibilityLabel={`Taken on for ${d.label}`}
2347
- />
2348
- </PipelineField>
2481
+ {status !== "upcoming" && d.desk ? (
2482
+ status === "current" || editingDesk === d.key ? (
2349
2483
  <PipelineField label="Owner" maxWidth={260}>
2350
2484
  <InlineMemberSelect
2351
2485
  members={TEAM}
@@ -2355,13 +2489,27 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2355
2489
  accessibilityLabel={`Owner for ${d.label}`}
2356
2490
  />
2357
2491
  </PipelineField>
2358
- </>
2492
+ ) : (
2493
+ // Read-only, but still SHOWN — and shown by whatever renders
2494
+ // that kind of value properly: a member as a `MemberChip`, a
2495
+ // select as its `Badge`. Collapsing them to muted text would
2496
+ // make the same fact look different depending on whether the
2497
+ // stage happened to be the live one, and a reader would have
2498
+ // to decode two renderings of one field. Only what is SET
2499
+ // appears; an empty field has nothing to display read-only.
2500
+ // No label. A label tells you what you are FILLING IN —
2501
+ // read-only, the value says what it is: a face and a name is
2502
+ // an owner, a badge is a status. Printing "Owner" over a
2503
+ // member chip is the field talking about itself, and down a
2504
+ // ladder it doubles the lines for nothing.
2505
+ owner ? <MemberChip name={TEAM.find((x) => x.id === owner)?.name ?? owner} size={22} /> : null
2506
+ )
2359
2507
  ) : null}
2360
2508
  {/* A condition belongs to the desk it is about. */}
2361
2509
  {status === "current" && stage !== "closed" && deliverBy !== "" && new Date(deliverBy) < new Date() ? (
2362
2510
  <PipelineNote tone="warning">Past the due date — chase it or move the date.</PipelineNote>
2363
2511
  ) : null}
2364
- {status === "current" && gate ? (
2512
+ {status === "current" && d.desk === stage && gate ? (
2365
2513
  <PipelineActions>
2366
2514
  <Button
2367
2515
  title={gate.cta}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "27.9.0",
3
+ "version": "27.10.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -218,6 +218,7 @@
218
218
  "./page_content": "./src/page_content.tsx",
219
219
  "./page_header": "./src/page_header.tsx",
220
220
  "./date_picker": "./src/date_picker.tsx",
221
+ "./date_stamp": "./src/date_stamp.tsx",
221
222
  "./date_filter": "./src/date_filter.tsx",
222
223
  "./date_range_filter_field": "./src/date_range_filter_field.tsx",
223
224
  "./time_picker": "./src/time_picker.tsx",
@@ -25,7 +25,8 @@ export interface CheckCircleProps {
25
25
  onChange?: (done: boolean) => void;
26
26
  /** Fill colour when done (and the half-fill when partial). Default zinc-900. */
27
27
  color?: string;
28
- /** Diameter. Default 20. */
28
+ /** Diameter. Default 24 — the same box `Checkbox` occupies, so the two forms of
29
+ * "mark this" reserve one gutter width wherever a surface hosts either. */
29
30
  size?: number;
30
31
  disabled?: boolean;
31
32
  accessibilityLabel: string;
@@ -43,7 +44,7 @@ export interface CheckCircleProps {
43
44
  * "how far", not "which state".
44
45
  */
45
46
  export function CheckCircle(props: CheckCircleProps) {
46
- const { state, onChange, color = colors.zinc[900], size = 20, disabled, accessibilityLabel } = props;
47
+ const { state, onChange, color = colors.zinc[900], size = 24, disabled, accessibilityLabel } = props;
47
48
  const done = state === "done";
48
49
  const v = useRef(new Animated.Value(done ? 1 : 0)).current;
49
50
  useEffect(() => {
@@ -0,0 +1,121 @@
1
+ import { useState } from "react";
2
+ import { Pressable, View } from "react-native";
3
+ import { Text } from "./text";
4
+ import { Icon } from "./icon";
5
+ import { colors } from "./colors";
6
+ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
7
+ import { DatePickerPanel } from "./date_picker";
8
+ import { formatDate } from "./format_date";
9
+ import { useLocaleTag } from "./locale";
10
+
11
+ export interface DateStampProps {
12
+ /** ISO date, or `""`/`null` for a stamp that has not been made. */
13
+ value: string | null;
14
+ /** Commit a correction. Omit for a read-only stamp — then nothing is pressable. */
15
+ onChange?: (iso: string) => void;
16
+ /** Locale tag for the rendered date. Defaults to the kit's locale. */
17
+ locale?: string;
18
+ accessibilityLabel: string;
19
+ }
20
+
21
+ /**
22
+ * A recorded date, shown quietly — and correctable.
23
+ *
24
+ * The value a SYSTEM writes and a person only occasionally overrides: the day a
25
+ * milestone was ticked, the day a status flipped. It is a READOUT first, so at
26
+ * rest it is nothing but small muted text — no frame, no icon, no control band.
27
+ * Hovering underlines it, which is the one cue prose has to say it opens; the
28
+ * press opens the kit's own `DatePickerPanel`.
29
+ *
30
+ * **Why this is not `InlineDatePicker` with a variant.** That component is a
31
+ * form FIELD: it renders a calendar icon, sits in a control band, keeps a typed
32
+ * `DateField` for keyboard entry and shows an em-dash when empty — the whole
33
+ * anatomy of "fill me in". Stripping its surface to get this left the icon and
34
+ * the sizing behind, because those belong to the field, not to the frame. A
35
+ * ladder of fields is a form with invisible borders; a ladder of these is a list
36
+ * of dates you can read at a glance. Different concept, different component.
37
+ *
38
+ * Reach for `InlineDatePicker` when the date is something a person is expected
39
+ * to ENTER, and for this when it is something the system already knows.
40
+ *
41
+ * Empty renders NOTHING (not a dash, not a placeholder): there is no value to
42
+ * report, whatever named the stamp already says it has not happened, and a
43
+ * column of dashes is noise pretending to be data. A read-only stamp with no
44
+ * value therefore renders nothing at all.
45
+ */
46
+ export function DateStamp(props: DateStampProps) {
47
+ const { value, onChange, locale, accessibilityLabel } = props;
48
+ const localeTag = useLocaleTag();
49
+ const tag = locale ?? localeTag;
50
+ const [hovered, setHovered] = useState(false);
51
+ const [open, setOpen] = useState(false);
52
+ const [draft, setDraft] = useState<string | null>(value);
53
+
54
+ const shown = value ? formatDate(value, { locale: tag }) : "";
55
+
56
+ // Read-only, or nothing to show and no way to add one: render the text alone
57
+ // so a caller can drop this into a column without a conditional.
58
+ if (!onChange) return shown ? <Text size="xs" color="muted">{shown}</Text> : null;
59
+
60
+ return (
61
+ <Popover
62
+ open={open}
63
+ onOpenChange={(next) => {
64
+ setOpen(next);
65
+ // Commit on CLOSE, matching `InlineDatePicker`: a single pick and the
66
+ // panel's Today button both auto-close through here, so committing on
67
+ // change instead would fire twice and a dismissed panel would still write.
68
+ if (!next && draft !== value) onChange(draft ?? "");
69
+ if (next) setDraft(value);
70
+ }}
71
+ >
72
+ <PopoverTrigger>
73
+ <Pressable
74
+ onPress={() => setOpen(true)}
75
+ onHoverIn={() => setHovered(true)}
76
+ onHoverOut={() => setHovered(false)}
77
+ accessibilityRole="button"
78
+ accessibilityLabel={accessibilityLabel}
79
+ >
80
+ {/* The underline is a BOTTOM BORDER, not a text decoration: it can then
81
+ be transparent at rest and coloured on hover with no reflow, and it
82
+ sits under the text rather than through its descenders. */}
83
+ <View
84
+ style={{
85
+ borderBottomWidth: 1,
86
+ borderBottomColor: hovered ? colors.zinc[400] : "transparent",
87
+ }}
88
+ >
89
+ {/* An empty stamp still needs somewhere to press, or the only way to
90
+ record a date the tick got wrong is to tick and re-tick. A muted
91
+ calendar glyph says "a date goes here" AND looks pressable; the
92
+ thin rule that was here first read as a divider and invited no
93
+ click at all. The icon appears ONLY when there is no date — once
94
+ one exists the date itself is the target, and an icon beside it
95
+ would be chrome on every row of the ladder. */}
96
+ {shown ? (
97
+ <Text size="xs" color="muted">{shown}</Text>
98
+ ) : (
99
+ // An icon has no text baseline, so in a `baseline` row it hangs off
100
+ // its own bottom edge. Centring it in the label's LINE BOX is not
101
+ // the fix either, and that is the version that shipped looking
102
+ // wrong: a line box carries descender space below the baseline that
103
+ // an icon has no equivalent of, so the label's optical centre (its
104
+ // cap band) sits ~2px above the box centre. Matching box centres
105
+ // therefore hangs the icon low against the text every time.
106
+ //
107
+ // Measured on this row: label box 344–364, cap band ~348–358. The
108
+ // lift is that gap, not a taste adjustment.
109
+ <View style={{ height: 16, justifyContent: "center", alignSelf: "center", marginBottom: 2 }}>
110
+ <Icon name="calendar" size={13} color={hovered ? colors.zinc[500] : colors.zinc[400]} />
111
+ </View>
112
+ )}
113
+ </View>
114
+ </Pressable>
115
+ </PopoverTrigger>
116
+ <PopoverContent>
117
+ <DatePickerPanel value={draft} onValueChange={setDraft} format="date" locale={tag} onRequestClose={() => setOpen(false)} />
118
+ </PopoverContent>
119
+ </Popover>
120
+ );
121
+ }
package/src/locale.tsx CHANGED
@@ -69,6 +69,10 @@ export interface LoticsLocale {
69
69
  /** `Ledger`: the screen-reader name of a peekable row. */
70
70
  ledger: { rowDetails: (label: string) => string };
71
71
  /** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
72
+ /** `Step`/`PipelineStage`'s toggleable MARKER, when the caller names no
73
+ * label of its own. A pipeline stage always passes its title, so this is the
74
+ * bare-`Step` fallback. */
75
+ stepper: { complete: string; progress: string };
72
76
  sectionHeading: { info: string };
73
77
  /** `ErrorState`'s retry button. The kit owns the wording so "try again" is
74
78
  * phrased identically everywhere instead of hand-written per app. */
@@ -236,6 +240,7 @@ export const en: LoticsLocale = {
236
240
  inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
237
241
  clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
238
242
  ledger: { rowDetails: (label) => `${label} details` },
243
+ stepper: { complete: "Complete step", progress: "Progress" },
239
244
  sectionHeading: { info: "About this data" },
240
245
  errorState: { retry: "Try again" },
241
246
  chip: { remove: "Remove" },
@@ -396,6 +401,7 @@ export const vi: LoticsLocale = {
396
401
  inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
397
402
  clarify: { otherPlaceholder: "Hoặc nhập câu trả lời khác…", back: "Quay lại", next: "Tiếp", cancel: "Hủy", submit: "Gửi" },
398
403
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
404
+ stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
399
405
  sectionHeading: { info: "Giải thích dữ liệu" },
400
406
  errorState: { retry: "Thử lại" },
401
407
  chip: { remove: "Xóa" },
package/src/pipeline.tsx CHANGED
@@ -2,7 +2,6 @@ import { type ReactNode } from "react";
2
2
  import { View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Stepper, Step, type StepPositional, type StepStatus } from "./stepper";
5
- import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
6
5
 
7
6
  export interface PipelineProps {
8
7
  children?: ReactNode;
@@ -84,45 +83,69 @@ export interface PipelineStageProps extends StepPositional {
84
83
  * and pushes the whole run past a screenful; use `PipelineField` when the value
85
84
  * needs a label to be read, or when there is more than one.
86
85
  *
87
- * The row is fixed at `INLINE_CONTROL_HEIGHT` whatever you put here, so the
88
- * marker lines up with the title and the stages keep one rhythm down the spine.
89
- * An ACT still belongs in `PipelineActions` this is for a value.
86
+ * The row is a TEXT line and the value sits on the title's own baseline, so a
87
+ * `DateStamp` beside a stage name reads as one line about one milestone. Put a
88
+ * value here, never a control: an editor drags the row to a control band, the
89
+ * marker follows it, and the ladder stops being something you scan. An ACT
90
+ * belongs in `PipelineActions`.
90
91
  */
91
92
  trailing?: ReactNode;
93
+
92
94
  /** The stage's own body — `PipelineNote`, `PipelineField`, `PipelineActions`,
93
95
  * or anything else. A stage with no body renders as its title alone, which is
94
96
  * what an unreached stage should be. */
95
97
  children?: ReactNode;
98
+ /**
99
+ * Make the stage's MARKER the completion control — the node becomes a
100
+ * pressable ring instead of a status dot. Reach for it when the ladder is a
101
+ * worklist the reader ticks off, not just a readout.
102
+ *
103
+ * Without it, a caller who wants a tickable ladder puts a checkbox in
104
+ * `trailing`, which lands a second completion affordance beside a ring that
105
+ * already looks like one and reads as two controls for one fact.
106
+ */
107
+ onToggle?: (done: boolean) => void;
96
108
  accessibilityLabel?: string;
97
109
  }
98
110
 
99
- /** One milestone. Its title reads by STATUSthe LIVE one (`current`, or the
100
- * terminal `complete`) in full ink and medium weight, everything else muted —
101
- * so the eye lands on where the record sits without reading a word.
111
+ /** One milestone. Every title reads the samefull ink, medium because the
112
+ * MARKER carries the status: filled behind, empty ahead. Emphasising one name
113
+ * encoded that a second time, and muting the passed ones read as "these matter
114
+ * less" about the part of the record that already happened.
102
115
  *
103
- * Deliberately NOT pressable. `Step` can be (a wizard whose steps navigate),
104
- * but a pipeline stage is a workspace, not a destination — its body already
105
- * holds the controls, and a press target wrapping them would swallow their taps. */
116
+ * The stage BODY is deliberately not a press target. `Step` can be (a wizard
117
+ * whose steps navigate), but a pipeline stage is a workspace, not a destination
118
+ * — its body already holds the controls, and a press target wrapping them would
119
+ * swallow their taps. `onToggle` is the exception and stays on the MARKER,
120
+ * which owns no other content and stops its own press. */
106
121
  export function PipelineStage(props: PipelineStageProps) {
107
- const { status, title, meta, trailing, children, accessibilityLabel, ...positional } = props;
122
+ const { status, title, meta, trailing, children, onToggle, accessibilityLabel, ...positional } = props;
108
123
  // `complete` is the terminal stage REACHED — where a finished record sits, not
109
124
  // one it walked past. Muting it like an unreached stage leaves a completed run
110
125
  // with nothing in full ink, so the eye has no landing point and the last thing
111
126
  // that happened reads as the thing that hasn't.
112
- const isLive = status === "current" || status === "complete";
127
+ // Every stage name reads the same: full ink, medium. The MARKER already says
128
+ // where the record sits — a filled ring behind, an empty one ahead — so muting
129
+ // the passed names said it a second time, and said it wrongly: a stage that is
130
+ // done is the RECORD of what happened, not something to de-emphasise. The kit
131
+ // makes the same argument for `CheckCircle`, which is monochrome at every
132
+ // position precisely so a row does not double-code its own state.
113
133
  const titleText = (
114
- <Text size="sm" color={isLive ? "default" : "muted"} weight={isLive ? "medium" : "regular"}>
134
+ <Text size="sm" color="default" weight="medium">
115
135
  {title}
116
136
  </Text>
117
137
  );
118
138
  return (
119
139
  <Step
120
140
  status={status}
141
+ onToggle={onToggle}
121
142
  accessibilityLabel={accessibilityLabel ?? title}
122
143
  // The marker centres on the FIRST ROW, so it has to be told when that row
123
144
  // is a control band and not a line of text — otherwise it stays pinned to
124
145
  // the text and every title reads low by half the difference.
125
- headHeight={trailing != null ? INLINE_CONTROL_HEIGHT : undefined}
146
+ // The row is a text line whatever rides it, so the marker centres on
147
+ // the text rather than on a control band that is no longer there.
148
+ headHeight={undefined}
126
149
  {...positional}
127
150
  >
128
151
  <View style={{ gap: 6 }}>
@@ -132,9 +155,21 @@ export function PipelineStage(props: PipelineStageProps) {
132
155
  // row height followed its content would step the marker in and out of
133
156
  // alignment down the ladder, and a badge would sit on a shorter row
134
157
  // than a date picker two stages up.
135
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: INLINE_CONTROL_HEIGHT }}>
136
- <View style={{ flex: 1, minWidth: 0 }}>{titleText}</View>
158
+ // BASELINE, not centre: the value sits beside the title as prose, and
159
+ // two texts of different sizes centred in one row share no baseline —
160
+ // the smaller one floats, which is exactly what "not aligned with the
161
+ // label" looks like. The row also stops forcing a control band, since
162
+ // what rides it now is text, not an editor.
163
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
164
+ {/* The title keeps its NATURAL width and the slack goes after the
165
+ value, so the value sits against the label — "Booked · 16 Jun"
166
+ reads as one line about one milestone. Pinning it to the row's
167
+ right edge instead made a column of dates that scanned well and
168
+ said nothing: the reader had to carry the label across the gap
169
+ to know which milestone the date belonged to. */}
170
+ <View style={{ flexShrink: 1, minWidth: 0 }}>{titleText}</View>
137
171
  {trailing}
172
+ <View style={{ flex: 1 }} />
138
173
  </View>
139
174
  ) : (
140
175
  titleText
package/src/stepper.tsx CHANGED
@@ -15,6 +15,8 @@ import { Icon } from "./icon";
15
15
  import { Text } from "./text";
16
16
  import { PressableHighlight } from "./pressable_highlight";
17
17
  import { AnimationFadeIn } from "./animation_fade_in";
18
+ import { CheckCircle } from "./check_circle";
19
+ import { useLoticsLocale } from "./locale";
18
20
  import { NODE, STEP_HEAD_TEXT_LINE, STEP_ROW_PAD, markerTopOffset } from "./stepper_layout";
19
21
 
20
22
  // A node's place in a sequence. `upcoming` = not reached (greyish); `current` =
@@ -53,6 +55,7 @@ export interface StepperProps {
53
55
  * whole set — extend this and spread it through, never re-list the keys, or a
54
56
  * new positional prop is dropped silently by everything but `Step`. */
55
57
  export interface StepPositional {
58
+ _first?: boolean;
56
59
  _last?: boolean;
57
60
  _leftFilled?: boolean;
58
61
  _rightFilled?: boolean;
@@ -65,6 +68,22 @@ export interface StepProps extends StepPositional {
65
68
  * `active` washes the selected one (a panel can also sit beside it in vertical). */
66
69
  onPress?: () => void;
67
70
  active?: boolean;
71
+ /**
72
+ * VERTICAL only. Make the MARKER itself the completion control — the node
73
+ * becomes a `CheckCircle` the reader can press to finish (or reopen) the step,
74
+ * instead of a status dot that only reports.
75
+ *
76
+ * The alternative every caller reaches for otherwise is a checkbox parked in
77
+ * the row beside the marker, which puts two completion affordances on one line
78
+ * — a ring that looks tickable and a box that is — and asks the reader to
79
+ * guess which one is real. The ring already reads as "press me" at every other
80
+ * task surface in this kit; here it finally is.
81
+ *
82
+ * `onPress` still owns navigation, and the two compose: the ring stops its own
83
+ * press, so a navigable step with a toggleable marker does both without the
84
+ * marker's tap opening the panel.
85
+ */
86
+ onToggle?: (done: boolean) => void;
68
87
  /**
69
88
  * VERTICAL only. Height of the content's FIRST ROW — the marker centres on it.
70
89
  * Defaults to one line of `sm` text, which is what a step's first row is unless
@@ -89,6 +108,7 @@ export interface StepProps extends StepPositional {
89
108
  */
90
109
  export function Stepper(props: StepperProps) {
91
110
  const { children, steps, current, orientation = "horizontal", live = false, color = colors.zinc[700], accessibilityLabel } = props;
111
+ const locale = useLoticsLocale();
92
112
 
93
113
  const content: ReactNode =
94
114
  children ??
@@ -107,7 +127,7 @@ export function Stepper(props: StepperProps) {
107
127
  const items = Children.toArray(content).filter((c): c is ReactElement<StepProps> => isValidElement(c));
108
128
  const statuses = items.map((c) => c.props.status);
109
129
  const last = items.length - 1;
110
- const a11y = accessibilityLabel ?? "Progress";
130
+ const a11y = accessibilityLabel ?? locale.stepper.progress;
111
131
  // A LIST of named positions, never `progressbar`. `progressbar` is a RANGE
112
132
  // widget — it wants valuenow/min/max, announces as indeterminate without them,
113
133
  // and its children are PRESENTATIONAL, which strips every step's label (and,
@@ -117,6 +137,7 @@ export function Stepper(props: StepperProps) {
117
137
 
118
138
  const positioned = items.map((child, i) =>
119
139
  cloneElement(child, {
140
+ _first: i === 0,
120
141
  _last: i === last,
121
142
  _leftFilled: i > 0 && reached(statuses[i - 1]),
122
143
  _rightFilled: i < last && reached(statuses[i]),
@@ -133,8 +154,9 @@ export function Stepper(props: StepperProps) {
133
154
  }
134
155
 
135
156
  export function Step(props: StepProps) {
136
- const { status, children, onPress, active, headHeight = STEP_HEAD_TEXT_LINE, accessibilityLabel, _last, _leftFilled, _rightFilled } = props;
157
+ const { status, children, onPress, active, onToggle, headHeight = STEP_HEAD_TEXT_LINE, accessibilityLabel, _first, _last, _leftFilled, _rightFilled } = props;
137
158
  const { orientation, color, live } = useContext(StepperContext);
159
+ const locale = useLoticsLocale();
138
160
  // Each step is one `listitem`, and the live one says so with `aria-current`.
139
161
  // It goes on the ITEM, not the label: "current" is a fact about this position
140
162
  // in the sequence, and a reader jumping between list items needs it announced
@@ -183,8 +205,48 @@ export function Step(props: StepProps) {
183
205
  <View {...item}>
184
206
  <AnimationFadeIn translateY={6}>
185
207
  <View style={styles.vItem}>
186
- <View style={[styles.vSpineCol, { paddingTop: markerTopOffset(headHeight) }]}>
187
- <Marker status={status} color={color} live={live} />
208
+ <View style={styles.vSpineCol}>
209
+ {/* The spine is TWO segments per step, above the marker and below it,
210
+ because the marker does not sit at the top of its row: it centres
211
+ on the first content row, so there is a `markerTopOffset` band of
212
+ empty column above it. A single below-the-marker segment cannot
213
+ reach into the NEXT step's band, so the line broke at every joint
214
+ — ~4px under each marker (this segment's old top margin) plus the
215
+ next one's offset above it. Measured on the record template: 4px
216
+ and 3px, at every stage, which reads as dashes rather than a spine.
217
+ The upper segment fills that band, so the line is continuous from
218
+ the first marker to the last. It also replaces the column's old
219
+ `paddingTop`, which is why the marker lands in the same place. */}
220
+ <View
221
+ style={[
222
+ styles.vSpineUp,
223
+ { height: markerTopOffset(headHeight) },
224
+ // The first step has nothing above it to connect to, so its band
225
+ // is spacing, not line.
226
+ _first ? null : { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] },
227
+ ]}
228
+ />
229
+ {onToggle ? (
230
+ // Sized to NODE so a toggleable step keeps the spine's rhythm — a
231
+ // ring a couple of pixels wider would bow the column at that row.
232
+ <CheckCircle
233
+ // `done`/`complete` only — NOT `reached`, which counts `current`
234
+ // too. `reached` is right for the status DOT (the step you are on
235
+ // is a place you have got to), but a completion RING that fills on
236
+ // the current step says the step is finished when it is the one
237
+ // still open. Every interaction then inverts on that step: the
238
+ // press reports "reopen" when the reader meant "finish", so
239
+ // pressing the next step appeared to do nothing, or to complete
240
+ // the whole run.
241
+ state={status === "done" || status === "complete" ? "done" : "none"}
242
+ onChange={onToggle}
243
+ color={color}
244
+ size={NODE}
245
+ accessibilityLabel={accessibilityLabel ?? locale.stepper.complete}
246
+ />
247
+ ) : (
248
+ <Marker status={status} color={color} live={live} />
249
+ )}
188
250
  {!_last ? <View style={[styles.vSpine, { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] }]} /> : null}
189
251
  </View>
190
252
  <View style={[styles.vContent, !_last ? styles.vGap : null]}>
@@ -266,7 +328,12 @@ const styles = StyleSheet.create({
266
328
 
267
329
  vItem: { flexDirection: "row", gap: 12 },
268
330
  vSpineCol: { width: NODE, alignItems: "center" },
269
- vSpine: { width: 1.5, flex: 1, minHeight: 14, borderRadius: 1, marginTop: 4 },
331
+ // No top margin: the segment starts AT the marker, or the line detaches from
332
+ // the node it is supposed to leave.
333
+ vSpine: { width: 1.5, flex: 1, minHeight: 14, borderRadius: 1 },
334
+ // The band above the marker — width matches `vSpine` so the two segments
335
+ // read as one line through the node.
336
+ vSpineUp: { width: 1.5 },
270
337
  vContent: { flex: 1 },
271
338
  vGap: { paddingBottom: 12 },
272
339
  vPress: { borderRadius: 8, marginHorizontal: -10 },
@@ -12,8 +12,24 @@
12
12
  * which Vitest cannot parse.
13
13
  */
14
14
 
15
- /** The marker disc's diameter. */
16
- export const NODE = 18;
15
+ /**
16
+ * The marker disc's diameter — and, deliberately, `CheckCircle`'s default size.
17
+ *
18
+ * A toggleable step renders a real `CheckCircle` in this slot, so the two have to
19
+ * agree: at 18 the spine was SHRINKING the kit's completion ring below the size
20
+ * it is everywhere else, and the same control read smaller on a ladder than on a
21
+ * task list for no reason a reader could name. Pinning `NODE` to the ring's own
22
+ * size makes the geometry follow the control instead of the other way round.
23
+ *
24
+ * 24 is that size, and it is `Checkbox`'s box as well: a ring and a box are two
25
+ * forms of one act, they share the leading gutter on `Table` and `TaskList`, and
26
+ * a gutter whose width depended on which one a surface hosted is a difference no
27
+ * reader could name either.
28
+ *
29
+ * Every other measurement here derives from it (`markerTopOffset`, the spine
30
+ * column's width, the disc and its pulse), so this is the one number to change.
31
+ */
32
+ export const NODE = 24;
17
33
 
18
34
  /** `vRowBox`'s vertical padding — the gap above the content's first row. */
19
35
  export const STEP_ROW_PAD = 2;
package/src/task.tsx CHANGED
@@ -115,7 +115,7 @@ export type TaskDensity = keyof typeof TASK_ROW_BAND;
115
115
  const TASK_ACTION_WIDTH = 28;
116
116
 
117
117
  const TaskListContext = createContext<TaskListContextValue>({
118
- controlWidth: 20,
118
+ controlWidth: 24,
119
119
  actionWidth: TASK_ACTION_WIDTH,
120
120
  labelWidth: DETAIL_LABEL_WIDTH,
121
121
  depth: 0,
@@ -143,8 +143,9 @@ export const TASK_TEXT_INSET = CONTROL_TEXT_INSET;
143
143
  export interface TaskListProps {
144
144
  children: ReactNode;
145
145
  /**
146
- * Width of every row's leading control, which the LEFT gutter is reserved for `CheckCircle`
147
- * 20 (the default), `CheckboxInput` 24. It also sets the INDENT: everything hanging under a
146
+ * Width of every row's leading control, which the LEFT gutter is reserved for. Both leading
147
+ * controls are 24 `CheckCircle` and `CheckboxInput` so the default fits either, and only a
148
+ * custom control needs this. It also sets the INDENT: everything hanging under a
148
149
  * task — its sub-rows, a nested list — steps right by this plus the row gap. Set on the ROOT
149
150
  * list; a nested list inherits it.
150
151
  */