# @lotics/ui — migration notes

**Crossing into 49.0.0.** Every breaking change in this major, with the call site before and after.
The current contract is `llms.txt` + `AGENTS.md` + `docs/` — this file exists only to move an app
from the release before this one to this one, and it is the one file in the tarball a reader opens
once. Older majors are `MIGRATION_ARCHIVE.md`, which stays in the repo: an app on 47 upgrades
through 48 first, and reads those notes there.

## 49.0.0

**ONE CONTROL PER INPUT, TWO COMMIT MODES.** The kit shipped a second family — `InlineTextInput`,
`InlineNumberInput`, `InlineSelect`, `InlineMemberSelect`, `InlineDatePicker`, `InlineMarkdown` —
that shadowed the plain controls: the same data role, a second component, a second sheet and a
second anatomy, which drifted. There is now one component per input, and the mode is read off which
callback you pass:

```tsx
<TextInput value={name} onValueChange={setName} />          {/* a FORM owns the draft */}
<TextInput value={name} onSave={saveName} />                {/* the FIELD owns the draft */}
```

`onSave` is the whole of what the `Inline*` family did — async, saving and a refused save stated in
place, Escape reverts, blur or Enter commits, and the write registered so an act pressed in the same
gesture waits for it. Passing both callbacks does not compile, and a caller must never FLIP an
instance between them: the branch picks a different component and React remounts the field under
the reader. Select and the date picker route through the same commit engine for the first time, so a
button pressed while one of them saves now waits for the write instead of reading the pre-edit row.

### The role becomes the name

Each row is a rename plus `onSave`. Nothing else changes unless the last column says so.

| Was | Is | Also |
|---|---|---|
| `InlineTextInput` (`@lotics/ui/inline_text_input`) | `TextInput onSave` (`@lotics/ui/text_input`) | `struck` / `link` are `reading="struck" \| "link"`; `autoGrow` implies multiline; the ✕ hands focus back to the input itself. |
| `InlineNumberInput` (`@lotics/ui/inline_number_input`) | `NumberInput onSave` (`@lotics/ui/number_input`) | `format` is `Intl.NumberFormatOptions` and nothing else — see the trap below. `step` now reaches the field, which the wrapper dropped. |
| `InlineSelect` (`@lotics/ui/inline_select`) | `Select onSave` (`@lotics/ui/select`) | Escape REVERTS a multi edit (every close used to commit); clearing answers `null` rather than `""`; a value not in `options` rests as the placeholder. |
| `InlineMemberSelect` (`@lotics/ui/inline_member_select`) | `MemberSelect onSave` (`@lotics/ui/member_select`) | `MEMBER_UNASSIGNED` and `unassignedLabel` are gone: the capability is `clearable` with `emptyLabel`. |
| `InlineDatePicker` (`@lotics/ui/inline_date_picker`) | `DatePicker onSave` (`@lotics/ui/date_picker`) | The segmented field is the resting face in BOTH modes, so a date reads as locale-ordered segments rather than "22 May 2026". Ranges are type-refused on the self-committing arm. `DatePickerLabels.invalidTime` is deleted. |
| `InlineMarkdown` (`@lotics/ui/inline_markdown`) | `MarkdownEditor surface="field"` (`@lotics/ui/markdown_editor`) | `variant` is `surface` (`"document" \| "field"`), `data-variant` is `data-surface`, `toolbar` defaults to `surface === "document"`, and `NOTE_LINES` is cut. |
| `InlineEditFrame` (`@lotics/ui/inline_edit`) | `FieldSurface` (`@lotics/ui/field_surface`) | The box a field wears when it carries verbs, a spinner, ✓/✕ or a refused save. It draws NO value. |
| `InlineEditView` (`@lotics/ui/inline_edit`) | `FieldSurface` with `onPress` | The trigger arm: the surface itself is the focusable control and `children` is the node it shows. |

### One entry per role — the merges

| Was | Now | The prop that replaces it |
|---|---|---|
| `useInlineEdit` (`@lotics/ui/inline_edit`) | `useFieldCommit` (`@lotics/ui/use_field_commit`) | none — the same hook, moved and renamed. Every control drives its draft through it. |
| `INLINE_CONTROL_HEIGHT` (`@lotics/ui/inline_edit`) | `CONTROL_HEIGHT` (`@lotics/ui/field_surface`) | none — the same 40. |
| `InlineEditVariant`, `InlineEditControls` (`@lotics/ui/inline_edit`) | `CommitControls` (`@lotics/ui/commit_mode`) | The rest axis is gone (below); `controls` keeps `"blur"` and `"buttons"`. |
| `useInlineEditFocusRestore`, `shouldOpenOnFocus`, `shouldRestoreFocusOnClose` (`@lotics/ui/inline_focus`, `@lotics/ui/interaction_modality`) | nothing | They answered "does keyboard focus OPEN this editor" and "where does focus go when it closes". Every field is its own editor now, mounted at rest, so focus lands in it and nothing unmounts. |
| `inlineValueTextStyle` (`@lotics/ui/inline_edit`) | nothing | `InlineValue` draws the read-only value; the style had no caller. |

### Subpaths the cull removed

Eight specifiers stop resolving. Each of the first seven is the row above it: import the plain
control instead.

`@lotics/ui/inline_text_input` · `@lotics/ui/inline_number_input` · `@lotics/ui/inline_select` ·
`@lotics/ui/inline_member_select` · `@lotics/ui/inline_date_picker` · `@lotics/ui/inline_markdown` ·
`@lotics/ui/inline_edit` · `@lotics/ui/screen_nav`

`@lotics/ui/inline_button`, `@lotics/ui/inline_files` and `@lotics/ui/inline_value` are NOT in this
list and are unchanged. New: `@lotics/ui/commit_mode`, `@lotics/ui/field_surface`,
`@lotics/ui/use_field_commit`.

The eighth is the shell's bar of screens, and it goes with the slot that held it:

| Was | Is |
|---|---|
| `ScreenNav` | nothing. AN APP IS ONE REGISTER AND THE RECORDS IT OPENS, so a bar over one destination is a control that answers the question it asks. A screen that holds several destinations of its OWN wants `Tabs`, one rung down. |
| `<PageContent nav={…}>` | `<PageContent>` — the `nav` slot is gone; the register's own title heads the column. |
| `LoticsLocale["nav"].screens` | nothing — `nav.back` is that slice's one word. |

### Removed exports and peers

**The silent ones first** — these compile after a mechanical rename and then behave differently.

- **`NumberInput.format` no longer takes a function.** It is `Intl.NumberFormatOptions`, and it
  states what the box PRINTS and what its parser will TAKE BACK — two statements is how a field
  rests on a string it cannot read, and a typed "2,812" committed 2. A currency maps directly
  (`{ style: "currency", currency }`); a free-text UNIT does not, so it goes BESIDE the field.
  `displayAs` was considered and not added: a readout that only applies at rest re-introduces the
  swap this release deletes. `NumberInputVariant` and `tone` are gone with it.
- **`onClear` is deleted from all five controls.** The fact is `clearable`: the ✕ on a text field,
  the named empty ROW on a select or member select, and on a date or a number the licence for an
  emptied entry to commit the empty value instead of reverting. `TextInput`'s ✕ already emitted
  `""` itself, so `onClear` was a side channel that could disagree with it.
- **`variant` is retired from every control.** One word meant three things: weight on `TextInput`
  (`framed`/`ruled`/`bare`), shape on `NumberInput` (`field`/`stepper`), engine on `Select`
  (`rich`/`native`). `ruled` and `bare` are DELETED outright — there is one rest state and every
  editor is framed, because the frame is the permission. `NumberInput variant="stepper"` is
  `stepper`; `Select variant="native"` is `native`, its own union leaf which refuses `multi`,
  `searchable`, `allowCustom` and `onSave`.
- **`TextInput.onChangeText` is `onValueChange`**, which is what the other four already spelled.
  `defaultValue` is gone: `value` is required on both arms and a second source of truth inside a
  union is a field that disagrees with itself.
- **`Select.autoFocus` no longer opens the list.** It focuses the trigger, as it does on every other
  control; `defaultOpen` opens the list.
- **`includeEmptyOption` is `clearable` and `emptyOptionLabel` is `emptyLabel`** on `Select`,
  `MemberSelect` and `OptionList`. `SelectOnClose` is deleted — it was `SelectOnValueChange` under a
  second name. A single select's `onValueChange` / `onClose` now receive `T | null`: clearing
  answers the empty ANSWER, never `"" as T`.
- **`seamless` is gone from the public surface.** A control derives it from its own `actions`: with
  verbs the SURFACE owns the box and the control inside surrenders its own.
- **`RecordSummary` / `RecordPage` lose `onTitleSave`, and the headline metric loses `onSave` and
  `format`.** The record's name and its lead figure are identity, and each needed the sheet to
  re-point an input's four type properties onto the display line so the heading did not shrink to
  the control rung under the pointer. A field disguised as a display line lies about what it is:
  both are readings, and the fact that owns the number is where it changes.
  `RecordSummaryMetric` is now one arm, `{ label, value, tone?, note? }`.
- **`RecordContact` takes `onPress`.** An `href` that is a route this app owns reloads the document
  and throws away the app's state; an in-app destination is a callback, exactly as it is on
  `RecordFacts`' link fact and on `RecordChildren`. `href` stays for a real URL.
- **`ChargeLines.formatMoney` and `formatQuantity` are `currency`.** Half the band's figures are
  typed into a numeric field, which prints what it will read back — so the notation cannot be a
  function the field cannot invert. `ChargeLine.formatMoney` is deleted with them: a line never
  formatted its own, or two lines in one band disagree about a currency.
- **`DatePicker`'s empty value is `null`, in and out.** `""` is still accepted going in and is never
  emitted, so `onValueChange` and `onSave` take `string | null`.
- **`examples/tpl_task_board.tsx` is deleted.** `DataGrid` is unchanged and still ships.
- **`SectionHeadingTitle.leading` is deleted.** A heading is its words and, where the section is
  operable, the verb beside them; the slot before the words was the only way a glyph could still
  reach one, and it had no caller. A SUBJECT's own mark leads the surface that names that subject —
  a `RecordPage`'s header, a `CellStack`'s row — never a heading over a block of its fields.
- **`LoticsLocale.itinerary.continues` is the WORD, not the phrase** — `"continues"`, where it was
  `(title) => \`${title} · continues\``. The mark composes the two itself so the stay's NAME is the
  half that clamps at a phone's width: as one run it was the word that fell off the end, and the
  row then read as the stay happening twice.

**And the one thing that changes appearance with no compile error:** a record's editable DATE used
to rest as prose ("22 May 2026") and now rests as the segmented field, locale-ordered. Every caller
of `RecordFacts`, `MoneyBand` and a `DetailRow` date changes look in one release.

**Two more that compile and look different.** A record's `mark` rides the TITLE's line now, at the
rung its register row marks the same subject at; `display: "hero"` is what still takes a line of its
own, and it is the one to reach for where the record has a photograph — at the profile rung on its
own line, a mark holding nothing but the kind's glyph was 72px of empty grey above the name. And a
`Dialog` at a phone's width is a bottom SHEET as tall as its content rather than the whole screen: a
sheet holding one control no longer spends a viewport on it. A `takeover` is unchanged.

