@lotics/ui 44.14.0 → 45.3.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.
Files changed (45) hide show
  1. package/AGENTS.md +9 -2
  2. package/MIGRATION.md +61 -5
  3. package/docs/ai_patterns.md +2 -3
  4. package/docs/catalog.md +40 -17
  5. package/docs/composition.md +143 -42
  6. package/docs/data_entry.md +15 -8
  7. package/docs/reviewing.md +24 -12
  8. package/docs/templates.md +1 -1
  9. package/package.json +10 -1
  10. package/src/accordion.tsx +2 -2
  11. package/src/avatar_group.tsx +1 -1
  12. package/src/avatar_size.ts +2 -3
  13. package/src/card_select_item.tsx +7 -6
  14. package/src/color_tokens.ts +22 -6
  15. package/src/combobox.tsx +11 -1
  16. package/src/control_surface.ts +22 -16
  17. package/src/date_stamp.tsx +1 -3
  18. package/src/detail_row.tsx +4 -5
  19. package/src/dialog.tsx +13 -6
  20. package/src/drawer.tsx +18 -4
  21. package/src/filter_chip.tsx +1 -2
  22. package/src/finding.tsx +24 -7
  23. package/src/form_markdown_editor.tsx +15 -0
  24. package/src/heading_altitude.ts +65 -0
  25. package/src/inline_markdown.tsx +49 -76
  26. package/src/inline_text_input.tsx +4 -5
  27. package/src/ledger.tsx +3 -3
  28. package/src/locale.tsx +48 -0
  29. package/src/markdown.web.tsx +3 -6
  30. package/src/markdown_editor.css +144 -0
  31. package/src/markdown_editor.tsx +29 -0
  32. package/src/markdown_editor.web.tsx +89 -0
  33. package/src/markdown_editor_props.ts +29 -0
  34. package/src/markdown_toolbar.web.tsx +231 -0
  35. package/src/matrix.tsx +1 -1
  36. package/src/modal.tsx +10 -1
  37. package/src/popover.tsx +151 -142
  38. package/src/pressable_row.tsx +24 -15
  39. package/src/reference_field.tsx +11 -9
  40. package/src/section_heading.tsx +57 -71
  41. package/src/stepper.tsx +2 -3
  42. package/src/summary.tsx +3 -3
  43. package/src/table.tsx +4 -1
  44. package/src/text_utils.ts +3 -4
  45. package/src/timeline.tsx +3 -4
@@ -7,7 +7,7 @@ import { formatDate } from "./format_date";
7
7
  import { InlineEditView } from "./inline_edit";
8
8
  import { InlineStatic } from "./inline_static";
9
9
  import { Popover, PopoverContent, PopoverFooter, PopoverHeader } from "./popover";
10
- import { DialogSectionHeadingTitle } from "./section_heading";
10
+ import { SectionHeadingTitle } from "./section_heading";
11
11
  import { Text } from "./text";
12
12
  import { TextInputField } from "./text_input_field";
13
13
  import { TextLink } from "./text_link";
@@ -256,19 +256,21 @@ export function ReferenceField(props: ReferenceFieldProps) {
256
256
  </View>
257
257
  }
258
258
  />
259
- {/* THE PEEK'S FORMAT — the kit's own grammar, not a bespoke one. A popover
260
- is dialog-scale, so the identity takes the ramp's dialog rung
261
- (`DialogSectionHeadingTitle`, ####) with the code as its description
262
- rather than a hand-picked font weight; the facts are `DetailRow`s, which
259
+ {/* THE PEEK'S FORMAT — the kit's own grammar, not a bespoke one. The
260
+ identity is a plain `SectionHeadingTitle` with the code as its
261
+ description rather than a hand-picked font weight — a popover is
262
+ dialog-scale, so the ramp puts it on the `####` rung by itself, and
263
+ this call site no longer names the surface it is standing
264
+ on; the facts are `DetailRow`s, which
263
265
  is what label-beside-value IS everywhere else on this page; and the
264
266
  verbs sit in the popover's own pinned footer rather than floating after
265
267
  the last fact.
266
268
 
267
269
  WIDTH COMES FROM THE FIELD (`inheritTriggerWidth`), not a constant. A
268
270
  fixed 320 was "every peek in an app is the same object", and the object
269
- it was actually the same as was nothing on screen: measured on a record
270
- page, a 558px field opened a 320px panel left-aligned under it, stopping
271
- 238px short of the edge the reader's eye had just travelled to. A
271
+ it was actually the same as was nothing on screen: a wide field opened a
272
+ narrow panel left-aligned under it, stopping short of the edge the
273
+ reader's eye had just travelled to. A
272
274
  popover narrower than the control that opened it reads as belonging to
273
275
  something else. `InlineSelect` — the same shape, an inline field opening
274
276
  a `triggerRef` popover — already inherits its field's width, so the two
@@ -294,7 +296,7 @@ export function ReferenceField(props: ReferenceFieldProps) {
294
296
  is never what scrolls out of view. */}
295
297
  <PopoverContent style={{ maxHeight: 420 }}>
296
298
  <PopoverHeader>
297
- <DialogSectionHeadingTitle description={code}>{name}</DialogSectionHeadingTitle>
299
+ <SectionHeadingTitle description={code}>{name}</SectionHeadingTitle>
298
300
  </PopoverHeader>
299
301
  <View style={{ gap: 12 }}>
300
302
  {/* ONE geometry for both modes — the table's own 40px band, which is
@@ -1,8 +1,9 @@
1
1
  import { View, type StyleProp, type ViewStyle } from "react-native";
2
- import { Text, type HeadingLevel } from "./text";
2
+ import { Text, type HeadingLevel, type TextSize } from "./text";
3
3
  import { Icon, type IconName } from "./icon";
4
4
  import { InfoPopover } from "./info_popover";
5
5
  import { useLoticsLocale } from "./locale";
6
+ import { useHeadingAltitude, type HeadingAltitude } from "./heading_altitude";
6
7
 
7
8
  // The card-less section header — the bare-canvas sibling of `CardHeader`, built
8
9
  // the same compound way. A title (+ optional leading icon / description) on one
@@ -117,9 +118,15 @@ export interface SubsectionHeadingTitleProps {
117
118
  info?: string;
118
119
  }
119
120
 
120
- /** The subsection title — ALWAYS `###` (lg semibold), the fixed step under the
121
- * xl section title and above the md/sm body. Grows to push siblings (a
122
- * `SectionHeadingMeta`, a `Badge`, an action) to the right edge. */
121
+ /** The subsection title — ALWAYS `###` (lg semibold), the fixed step under a
122
+ * PAGE's xl section title and above the md/sm body. Grows to push siblings (a
123
+ * `SectionHeadingMeta`, a `Badge`, an action) to the right edge.
124
+ *
125
+ * It has one altitude, not two: inside a PANEL the section rung is already
126
+ * the ramp's floor (`####` md), so a subsection there would out-rank its own
127
+ * section. A named group inside a panel's section is `Text size="sm"
128
+ * weight="semibold"` — a panel is one surface, and a second heading level in it
129
+ * is a document the reader is not reading. */
123
130
  export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
124
131
  const { children, description, level = 3, info } = props;
125
132
  const words = useLoticsLocale();
@@ -144,64 +151,6 @@ export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
144
151
  );
145
152
  }
146
153
 
147
- export interface DialogSectionHeadingTitleProps {
148
- children: React.ReactNode;
149
- /** Optional muted line under the title, as on `SectionHeadingTitle`. */
150
- description?: string;
151
- /** Leading glyph, as on `SectionHeadingTitle`. */
152
- icon?: IconName;
153
- /** Heading rank. Defaults to 4 — one level under `SubsectionHeadingTitle`'s 3. */
154
- level?: HeadingLevel;
155
- /** An ⓘ popover after the title, as on the two rungs above. */
156
- info?: string;
157
- }
158
-
159
- /**
160
- * The section title for a surface hosted in a DIALOG — ALWAYS `####` (md semibold), the fourth
161
- * rung of the same fixed ladder.
162
- *
163
- * It exists because `DialogHeaderTitle` is `lg`, which is the rung `SubsectionHeadingTitle`
164
- * occupies: a dialog holding a multi-section surface has its own title tying with, or being
165
- * outranked by, its content. The ladder stays ABSOLUTE — this is one more fixed size, never a
166
- * knob on an existing rung. Do not reach for a `size`/`compact` prop on the titles above; the same
167
- * rank rendering at two sizes is exactly what the fixed ramp prevents.
168
- *
169
- * Compose it with the SUBSECTION layout — `Subsection` › `SubsectionHeading` › this, inside a
170
- * `SubsectionStack`. Only the type size steps down; the 12px body gap and the 32px between-group
171
- * beat are already right for a dialog, so duplicating them under a `Dialog*` name would add three
172
- * exports that differ from their siblings in nothing.
173
- *
174
- * <SubsectionStack divided>
175
- * <Subsection>
176
- * <SubsectionHeading>
177
- * <DialogSectionHeadingTitle info="…">Usage</DialogSectionHeadingTitle>
178
- * <SectionHeadingMeta>Aug 2 – Sep 2</SectionHeadingMeta>
179
- * </SubsectionHeading>
180
- * {body}
181
- * </Subsection>
182
- * </SubsectionStack>
183
- */
184
- export function DialogSectionHeadingTitle(props: DialogSectionHeadingTitleProps) {
185
- const { children, description, icon, level = 4, info } = props;
186
- const words = useLoticsLocale();
187
- return (
188
- <View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
189
- <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
190
- {icon ? <Icon name={icon} size={16} /> : null}
191
- <Text level={level} size="md" weight="semibold">
192
- {children}
193
- </Text>
194
- {info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
195
- </View>
196
- {description ? (
197
- <Text color="zinc-500" size="sm">
198
- {description}
199
- </Text>
200
- ) : null}
201
- </View>
202
- );
203
- }
204
-
205
154
  export interface SectionHeadingProps {
206
155
  children: React.ReactNode;
207
156
  style?: StyleProp<ViewStyle>;
@@ -219,7 +168,15 @@ export interface SectionHeadingTitleProps {
219
168
  children: React.ReactNode;
220
169
  description?: string;
221
170
  icon?: IconName;
222
- /** Heading rank. Defaults to 2 — typical for page-level section titles. */
171
+ /**
172
+ * Heading rank, when the surface's own outline needs one the altitude does not
173
+ * give it. Leave it unset: the default is the rank of whichever rung the
174
+ * altitude selected (`2` on a page, `4` in a panel), so an outline is
175
+ * correct without anyone thinking about it.
176
+ *
177
+ * It does NOT move the type size — the ramp is absolute, and a rank rendering
178
+ * at two sizes is exactly what a fixed ladder exists to prevent.
179
+ */
223
180
  level?: HeadingLevel;
224
181
  /** Title weight. Defaults to `semibold` — a section title must separate from
225
182
  * the body rows at a glance (medium blends in; it remains as an opt-down for
@@ -230,21 +187,50 @@ export interface SectionHeadingTitleProps {
230
187
  info?: string;
231
188
  }
232
189
 
233
- /** The title block — ALWAYS `##` (xl semibold): the heading ramp is the fixed
234
- * markdown ladder `#` xxl (`RecordSummary`/`PageHeader`) `##` xl (section)
235
- * `###` lg (`SubsectionHeadingTitle`) over the sm body — no per-surface size
236
- * knobs, one outline everywhere. Optional muted `description` line; grows to
237
- * push any sibling actions to the right edge. */
190
+ /**
191
+ * THE SECTION RUNG, per altitude the two fixed points of one ladder, written
192
+ * down once.
193
+ *
194
+ * On a PAGE the section title is `##` (xl), under the `#` xxl identity band.
195
+ * Inside a PANEL it is `####` (md), under the container's `lg` chrome title —
196
+ * the ladder does not gain a knob, it gains a second fixed point, chosen by
197
+ * where the heading IS rather than by what the caller remembered to pass.
198
+ *
199
+ * The glyph steps with the type: a 20px icon beside 16px text is a mark that
200
+ * outweighs the words it introduces.
201
+ */
202
+ const SECTION_RUNG = {
203
+ page: { size: "xl", level: 2, icon: 20 },
204
+ panel: { size: "md", level: 4, icon: 16 },
205
+ } as const satisfies Record<HeadingAltitude, { size: TextSize; level: HeadingLevel; icon: number }>;
206
+
207
+ /** The title block — the heading ramp's SECTION rung, on the fixed markdown
208
+ * ladder `#` xxl (`RecordSummary`/`PageHeader`) → `##` xl (section) → `###` lg
209
+ * (`SubsectionHeadingTitle`) over the sm body. No size knob: which rung it
210
+ * renders is decided by the SURFACE, not the call site — `##` xl on a page,
211
+ * `####` md inside a panel's content region, where the container's own title
212
+ * is already `lg`. A caller never says which; the container publishes it (see
213
+ * `heading_altitude.ts`), so the same heading is correct on a page and in a
214
+ * drawer without being written twice.
215
+ *
216
+ * Inside a panel this rung is the ramp's FLOOR — a pane is one surface,
217
+ * not a document. A named group inside a section there is `Text size="sm"
218
+ * weight="semibold"`, not a `SubsectionHeadingTitle`, which is `lg` at every
219
+ * altitude and would out-rank the section holding it.
220
+ *
221
+ * Optional muted `description` line; grows to push any sibling actions to the
222
+ * right edge. */
238
223
  export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
239
- const { children, description, icon, level = 2, weight = "semibold", info } = props;
224
+ const { children, description, icon, level, weight = "semibold", info } = props;
240
225
  // Hoisted — a hook inside the `info ?` ternary breaks hook ordering the
241
226
  // render `info` appears or disappears.
242
227
  const words = useLoticsLocale();
228
+ const rung = SECTION_RUNG[useHeadingAltitude()];
243
229
  return (
244
230
  <View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
245
231
  <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
246
- {icon ? <Icon name={icon} size={20} /> : null}
247
- <Text level={level} weight={weight} size="xl">
232
+ {icon ? <Icon name={icon} size={rung.icon} /> : null}
233
+ <Text level={level ?? rung.level} weight={weight} size={rung.size}>
248
234
  {children}
249
235
  </Text>
250
236
  {info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
package/src/stepper.tsx CHANGED
@@ -288,9 +288,8 @@ export function Step(props: StepProps) {
288
288
  on the first content row, so there is a `markerTopOffset` band of
289
289
  empty column above it. A single below-the-marker segment cannot
290
290
  reach into the NEXT step's band, so the line broke at every joint
291
- ~4px under each marker (this segment's old top margin) plus the
292
- next one's offset above it. Measured on the record template: 4px
293
- and 3px, at every stage, which reads as dashes rather than a spine.
291
+ a gap under each marker (this segment's old top margin) plus the
292
+ next one's offset above it, which reads as dashes rather than a spine.
294
293
  The upper segment fills that band, so the line is continuous from
295
294
  the first marker to the last. It also replaces the column's old
296
295
  `paddingTop`, which is why the marker lands in the same place. */}
package/src/summary.tsx CHANGED
@@ -231,9 +231,9 @@ const styles = StyleSheet.create({
231
231
  // 12, and it is half of what a page must put BETWEEN this band and its
232
232
  // neighbours. The band's parts are one group — a figure, the shape of what it
233
233
  // counts, and that shape's key — so they have to sit closer to each other than
234
- // the band sits to the register below it. Measured at 14 against a 16px page
235
- // gap, the ratio was 1.14: every block on the screen an equal peer, nothing
236
- // grouped, which is what "no rhythm" is when you measure it.
234
+ // the band sits to the register below it. At near-parity with the page gap
235
+ // every block on the screen is an equal peer, nothing
236
+ // grouped, which is what "no rhythm" means.
237
237
  root: { gap: 12 },
238
238
  header: {
239
239
  flexDirection: "row",
package/src/table.tsx CHANGED
@@ -612,7 +612,10 @@ const styles = StyleSheet.create({
612
612
  // A hairline under the column header anchors the columns; the rows below it are
613
613
  // Divider-separated.
614
614
  headerBandFilled: {
615
- backgroundColor: colors.accent_wash,
615
+ // The literal neutral, not `accent_wash`: this band is a lid on the columns,
616
+ // never a "you are here", and reading it through the brand token made a
617
+ // themed app tint a piece of pure furniture.
618
+ backgroundColor: colors.zinc[100],
616
619
  // The band bleeds to the row wash's width so its edges line up with a hovered
617
620
  // row beneath it; without this the lid is narrower than the rows it caps.
618
621
  marginHorizontal: -ROW_WASH_BLEED,
package/src/text_utils.ts CHANGED
@@ -103,10 +103,9 @@ export const inputTextStyleWeb = {
103
103
  // THE INK IS PART OF THE CONTRACT, because leaving it out does not mean
104
104
  // "inherit" — a native <input> ignores inherited colour and falls to the UA
105
105
  // default, PURE BLACK, while every resting value beside it renders `zinc-900`.
106
- // So a field's own text changed colour the instant it was focused: measured
107
- // rgb(24,24,27) at rest against rgb(0,0,0) editing, on an unremarkable text
108
- // field. Nothing moved which is why box geometry, insets, line boxes, fonts
109
- // and five widths all measured identical — but at 14px a darkness change
106
+ // So a field's own text changed colour the instant it was focused — the
107
+ // resting ink against the UA's black. Nothing MOVED, which is why every
108
+ // geometry check came back identical, but at body size a darkness change
110
109
  // re-weights the antialiasing of every stroke, and the eye reads that as the
111
110
  // text shifting on focus. An EMPTY field hid it completely, since a placeholder
112
111
  // sets its own colour and therefore never changed.
package/src/timeline.tsx CHANGED
@@ -17,10 +17,9 @@ export interface TimelineItem {
17
17
  /**
18
18
  * The row's identity line — CLAMPED TO TWO LINES, because it is a scan target
19
19
  * before it is a sentence. A feed is the one place user-authored prose lands in
20
- * a row primitive, and unclamped it eats the section: measured on a real
21
- * interaction log, one 700-character summary drew a 180px label — nine lines
22
- * for one entry, and the disc, which centres on the label row, ended up 80px
23
- * below the first line it was meant to sit beside.
20
+ * a row primitive, and unclamped it eats the section: one long summary can
21
+ * draw a label many lines deep, and the disc which centres on the label row
22
+ * ends up far below the first line it was meant to sit beside.
24
23
  *
25
24
  * The clamp holds while the row is COLLAPSED and lifts when it opens, so a
26
25
  * long label is readable in full without being copied anywhere. **Never repeat