@lotics/ui 47.6.1 → 47.7.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 +1 -1
- package/docs/catalog.md +11 -4
- package/docs/composition.md +16 -0
- package/docs/reviewing.md +28 -0
- package/package.json +1 -1
- package/src/filter_band.tsx +18 -8
- package/src/list_item.tsx +7 -8
- package/src/switcher.tsx +27 -4
package/AGENTS.md
CHANGED
|
@@ -18,7 +18,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
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 (`Finding` is filed here because AI produces most of them, NOT because it is AI-only — a rules-derived contradiction is as in scope as an extraction's); **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
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), **an identity mark's GLYPH is derived from the name's distinguishing part** (an organization's first word is its legal form, so untreated every row marks one letter), **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) the first-line drift a column shows when its cell is sometimes one line and sometimes two,
|
|
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) the first-line drift a column shows when its cell is sometimes one line and sometimes two, the cross-FRAME check (what the screen destroys when a row arrives — the one defect that exists in no single frame), and the semantic-section check (a danger or empty section drawn as an ordinary one, and the primitive nobody reached for). 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
|
@@ -1045,8 +1045,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1045
1045
|
- **`card_select_item`** — `CardSelectItem`: a bordered, card-shaped button; `selected` =
|
|
1046
1046
|
persistent ring (reads as `aria-pressed`) — the org-picker / entity-switcher item.
|
|
1047
1047
|
- **`switcher`** — `Switcher`: current-item trigger opening a popover of `MenuButton` items
|
|
1048
|
-
(`SwitcherItem { id, label }`, `currentId`, `onSelect`) — the compact
|
|
1049
|
-
switcher.
|
|
1048
|
+
(`SwitcherItem { id, label, imageUri? }`, `currentId`, `onSelect`) — the compact
|
|
1049
|
+
entity/workspace switcher. A square `Avatar` appears on the trigger and every row as soon as
|
|
1050
|
+
ONE item carries `imageUri`, the rest falling back to initials, so an unpictured entry still
|
|
1051
|
+
lines up. `minItemsToRender` defaults to 2 (a single choice is clutter); lower it to 1 where
|
|
1052
|
+
the switcher states a scope the reader has no other way to see.
|
|
1050
1053
|
- **`count`** — `Count`: a 20px count disc that grows into a pill past one digit (`color` highlight|muted|red).
|
|
1051
1054
|
- **`shortcut_badge`** — `ShortcutBadge`: the keycap hint pill — a zinc-50 badge rendering a
|
|
1052
1055
|
shortcut from a raw string or `ShortcutDescriptor` (⌘B on Mac, Ctrl+B elsewhere); null on
|
|
@@ -1672,7 +1675,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1672
1675
|
**The row OWNS its `title`/`description` pair, so do not hand-roll one beside it.** Both
|
|
1673
1676
|
lines are `sm`, `leading="tight"`, no gap between them — the stacked-pair law in
|
|
1674
1677
|
[composition.md](./composition.md), applied by the component rather than restated at each
|
|
1675
|
-
call site. The row is
|
|
1678
|
+
call site. The row's floor is 64 — 12 a side around a tight pair and an `md` leading mark —
|
|
1679
|
+
and it grows for a `description` node taller than a line.
|
|
1676
1680
|
A `description` passed as a NODE keeps its own leading — the component only sets it on the
|
|
1677
1681
|
string branch, because forcing a line box on somebody else's subtree re-spaces content that
|
|
1678
1682
|
may not be a line of text at all.
|
|
@@ -1772,7 +1776,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1772
1776
|
opened the screen for. Pass `activeCount` so the collapsed button still says the list is
|
|
1773
1777
|
filtered, and `onClearAll` to put one clear in the sheet. Compose the band with this rather
|
|
1774
1778
|
than a hand-rolled `<View flexDirection="row">`: a hand-rolled band is the one that does not
|
|
1775
|
-
collapse.
|
|
1779
|
+
collapse. **A facet with nothing to offer should render `null`, and the band then draws no
|
|
1780
|
+
trigger at all** — narrow as well as wide. Gate each chip on its own options (a status filter
|
|
1781
|
+
whose values are all already in view, a round picker before any round is recorded) rather than
|
|
1782
|
+
shipping an empty one; search and the action stay on the band either way.
|
|
1776
1783
|
- **`filter_chip`** — `FilterChip` + `selectSummary`: the toolbar filter pill hosting a
|
|
1777
1784
|
facet (options, a `Slider range`, a `Counter`). It draws NO bottom band of its own — the ×
|
|
1778
1785
|
on the pill is the clear, and the editor inside brings whatever actions it has, so a
|
package/docs/composition.md
CHANGED
|
@@ -1751,6 +1751,22 @@ The hit area is unchanged — it paints outside the box rather than growing it
|
|
|
1751
1751
|
where it would if the control were not there. The same applies to any verb riding a value: a
|
|
1752
1752
|
`CopyButton`, an `InlineButton`, a count chip.
|
|
1753
1753
|
|
|
1754
|
+
## "Who can reach this" is drawn as MARKS, on every surface that asks it
|
|
1755
|
+
|
|
1756
|
+
Access is a set of people, so it renders as `AvatarGroup` — a register cell, a settings row, a
|
|
1757
|
+
detail header. A word in place of the marks (`Shared`, `2 people`) answers a smaller question than
|
|
1758
|
+
the faces do: it says THAT the thing is shared without saying WITH WHOM, which is the half the
|
|
1759
|
+
reader opened the screen for, and it puts one datum in two treatments the moment any other surface
|
|
1760
|
+
draws the marks.
|
|
1761
|
+
|
|
1762
|
+
The one case marks cannot carry is the whole organization: "everyone" is not a set you can show
|
|
1763
|
+
three of, and the three that happen to sort first would say something false about who else is in
|
|
1764
|
+
there. That gets an icon and a word.
|
|
1765
|
+
|
|
1766
|
+
Whether the OWNER is among the marks is the caller's decision, not a flag on the component — a
|
|
1767
|
+
register with its own owner column passes the shares alone, a list without one leads with the
|
|
1768
|
+
owner. Compose it; a boolean would make one component answer two questions.
|
|
1769
|
+
|
|
1754
1770
|
## Identity marks — a mark that is the same on every row carries nothing
|
|
1755
1771
|
|
|
1756
1772
|
`Avatar` derives its ground from the NAME (`avatar_tone`), and takes `shape="circle" | "square"`
|
package/docs/reviewing.md
CHANGED
|
@@ -232,6 +232,13 @@ reads as one cluster. One `gap` on a container makes every child a peer; wrap th
|
|
|
232
232
|
space the groups. Worst where both items are small grey glyphs on one line, because nothing but
|
|
233
233
|
spacing distinguishes them.
|
|
234
234
|
|
|
235
|
+
**The inverted case is easiest to measure and easiest to miss: a control sitting directly on a
|
|
236
|
+
list.** An action above a `List` with nothing between them measures 0, against the 1px divider
|
|
237
|
+
INSIDE the list — so the boundary between two different things is smaller than the boundary
|
|
238
|
+
between two rows of one thing, and the button reads as the list's first row. Measure the CTA's
|
|
239
|
+
bottom against the first row's top on any panel built as *action, then list*; it is a per-screen
|
|
240
|
+
defect, so finding it on one panel is a reason to sweep the siblings.
|
|
241
|
+
|
|
235
242
|
→ [composition.md](./composition.md) §"Touch & whitespace".
|
|
236
243
|
|
|
237
244
|
### 5. State diff
|
|
@@ -675,6 +682,27 @@ its whole subtree on each background refresh.
|
|
|
675
682
|
|
|
676
683
|
→ [composition.md](./composition.md) §"A list stays fast by keeping IDENTITY, not by memoizing".
|
|
677
684
|
|
|
685
|
+
### 9d. A SEMANTIC section drawn as an ordinary one — and the primitive nobody reached for
|
|
686
|
+
Probes 1 and 2 group by shape and find each group internally consistent. The defect neither sees
|
|
687
|
+
is a section whose MEANING is special — destructive, warning, empty, generated — rendered in the
|
|
688
|
+
same heading treatment, on the same ground, as the ordinary sections beside it. Every measurement
|
|
689
|
+
passes, and the meaning is carried by the heading's WORDS alone: delete the word "Danger" and
|
|
690
|
+
nothing on screen still says it.
|
|
691
|
+
|
|
692
|
+
**Collect**, per section on the surface: heading `rung / weight / ink`, the section's own
|
|
693
|
+
background and border, and the treatment of the action inside it. **The signature** is a
|
|
694
|
+
semantically-marked section byte-identical to its neighbours — and, downstream of that, its
|
|
695
|
+
action reading as the FAINTEST control on a screen whose ordinary Save is solid (probe 2's
|
|
696
|
+
singleton ground, arrived at from the other end).
|
|
697
|
+
|
|
698
|
+
**The cheaper detection is structural: grep the kit for a component named after the meaning
|
|
699
|
+
before composing one.** A section the kit already ships — `DangerZone`, `EmptyState` — hand-rolled
|
|
700
|
+
out of a divider, a heading and a button loses more than its treatment: it loses the SLOTS the
|
|
701
|
+
primitive requires, which is why hand-rolled destructive sections routinely carry no consequence
|
|
702
|
+
line at all. If a screen's section has a name, that name is the thing to search for. And a
|
|
703
|
+
component that exists and is used NOWHERE is the same finding seen from the kit's side — worth a
|
|
704
|
+
sweep, not a second copy.
|
|
705
|
+
|
|
678
706
|
### 10. Microcopy
|
|
679
707
|
- **No punctuation doing a word's job.** ` · ` is banned outright — it claims a relationship while
|
|
680
708
|
refusing to name it, and it is the loudest templated-metadata tell there is. If a mark cannot be
|
package/package.json
CHANGED
package/src/filter_band.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContext, useContext, useState, type ReactNode } from "react";
|
|
1
|
+
import { Children, createContext, useContext, useState, type ReactNode } from "react";
|
|
2
2
|
import { View } from "react-native";
|
|
3
3
|
import { Button } from "./button";
|
|
4
4
|
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "./dialog";
|
|
@@ -58,6 +58,14 @@ export function FilterBand(props: FilterBandProps) {
|
|
|
58
58
|
const t = useLoticsLocale().filterBand;
|
|
59
59
|
const { small } = useScreenSize();
|
|
60
60
|
const [open, setOpen] = useState(false);
|
|
61
|
+
// WHETHER THERE IS ANYTHING TO COLLAPSE. `Children.toArray` drops the nulls a
|
|
62
|
+
// caller renders for a facet that has nothing to offer — a status filter whose
|
|
63
|
+
// options are all in view, a round picker before any round is recorded — so a
|
|
64
|
+
// band that draws no chips wide must not draw a button narrow. It did: the wide
|
|
65
|
+
// branch renders `children` and gets nothing, the narrow one rendered the
|
|
66
|
+
// trigger unconditionally, and the two presentations stopped agreeing at exactly
|
|
67
|
+
// the width where a control that opens an empty sheet costs the most.
|
|
68
|
+
const hasFilters = Children.toArray(children).length > 0;
|
|
61
69
|
|
|
62
70
|
if (!small) {
|
|
63
71
|
return (
|
|
@@ -77,14 +85,16 @@ export function FilterBand(props: FilterBandProps) {
|
|
|
77
85
|
return (
|
|
78
86
|
<View testID={testID} style={{ flexDirection: "row", alignItems: "center", gap: SPACE.sm }}>
|
|
79
87
|
{search != null && <View style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}>{search}</View>}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
{hasFilters && (
|
|
89
|
+
<Button
|
|
90
|
+
color="secondary"
|
|
91
|
+
title={activeCount > 0 ? `${t.filters} (${activeCount})` : t.filters}
|
|
92
|
+
onPress={() => setOpen(true)}
|
|
93
|
+
testID={testID ? `${testID}-filters` : undefined}
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
86
96
|
{action}
|
|
87
|
-
<Dialog open={open} onOpenChange={setOpen} width="92%" maxWidth={520}>
|
|
97
|
+
<Dialog open={open && hasFilters} onOpenChange={setOpen} width="92%" maxWidth={520}>
|
|
88
98
|
<DialogHeader>
|
|
89
99
|
<DialogHeaderTitle>{t.filters}</DialogHeaderTitle>
|
|
90
100
|
</DialogHeader>
|
package/src/list_item.tsx
CHANGED
|
@@ -171,18 +171,17 @@ function PressRow(props: {
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
const styles = StyleSheet.create({
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
// share a beat.
|
|
174
|
+
// 64 around a tight pair of 40 and an `md` (28px) leading mark — 12 a side,
|
|
175
|
+
// which is what lets a settings list of identity rows read as a list rather
|
|
176
|
+
// than a table. A title-only row keeps one 20px line centred in the same box,
|
|
177
|
+
// so the two shapes share a beat. `minHeight`, not `height`: a `description`
|
|
178
|
+
// node the caller supplies (a chip row, two stacked facts) is taller than a
|
|
179
|
+
// line of text, and a fixed height clips it.
|
|
181
180
|
container: {
|
|
182
181
|
flexDirection: "row",
|
|
183
182
|
alignItems: "center",
|
|
184
183
|
gap: 8,
|
|
185
|
-
|
|
184
|
+
minHeight: 64,
|
|
186
185
|
paddingHorizontal: 8,
|
|
187
186
|
marginHorizontal: -8,
|
|
188
187
|
borderRadius: 12,
|
package/src/switcher.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useCallback, useState } from "react";
|
|
2
2
|
import { StyleSheet } from "react-native";
|
|
3
|
+
import { Avatar } from "./avatar";
|
|
3
4
|
import { Icon } from "./icon";
|
|
4
5
|
import { MenuButton } from "./menu_button";
|
|
5
6
|
import { PressableHighlight } from "./pressable_highlight";
|
|
@@ -12,6 +13,15 @@ import { CONTROL_HEIGHT, CONTROL_RADIUS } from "./control_surface";
|
|
|
12
13
|
export interface SwitcherItem {
|
|
13
14
|
id: string;
|
|
14
15
|
label: string;
|
|
16
|
+
/**
|
|
17
|
+
* An identity image for this item. Optional, and optional PER ITEM rather than
|
|
18
|
+
* per switcher: the marks appear as soon as ONE item carries an image, and an
|
|
19
|
+
* item without one falls back to `Avatar`'s initials. Letting the mark appear
|
|
20
|
+
* only on the items that have a picture is the alternative, and it shifts every
|
|
21
|
+
* unmarked label sideways — so the one entry nobody has photographed is also the
|
|
22
|
+
* one that stops lining up with the rest of the list.
|
|
23
|
+
*/
|
|
24
|
+
imageUri?: string;
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
interface SwitcherProps {
|
|
@@ -61,17 +71,29 @@ export function Switcher(props: SwitcherProps) {
|
|
|
61
71
|
[currentId, onSelect],
|
|
62
72
|
);
|
|
63
73
|
|
|
64
|
-
|
|
74
|
+
// `minItemsToRender` can be lowered to 1 — a switcher that always shows is how a
|
|
75
|
+
// hard scope tells the reader which one they are in — so the empty set is the
|
|
76
|
+
// only one left to answer for, and there is nothing to render for it.
|
|
77
|
+
if (items.length === 0 || items.length < minItemsToRender) return null;
|
|
65
78
|
|
|
66
|
-
const current = items.find((item) => item.id === currentId);
|
|
67
|
-
const
|
|
79
|
+
const current = items.find((item) => item.id === currentId) ?? items[0];
|
|
80
|
+
const withMarks = items.some((item) => item.imageUri);
|
|
81
|
+
// The `md` rung is `CONTROL_CONTENT_HEIGHT` — what a 40px band seats, which
|
|
82
|
+
// both the trigger and a `MenuButton` row are. `square`, because the things a
|
|
83
|
+
// switcher switches BETWEEN are organizations, workspaces and projects; a disc
|
|
84
|
+
// would file them as people.
|
|
85
|
+
const mark = (item: SwitcherItem) =>
|
|
86
|
+
withMarks ? (
|
|
87
|
+
<Avatar shape="square" size="md" name={item.label} source={item.imageUri ? { uri: item.imageUri } : undefined} />
|
|
88
|
+
) : undefined;
|
|
68
89
|
|
|
69
90
|
return (
|
|
70
91
|
<Popover open={open} onOpenChange={setOpen} side={side} align={align}>
|
|
71
92
|
<PopoverTrigger>
|
|
72
93
|
<PressableHighlight focusRing style={[styles.trigger, { maxWidth: maxTriggerWidth }]}>
|
|
94
|
+
{mark(current)}
|
|
73
95
|
<Text size="sm" weight="medium" color="muted" numberOfLines={1}>
|
|
74
|
-
{label}
|
|
96
|
+
{current.label}
|
|
75
97
|
</Text>
|
|
76
98
|
<Icon name="chevrons-up-down" size={14} color={colors.zinc["500"]} />
|
|
77
99
|
</PressableHighlight>
|
|
@@ -81,6 +103,7 @@ export function Switcher(props: SwitcherProps) {
|
|
|
81
103
|
{items.map((item) => (
|
|
82
104
|
<MenuButton
|
|
83
105
|
key={item.id}
|
|
106
|
+
icon={mark(item)}
|
|
84
107
|
title={item.label}
|
|
85
108
|
onPress={() => handleSelect(item.id)}
|
|
86
109
|
right={
|