nexus-shared 3.1.0 → 4.0.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 (234) hide show
  1. package/CHANGELOG.md +2268 -446
  2. package/LICENSE +21 -21
  3. package/README.md +1705 -1084
  4. package/dist/Client.Index.d.ts +17 -0
  5. package/dist/Client.Index.js +17 -0
  6. package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
  7. package/dist/Components/Documents/DocumentFrame.js +83 -0
  8. package/dist/Components/Documents/Menu.d.ts +4 -0
  9. package/dist/Components/Documents/Menu.js +40 -24
  10. package/dist/Components/Documents/PrintDocument.d.ts +95 -0
  11. package/dist/Components/Documents/PrintDocument.js +62 -0
  12. package/dist/Components/Documents/Tabs.d.ts +59 -0
  13. package/dist/Components/Documents/Tabs.js +70 -0
  14. package/dist/Components/Forms/ApiForm.js +19 -2
  15. package/dist/Components/Forms/Crud.d.ts +2 -2
  16. package/dist/Components/Forms/Crud.js +871 -377
  17. package/dist/Components/Forms/CrudActions.d.ts +136 -0
  18. package/dist/Components/Forms/CrudActions.js +184 -0
  19. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  20. package/dist/Components/Forms/CrudHistory.js +140 -0
  21. package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
  22. package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
  23. package/dist/Components/Forms/CrudParts.d.ts +30 -2
  24. package/dist/Components/Forms/CrudParts.js +56 -5
  25. package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
  26. package/dist/Components/Forms/CrudRowForms.js +114 -0
  27. package/dist/Components/Forms/CrudScopes.d.ts +28 -0
  28. package/dist/Components/Forms/CrudScopes.js +76 -0
  29. package/dist/Components/Forms/CrudTree.d.ts +90 -0
  30. package/dist/Components/Forms/CrudTree.js +363 -0
  31. package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
  32. package/dist/Components/Forms/CrudWorkspace.js +169 -0
  33. package/dist/Components/Forms/ExcelImport.js +3 -3
  34. package/dist/Components/Forms/Form.d.ts +4 -4
  35. package/dist/Components/Forms/Form.js +125 -16
  36. package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
  37. package/dist/Components/Forms/FormLeaveGuard.js +65 -0
  38. package/dist/Components/Forms/RecordFlows.d.ts +50 -0
  39. package/dist/Components/Forms/RecordFlows.js +282 -0
  40. package/dist/Components/Forms/SubmitForm.d.ts +37 -2
  41. package/dist/Components/Forms/SubmitForm.js +195 -10
  42. package/dist/Components/Inputs/CheckBox.js +1 -1
  43. package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
  44. package/dist/Components/Inputs/ChoiceField.js +28 -2
  45. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  46. package/dist/Components/Inputs/CodeBox.js +316 -0
  47. package/dist/Components/Inputs/DatePicker.js +4 -1
  48. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  49. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  50. package/dist/Components/Inputs/Dropdown.d.ts +13 -0
  51. package/dist/Components/Inputs/Dropdown.js +8 -5
  52. package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
  53. package/dist/Components/Inputs/DropdownParts.js +16 -4
  54. package/dist/Components/Inputs/Field.d.ts +7 -1
  55. package/dist/Components/Inputs/Field.js +2 -2
  56. package/dist/Components/Inputs/GroupForm.d.ts +6 -6
  57. package/dist/Components/Inputs/GroupForm.js +10 -11
  58. package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
  59. package/dist/Components/Inputs/InputRenderer.js +39 -31
  60. package/dist/Components/Inputs/NumberBox.js +4 -2
  61. package/dist/Components/Inputs/PictureInput.d.ts +41 -0
  62. package/dist/Components/Inputs/PictureInput.js +70 -0
  63. package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
  64. package/dist/Components/Inputs/RadioGroup.js +8 -3
  65. package/dist/Components/Inputs/RowsInput.d.ts +1 -1
  66. package/dist/Components/Inputs/RowsInput.js +18 -3
  67. package/dist/Components/Inputs/TabularForm.d.ts +5 -5
  68. package/dist/Components/Inputs/TabularForm.js +29 -29
  69. package/dist/Components/Inputs/TextArea.d.ts +6 -0
  70. package/dist/Components/Inputs/TextArea.js +14 -2
  71. package/dist/Components/Inputs/TextBox.js +4 -1
  72. package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
  73. package/dist/Components/Inputs/TextBoxTypes.js +17 -0
  74. package/dist/Components/Inputs/TimePicker.js +4 -1
  75. package/dist/Components/Inputs/TreeView.d.ts +107 -6
  76. package/dist/Components/Inputs/TreeView.js +513 -30
  77. package/dist/Components/Layouts/MessageBox.d.ts +15 -2
  78. package/dist/Components/Layouts/MessageBox.js +56 -7
  79. package/dist/Components/Layouts/Popup.d.ts +13 -6
  80. package/dist/Components/Layouts/Popup.js +37 -9
  81. package/dist/Components/Layouts/PopupDock.d.ts +3 -1
  82. package/dist/Components/Layouts/PopupDock.js +2 -1
  83. package/dist/Components/Layouts/PopupHost.js +5 -4
  84. package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
  85. package/dist/Components/Layouts/ThemePicker.js +16 -4
  86. package/dist/Components/Viewers/DataTable.js +205 -61
  87. package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
  88. package/dist/Components/Viewers/DataTableFilters.js +17 -2
  89. package/dist/Components/Viewers/DataTableParts.d.ts +46 -12
  90. package/dist/Components/Viewers/DataTableParts.js +62 -21
  91. package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
  92. package/dist/Components/Viewers/OwnerFiles.js +589 -0
  93. package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
  94. package/dist/Components/Viewers/QrCodeImage.js +15 -0
  95. package/dist/Components/Viewers/RecordCards.d.ts +16 -0
  96. package/dist/Components/Viewers/RecordCards.js +509 -0
  97. package/dist/Helpers/ApiModules.d.ts +3 -34
  98. package/dist/Helpers/ApiModules.js +3 -3
  99. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  100. package/dist/Helpers/ApiRoutes.js +31 -27
  101. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  102. package/dist/Helpers/ArrayHelpers.js +2 -0
  103. package/dist/Helpers/BusyHooks.d.ts +6 -0
  104. package/dist/Helpers/BusyHooks.js +39 -0
  105. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  106. package/dist/Helpers/CodeHelpers.js +332 -0
  107. package/dist/Helpers/CrudBackend.d.ts +114 -6
  108. package/dist/Helpers/CrudBackend.js +214 -11
  109. package/dist/Helpers/CrudColumns.d.ts +98 -0
  110. package/dist/Helpers/CrudColumns.js +160 -0
  111. package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
  112. package/dist/Helpers/CrudDetailsTabs.js +126 -0
  113. package/dist/Helpers/CrudHelpers.d.ts +130 -6
  114. package/dist/Helpers/CrudHelpers.js +304 -12
  115. package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
  116. package/dist/Helpers/CrudNarrowPick.js +157 -0
  117. package/dist/Helpers/CrudRowForms.d.ts +15 -0
  118. package/dist/Helpers/CrudRowForms.js +43 -0
  119. package/dist/Helpers/CrudScopes.d.ts +32 -0
  120. package/dist/Helpers/CrudScopes.js +79 -0
  121. package/dist/Helpers/DropdownHelpers.d.ts +3 -1
  122. package/dist/Helpers/DropdownHelpers.js +6 -2
  123. package/dist/Helpers/ExcelHelpers.d.ts +1 -1
  124. package/dist/Helpers/ExcelHelpers.js +12 -11
  125. package/dist/Helpers/FileAdapter.d.ts +78 -0
  126. package/dist/Helpers/FileAdapter.js +102 -0
  127. package/dist/Helpers/FilePrimaries.d.ts +48 -0
  128. package/dist/Helpers/FilePrimaries.js +138 -0
  129. package/dist/Helpers/FormConfirm.d.ts +32 -0
  130. package/dist/Helpers/FormConfirm.js +40 -0
  131. package/dist/Helpers/FormStore.d.ts +90 -5
  132. package/dist/Helpers/FormStore.js +318 -22
  133. package/dist/Helpers/InputConditions.d.ts +50 -0
  134. package/dist/Helpers/InputConditions.js +168 -0
  135. package/dist/Helpers/InputMemory.d.ts +106 -0
  136. package/dist/Helpers/InputMemory.js +385 -0
  137. package/dist/Helpers/InputNarrowing.d.ts +18 -0
  138. package/dist/Helpers/InputNarrowing.js +67 -0
  139. package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
  140. package/dist/Helpers/InputParamsHelpers.js +162 -71
  141. package/dist/Helpers/MessageBuilder.js +2 -0
  142. package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
  143. package/dist/Helpers/NexusDriveAdapter.js +207 -0
  144. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  145. package/dist/Helpers/NexusHelpers.js +1 -0
  146. package/dist/Helpers/NumberHelpers.d.ts +95 -0
  147. package/dist/Helpers/NumberHelpers.js +126 -0
  148. package/dist/Helpers/Permissions.js +2 -1
  149. package/dist/Helpers/QrCode.d.ts +26 -0
  150. package/dist/Helpers/QrCode.js +463 -0
  151. package/dist/Helpers/RecordCards.d.ts +205 -0
  152. package/dist/Helpers/RecordCards.js +378 -0
  153. package/dist/Helpers/RequestCache.d.ts +47 -0
  154. package/dist/Helpers/RequestCache.js +132 -0
  155. package/dist/Helpers/RowsStore.d.ts +37 -2
  156. package/dist/Helpers/RowsStore.js +128 -10
  157. package/dist/Helpers/ServerPreview.d.ts +69 -0
  158. package/dist/Helpers/ServerPreview.js +99 -0
  159. package/dist/Helpers/TableColumns.js +4 -3
  160. package/dist/Helpers/TableExport.d.ts +7 -1
  161. package/dist/Helpers/TableExport.js +21 -15
  162. package/dist/Helpers/TableFilters.d.ts +1 -1
  163. package/dist/Helpers/TableFilters.js +24 -23
  164. package/dist/Helpers/TableHelpers.d.ts +24 -3
  165. package/dist/Helpers/TableHelpers.js +58 -12
  166. package/dist/Helpers/TableJoins.d.ts +83 -0
  167. package/dist/Helpers/TableJoins.js +284 -0
  168. package/dist/Helpers/TableStore.d.ts +26 -1
  169. package/dist/Helpers/TableStore.js +73 -7
  170. package/dist/Helpers/TreeStore.d.ts +306 -11
  171. package/dist/Helpers/TreeStore.js +1048 -90
  172. package/dist/Helpers/UserPreferences.d.ts +8 -0
  173. package/dist/Helpers/UserPreferences.js +17 -0
  174. package/dist/Interfaces/CrudInterfaces.d.ts +512 -14
  175. package/dist/Interfaces/FileInterfaces.d.ts +162 -0
  176. package/dist/Interfaces/FileInterfaces.js +4 -0
  177. package/dist/Interfaces/FormInterfaces.d.ts +393 -29
  178. package/dist/Interfaces/FormInterfaces.js +79 -1
  179. package/dist/Interfaces/InputInterfaces.d.ts +39 -3
  180. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  181. package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
  182. package/dist/Interfaces/PreferenceInterfaces.js +2 -5
  183. package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
  184. package/dist/Interfaces/RecordInterfaces.js +1 -0
  185. package/dist/Interfaces/TableInterfaces.d.ts +160 -5
  186. package/dist/Services/ApiProxy.d.ts +36 -1
  187. package/dist/Services/ApiProxy.js +182 -52
  188. package/dist/Services/ApiProxyRules.d.ts +203 -0
  189. package/dist/Services/ApiProxyRules.js +426 -0
  190. package/dist/Services/ExcelApi.js +5 -0
  191. package/dist/Services/FileApi.d.ts +13 -0
  192. package/dist/Services/FileApi.js +29 -0
  193. package/dist/Services/ImageEditorLoader.d.ts +14 -0
  194. package/dist/Services/ImageEditorLoader.js +51 -0
  195. package/dist/Services/JoinSources.d.ts +80 -0
  196. package/dist/Services/JoinSources.js +150 -0
  197. package/dist/Services/ModuleImport.d.ts +7 -0
  198. package/dist/Services/ModuleImport.js +22 -0
  199. package/dist/Services/ServerApi.d.ts +13 -2
  200. package/dist/Services/ServerApi.js +8 -3
  201. package/dist/Shared.Index.d.ts +20 -0
  202. package/dist/Shared.Index.js +20 -0
  203. package/package.json +92 -85
  204. package/src/Styles/Nexus.Avatar.css +80 -79
  205. package/src/Styles/Nexus.Base.css +117 -117
  206. package/src/Styles/Nexus.Button.css +186 -180
  207. package/src/Styles/Nexus.Calendar.css +273 -273
  208. package/src/Styles/Nexus.Chat.css +1443 -1443
  209. package/src/Styles/Nexus.Choice.css +359 -343
  210. package/src/Styles/Nexus.Crud.css +542 -168
  211. package/src/Styles/Nexus.DateTime.css +81 -81
  212. package/src/Styles/Nexus.Dropdown.css +733 -725
  213. package/src/Styles/Nexus.Editor.css +295 -295
  214. package/src/Styles/Nexus.Excel.css +389 -389
  215. package/src/Styles/Nexus.Files.css +410 -0
  216. package/src/Styles/Nexus.Form.css +282 -279
  217. package/src/Styles/Nexus.Index.css +41 -37
  218. package/src/Styles/Nexus.Input.css +238 -4
  219. package/src/Styles/Nexus.Markdown.css +237 -237
  220. package/src/Styles/Nexus.Menu.css +246 -237
  221. package/src/Styles/Nexus.Popup.css +800 -724
  222. package/src/Styles/Nexus.Print.css +288 -0
  223. package/src/Styles/Nexus.QrCode.css +12 -0
  224. package/src/Styles/Nexus.Record.css +430 -0
  225. package/src/Styles/Nexus.Rows.css +637 -637
  226. package/src/Styles/Nexus.Slider.css +276 -276
  227. package/src/Styles/Nexus.Tab.Buttons.css +497 -432
  228. package/src/Styles/Nexus.Table.css +98 -35
  229. package/src/Styles/Nexus.Theme.Picker.css +59 -3
  230. package/src/Styles/Nexus.Themes.css +200 -194
  231. package/src/Styles/Nexus.Time.css +144 -144
  232. package/src/Styles/Nexus.Toast.css +263 -263
  233. package/src/Styles/Nexus.Tokens.css +94 -89
  234. package/src/Styles/Nexus.Tree.css +530 -431
package/CHANGELOG.md CHANGED
@@ -1,446 +1,2268 @@
1
- # Changelog
2
-
3
- Notable changes to `nexus-shared`. The **values** of exported constants count as API here: several of them are the
4
- Nexus backend's own numbers, and a value that differs from the backend by one does not fail loudly — it silently
5
- means the wrong thing on every record and every permission check.
6
-
7
- ## 3.1.0
8
-
9
- **New features, no code break.** Every export of `3.0.0` is still exported (49 are new: 33 from `nexus-shared`, 16 from
10
- `nexus-shared/client`), and no exported constant changed its value, so `^3.0.0` takes it. The owner chose the number
11
- on 2026-10-08. What can move for an app is its **stylesheet overrides**: these class names and custom properties were
12
- renamed or dropped, and an override of the old name no longer applies.
13
-
14
- | Was | Now |
15
- |---|---|
16
- | `.nx-columns__head`, `__title`, `__count`, `__foot` | `.nx-table-panel__head`, `__title`, `__count`, `__foot` |
17
- | `.nx-table-panel__body` | `.nx-popover__scroll` |
18
- | `.nx-chat-avatar`, `--nx-chat-avatar` | `.nx-avatar`, `--nx-avatar-size` |
19
- | `.nx-table__frame`'s border | on `.nx-table` |
20
- | `--nx-field-scrollbar`, `--nx-field-scrollbar-hover`, `--nx-dropdown-scrollbar`, `--nx-time-scrollbar` | gone: one thin bar everywhere |
21
- | `.nx-form__skeleton*` | gone: a loading form shows its real inputs, disabled |
22
- | `.nx-excel__refused` | gone: the import's report lists are `.nx-excel__problems` |
23
-
24
- - **Bulk insert, and rows added to an import (the owner, 2026-10-07).** `ExcelImport` and `openExcelImport` take
25
- `mode: "bulk"`: the import with no file, its new rows' editor opening with one empty row for the person to type
26
- into, sent to the same `Excel/Import` with the same checks, a problem then naming its row ("Row 3 · Region name").
27
- `DataTable` offers it as **Bulk insert** beside **Import from a spreadsheet**, and only beside an Add, as it is an Add
28
- of many rows (`TableExcel.bulk: false` leaves it out). A sheet with a required list cannot be typed in - a list's key
29
- is picked in the spreadsheet - and the panel says so; its file review takes no added rows either. Any other file's
30
- new rows take Add, Insert and Duplicate: an added row is numbered after every row the file had
31
- (`excelSheetRows(rows, columns, after)`), and one left empty is dropped, not refused. The new rows no longer fold
32
- away (folding unmounted the editor and lost every edit), the footer counts what will be sent, and the review is inert
33
- while an import is out.
34
- - **Nothing typed is lost unasked.** Closing the window, or Start over, over rows typed or changed in it asks first
35
- (`ExcelImportHandle.unsavedRows`); picking the entry again while its window is minimized brings that window back
36
- rather than a fresh one. An untouched date or time is no edit: `toInputValue` reads them as their inputs do.
37
- - **An import that wrote nothing keeps its rows** and lists under them each refusal, by row, in the backend's words:
38
- `readExcelResponse` keeps a refusal's report (`result`, as XLS_X044 sends it) instead of dropping it, so the panel no
39
- longer says only "No Region was added". Refusals read in row order.
40
- - `ExcelEditorRow`'s three own fields are optional, as an added row has none; new helpers `excelRowNumber`,
41
- `hasExcelValues` and `isExcelRowTyped`, and `excelProblemPlace(problem, byRow)`. `RowsInputHandle.remove(index,
42
- { undo: false })` drops a row with no Undo offered, and keeps the person's own last Undo where it was.
43
- - **A list shows what an import wrote.** After an import, `DataTable` runs `excel.onImported` first and reads its rows
44
- after it: a `Crud` drops its cached lists there, and the reload used to be asked before, so on a page just opened it
45
- was answered from the cache and the imported rows stayed off the screen until a reload.
46
- - **What imported records start as.** `ExcelSettings` gains `recordStatuses` (`ExcelRecordStatus`: `value`, `label`)
47
- and `defaultRecordStatus`; when a model offers any, the import shows "New records start as", the default picked,
48
- and sends the choice (`importExcelRows(..., { recordStatus })`; `ExcelBackend.importRequest(rows, recordStatus?)`,
49
- the Nexus one sending `{ rows, recordStatus }`). The report says "3 cities added as In review".
50
- - **`Crud`, from the owner's review on 2026-10-07.** The active list is labelled **Active**, not "All" - it leaves out
51
- the trash and the deleted. **Recover asks first** ("Recover Asia?", "It goes back to the active list."), one record
52
- or many, like trash and delete. Recover and Move back to trash wear the back arrow (`iconArrowBackUp`): the
53
- clock-shaped restore icon read as History beside a page's history action.
54
- - **`details.actions`**: buttons of the page's own in the details popup's footer, before Edit and Close
55
- (`CrudDetailsAction`: `id`, `label`, `icon`, `endpoint`, `hidden`, `onSelect(row, popup)`). One that names an
56
- `endpoint` shows only to a user who may call it, by the rule every built-in action follows.
57
- - **The spreadsheet import, as the owner asked on 2026-10-07.** Once a file is picked, the steps and the template and
58
- file buttons give way to the file's name; another file is a Start over (now a bordered button) or a fresh popup. The
59
- rows already there are read-only and never sent, and the footer says they are skipped. The new rows have Remove and
60
- Lock at their end. After an import the review is gone: the panel shows what was added, what was not written and why,
61
- and what was skipped and why. The `nx-excel__refused` list is gone with it; the report's lists are
62
- `nx-excel__problems` blocks.
63
- - **`TabularForm` `actionsAt="end"` and `lockable`.** The row's actions in a column fixed at the right edge, as a data
64
- table has them: Remove and Lock as buttons of their own, anything else in a menu after them. A locked row's inputs
65
- are read-only ("Locked: unlock the row to change it.") and it cannot be removed; the locks are the screen's, never
66
- the value's.
67
- - **A tabular form no longer jumps when a cell is clicked.** The line under the table, which holds the hint or the
68
- message of the input with focus, keeps its height whether or not it says anything.
69
- - **A slow answer lands in the list the user moved to.** `Crud` writes an action's answer into the table only when
70
- the table still holds the list the action was asked from, fully loaded. Otherwise (the user switched lists while
71
- the request was out, or a list is still loading) it patches every cached list and reads the one on screen again from
72
- them, stopping a load that may have been asked before the change. Before, a record trashed from the active list and
73
- answered after a switch to the trash was written over by the stale trash list and showed in neither list until the
74
- page was opened again. The same rule covers `handle.apply`, Add, Edit and the read-back after an Add; a Create that
75
- answers no key now loads the list instead of showing the cached one.
76
- - **The theme panel is a quick preference panel.** Under the theme, `ThemeSwitcher` now shows `PreferencesPicker`
77
- (`preferences={false}` leaves it out): Language and region (interface language, calendar), Dates and times (date
78
- picker language, clock, display date format: the existing `DatePreferences`), Numbers (number scale, thousands
79
- separator) and Tables (default page size, a row click opens the record). New: `UserPreferences`,
80
- `readUserPreferences`, `setUserPreferences`, `useUserPreferences`, `subscribeUserPreferences`, `useNumberLocale`,
81
- `DEFAULT_USER_PREFERENCES`, `PREFERENCE_STORAGE_KEYS` (`nx-language`, `nx-number-scale`, `nx-thousands`,
82
- `nx-page-size`, `nx-row-click`), `InterfaceLanguage`, `ThousandsSeparator`, and `ENumberScales` (the core's enum, by
83
- member value).
84
- - **Numbers follow the person's settings.** `numberLocale(scale, separator)` gives `en-US`/`en-IN` or a key such as
85
- `"en-IN|.|,"` that `formatNumber`, `parseNumber` and `getNumberSymbols` read. `NumberBox` and `Slider` use it when
86
- they have no `locale` (they defaulted to `en-US`, which is still the setting's default), and so do a `DataTable`'s and
87
- `CrudDetails`' number cells (`CellTextOptions.locale`, new). A number box rewrites its text when the setting changes.
88
- - **`DataTable` `pageSize` and `openOnClick` give way to the person's choice once it is made**: the default page size
89
- (when the table offers it) and the row click. A page size picked in a table with a `stateKey` still wins, and that
90
- table now saves a size only when it differs from the one it opens with, so a later default reaches it.
91
- - **Animations: On, Off or System, chosen in the theme panel.** `ThemeScript` now writes `data-nx-motion`
92
- (`reduce` or `full`) on `<html>` before the first paint, from the visitor's choice (`data-nx-animations`, saved as
93
- `nx-animations`) and the system's `prefers-reduced-motion`, and keeps it current when the system setting changes.
94
- **Every stylesheet reads that attribute instead of the media query**, and so does script motion (the popup's
95
- minimize and maximize, smooth scrolls). On animates on a machine whose operating system turned animations off; Off
96
- never animates. An app that does not render `ThemeScript` animates whatever the system says. New: `AnimationMode`,
97
- `setAnimations`, `motionFor`, `REDUCED_MOTION_QUERY`, the `ANIMATIONS_*` and `MOTION_ATTRIBUTE` constants, and
98
- `useTheme()`'s `animations`, `motion` and `setAnimations`.
99
- - **Density: Compact or Cozy.** `data-nx-density` on `<html>` (`nx-density`; `setDensity`, `useTheme().density`).
100
- Cozy makes `--nx-control-sm/md/lg` 32/38/44 and the new `--nx-row` 38; compact is exactly the old sizes. Table rows
101
- and headers, menu items, dropdown options and the Columns panel's lines are now sized from `--nx-row`, so they
102
- follow it. `ThemeScript` takes `defaultDensity`.
103
- - **Scale grid, development builds only**: Off, px (a line every 4px, solid every 48px) or mm (millimetres, solid every
104
- centimetre), drawn over the window on `[data-nx-grid] body::after`. `ThemeScript`'s `grid` and `ThemePicker`'s
105
- `showGrid` default to `isDevelopmentBuild()`, so a production build neither shows the section nor applies a saved
106
- grid. New: `ScaleGrid`, `setScaleGrid`, `isDevelopmentBuild`.
107
- - **`ThemePicker` / `ThemeSwitcher`** add Density, Animations and Scale grid under Mode and Theme (`showDensity`,
108
- `showAnimations`, `showGrid`).
109
- - **Popups fade in and out** (160ms in, 120ms out), with nothing else moving: the slide and scale are gone. **Every
110
- popover panel fades** in and out in 120ms through one rule on `.nx-popover`; the menu's, the table panels' and the
111
- dropdown's own opening animations are gone. A closing panel takes no clicks, and nothing waits for a fade.
112
- - **Popover panels fit the window.** `placeUnderAnchor` and `placeAtPoint` pick the side that fits, or the one with
113
- more room, and write that room as `--nx-popover-room`; a panel that marks its scrolling part `.nx-popover__scroll`
114
- is never taller than it. That part reaches the panel's right wall, so its scroll bar sits on the wall, and carries
115
- the right padding inside. Menus, the theme panel and the table's Columns and Filters panels use it; `usePopoverPanel`
116
- lost its own height cap. New `showPanel` opens a panel scrolled to the top, and a hovered menu item no longer scrolls
117
- its list. Placing an open panel again (on every scroll) no longer throws its list back to the top.
118
- - **A panel's padding is `--nx-popover-pad`.** `.nx-popover`'s own padding loaded after the menu's and the table
119
- panels', so a menu was 16px inside its edge, not the 4px it asked for, and the table panels 16px, not 8px; both now
120
- get theirs. `.nx-table-panel__body` is gone (`.nx-popover__scroll`).
121
- - **One thin, quiet scroll bar everywhere, the design lab's.** `Nexus.Base.css` gives every element
122
- `scrollbar-width: thin` and a thumb of the text colour at 22% on no track. The theme panel, menus and context menus,
123
- the table's Columns and Filters panels, popup bodies, the date-time panel, the chat, code blocks and tables in
124
- Markdown and rich text, and the admin page and menu drew the browser's 15px bar with a painted grey track; on
125
- Windows, Chrome and Edge now draw every bar 10px wide, as the lab does (Firefox's thin bar is 8px; Safari draws its
126
- own thin bar). The components' own bars are gone: the 8px `::-webkit-scrollbar` pill of the text area, the Markdown
127
- preview and the rich text (Chromium and WebKit ignore it once `scrollbar-width` is set), and the thin bar with a
128
- stronger thumb of the tree, the tabular form, the dropdown list and the time picker. The data table's rows keep
129
- theirs (`Nexus.Table.css`) until a later change, and the time picker's columns still hide the thumb until the
130
- pointer or the keys are in them. `--nx-field-scrollbar`, `--nx-field-scrollbar-hover`, `--nx-dropdown-scrollbar` and
131
- `--nx-time-scrollbar` are gone; the text area's resize grip reads `--nx-field-grip`. Room kept for a bar narrows with
132
- it: every `scrollbar-gutter: stable` (the admin page and its bar, whose right edge moves from 27 to 22px off the
133
- window, the chat thread, the dropdown list) and the padding a modal popup keeps for the page's bar
134
- (`--nx-scrollbar-gap`, measured).
135
- - **A form whose values load shows its real inputs, disabled, instead of placeholders.** A `Crud`'s Edit popup, an
136
- `ApiForm` with `loadValue` and a `Form` with `loading` have their final size from the first frame, and the values
137
- land in the same inputs, as a load (no "Unsaved changes", no "Discard your changes?"). While loading, `FormStore`
138
- takes nothing typed, even from an input whose `disabled` was taken off by hand, and draws such an input again from
139
- the store when loading ends; a submit already waited. The `.nx-form__skeleton*` classes and their animation are gone.
140
- - **The form's status line keeps its one line when empty, and a form's bar fills a popup's foot**, so the status sits
141
- beside the buttons: "Loading…" or "Unsaved changes" coming and going no longer moves the bar, and a `Crud` popup
142
- lost the empty 12px row it had above its buttons.
143
- - **`Avatar`** (`nexus-shared/client`): a person's round picture, or their initials on a soft tone without one or
144
- when it fails to load. `size` `sm`/`md`/`lg` or `--nx-avatar-size`; `onChangePicture` makes it a button with a
145
- camera over it on hover. The chat's and the dropdown's avatars are now this one: `ChatAvatar` and
146
- `DropdownAvatar` still exist and draw it, `.nx-chat-avatar` is gone (`.nx-avatar`), `.nx-dropdown-avatar` stays
147
- as the dropdown's size, and `--nx-chat-avatar` is `--nx-avatar-size`.
148
- - **`Button` `dot`**: a small dot on the corner, for something new behind it such as unread notifications; `"primary"`
149
- marks a state that is on, such as filters.
150
- - **`Menu`: a control inside a heading is chosen like a row.** An entry `{ heading: <node> }` may hold a button with
151
- `role="menuitem"`: the arrow keys walk it with the rows, and a click on it closes the menu. A menu opened from the
152
- keyboard still starts on its first row.
153
- - **The data table is one frame**: the bar, the rows and the footer sit in one border (`.nx-table` carries it now,
154
- not `.nx-table__frame`), with a line under the bar and the footer as the bottom band (`--nx-footbar`, a new
155
- token). An empty bar takes no room. Column headings are 12px, weight 650.
156
- - **`DataTable` `add`**: the table's own Add, first in the bar. With `excel`, it is a split button whose arrow holds
157
- Download template, Import from a spreadsheet and Export to Excel, which then leave the options menu. `Crud`
158
- passes its Add this way.
159
- - **The data table's bar is one line on a wide table, in a new order**: the Add section (`add`, then the page's
160
- `actions`) and the lists (`toolbar`) on the left; Reload, the options menu (⋮) and the search, rows per page inside
161
- it, on the right. It was the lists, the search, Reload, ⋮, `actions` and Add, wrapping where they fell: at 1126px
162
- on the admin shell the bar broke into three ragged rows. A narrower table now moves whole parts, by its own width
163
- (container queries on `.nx-table`): under 670px the search takes the top line (with nothing on the left, it leads
164
- the buttons' line), under 375px Reload moves to the end of ⋮ (`DataTable` asks the hidden button, so the width is
165
- written once, in the stylesheet), and under 325px, beside lists, the Add section takes the last line with its buttons
166
- sharing it. On the admin shell those are windows of 695, 400 and 350px. A bar wider than those widths expect (a
167
- long Add label, the page's own buttons, cozy density) is squeezed into the next step as soon as a line would break
168
- (`data-squeeze` on `.nx-table__bar`, set a frame after a size change). A `toolbar` or `actions` that renders
169
- nothing takes no room and decides no step, and a bar left with nothing else is hidden. New classes
170
- `.nx-table__bar-add` (Add and `actions`) and `.nx-table__reload`; the search is now a child of `.nx-table__bar` after
171
- `.nx-table__bar-end`, not inside it, and grows to 19rem from a 12rem basis.
172
- - **Columns opens from the options menu (⋮)**, as its first item; the bar has no Columns button. Reload, Columns
173
- and the options button are ghost buttons. `TableColumnsPanel` takes an `anchor` and an `open()` handle for this.
174
- - **`TabButtons` `labels="auto"`**: every label while its container (a data table) is at least 900px wide, only the
175
- chosen one's below. `Crud`'s lists use it.
176
- - **`DataTable` `selectBy`**: while rows are selected, their count opens a menu that selects again by what the rows
177
- are: All on this page, then each `{ label, icon, test }`, which leaves exactly the matching rows on the page
178
- selected. `Crud`'s main list offers Pinned, Not pinned, Flagged and Not flagged.
179
- - **`SplitButton` `fixed`**: the main button never changes; the menu holds other ways to do it, run when picked.
180
- - **`DataTable` `filterInputs`: an advanced filter.** Input params, each named after the column it filters, become
181
- **Filters** in the options menu (⋮): a small form under the button, applied as it changes. The values are
182
- `TableQuery.filters` (`TableStore.setFilters` sets them, `{}` clears); a client table matches them in `queryRows`
183
- (a fifth argument), and a server gets them flat by name through `CrudBackend.pageRequest`'s third argument, as
184
- `filterRequest` writes them (a date range as `{name}From`/`{name}Before`). A `CrudBackend` or loader of your own
185
- ignores them until it reads that argument or `query.filters`. Also new: `activeFilters`, `asFilterInput`,
186
- `filterMatcher`, `filterNames`; `usePopoverPanel` and `PopoverPanelHandle` (`nexus-shared/client`), the panel
187
- Filters and Columns share; `followAnchor`'s second argument, a panel whose size it follows too.
188
- - **Renamed: `.nx-columns__head`, `__title`, `__count` and `__foot` are `.nx-table-panel__*`**, shared by both panels.
189
- An override of the old names no longer applies.
190
-
191
- ## 3.0.0
192
-
193
- **Breaking. This changes the value of exported constants, and `2.0.0` is on npm with the old ones.** Publishing it
194
- as a patch or a minor would hand `^2.0.0` consumers different numbers under a compatible-looking range, which is the
195
- worst way to ship this, so it takes a major. The owner chose the number on 2026-10-02, together with `nexus-public`
196
- and `nexus-admin`, which both depend on this package and so move to `3.0.0` with it; their `nexus-shared` range is
197
- now `^3.0.0`, because a bump of this one alone would leave them pulling the old, wrong constants.
198
-
199
- **`nexus-icons` must be installed at `1.1.0` or newer**, and the dependency range here says so. The chat components
200
- added in this version use `iconChecks`, `iconCornerUpLeft`, `iconMessageCircle`, `iconPaperclip`, `iconSend` and
201
- `iconUserPlus`, none of which exist in `nexus-icons@1.0.0`: with the old range a fresh install resolved to `1.0.0`
202
- and the build failed on six missing exports.
203
-
204
- ### `NexusModule` — `Chat` added, and every module after it moved up one bit
205
-
206
- The backend's `EModules` gained `Chat` at bit 6, and everything after it shifted. `NexusModule` follows it exactly.
207
- A permission is a bitmask of these flags, so a package that kept the old numbers would grant and deny the wrong
208
- modules without any error to show for it.
209
-
210
- | Module | Was | Now |
211
- |---|---|---|
212
- | `None` | 0 | 0 |
213
- | `Migrations` | 1 | 1 |
214
- | `Configuration` | 2 | 2 |
215
- | `Identity` | 4 | 4 |
216
- | `Enterprise` | 8 | 8 |
217
- | `Drive` | 16 | 16 |
218
- | `Message` | 32 | 32 |
219
- | **`Chat`** | *(did not exist)* | **64** |
220
- | `Accounting` | 64 | **128** |
221
- | `Report` | 128 | **256** |
222
- | `Requisition` | 256 | **512** |
223
- | `Tenant` | 512 | **1024** |
224
- | `Subscription` | 1024 | **2048** |
225
- | `Reservation` | 2048 | **4096** |
226
- | `School` | 4096 | **8192** |
227
- | `Pharmacy` | 8192 | **16384** |
228
- | `Staffing` | 16384 | **32768** |
229
- | `Portfolio` | 32768 | **65536** |
230
-
231
- `NEXUS_MODULES` gained the matching entry (`key: "chat"`, `label: "Chat"`, `env: "API_CHAT"`), so `Chat` can be
232
- looked up, proxied and named in messages like every other module.
233
-
234
- **What to check when you upgrade.** Code that uses the names (`NexusModule.Reservation`) needs nothing. Anything
235
- that stored, transmitted or hard-coded a **number** has to be revisited:
236
-
237
- - permission masks saved in a database, a cache, a token claim or a settings file;
238
- - proxy URLs written with a number in place of a key (`/api/app/2048/…` is now `/api/app/4096/…`, and `reservation`
239
- by key has always worked and still does);
240
- - a backend that has not taken the `Chat` shift yet — both sides move together or neither does.
241
-
242
- ### `NEXUS_FLAGGED_STATUS` — 15 → 25
243
-
244
- The owner settled the record-status bands, and `ERecordStatus.Flagged` is seeded at **25**. `NexusCrudBackend`
245
- decides whether a row is flagged by comparing `recordStatus` to this constant, so a package on 15 while the backend
246
- writes 25 shows the wrong flag on every row, and flagging one writes a status the backend does not recognise.
247
-
248
- The bands, for anything that reasons about them:
249
-
250
- | Band | Range |
251
- |---|---|
252
- | Active | 0–39 |
253
- | — Active account | 0–29 |
254
- | — Flag | **25–29** |
255
- | — Pending, unverified, in review, approval required | 30–39 |
256
- | Trash | 40–59 |
257
- | Delete | 60 and above |
258
-
259
- `NEXUS_ACTIVE_STATUS` stays 0, and the `recordStatus < 40` reading of "a live record" that the rest of the code
260
- already assumes stays right.
261
-
262
- Note that **the flag is a range, 25–29, and this release still checks a single value.** Only the seeded member at 25
263
- has a meaning today, and the bands are about to become configurable per project, so a range-aware check belongs with
264
- that work rather than here. Do not read the single value as the final answer.
265
-
266
- ### Removed: the archive
267
-
268
- The owner removed the archive on 2026-09-30: a record is live, in the trash, or deleted, and the way back from
269
- deleted lands it in the trash. The second put-away list and its second recovery path were the only reason the CRUD
270
- had both Restore and Recover, and the bands close up behind it — **Trash is 40–59 and Delete 60 and above**, so
271
- nothing stored keeps a meaning it no longer has. `recordStatus < 40` still reads "a live record".
272
-
273
- What goes, all of it breaking for anything that named it:
274
-
275
- - `CrudMode` loses `"archive"`, so a CRUD shows All, Flagged, Trash and Deleted.
276
- - `CrudRowActionName` and `CrudMassActionName` lose `"archive"` and `"restore"`. Recover, the trash's own way back,
277
- is untouched: a CRUD that offered both now offers one.
278
- - `CrudEndpoints` loses `archive` and `restore`; `apiController` stops naming `Archive`, `Restore`, `MassArchive`,
279
- `MassRestore`, `ArchiveListing` and `ArchivePagination`, and `lists`/`pages` lose their `archive` key.
280
- - `MessageActionName` loses `"archive"`, and the message builder its words and its "It can be restored from the
281
- archive." confirmation detail. `"restore"` stays in the vocabulary: it is a plain verb, not the archive's.
282
-
283
- **What to check when you upgrade.** A CRUD that listed `"archive"` in `modes` or `actions` no longer compiles,
284
- which is the point — the endpoint behind it is gone from the backend too. A backend that still answers
285
- `ArchiveListing` is not asked for it any more.
286
-
287
- ### Added: the chat
288
-
289
- A discussion on any record — a leave request the manager and a co-worker talk through, a purchase order, an
290
- applicant, a tender — behind one icon that says whether anything is waiting. Nothing that does not use it changes.
291
-
292
- - A conversation is named by three values, `ChatScope`: the **domain** (which kind of record), the **parent id** (the
293
- record) and the **user id** (who is reading). The domain is the app's own enum, given either as a template
294
- (`<Chat<EDomainTypes> …>`) or once in `ChatRegister`, the same way `ApiRegister` names the module enum. Without one
295
- it is a plain number.
296
- - **No domain numbers are defined here.** They belong to the backend's `EDomainTypes`, and the warning at the top of
297
- this file applies to them in full. `defineChatDomains` only gives an app's domains their names on screen.
298
- - `ChatButton` is an icon in three looks: quiet with no messages, the accent on its tint once there are messages, and
299
- the accent filled with an unread badge once some are unread. One size in all three, with the badge over the corner,
300
- so a column of them in a table does not move as the counts arrive.
301
- - `ChatSummaryCache` fills every icon that mounted in the same moment with **one call per domain**. A list whose rows
302
- already carry their counts passes `summary` and makes no call at all.
303
- - `Chat` is the icon with the conversation in a popup; `ChatPanel` is the conversation on its own, for a record's own
304
- page. Messages are **Markdown**: written in `MarkdownEditor` and drawn by `MarkdownView`. Enter sends and
305
- Shift+Enter starts a line, except in a list or a code fence, where the editor keeps Enter; `sendKey="modifier"`
306
- moves sending to Ctrl+Enter.
307
- - One mark where the time sits says how far a message of your own has got: a spinner while sending, a tick once the
308
- server has it, two once `deliveredTo` holds somebody, two in the accent once `readBy` does.
309
- - Where the data comes from is one interface, `ChatSource`. `apiChatSource` goes through the app's `ChatBackend`
310
- (`NexusChatBackend` by default); `localChatSource` keeps a conversation in memory for a demo or a test.
311
- - **The Nexus endpoints are the shape the Chat module is expected to take, not an API that answers today.**
312
- `NexusModule.Chat` is a number and a proxy key; the controller is not built. Each difference, when it lands, is one
313
- override on `NexusChatBackend`.
314
- - `check:themes` gained a rule: body text on the primary tint, which is a message bubble of your own. Every theme
315
- clears AAA except catppuccin light at 6.84, so it is held to the AA 4.5 that applies to body text.
316
- - **A record holds many conversations.** `ChatScope` names the record; each conversation in it is a
317
- `ChatConversation` with its own id and a type, `one-to-one` or `group`. `ChatPlaceSource` lists them and starts
318
- them, `useChatPlace` is the hook, and `apiChatPlace` is the API one. A panel given a `source` still shows that one
319
- conversation and no switch, so nothing that used the first round changes.
320
- - The panel's body has three views — the thread, the list of conversations, and New chat — and the bar moves between
321
- them. With several conversations the title is the switch; with one it is just the title.
322
- - Starting a chat never asks for the type: one person makes a one-to-one, more make a group, and a name makes a
323
- group either way. A one-to-one with somebody who already has one is expected to come back rather than be made
324
- twice; `localChatPlace` keeps that rule.
325
- - `@` at the start of a word offers the people in the conversation, narrows as it is typed, and owns Enter while it
326
- is open. A mention is plain `@Name`, or a Markdown link with `mentionHref`; either way the ids travel in the
327
- draft's `mentions`.
328
- - `ChatSnapshot` gained `loaded`: a store that has read nothing yet holds empty counts, and a panel must not write
329
- those over what a conversation list already knows.
330
-
331
- ### Added: several at once, `TabButtons multiple`
332
-
333
- `TabButtons` picks one option; with `multiple` it picks several, so a list of choices that must be seen all at once
334
- (the weekdays a rule runs on, the letters or symbols a pattern allows) needs no second control. Nothing that does
335
- not pass it changes.
336
-
337
- - `value` is an array and `onChange` hands back the chosen values **in option order**, whatever order they were
338
- clicked in. Values that match no option are ignored and never handed back.
339
- - The options are native checkboxes in a `role="group"`: Tab moves between them and Space toggles one. The group
340
- still needs `aria-label` or `aria-labelledby`.
341
- - There is no sliding indicator: each chosen option is raised with the indicator's own colors, border and shadow,
342
- and keeps the pointer, since a click takes it away again. An option that is not chosen sits flat on the track.
343
- - The options **wrap** onto more rows when they do not fit, each row as tall as a one-row track's options, and an
344
- option is never narrower than it is tall, so a row of single letters lines up. On a touch screen
345
- (`pointer: coarse`) every option is a medium control's height: 26 letters on a phone are 34px targets, not 22px.
346
- - Made and checked for the `nav` design (`/demo/tab-buttons`, "Several at once"); `design="list"` with `multiple`
347
- renders checkboxes too, but has not been checked yet.
348
-
349
- ### Added: inputs that follow others, `dependsOn`, and a row's own params, `cellParams`
350
-
351
- Two optional keys, added 2026-09-26 for the builder's class form, whose company model shows only when the class has a
352
- company or a branch and whose type arguments offer a key type in one row and a model in the next. Nothing that does
353
- not pass them changes.
354
-
355
- - **`dependsOn: ["hasCompany"]`** on an input's params (Form only). When a named input's value changes — typed, or
356
- set from code — this input draws again **without mounting again** (nothing typed in it is lost) and its params are
357
- read afresh, so getters for `hidden`, `readOnly`, `readOnlyMessage`, `label`, `hint` or `options` answer for the
358
- values as they are now. Its rule is asked again too, so a `required` a getter switched on counts at once. An input
359
- that names any is asked `hidden` each time it draws, so it can come and go; one that names none is asked once, when
360
- the form draws, as before. Name the input itself to follow its own typing.
361
- - **`cellParams(row, name, index)`** on `TabularForm` and `GroupForm`: the params of one cell that differ from its
362
- column's, such as a picker's `options` and `selectedOptions`. Asked as the row draws; return nothing for the
363
- column's own params, and the **same object** for the same answer (the merged params are kept per answer, so the cell
364
- does not draw again for nothing). Getters on either side stay getters. Rules still come from the column.
365
-
366
- ### Added: a generated column in a tabular or group form
367
-
368
- Two optional props on `TabularForm` and `GroupForm`, for a column whose values a row's **position** decides rather
369
- than the user — a bitwise enum's value, a line number, a running total. Nothing that does not pass them changes.
370
-
371
- - `deriveRows(rows)` returns one patch per row and runs after every change, including a row being added, removed,
372
- duplicated or **moved**. The patches are written as code writes, so only the cells whose value really changed
373
- mount again and focus stays on the row's own menu: a keyboard can move a row twice in a row.
374
- - `cellReadOnly(row, name, index)` is asked for every cell as it renders and returns the reason a cell is
375
- generated. The cell is locked and shows that reason as its read-only note. Because it is asked per render, a
376
- column can lock and unlock while the form around it changes, which params cannot do — they are fixed once a form
377
- opens.
378
-
379
- ### Added: rows nobody may change, `rowReadOnly`
380
-
381
- One optional prop on `TabularForm` and `GroupForm`, for rows the user did not write and may not change — the fields
382
- a class inherits from its parent, shown first in the same grid as its own. Nothing that does not pass it changes.
383
-
384
- - `rowReadOnly(row, index)` returns the reason (or `true`). Every cell of such a row is read-only with that reason
385
- as its note, the row's menu in a tabular form offers nothing (no insert, duplicate, move or remove), and the row
386
- below it cannot move up past it. Nothing reorders the rows: keep the locked ones first.
387
- - A locked row says nothing about its columns: the column-header lock is worked out from the other rows in view, so
388
- a grid of inherited rows and one own row does not put a lock on every header.
389
-
390
- ### Added: a panel beside a CRUD form, `form.aside`
391
-
392
- An Add or Edit popup can show a panel next to its form: a preview of what a save would produce, a summary, a help
393
- panel. It takes a node, or a function handed `{ form, action, row }` — `form` being that popup's own `FormStore`, so
394
- a panel follows what is typed through `subscribeValue()` and the form around it never re-renders for it.
395
-
396
- The two are columns above 1000px, each scrolling on its own so a long form cannot push the panel off the screen, and
397
- they stack below that with the **form first**, in the DOM as well as on the screen. `--nx-crud-aside-width` sets the
398
- panel's share (default `clamp(20rem, 38%, 44rem)`). Pair it with a wide `form.size` — `xl` or `full`; a panel in a
399
- 560px popup leaves the form nothing. A CRUD that passes no `aside` renders exactly what it did before, wrapper and
400
- all.
401
-
402
- ### `TabularForm` reworked: one row control, rows that do not move, one lock per column
403
-
404
- The owner called the tabular design bad, from a screenshot of an 18-row table with one generated column. Every fault
405
- was in this component rather than in the page that used it, so every fix is here. Nothing needs a new prop, and a
406
- table that passed nothing new looks different — deliberately.
407
-
408
- **Breaking, visually and for tests.** Two things a consumer may be reaching for have moved:
409
-
410
- - **The remove column is gone.** Remove was a column of unlabelled `×` buttons at the end, beside a row menu that
411
- already held Remove: two ways to act on one row, and a whole column spent on one of them. Now a row has one
412
- control, the menu beside its number, holding Insert above, Insert below, Duplicate, Move up, Move down and Remove.
413
- `removable` still decides whether Remove is offered. A test that clicked a button named `Remove <row>` opens the
414
- row's menu instead: `Row 2 actions`, then the `Remove` item. `.nx-tabular__end` and `.nx-tabular__remove` are gone
415
- from the DOM and the stylesheet; so is `--nx-tabular-end`, replaced by `--nx-tabular-menu` (the menu's slot width).
416
- - **The row number no longer opens the menu.** It used to *become* the menu on hover: the number faded out and dots
417
- faded in over it, so the numbering looked broken and the grid felt unstable as the pointer crossed it. The number
418
- now stays where it is, right-aligned, at every moment, and the menu button sits beside it in its own space,
419
- always present. The number column is that much wider.
420
-
421
- **Fixed, with no change to how a table is written:**
422
-
423
- - **Every row is one line high, whatever it holds.** The row's grid track was sized by its tallest cell, so a single
424
- `textarea` column of `rows: 2` made every cell of its row 20px taller than the row's own box — drawing the cells,
425
- their edges and their focus rings over the rows below. One row of content therefore looked taller than its
426
- neighbours, and a focus ring bled outside its cell. The row now has one track exactly its own height, cells keep
427
- what they hold inside their bounds, and a `textarea` fills its cell and scrolls inside it.
428
- - **No resize grips in cells.** A `textarea` in a cell took `resize: vertical` from the multi-line input rule, so
429
- every cell of such a column offered a grip that would break the alignment of its row. They are `resize: none` now.
430
- - **A filled cell and an empty one are drawn the same.** Same consequence as the above: only the cell with text was
431
- tall enough to show what the overflow was doing.
432
- - **`maxHeight` comes down to whole rows.** The scroller used to end wherever the number fell, cutting the last row
433
- in half, which reads as broken rather than as scrollable. The component measures its header, its frame and its
434
- real row height and caps itself just under what was asked for. A number too small for one row is left alone.
435
- - **A generated column says so once.** `cellReadOnly` put a lock in every cell of the column it locked — eighteen
436
- locks for one fact. The lock is now on the **column header**, with the reason as its title, and the cells keep the
437
- read-only tint and their own reason, which still shows under the table when one is used. A lock that really is per
438
- row (some rows locked, not all) leaves the header alone and is drawn per cell as before.
439
- - **Less chrome.** The frame is `--nx-border` rather than `--nx-border-strong`: the sheet draws every line it needs
440
- inside itself, and in a popup or a panel the strong edge was a box around a box around boxes.
441
-
442
- ## 2.0.0
443
-
444
- The rewrite: compiled `dist` with the entry points `.`, `/client`, `/server` and `/styles.css`, in place of 1.x's
445
- raw TypeScript from `src` with `.`, `/client` and `/interface`. A major version, so nobody on `^1.1.21` is moved
446
- into a different API.
1
+ # Changelog
2
+
3
+ Notable changes to `nexus-shared`. The **values** of exported constants count as API here: several of them are the
4
+ Nexus backend's own numbers, and a value that differs from the backend by one does not fail loudly — it silently
5
+ means the wrong thing on every record and every permission check.
6
+
7
+ ## 4.0.0
8
+
9
+ **Breaking: an input's `type` is a number, a member of `EInputTypes`, and a string is refused.** Everything else in
10
+ this version was prepared as `3.3.0`, which was never published; it follows under
11
+ [the changes prepared as 3.3.0](#also-in-400-the-changes-prepared-as-330).
12
+
13
+ Every export of the published `3.2.0` is still exported (287 are new: 226 from `nexus-shared`, 61 from
14
+ `nexus-shared/client`, none from `nexus-shared/server`; 55 of them are [pictures and documents](#pictures-and-documents-declared-by-params), 19 are [cards C1's and C1-fix's](#the-editor-loads-in-every-bundler-a-reason-initials-one-action-locked-options-narrowed), 24 are [card RF-comp's](#a-printable-document-money-in-a-currency-inputs-shown-by-another-focus-kept-through-a-picture-change), 20 are [card TABS-comp's](#a-records-workspace-a-profile-and-tabs-in-the-details-popup), 3 are [card R2-comp's](#a-servers-page-in-a-sandboxed-frame-code-text-in-a-text-area), 1 is [card C-table-whole's](#a-short-list-read-whole-paging-in-the-footer-only), 1 is [card C-choice-tabs'](#a-choice-drawn-as-tabs), 1 is [card C-input-memory's](#an-input-that-remembers-its-last-value-or-opens-on-the-companys-default), 1 is [card C-label-follows'](#a-label-that-follows-another-input), 6 are [card C-server-preview's](#a-preview-the-server-works-out-as-the-person-types), 1 is [card C-print-logo's](#a-printable-documents-logo), 12 are [card C-crud-scope's](#a-list-that-can-be-widened-to-other-endpoints), 5 are [card C-row-form's](#a-row-action-that-opens-a-form-of-its-own-for-the-record), 3 are [cards C-leave-guard's and C-leave-fix's](#a-form-with-changes-asks-before-a-navigation-inside-the-app), 4 are [cards IMG-comp's and IMG-comp-fix's](#every-picture-through-the-editor-and-within-drives-limits), 2 are [card C-pin-narrow's](#pinned-columns-give-way-on-a-narrow-table), 56 are [card C-record-cards'](#a-records-page-of-cards-every-change-in-a-popup), 2 are [card C-tab-footer's](#a-record-tabs-footer-that-stays-in-view), 13 are [card C-form-figures'](#form-figures-an-input-worked-out-from-others-the-servers-money-rule-and-a-money-column), 7 are [card C-form-confirm's](#a-form-asks-before-it-saves-the-full-details-in-sections), 2 are [card C-profile-fields'](#a-few-fields-in-a-records-profile-left-out-of-its-details-tab), 2 are [card C-span-when's](#an-inputs-width-that-follows-a-value), 6 are [card C-record-cards-fit's](#record-cards-that-fit-what-was-built-on-them); the rest are named in the sections below or
15
+ [in the list at the end of these notes](#new-exports-no-section-names)), and no exported constant changed a value (86
16
+ compared), both checked against the published `3.2.0` with `Frontend/scripts/release-compare` (again on 2026-11-06,
17
+ which corrected the hand count of 195; card C-record-cards' 56 are counted from its three files). What breaks is a type: `InputParams` and
18
+ every params type in it take `type` as an `EInputTypes` member, so every list of inputs an app writes changes.
19
+ `nexus-icons` stays `^1.2.0`; its published copy exports every name this package imports (125). The packages built on
20
+ this one move with it: `nexus-admin` `4.0.0` asks `^4.0.0` (a major, because its range crosses this one), and
21
+ `nexus-public` `3.0.1` asks `^3.0.0 || ^4.0.0` (it imports only `cx`).
22
+
23
+ ### Breaking: an input's type is the numbered `EInputTypes`
24
+
25
+ The owner, 2026-10-30, pointing at `type: "switch"` and `type: "dropdown"` in an app's inputs: the old project held an
26
+ input's type as a numbered enum, and so does this package now. A number is what a database, a generated page and a C#
27
+ enum can hold, and a member name that does not exist fails to compile where a mistyped string compiled.
28
+
29
+ ```tsx
30
+ // 3.x
31
+ import type { InputParams } from "nexus-shared";
32
+ const INPUTS: InputParams[] = [
33
+ { type: "text", name: "fullName", label: "Full name", required: true },
34
+ { type: "dropdown", name: "tags", label: "Tags", multiple: true, options: TAGS },
35
+ { type: "switch", name: "isActive", label: "Active" },
36
+ ];
37
+
38
+ // 4.0.0
39
+ import { EInputTypes, type InputParams } from "nexus-shared";
40
+ const INPUTS: InputParams[] = [
41
+ { type: EInputTypes.Text, name: "fullName", label: "Full name", required: true },
42
+ { type: EInputTypes.Dropdown, name: "tags", label: "Tags", multiple: true, options: TAGS },
43
+ { type: EInputTypes.Switch, name: "isActive", label: "Active" },
44
+ ];
45
+ ```
46
+
47
+ `EInputTypes` is written as `ENumberScales` is: an `as const` object and a type of the same name, so it is a value and
48
+ a type, works in a server component, and `InputParams` still narrows on it (a dropdown's `multiple: true` still makes
49
+ its value a list). Its numbers are this package's own, in blocks by kind with room to grow; **0 is never a kind**, so a
50
+ value left out is never a valid one, and a released number is never reused or given another meaning.
51
+
52
+ | Block | Kind | Member | Number | Was |
53
+ |---|---|---|---|---|
54
+ | 1–19 | Text boxes | `Text` | 1 | `"text"` |
55
+ | | | `Email` | 2 | `"email"` |
56
+ | | | `Password` | 3 | `"password"` |
57
+ | | | `Search` | 4 | `"search"` |
58
+ | | | `Tel` | 5 | `"tel"` |
59
+ | | | `Url` | 6 | `"url"` |
60
+ | | | `TextArea` | 7 | `"textarea"` |
61
+ | | | `Code` | 8 | `"code"` |
62
+ | 20–39 | Choices | `CheckBox` | 20 | `"checkbox"` |
63
+ | | | `Switch` | 21 | `"switch"` |
64
+ | | | `Radio` | 22 | `"radio"` |
65
+ | | | `CheckBoxGroup` | 23 | `"checkbox-group"` |
66
+ | | | `RadioGroup` | 24 | `"radio-group"` |
67
+ | | | `Dropdown` | 25 | `"dropdown"`, one item or many by `multiple` |
68
+ | 40–59 | Dates and times | `Date` | 40 | `"date"` |
69
+ | | | `DateRange` | 41 | `"date-range"` |
70
+ | | | `Time` | 42 | `"time"` |
71
+ | | | `DateTime` | 43 | `"date-time"` |
72
+ | 60–79 | Numbers and sliders | `Number` | 60 | `"number"` |
73
+ | | | `Slider` | 61 | `"slider"` |
74
+ | | | `RangeSlider` | 62 | `"range-slider"` |
75
+ | 80–99 | Editors | `Markdown` | 80 | `"markdown"` |
76
+ | | | `RichText` | 81 | `"rich-text"` |
77
+ | 100–119 | Structures | `Tree` | 100 | `"tree"` |
78
+ | | | `Tabular` | 101 | `"tabular"` |
79
+ | | | `Group` | 102 | `"group"` |
80
+ | | | `Picture` | 103 | new: [a record's picture](#pictures-and-documents-declared-by-params) |
81
+
82
+ Every number fits a byte. To move an app: import `EInputTypes` from `nexus-shared` and write the member in place of
83
+ each string in the table; the type checker lists every place it is still a string. A page that compared an input's type
84
+ (`params.type === "radio"`) compares the member. Unchanged: a `TextBox`'s own `type` prop is still its HTML type
85
+ (`<TextBox type="email" />`), which is not an input's params; a dropdown column's `type` (`"code"`, `"muted"`), a table
86
+ column's `format` and the chat's conversation `type` are not inputs either and keep their strings.
87
+
88
+ New beside it, and counted in the 30 above: `TextInputType` (the six text box members, whose `TextBox` gets its HTML
89
+ type from one map in the renderer) and `inputTypeName(type)`, the member's name for a message or a log
90
+ (`inputTypeName(EInputTypes.RichText)` is `"RichText"`; a number that is no member comes back as its digits). A table's
91
+ filter that cannot filter now warns with the name: *a RichText input cannot filter rows*.
92
+
93
+ ### Pictures and documents, declared by params
94
+
95
+ Card D2 of the subscription demo round (2026-11-02). A record's pictures and documents - a company's logo, a branch's
96
+ pictures, a person's own picture, a subscriber's documents - are the files of an **owner** (a row of a module) in a
97
+ **category** (what they are for, with its limits), and a page adds them by declaration:
98
+
99
+ ```tsx
100
+ import { EInputTypes, NexusModule } from "nexus-shared";
101
+ import { DocumentList, openPictureGallery, pictureColumn } from "nexus-shared/client";
102
+
103
+ const BRANCH_PICTURES = { module: NexusModule.Enterprise, category: "branch-picture", aspect: 4 / 3 };
104
+
105
+ // A form: the owner's main picture, changed at once (Upload, Edit, Remove, All pictures).
106
+ inputs: (action, row) => [{ type: EInputTypes.Picture, name: "picture", label: "Picture", ...BRANCH_PICTURES, ownerId: row?.id }];
107
+ // A table: each row's picture, the page's rows read in one call; a click opens the row's gallery.
108
+ columns: [pictureColumn({ ...BRANCH_PICTURES, rowTitle: row => row.branchName }), ...];
109
+ // A window, and a list for a category that takes PDF.
110
+ openPictureGallery({ ...BRANCH_PICTURES, ownerId: branch.id, title: branch.branchName });
111
+ <DocumentList module={NexusModule.Subscription} category="subscriber-document" ownerId={subscriber.id} />;
112
+ ```
113
+
114
+ - **`EInputTypes.Picture` (103)**, the next number of the structures block: `PictureInput`, the owner's main picture
115
+ with Upload (choose, edit with the shape locked, store, make it the main one), Edit (a new picture from the main one),
116
+ Remove (no main picture; the pictures stay) and All pictures. In a category that keeps one file, Upload is Replace
117
+ (the new picture takes the kept one's place once the editor has saved) and Remove is Delete, so the input is never
118
+ left with a file it cannot delete. It holds no value of the form: the form store keeps no field for it
119
+ (**`holdsValue`**), so a save sends nothing under its name, and it takes no `required`, since the form could not
120
+ refuse a save without a picture. A record not saved yet (`ownerId` null) says "Save the {subject} first".
121
+ - **`PictureGallery` / `openPictureGallery`**: upload several at once up to the category's count (each clamped to
122
+ 2048 px and given its thumbnail by the editor's `renderImage`, without opening it; a picture's size is checked once
123
+ it is made, so a phone photo over the category's size is stored), make one the main picture, view
124
+ them in the editor module's viewer, edit one (saved as a new picture), delete. **`DocumentList`**: the same for a
125
+ category that takes PDF, as a list (kind, size, date, open, rename, delete); the gallery picks it by itself, and
126
+ `openPictureGallery` without `view` names its window after it ("Documents") once the category is read.
127
+ - **`pictureColumn`**: each row's picture; every row a table draws asks `primaryPictures` (`PrimaryPictures`), which
128
+ gathers the asks of one moment into **one call per module and category**, at most the adapter's `maxOwners` (200),
129
+ and keeps the answers until a change on the page writes one in, so a row redraws without a reload. A refused or
130
+ failed read is kept as no picture for `retryAfter` (30 s), so rows that draw again do not each ask again; `clear()`
131
+ also drops the reads under way, so an ask after a sign-out never gets the answer meant for the person before.
132
+ - **`loadImageEditor()`** (about 1 KB): the image editor (`nexus-image-editor`) is **never in an app's bundle**. The
133
+ adapter names its versioned address (with Nexus, Drive's `Assets/File/nexus-image-editor.txt`), and the loader
134
+ imports it with the bundler told to leave the import alone (`webpackIgnore` / `turbopackIgnore`; proved in a
135
+ Next.js 16 Turbopack build: the chunk holds a plain `import(url)`), once per page; a failed load is not kept.
136
+ `preloadImageEditor()` starts it on the hover of an Edit or Upload button. Its types are in `ImageEditorModule`.
137
+ - **`FileAdapter`** is the abstract class an app implements for its own file backend (`list`, `primaries`, `upload`,
138
+ `setPrimary`, `rename`, `remove`, `fileUrl`, `editorModuleUrl`), registered once with `defineFileAdapter`;
139
+ **`NexusDriveAdapter`** is the default: a write ticket from the owner's module (`Files/Ticket/{category}/{ownerId}`,
140
+ kept until a minute before it runs out, asked again once when Drive answers `DRV_X011`), a read ticket
141
+ (`Files/Owners/{category}`) for a page's rows and for a person who may see but not change an owner's files, then
142
+ Drive's `File/List`, `Primaries`, `Upload`, `SetPrimary`, `Rename`, `Delete` and `View/{id}/{Original|Thumbnail}`.
143
+ Nothing generic names a backend's numbers: a category's kinds are MIME types.
144
+ - New shared helpers: `fileRefusal` (a kind or size the category refuses, said before the upload), `fileKindsSentence`,
145
+ `fileSizeText`, `roomLeft`, `takesDocuments`, `pictureName`; one stylesheet, `Nexus.Files.css` (`nx-picture`,
146
+ `nx-picture-input`, `nx-files`), whose hovered pictures get a ring and never a new size.
147
+
148
+ Proved by `npm run test:files` (the adapter against a pretend backend: the tickets, the fresh one after `DRV_X011`, the
149
+ read-only fallback, the page's order, the multipart fields, the pointer file) and nexus.template's `/demo/pictures`
150
+ with its browser check `pictures.cjs`, on a pretend Drive that answers as the C# one.
151
+
152
+ ### The editor loads in every bundler; a reason, initials, one action locked, options narrowed
153
+
154
+ Card C1 of the subscription demo's second round (2026-11-02): fixes to the pictures above, and two features the
155
+ subscription pages needed and kept as code of their own.
156
+
157
+ - **The image editor loads in an app's production build.** The package build rewrites "./x.ts" imports to "./x.js"
158
+ (`rewriteRelativeImportExtensions`), and for an `import()` of a variable it wrapped the address in a run-time helper,
159
+ `import(__rewriteRelativeImportExtension(url))`. The bundler-ignore comments were then inside a call, and Next 16's
160
+ Turbopack compiled the import to "Cannot find module as expression is too dynamic": in every app built on `dist`,
161
+ Upload and Edit on a picture never opened the editor (an app reading the source, like nexus.template, never saw it).
162
+ The import now lives alone in **`importModule(url)`** (`Services/ModuleImport.ts`, which imports nothing), built a
163
+ second time without the rewrite (`tsconfig.build.module-import.json`, the second step of `npm run build` and of
164
+ `copy-packages`' watch), so `dist` keeps `import(/* webpackIgnore: true */ /* turbopackIgnore: true */ /* @vite-ignore */ url)`.
165
+ The setting stays for every other file. `scripts/test-dist-loader.mts` (in `npm test`) runs the build's steps into a
166
+ scratch folder and fails if any built file holds the helper.
167
+ - **An editor that cannot load says so.** `loadImageEditor()` rejects in plain words ("The image editor could not be
168
+ downloaded. Check the connection and try again; ...") and logs the cause (a network error, a 404, a bundler that
169
+ rewrote the import) with `console.error`; the picture input, the gallery and the viewer show it as a toast.
170
+ - **A picture input says why it is read only.** `OwnerFilesParams.lockReason` (on `PictureInput`, `PictureGallery`,
171
+ `openPictureGallery`, and `pictureColumn` as a sentence or `row => sentence`) makes the files read only with that
172
+ sentence under them: a record that is closed, a year that is locked. Without it, a person the backend lets only look
173
+ is told the backend's own words: `NexusDriveAdapter` keeps the refused write ticket's message as
174
+ `OwnerFiles.readOnlyReason`, and **`READ_ONLY_REASON`** stands in when there is none.
175
+ - **Initials for an owner with no picture.** `OwnerPicture` `name` and `pictureColumn` `nameKey` (the row's field with
176
+ a name): without a picture, the name's initials, on the tone the shared `Avatar` gives that name
177
+ (`.nx-picture--initials`; the avatar tones now also apply to `.nx-picture[data-tone]`).
178
+ - **One action locked on a row: `Crud` `rowActionLock`**: `row => ({ trash: "The head office stays open..." })` switches
179
+ off single actions of a record, each with its sentence in the row's menu, in a selection that holds it, and for
180
+ `edit` in its details too; the rest still run. `rowLock` still locks every change at once. New types
181
+ `CrudLockableAction`, `CrudActionLocks`.
182
+ - **Options narrowed by another input: `narrowedBy`** on any input (Form only), `{ name, key?, placeholder? }`: a
183
+ Package list only of the Group picked. The form hands the input its params narrowed by the other value as it draws
184
+ (`narrowInputParams`, `narrowingValue`, `narrowingPlaceholder`, pure): static `options` filtered, `loadOptions` asked
185
+ with the new **`DropdownPageRequest.narrow`**, a load key per value, nothing offered and "Pick a group first" while the
186
+ other is empty; the form store follows it as `dependsOn` would and clears the narrowed value when a person changes the
187
+ other (never when code sets it). `crudPicker` answers `narrow` from its kept list, or, with the new **`narrowQuery`**,
188
+ asks the server (`activeListing?{narrowQuery}={value}`, one kept list per value, dropped with the others on a save).
189
+ `FormStore.inputParams(name)` is new.
190
+ - **A list narrowed by a box of its own, remembered per viewer: `Crud` `narrowPicker`** (`CrudNarrowPicker`): the
191
+ picker's params, the box's label, `remember: { key, viewer }` and `onPick`. The CRUD draws the box in its toolbar, keeps
192
+ the pick as `narrow` would, and opens the same pick on the next visit in this browser for the same viewer while the
193
+ box still offers it; only the record's key is stored. `useCrudNarrowPicker` is exported (`nexus-shared/client`).
194
+
195
+ Proved by `npm test` (`test-dist-loader`, `test-input-narrowing`, `test-files`' reason), nexus.template's `/demo/pictures`
196
+ (`pictures.cjs`: initials, the lock reason, an editor whose module is refused) and `/demo/crud`'s "One group at a time"
197
+ (`crud-narrowing.cjs`), and nexus.web's production build against a Drive API.
198
+
199
+ Card C1-fix (the review of C1, 2026-11-02):
200
+
201
+ - **`narrowedBy` clears down a chain.** A person changing the Country clears the Region, and now the District picked
202
+ among that Region's too, whatever order the form lists them in (before, the District kept a value of the old Region,
203
+ and could be saved).
204
+ - **A narrowed input's getters answer on every draw.** `Form` copied a `narrowedBy` input's params once and kept the
205
+ copy, so a `readOnly`, `hint`, `label` or `options` getter that `dependsOn` follows stayed as it first read. The
206
+ copy is now made afresh each time the input draws.
207
+ - **A `narrowPicker`'s words name its box**: "Pick a group in the Group box to see its amenities.", "... to add a new
208
+ amenity for it." on Add, and "Added for Bathroom: pick another group in the Group box to add elsewhere." on the Add
209
+ form's fixed field (before, all three said "the other list"). New on `CrudNarrow` (and so on `narrowPicker`):
210
+ **`addMessage`** and **`fixedMessage: name => sentence`**, beside `emptyMessage`; the words are
211
+ **`narrowPickWords(label, subject, plural)`**.
212
+ - **The remembered pick keeps the key alone, and only for a viewer.** No name is written to local storage (a pick kept
213
+ with one by C1's build still opens, and is written again without it), and without `remember.viewer` nothing is
214
+ remembered: **`narrowPickerStorageKey`** (now from `nexus-shared`) answers `null`. A kept pick is found again by its
215
+ key alone, **`findNarrowPick`**: `resolveOptions` when the picker has it, else the box's pages at 50 a page (never one
216
+ page of everything, which a server-paged source refuses). `readNarrowPick`, `writeNarrowPick`,
217
+ `narrowPickerPlaceholder` are the rest of `Helpers/CrudNarrowPick.ts`, pure.
218
+ - **An owner not in use is not "not found".** When a module refuses the write ticket with a 404 (the owner pending,
219
+ ended, in the trash or deleted) and a read ticket still finds it, the files say **`OWNER_NOT_IN_USE_REASON`** ("This
220
+ record is not in use now, so its files can be seen but not changed.") rather than "The record was not found.".
221
+ - **`test-dist-loader` checks this folder's whole `dist`** with the scratch build's rules (ModuleImport.js there, its
222
+ import plain, no helper in any file), so a dist built with only the first step fails.
223
+
224
+ Proved by `npm test` (23 suites; `test-input-narrowing` 49, `test-files` 73, `test-dist-loader` 17) and nexus.template's
225
+ `crud-narrowing.cjs` (55, both motions: the three sentences, a narrowed Icon whose hint follows "Free for guests"),
226
+ `staff-posts.cjs` (86, the other-list words unchanged), `crud.cjs`, `forms.cjs` (154) and `pictures.cjs` (83).
227
+
228
+ ### A record's workspace: a profile and tabs in the details popup
229
+
230
+ Card TABS-comp (2026-11-03), from the owner's request for the tab buttons of the 2026-06-20 project's record popups:
231
+ View, and a click on a row under `table.openOnClick`, open the record as a wide popup with who it is and its tabs on the
232
+ left and the chosen tab on the right. Additions only.
233
+
234
+ - **`CrudDetailsOptions.tabs`**: `{ id, label, icon?, endpoint?, hidden?(row), badge?, render(context) }[]`. A tab that
235
+ names an `endpoint` shows only to a person who may call it (the rule of `CrudDetailsAction`, again whenever the rights
236
+ change). `render` is handed a **`CrudDetailsTabContext`**: `row` (as the list has it now), `form` (the record in the
237
+ `FormStore` the `aside` is handed: the row, then the `details` endpoint's record, then each refresh's), `popup`, `tab`,
238
+ `select(tabId)` and **`refresh()`**, which reads the record again from the `details` endpoint and writes it into the
239
+ table, the pinned band, every list kept and every tree (`trees`), as an edit's answer is - no list is read again - and
240
+ answers the record (or `null`). The built-in tab `details` (the fields, with `aside` beside them) comes first; list
241
+ `{ id: "details" }` to move or rename it, give it `hidden: () => true` to leave it out, or a `render` to replace it.
242
+ Only the chosen tab is drawn. With tabs the popup's default size is `full`.
243
+ - **`CrudDetailsOptions.profile`**: `row => ({ picture?, title, subtitle?, tags? })` or anything to draw. `picture` is
244
+ the owner's main picture by the params the picture column takes (`{ ...GUEST_PICTURE, ownerId }`, a
245
+ **`CrudDetailsPicture`**: a click opens the pictures, initials stand in without one) or a node; `tags` are
246
+ `{ label, tone? }` in the look of a table's tags. It follows the record as it is loaded and refreshed.
247
+ - **`CrudDetailsOptions.remember`**: `{ key, viewer }` keeps the last tab chosen in this browser
248
+ (`nx-crud-tab:{key}:{viewer}`) and opens it next time while it is still offered; nothing is kept without a viewer.
249
+ - The layout: a 20rem column (the picture 10rem, the name, the line, the tags, then the tabs as `design="list"` rows)
250
+ and the tab beside it, which scrolls inside itself; the popup's body never scrolls and a switch never changes its
251
+ size. The workspace is a size container: below 720px of popup width (a phone) the profile is one row (a 3rem
252
+ picture) and the tabs one strip that scrolls sideways, above the tab. The focus opens on the chosen tab, a switch
253
+ that takes away the panel holding the focus puts it on the chosen tab, and it never drops to the page.
254
+ - **`Tabs`** and **`TabPanel`** (`nexus-shared/client`): tabs that switch panels, as a WAI-ARIA `tablist` - the one
255
+ piece `TabButtons`' own doc pointed to and the package did not have. The look is `TabButtons`', from the same
256
+ stylesheet (`design` `list`, `nav`, or `auto`: the list, and a strip in a container under 720px); the chosen tab is
257
+ `aria-selected` and the only one in the Tab order, the arrows (either way), Home and End move and open, the panel is
258
+ joined by `tabIds(id, value)` and takes the focus itself when it holds nothing that does. `TabOption`, `TabsDesign`,
259
+ `TabsProps`, `TabPanelProps`.
260
+ - The tabs' rules without a browser, in `nexus-shared`: `offeredCrudTabs`, `openingCrudTab`, `crudTabStorageKey`,
261
+ `readCrudTab`, `writeCrudTab`, `isCrudProfile`, `isCrudPicture`, `CRUD_DETAILS_TAB`, `OfferedCrudTab`.
262
+ - `OwnerPicture` `size` also takes a CSS length (`"var(--nx-crud-profile-picture)"`), so a picture can follow a
263
+ container query.
264
+ - Styles: `Nexus.Crud.css` (`nx-crud-workspace`, `__frame`, `__frame--alone`, `__side`, `__profile`, `__nav`,
265
+ `__panel`; `nx-crud-profile`, `__picture`, `__text`, `__title`, `__subtitle`, `__tags`) and `Nexus.Tab.Buttons.css`
266
+ (the chosen option is `:checked` or `[aria-selected="true"]`, a button's own look taken away, `nx-tabs--auto`,
267
+ `nx-tab-panel`).
268
+ - Proved by `scripts/test-crud-details-tabs.mts` (33) and nexus.template's `/demo/crud` "A guest's workspace" with
269
+ `scripts/browser-checks/crud-workspace.cjs` (60 in both motion modes: the click on a row, the keyboard, one Update
270
+ and one Detail for a check-in with the row's visits written in, the remembered tab, a role without the endpoint, every
271
+ theme light and dark at 1280x720 and 390x844).
272
+ - Card TABS-fix1-comp (2026-11-03), from the review of the first page built on it:
273
+ - **A form in a tab is never thrown away without asking.** A `SubmitForm` (and so an `ApiForm`) joins the place it
274
+ is drawn in by itself (`FormLeaveGuard`, by the rule of its "leave the page?" question: not while locked, not when
275
+ a draft keeps it); while one has changes, choosing another tab, Close, Edit, the popup's X, Esc and its bar on the
276
+ dock ask "Discard your changes?". Keep editing stays where it was. No new export.
277
+ - **`refresh()` lands after the popup closed.** Only the first read of the record stops when the popup closes; a
278
+ save in a tab followed at once by Close still writes the row's new status and dates into the lists.
279
+ - **A record off the page shown is patched in place.** `refresh()` hands the record as the popup held it, so one
280
+ opened from the pinned band, or from a page turned since, is written into every page kept that has it: no page is
281
+ dropped and the page shown is not read again.
282
+ - **A popup closed over another never leaves the focus on the page.** When its opener cannot take the focus back (an
283
+ item of a menu that closed, a button disabled while its action runs), the popup now on top takes it where it puts
284
+ it on opening: in a workspace, the chosen tab.
285
+ - The examples here, in the README and in `CrudDetailsOptions.tabs`' doc are a guest's, as the template's demo is.
286
+
287
+ ### A printable document, money in a currency, inputs shown by another, focus kept through a picture change
288
+
289
+ Card RF-comp (2026-11-03), from the whole-round review of the subscription demo's second round: three pieces the
290
+ subscription pages had written as code of their own (findings rules-3, rules-4, rules-5), and the picture input and
291
+ gallery keeping a keyboard's focus (packages-3).
292
+
293
+ - **`PrintDocument`** (`nexus-shared`, no state, so server components too): a receipt, a bill or a statement by params:
294
+ `paper` (`"a5"`, the default, or `"thermal"`, an 80 mm roll), `title`, `subtitles`, `kind`, `stamp` ("Voided: ..."),
295
+ `blocks` in order - `{ facts: [{ label, value, hidden? }] }` and `{ columns: [{ label, align? }], lines: [{ cells,
296
+ note?, hidden? }], totals: [{ label, value }] }`, falsy entries left out - and `footer` (line breaks kept). On screen it
297
+ reads in the theme's colours at its paper's width (128 mm, or 72 mm); printed with `window.print()` it is the only thing
298
+ on the paper, black on white, at the paper's left edge, and the component writes the page's `@page` rule (A5 portrait
299
+ with 10 mm margins, or 80 x 297 mm with 4 mm) while it is on the page. The print rule applies only while a document is
300
+ on the page (`body:has(.nx-print)`), so any other page prints as it is. **`PRINT_PAPERS`** are the papers as
301
+ `TabButtons` options. Styles: `Styles/Nexus.Print.css` (`nx-print`, `--thermal`, `__head`, `__title`, `__kind`,
302
+ `__stamp`, `__facts`, `__lines`, `__end`, `__note`, `__foot`). Types `PrintPaper`, `PrintFact`, `PrintColumn`,
303
+ `PrintLine`, `PrintFactsBlock`, `PrintLinesBlock`, `PrintBlock`, `PrintDocumentProps`.
304
+ - **Money in the company's currency, set once.** `DataTableProps.currency` (and `Crud` `table.currency`) writes every
305
+ `money` column as "Rs. 1,250.00" in the cells, the details, the CSV and the print; **`TableColumn.currency`** gives a
306
+ column its own, a text or `row => row.currencyText`. `formatCellText(column, value, options, row?)` takes the row for
307
+ it and `CellTextOptions.currency` the table's; **`columnCurrency`** answers which. **`formatMoney(value, { currency,
308
+ decimals = 2, locale, empty })`** writes the same anywhere else (a dashboard's figures, a receipt): "Rs. 1,250.00".
309
+ `prefix` still works as before.
310
+ - **An input shown by another input's value: `showWhen`** on any input (Form only): `{ name, in?, notIn?, filled? }`
311
+ or a list of them, every one holding (`InputCondition`). Values compare as text (1 matches "1"), a list value matches
312
+ `in` by any item. The form gives such an input a live `hidden` that reads its store and follows the inputs named as
313
+ `dependsOn` would; a hidden input keeps its value and is not checked; its own `hidden` still hides it. Pure helpers
314
+ `withInputConditions`, `inputShown`, `inputReadOnly`, `conditionsHold`, `conditionHolds`, `conditionsOf`, `isEmptyValue`.
315
+ - **An input read-only by another input's value: `readOnlyWhen`** (Form only), the same `InputCondition`s: a live
316
+ `readOnly` that follows the inputs named, the input's own `readOnlyMessage` as the reason, its value kept in the data
317
+ and not checked while read-only; its own `readOnly` still locks it. A CRUD's Edit form holds the record's key and its
318
+ Add form does not, so `readOnlyWhen: { name: "id", filled: true }` is a field picked on Add and fixed on Edit (a
319
+ price's group).
320
+ - **Options narrowed by a fixed value: `narrowedBy: { key, value, orUnset?, placeholder? }`** beside the
321
+ `{ name }` form (`InputNarrowing` is now `InputNarrowingByInput | InputNarrowingByValue`): the subscriber's own group,
322
+ known when the form opens; nothing followed, nothing cleared, a `null` value offers nothing. **`orUnset`** (both forms)
323
+ also offers the options whose field is empty, a fee of every group: `DropdownPageRequest.narrow` carries it (and a
324
+ `null` value with it), and `crudPicker` honours it from its kept list. `isFixedNarrowing` tells the forms apart.
325
+ - **The picture input and the gallery keep a keyboard's focus.** Their buttons no longer switch themselves off while a
326
+ change runs (`disabled` sent the focus to the page in every browser): they stay focusable, `aria-disabled`, their
327
+ press ignored (**`busyProps(busy, run)`**). When a change ends with the focused button gone - Remove and Edit with
328
+ the main picture, a star once its picture is the main one, a deleted file's buttons - the focus goes to the first
329
+ control left in its tile, else in the input (**`useFocusThroughChange(root, busy)`**, `nexus-shared/client`).
330
+
331
+ Proved by `npm test` (24 suites; `test-input-conditions` new, 37; `test-input-narrowing` 61, `test-table` 439,
332
+ `test-number-helpers` 125; the CSS contract), and nexus.template's `/demo/print-document` and `/demo/form`'s "Inputs
333
+ shown by another input" (`print-document.cjs`, 52: the money cells, the receipt, its widths and `@page`, printed alone
334
+ and black on white, a page without one printed whole, `showWhen` and the fixed narrowing, every theme light and dark at
335
+ 1280x720 and 390x844 at AA) and `pictures.cjs` (87: Remove, the star and Delete from the keyboard keep the focus).
336
+
337
+ ### A server's page in a sandboxed frame; code text in a text area
338
+
339
+ Card R2-comp (2026-11-05), for the Report module's Print layouts page: its preview and print door answer a whole HTML
340
+ page (`{ html }`, with its own `@page` sheet, screen style and a CSP that runs no script), and its editor holds a
341
+ layout's HTML. Additions only.
342
+
343
+ - **`DocumentFrame`** (`nexus-shared/client`): the page in an `<iframe srcdoc>`, sandboxed `""` (an opaque origin) or,
344
+ with `printable`, `"allow-same-origin allow-modals"`, so the handle's **`print()`** (`DocumentFrameHandle`) opens the
345
+ print dialog for the page alone; never `allow-scripts`. Props (`DocumentFrameProps`): `html`, `title` (required; the
346
+ frame's name to a screen reader), `loading` (the page area `aria-busy` at once; a read that lasts 0.4s dims the page
347
+ and says `loadingMessage`, default "Loading…"), `error` (a `role="alert"` line over the last page, which stays),
348
+ `emptyMessage` (default "Nothing to show yet."), `actions` (buttons at the end of the frame's bar - the form's bar and
349
+ status line, shown only with actions or a status - which wrap under the line on a phone), `printable`, `className`,
350
+ `style`, `ref`. It fills the height its container gives (a flex column, a grid row) and is never under 22rem; the page
351
+ scrolls inside it, an A4 sheet sideways too. While loading or failed, an `html` of null keeps the last page; a
352
+ printable page read again keeps its scroll while the title stays. A frame that becomes printable is a new frame (a
353
+ sandbox counts from the next page). Styles in `Nexus.Print.css` (`nx-document-frame`, `__bar`, `__view`,
354
+ `__view--busy`, `__page`, `__empty`).
355
+ - **`TextArea` `code`** (and `code: true` in a TextArea's `InputParams`): the monospace token, lines kept as typed
356
+ (`wrap="off"`, a long line scrolled sideways in the box), `tab-size: 2`, and `spellCheck`, `autoCapitalize`,
357
+ `autoCorrect`, `autoComplete` off and `translate="no"`; Tab still leaves the box. `nx-field__control--code` in
358
+ `Nexus.Input.css`; read-only and disabled code keep the look.
359
+ - `.nx-form-status` takes its room beside a bar's buttons from `--nx-form-status-basis` (default 14rem, as before), so
360
+ the frame's bar, whose words are few, keeps two buttons beside its line in a half-width frame (10rem).
361
+ - A CRUD's details footer and the frame share one rule for a slow read (said after 0.4s, held 0.3s): `useSlowBusy`,
362
+ internal, moved out of `Crud.tsx`.
363
+
364
+ Proved by `npm test` (25 suites; the CSS contract) and nexus.template's `/demo/document-frame`
365
+ (`document-frame.cjs`, 68 and 69 with reduced motion: the sandboxes, srcdoc, the empty, slow and failed states, the
366
+ bar's buttons by keyboard, Print firing the frame's `beforeprint` and `false` without a page or when not printable, the
367
+ scroll kept, a script that never runs printable or sealed, the code text area, every theme light and dark at 1280x720
368
+ and 375x812 with no overflow and the status lines at AA), with `crud.cjs` and `crud-workspace.cjs` for the footer.
369
+
370
+ ### A field at the top of a CRUD popup's column keeps its label and focus ring whole
371
+
372
+ Card R2-fix-label (2026-11-05), the owner's "overflow hidden bug": in a subscriber's window, the outlined "New note" box
373
+ first in the notes beside the details showed only the lower half of its raised label, and its focus outline was cut at
374
+ the top and at the panel's right edge. No export changed.
375
+
376
+ - **`.nx-crud-aside__main` and `.nx-crud-aside__side`** (side by side, from 1000px) each scroll on their own, and a
377
+ scroll box clips at its padding edge, so a field first in either lost what it draws past its box. Both now keep room
378
+ inside their edges, paid back by a negative margin so nothing moves: above, an outlined field's raised label (0.6em
379
+ of the xs text) and its 2px outline; on every side, the 3px focus ring. The form's column had only 2px (rings), now
380
+ the same room; the panel's column had none.
381
+ - **`scroll-padding`** on those columns, `.nx-crud-workspace__panel` and **`.nx-popup__body`**: focus scrolls a field's
382
+ control to the box's edge, and the raised label above it, or the box's border and ring below it, were cut there by 4
383
+ to 7.2px. The room past the control is now kept the same way in every popup.
384
+ - Proved by nexus.template's `/demo/crud` "A guest's workspace", which now has the guest's notes beside the details (a
385
+ form first) and the guest's card beside the Add and Edit forms, and `scripts/browser-checks/crud-field-room.cjs`.
386
+ It measures the label and the outline's and shadows' reach against every box that clips them, focused with text,
387
+ in the standard, outlined and inbox designs and the sm, md and lg sizes, light and dark, at 1280x720, 1040x720 and
388
+ 390x844, and at each scroll edge. 306 pass. On the old CSS 108 failed: every case in a column side by side. With
389
+ `scroll-padding` off, 20 failed: the edge cases.
390
+
391
+ ### A short list read whole; paging in the footer only
392
+
393
+ Card C-table-whole (2026-11-05), the owner on a subscriber's notes: *"footer have gap top side have gap ... used only
394
+ footer for this types of datatable"*, *"or do not put limit here"* (a subscriber keeps at most 50 notes). Additions only:
395
+ a table without the new prop draws as before.
396
+
397
+ - **`DataTable` `pager`** (`TablePagerMode`: `"bar"` | `"footer"` | `"none"`, default `"bar"`), where the paging lives:
398
+ - `"bar"`: rows per page inside the search box (or the bar without a search), the pager in the footer - as before.
399
+ - `"footer"`: rows per page beside the pager, in the footer; a table with nothing else on top draws no bar.
400
+ - `"none"`: every row on one page, for a short list read whole: no pager, no rows per page, no remembered or
401
+ preferred page size, and no footer unless a search or the filters leave rows out ("Showing 1–2 of 2 · filtered from
402
+ 6"). The count line stays as a visually hidden live line, so a screen reader still hears it. With
403
+ `searchable={false}`, `columnsMenu={false}`, `exportable={false}` and `printable={false}` the table is its rows
404
+ alone in its frame (the bar already hid itself when it held nothing; the empty footer band was what was left). A
405
+ server's pages cannot all be shown, so `paging="server"` takes `"none"` as `"footer"`.
406
+ - **`Crud`** passes it through `table: { pager }` (`CrudTableOptions`).
407
+ - Proved by nexus.template's `/demo/data-table` "A short list read whole" (`data-table-pager.cjs`, 52 in each motion
408
+ mode: the whole table with 3, 14 and no notes, the footer's own rows per page and pager, a whole table's search, the
409
+ default unchanged, every theme light and dark at 1280x720 and 390x844 with no overflow), with `data-table.cjs` and
410
+ `crud.cjs`.
411
+
412
+ ### A record's workspace draws its panel under the details
413
+
414
+ Card C-workspace-below (2026-11-05), the owner on a subscriber's window: *"make width full 100% for right side
415
+ content. the details page make full width 100% then bellow show others informations"*. No export changed; one option
416
+ was added to an exported type.
417
+
418
+ - **`CrudDetailsOptions.asidePlace`**: `"beside"` | `"below"`. `"below"` draws `details.aside` under the fields at
419
+ every width: the fields across the whole width of the tab, the panel across the whole width under them, as tall as
420
+ it needs, scrolled with them by the workspace's panel (or the popup's body) - never a column of its own, so no box of
421
+ its own clips a raised label or a focus ring. `"beside"` is the layout as it was: side by side from 1000px, stacked
422
+ under that.
423
+ - **The default changed for a record's workspace** (`tabs` or `profile`): `"below"`, so its Details tab reads as the
424
+ record first, then what belongs to it. Only the Subscribers page passes a workspace aside today; a plain details
425
+ popup keeps `"beside"`.
426
+ - `nx-crud-aside--below` in `Nexus.Crud.css`; the side-by-side rules now name `:not(.nx-crud-aside--below)`.
427
+ - Proved by nexus.template's `/demo/crud` "A guest's workspace" (a switch: notes under the details / beside them) and
428
+ `crud-aside-below.cjs` (82 per motion mode: widths, order, no scroll of its own, the note field's label and ring, at
429
+ 1280x720, 1040x720 and 390x844, light and dark); `crud-field-room.cjs` now picks "beside" for the columns it
430
+ measures; `crud-workspace.cjs` and `crud.cjs` unchanged.
431
+
432
+ ### A choice drawn as tabs
433
+
434
+ Card C-choice-tabs (2026-11-05), the owner on the counter's "What is it?" (Payment, Activate, Disconnect, each with a
435
+ sentence under it): *"if we make it tab types of design maybe it batter. (nav style) the helper label should be tool tips
436
+ or somethings"*. Additions only.
437
+
438
+ - **`RadioGroup` `design: "tabs"`** (`RadioGroupDesign` = `ChoiceDesign | "tabs"`, in `nexus-shared/client`; a
439
+ RadioGroup's `InputParams` take it too): the options as nav-style tabs on one `TabButtons` track, still native
440
+ radios in a fieldset with `role="radiogroup"` - the arrow keys move the choice, `required` and `validate` stop a
441
+ form's save with the group's message (the track edged in the danger colour), `readOnly` keeps the value with its
442
+ lock and note, `disabled` dims every tab, `size` sm/md/lg is the track's. The tabs wrap onto more rows where they do
443
+ not fit (a phone). `orientation` and `columns` do not apply.
444
+ - **Each option's `description`** is its tooltip (`title`, when it is text) and its input's accessible description
445
+ (`aria-describedby` to its note, which a screen reader reads even while hidden). The chosen option's note shows
446
+ under the track, since a phone has no tooltip; it follows every change and a form's reset (`showChosenNote`, the
447
+ group's `sync` rule), with no React state.
448
+ - **`Nexus.Tab.Buttons.css`**: the rules that raise each chosen option on its own (`multiple`) are shared by
449
+ `nx-tabs--wrap` (`:is(.nx-tabs--multiple, .nx-tabs--wrap)`, the same specificity), since a radio group's choice lives
450
+ in its inputs, not in a value a sliding indicator could follow; only `multiple` keeps the pointer on a chosen option.
451
+ `Nexus.Choice.css`: `nx-choices--tabs` (the danger edge) and `nx-choices__note`.
452
+ - The read-only note finds a tab as it finds a choice (`optionAnchor`: `.nx-choice, .nx-tabs__option`).
453
+ - Proved by nexus.template's `/demo/form` "A choice drawn as tabs" (`choice-tabs.cjs`, both motion modes: the track,
454
+ the tooltips and descriptions, required, a click and the arrow keys, read-only, disabled, the values sent, a reset,
455
+ every theme light and dark at 1280x720 and 390x844 with the labels and the note at AA), with `forms.cjs` and
456
+ `read-only.cjs`.
457
+
458
+ ### An input that remembers its last value, or opens on the company's default
459
+
460
+ Card C-input-memory (2026-11-05), the owner on the counter's Paid by and Bill: *"we should be remember the input value.
461
+ we also need company level preference to select default, if default not config then we need remember"*. Additions only.
462
+
463
+ - **`InputLayoutParams.remember`** (`InputRemember`: `key`, `viewer`, `preferred`; Form only, any input type). When
464
+ the values the form opens with leave the input without one (they do not name it, or hold it empty - a new record), it
465
+ opens on `preferred` (the company's default, from a setting the page reads), else on the person's last saved choice,
466
+ else on its own `defaultValue`; `preferred` and the kept choice each only while the input still offers them (a
467
+ choice group's or a dropdown's own `options`: not disabled, not inactive, still listed; a list keeps what is
468
+ offered; a server's list is asked since [card C-input-memory-offered](#a-remembered-choice-from-a-servers-list-kept-per-group-on-add-only-and-in-rows)).
469
+ A value no longer offered is dropped without a word, never drawn as a broken one. It is the value the form opened with, not a change: no "Unsaved changes", no "Discard?".
470
+ - **Kept when a form saves** (`SubmitForm`, so `ApiForm` and a CRUD's forms too), in local storage under
471
+ `nx-input:{key}:{viewer}` as `{ value }`; an empty value forgets. A form reset after the save (`resetOnSuccess`)
472
+ opens on the new choice. No `viewer`: nothing read or kept (`preferred` still applies). A hidden input keeps nothing.
473
+ - **No hydration difference:** the storage is read once the form is in the browser (a layout effect, before the first
474
+ paint), so a form drawn by a server opens the same there and while it hydrates; `preferred` needs no storage and
475
+ applies from the first draw. A later load (a CRUD's record) opens remembered inputs again from what it was given.
476
+ - `FormStore` gains `attachMemory(storage)` and `rememberSaved(values)` (both `@internal`); the rules are pure in
477
+ `Helpers/InputMemory.ts` (`scripts/test-input-memory.mts`, 46 checks).
478
+ - **Fixed (card C-input-memory-fix):** an input with `showWhen` whose other input gets its value in the same load (its
479
+ `defaultValue`, the record's) opened empty - without its own `defaultValue` and without its remembered value -
480
+ because the form judged it hidden on the store's values from before the load. A load, a reset and a draft's restore
481
+ now judge `showWhen` on the values they open with (the store's `get` answers from them while they are worked out); a
482
+ hidden input still keeps only what the data gave it.
483
+ - Proved by nexus.template's `/demo/form` "Remembered choices" (`input-memory.cjs`, both motion modes: empty with
484
+ nothing kept, a save keeps and the reset opens on it with nothing unsaved, a new form, the company's default winning,
485
+ a disabled and a gone value dropped with no broken tag, a server-drawn page with no hydration error, Forget, light
486
+ and dark at 1280x720 and 390x844), with `forms.cjs`.
487
+
488
+ ### A label that follows another input
489
+
490
+ Card C-label-follows (2026-11-05), the owner on a company's registration number: *"label should dynamic if possible like
491
+ if user choosed pan then pan number if vat then vat number if it possible our component allow minimal code logic"*.
492
+ Additions only.
493
+
494
+ - **`InputLayoutParams.labelWhen`** (`InputLabelWhen`: `name`, `labels`, `otherwise`, `placeholders`; Form only):
495
+ the input's label is `labels[value]` for the other input's value as it is now (compared as text, so `1` matches
496
+ `"1"`), else `otherwise`, else its own `label`; its placeholder is `placeholders[value]` where listed, else its own.
497
+ Its messages name it by the label shown ("VAT number is required.") unless it has a `fieldName`.
498
+ - Built on the `showWhen` machinery: `withInputConditions` gives such an input a live `label` (and `placeholder`)
499
+ getter and joins the other input's name into `dependsOn`, so the field draws again when it changes - no remount,
500
+ what was typed and the focus stay. `textFor` and `labelFor` are the pure rules (`test-input-conditions.mts`, 52 to 59).
501
+ - Proved by nexus.template's `/demo/form` "A label that follows another input" (`label-when.cjs`, both motion modes).
502
+
503
+ ### A preview the server works out as the person types
504
+
505
+ Card C-server-preview (2026-11-05): nexus.web's counter form and the Print layouts editor each wrote by hand "ask the
506
+ server a moment after the last change, drop the older question, keep the last answer while a new one runs". Additions
507
+ only; nexus.web is not changed here.
508
+
509
+ - **`useServerPreview(ask, { delay, failure })`** (`nexus-shared/client`): `request(question)` asks `ask(question,
510
+ signal)` `delay` ms (default 300) after the last request; an older question is aborted (its signal) and a late
511
+ answer of it is never shown; `result` and the `question` it answers stay while a newer one waits or runs and after
512
+ one fails; `waiting` during the pause, `loading` while it is with the server; `error` is the thrown error's message (an abort is no failure; `failure`
513
+ is the words when it has none); `cancel()` drops what waits or runs, `clear()` the answer too; leaving the page
514
+ stops what runs; `request`, `cancel` and `clear` are the same functions on every draw.
515
+ - **`ServerPreviewStore`**: the same outside React (`getState`, `subscribe`), and the types `ServerPreview`,
516
+ `ServerPreviewState`, `ServerPreviewAsk`, `ServerPreviewOptions`.
517
+ - Proved by `scripts/test-server-preview.mts` (17: a burst asks once, waiting then loading, an older answer dropped, the last answer kept
518
+ through a newer question and a failure, cancel, clear, the latest ask) and nexus.template's `/demo/document-frame`
519
+ layout editor, which now reads its preview through the hook (`document-frame.cjs`, both motion modes, unchanged).
520
+
521
+ ### A printable document's logo
522
+
523
+ Card C-print-logo (2026-11-05), for the receipt: the company's bill logo at its head. Additions only.
524
+
525
+ - **`PrintDocument` `logo`** (`PrintLogo` `{ src, alt }`, or any node) and **`logoOnThermal`** (default true). On A5 the
526
+ logo sits beside the title lines in a 24 mm column (at most 16 mm tall) with a column as wide after the lines, so they
527
+ stay centred on the paper and the head grows no taller than they are: a one-page receipt stays one page. On an 80 mm
528
+ roll, and on an A5 document shown narrower than 100 mm (a phone; `.nx-print` is now the `nx-print` container), it
529
+ sits small and centred above them (at most 36 x 10 mm on the roll); `logoOnThermal={false}` leaves it off a roll.
530
+ Printed in black and white (`filter: grayscale(1)`). An `{ src }` image is decoded at once (not lazy); it must
531
+ have loaded before `window.print()`.
532
+ - `Nexus.Print.css`: `nx-print__head--logo`, `nx-print__logo`, `nx-print__heading`. Without a logo the head's markup
533
+ is as it was.
534
+ - Proved by nexus.template's `/demo/print-document` (a colour logo, switches for it and for the roll) and
535
+ `print-document.cjs`: beside the title on A5 within 24 x 16 mm, the title centred, the head no taller than its lines;
536
+ centred above on the roll within 36 x 10 mm; off with `logoOnThermal`; grey in print; the longest receipt, voided,
537
+ printed alone to PDF: one A5 page (420 pt) and one roll page (227 pt).
538
+
539
+ ### A page's own switch in the table's bar keeps its label on one line
540
+
541
+ Card C-bar-noshrink (2026-11-06), from nexus.web's Branch users: a `Switch` beside Add ("All branches") broke its label
542
+ between its words at every width, and a non-breaking space made it run into the lists instead. The cause was the Add
543
+ itself: it is a `block` button, `width: 100%` of the Add section, so on a shared line it claimed the section and the
544
+ page's own items shrank (a switch's field may shrink to nothing; a button stopped at its label).
545
+
546
+ - `Nexus.Table.css`: every item of `.nx-table__bar-add` keeps its width (`flex: none`, `min-width: fit-content`), and
547
+ the Add beside other parts is as wide as its label (`width: auto`), so a bar that does not fit takes its next step
548
+ (search, Reload into ⋮, the Add section's own line). On that line the items still share it (`flex: 1 1 0`) and one
549
+ that would not fit its share wraps whole onto a line of its own. No markup or prop changed.
550
+ - Proved by nexus.template's `/demo/data-table` section "The page's own controls in the bar" (a Switch and a Button in
551
+ `actions`, lists in `toolbar`) and `/demo/crud`'s amenities ("Reset demo") in `data-table-bar.cjs`: at table widths
552
+ 1000, 899, 680, 660, 380, 370, 330, 320 and 300, with the switch and the button, each alone, and the CRUD's button,
553
+ in seven themes light and dark, compact and cozy: no label broken or cut, no two controls overlapping, nothing
554
+ outside the bar, one line from 670px (compact), the search alone under 670, Reload in ⋮ under 375, the Add section
555
+ last under 325.
556
+
557
+ ### A list that can be widened to other endpoints
558
+
559
+ Card C-crud-scope (2026-11-06), from nexus.web's Branch users: the branch signed in at (`BranchUser`) and, for a person
560
+ who may, every branch (`AllBranchUser`), picked by a switch in the toolbar and remembered per person - written by hand
561
+ with a remounted `Crud`, a page-level hook for the storage and two copies of its columns and inputs. Additions only.
562
+
563
+ - **`Crud` `scopes`** (`CrudScopes`: `options`, `label`, `description`, `remember: { key, viewer }`,
564
+ `onChange`): other lists in place of the CRUD's own, each a **`CrudScope`** (`id`, `label`, `description`, `api`,
565
+ `when`, and where they differ `columns`, `inputs`, `form`, `table`, `aria-label`; `form` and `table` over the
566
+ CRUD's, field by field). One scope is a `Switch` after Add (`description` its tooltip); two or more are tabs, the
567
+ CRUD's own list first. A scope is offered to a person who may call one of its lists and passes `when`, again on every
568
+ change of their rights; a kept scope not offered opens the CRUD's own list and stays kept.
569
+ - **Switching keeps the CRUD**: the new list is read on its endpoints (`loadKey` names the scope), the switch keeps
570
+ the focus, popups stay open. Each scope keeps its own cache of lists while the page is open, so switching back costs
571
+ no request; a change made on one scope drops the other scopes' lists (the server alone knows which hold the record).
572
+ An answer asked on one scope and landing after a switch (a row action, a mark, a mass action, a form's save, a
573
+ details refresh) writes nothing into the list on screen: every kept list is dropped and the one on screen read again,
574
+ quietly, and `onChange` still hears of it.
575
+ - **Each scope keeps its own table settings**: its `table.stateKey`, else the CRUD's and `:{id}`. **`DataTable`
576
+ reads a changed `stateKey`** as the first draw does (page size, sort, hidden columns with each column's own
577
+ default, order, widths, pins, open sections) and never writes the settings on screen under the new key;
578
+ `TableStore.restore(saved, { notify: true })` restores over a table already shown.
579
+ - **`remember`** keeps the choice in this browser (`nx-crud-scope:{key}:{viewer}`, the CRUD's own list as nothing,
580
+ storage in try/catch), read after the first draw and before any list is asked for: a reload asks for one list.
581
+ - **`CrudHandle.scope`** and **`setScope(id | null)`**.
582
+ - **A `CheckBox`'s or `Switch`'s `title`** is now its whole row's tooltip (its label too), still on the input.
583
+ - New exports: `CrudScope`, `CrudScopes`, `offeredCrudScopes`, `shownCrudScope`, `crudScopeProps`,
584
+ `crudScopeStateKey`, `crudScopeStorageKey`, `readCrudScope`, `writeCrudScope` (`nexus-shared`);
585
+ `useCrudScopes`, `CrudScopeControl`, `CrudScopeState` (`nexus-shared/client`).
586
+ - Proved by `scripts/test-crud-scopes.mts` (32) and nexus.template's `/demo/crud` section "One desk, or every desk"
587
+ (posts through `DeskStaffPost` and, for the Admin role, every desk's through `StaffPost`; the demo backend's
588
+ narrower controllers are views of the same records) in `crud-scopes.cjs`.
589
+ - **Review fixes** (card C-scope-fix, review round 4):
590
+ - **Opening on a remembered scope no longer wipes the CRUD's own list's table settings.** `DataTable` saves what its
591
+ store holds, never the draw's settings: the first draw, which still held the opening defaults, wrote them under the
592
+ own list's key when the kept scope was read, and its sort and hidden columns were gone on the way back.
593
+ - **A form stays in the scope it was opened on**: an Edit pressed in a details popup after a switch is the details'
594
+ scope's form - its `inputs`, `form` and endpoints - and its save is that scope's answer (it went to the scope shown,
595
+ with the other scope's fields).
596
+ - **Each scope keeps its own Add**, and so its draft (the popup's name is `{name}@{scope}`): a minimized Add of one
597
+ list never comes back on another. An Edit keeps one popup per record wherever it is opened.
598
+ - **An answer that lands after a switch** still marks the lookups of the scope it was asked on and of the one shown as
599
+ old (`expireLookups`: a picker's or a join's next read asks again) and drops every tree's kept pages, as an answer on
600
+ screen writes them.
601
+ - Proved by `crud-scopes.cjs` section K, each line failing on the code before: the own list's sort and hidden
602
+ Department kept through a client navigation that opens on the kept scope; a Spa post's Edit from details restored
603
+ after a switch is every desk's form and saves to `StaffPost/Update`; the own Add's draft stays the own list's; the
604
+ demo's "Find a post" box (every desk's `StaffPost/ActiveListing`, its pages dropped on every change of the CRUD)
605
+ asks its list again after an answer for every desk lands on the front desk's list.
606
+
607
+ ### Pinned columns give way on a narrow table
608
+
609
+ Card C-pin-narrow (2026-11-06, with C-scope-fix), from nexus.web's Branches at 390 px: Name and Picture pinned left took
610
+ 284 of the table's 354 px, leaving 70 for every other column. Additions only, and no page passes anything.
611
+
612
+ - **The columns that stay - the numbers and checkbox column and the actions, which always stay, and the pinned ones -
613
+ take at most 60% of the table's width on screen** (`PINNED_SHARE`). Pins are kept from the outer edge in; a pinned
614
+ column that would pass that share, and every one inside it on the same side, scrolls with the others until the table
615
+ is wide enough again. Measured after every draw and whenever the table's width changes; staying or scrolling changes
616
+ no width, so it settles at once.
617
+ - A column that gives way keeps its side: it is drawn there, moves among its side's columns, and its menu still offers
618
+ Unpin. **`TableViewColumn.side`** (new) is the side chosen; **`pin`** is the side it stays on now (null while it
619
+ scrolls, so its cells carry no `data-pin`). Headings carry `data-pin-side`, which the drag's drop targets follow.
620
+ - New exports (`nexus-shared`): `PINNED_SHARE`, `releasedPins(view, frame, left, right, share?)`.
621
+ - Proved by `scripts/test-table.mts` (the rule, and `viewColumns` with released columns) and nexus.template's
622
+ `data-table.cjs` at 390 px with Booking and Guest pinned: the columns that stay keep to 60% of the table, the outer
623
+ ones stay with their cells and the others scroll, both keep their side, and at 1280 both stay.
624
+
625
+ ### A row action that opens a form of its own for the record
626
+
627
+ Card C-row-form (2026-11-06), from nexus.web's Permissions row action (`usePermissionSetActions`, written by hand: a
628
+ popup with an `ApiForm` reading the set, saving it elsewhere, then `apply` of the row's count). Additions only.
629
+
630
+ - **`Crud` `rowForms`**, each a **`CrudRowForm`**: `id`, `label`, `description`, `icon`, `inline`, `modes`
631
+ (default the lists that offer Edit), `when(row, mode)`, `lock(row)` (listed switched off with its sentence), `target(row)`
632
+ (the `{id}` the endpoints are called with), `inputs` (params, or a function of the record and of what `load` read;
633
+ it may answer a promise), `load` (an endpoint or a function), `submit`, `transform(values, row, loaded)`, `writes`
634
+ (the answer's fields by name, or a function), `title`, `size`, `submitLabel`, `onSaved(result, row, values)`.
635
+ Offered after the built-in actions and before `rowActions`, to a person who may call `submit` and an endpoint
636
+ `load`; one popup per record and form (`reopen: "restore"`), a discard question before changes are lost.
637
+ - **A read that fails never leaves a savable form**: the API client answers `ok: false` rather than throwing, so a
638
+ failed answer (and one with no record, and a function's `null`) is turned into `ApiForm`'s failed state - "Could not
639
+ load the values" with Retry - and the footer's Save is gone until a read succeeds. (A permission set read that failed
640
+ opened empty, and its Save wiped the set.)
641
+ - **The write-back is only what the answer names**: `writes` fields merged over the record as the CRUD holds it when the
642
+ answer lands (the table, else a kept list, else a pinned section), never the record as the action was clicked, so an
643
+ Edit saved while the popup was minimized stays. Written through the cache sync (`applyRows` with the held record),
644
+ so every list kept and the pinned sections follow with no list read; an answer after a scope switch writes nothing.
645
+ - **`CrudHandle.openRowForm(id, row)`**. New exports: `CrudRowForm`, `crudRowFormOffered`, `crudRowFormWrites`
646
+ (`nexus-shared`); `openCrudRowForm`, `CrudRowFormRequest` (`nexus-shared/client`).
647
+ - Proved by `scripts/test-crud-row-forms.mts` (24) and nexus.template's `/demo/crud` posts, whose Duties (PostDuty's
648
+ Detail and Update in the demo backend, a count joined into each post) are a row form, in `crud-row-form.cjs`.
649
+
650
+ ### A held menu item says why when it is pressed
651
+
652
+ Card C-held-action-note (2026-11-06), from a review of nexus.accounts' dashboard: a row action switched off with its
653
+ reason (`rowLock`, `rowActionLock`, a row form's `lock`) showed that reason only as its menu line, and a press did
654
+ nothing, where the table's held Add says why under it. Behaviour of `Menu`, `ContextMenu` and `SplitButton`'s list; no
655
+ export is new.
656
+
657
+ - **`MenuPanel`**: an item `disabled` with a `description` is held (`data-held`). A press - a click, Enter, Space -
658
+ shows the description in the note a read-only field and a held Add show (`useReadOnlyNotice`, `.nx-field-notice`,
659
+ `role="status"`), under the item; the menu stays open, nothing is chosen, and the note goes when the menu closes. The
660
+ arrow keys and the pointer reach a held item (`ENABLED_ITEM` takes `[data-held]`), so the keyboard can press it;
661
+ a disabled item without a description is skipped as before.
662
+ - Proved by nexus.template's `held-row-actions.cjs` on `/demo/crud`: Wi-Fi's held Move to trash (`rowActionLock`) and
663
+ a not-yet-joined person's held Duties (a row form's `lock`) say their menu line's words when clicked and on Enter
664
+ after the arrow keys reach them, with the menu open and no request sent; Esc closes both; `/demo/menus`' Print
665
+ (switched off with no reason) is still skipped; the note inside the window and its words at 4.5:1 or more in seven
666
+ themes, light and dark, at 1280 and 390.
667
+
668
+ ### A form with changes asks before a navigation inside the app
669
+
670
+ Card C-leave-guard (2026-11-06), from a review of nexus.accounts' dashboard: its profile form lost its edits through the
671
+ side menu, since only the page's close (`beforeunload`) asked. Additions only.
672
+
673
+ - **The page's own forms**: a `SubmitForm` (and so an `ApiForm`) that asks before the page closes (`confirmLeave`, on
674
+ without a draft) also joins the tab's page guard (`PageLeaveGuard` in `FormLeaveGuard.ts`, beside the workspace's
675
+ guard), unless it is drawn in a `PopupHost` popup, which stays open through a navigation (`PopupHost` gives its
676
+ popups no page guard).
677
+ - **Links**: while one of them has changes, a press on a same-origin link - next/link or a plain `<a>`, the side menu,
678
+ the brand, the trail, a menu's link row - is held in the capture phase on `window` (before any other handler) and
679
+ asks "Discard your changes?" (`confirmDiscard`'s words, as a workspace tab and a form popup ask); Keep editing stays,
680
+ Discard follows the link as it was pressed (the app's router for its links). A link to another tab, a download,
681
+ another site (the browser's close question asks there) or this page is left alone. Set up once, by the first such
682
+ form.
683
+ - **`confirmPageLeave()`** (`nexus-shared/client`): the same question for a navigation the app makes itself
684
+ (`if (await confirmPageLeave()) router.push(href)`). A confirmed navigation is let go for a moment
685
+ (`PageLeaveGuard.allow`), so the form's `beforeunload` does not ask a second time after a plain link's Discard.
686
+ - Proved by nexus.template's `leave-guard.cjs` on `/admin/settings`: the side menu, the brand, the trail, Back to the
687
+ site, the account menu's link and the module menu each ask and Keep editing keeps what was typed; Discard goes (by the
688
+ router, and by the address for a plain link with no browser question); a form without changes, and one just saved,
689
+ goes with no question; a minimized Add with changes (a `PopupHost` popup) holds nothing and is still on the dock; a
690
+ reload with changes still gets the browser's question.
691
+ - **Review fixes** (card C-leave-fix, review round 4):
692
+ - **A popup's form keeps the browser's close question**: a confirmed Discard of the page's form let the next moment's
693
+ full-page load go with no question for every form, so a minimized Add with changes was lost without a word. Only the
694
+ page's own forms are let go now.
695
+ - **The page already in view asks nothing**: a module or account menu row for it lost nothing and still asked.
696
+ **`keepsPage(href, target?, download?)`** (new, `nexus-shared/client`) is the one test, for links and for the
697
+ rows nexus-admin moves by `router.push`.
698
+ - **A link inside text being edited** (the rich-text editor's) is not a navigation: a click there places the caret,
699
+ and nothing is asked.
700
+ - **`confirmPageLeave({ unload: true })`**, for a move that loads a whole page (a sign-out, `location.assign`): asks
701
+ about every form with changes, the popups' too, since the load takes them all; once confirmed, neither it nor the
702
+ browser's close question asks again until the page goes (`pagehide`), however long the call before the move takes.
703
+ **`endPageLeave()`** (new) gives the questions back when the move did not happen.
704
+ - Proved by `leave-guard.cjs` section F: the module row for the page in view and a link in the House rules editor ask
705
+ nothing and keep the typing; a page Discard followed by a full load still gets the browser's question for a
706
+ minimized Add with changes; Sign out (slowed by 2.5 s) asks about a minimized Add first, Keep editing sends nothing,
707
+ Discard signs out with no second question.
708
+
709
+ ### Every picture through the editor, and within Drive's limits
710
+
711
+ Card IMG-comp (2026-11-06), the owner: *"any image editor should be open the editor then it upload ... first compress
712
+ then save ... ~300KB max but put it into config ... max resolutions, max quality (%) in config for drive."* A picture
713
+ of about 3 MB was stored: the gallery's Upload never opened the editor, a PNG stayed a PNG at up to 2048 px, a
714
+ remembered quality could be 100, and nothing kept a byte budget. Drive's side is card IMG-api
715
+ (`drive-picture-limits-contract.md`): its host settings `Nexus:Drive:Pictures` (MaxSide 1920, Quality 80, MaxKilobytes
716
+ 300), answered by `File/List` and enforced on every upload.
717
+
718
+ - **`FileCategoryInfo`** gains `pictureMaxSide` (px), `pictureQuality` (%) and `pictureMaxBytes`, optional, read by
719
+ `NexusDriveAdapter` from `File/List` with the defaults when an older Drive answers none (so every picture is still
720
+ made small). **`PICTURE_LIMITS`** (`{ maxSide: 1920, quality: 80, maxBytes: 307200 }`) and **`pictureLimits(category)`**
721
+ (the category's, else the defaults; the bytes never above its `maxBytes`) are new. `MAX_PICTURE_SIDE` is now the
722
+ default 1920 (it was 2048) and nothing in the package reads it.
723
+ - **Every picture through the editor** (`useOwnerFiles`): the gallery's Upload - every picture column, the top bar's own
724
+ picture, a record's workspace, the accounts dashboard - opens the editor for each picture in turn ("Picture 2 of 5 ·
725
+ {title}"); Cancel skips that one and, with more to come, asks "Go on with the next file?" (Next file, Stop here). It
726
+ keeps the chosen file's name with the kind written. Documents (a PDF) are sent as they are. `renderImage` is no longer
727
+ called by the package.
728
+ - **Within the limits**: the editor gets the long edge at most `pictureMaxSide` (less when the params say), `maxQuality`
729
+ `pictureQuality` / 100 and `maxBytes` `pictureMaxBytes` (`ImageEditorOptions` and `renderImage`'s options take both;
730
+ `ImageEditorResult.fitted` says what the budget took, see nexus-image-editor's CHANGELOG). The toast after a save
731
+ says when a picture was made smaller.
732
+ - **`fileRefusal`** checks a picture against `pictureLimits(category).maxBytes` and a document against `maxBytes`; the
733
+ gallery's line says "up to 300 KB each", or "pictures up to 300 KB, documents up to 10 MB" where both are taken.
734
+ - Proved by `scripts/test-files.mts` (84) and nexus.template, whose pretend Drive answers and enforces the three
735
+ (`Demo/PictureLimits` raises them for the checks): `picture-limits.cjs` (a 4.2 MB photo stored at about 175 KB with the
736
+ Save panel's real size and its note, the slider's top 80 and a remembered 95 starting at 80, a see-through PNG stored
737
+ as a WebP that keeps its see-through parts, an opaque PNG as a JPEG, three pictures each through the editor with a
738
+ Cancel and Next file, Stop here, DRV_X059 for a client that skips the editor, raised settings letting a 1.8 MB
739
+ picture through) and `pictures.cjs` (the gallery's three uploads each through the editor, the page's square shape).
740
+ - **Review fixes** (card IMG-comp-fix, review round 4):
741
+ - **Only into a kind the category takes**: the editor gets the category's picture kinds (`ImageEditorOptions.types`,
742
+ from `pictureOptions`). Its Save panel offers only those, a picture opens on one of them, and a PNG over the size is
743
+ made a JPEG or a WebP only where that kind is taken - where neither is, it stays a PNG and is made smaller. A
744
+ see-through PNG for a category of JPEG and PNG was written as a WebP and then refused.
745
+ - **A picture the browser could not type** (no type, or `application/octet-stream`: a phone's share, a name with no
746
+ extension) is typed from its first bytes and goes through the editor, where it was sent as it was:
747
+ **`sniffPictureType(blob)`** (JPEG, PNG or WebP, else null) and **`typedFile(file)`** (new, `nexus-shared`). Any other
748
+ untyped file is still the backend's to judge.
749
+ - **An editor that cannot be downloaded says so where it was asked for**: the picture input's line under its buttons
750
+ and the gallery's alert say the loader's words ("The image editor is not installed on this server yet." for Drive's
751
+ DRV_X053) until a try opens it, besides the toast; the button stays ready, and pressing it again tries again.
752
+ `useOwnerFiles` answers `editorProblem`.
753
+ - Proved by `test-files.mts` (88) and `picture-limits.cjs` section G: the ID photo's category (JPEG and PNG) offers
754
+ those two in Save and stores a 3 MB see-through PNG as a PNG under 300 KB that is still see-through; a photo handed
755
+ over with no type opens the editor and is stored as "IMG_2041.jpg" under 300 KB; with Drive's editor missing the
756
+ input and the gallery say why, and once it is there the same button opens it and the line goes.
757
+
758
+ ### A record's page of cards, every change in a popup
759
+
760
+ Card C-record-cards (2026-11-06), from the owner's request for nexus.accounts' profile: *"make card types block
761
+ buetifull design ... when click editor then show popup and apply editor or add or something do not show direct save or
762
+ update"*, and *"Emails / Phones / Addresses - those all put inside profile tab as card. put right side icons to verify if
763
+ not. switch primary like this. single page, CURD from popup show add button, edit, remove like this. primary do not
764
+ allow"*. Additions only.
765
+
766
+ - **`RecordCards`** (`nexus-shared/client`): one record that is not a list - a person's profile, my company, a
767
+ subscriber's details - as a hero and cards, declared by params (`RecordCardsProps`): `sources` (each read once by GET
768
+ through the app's `api`, or a function; `{ read, held }` draws what is known before, such as the session's name),
769
+ `hero` (`picture` by Drive params - a click opens the pictures window - `title`, `lines`, `marks`, `actions`), `cards`,
770
+ `readOnly`, `signedOut` (the page's words, `onSignIn`, `onEnded`), `isSessionEnded`, `name`, `headingLevel`. **The page
771
+ itself never saves.**
772
+ - Three cards, each with an `id` (its element id, so `#emails` leads to it), a `source`, `title`, `icon`, `description`,
773
+ heading `actions`, `hidden`, `wide`, `readOnly`:
774
+ - **fields** (`RecordFieldsCardParams`): label over value, each value drawn by the `InputParams` its Edit uses - a
775
+ choice by its label, a date in the person's format, a number with its format, Yes or No, a multiple choice or
776
+ `look: "chips"` as chips, long text on its own lines, Markdown and rich text as such, a password never, "Not said"
777
+ (`empty`) when empty; `show` draws one by the page. **`edit`**: a popup of the card's inputs on the record, its values
778
+ merged over the record as held now when sent, so an endpoint that takes the whole record works.
779
+ - **items** (`RecordItemsCardParams`): rows between hairlines (a picture or icon, the title, marks, lines) with **icon
780
+ actions on the right** (`RecordItemActionParams`), each named as a tooltip and to a screen reader with its item
781
+ ("Verify: ram@example.com"), hidden, or held with its reason - aria-disabled, the reason in its tooltip and said in
782
+ the note a held Add shows when pressed; **`add`** in the heading, held at the `limit` ("5 of 5" beside the title,
783
+ `limitReason`).
784
+ - **content** (`RecordContentCardParams`): `render(context)`, the page's own; `context.open(flow)` opens a popup.
785
+ - **Every change is a popup** (`RecordFlowParams`): one step, or `steps` with `start` and `next` (a code sent, then
786
+ typed), each with its `lead`, `inputs`, `submit` (a URL, a request, an endpoint or a function), `transform`,
787
+ `submitLabel`/`submitTone`, `writes`, `also` (a button that calls and stays, held with a countdown while the server says
788
+ to wait: "Send again in 20s"), `done` (run another action: the code step after an Add, Make primary after a Verify),
789
+ `onError`, `render`, `confirmCancel`; one popup per card, action and item, brought back as it was; closing with changes
790
+ asks first. A form opens only on the plain values its inputs name, so a code box never opens on an item's `code`
791
+ record. **`confirm`** (`RecordConfirmParams`) asks, then calls.
792
+ - **The card shows the answer at once**: `writes` (`RecordWrites`) - the answer merged over the data as held now (the
793
+ default), the values, one item replaced or added, the item removed, a read again, named fields, or a function - into
794
+ the source, so every card on it (the hero too) follows and nothing is read again.
795
+ - **States**: loading draws every card as it will be, held (labels and bones as tall as their values, a held mark row,
796
+ Edit held with "Still being read."); a source that fails says why in each of its cards with Try again (the first says
797
+ it aloud); read-only draws no button; an answer that ended the session (default a 401 naming no input - a wrong
798
+ password names its box) closes the page's popups and leaves the page's words with Sign in again, which takes the
799
+ focus. The focus comes home when a popup's or a question's opener went with the change (the row's first button, else
800
+ the card's). A link to a card (`#emails`) is followed again once every source has answered, since the cards above it
801
+ grow as their records come.
802
+ - **Layout**: the hero across, then as many cards to a row as have 26rem (one on a phone), each as tall as what it
803
+ holds; fields as many to a row as have 11rem. It sits in the admin page's scroller, so the window never scrolls.
804
+ `Nexus.Record.css`; the table's tag gets `nx-table__tag--plain` (no dot) for a chip.
805
+ - New exports: `RecordCards` (`nexus-shared/client`); in `nexus-shared` the interfaces `RecordCardsProps`, `RecordRead`,
806
+ `RecordSourceParams`, `RecordWrites`, `RecordMark`, `RecordSubmit`, `RecordContext`, `RecordStepContext`,
807
+ `RecordStepAnswer`, `RecordCallParams`, `RecordStepParams`, `RecordFlowParams`, `RecordConfirmParams`,
808
+ `RecordCardActionParams`, `RecordItemActionParams`, `RecordCardBase`, `RecordFieldParams`, `RecordFieldsCardParams`,
809
+ `RecordItemView`, `RecordItemsCardParams`, `RecordContentCardParams`, `RecordCardParams`, `RecordHeroParams`,
810
+ `RecordSignedOutParams`; the typed helpers `fieldsCard`, `itemsCard`, `contentCard`; and the pure half
811
+ (Helpers/RecordCards.ts) `recordCardKind`, `recordField`, `recordFieldInputs`, `recordOpenedValues`, `recordFieldWide`,
812
+ `recordFieldHeld`, `recordValueView`, `RecordValueView`, `recordItems`, `recordItemKey`, `recordWithItems`,
813
+ `recordLimit`, `recordCount`, `recordLimitReason`, `recordWrite`, `RecordWriteRequest`, `RecordWriteOutcome`,
814
+ `recordActionState`, `recordActionEndpoint`, `RecordActionState`, `recordSteps`, `recordStartStep`, `recordStatusLine`,
815
+ `recordSessionEnded`, `secondsUntil`, `RecordSources`, `RecordSourceState`, `RecordReader`.
816
+ - Proved by `scripts/test-record-cards.mts` (130) and nexus.template's `/admin/profile` (a pretend person's profile on
817
+ `src/app/api/demo-profile`, with a menu of demo states) in `record-cards.cjs`.
818
+
819
+ ### The pictures window in two panes, as 2026-06-20's
820
+
821
+ Card C-gallery (2026-11-06), from the owner's look at the pictures window on nexus.accounts' profile: *"the over all
822
+ design i do not like. when hover then show inside the image the absolute position. look like the second image. this
823
+ image capture from: 06-20 version. when click profile icon then show popup like this."* No new exports, nothing removed;
824
+ the look and where the buttons are changed, every behaviour stayed.
825
+
826
+ - **`PictureGallery` / `openPictureGallery`**, for pictures: two panes with a hairline between them. On the left the
827
+ main picture large (about 15rem on its long side, a rounded square for every shape; the picture icon when there is no
828
+ main one), **Upload** (a solid primary button; Replace in a category that keeps one file) and **Remove** (no main
829
+ picture, the pictures kept; Delete in a one-file category) under it, then the count and kinds ("2 of 20 · PNG, JPEG or
830
+ WebP, up to 300 KB each"), a read-only reason and the editor-cannot-load note. On the right the pictures as a grid of
831
+ rounded squares (about 11rem, at least two a row), a round category's too - only an avatar stays round. The main one
832
+ has the accent ring and a **Main** mark in its top left corner.
833
+ - **Each picture's buttons are inside it**, centred on a popover's panel: View, Make main and Delete (neither on the
834
+ main one: it is never deleted from its own picture, as in 2026-06-20's gallery - Remove it first, or make another the
835
+ main one) and Edit. They show on hover and on a keyboard's focus in the picture (`:has(:focus-visible)`, so a click
836
+ does not leave them shown), and always where nothing can hover (`@media (hover: none)`: a touch screen); hidden, they
837
+ stay in the Tab order and to a screen reader, each named as a tooltip and to a screen reader ("Make Sita.png the main
838
+ picture"). They fade in briefly, and not at all with animations off.
839
+ - **Under 560px of its own width** (a phone, a narrow tab) the main picture is 7rem beside its buttons, over the grid.
840
+ - **The window** (`openPictureGallery`) is `xl` and edge to edge for pictures, with a height of its own
841
+ (`min(36rem, 100dvh - 10rem)`, the whole window when maximized): only the grid scrolls. Documents stay a list in an
842
+ `lg` window; one opened without `view` changes to it once the category says it takes PDF.
843
+ - **States**: loading draws the panes at their size, the main picture's place held and three held pictures where the
844
+ grid will be ("Loading the pictures…"); a gallery with none says "No pictures yet." with Upload on the left; read-only
845
+ owners (`readOnly`, `lockReason`, a refused write ticket) get no Upload, Remove, Make main, Edit or Delete. Upload
846
+ and Edit when the category is full are `aria-disabled` with the reason as their tooltip, and say it when pressed.
847
+ - **`PictureInput`**: a click on its picture, or Enter, opens the same window, as All pictures does (read only where the
848
+ input is); its buttons stay. The picture is named by what it opens ("Picture: open the pictures"), never by the
849
+ file's name. Its buttons are a `role="group"`, and `useFocusThroughChange` keeps the focus in a tile, row or group, so
850
+ after a Delete the focus lands on Upload, not on the picture.
851
+ - The data hook is `data-state="loading|ready|failed"` on `.nx-files`; the tile's `.nx-files__tile[data-primary]`,
852
+ `.nx-files__thumb` and the buttons' names are as before. `.nx-files__tile-bar` and `.nx-files__thumb--round` are gone.
853
+ - Proved by nexus.template's `pictures.cjs` (its section K: the panes, the mark, hover, keyboard and touch, Remove,
854
+ loading, empty, read only, a 390 px phone, the picture input's picture) and `picture-limits.cjs`, both motion modes.
855
+
856
+ ### A popup has no minimize button unless it asks
857
+
858
+ Card C-popup-minimize (2026-11-06), from the owner: *"every place do not put minimize popup buttons - for example the
859
+ profile picture viewer, or something just full admin page + CURD this place put minimized option for only the edit and
860
+ add"*. No new exports, nothing removed; **one default changed**, which an app sees without changing a line.
861
+
862
+ - **`Popup` / `openPopup`'s `minimizable` defaults to false** (it was `closable`, so every closable popup had the
863
+ button). Maximize and close are as they were. A page may still ask: `minimizable` on a `Popup` or in `openPopup`'s
864
+ options, or a rule by key (`definePopupRules({ "booking-edit-*": { minimizable: true } })`).
865
+ - **Only a CRUD page's forms ask**: a `Crud`'s Add and Edit (`openCrudForm`: from the list's Add, a row's Edit, a
866
+ tree's Add inside a row, and the details popup's Edit) and its row forms (`openCrudRowForm`, such as a record's
867
+ Permissions). They ask with `popupRuleFor(key)?.minimizable ?? true`, so a page's rule for their key can still take it
868
+ away. The dock, `reopen`, and the leave guard work for them as before: a minimized form with changes is still asked
869
+ about by a full-page move (`confirmPageLeave({ unload: true })`) and by the browser's `beforeunload`.
870
+ - **Everything else has none**: the details popup and a record's workspace (its Edit opens the form that has one), the
871
+ mass Description, the audit history, the spreadsheet import and bulk insert, the pictures window and gallery
872
+ (`openPictureGallery`), a record's cards' popups (`RecordCards`, `openRecordFlow`), a `Chat`'s popup, message boxes
873
+ and confirms (which already said `minimizable: false`), and any popup a page opens without asking. The image
874
+ editor's window and the picture viewer are the editor module's own and never had one.
875
+ - `handle.minimize()` from code still minimizes any popup opened with `openPopup`; the dock shows it as before.
876
+ - Proved by nexus.template's `popup-windows.cjs` (a popup that asks has Minimize, Maximize, Close; one that does not -
877
+ from a page, from code, opening maximized - has Maximize and Close; the dock, the stack, the rules by key, a scrolled
878
+ body kept), `crud.cjs`, `crud-row-form.cjs`, `staff-posts.cjs`, `leave-guard.cjs` (the forms that minimize),
879
+ `crud-workspace.cjs` (the workspace has none; a record off the page shown is now opened from the pinned guests),
880
+ `crud-scopes.cjs` (the details have none; the Edit form they open is minimized across a switch), `excel.cjs`,
881
+ `pictures.cjs` and `record-cards.cjs` (none), both motion modes, and `test-popup-rules.mts` (the forms' rule).
882
+
883
+ ### A CRUD's narrowing box asks to be set
884
+
885
+ Card C-narrow-required (2026-11-06), from the owner on nexus.web's subscribers: *"when open it focus on the group and
886
+ high light the required tab which help user to it need to set"*. Every `Crud` `narrowPicker` changes; a page changes
887
+ nothing. New: `Field` / `Dropdown` `invalid`, `DataTable` `add.onDisabledPress`, and in `CrudNarrowPick.ts`
888
+ `findLoneNarrowPick`, `startNarrowPick` (`NarrowPickStart`) and `narrowBoxTakesCursor` (`NarrowCursorMoment`).
889
+ `useCrudNarrowPicker` takes the list's words too (optional) and returns more parts (`CrudNarrowPickerParts`); `Crud`
890
+ is its only caller in the packages.
891
+
892
+ - **It settles before it asks.** As the page opens, the box shows a spinner and the list waits (no "Pick a group" that
893
+ flashes before a remembered pick opens) while the kept pick is looked up; a kept pick no longer offered is forgotten.
894
+ - **A box that offers one record picks it by itself**, and keeps it as a person's pick is (`remember`), `onPick` told.
895
+ Offered as the box offers it: a record it never offers (a person still invited, an inactive one) does not count, and
896
+ its pages are read at the box's own size, only until a second record shows.
897
+ - **With nothing picked it asks:** the box is required (`aria-required`) and drawn with a field's invalid edge and ring
898
+ (`invalid`: the look an error gives, with no message line in the toolbar), and a screen reader hears the list's
899
+ words with it (`aria-describedby`; the page's `emptyMessage` when given). It takes the cursor as the page opens,
900
+ without scrolling, unless the person has already pressed a key or a pointer there, focus is elsewhere, a popup or a
901
+ panel is open, the box is not wholly on screen (a CRUD further down a demo page), or the screen is touch-only (a
902
+ cursor would raise the on-screen keyboard over the list). A pick removes the mark; clearing it brings it back.
903
+ - **The list points at it:** its empty message has a button with the box's placeholder ("↑ Pick a group") that sends
904
+ the cursor to the box and opens its list. A read-only list (no Add for this person) asks the same way.
905
+ - **Add pressed with nothing picked** sends the cursor to the box and nudges it (the field's nudge, as a read-only lock's;
906
+ none with reduced motion), and the box's description becomes Add's reason (`addMessage`), so a screen reader hears
907
+ why it landed there; the note under Add no longer shows for it. Add still looks unavailable and keeps its tooltip.
908
+ - **A failed read** of the box's records leaves it asking, the cursor in it; its own list says "Could not load the
909
+ options." with Retry when opened.
910
+ - Proved by nexus.template's new **Admin › Posts** page (`/admin/posts`: one person's posts, the Person box in the CRUD's
911
+ toolbar; `?people=one` offers one person) and `crud-narrow-required.cjs` (settling, the mark, the cursor, Add from the
912
+ mouse and the keyboard, the list's button, a pick, remembered, forgotten, at work, a popup open, a failed read and
913
+ Retry, one person offered among invited ones, below the fold and the Viewer role on /demo/crud, 390 px with a mouse
914
+ and on a touch screen, the edge at 3:1 and the button at AA in all seven themes light and dark), both motion modes,
915
+ and `test-input-narrowing.mts` (the opening and the cursor rule).
916
+
917
+ ### A remembered choice from a server's list, kept per group, on Add only, and in rows
918
+
919
+ Cards C-input-memory-offered (2026-11-06, committed unfinished) and C-input-memory-offered-finish (2026-11-07), from what
920
+ nexus.web's Subscribers Add (card SUB-defaults) found in [`remember`](#an-input-that-remembers-its-last-value-or-opens-on-the-companys-default):
921
+ a kept type, area or package since trashed opened tagged "Inactive", one moved to another group opened untagged and the
922
+ save was refused, a contact's or a box's kind could not be remembered, and the page wrote "on Add only" and "per group"
923
+ by hand. Additions only; no new export.
924
+
925
+ - **A picker confirms a kept choice before it opens.** A dropdown whose options come from `loadOptions` (as `crudPicker`
926
+ gives) is asked by the value's key: at once from what the page holds (`peekOption`), else by `resolveOptions`, and for a
927
+ key that answers nothing (a picker that names only its kept list) or a record without its narrowing's field, through
928
+ its own pages narrowed as the form narrows them. Offered = found, not disabled, not inactive (`isOptionInactive`, else
929
+ `inactive`/`isActive: false`), and inside its `narrowedBy` narrowing; for a list of values, the part offered. A kept
930
+ choice not offered never opens and is forgotten; `preferred` gives way to the kept choice the same way. Before it was
931
+ "a server's list takes any value".
932
+ - **While it is asked** the box shows its spinner (`Dropdown` `loading`, through the new `InputOverrides.loading`) and
933
+ opens nothing; the answer opens the value as the one the form opened with. It opens nothing over the person: a pick
934
+ (even one cleared again) or a value set from code ends the ask at once. A save, a reset and a later load stop it. An
935
+ answer given for a group (the `narrowedBy`, `per` or `when` inputs) the form no longer holds opens nothing. A picker
936
+ that cannot be read opens nothing and forgets nothing; it is asked again by the next form. A reset that finds the
937
+ kept choice gone, or still to be asked, takes back the value memory opened before instead of leaving it in.
938
+ - **`remember.per`:** one value kept per value of another input, under `nx-input:{key}-{that value}:{viewer}`: a package
939
+ per group, since a package is one of its group's. While the other input holds no single value nothing is read or kept.
940
+ - **`remember.when`:** remember only while these `InputCondition`s hold on the values the form opens with and saves.
941
+ A CRUD's Edit form holds the record's key and its Add form does not, so `{ name: "id", filled: false }` remembers on
942
+ Add only: Edit opens on the record's values, asks no picker, and its save keeps nothing.
943
+ - **Rows remember** (a tabular or group form's row inputs): a new row's input opens on its kept value unless `newRow` or
944
+ the values it is added with name it (so a remembered row input takes its first choice as its own `defaultValue`, not
945
+ in `newRow`); a save of the form around them (`SubmitForm`) keeps the value of the row added last that is still there
946
+ (one added then removed does not count; none added, nothing kept). `per` and `when` read the row's own values. A
947
+ picker in a row is asked the same way (no spinner there), judged on the row as it was added.
948
+ - Inside: `Helpers/InputMemory.ts` (`keptStandingNow`, `findKeptStanding`, `rememberedOpeningNow`,
949
+ `findRememberedOpening`, `rememberDependencies`, `lastAddedRowKey`; pure), `FormStore` (`isRemembering`, internal),
950
+ `RowsStore` (`attachMemory`, `rememberAdded`), `RowsInput` and `SubmitForm` (the save tells the rows inside the
951
+ `<form>`). `test-input-memory.mts` 115 checks, `test-rows-store.mts` 123.
952
+ - Proved by nexus.template's `/demo/form` "Remembered picks from a server's list, and in rows" (an order's amenity, a
953
+ `crudPicker` narrowed by the order's group and kept per group on a new order only; a contact's kind in rows) and
954
+ `input-memory-picker.cjs`: nothing kept, a save and the reset, another group, a page drawn by the server with the
955
+ lookup held (the spinner, then the value, no hydration error), a trashed amenity and another group's (asked by key on
956
+ a cold page, and at once from the list the page holds; no tag), a pick before the answer, the lookup down and the
957
+ whole picker down, Edit, a row added then removed, a pick by the keyboard while asked and the kept value heard with
958
+ the box, light and dark at 1280x720 and 390x844, both motion modes.
959
+
960
+ ### A record tab's footer that stays in view
961
+
962
+ Card C-tab-footer (2026-11-07), the owner's first ask of the day for the subscriber window's counter tabs: their line
963
+ ("To pay …") and Save sat under the form, so at 1280x720 Save was at y=807 of a 644px panel and had to be scrolled to.
964
+ Additions only: 2 exports.
965
+
966
+ - **`CrudTabFooter`** (`nexus-shared/client`), with **`CrudTabFooterProps`** (`children`, `className`): put last in a
967
+ tab's content, it holds the tab's words (a `<p>`) at the start and its buttons (`FormButtons`, or `Button`s) at the
968
+ end. In a record's workspace it sticks to the bottom of the tab's scrolling panel while the tab scrolls and sits in its
969
+ place at the end of the content, so Save is always in view; it reaches across the panel's whole inner width on the
970
+ panel's own surface under a `--nx-border` hairline, with the popup footer's room (`--nx-space-3` above and below, the
971
+ panel's side padding at the sides) and gap, no shadow and no frame of its own. On a phone the words and the buttons
972
+ wrap as whole groups. Outside a workspace it is a plain bar where it stands.
973
+ ```tsx
974
+ <ApiForm form={form} buttons={false} api={COUNTER_CREATE} inputs={inputs} />
975
+ <CrudTabFooter>
976
+ <p role="status" aria-live="polite">{summary}</p>
977
+ <FormButtons form={form} submitLabel="Save" />
978
+ </CrudTabFooter>
979
+ ```
980
+ - **The keyboard and the footer.** A field (or a text box's caret) the focus moves to is scrolled clear of the footer:
981
+ the panel keeps the footer's height as room (`scroll-padding`), from `--nx-crud-tab-footer-size`, which the footer
982
+ measures and keeps on its panel while it is drawn. While the focus is in the footer that room goes, so Save taking
983
+ the focus never scrolls the tab. A Save that finds a problem scrolls the field to above the footer.
984
+ - Styles: `Nexus.Crud.css` (`nx-crud-tab-footer`; the panel's side padding is now `--nx-crud-panel-pad-x`, the same
985
+ `--nx-space-5`, and `--nx-space-4` under 720px, and it keeps no padding under a footer).
986
+ - Proved by nexus.template's `/demo/crud` "A guest's workspace", a new tab, **Book a stay** (a form longer than the
987
+ panel at 1280x720, its line and Save in the footer, saved into the guest's notes), and `crud-workspace.cjs` section K
988
+ and section F: the footer at the panel's foot scrolled to the top and to the end (Save hit at its centre both times),
989
+ across the panel's inner width, covering what scrolls under it, a field reached by Tab and Shift+Tab clear of it, Save
990
+ focused by code and by Shift+Tab without the tab moving, the first problem scrolled to, one Update and one Detail for
991
+ a save, the panel's own padding back on another tab, and a plain bar outside a workspace; then in 7 themes, light and
992
+ dark, at 1280x720 and 390x844, a `--nx-border` hairline, the panel's background, no shadow, the line at AA, nothing
993
+ past the bar or the window, whole groups wrapped.
994
+
995
+ ### Form figures: an input worked out from others, the server's money rule, and a money column
996
+
997
+ Card C-form-figures (2026-11-07), the owner's first ask of the day: *"after the calculate what is actual value here not
998
+ showing the old version 06-20 showing as calculate value"*, on the price list, the miscellaneous fees and the counters.
999
+ Additions only: 13 exports, all from `nexus-shared`. One look moved by a pixel: a column dropdown's cells now meet their
1000
+ headings' edges (below).
1001
+
1002
+ - **`InputLayoutParams.compute`** (`InputCompute`: `from`, `value(values, withValue)`; Form only): an input worked out
1003
+ from others, shown and never typed. It is written when the form opens (new values, a loaded record) and again when an
1004
+ input in `from` changes - typed, set from code, loaded, reset - or when the form's `computeWith` changes. It is **no
1005
+ form value**: the store keeps none under its name (a loaded record's own field of that name is dropped), so it is
1006
+ never in `getValue()`, `getChanges()` or what Save sends, never checked (`problems()`), never a change (`isChanged()`,
1007
+ the leave guard, drafts), never remembered, and a value set from code or an `onFieldChange` patch naming it is not
1008
+ taken. A server's message or the form's rule naming it is the form's message. In rows it is ignored, as `showWhen` is.
1009
+ ```ts
1010
+ { type: EInputTypes.Number, name: "netAmount", label: "Calculated amount", decimals: 2, compute: { from: ["price", "vatPercentage"], value: values => priceAmounts(values).netAmount } }
1011
+ ```
1012
+ - **The box**: the field's frame (label, hint, prefix, suffix, size, design) around an `<output>` named by its label,
1013
+ `aria-live="polite"`, on the read-only tint, its value in `--nx-text`: no lock, no read-only note, no − / +, no Tab
1014
+ stop. A Number is written with its `decimals` always (1,073.50), grouped, in the person's number format; null,
1015
+ undefined, "" and NaN leave it blank, never "NaN" or "0.00". While the values load (`loading`, an ApiForm's
1016
+ `loadValue`) it is blank and disabled with the inputs beside it; in a disabled or read-only form it takes that look.
1017
+ It is as tall as a NumberBox beside it, with the same edge, in every input design. A rule that throws shows nothing and
1018
+ says so in the console. Styles: `nx-field__control--output` and `nx-field__output-text` in `Nexus.Input.css`.
1019
+ - **`FormProps.computeWith`** (so `SubmitForm`'s and `ApiForm`'s too): what the page knows beside the values, such as a
1020
+ server's preview answer, handed to each `compute` as `withValue`. Only the worked-out inputs draw again when it
1021
+ changes, compared by identity, so pass state or a `useMemo` value. `compute: { from: [], value: (_values, line) =>
1022
+ line?.netAmount }` shows a figure the server worked out, and nothing until it answers.
1023
+ - `withInputConditions` joins `compute.from` into the input's `dependsOn`. **`isComputedInput`**, **`computedValue`**
1024
+ and **`computedText`** are the pure rules (`test-input-conditions.mts`).
1025
+ - **`priceAmounts(input, places = 2)`** (`PriceAmountsInput`: `price`, `quantity`, `discountPercentage`,
1026
+ `vatPercentage`; `PriceAmounts`: `amount`, `discountAmount`, `vatAmount`, `netAmount`): the Nexus server's money rule
1027
+ (`SubscriberRenewals.Price`, 06-20's `FN_CalculateAmount`), each step rounded half away from zero, on exact decimals
1028
+ (a scaled BigInt, each figure read from its shortest text), never on floats. A missing, null or NaN figure is 0, and a
1029
+ missing quantity 1. 1,000 at 5 % off and 13 % VAT is 1,073.50; 99.99 at 12.5 % and 13 % is 98.86.
1030
+ - **`priceLadder(row, { keys, locale, totalLabel })`** (`PriceLadderKeys`, `PriceLadderLine`, `PriceLadderOptions`): a
1031
+ receipt's money lines from a row's own figures, on the server's `CounterAmounts` names by default: Rate (left out
1032
+ when the row has none), Amount, "Discount (5%)" and "VAT (13%)" (each left out at 0), then "Calculated amount". A
1033
+ percentage has up to 4 places and no trailing zeros, in `locale` ("Discount (2,5%)" with a decimal comma).
1034
+ - **The presets** (`CrudColumns.ts`, one wording for every page; **`PriceKeys`**: `price`, `quantity`, `discount`,
1035
+ `vat`, by default `price`, `discountPercentage`, `vatPercentage` and no quantity):
1036
+ - `crudInputs.netAmount(keys?, changes?)`: a Number `netAmount` labelled "Calculated amount", 2 places, span 3, hint
1037
+ "Price less the discount, plus VAT on the rest.", worked out from the keys, and blank while the price is empty.
1038
+ `changes` replaces any key, `compute` included (a counter's box shows the server's preview instead).
1039
+ - `crudColumns.amounts(keys?, { group })`: Price (money), Discount and VAT (percents) and Calculated amount
1040
+ (`netAmount`, money, worked out from the row's own figures, so a row a save just answered shows it), under one band,
1041
+ "Amount details". A page spreads them: `...crudColumns.amounts()`.
1042
+ - `crudColumns.priceLadder(row, { keys, locale, totalLabel, group = "Money" })`: the ladder's lines as money columns
1043
+ for a record's details.
1044
+ - **A money column in a dropdown**: `DropdownColumnType` gains `"money"`: the item's figure with 2 places in the
1045
+ person's number format (1,00,000.00 under lakh and crore), at the end of its column (its heading too), in even digits,
1046
+ and empty for a missing one. Its track is `auto` unless it has a `width`. **`dropdownColumnAlign`** says where a
1047
+ column's cells sit. A column dropdown's rows now take the list's gutters (`column-gap: normal` on the row's subgrid),
1048
+ so every cell meets its heading's edges. Before, a row's own 8px gap set each cell a pixel past both sides of its
1049
+ heading.
1050
+ - Proved by `test-number-helpers.mts` (SubscriberRenewalCheck's money cases and more, and the ladder),
1051
+ `test-form-store.mts` (a computed input never in getValue, getChanges or problems, never a change; the presets),
1052
+ `test-input-conditions.mts` and `test:types`. Also by nexus.template's `/demo/form` "Worked out from other inputs"
1053
+ and `/demo/dropdown`'s Fee, with `form-figures.cjs` in both motion modes: 1,073.50 on open, every keystroke followed
1054
+ (900 / 0 / 13 gives 1,017.00), Reset, a held Load blank and disabled then 904.00, a cleared price blank, a 12-digit
1055
+ price's figure whole inside its box, the disabled look, and no netAmount sent; an `<output>` with no lock, stepper or Tab stop, as tall as Price with its edge in the
1056
+ three designs; the "From the page" pair blank, then 800.00 | 904.00; the money column at 1,00,000.00 and
1057
+ 100,000.00, ending on its heading's edge; and in 7 themes, light and dark, at 1280x720 and 390x844, the figure at
1058
+ 7.54:1 or more with nothing past its box or the window.
1059
+
1060
+ ### A form asks before it saves, the full details in sections
1061
+
1062
+ Card C-form-confirm (2026-11-07), the owner's second ask of the day: *"the misc counter and payment counter when click
1063
+ save show popup all full details then user do contirm then save"*, as 2026-06-20's "Confirm Counter Payment" did. 7
1064
+ exports, all from `nexus-shared`, and three additions to existing options. Two looks change without a line of the
1065
+ app's: a record's details whose columns name a `group` now show a heading over each run (below), and a press outside a
1066
+ popup that stays open through it no longer takes the focus.
1067
+
1068
+ - **`SubmitFormProps.confirm`** (**`FormConfirmParams`**: `when`, `title`, `message`, `details`, `confirmLabel`,
1069
+ `cancelLabel`, `size`), so `ApiForm`'s too, and **`CrudFormOptions.confirm`** for a CRUD's Add and Edit: a question
1070
+ after every value has passed its checks and before the save starts. It is always a `question`, so its confirm button
1071
+ has the focus and Enter saves. While it is open nothing else submits the form (Save, Enter, Ctrl+S, `form.submit()`),
1072
+ Ctrl+S does not open the browser's Save page, and the form says no "Saving…". **Back to the form** (the default
1073
+ `cancelLabel`), Esc and × send nothing and leave every value as it was, the focus back on what opened it; a click
1074
+ outside leaves it open. **Confirm and save** (the default `confirmLabel`) goes on to the save as before. A rule here
1075
+ that throws shows its message under the form and asks nothing. `when` limits it to some saves (the kinds that take
1076
+ money). The question goes if the form leaves while it asks.
1077
+ ```tsx
1078
+ <ApiForm api={COUNTER_CREATE} transform={saveBody} confirm={{
1079
+ when: values => ASKS_BEFORE_SAVING.includes(values.counterType),
1080
+ title: "Save this payment?",
1081
+ message: summary,
1082
+ details: () => ({ row: line, columns: counterDetailColumns(line, "confirm"), currency }),
1083
+ confirmLabel: "Confirm payment",
1084
+ size: "xl",
1085
+ }} />
1086
+ ```
1087
+ - **What it shows is what is sent.** Each text and the details are handed the values and **`body`**, what will be sent:
1088
+ an `ApiForm`'s `transform` runs once, before the question (a throw there shows under the form and asks nothing), and
1089
+ that very body is sent after Confirm, never worked out again; a plain `SubmitForm`'s body is its values. Without
1090
+ `confirm`, `ApiForm` sends as before.
1091
+ - **`details`** answers **`FormConfirmDetails`** (`row`, `columns`, `currency`; the row and columns may be the page's own
1092
+ types) and is drawn by `CrudDetails`, in the normal text colour, under the muted line. The box is `lg` with details
1093
+ (two fields to a line) and `sm` without, unless `size` says otherwise (`xl`: three to a line). Its body scrolls while
1094
+ the buttons stay in view.
1095
+ - **`CrudDetails` draws `column.group`**: each run of neighbouring columns that name one group is a section under an
1096
+ `h3` heading (`nx-crud-details__section`, `nx-crud-details__heading`, the small muted heading style of a dropdown's
1097
+ groups), the heading nearer its fields than the section above it. Columns with no group draw as before, without a
1098
+ heading; with no group anywhere the markup is exactly as before. So every details view gets it: the View popup, a
1099
+ workspace's Details tab, a counter's preview, the question. **`crudDetailsRuns(columns)`** (**`CrudDetailsRun`**) is
1100
+ the rule; a group named again further on starts a new run, as a table's band does.
1101
+ - **`formConfirmAsks(confirm, values)`** and **`formConfirmQuestion(confirm, values, body)`**
1102
+ (**`FormConfirmQuestion`**) are the question's pure rules: whether to ask, each text settled, the defaults.
1103
+ - **The message box** (`messageBox.confirm`, `show`, `alert`, `prompt`) takes **`details`**: drawn after the message in
1104
+ its own `.nx-message__details`, in the normal text colour (the message stays muted), scrolling with the body. Also
1105
+ **`closeOnBackdrop`** (default: as `closable`; `false` keeps a box open through a click outside while Esc and × still
1106
+ dismiss it) and **`signal`** (an abort dismisses the box). A box without them draws exactly as before.
1107
+ - **`Popup`**: a press on the backdrop of a popup that stays open through it (`closeOnBackdrop: false`: the question, a
1108
+ CRUD's Add and Edit, a row's form, a spreadsheet import) leaves the focus where it was. It used to move to the
1109
+ dialog, so after a stray click Enter no longer confirmed and a field lost its caret.
1110
+ - Styles: `Nexus.Crud.css` (`nx-crud-details__section`, `nx-crud-details__heading`), `Nexus.Popup.css`
1111
+ (`nx-message__details`).
1112
+ - Proved by `test-form-confirm.mts` (the runs, `when`, the texts and the defaults) and `test:types` (`form-confirm.tsx`:
1113
+ a page's own row type in the details, the mistakes refused). Also by nexus.template's `/demo/form` "Confirm before
1114
+ saving" (an order: "Place this order?", the body in Order and Money sections, "Place order", `xl`, a transform that
1115
+ can throw, a customer the demo server refuses), with `form-confirm.cjs` in both motion modes: Place order, Enter in a box
1116
+ and Ctrl+S in a box each open one question with no call and no "Saving…"; a click where the form's button is, the
1117
+ form's own submit, Ctrl+S (its default held) and a backdrop click while it asks change nothing and leave the focus on
1118
+ Place order; Enter sends one call whose body is the figures shown; Esc, × and Back send nothing, keep every value and
1119
+ give the focus back to the button or the box that opened it; a throw and a refusal show under the form and the next
1120
+ save asks again; and in 7 themes, light and dark, at 1280x720 and 390x844, the details at 8.3:1 or more in the text
1121
+ colour, the headings at 4.85:1 or more, three fields to a line at `xl` and one on a phone, nothing past the window,
1122
+ and in windows too short for them the body scrolling with Place order still in view. `/demo/crud`'s guest workspace
1123
+ shows its Details in Contact and Account sections (`crud-workspace.cjs`, in every theme and both sizes).
1124
+
1125
+ ### A few fields in a record's profile, left out of its Details tab
1126
+
1127
+ Card C-profile-fields (2026-11-07), the owner's third ask of the day: *"in subscriber profile when open then show the
1128
+ expirations status like the how much will run when disconnect the status which displaying in the datatable"* - a
1129
+ subscriber's Expiry and Inactive under the status tags of its window. Additions only: 2 exports, both from
1130
+ `nexus-shared`, and one addition to an existing type. A profile without `fields` draws exactly as before.
1131
+
1132
+ - **`CrudDetailsProfile.fields`** (the type takes the row now, `CrudDetailsProfile<TRow>`, defaulting as before): a
1133
+ few of the record's fields under the profile's tags, each a label and a value, the value drawn as the table and the
1134
+ details draw it (`TableCellValue`): its format in the person's settings (a date in their date format, read again when
1135
+ they change it), an option's tone, the column's `tag` after it. A field is a column given whole or a key of the
1136
+ details' `columns` (the table's, unless the details name their own); a key no column has is left out, and so is a
1137
+ field with no value for the record (null, undefined, blank text, an empty list, NaN; `false` and `0` are values).
1138
+ ```ts
1139
+ profile: row => ({ title: row.fullName, tags: [...], fields: [EXPIRY_COLUMN, INACTIVE_COLUMN] })
1140
+ ```
1141
+ - **The built-in Details tab leaves out every key `fields` names**, an empty one too, so a record's window never shows
1142
+ a field twice and a page filters nothing by hand. Sections (`group`) stay as they were for the columns left.
1143
+ - **The look**: a `<dl class="nx-crud-profile__fields">`, a `div.nx-crud-profile__field` a pair (`dt.nx-crud-profile__label`,
1144
+ `dd.nx-crud-profile__value`). Beside a wide popup's tabs it is a small grid in the middle of the 20rem column, a pair a
1145
+ line on the grid's two tracks, labels in `--nx-text-muted` and values in `--nx-text` at `--nx-text-sm`; a long value
1146
+ wraps inside the column and a tag that no longer fits goes under its value, at its start. Under 720px of popup width
1147
+ (the one-row profile) the pairs follow the tags as one wrapped line - beside them when they all fit, else on the next
1148
+ line - each pair wrapping as words do with its label kept with the start of its value.
1149
+ - **`crudProfileFields(fields, columns, row)`** (the fields a profile shows for a record) and
1150
+ **`crudDetailsWithoutProfile(columns, fields)`** (the details' columns less the keys named; the same list when none
1151
+ are) are the rules. `isCrudProfile` takes the row type too.
1152
+ - Styles: `Nexus.Crud.css`, beside the profile's.
1153
+ - Proved by `test-crud-profile-fields.mts` (37 checks: a column or a key, an unknown key, a key named twice, every kind of
1154
+ empty value, a column's own `value` and a path, the keys taken out of the Details and the groups kept) and
1155
+ `test:types` (`crud-profile.tsx`: a page's own row type, a column written in place with its tag typed from the row,
1156
+ a column of another row's type refused). Also by nexus.template's `/demo/crud` "A guest's workspace" (the guest's next
1157
+ stay, Arrives with how soon as a tag and Leaves, in the profile and not in the Details; Book a stay moves them), with
1158
+ `crud-workspace.cjs` section L: the `<dl>` after the tags, the dates in the default format and again as DD/MM/YYYY
1159
+ after a live change of the preference, no Arrives or Leaves in the Details, a long date (`dddd, MMMM DD, YYYY`) inside
1160
+ the 20rem column with its tag under it, one wrapped line at 700px and 390px with each label beside its value, a stay
1161
+ booked followed with one Update and one Detail, and a guest with no stay with no fields; then in 7 themes, light and
1162
+ dark, at 1280x720 and 390x844, the labels muted, the values and the tag at AA, the tag's dot in `--nx-info`, the
1163
+ date in two formats, nothing past the profile, its column or the window, and no Arrives or Leaves in the Details.
1164
+
1165
+ ### An input's width that follows a value
1166
+
1167
+ Card C-span-when (2026-11-07), the owner's fourth ask of the day: *"if possible and easy: in the cash counter make:
1168
+ Bill date as col-12 when it put value and during the visible input time make it col-6."* - read as: Bill date takes
1169
+ the whole row while it is empty and half once a date is put, with the switch that counts from it beside it, so Bill
1170
+ number and what follows never move. Additions only: 2 exports, both from `nexus-shared`. An input without `spanWhen`
1171
+ draws exactly as before.
1172
+
1173
+ - **`InputLayoutParams.spanWhen`** (`InputSpanWhen`: `when`, `span`; Form only): the input spans `span` columns while
1174
+ every condition of `when` holds - `showWhen`'s conditions, one or a list, each on an input's value as it is now; the
1175
+ input's own name follows its own typing - else its own `span` (a value or a getter).
1176
+ ```ts
1177
+ { type: EInputTypes.Date, name: "billDate", label: "Bill date", span: 6, spanWhen: { when: { name: "billDate", filled: false }, span: 12 } }
1178
+ ```
1179
+ - Built on the `showWhen` machinery: `withInputConditions` gives such an input a live `span` getter and joins the names
1180
+ its conditions read into `dependsOn`, so its cell takes the new width on the key that makes or unmakes the value,
1181
+ without mounting the input again: the text, the caret, the focus and an open calendar stay. Form's grid and
1182
+ `Nexus.Form.css` are unchanged (a cell reads `span` each time it draws), so a narrow form (under 34rem) still gives
1183
+ every input the whole row. **`spanFor(spanWhen, read, own)`** is the pure rule.
1184
+ - The `dependsOn` docs now name `span` among the getters a followed change reads afresh: a `get span()` with
1185
+ `dependsOn` already worked (nexus.builder.web's class shape uses one); `spanWhen` says it without a getter.
1186
+ - Like `showWhen`, it is Form's only: a tabular or group form's rows ignore it.
1187
+ - Proved by `test-input-conditions.mts` (21 new checks: the rule both ways, a list, an empty list, the live span both
1188
+ ways, a condition on the input's own name, its own `span` getter kept and giving way, with `showWhen` on the same
1189
+ input, `dependsOn` joined once each, the page's params unchanged; in a form store, the cell drawn again on its own
1190
+ typing with the same stamp, and a load) and `test:types` (`input-types.ts`: one condition or a list; refused without
1191
+ `span`, without `when`, a condition with no name, a span as text). Also by nexus.template's `/demo/form` "Inputs
1192
+ shown by another input", whose form now ends with a Bill section - Bill date, the switch "Count the dates from the bill
1193
+ date" (shown while the date is filled) and Bill number - with `label-when.cjs` section S in both motion modes: on every
1194
+ key the width follows the value and the focus and caret stay at the end of the same box, the calendar the click opened
1195
+ stays open at the box's start, the switch's top is the date's with its knob level with the date's box, Bill number
1196
+ never moves (typing, a key that unmakes the date, ×, a calendar pick), Tab goes from the date to the switch and then
1197
+ Bill number; every density and input design; then 7 themes, light and dark, at 1280x720 and, where every cell is the
1198
+ whole row, 390x844. Seen red with the rule broken (42 lines) and with a remount on a width change (33 lines).
1199
+
1200
+ ### Record cards that fit what was built on them
1201
+
1202
+ Card C-record-cards-fit (2026-11-07): the nine gaps the two Accounts agents met while building nexus.accounts'
1203
+ dashboard on `RecordCards`, and the seven findings of the round-2 review in this package. Additions only: 6 exports, all
1204
+ from `nexus-shared` (`keepsTyping`, `recordActionName`, `recordBusyKeys`, `recordHeldLines`, `RECORD_STILL_READ`, the
1205
+ type `RecordGoToOptions`), and new optional params. **Three looks change with no app change**: a card's heading buttons
1206
+ go under its title where the card is narrow, a window popup's description wraps under its title on a phone, and a
1207
+ picture's hidden buttons take no press. **One behaviour changes**: a password or a one-time code typed is no longer
1208
+ asked about before it is dropped (below).
1209
+
1210
+ - **A step's form** (`RecordStepContext.form`): every step is handed its `FormStore`, and `inputs` as a function gets
1211
+ it, so an input's label, `hidden` or options can follow another input as typed (getters with `dependsOn`) - the
1212
+ region levels of an address, named after the place picked above. nexus.accounts' address card can now be a flow.
1213
+ ```tsx
1214
+ add: { title: "Add an address", inputs: ({ form }) => addressInputs(form), submit: ACCOUNT_API.addAddress }
1215
+ ```
1216
+ - **A step's own rule**, `RecordStepParams.validate(values, context)`, the form's `validate` (messages by input, or one
1217
+ for the step), asked before anything is sent: a new password and its confirmation that differ.
1218
+ - **A step shown fresh**: `goTo(step, { fresh: true })`, and `RecordStepParams.fresh` for a step shown fresh each time
1219
+ it is reached again (by `next` or `goTo`): its form is dropped and it opens on its values, so an old code or
1220
+ password is gone.
1221
+ - **Nothing worth keeping** (`FormStore.isWorthKeeping()`, `keepsTyping(params)`): a value typed in a `Password` or
1222
+ `Code` input is a change (Save and the status line say so) but never asked about - not by Cancel or a popup's close
1223
+ (`confirmDiscard`), not by a link inside the app, not by `beforeunload` or `confirmPageLeave`. Any other change is
1224
+ asked about as before. A record step with `confirmCancel: false` also leaves the page's guards.
1225
+ - **A heading's buttons while the data is read**: a card's (and the hero's) action `hidden` and `held`, typed on the
1226
+ data, are asked only once it is read; until then the button is held, saying "Still being read." (the hero's were
1227
+ hidden), or not drawn yet with the new `loading: "hidden"`. `recordActionState(action, null, ...)` is that rule.
1228
+ - **Heading buttons named with their card** to a screen reader ("Edit: Personal details"), as an item's icons are with
1229
+ their item (`recordActionName`); the visible label is unchanged and starts the name.
1230
+ - **Nothing moves when the records come**: a held list row is a read row's height (its name's line and lines under it
1231
+ as line boxes; a row's title is at least a mark's height, with marks or without), a content card's two held lines
1232
+ are two lines of text, a count in a title no longer makes its line a pixel taller, and the new
1233
+ `RecordFieldParams.heldLines` and `RecordItemsCardParams.heldRowLines` hold as many lines as a value or a row
1234
+ usually takes (`heldRows` already held as many rows). Text that wraps by the width can still grow its card.
1235
+ - **A narrow card's buttons under its title**: the heading's text keeps 12rem (8rem for a title alone) beside its
1236
+ buttons, else the buttons go on a line under it, at its start - no title squeezed to a word a line on a phone.
1237
+ - **A window popup's description on a phone** (600px and under) takes the lines under the title it needs, in full; the
1238
+ icon and the window buttons keep the bar's first line. Wider, it is the one low bar as before.
1239
+ - Review fixes: a page's popups close with it and are keyed by the page that opened them, so a page drawn afresh never
1240
+ brings back a popup bound to the old data (whose Save sent the stale record); an Edit with a Tabular or Group input
1241
+ opens on its rows (`recordOpenedValues`), so its Save no longer wipes them; a card keeps a list of its calls on their
1242
+ way (`recordBusyKeys`), so one that ends first never frees a row whose own call still runs; the once-only scroll to
1243
+ a linked card (`#emails`) is skipped once the person has scrolled, touched, pressed a key or 5 seconds have gone, or
1244
+ when the card is already in view; a picture's hidden buttons take no press (`pointer-events: none` until shown) and
1245
+ are always shown where any pointer is coarse (a laptop's touch screen); a list that could not be read in the pictures
1246
+ window has the window's padding; the document list's Upload in a full one-file category is held with its reason
1247
+ ("This keeps one file: delete it first.").
1248
+ - Proved by `test-record-cards.mts` (151 checks: rows opened, the rules not asked while read, `loading`, busy keys,
1249
+ held lines), `test-form-store.mts` (worth keeping), `test:types` (`record-cards.tsx`), and nexus.template's
1250
+ `/admin/profile`, whose demo now has an address's lines named after what it is, Change password with its
1251
+ confirmation, Send a reset link, Back on the code step, and held rows as many as the lists: `record-cards.cjs` section
1252
+ K and new lines in A (every card's place and height through the load at 1280, every heading button's name), and
1253
+ `pictures.cjs` K7. Seen red with the busy list, the popups' closing, and the row's height undone.
1254
+
1255
+ ### Also in 4.0.0: the changes prepared as 3.3.0
1256
+
1257
+ Prepared on 2026-10-22 with asks 1-6 of the template's auth follow-ups and everything unreleased since `3.2.0`, with the
1258
+ solid fills' hover added on 2026-10-26, and never published. On their own they are new features with no code break;
1259
+ the sections below say what each added. What can move for an app:
1260
+
1261
+ - **A list's Add button reads "Add New"** on every `Crud`, whatever its subject; the form it opens keeps its subject.
1262
+ - **Menus** fade a switched-off item's icon and label, not its reason, and wrap a long second line.
1263
+ - **A table's spreadsheet menu** offers only what the app's permissions allow; the template and the export stop at a
1264
+ failed settings call, with that call's one toast.
1265
+ - **A Nexus save is not read back**, except a new record with a `crudColumns.dbJoin` column given no `from`.
1266
+ - **An Edit saved while the CRUD shows another list** goes where its band says, no longer into that list.
1267
+ - **A `crudPicker` with a `valueKey` other than `id` and no `find`** no longer posts its values to `findListing`.
1268
+ - **A solid fill under the pointer is darker than at rest in every theme**, light and dark (it turned lighter in a dark
1269
+ theme): a primary or danger `Button`, and so `SplitButton`, `FormButtons` and every solid button built on them, the
1270
+ chat's unread button and Send, and the popup's close button while it is pressed. **An app's own theme that puts a
1271
+ dark label on a light solid fill** loses contrast under the pointer now: give its fills a light label at 4.6:1 or
1272
+ more, as the README's example does.
1273
+ - **Nord's dark solid fills are a little lighter and carry white**: `--nx-primary-solid` `#557b86` (was `#4b717c`),
1274
+ `--nx-danger-solid` `#99686d` (was `#8e5e63`), and `--nx-primary-text` and `--nx-danger-text` `#ffffff` (were snow,
1275
+ `#eceff4`), on every button, check, selected day and tab indicator in Nord's dark mode.
1276
+
1277
+ ### A solid fill under the pointer keeps its label readable
1278
+
1279
+ 2026-10-26, the owner's decision of 2026-10-22: "fix it everywhere". `--nx-primary-hover` and `--nx-danger-hover` mixed
1280
+ the fill 16% toward `--nx-text`. That darkened it in light mode, but lightened it in dark mode, where the fill is already
1281
+ as light as its white label allows, so the label under the pointer fell from 4.6:1 to 3.48-3.69:1 in all seven dark
1282
+ themes. Both are now the fill with **12% black** mixed in (OKLab), in every theme and in both modes:
1283
+
1284
+ - the label under the pointer, and pressed, measures **6.06:1 or more** in a dark theme and **7.09:1 or more** in a light
1285
+ one (it was 5.94:1 or more there); at rest and with the keyboard's focus nothing changed (4.60:1 or more);
1286
+ - the step from rest is the same size in every theme, an OKLab difference of **5.8 to 7.4** (x100). Light mode's was
1287
+ 1.9 to 6.0, so a light theme's hover is a little darker than it was (Rosé Pine's, Nord's and Solarized's the most),
1288
+ and now seen in every theme.
1289
+
1290
+ Measured in a browser under a real mouse, the colours composited through a canvas, on every solid fill the packages
1291
+ hover: `Button` primary and danger, both halves of a `SplitButton`, `FormButtons`' danger submit, the chat's unread
1292
+ button and Send, and the popup's close button. The close button still turns `--nx-danger-solid` under the pointer;
1293
+ pressed, it takes `--nx-danger-hover`, so it now turns darker instead of lighter. Its minimise, maximise and restore
1294
+ buttons did not change. `npm run check:themes` reads the two lines from `Nexus.Tokens.css` and checks both: the label at
1295
+ 4.5:1 or more, and a step of 5 or more. No export or constant changed. An app that used either token for something
1296
+ other than a solid fill's hover gets the darker colour in dark mode too.
1297
+
1298
+ **The fill under the pointer keeps 2:1 on the panel**, as it does at rest. In a dark theme the darker step moves the
1299
+ fill toward the panel. In Nord's dark mode it reached 1.79:1 on `#2e3440`, under the 2:1 every solid fill keeps at rest:
1300
+ Nord's panel is lighter than other themes' and its label was snow (`#eceff4`), which left the fill no room to darken.
1301
+ Nord's dark fills now carry white, as five of the other six themes' do, and were set the way every dark fill was: the
1302
+ same hue and chroma, at the lightness where white reaches 4.6:1. They stand 2.71:1 off the panel at rest (were 2.35)
1303
+ and 2.04:1 under the pointer, and the label reads 4.60:1 at rest and 6.11:1 under the pointer. The other dark themes'
1304
+ fills keep 2.30:1 or more under the pointer. `check:themes` now holds every hovered fill to 2:1 on `--nx-surface`. A
1305
+ solid fill's label is light in every theme and both modes, because hover and pressed darken the fill: a dark label on
1306
+ a light fill reads at rest and loses contrast under the pointer.
1307
+
1308
+ ### A QR code, `QrCodeImage`
1309
+
1310
+ 2026-10-22, ask 4 of the template's two-step pages (`nexus.accounts`, whose authenticator setup drew its own). Added
1311
+ only.
1312
+
1313
+ - **`QrCodeImage`** (`nexus-shared`; a server component can render it: nothing in it needs the browser): `value`,
1314
+ `label` for screen readers, `level` (L, M, Q, H; M by default), `className` and `style` for where the page puts it.
1315
+ One SVG path of black modules on a white square that holds the quiet zone, black on white in every theme and in
1316
+ forced colours. New stylesheet `Nexus.QrCode.css`, class `nx-qr`: 9rem wide, a control's rounded corners - the look
1317
+ of nexus.accounts' `.tp-auth__qr`, whose grid area stays the page's.
1318
+ - **`encodeQr(text, level)`** and **`qrPath(code, border)`** (`QrCode`, `QrLevel`): QR Code Model 2 written by hand,
1319
+ byte mode (UTF-8), versions 1 to 40, levels L, M, Q and H, Reed-Solomon, the eight masks scored by the four penalty
1320
+ rules. A text past version 40 throws, with how much the level holds. Plain erasable TypeScript, so Node runs it as it
1321
+ is: `Project-Skills/Checks/qr-code-check.mjs` reads every code back with jsQR 1.4.0 (410 of 410 against this file),
1322
+ and `npm run test:qr` checks its shape without a decoder.
1323
+
1324
+ ### A list beside the record picked in another, and a picker's own find
1325
+
1326
+ 2026-10-22, asks 5 and 6 of nexus.web's company users page. The owner picked "the places of the person picked beside
1327
+ the people", and decided that a person is given a place only once they have joined. Added only: no export removed and no
1328
+ exported constant changed. A table or CRUD that passes none of the new props is unchanged, but for the two fixes at the
1329
+ end, which any app can see.
1330
+
1331
+ - **A picked row, `DataTable` `onPick`** (and `Crud` `onPick`): rows the user picks one of, for a page that shows the
1332
+ picked one's records beside the table.
1333
+ - A click on a row picks it; a double-click still opens it, and `openOnClick` gives way. The keyboard reaches the rows
1334
+ through a radio button each, out of sight in the first column and named with the row: Tab to the picked one, and the
1335
+ arrow keys move the pick.
1336
+ - The picked row is tinted as a selected one is, a ring round it shows the radio's focus, and the rows are numbered
1337
+ unless `numbered` is false.
1338
+ - The pick lives in the store (**`TableState.picked`**, **`TableStore.pick`**, **`TableStoreOptions.onPickChange`**)
1339
+ and follows the record: a search, a sort or another page keep it, an upsert hands the page its new copy, and it is
1340
+ let go when the record is removed, is gone after a load, or another list starts over.
1341
+ - New attributes `data-pick` and `data-picked` on a row, and the class `nx-table__pick`.
1342
+ - **A list narrowed to it, `Crud` `narrow`** (**`CrudNarrow`**: `picked`, `key`, `valueKey`, `labelKey`, `addLock`,
1343
+ `emptyMessage`), fed from the other list's `onPick`.
1344
+ - Only the picked record's records show, under a caption naming it ("Places of: Ram Thapa"). The table's name for
1345
+ screen readers says it too.
1346
+ - A list paged in the browser keeps the whole list and shows the picked record's rows from it, so another pick costs
1347
+ no request. A server's pages carry the value as a filter named `key`, and are kept per picked record.
1348
+ - Add fixes the value in the new record, read-only with the lock and a note, in a form of its own per picked record
1349
+ ("For Ram Thapa" in its title bar). Its box is named from the picked record, with no lookup.
1350
+ - Add waits, saying why on a press, while nothing is picked or the picked record may take none: `addLock`, by default
1351
+ the backend's rule (`CrudBackend.isPickable`), so a Nexus record pending (30-39), trashed or deleted takes none.
1352
+ - A save lands in the list of the record it names and in no other, whichever record is picked when it is saved.
1353
+ Joined names come from the picked record with no call (a `crudColumns.dbJoin` given `from`).
1354
+ - New helpers **`crudNarrowing`** and **`crudInNarrowing`** (**`CrudNarrowing`**).
1355
+ - **`DataTable` `caption`**: a line inside the table's frame, over its bar, saying what its rows are. Beside another
1356
+ table, the two frames still start level. New class `nx-table__caption`.
1357
+ - **`DataTable` `add`** takes `disabled` and `description`, as `TreeView`'s own button does. The button stays in the Tab
1358
+ order, `aria-disabled`, and a press shows the description in a note under it; the description is its tooltip too. While
1359
+ it waits, the spreadsheet's items stay in the options menu, without Bulk insert.
1360
+ - **`crudPicker` `find`**: where a value the kept list does not offer is looked up when `valueKey` is not `id` - an
1361
+ endpoint POSTed such values, as `findListing` is the keys, whose answers are read by `valueKey` (a person's `userId`,
1362
+ which their company row carries beside its own key); or a source of the page's own. Its answers are kept apart from
1363
+ `findListing`'s, and `expireLookups` marks them old with the controller's own. **`joinSource(source, backend, key)`**
1364
+ takes the field the answers are filed under.
1365
+ - **`lockInputs(inputs, locked, values, known)`** and **`CrudFormRequest.known`**: records the caller holds for the values
1366
+ it fixes. A fixed dropdown whose value one of them holds gets it as a selected option.
1367
+ - A save names its joined columns from a dropdown's `selectedOptions` too, after what the user picked.
1368
+
1369
+ What can move for an app on 3.2.0:
1370
+
1371
+ - **An Edit saved while the CRUD shows another list** - its form minimized, the trash shown meanwhile - no longer goes into
1372
+ that list's table, which then filed it in the cache under that list's name. It goes where its band says, and out of
1373
+ the table when an edit took it out of the list on screen.
1374
+ - **A `crudPicker` whose `valueKey` is not `id`, with no `find`**, no longer posts its values to `findListing`, which
1375
+ takes the records' keys and could answer none of them. The form names such a value from the record it holds
1376
+ (`selectedOptions`), or shows it as not found, without the call.
1377
+
1378
+ ### A step that removes something
1379
+
1380
+ 2026-10-22, asks 1-3 of the template's two-step pages (`nexus.accounts`, `/account/security`): a step that removes an
1381
+ authenticator app, turns two-step off or makes new codes confirms with a password, and carried two stand-ins of its own.
1382
+ Added only: no export removed, no exported constant changed, and a form that passes none of the new props is unchanged.
1383
+
1384
+ - **`FormButtons` `submitTone="danger"`** (and so `SubmitForm`, `ApiForm`): the submit button filled with
1385
+ `--nx-danger-solid` and `--nx-danger-text`, the `Button` `danger` variant every solid danger fill uses, for a save
1386
+ that removes something. Default `primary`, as before. New type **`FormSubmitTone`**.
1387
+ - **`downloadText(name, text, type)`** (`nexus-shared/client`): the file a table's CSV export hands over, for any text
1388
+ made in the browser - a Blob behind a link that is clicked and taken out, its address let go ten seconds later, the
1389
+ text after a byte order mark so Excel and Windows editors read UTF-8.
1390
+ - **`SubmitForm` `aria-describedby`** (and so `ApiForm`): passed to the `<form>` beside `aria-label` and
1391
+ `aria-labelledby`, so a step that puts the focus straight into its password box can point at the line saying what
1392
+ goes.
1393
+
1394
+ ### A record nobody may change here, and a switched-off item's reason readable
1395
+
1396
+ 2026-10-12, for the Builder's locked system models (the auth tables a person may only read). Added only: no export
1397
+ removed, no exported constant changed, and a `Crud` without the new prop behaves as before.
1398
+
1399
+ - **`Crud` `rowLock`** (`(row) => string | null | undefined`): why a record may not be changed here. Its Edit, Flag,
1400
+ Move to trash, Recover, Delete permanently and Move back to trash are listed **switched off** with that sentence under
1401
+ each (none on the row as an icon), its details offer no Edit, the handle's `edit(row)` opens its details instead, and
1402
+ a selection holding it has the mass actions that change records switched off. View, History, Pin and Favourite stay.
1403
+ It fills the gap pages worked around with row actions of their own ("`Crud` cannot switch a built-in action off for
1404
+ one row"); a page's own `rowActions` are still the page's to switch off.
1405
+ - **Menus** (`Nexus.Menu.css`): a switched-off item fades its icon, check, label and shortcut to half, no longer the
1406
+ whole item, so its second line (why it is off) is read at full strength: at half it measured 2.0 to 3.1:1 in every
1407
+ theme. `.nx-menu` sets `white-space: normal`, so a row's menu inside a table's actions cell (which does not wrap)
1408
+ wraps its items' second lines instead of cutting them. An app that undid either rule for its own menus can drop that.
1409
+
1410
+ ### A table's spreadsheet menu asks the permissions
1411
+
1412
+ A review of 2026-10-10 found an Admin offered a model's whole spreadsheet menu on nexus.web, every item of it refused
1413
+ by the backend (the sheets are SuperAdmin's). No export or constant changed. What can move for an app on 3.2.0:
1414
+
1415
+ - **`DataTable` `excel`** (and so `Crud` `table={{ excel }}`): each item is offered only when the app's permissions
1416
+ (`canCall`) allow every endpoint it calls - Download template the settings and the template, Import from a
1417
+ spreadsheet and Bulk insert the settings, Verify and Import, Export to Excel the settings and the export - and the
1418
+ menu follows a change of rights. An app with no permissions registered allows everything, so it sees no change; one
1419
+ with `NexusPermissions` now needs its users' access actions to hold the `{Model}/Excel/...` actions for the menu to show.
1420
+ - **`downloadExcelTemplate` and `exportExcel`** stop at a failed settings call and answer it (`ok: false`, its status
1421
+ and message): the person sees that call's one toast. Before, the template went on to a second refused call and a
1422
+ second toast, and the export went on without knowing its row limit.
1423
+
1424
+ ### The list's Add button says "Add New"
1425
+
1426
+ The owner, 2026-10-10: *"the CRUD has Add Region, Add Country and ... I want just fixed name Add New ... no dynamic"*.
1427
+ A `Crud`'s Add button above its list reads **Add New** on every page, whatever its `subject`; it was `Add <subject>`.
1428
+ The form it opens keeps its subject in the title and on its submit button ("Add region"), and Edit still says Save.
1429
+ No export or constant changed. An app's test that finds the list's button by its old name ("Add region") finds
1430
+ nothing now; one that finds the form's submit button is unaffected.
1431
+
1432
+ ### A tree that fills
1433
+
1434
+ The owner, 2026-10-10, on nexus.web's administrative region tree: *"make full height here similar datatable both side
1435
+ panel, make sure responsive"*. Added only; a tree without the new prop is unchanged.
1436
+
1437
+ - **`TreeView` `fill`**: the tree takes the height it is given, as `DataTable` `fill` does - the toolbar on top, the
1438
+ rows scrolling inside under it - and `height` and `maxHeight` are ignored while it is on. New class
1439
+ `nx-tree-field--fill` on the field. Give it a cell with a height: a side of nexus-admin's `AdminSplit` on an
1440
+ `AdminPage` with `fill` (both new in nexus-admin), or a parent of your own.
1441
+
1442
+ ### CRUD inside a tree, and changes written into a tree's kept pages
1443
+
1444
+ The owner, 2026-10-10, on the same page: *"allow CRUD ... inside the treeview"*. Added only: no export removed, no
1445
+ exported constant changed, and a page that passes none of the new params behaves as before - with one call fewer where
1446
+ a picker's saved value is in the list it keeps (see `crudPicker` below).
1447
+
1448
+ - **`CrudTree`** (`nexus-shared/client`): a controller's records as a lazy tree with Add New (under a row, or from the
1449
+ toolbar), Edit, Move to trash, Recover, Delete permanently, Move back to trash and History - through the **same**
1450
+ Add/Edit popup and calls `Crud` uses. Row icons for Add New, Edit and Move to trash (Insert, F2 and Delete on a
1451
+ focused row, all in its context menu); the picked record's fields and buttons go to a panel the page draws
1452
+ (`details`). Every change is written into the tree and the pages it keeps: a record added is placed where its
1453
+ siblings' order puts it, an edit is written in place or moved to its new parent, one in the trash leaves, and the
1454
+ parents' counts and chevrons follow. A Create still reads its record back once (as `Crud` does), and an edit that
1455
+ moved a record reads it once for the names joined from its new parent. New class `nx-crud-tree__actions`.
1456
+ - **`CrudTreePlacement`** (`cacheKey`, `rules`, `itemOf`, `parentOf`, `recordOf`, `childValues`): where a controller's
1457
+ records sit in a tree, given to `CrudTree` and to **`Crud` `trees`** - a data table on another page then writes its
1458
+ changes into that tree's kept pages too (an import drops them instead). `crudTreeChanges` turns a `CrudChange` into
1459
+ tree changes; **`CrudChange.before`** carries the records as they were.
1460
+ - **Changes into a tree's kept pages**: `writeTreeChanges(cacheKey, changes, rules)` patches `treePages`
1461
+ (`patchTreePages`) and every tree on screen with that `cacheKey` (`subscribeTreeChanges`); `TreeItemChange`
1462
+ (`put` / `remove`) and `TreeChangeRules` (`valueKey`, `hasChildrenKey`, `compare`). `TreeStore.placeItem` and
1463
+ `removeItem` keep a lazy parent's `loaded`/`total` and its own count right, so the next page skips the right number.
1464
+ - **`TreeView`**: `rowActions` (`TreeRowAction`: `TableRowAction` and a `shortcut` of Insert, F2 or Delete), `add` (a
1465
+ toolbar button of the page's own), and on the handle `apply(changes, rules)`, `getItem(value)`, and a `reveal` that
1466
+ waits for an item still loading. Focus follows a row that leaves or moves, released before the row leaves the page.
1467
+ - `RequestCache.entries(prefix)` and `set(key, value, at)` (a value written over a kept one keeps its age);
1468
+ `TableJoins.remember(source, records)` and `peekList(name)`.
1469
+ - **`crudPicker`'s `resolveOptions` names a saved value from the list it keeps** before asking `findListing`, so a form
1470
+ opened with a value the list offers (an Edit, an Add under a tree's country) makes no lookup. A value the list does not
1471
+ offer - a parent since trashed - is still looked up and shown tagged.
1472
+ - `Crud`'s form popup, its call for an action on one record and its action words now come from one internal module
1473
+ shared with `CrudTree` (`Components/Forms/CrudActions.tsx`): no change to what `Crud` shows or sends.
1474
+
1475
+ ### A tree that fixes where a record sits
1476
+
1477
+ The owner, 2026-10-10, on the same tree: *"I have select the row to add, auto add inside this. Not allow to switch
1478
+ parent ... both should be disabled, not allow to change those values."* Added only: no export removed, no exported
1479
+ constant changed, and a tree whose placement names no `placeKeys` behaves as before - except that a `CrudTree` now
1480
+ selects the row it shows (below).
1481
+
1482
+ - **`CrudTreePlacement.placeKeys`**: the fields that say where a record sits (its parent, its country). Given, `CrudTree`
1483
+ fixes them in every form it opens - an Add inside a row takes them from `childValues` (a field it leaves out is fixed
1484
+ empty), an Edit keeps the record's own - and every Add goes inside a row: the row's +, Insert, the context menu, the
1485
+ panel's Add New, `handle.create(parent)`, and the toolbar's Add New, which now adds inside the selected row - the one
1486
+ the panel shows - and, while there is none (or the selected record is in the trash), says why on a press (a click, a
1487
+ tap, Enter); it used to open with nothing filled. A record then moves only where those fields are offered, such as a
1488
+ data table's form. Fixed is read-only, the design's "cannot change" look: the lock, and a note on any try ("Added
1489
+ inside Bagmati: pick another row in the tree to add elsewhere.", "Its place cannot be changed in the tree."); the
1490
+ values stay and are sent as before, and a form's own `onFieldChange` never rewrites them (**`lockedFieldChange`**).
1491
+ - **The selected row**: a click or Enter on a `CrudTree` row selects it - tinted, and staying so while focus, the arrow
1492
+ keys and the chevrons move on - and a record's row shows that record in the panel, while a row that is no record (a
1493
+ country above its regions) empties the panel and opens. So the toolbar's Add New and the panel's never disagree.
1494
+ - **`lockInputs(inputs, locked, values)`**: inputs fixed for one opening of a form (input name -> why), copied with their
1495
+ own property descriptors so a getter param keeps working; a required input with no value stays free. The Add and
1496
+ Edit popup `Crud` and `CrudTree` share (internal) takes them as `locked`. An Add with fixed inputs is a popup of its
1497
+ own per set of fixed values (**`crudFormPlace`**, added to the popup's and the draft's keys) and says where in its
1498
+ title bar ("Inside Bagmati"): an Add started inside one row never comes back inside another, and asking for an Add
1499
+ replaces every untouched Add of the controller, so only forms with work in them wait on the dock. A record keeps **one**
1500
+ Edit popup wherever it is asked for: with the same inputs fixed it comes back; with others (a tree's fixed form, a
1501
+ data table's free one) an untouched one is replaced, and one with changes comes back as it is, with a note saying
1502
+ why - an older form's save would undo what a newer one saved. Drafts stay one per set of fixed values, so a draft
1503
+ written before a record moved is not brought back over its new place.
1504
+ - **`TreeView`**: `selectedValue` - the row the page picked, marked `aria-selected` and tinted (new class
1505
+ `nx-tree__row--selected`); moving focus, a chevron, opening or closing a row leave it where it is. `add.disabled` with
1506
+ `add.description`: the toolbar button stays in the Tab order (`aria-disabled`) and a press shows the description in a
1507
+ note under it; the description is also its tooltip and what a screen reader hears after its name.
1508
+ - **`Button`** with `aria-disabled="true"` looks disabled, stays focusable, and draws a stronger ring through the dimming.
1509
+ - **The dock** shows a popup's `description` under its title (`DockedWindow.description`, new classes `nx-dock__text`
1510
+ and `nx-dock__description`), in the bar's tooltip and in its buttons' names: two Edit forms, or two Adds, read apart.
1511
+ - A **read-only** field's placeholder is drawn in muted text, at least 4.5:1 on the read-only box (it was under 3:1 in
1512
+ light themes), since it is all the field shows ("None: directly under the country").
1513
+ - The details panel's Add New says "Add New: inside {name}" in its tooltip (it said "under").
1514
+
1515
+ ### A save is never read back
1516
+
1517
+ The owner, 2026-10-10: *"When I created a new item it calls a new API and gets details; this is not needed. We return the
1518
+ details during the creating time, so use this; the creationTime, recordStatus we will provide fake or just right now."*
1519
+ With the Nexus backend an Add is now one Create and an Edit one Update, on a data table and in a tree: the row shown is
1520
+ built from the answer, the form and what the page holds. This replaces the read backs described under "CRUD inside a
1521
+ tree" above (a Create read once, a move read once). Added only: no export removed, no exported constant changed. What
1522
+ can move for an app on 3.2.0:
1523
+
1524
+ - **A `crudColumns.dbJoin` column without `from`** - the form 3.2.0 documented - still has its new record read back once
1525
+ after an Add (`details`), as 3.2.0 read every Create with a payload answer back; it is now the only reason a Nexus save
1526
+ reads anything. Give the column `from` (the field of the joined record's key) and the read goes.
1527
+ - `TableColumn.dbJoin` is set by every `crudColumns.dbJoin` column, its `from` optional.
1528
+
1529
+ The changes:
1530
+
1531
+ - **`CrudBackend.savedRow(action, record, before)`**: the fields only the server keeps, written as the backend writes
1532
+ them. The default leaves the record as it is, and a CRUD still reads a record back once when its list cannot be told
1533
+ from it (another app's backend). **`NexusCrudBackend`**: a new record lacking them is Active (`NEXUS_ACTIVE_STATUS`),
1534
+ has no marks, was added now (`creationTime`, a UTC ISO string) and never changed; an edited one takes the status, the
1535
+ marks and the time added from the record the page holds now (`before`) - over its form's copies, which a flag or a
1536
+ mark made while the form was open has left behind - and was changed now.
1537
+ - **Joined names with no call**: `crudColumns.dbJoin` takes `from`, the field holding the joined record's key (kept as
1538
+ **`TableColumn.dbJoin`**), and after a save **`crudJoinedNames`** writes each such column again. A key kept keeps its
1539
+ name and status, an emptied one empties both, and a new or changed one takes the name (the dropdown's `labelKey`) and
1540
+ the `recordStatus` of the record behind it. That record is the one the user picked (the form now keeps a dropdown's
1541
+ item: **`FormStore.pickedItem(name)`**, `input(name, value, stamp, item)`), else one the page holds (**`Dropdown`
1542
+ `peekOption(key)`**: `crudPicker` gives one from its kept list and the FindListing cache, and **`peekFound(findListing,
1543
+ key)`** serves a picker of a page's own), else, in a tree, the parent the record sits under, else one the page held
1544
+ and has marked old (`peekOption(key, true)`). A key nothing names is left unnamed - an edit drops the old key's name -
1545
+ and **`crudJoinsMissing`** then has the record read back once. A data table's Edit that moves a record now shows the
1546
+ new parent's name; it showed the old one until the page was read again.
1547
+ - **Counts**: with `trees`, **`crudSavedCount`** gives a new record its placement's count (0) and an edited one the count
1548
+ the page holds now (a child added since its form opened counts), and **`crudParentCounts`** moves the counts on the
1549
+ parents' own rows that a data table holds - one more under the new parent, one fewer under the old - on a create, a
1550
+ move, the trash and back, as the tree's parents already did; each parent row once, however many placements the page
1551
+ gives. Both write what `CrudTreePlacement.recordOf` gives over the row, so `recordOf` carries the item's count back.
1552
+ - **In a tree**, a record added under a parent never opened is counted there and shown in the panel; the parent stays
1553
+ closed, since opening it would read its page.
1554
+ - `Dropdown` `peekOption` on its own: a value the page already holds is named from the first draw, with no
1555
+ `resolveOptions` call - while it is fresh: a record marked old since (a save on its controller, a Reload) is asked for
1556
+ again, so the box shows it as it is now. **`TableJoins.peekFresh(name, key)`**, and `peekFound`'s `aged`.
1557
+
1558
+ ### New exports no section names
1559
+
1560
+ The types and small helpers that come with the parts the sections describe, so that every new name is listed once:
1561
+
1562
+ - `nexus-shared`: the file types `FileOwner`, `FileCopy`, `StoredFile`, `FileUpload`, `FileCallOptions`,
1563
+ `PictureType`, `PICTURE_TYPES`, `isPictureType`, `ImageViewerItem`, `PictureParams`; the file helpers `fileKindName`,
1564
+ `fileOwnerKey`, `acceptOf`, `readStoredFile`, `readOwnerFiles`, `FileApiClient`, `NexusDriveAdapterOptions`,
1565
+ `PrimariesReader`, `PrimaryPicturesOptions`; `QrCodeImageProps`, `MoneyFormatOptions`, `CrudJoinedRecord`,
1566
+ `NarrowPickWords`, `CrudNarrowPickerSource`, `CrudDetailsProfile`, `CrudDetailsTab`, `TableDbJoin`.
1567
+ - `nexus-shared/client`: `getFileAdapter`, `toastFileFailure`, `usePrimaryPicture`, the props types
1568
+ `PictureInputProps`, `OwnerPictureProps`, `PictureGalleryProps`, `OpenPictureGalleryOptions`, `PictureColumnParams`,
1569
+ and the tree CRUD's `CrudTreeProps`, `CrudTreeDetails`, `CrudTreeHandle`.
1570
+
1571
+ ## 3.2.0
1572
+
1573
+ **New features, no code break.** Every export of `3.1.0` is still exported (99 are new: 72 from `nexus-shared`, 16
1574
+ from `nexus-shared/client`, 11 from `nexus-shared/server`), and no exported constant changed a value: six only gained
1575
+ keys or entries (`API_ACTIONS`, `CRUD_ROW_ACTIONS`, `CRUD_MASS_ACTIONS`, `MESSAGE_ACTION_NAMES`,
1576
+ `DEFAULT_USER_PREFERENCES`, `PREFERENCE_STORAGE_KEYS`). Both were checked against the published `3.1.0`: its exports
1577
+ by the TypeScript checker, its constants by importing both builds. The owner chose the number on 2026-10-10. It needs
1578
+ `nexus-icons` `^1.2.0`, the first with `iconStarOff`. What can move for an app:
1579
+
1580
+ - **A CRUD built from `apiController` reads `BookmarkListing` and writes `SwitchBookmarks`**, the Nexus backend's
1581
+ names since 2026-10-07, in place of `PinListing` / `Pin`. See "Pins and favourites from one call" below.
1582
+ - **Stylesheet overrides** of the pinned section's classes and of the outlined field's raised label (both below).
1583
+ - **`TablePinnedHead` is deprecated**: `TableSectionHead` takes the section's icon. The old name still renders the
1584
+ pinned section's head, with the pin icon and the new classes, so an import of it keeps building.
1585
+
1586
+ ### A tree whose children load when they are opened
1587
+
1588
+ The owner, 2026-10-09: administrative regions in two versions, a data table and a tree, both paged on the server; the
1589
+ tree "when a node is expanded, calls the API, gets the info and caches it". The data table already paged on the
1590
+ server, so the change is the tree's. Added only; a tree without `loadChildren` works as before, with one fix it shares.
1591
+
1592
+ - **`TreeView` `loadChildren`** (`nexus-shared/client`): asks for an item's children when it is first expanded, a page
1593
+ at a time - `{ parent, parentValue, skip, take, signal }` in, `{ items, total?, hasMore? }` out, at once or in a
1594
+ promise - and for the top level too when `items` is left out. The children go under the item asked for, whatever
1595
+ their own parent field says. New params `childPageSize` (default 50), `hasChildrenKey` (a boolean or a count such as
1596
+ `childCount`; without it an item shows a chevron until its first page answers, and loses it when that is empty),
1597
+ `cacheKey`, `cacheLifetime` and `partialMessage`; `items` is optional now.
1598
+ - **Status rows**, rows of the windowed list like the others, reached with the arrows and pressed with Enter or Space:
1599
+ a loading row while a page comes (the parent `aria-busy`, a polite announcement), "Could not load the items." /
1600
+ "Could not load more items." with Retry (the Dropdown's words), and **Load more** with how many are left when the
1601
+ total is known. Focus on one of them follows the page to its first new item. New classes `.nx-tree__row--status`,
1602
+ `--loading`, `--error`, `--more`, `.nx-tree__status-icon`, `.nx-tree__status-text`, `.nx-tree__retry`.
1603
+ - **What is loaded stays**: collapse and expand again asks nothing. With `cacheKey`, pages are kept in `treePages`, a
1604
+ `RequestCache` shared by every tree on the page, for 5 minutes: a remount or a second tree asks nothing, and a request
1605
+ already on its way is shared. A failure is not kept, so Retry asks again.
1606
+ - **`refresh(value?)`** on the handle drops the kept pages (all, or an item's and everything under it) and loads again
1607
+ only what is on screen - the top level and the open items with rows in view; the rest load fresh when next opened or
1608
+ scrolled to. What is drawn stays until the fresh pages replace it, and an item still there keeps what is open under it.
1609
+ - **Checking, with `cascade`**: an item whose items are not all loaded (`TreeNode.partial`) cannot be checked as a
1610
+ whole - a check stands for everything under it, and the value can only hold items that were loaded, so it would
1611
+ silently leave the rest out. A click says so (`partialMessage`); it shows mixed when something loaded under it is
1612
+ checked, never checked; Select all checks the loaded parts. A saved value whose item is not loaded yet stays in the
1613
+ value and checks the item when it comes. Without `cascade` nothing changes.
1614
+ - **Nothing asks in bulk**: Expand all, `*` and `defaultExpanded` open loaded items only.
1615
+ - **`TreeStore`**: options `lazy`, `lazyRoots`, `hasChildrenKey`; `canExpand`, `find`, `pagesOf`, `beginLoad`,
1616
+ `receivePage`, `failLoad`, `replacePages`, `markStale`, `rootPages`, and `insertMany`, a batch insert that updates
1617
+ counts along one path (a page of 50 into 100,000 items: 0.3 ms); `TreeNode.pages`, `.partial`, `.status`. Helpers
1618
+ `treePages`, `treePagesKey`, `treePageKey`, `expireTreePages`, `treeHasChildren`, `treeStatusKey`, and the types
1619
+ `TreeChildrenRequest`, `TreeChildrenPage`, `TreeChildPages`, `TreeStatusKind`.
1620
+ - **`RequestCache`** (`Helpers/RequestCache.ts`, `nexus-shared`): answers kept by key for a lifetime, a request on its
1621
+ way shared, a failure not kept, `drop` by key prefix or test; `REQUEST_CACHE_LIFETIME`, `isPromiseLike`.
1622
+ - **Fix, every tree**: a node's `uid` counted from one counter for the whole page, so a page rendered on a server that
1623
+ had rendered trees before numbered its rows differently from the browser, and React reported a hydration mismatch
1624
+ in development (production does not check attributes). It is counted per store now.
1625
+ - **Fix, every tree: a scroll that takes the focused row out of view keeps its position** (nexus.web, the same
1626
+ evening: after a client-side navigation a jump to the end came back up to 1,120px, and a wheel step left Load more
1627
+ out of view). Chrome lays the page out inside the removal of a focused element, in the middle of React's commit,
1628
+ when the leaving rows are gone and the new ones not yet in, and clamped or anchored the scroll position to that short
1629
+ list. Focus now moves from a row to the tree's viewport in the event that moves the window, before the row leaves
1630
+ (a name box being edited lets go without saving, as when its row unmounted), and `.nx-tree__list` gets a `min-height`
1631
+ of all its rows, so a layout at any moment of a commit sees its full height.
1632
+
1633
+ ### Focus rings shown whole
1634
+
1635
+ The owner, 2026-10-08: the theme panel cut the focus ring of its last example input (bottom) and of the date-format
1636
+ dropdown (left), and a focused outlined input's raised label sat across its own ring. No export changed; what a
1637
+ consumer may notice:
1638
+
1639
+ - **`.nx-popover__scroll` keeps 4px of room** on its top, bottom and left for focus rings, with a matching negative
1640
+ margin so nothing moves, and `scroll-padding` of 4px. Every panel that scrolls through it gets it: the theme panel,
1641
+ menus, and the table's Columns and Filters panels. A panel's `--nx-popover-pad` must be 4px or more. Where the
1642
+ scroller is its panel's first or last child, as a menu's list is, its left corner follows the panel's curve (the
1643
+ inner radius less that room: 9px in a menu), so a scrolled item's highlight never paints over the rounded border.
1644
+ - **A focused outlined field** (`design="outlined"`) draws a 2px accent outline over its border instead of the soft
1645
+ ring outside it, so the raised label sits in a clean gap. The raised label is placed with `top: -0.6em` instead of a
1646
+ transform, and its backing is now the label's `::before`: it starts exactly on the box's edge at every screen scale
1647
+ and paints nothing above it. The gradient it replaces painted a strip of the field's colour above read-only and
1648
+ disabled boxes and in dark popovers. An app that styles the outlined label's `::before` overrides that backing. The
1649
+ standard and inbox designs are unchanged.
1650
+
1651
+ ### The API proxy hides the backend
1652
+
1653
+ The owner, 2026-10-08: nexus.web's network tab showed `POST /api/app/configuration/Region/MassRecover` answer
1654
+ `307 Temporary Redirect` with the backend's own https address in it, which the browser then called directly, and
1655
+ asked for a proxy behind which the app looks like the backend itself. `createApiProxy({ token })` is called as before
1656
+ and no export is removed; what a route may notice:
1657
+
1658
+ - **No redirect reaches the browser** (was: passed on, its Location rewritten only on the backend's own host). One that
1659
+ stays inside the module - the root's origin, or its host moved to https, under the root's path - is followed on the
1660
+ server as `fetch` follows it, up to 3; any other answers 502 `BACKEND_REDIRECT_REFUSED` or `BACKEND_REDIRECT_LIMIT`.
1661
+ - **Response headers by an allowlist** (was: all but the hop headers): `DEFAULT_PROXY_RESPONSE_HEADERS` and the Nexus
1662
+ backend's `X-Nexus-*`. A header an app read through the proxy (`X-Request-Id`, `X-Total-Count`) must now be named in
1663
+ `responseHeaders`; Server, X-Powered-By, X-AspNet-*, HSTS, CORS, Location, Link and Set-Cookie never pass (Set-Cookie
1664
+ with `passCookies`). Every answer gets `X-Content-Type-Options: nosniff`, and `Cache-Control: private, no-store`
1665
+ when the backend sent none.
1666
+ - **Error pages are replaced** with the failure envelope and the backend's status (`BACKEND_ERROR_PAGE`): HTML, text,
1667
+ stack traces, and JSON that repeats the user's token. JSON refusals pass as they came.
1668
+ - **Refusals carry `errorCode` and `errorMessages`** beside `message` and `messageCode`, and say nothing about the
1669
+ server: an unset module answers "The School API is not set up on this server." (was: "... is not set (API_SCHOOL).",
1670
+ which named a setting; the log has it now), and a module the browser calls directly "... is not available at this
1671
+ address." New: 403 `CROSS_SITE_REFUSED`, 413 `REQUEST_TOO_LARGE`, 400 `REQUEST_UNREADABLE`.
1672
+ - **The request**: the browser's X-Forwarded-*, Forwarded, X-Real-IP, method and path overrides, `Expect` and Next.js's
1673
+ own headers never go on; the proxy writes X-Forwarded-For (the last address the app received, and only ever an IPv4
1674
+ or IPv6 address) and X-Forwarded-Host and -Proto (the app's configured origin, else the request's). A module whose
1675
+ root is a path on the app is called at this server's own host and port, over http (was: the request URL's protocol,
1676
+ which Next.js takes from X-Forwarded-Proto), and never at the configured origin or anywhere a request header names.
1677
+ - **`configureServerApi({ origin })` must not trust the Host header.** `serverApi` calls a relative root at that origin,
1678
+ so one built from a request's Host lets a client pick an address on the server's machine. The template's
1679
+ `api-setup.ts` now takes the Host only as `localhost:<PORT>` or `127.0.0.1:<PORT>` (Next.js sets `PORT` to the port
1680
+ it listens on); an app with a copy of the older one, which took any localhost port, should change it the same way.
1681
+ - **A server call can say who the client is.** `lastForwardedAddress` (which now takes anything with a `get`, Next.js's
1682
+ `headers()` included) and `readIpAddress` are exported from `nexus-shared/server`: the client's address as the proxy
1683
+ reads it, an IP address or nothing. The template's `api-setup.ts` uses the first in `configureServerApi({ headers })`
1684
+ to give Identity calls, and only them, `X-Forwarded-For` and the browser's `User-Agent`: sign-in leaves from the
1685
+ server, and Identity's rate limit counts per address. An app that signs in through `serverApi` should do the same,
1686
+ for Identity's calls only, since reading the request makes a page render per request.
1687
+ - **New options** `responseHeaders`, `passCookies`, `maxBodySize` (100 MB), `maxRedirects`, `clientAddress`,
1688
+ `allowCrossSite`, `failure`, `onProblem`; **new exports** `DEFAULT_PROXY_RESPONSE_HEADERS`, `DEFAULT_PROXY_BODY_LIMIT`,
1689
+ `DEFAULT_PROXY_REDIRECTS`, `PROXY_REFUSALS`, `nexusFailure`, `lastForwardedAddress`, `readIpAddress` and the types
1690
+ `ApiProxyFailure`, `ApiProxyProblem`, `ApiProxyCode` (`nexus-shared/server`), and `serverAppOrigin`.
1691
+ - **A backend on a development certificate**: the proxy now calls the backend's https address itself, and Node.js
1692
+ trusts ASP.NET Core's development certificate only when started with `NODE_USE_SYSTEM_CA=1` (or
1693
+ `NODE_EXTRA_CA_CERTS`). Without it such a module answers 502 `BACKEND_UNREACHABLE`, and the server's log says why.
1694
+
1695
+ ### The code box
1696
+
1697
+ The owner, 2026-10-08: one shared input for codes typed a box to a character - one-time codes, SSNs, and more - as
1698
+ dynamic as possible (numbers, strings, hex). New exports, nothing removed:
1699
+
1700
+ - **`CodeBox`** (`nexus-shared/client`): one real input lying unseen over a box per character, so autofill
1701
+ (`autoComplete="one-time-code"`), a paste and a screen reader see one field. `length` or `groups`, a `separator`
1702
+ outside the value, `characters` (`digits`, `letters`, `alphanumeric`, `hex`) or an `allowed` one-character pattern,
1703
+ `letterCase`, `masked`, `slotPlaceholder`, `block`, `onComplete`, and the field shell's label, hint, error, sizes and
1704
+ read-only note.
1705
+ - **`type: "code"`** in `InputParams` (`CodeInputParams`): `renderInput`, `validateInput`, `normalizeInputValue` and
1706
+ `formatInputValue` know it.
1707
+ - **One typing model** for every way text arrives - a key, a phone's keyboard or its suggestion, an input method, a
1708
+ paste, autofill: a whole code replaces everything; a character replaces a selected box, or goes into the box with the
1709
+ ring - inserted while the code has an empty box, so a box Backspace removed takes the right digit back, and over that
1710
+ box once the code is full. ← → Home End select box by box, and a full code's caret selects its last box. An input
1711
+ method's word goes in once, when it commits. A pasted message gives its one code ("Your 2-step code is 482913" is
1712
+ 482913; "482 913" and "123-45-6789" are read whole), or nothing when it holds more characters than boxes and no one
1713
+ code. `onComplete` runs once per code, and again once the boxes were short - by typing, a form's reset, or the page
1714
+ emptying a controlled `value`.
1715
+ - **A saved code is never cut**: `value`, `defaultValue` and a form's loaded record (`normalizeInputValue`) lose only
1716
+ separators, case and a hex "#". A code the boxes cannot hold is kept, shown invalid at once, and a form refuses to
1717
+ save it. The over-long message names the count: "The code is 6 digits long, not 7."
1718
+ - **What a screen reader hears** is what the boxes show: "9 digits, in groups of 3, 2 and 4", "8 digits: DD/MM/YYYY".
1719
+ A read-only code shows the accent border on every box while it has focus.
1720
+ - **Styles**: a box without `block` keeps its own width (`.nx-code:not([data-block]) .nx-code__slot`), about as wide
1721
+ as it is tall, and the boxes shrink alike on a narrow screen.
1722
+ - **Helpers and types** (`nexus-shared`): `validateCode`, `formatCode`, `codeGroups`, `codeLength`, `loadCode`,
1723
+ `codeFits`, `describeCode`, `readCodeText`, `typeCode`, `settleCode`, `moveCode`, `codeAfterInput`,
1724
+ `CODE_CHARACTER_PATTERNS`, `DEFAULT_CODE_LENGTH`, `CodeCharacters`, `CodeRules`, `CodeShape`, `CodeEdit`, `CodeText`,
1725
+ `CodeMove`; `normalizeCode` stays exported, though the box itself now loads a code with `loadCode`.
1726
+
1727
+ ### Another record's name, joined by the database or by the table
1728
+
1729
+ Added only; nothing an app uses changes (2026-10-08, the owner: "some columns we joined into DB, some frontend").
1730
+
1731
+ - **A column whose value is another record's** (`TableColumn.join`): the row holds the key (`from`), and the table asks
1732
+ the source once for every key its rows hold - a Nexus controller's `findListing`, or a `TableJoinSource` of the
1733
+ page's own - then shows the record's field, searched, sorted, exported and shown in the details like any column. A key
1734
+ with no record shows "Not found". `crudColumns.clientJoin(SUB_REGIONS, { key: "subRegionName", label: "Sub-region",
1735
+ from: "subRegionId" })` is the Nexus form.
1736
+ - **One cache for the page** (`tableJoins`, `Helpers/TableJoins.ts`): keys asked for in the same moment by every table and
1737
+ picker go in one call per source; a call already out is waited for; answers are kept 5 minutes (`JOIN_LIFETIME`) and an
1738
+ aged one is still drawn while it is asked again; a failed call is not repeated for 30 seconds. A Reload of a table, and
1739
+ a `Crud` write on the controller looked up, mark its answers old (`expireLookups`).
1740
+ - **A short tag after a value** (`TableColumn.tag`), and the one every reference to a record gets when that record is no
1741
+ longer available: `CrudBackend.unavailableTag(status)` - Nexus: "Trashed" (warning) from 40, "Deleted" (danger) from 60,
1742
+ none for a live record. `crudColumns.dbJoin({ key: "regionName", label: "Region", status: "regionRecordStatus" })` reads
1743
+ it from a status the database joined into the row; `clientJoin` from the record it looked up. New class
1744
+ `.nx-table__tag--after`.
1745
+ - **A picker of a controller's usable records** (`crudPicker(REGIONS, { labelKey: "regionName" })`, `nexus-shared/client`):
1746
+ its `ActiveListing` read once for the page and searched in the browser, a saved value it no longer offers found through
1747
+ the same cached `findListing`, tagged and never offered again. `CrudBackend.isPickable(record)` - Nexus: under 30.
1748
+ - `TableStore.refreshValues()` searches and sorts again when values changed without the rows; `CrudEndpoints` takes the
1749
+ `findListing` and `activeListing` an `apiController` already has.
1750
+
1751
+ ### Pins and favourites from one call
1752
+
1753
+ Two things move for an app:
1754
+
1755
+ - **A CRUD built from `apiController` now reads `BookmarkListing` and writes `SwitchBookmarks` / `MassSwitchBookmarks`**,
1756
+ the Nexus backend's names since 2026-10-07. An app whose backend still answers only `PinListing` / `Pin` / `MassPin`
1757
+ passes its own endpoints without `bookmarks`, `bookmark` and `mass.bookmark`, and keeps the pin-only behaviour.
1758
+ - **The section classes are renamed** (an app's override of an old name stops matching):
1759
+
1760
+ | Was | Now |
1761
+ |---|---|
1762
+ | `tbody.nx-table__pins` | `tbody.nx-table__section[data-section="pins"]` (and `"favorites"`) |
1763
+ | `.nx-table__pin-row`, `__pin-cell`, `__pin-toggle`, `__pin-caret`, `__pin-icon`, `__pin-title`, `__pin-count`, `__pin-loading` | `.nx-table__section-row`, `__section-cell`, `__section-toggle`, `__section-caret`, `__section-icon`, `__section-title`, `__section-count`, `__section-loading` |
1764
+ | `.nx-table__pin-mark` | `.nx-table__mark`, inside `.nx-table__marks` |
1765
+ | `data-pinned` on a section's row, `data-table-pins` on its toggle | gone: `data-section` on the section |
1766
+ | saved `pinsOpen` | `openSections` (an old `pinsOpen: true` still opens the pinned section) |
1767
+
1768
+ What is new (the owner's rules, `Project-Skills/README.RULES.md`, "Rules for Favorite or Pin"):
1769
+
1770
+ - **Favourites beside pins, from one call.** `CrudEndpoints.bookmarks` (`Room/BookmarkListing`) answers every record
1771
+ this user marked, each with its bits (`bookmarkTypes`: 1 pinned, 2 favourite); `Crud` splits them into a **Pinned**
1772
+ and a **Favourites** section above the active list, each folding on its own and remembered. The lists' own rows need
1773
+ not carry marks: every record's marks come from that call.
1774
+ - **The marks show where a row's number is**, one under the other: a pinned favourite wears the pin above the star, in
1775
+ the list and in both sections, with no column of its own (the owner, 2026-10-08). `DataTable` takes
1776
+ `rowMarks(row)`; a section's row with none wears the section's icon.
1777
+ - **Directed switches, never a toggle.** Pin and **Add to favourites** / **Remove from favourites** on a row send the
1778
+ record's whole set (`bookmark`, `?bookmarkTypes=3`, built by `CrudBackend.marksRequest`); on a selection they send
1779
+ one bit and the way (`mass.bookmark`, `?bookmarkTypes=2&isAdding=true`, `massMarksRequest`), only the records it
1780
+ is for, and a selection is offered only the ways it can still go - both, when it is mixed. Bits the package does not
1781
+ know are kept. While a whole-set backend's marks are not read yet, the actions wait and say why.
1782
+ - **A limit each:** `configureCrud({ maxFavorites })`, the same as `maxPins` unless set (`markLimit`). Nothing is ever
1783
+ taken off to meet a lowered limit.
1784
+ - **No reload:** a switch's answer (`answeredMarks`, or the ids a mass switch answers) is written into the sections,
1785
+ the table and every list kept. A record trashed leaves the sections and keeps its marks; recovered, it comes back.
1786
+ - `DataTable` takes `sections` (`TableRowSection`: key, label, icon, rows, loading, max); `pinnedRows` and the other
1787
+ `pinned*` props stay, as one section with the key `pins`. Select by **Favourites** / **Not favourites**. The message
1788
+ builder knows `favorite` and `unfavorite` ("3 guests added to the favourites."). New helpers: `CRUD_MARKS`,
1789
+ `CRUD_MARK_BITS`, `hasMark`, `withMark`, `markLimit`, `markDirections`, `markedRecords`, `patchMarkedRecords`,
1790
+ `dropMarkedRows`, `crudMassEndpoint`.
1791
+ - **Needs `nexus-icons` with `iconStarOff`**, which is new in its source (not on npm): release `nexus-icons` with a
1792
+ raised version first, and raise this package's `nexus-icons` range to it.
1793
+
1794
+ ### A CRUD page passes params and the CRUD does the rest (2026-10-08)
1795
+
1796
+ The owner: *"we just pass params no logic, due to we have 1000+ similar page"*. What the Regions page did itself is
1797
+ now `Crud`'s, so a page is its controller, columns and inputs:
1798
+
1799
+ - **`apiController(module, controller, subject, options)`**: `commands: "switch"` (the default, the Nexus backend since
1800
+ 2026-10-07: `SwitchFlag`, `MassSwitchFlag`, `BookmarkListing`, `SwitchBookmarks`, `MassSwitchBookmarks`) or
1801
+ `"toggle"` (`Flag`, `MassFlag`, `PinListing`, `Pin`, `MassPin`), and `without` for what a backend lacks (`"pages"`,
1802
+ `"audit"`, `"flag"`, `"marks"`, `"mass.description"`, ...). **The return type now marks those members optional**
1803
+ (`pages`, `pagination`, `flag`, `audit`, the marks, the mass actions): a caller that reads one adds `!` or `?.`.
1804
+ `MODULES.controller` takes the same options.
1805
+ - **A directed flag.** Flag and Unflag send the way (`CrudBackend.flagRequest`, Nexus `?isFlagged=`); a selection is
1806
+ offered Flag and Unflag only as it can still go, sends only the records each is for, and says "the other N already
1807
+ were" when the answer lists fewer. `NexusCrudBackend.isFlagged` is the band 25-29, and `canFlag` keeps Flag off a
1808
+ pending record (30-39). The old "Flag / unflag" toggle of a mixed selection is gone.
1809
+ - **History built in.** With an `audit` endpoint the person may call, every row offers **History** and the details
1810
+ **View history**: `AuditHistory` and `openAuditHistory` (moved from nexus.web), its words from
1811
+ `CrudBackend.auditWords`.
1812
+ - **Presets:** `crudColumns.status()`, `.description()` (a `markdown` column: text in the table, formatted in the
1813
+ details), `.added()`, `.changed()`, and `crudInputs.description()` (Markdown, 1000 characters, one hint for every
1814
+ model). `TableColumn.markdown` is new.
1815
+ - **A Create answered with the payload** (no status, so no list can place it) is read back once through `details`.
1816
+ - **The export names the list on screen** (`CrudBackend.exportFilters`, Nexus `{ flag: 50 | 52 | 54 | 55 }`).
1817
+ ### The theme panel: grouped, twice as wide, and the input design (2026-10-08)
1818
+
1819
+ - **Input design is a quick preference** (`UserPreferences.inputDesign`, key `nx-input-design`): Standard, Outlined or
1820
+ Inbox for every labelled input whose page and form name none (`useInputDesign`). An input with a design of its own
1821
+ (a grid's cells) keeps it, and one with no label (a table's search box) stays standard: it has no label to place.
1822
+ - **`ThemeSwitcher`'s panel is 680px wide** (was 340), its settings in named sections two to a row - Theme across the
1823
+ top, then Appearance, Language and region, Dates and times, Numbers, Tables, and Forms (the input design, with a box
1824
+ to try it) - and one under the other on a narrow window. `ThemePicker`'s card group is named Colors now, under Theme.
1825
+
1826
+ - **`Crud` takes `backend`**, for an app whose pages sit on two backends. `CrudBackend` gains `canFlag`, `flagRequest`,
1827
+ `exportFilters`, `statusText` and `auditWords`, each with a default, so an app's own backend class still compiles.
1828
+
1829
+ ## 3.1.0
1830
+
1831
+ **New features, no code break.** Every export of `3.0.0` is still exported (49 are new: 33 from `nexus-shared`, 16 from
1832
+ `nexus-shared/client`), and no exported constant changed its value, so `^3.0.0` takes it. The owner chose the number
1833
+ on 2026-10-08. What can move for an app is its **stylesheet overrides**: these class names and custom properties were
1834
+ renamed or dropped, and an override of the old name no longer applies.
1835
+
1836
+ | Was | Now |
1837
+ |---|---|
1838
+ | `.nx-columns__head`, `__title`, `__count`, `__foot` | `.nx-table-panel__head`, `__title`, `__count`, `__foot` |
1839
+ | `.nx-table-panel__body` | `.nx-popover__scroll` |
1840
+ | `.nx-chat-avatar`, `--nx-chat-avatar` | `.nx-avatar`, `--nx-avatar-size` |
1841
+ | `.nx-table__frame`'s border | on `.nx-table` |
1842
+ | `--nx-field-scrollbar`, `--nx-field-scrollbar-hover`, `--nx-dropdown-scrollbar`, `--nx-time-scrollbar` | gone: one thin bar everywhere |
1843
+ | `.nx-form__skeleton*` | gone: a loading form shows its real inputs, disabled |
1844
+ | `.nx-excel__refused` | gone: the import's report lists are `.nx-excel__problems` |
1845
+
1846
+ - **Bulk insert, and rows added to an import (the owner, 2026-10-07).** `ExcelImport` and `openExcelImport` take
1847
+ `mode: "bulk"`: the import with no file, its new rows' editor opening with one empty row for the person to type
1848
+ into, sent to the same `Excel/Import` with the same checks, a problem then naming its row ("Row 3 · Region name").
1849
+ `DataTable` offers it as **Bulk insert** beside **Import from a spreadsheet**, and only beside an Add, as it is an Add
1850
+ of many rows (`TableExcel.bulk: false` leaves it out). A sheet with a required list cannot be typed in - a list's key
1851
+ is picked in the spreadsheet - and the panel says so; its file review takes no added rows either. Any other file's
1852
+ new rows take Add, Insert and Duplicate: an added row is numbered after every row the file had
1853
+ (`excelSheetRows(rows, columns, after)`), and one left empty is dropped, not refused. The new rows no longer fold
1854
+ away (folding unmounted the editor and lost every edit), the footer counts what will be sent, and the review is inert
1855
+ while an import is out.
1856
+ - **Nothing typed is lost unasked.** Closing the window, or Start over, over rows typed or changed in it asks first
1857
+ (`ExcelImportHandle.unsavedRows`); picking the entry again while its window is minimized brings that window back
1858
+ rather than a fresh one. An untouched date or time is no edit: `toInputValue` reads them as their inputs do.
1859
+ - **An import that wrote nothing keeps its rows** and lists under them each refusal, by row, in the backend's words:
1860
+ `readExcelResponse` keeps a refusal's report (`result`, as XLS_X044 sends it) instead of dropping it, so the panel no
1861
+ longer says only "No Region was added". Refusals read in row order.
1862
+ - `ExcelEditorRow`'s three own fields are optional, as an added row has none; new helpers `excelRowNumber`,
1863
+ `hasExcelValues` and `isExcelRowTyped`, and `excelProblemPlace(problem, byRow)`. `RowsInputHandle.remove(index,
1864
+ { undo: false })` drops a row with no Undo offered, and keeps the person's own last Undo where it was.
1865
+ - **A list shows what an import wrote.** After an import, `DataTable` runs `excel.onImported` first and reads its rows
1866
+ after it: a `Crud` drops its cached lists there, and the reload used to be asked before, so on a page just opened it
1867
+ was answered from the cache and the imported rows stayed off the screen until a reload.
1868
+ - **What imported records start as.** `ExcelSettings` gains `recordStatuses` (`ExcelRecordStatus`: `value`, `label`)
1869
+ and `defaultRecordStatus`; when a model offers any, the import shows "New records start as", the default picked,
1870
+ and sends the choice (`importExcelRows(..., { recordStatus })`; `ExcelBackend.importRequest(rows, recordStatus?)`,
1871
+ the Nexus one sending `{ rows, recordStatus }`). The report says "3 cities added as In review".
1872
+ - **`Crud`, from the owner's review on 2026-10-07.** The active list is labelled **Active**, not "All" - it leaves out
1873
+ the trash and the deleted. **Recover asks first** ("Recover Asia?", "It goes back to the active list."), one record
1874
+ or many, like trash and delete. Recover and Move back to trash wear the back arrow (`iconArrowBackUp`): the
1875
+ clock-shaped restore icon read as History beside a page's history action.
1876
+ - **`details.actions`**: buttons of the page's own in the details popup's footer, before Edit and Close
1877
+ (`CrudDetailsAction`: `id`, `label`, `icon`, `endpoint`, `hidden`, `onSelect(row, popup)`). One that names an
1878
+ `endpoint` shows only to a user who may call it, by the rule every built-in action follows.
1879
+ - **The spreadsheet import, as the owner asked on 2026-10-07.** Once a file is picked, the steps and the template and
1880
+ file buttons give way to the file's name; another file is a Start over (now a bordered button) or a fresh popup. The
1881
+ rows already there are read-only and never sent, and the footer says they are skipped. The new rows have Remove and
1882
+ Lock at their end. After an import the review is gone: the panel shows what was added, what was not written and why,
1883
+ and what was skipped and why. The `nx-excel__refused` list is gone with it; the report's lists are
1884
+ `nx-excel__problems` blocks.
1885
+ - **`TabularForm` `actionsAt="end"` and `lockable`.** The row's actions in a column fixed at the right edge, as a data
1886
+ table has them: Remove and Lock as buttons of their own, anything else in a menu after them. A locked row's inputs
1887
+ are read-only ("Locked: unlock the row to change it.") and it cannot be removed; the locks are the screen's, never
1888
+ the value's.
1889
+ - **A tabular form no longer jumps when a cell is clicked.** The line under the table, which holds the hint or the
1890
+ message of the input with focus, keeps its height whether or not it says anything.
1891
+ - **A slow answer lands in the list the user moved to.** `Crud` writes an action's answer into the table only when
1892
+ the table still holds the list the action was asked from, fully loaded. Otherwise (the user switched lists while
1893
+ the request was out, or a list is still loading) it patches every cached list and reads the one on screen again from
1894
+ them, stopping a load that may have been asked before the change. Before, a record trashed from the active list and
1895
+ answered after a switch to the trash was written over by the stale trash list and showed in neither list until the
1896
+ page was opened again. The same rule covers `handle.apply`, Add, Edit and the read-back after an Add; a Create that
1897
+ answers no key now loads the list instead of showing the cached one.
1898
+ - **The theme panel is a quick preference panel.** Under the theme, `ThemeSwitcher` now shows `PreferencesPicker`
1899
+ (`preferences={false}` leaves it out): Language and region (interface language, calendar), Dates and times (date
1900
+ picker language, clock, display date format: the existing `DatePreferences`), Numbers (number scale, thousands
1901
+ separator) and Tables (default page size, a row click opens the record). New: `UserPreferences`,
1902
+ `readUserPreferences`, `setUserPreferences`, `useUserPreferences`, `subscribeUserPreferences`, `useNumberLocale`,
1903
+ `DEFAULT_USER_PREFERENCES`, `PREFERENCE_STORAGE_KEYS` (`nx-language`, `nx-number-scale`, `nx-thousands`,
1904
+ `nx-page-size`, `nx-row-click`), `InterfaceLanguage`, `ThousandsSeparator`, and `ENumberScales` (the core's enum, by
1905
+ member value).
1906
+ - **Numbers follow the person's settings.** `numberLocale(scale, separator)` gives `en-US`/`en-IN` or a key such as
1907
+ `"en-IN|.|,"` that `formatNumber`, `parseNumber` and `getNumberSymbols` read. `NumberBox` and `Slider` use it when
1908
+ they have no `locale` (they defaulted to `en-US`, which is still the setting's default), and so do a `DataTable`'s and
1909
+ `CrudDetails`' number cells (`CellTextOptions.locale`, new). A number box rewrites its text when the setting changes.
1910
+ - **`DataTable` `pageSize` and `openOnClick` give way to the person's choice once it is made**: the default page size
1911
+ (when the table offers it) and the row click. A page size picked in a table with a `stateKey` still wins, and that
1912
+ table now saves a size only when it differs from the one it opens with, so a later default reaches it.
1913
+ - **Animations: On, Off or System, chosen in the theme panel.** `ThemeScript` now writes `data-nx-motion`
1914
+ (`reduce` or `full`) on `<html>` before the first paint, from the visitor's choice (`data-nx-animations`, saved as
1915
+ `nx-animations`) and the system's `prefers-reduced-motion`, and keeps it current when the system setting changes.
1916
+ **Every stylesheet reads that attribute instead of the media query**, and so does script motion (the popup's
1917
+ minimize and maximize, smooth scrolls). On animates on a machine whose operating system turned animations off; Off
1918
+ never animates. An app that does not render `ThemeScript` animates whatever the system says. New: `AnimationMode`,
1919
+ `setAnimations`, `motionFor`, `REDUCED_MOTION_QUERY`, the `ANIMATIONS_*` and `MOTION_ATTRIBUTE` constants, and
1920
+ `useTheme()`'s `animations`, `motion` and `setAnimations`.
1921
+ - **Density: Compact or Cozy.** `data-nx-density` on `<html>` (`nx-density`; `setDensity`, `useTheme().density`).
1922
+ Cozy makes `--nx-control-sm/md/lg` 32/38/44 and the new `--nx-row` 38; compact is exactly the old sizes. Table rows
1923
+ and headers, menu items, dropdown options and the Columns panel's lines are now sized from `--nx-row`, so they
1924
+ follow it. `ThemeScript` takes `defaultDensity`.
1925
+ - **Scale grid, development builds only**: Off, px (a line every 4px, solid every 48px) or mm (millimetres, solid every
1926
+ centimetre), drawn over the window on `[data-nx-grid] body::after`. `ThemeScript`'s `grid` and `ThemePicker`'s
1927
+ `showGrid` default to `isDevelopmentBuild()`, so a production build neither shows the section nor applies a saved
1928
+ grid. New: `ScaleGrid`, `setScaleGrid`, `isDevelopmentBuild`.
1929
+ - **`ThemePicker` / `ThemeSwitcher`** add Density, Animations and Scale grid under Mode and Theme (`showDensity`,
1930
+ `showAnimations`, `showGrid`).
1931
+ - **Popups fade in and out** (160ms in, 120ms out), with nothing else moving: the slide and scale are gone. **Every
1932
+ popover panel fades** in and out in 120ms through one rule on `.nx-popover`; the menu's, the table panels' and the
1933
+ dropdown's own opening animations are gone. A closing panel takes no clicks, and nothing waits for a fade.
1934
+ - **Popover panels fit the window.** `placeUnderAnchor` and `placeAtPoint` pick the side that fits, or the one with
1935
+ more room, and write that room as `--nx-popover-room`; a panel that marks its scrolling part `.nx-popover__scroll`
1936
+ is never taller than it. That part reaches the panel's right wall, so its scroll bar sits on the wall, and carries
1937
+ the right padding inside. Menus, the theme panel and the table's Columns and Filters panels use it; `usePopoverPanel`
1938
+ lost its own height cap. New `showPanel` opens a panel scrolled to the top, and a hovered menu item no longer scrolls
1939
+ its list. Placing an open panel again (on every scroll) no longer throws its list back to the top.
1940
+ - **A panel's padding is `--nx-popover-pad`.** `.nx-popover`'s own padding loaded after the menu's and the table
1941
+ panels', so a menu was 16px inside its edge, not the 4px it asked for, and the table panels 16px, not 8px; both now
1942
+ get theirs. `.nx-table-panel__body` is gone (`.nx-popover__scroll`).
1943
+ - **One thin, quiet scroll bar everywhere, the design lab's.** `Nexus.Base.css` gives every element
1944
+ `scrollbar-width: thin` and a thumb of the text colour at 22% on no track. The theme panel, menus and context menus,
1945
+ the table's Columns and Filters panels, popup bodies, the date-time panel, the chat, code blocks and tables in
1946
+ Markdown and rich text, and the admin page and menu drew the browser's 15px bar with a painted grey track; on
1947
+ Windows, Chrome and Edge now draw every bar 10px wide, as the lab does (Firefox's thin bar is 8px; Safari draws its
1948
+ own thin bar). The components' own bars are gone: the 8px `::-webkit-scrollbar` pill of the text area, the Markdown
1949
+ preview and the rich text (Chromium and WebKit ignore it once `scrollbar-width` is set), and the thin bar with a
1950
+ stronger thumb of the tree, the tabular form, the dropdown list and the time picker. The data table's rows keep
1951
+ theirs (`Nexus.Table.css`) until a later change, and the time picker's columns still hide the thumb until the
1952
+ pointer or the keys are in them. `--nx-field-scrollbar`, `--nx-field-scrollbar-hover`, `--nx-dropdown-scrollbar` and
1953
+ `--nx-time-scrollbar` are gone; the text area's resize grip reads `--nx-field-grip`. Room kept for a bar narrows with
1954
+ it: every `scrollbar-gutter: stable` (the admin page and its bar, whose right edge moves from 27 to 22px off the
1955
+ window, the chat thread, the dropdown list) and the padding a modal popup keeps for the page's bar
1956
+ (`--nx-scrollbar-gap`, measured).
1957
+ - **A form whose values load shows its real inputs, disabled, instead of placeholders.** A `Crud`'s Edit popup, an
1958
+ `ApiForm` with `loadValue` and a `Form` with `loading` have their final size from the first frame, and the values
1959
+ land in the same inputs, as a load (no "Unsaved changes", no "Discard your changes?"). While loading, `FormStore`
1960
+ takes nothing typed, even from an input whose `disabled` was taken off by hand, and draws such an input again from
1961
+ the store when loading ends; a submit already waited. The `.nx-form__skeleton*` classes and their animation are gone.
1962
+ - **The form's status line keeps its one line when empty, and a form's bar fills a popup's foot**, so the status sits
1963
+ beside the buttons: "Loading…" or "Unsaved changes" coming and going no longer moves the bar, and a `Crud` popup
1964
+ lost the empty 12px row it had above its buttons.
1965
+ - **`Avatar`** (`nexus-shared/client`): a person's round picture, or their initials on a soft tone without one or
1966
+ when it fails to load. `size` `sm`/`md`/`lg` or `--nx-avatar-size`; `onChangePicture` makes it a button with a
1967
+ camera over it on hover. The chat's and the dropdown's avatars are now this one: `ChatAvatar` and
1968
+ `DropdownAvatar` still exist and draw it, `.nx-chat-avatar` is gone (`.nx-avatar`), `.nx-dropdown-avatar` stays
1969
+ as the dropdown's size, and `--nx-chat-avatar` is `--nx-avatar-size`.
1970
+ - **`Button` `dot`**: a small dot on the corner, for something new behind it such as unread notifications; `"primary"`
1971
+ marks a state that is on, such as filters.
1972
+ - **`Menu`: a control inside a heading is chosen like a row.** An entry `{ heading: <node> }` may hold a button with
1973
+ `role="menuitem"`: the arrow keys walk it with the rows, and a click on it closes the menu. A menu opened from the
1974
+ keyboard still starts on its first row.
1975
+ - **The data table is one frame**: the bar, the rows and the footer sit in one border (`.nx-table` carries it now,
1976
+ not `.nx-table__frame`), with a line under the bar and the footer as the bottom band (`--nx-footbar`, a new
1977
+ token). An empty bar takes no room. Column headings are 12px, weight 650.
1978
+ - **`DataTable` `add`**: the table's own Add, first in the bar. With `excel`, it is a split button whose arrow holds
1979
+ Download template, Import from a spreadsheet and Export to Excel, which then leave the options menu. `Crud`
1980
+ passes its Add this way.
1981
+ - **The data table's bar is one line on a wide table, in a new order**: the Add section (`add`, then the page's
1982
+ `actions`) and the lists (`toolbar`) on the left; Reload, the options menu (⋮) and the search, rows per page inside
1983
+ it, on the right. It was the lists, the search, Reload, ⋮, `actions` and Add, wrapping where they fell: at 1126px
1984
+ on the admin shell the bar broke into three ragged rows. A narrower table now moves whole parts, by its own width
1985
+ (container queries on `.nx-table`): under 670px the search takes the top line (with nothing on the left, it leads
1986
+ the buttons' line), under 375px Reload moves to the end of ⋮ (`DataTable` asks the hidden button, so the width is
1987
+ written once, in the stylesheet), and under 325px, beside lists, the Add section takes the last line with its buttons
1988
+ sharing it. On the admin shell those are windows of 695, 400 and 350px. A bar wider than those widths expect (a
1989
+ long Add label, the page's own buttons, cozy density) is squeezed into the next step as soon as a line would break
1990
+ (`data-squeeze` on `.nx-table__bar`, set a frame after a size change). A `toolbar` or `actions` that renders
1991
+ nothing takes no room and decides no step, and a bar left with nothing else is hidden. New classes
1992
+ `.nx-table__bar-add` (Add and `actions`) and `.nx-table__reload`; the search is now a child of `.nx-table__bar` after
1993
+ `.nx-table__bar-end`, not inside it, and grows to 19rem from a 12rem basis.
1994
+ - **Columns opens from the options menu (⋮)**, as its first item; the bar has no Columns button. Reload, Columns
1995
+ and the options button are ghost buttons. `TableColumnsPanel` takes an `anchor` and an `open()` handle for this.
1996
+ - **`TabButtons` `labels="auto"`**: every label while its container (a data table) is at least 900px wide, only the
1997
+ chosen one's below. `Crud`'s lists use it.
1998
+ - **`DataTable` `selectBy`**: while rows are selected, their count opens a menu that selects again by what the rows
1999
+ are: All on this page, then each `{ label, icon, test }`, which leaves exactly the matching rows on the page
2000
+ selected. `Crud`'s main list offers Pinned, Not pinned, Flagged and Not flagged.
2001
+ - **`SplitButton` `fixed`**: the main button never changes; the menu holds other ways to do it, run when picked.
2002
+ - **`DataTable` `filterInputs`: an advanced filter.** Input params, each named after the column it filters, become
2003
+ **Filters** in the options menu (⋮): a small form under the button, applied as it changes. The values are
2004
+ `TableQuery.filters` (`TableStore.setFilters` sets them, `{}` clears); a client table matches them in `queryRows`
2005
+ (a fifth argument), and a server gets them flat by name through `CrudBackend.pageRequest`'s third argument, as
2006
+ `filterRequest` writes them (a date range as `{name}From`/`{name}Before`). A `CrudBackend` or loader of your own
2007
+ ignores them until it reads that argument or `query.filters`. Also new: `activeFilters`, `asFilterInput`,
2008
+ `filterMatcher`, `filterNames`; `usePopoverPanel` and `PopoverPanelHandle` (`nexus-shared/client`), the panel
2009
+ Filters and Columns share; `followAnchor`'s second argument, a panel whose size it follows too.
2010
+ - **Renamed: `.nx-columns__head`, `__title`, `__count` and `__foot` are `.nx-table-panel__*`**, shared by both panels.
2011
+ An override of the old names no longer applies.
2012
+
2013
+ ## 3.0.0
2014
+
2015
+ **Breaking. This changes the value of exported constants, and `2.0.0` is on npm with the old ones.** Publishing it
2016
+ as a patch or a minor would hand `^2.0.0` consumers different numbers under a compatible-looking range, which is the
2017
+ worst way to ship this, so it takes a major. The owner chose the number on 2026-10-02, together with `nexus-public`
2018
+ and `nexus-admin`, which both depend on this package and so move to `3.0.0` with it; their `nexus-shared` range is
2019
+ now `^3.0.0`, because a bump of this one alone would leave them pulling the old, wrong constants.
2020
+
2021
+ **`nexus-icons` must be installed at `1.1.0` or newer**, and the dependency range here says so. The chat components
2022
+ added in this version use `iconChecks`, `iconCornerUpLeft`, `iconMessageCircle`, `iconPaperclip`, `iconSend` and
2023
+ `iconUserPlus`, none of which exist in `nexus-icons@1.0.0`: with the old range a fresh install resolved to `1.0.0`
2024
+ and the build failed on six missing exports.
2025
+
2026
+ ### `NexusModule` — `Chat` added, and every module after it moved up one bit
2027
+
2028
+ The backend's `EModules` gained `Chat` at bit 6, and everything after it shifted. `NexusModule` follows it exactly.
2029
+ A permission is a bitmask of these flags, so a package that kept the old numbers would grant and deny the wrong
2030
+ modules without any error to show for it.
2031
+
2032
+ | Module | Was | Now |
2033
+ |---|---|---|
2034
+ | `None` | 0 | 0 |
2035
+ | `Migrations` | 1 | 1 |
2036
+ | `Configuration` | 2 | 2 |
2037
+ | `Identity` | 4 | 4 |
2038
+ | `Enterprise` | 8 | 8 |
2039
+ | `Drive` | 16 | 16 |
2040
+ | `Message` | 32 | 32 |
2041
+ | **`Chat`** | *(did not exist)* | **64** |
2042
+ | `Accounting` | 64 | **128** |
2043
+ | `Report` | 128 | **256** |
2044
+ | `Requisition` | 256 | **512** |
2045
+ | `Tenant` | 512 | **1024** |
2046
+ | `Subscription` | 1024 | **2048** |
2047
+ | `Reservation` | 2048 | **4096** |
2048
+ | `School` | 4096 | **8192** |
2049
+ | `Pharmacy` | 8192 | **16384** |
2050
+ | `Staffing` | 16384 | **32768** |
2051
+ | `Portfolio` | 32768 | **65536** |
2052
+
2053
+ `NEXUS_MODULES` gained the matching entry (`key: "chat"`, `label: "Chat"`, `env: "API_CHAT"`), so `Chat` can be
2054
+ looked up, proxied and named in messages like every other module.
2055
+
2056
+ **What to check when you upgrade.** Code that uses the names (`NexusModule.Reservation`) needs nothing. Anything
2057
+ that stored, transmitted or hard-coded a **number** has to be revisited:
2058
+
2059
+ - permission masks saved in a database, a cache, a token claim or a settings file;
2060
+ - proxy URLs written with a number in place of a key (`/api/app/2048/…` is now `/api/app/4096/…`, and `reservation`
2061
+ by key has always worked and still does);
2062
+ - a backend that has not taken the `Chat` shift yet — both sides move together or neither does.
2063
+
2064
+ ### `NEXUS_FLAGGED_STATUS` — 15 → 25
2065
+
2066
+ The owner settled the record-status bands, and `ERecordStatus.Flagged` is seeded at **25**. `NexusCrudBackend`
2067
+ decides whether a row is flagged by comparing `recordStatus` to this constant, so a package on 15 while the backend
2068
+ writes 25 shows the wrong flag on every row, and flagging one writes a status the backend does not recognise.
2069
+
2070
+ The bands, for anything that reasons about them:
2071
+
2072
+ | Band | Range |
2073
+ |---|---|
2074
+ | Active | 0–39 |
2075
+ | — Active account | 0–29 |
2076
+ | — Flag | **25–29** |
2077
+ | — Pending, unverified, in review, approval required | 30–39 |
2078
+ | Trash | 40–59 |
2079
+ | Delete | 60 and above |
2080
+
2081
+ `NEXUS_ACTIVE_STATUS` stays 0, and the `recordStatus < 40` reading of "a live record" that the rest of the code
2082
+ already assumes stays right.
2083
+
2084
+ Note that **the flag is a range, 25–29, and this release still checks a single value.** Only the seeded member at 25
2085
+ has a meaning today, and the bands are about to become configurable per project, so a range-aware check belongs with
2086
+ that work rather than here. Do not read the single value as the final answer.
2087
+
2088
+ ### Removed: the archive
2089
+
2090
+ The owner removed the archive on 2026-09-30: a record is live, in the trash, or deleted, and the way back from
2091
+ deleted lands it in the trash. The second put-away list and its second recovery path were the only reason the CRUD
2092
+ had both Restore and Recover, and the bands close up behind it — **Trash is 40–59 and Delete 60 and above**, so
2093
+ nothing stored keeps a meaning it no longer has. `recordStatus < 40` still reads "a live record".
2094
+
2095
+ What goes, all of it breaking for anything that named it:
2096
+
2097
+ - `CrudMode` loses `"archive"`, so a CRUD shows All, Flagged, Trash and Deleted.
2098
+ - `CrudRowActionName` and `CrudMassActionName` lose `"archive"` and `"restore"`. Recover, the trash's own way back,
2099
+ is untouched: a CRUD that offered both now offers one.
2100
+ - `CrudEndpoints` loses `archive` and `restore`; `apiController` stops naming `Archive`, `Restore`, `MassArchive`,
2101
+ `MassRestore`, `ArchiveListing` and `ArchivePagination`, and `lists`/`pages` lose their `archive` key.
2102
+ - `MessageActionName` loses `"archive"`, and the message builder its words and its "It can be restored from the
2103
+ archive." confirmation detail. `"restore"` stays in the vocabulary: it is a plain verb, not the archive's.
2104
+
2105
+ **What to check when you upgrade.** A CRUD that listed `"archive"` in `modes` or `actions` no longer compiles,
2106
+ which is the point — the endpoint behind it is gone from the backend too. A backend that still answers
2107
+ `ArchiveListing` is not asked for it any more.
2108
+
2109
+ ### Added: the chat
2110
+
2111
+ A discussion on any record — a leave request the manager and a co-worker talk through, a purchase order, an
2112
+ applicant, a tender — behind one icon that says whether anything is waiting. Nothing that does not use it changes.
2113
+
2114
+ - A conversation is named by three values, `ChatScope`: the **domain** (which kind of record), the **parent id** (the
2115
+ record) and the **user id** (who is reading). The domain is the app's own enum, given either as a template
2116
+ (`<Chat<EDomainTypes> …>`) or once in `ChatRegister`, the same way `ApiRegister` names the module enum. Without one
2117
+ it is a plain number.
2118
+ - **No domain numbers are defined here.** They belong to the backend's `EDomainTypes`, and the warning at the top of
2119
+ this file applies to them in full. `defineChatDomains` only gives an app's domains their names on screen.
2120
+ - `ChatButton` is an icon in three looks: quiet with no messages, the accent on its tint once there are messages, and
2121
+ the accent filled with an unread badge once some are unread. One size in all three, with the badge over the corner,
2122
+ so a column of them in a table does not move as the counts arrive.
2123
+ - `ChatSummaryCache` fills every icon that mounted in the same moment with **one call per domain**. A list whose rows
2124
+ already carry their counts passes `summary` and makes no call at all.
2125
+ - `Chat` is the icon with the conversation in a popup; `ChatPanel` is the conversation on its own, for a record's own
2126
+ page. Messages are **Markdown**: written in `MarkdownEditor` and drawn by `MarkdownView`. Enter sends and
2127
+ Shift+Enter starts a line, except in a list or a code fence, where the editor keeps Enter; `sendKey="modifier"`
2128
+ moves sending to Ctrl+Enter.
2129
+ - One mark where the time sits says how far a message of your own has got: a spinner while sending, a tick once the
2130
+ server has it, two once `deliveredTo` holds somebody, two in the accent once `readBy` does.
2131
+ - Where the data comes from is one interface, `ChatSource`. `apiChatSource` goes through the app's `ChatBackend`
2132
+ (`NexusChatBackend` by default); `localChatSource` keeps a conversation in memory for a demo or a test.
2133
+ - **The Nexus endpoints are the shape the Chat module is expected to take, not an API that answers today.**
2134
+ `NexusModule.Chat` is a number and a proxy key; the controller is not built. Each difference, when it lands, is one
2135
+ override on `NexusChatBackend`.
2136
+ - `check:themes` gained a rule: body text on the primary tint, which is a message bubble of your own. Every theme
2137
+ clears AAA except catppuccin light at 6.84, so it is held to the AA 4.5 that applies to body text.
2138
+ - **A record holds many conversations.** `ChatScope` names the record; each conversation in it is a
2139
+ `ChatConversation` with its own id and a type, `one-to-one` or `group`. `ChatPlaceSource` lists them and starts
2140
+ them, `useChatPlace` is the hook, and `apiChatPlace` is the API one. A panel given a `source` still shows that one
2141
+ conversation and no switch, so nothing that used the first round changes.
2142
+ - The panel's body has three views — the thread, the list of conversations, and New chat — and the bar moves between
2143
+ them. With several conversations the title is the switch; with one it is just the title.
2144
+ - Starting a chat never asks for the type: one person makes a one-to-one, more make a group, and a name makes a
2145
+ group either way. A one-to-one with somebody who already has one is expected to come back rather than be made
2146
+ twice; `localChatPlace` keeps that rule.
2147
+ - `@` at the start of a word offers the people in the conversation, narrows as it is typed, and owns Enter while it
2148
+ is open. A mention is plain `@Name`, or a Markdown link with `mentionHref`; either way the ids travel in the
2149
+ draft's `mentions`.
2150
+ - `ChatSnapshot` gained `loaded`: a store that has read nothing yet holds empty counts, and a panel must not write
2151
+ those over what a conversation list already knows.
2152
+
2153
+ ### Added: several at once, `TabButtons multiple`
2154
+
2155
+ `TabButtons` picks one option; with `multiple` it picks several, so a list of choices that must be seen all at once
2156
+ (the weekdays a rule runs on, the letters or symbols a pattern allows) needs no second control. Nothing that does
2157
+ not pass it changes.
2158
+
2159
+ - `value` is an array and `onChange` hands back the chosen values **in option order**, whatever order they were
2160
+ clicked in. Values that match no option are ignored and never handed back.
2161
+ - The options are native checkboxes in a `role="group"`: Tab moves between them and Space toggles one. The group
2162
+ still needs `aria-label` or `aria-labelledby`.
2163
+ - There is no sliding indicator: each chosen option is raised with the indicator's own colors, border and shadow,
2164
+ and keeps the pointer, since a click takes it away again. An option that is not chosen sits flat on the track.
2165
+ - The options **wrap** onto more rows when they do not fit, each row as tall as a one-row track's options, and an
2166
+ option is never narrower than it is tall, so a row of single letters lines up. On a touch screen
2167
+ (`pointer: coarse`) every option is a medium control's height: 26 letters on a phone are 34px targets, not 22px.
2168
+ - Made and checked for the `nav` design (`/demo/tab-buttons`, "Several at once"); `design="list"` with `multiple`
2169
+ renders checkboxes too, but has not been checked yet.
2170
+
2171
+ ### Added: inputs that follow others, `dependsOn`, and a row's own params, `cellParams`
2172
+
2173
+ Two optional keys, added 2026-09-26 for the builder's class form, whose company model shows only when the class has a
2174
+ company or a branch and whose type arguments offer a key type in one row and a model in the next. Nothing that does
2175
+ not pass them changes.
2176
+
2177
+ - **`dependsOn: ["hasCompany"]`** on an input's params (Form only). When a named input's value changes — typed, or
2178
+ set from code — this input draws again **without mounting again** (nothing typed in it is lost) and its params are
2179
+ read afresh, so getters for `hidden`, `readOnly`, `readOnlyMessage`, `label`, `hint` or `options` answer for the
2180
+ values as they are now. Its rule is asked again too, so a `required` a getter switched on counts at once. An input
2181
+ that names any is asked `hidden` each time it draws, so it can come and go; one that names none is asked once, when
2182
+ the form draws, as before. Name the input itself to follow its own typing.
2183
+ - **`cellParams(row, name, index)`** on `TabularForm` and `GroupForm`: the params of one cell that differ from its
2184
+ column's, such as a picker's `options` and `selectedOptions`. Asked as the row draws; return nothing for the
2185
+ column's own params, and the **same object** for the same answer (the merged params are kept per answer, so the cell
2186
+ does not draw again for nothing). Getters on either side stay getters. Rules still come from the column.
2187
+
2188
+ ### Added: a generated column in a tabular or group form
2189
+
2190
+ Two optional props on `TabularForm` and `GroupForm`, for a column whose values a row's **position** decides rather
2191
+ than the user — a bitwise enum's value, a line number, a running total. Nothing that does not pass them changes.
2192
+
2193
+ - `deriveRows(rows)` returns one patch per row and runs after every change, including a row being added, removed,
2194
+ duplicated or **moved**. The patches are written as code writes, so only the cells whose value really changed
2195
+ mount again and focus stays on the row's own menu: a keyboard can move a row twice in a row.
2196
+ - `cellReadOnly(row, name, index)` is asked for every cell as it renders and returns the reason a cell is
2197
+ generated. The cell is locked and shows that reason as its read-only note. Because it is asked per render, a
2198
+ column can lock and unlock while the form around it changes, which params cannot do — they are fixed once a form
2199
+ opens.
2200
+
2201
+ ### Added: rows nobody may change, `rowReadOnly`
2202
+
2203
+ One optional prop on `TabularForm` and `GroupForm`, for rows the user did not write and may not change — the fields
2204
+ a class inherits from its parent, shown first in the same grid as its own. Nothing that does not pass it changes.
2205
+
2206
+ - `rowReadOnly(row, index)` returns the reason (or `true`). Every cell of such a row is read-only with that reason
2207
+ as its note, the row's menu in a tabular form offers nothing (no insert, duplicate, move or remove), and the row
2208
+ below it cannot move up past it. Nothing reorders the rows: keep the locked ones first.
2209
+ - A locked row says nothing about its columns: the column-header lock is worked out from the other rows in view, so
2210
+ a grid of inherited rows and one own row does not put a lock on every header.
2211
+
2212
+ ### Added: a panel beside a CRUD form, `form.aside`
2213
+
2214
+ An Add or Edit popup can show a panel next to its form: a preview of what a save would produce, a summary, a help
2215
+ panel. It takes a node, or a function handed `{ form, action, row }` — `form` being that popup's own `FormStore`, so
2216
+ a panel follows what is typed through `subscribeValue()` and the form around it never re-renders for it.
2217
+
2218
+ The two are columns above 1000px, each scrolling on its own so a long form cannot push the panel off the screen, and
2219
+ they stack below that with the **form first**, in the DOM as well as on the screen. `--nx-crud-aside-width` sets the
2220
+ panel's share (default `clamp(20rem, 38%, 44rem)`). Pair it with a wide `form.size` — `xl` or `full`; a panel in a
2221
+ 560px popup leaves the form nothing. A CRUD that passes no `aside` renders exactly what it did before, wrapper and
2222
+ all.
2223
+
2224
+ ### `TabularForm` reworked: one row control, rows that do not move, one lock per column
2225
+
2226
+ The owner called the tabular design bad, from a screenshot of an 18-row table with one generated column. Every fault
2227
+ was in this component rather than in the page that used it, so every fix is here. Nothing needs a new prop, and a
2228
+ table that passed nothing new looks different — deliberately.
2229
+
2230
+ **Breaking, visually and for tests.** Two things a consumer may be reaching for have moved:
2231
+
2232
+ - **The remove column is gone.** Remove was a column of unlabelled `×` buttons at the end, beside a row menu that
2233
+ already held Remove: two ways to act on one row, and a whole column spent on one of them. Now a row has one
2234
+ control, the menu beside its number, holding Insert above, Insert below, Duplicate, Move up, Move down and Remove.
2235
+ `removable` still decides whether Remove is offered. A test that clicked a button named `Remove <row>` opens the
2236
+ row's menu instead: `Row 2 actions`, then the `Remove` item. `.nx-tabular__end` and `.nx-tabular__remove` are gone
2237
+ from the DOM and the stylesheet; so is `--nx-tabular-end`, replaced by `--nx-tabular-menu` (the menu's slot width).
2238
+ - **The row number no longer opens the menu.** It used to *become* the menu on hover: the number faded out and dots
2239
+ faded in over it, so the numbering looked broken and the grid felt unstable as the pointer crossed it. The number
2240
+ now stays where it is, right-aligned, at every moment, and the menu button sits beside it in its own space,
2241
+ always present. The number column is that much wider.
2242
+
2243
+ **Fixed, with no change to how a table is written:**
2244
+
2245
+ - **Every row is one line high, whatever it holds.** The row's grid track was sized by its tallest cell, so a single
2246
+ `textarea` column of `rows: 2` made every cell of its row 20px taller than the row's own box — drawing the cells,
2247
+ their edges and their focus rings over the rows below. One row of content therefore looked taller than its
2248
+ neighbours, and a focus ring bled outside its cell. The row now has one track exactly its own height, cells keep
2249
+ what they hold inside their bounds, and a `textarea` fills its cell and scrolls inside it.
2250
+ - **No resize grips in cells.** A `textarea` in a cell took `resize: vertical` from the multi-line input rule, so
2251
+ every cell of such a column offered a grip that would break the alignment of its row. They are `resize: none` now.
2252
+ - **A filled cell and an empty one are drawn the same.** Same consequence as the above: only the cell with text was
2253
+ tall enough to show what the overflow was doing.
2254
+ - **`maxHeight` comes down to whole rows.** The scroller used to end wherever the number fell, cutting the last row
2255
+ in half, which reads as broken rather than as scrollable. The component measures its header, its frame and its
2256
+ real row height and caps itself just under what was asked for. A number too small for one row is left alone.
2257
+ - **A generated column says so once.** `cellReadOnly` put a lock in every cell of the column it locked — eighteen
2258
+ locks for one fact. The lock is now on the **column header**, with the reason as its title, and the cells keep the
2259
+ read-only tint and their own reason, which still shows under the table when one is used. A lock that really is per
2260
+ row (some rows locked, not all) leaves the header alone and is drawn per cell as before.
2261
+ - **Less chrome.** The frame is `--nx-border` rather than `--nx-border-strong`: the sheet draws every line it needs
2262
+ inside itself, and in a popup or a panel the strong edge was a box around a box around boxes.
2263
+
2264
+ ## 2.0.0
2265
+
2266
+ The rewrite: compiled `dist` with the entry points `.`, `/client`, `/server` and `/styles.css`, in place of 1.x's
2267
+ raw TypeScript from `src` with `.`, `/client` and `/interface`. A major version, so nobody on `^1.1.21` is moved
2268
+ into a different API.