nexus-shared 4.0.0 → 4.1.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 (163) hide show
  1. package/CHANGELOG.md +795 -0
  2. package/README.md +220 -14
  3. package/dist/Client.Index.d.ts +13 -0
  4. package/dist/Client.Index.js +13 -0
  5. package/dist/Components/Chats/ChatComposer.d.ts +7 -1
  6. package/dist/Components/Chats/ChatComposer.js +9 -6
  7. package/dist/Components/Chats/ChatParts.d.ts +15 -2
  8. package/dist/Components/Chats/ChatParts.js +15 -4
  9. package/dist/Components/Chats/ChatPopup.d.ts +46 -0
  10. package/dist/Components/Chats/ChatPopup.js +34 -0
  11. package/dist/Components/Chats/ChatThread.d.ts +9 -1
  12. package/dist/Components/Chats/ChatThread.js +54 -6
  13. package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
  14. package/dist/Components/Dashboards/DashboardBoard.js +594 -0
  15. package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
  16. package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
  17. package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
  18. package/dist/Components/Dashboards/WidgetFrame.js +45 -0
  19. package/dist/Components/Documents/Button.d.ts +6 -1
  20. package/dist/Components/Documents/Button.js +2 -2
  21. package/dist/Components/Documents/TabButtons.d.ts +6 -0
  22. package/dist/Components/Documents/TabButtons.js +5 -4
  23. package/dist/Components/Domains/DomainCard.d.ts +98 -0
  24. package/dist/Components/Domains/DomainCard.js +182 -0
  25. package/dist/Components/Forms/ApiForm.js +5 -1
  26. package/dist/Components/Forms/Crud.js +72 -24
  27. package/dist/Components/Forms/CrudActions.d.ts +5 -4
  28. package/dist/Components/Forms/CrudActions.js +42 -12
  29. package/dist/Components/Forms/CrudHistory.d.ts +6 -1
  30. package/dist/Components/Forms/CrudHistory.js +14 -6
  31. package/dist/Components/Forms/CrudParts.d.ts +11 -4
  32. package/dist/Components/Forms/CrudParts.js +76 -10
  33. package/dist/Components/Forms/CrudRowForms.js +65 -5
  34. package/dist/Components/Forms/CrudTree.js +7 -7
  35. package/dist/Components/Forms/CrudWorkspace.d.ts +15 -5
  36. package/dist/Components/Forms/CrudWorkspace.js +19 -11
  37. package/dist/Components/Forms/Form.d.ts +3 -2
  38. package/dist/Components/Forms/Form.js +33 -26
  39. package/dist/Components/Forms/FormQuestion.d.ts +6 -0
  40. package/dist/Components/Forms/FormQuestion.js +28 -0
  41. package/dist/Components/Forms/SubmitForm.js +2 -15
  42. package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
  43. package/dist/Components/Inputs/ComputedInput.js +179 -0
  44. package/dist/Components/Inputs/Dropdown.js +4 -2
  45. package/dist/Components/Inputs/GroupForm.js +3 -2
  46. package/dist/Components/Inputs/InputRenderer.d.ts +5 -1
  47. package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
  48. package/dist/Components/Inputs/MarkdownEditor.js +32 -6
  49. package/dist/Components/Inputs/PictureInput.js +8 -3
  50. package/dist/Components/Inputs/RowsInput.d.ts +14 -1
  51. package/dist/Components/Inputs/RowsInput.js +78 -5
  52. package/dist/Components/Inputs/TabularForm.js +16 -3
  53. package/dist/Components/Inputs/TreeView.d.ts +24 -1
  54. package/dist/Components/Inputs/TreeView.js +84 -7
  55. package/dist/Components/Layouts/Popup.d.ts +22 -2
  56. package/dist/Components/Layouts/Popup.js +108 -6
  57. package/dist/Components/Messages/MailClient.d.ts +50 -0
  58. package/dist/Components/Messages/MailClient.js +410 -0
  59. package/dist/Components/Messages/MailColumns.d.ts +21 -0
  60. package/dist/Components/Messages/MailColumns.js +92 -0
  61. package/dist/Components/Messages/MailCompose.d.ts +46 -0
  62. package/dist/Components/Messages/MailCompose.js +154 -0
  63. package/dist/Components/Messages/MailReader.d.ts +43 -0
  64. package/dist/Components/Messages/MailReader.js +207 -0
  65. package/dist/Components/Messages/MailThread.d.ts +71 -0
  66. package/dist/Components/Messages/MailThread.js +185 -0
  67. package/dist/Components/Messages/MessageBell.d.ts +41 -0
  68. package/dist/Components/Messages/MessageBell.js +76 -0
  69. package/dist/Components/Viewers/DataTable.js +7 -5
  70. package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
  71. package/dist/Components/Viewers/DataTableColumns.js +2 -1
  72. package/dist/Components/Viewers/DataTableParts.d.ts +3 -1
  73. package/dist/Components/Viewers/DataTableParts.js +2 -2
  74. package/dist/Components/Viewers/OwnerFiles.d.ts +57 -5
  75. package/dist/Components/Viewers/OwnerFiles.js +142 -31
  76. package/dist/Helpers/ApiModules.js +4 -0
  77. package/dist/Helpers/AuditEntries.d.ts +50 -0
  78. package/dist/Helpers/AuditEntries.js +96 -0
  79. package/dist/Helpers/ChatBackend.d.ts +8 -1
  80. package/dist/Helpers/ChatBackend.js +18 -0
  81. package/dist/Helpers/ChatHelpers.d.ts +2 -0
  82. package/dist/Helpers/ChatHelpers.js +4 -1
  83. package/dist/Helpers/CrudColumns.d.ts +44 -6
  84. package/dist/Helpers/CrudColumns.js +63 -13
  85. package/dist/Helpers/CrudHelpers.d.ts +36 -1
  86. package/dist/Helpers/CrudHelpers.js +61 -0
  87. package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
  88. package/dist/Helpers/CrudPickerPlan.js +46 -0
  89. package/dist/Helpers/CrudRowForms.d.ts +18 -1
  90. package/dist/Helpers/CrudRowForms.js +35 -2
  91. package/dist/Helpers/DashboardHelpers.d.ts +115 -0
  92. package/dist/Helpers/DashboardHelpers.js +305 -0
  93. package/dist/Helpers/DomainCards.d.ts +140 -0
  94. package/dist/Helpers/DomainCards.js +370 -0
  95. package/dist/Helpers/FileAdapter.d.ts +82 -2
  96. package/dist/Helpers/FileAdapter.js +72 -1
  97. package/dist/Helpers/FormStore.d.ts +11 -1
  98. package/dist/Helpers/FormStore.js +66 -19
  99. package/dist/Helpers/InputConditions.d.ts +6 -0
  100. package/dist/Helpers/InputConditions.js +24 -0
  101. package/dist/Helpers/InputMemory.js +40 -30
  102. package/dist/Helpers/InputNarrowing.d.ts +38 -6
  103. package/dist/Helpers/InputNarrowing.js +102 -21
  104. package/dist/Helpers/MailClientHelpers.d.ts +96 -0
  105. package/dist/Helpers/MailClientHelpers.js +211 -0
  106. package/dist/Helpers/MessageBackend.d.ts +152 -0
  107. package/dist/Helpers/MessageBackend.js +329 -0
  108. package/dist/Helpers/MessageHelpers.d.ts +160 -0
  109. package/dist/Helpers/MessageHelpers.js +573 -0
  110. package/dist/Helpers/MessageUnread.d.ts +95 -0
  111. package/dist/Helpers/MessageUnread.js +245 -0
  112. package/dist/Helpers/NexusDriveAdapter.d.ts +17 -1
  113. package/dist/Helpers/NexusDriveAdapter.js +32 -3
  114. package/dist/Helpers/NumberHelpers.d.ts +61 -0
  115. package/dist/Helpers/NumberHelpers.js +124 -8
  116. package/dist/Helpers/PagedPicker.d.ts +42 -0
  117. package/dist/Helpers/PagedPicker.js +85 -0
  118. package/dist/Helpers/RowsStore.d.ts +24 -0
  119. package/dist/Helpers/RowsStore.js +79 -10
  120. package/dist/Helpers/TableHelpers.d.ts +15 -2
  121. package/dist/Helpers/TableHelpers.js +28 -1
  122. package/dist/Helpers/TableStore.d.ts +7 -3
  123. package/dist/Helpers/TableStore.js +9 -4
  124. package/dist/Helpers/ToolbarFit.d.ts +23 -0
  125. package/dist/Helpers/ToolbarFit.js +30 -0
  126. package/dist/Helpers/TreeStore.d.ts +75 -6
  127. package/dist/Helpers/TreeStore.js +182 -18
  128. package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
  129. package/dist/Interfaces/ApiInterfaces.js +10 -1
  130. package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
  131. package/dist/Interfaces/CrudInterfaces.d.ts +78 -5
  132. package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
  133. package/dist/Interfaces/DashboardInterfaces.js +1 -0
  134. package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
  135. package/dist/Interfaces/DomainInterfaces.js +1 -0
  136. package/dist/Interfaces/FileInterfaces.d.ts +21 -0
  137. package/dist/Interfaces/FileInterfaces.js +2 -0
  138. package/dist/Interfaces/FormInterfaces.d.ts +78 -11
  139. package/dist/Interfaces/InputInterfaces.d.ts +12 -5
  140. package/dist/Interfaces/MailInterfaces.d.ts +192 -0
  141. package/dist/Interfaces/MailInterfaces.js +1 -0
  142. package/dist/Interfaces/TableInterfaces.d.ts +28 -2
  143. package/dist/Services/JoinSources.d.ts +47 -6
  144. package/dist/Services/JoinSources.js +68 -33
  145. package/dist/Services/MessageApi.d.ts +18 -0
  146. package/dist/Services/MessageApi.js +64 -0
  147. package/dist/Shared.Index.d.ts +12 -0
  148. package/dist/Shared.Index.js +12 -0
  149. package/package.json +7 -1
  150. package/src/Styles/Nexus.Button.css +29 -0
  151. package/src/Styles/Nexus.Chat.css +114 -22
  152. package/src/Styles/Nexus.Crud.css +119 -28
  153. package/src/Styles/Nexus.Dashboard.css +763 -0
  154. package/src/Styles/Nexus.Domain.css +384 -0
  155. package/src/Styles/Nexus.Editor.css +20 -0
  156. package/src/Styles/Nexus.Files.css +27 -0
  157. package/src/Styles/Nexus.Index.css +3 -0
  158. package/src/Styles/Nexus.Input.css +31 -0
  159. package/src/Styles/Nexus.Mail.css +1062 -0
  160. package/src/Styles/Nexus.Popup.css +58 -0
  161. package/src/Styles/Nexus.Table.css +1458 -1449
  162. package/src/Styles/Nexus.Theme.Picker.css +1 -0
  163. package/src/Styles/Nexus.Tree.css +10 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,801 @@ Notable changes to `nexus-shared`. The **values** of exported constants count as
4
4
  Nexus backend's own numbers, and a value that differs from the backend by one does not fail loudly — it silently
5
5
  means the wrong thing on every record and every permission check.
6
6
 
7
+ ## 4.1.0
8
+
9
+ **Additions only.** Everything written after `4.0.0` was published (2026-11-07 16:14 UTC; the first change after it is
10
+ card SUB4-component-review-fix-finish on 2026-11-09). Checked against the published `4.0.0` (`npm pack nexus-shared@4.0.0`)
11
+ with the four scripts in `Frontend/scripts/release-compare`, on 2026-11-11:
12
+
13
+ - **Exports** (`compare-exports.cjs`): every export of `4.0.0` is still exported; **279 are new**, 212 from
14
+ `nexus-shared` and 67 from `nexus-shared/client`, none from `nexus-shared/server`. Per card, as each counted its own:
15
+ 2 are [card SUB4-component-review-fix's](#the-round-4-reviews-fixes-a-hasty-enter-a-question-read-out-a-remembered-value-kept), 4 are [card C-crud-hidden-value-after-save's](#the-row-a-save-leaves-is-what-was-sent), 58 are [card C-mail-thread's](#a-mail-thread-its-source-and-backend-and-three-params-any-page-can-use), 49 are [card C-domain-card's](#a-records-card-in-a-message-read-live-from-its-module), 18 are [card C-message-bell's](#a-message-bell-the-unread-count-on-a-top-bar-one-read-however-many-bells), 3 are [card C-mail-thread-fit's](#a-mail-threads-window-opens-on-its-subject-and-its-reply-box-keeps-one-row-on-a-phone), 1 is [card C-crud-row-confirm's](#a-row-form-asks-before-it-saves-a-picker-reads-another-listing-a-history-names-who), 8 are [card C-documents-add-only's](#an-owners-files-that-a-records-state-lets-only-be-added-to), 9 are [card C-row-picker-narrow's](#a-rows-picker-narrowed-by-the-forms-value-and-the-rows-own), 36 are [card C-mail-client's](#a-mail-client-three-panes-docked-chat-windows-and-a-compose-window), 58 are [card C-dashboard-board's](#a-customisable-dashboard-widgets-in-size-presets-one-call-per-module-only-near-the-screen), 1 is [card C-rows-solve's](#a-rows-worked-out-input-typed-in-a-lines-total-sets-its-rate), 5 are [card C-mail-client-fix's](#the-mail-clients-fixes-and-a-crud-forms-computewith), 5 are [card component-details-0620-look's](#details-as-the-2026-06-20-window-drew-them-a-list-of-rows-as-a-table-history-names-from-the-app), 7 are [card C-gallery-pending-removal's](#a-gallery-whose-removals-wait-for-a-review); the rest are named in their sections
16
+ ([tree filters](#a-trees-filter-tabs), [the picture column first](#a-tables-picture-column-first-and-unpin-on-a-column-its-page-pins), ...).
17
+ - **Constants** (`compare-values.mjs`): 94 compared (90 from `nexus-shared`, 4 from `nexus-shared/server`), 2 differ,
18
+ both additions: `NexusModule.Inventory` (131072) and its
19
+ `NEXUS_MODULES` entry ([card INV-0-comp](#inventory-added-at-bit-17-no-value-moves)). No value changed or moved;
20
+ `NexusModule.Report` keeps its value and is [deprecated](#nexusmodulereport-is-deprecated-the-backend-retired-it).
21
+ - **Types** (`compare-types.cjs`, new with this release: each exported type's members and optionality, each function's
22
+ required parameters): 1,410 exports compared, **0 breaks**. 12 members are new and required, all on types this package
23
+ builds and an app only reads: `ChatRun.notification` (made by `groupChatMessages`), `TreeNode`'s six `shown...` counts
24
+ (made by `TreeStore`), and `FormStore.setFromPerson` (a method of the class, which no app can implement: it has
25
+ private members). One member's type widened: `TableState.pinned` may hold `null` for a column its page pins and the
26
+ user unpinned, which [could not be done before](#a-tables-picture-column-first-and-unpin-on-a-column-its-page-pins);
27
+ `columnPin(column, pinned)` reads the side. No app in this repository reads it.
28
+ - **Stylesheet**: no class name or custom property of `4.0.0` removed; 241 added.
29
+ - **`nexus-icons ^1.2.0`** (`compare-floor.cjs`): the 130 names this package imports are all in the published `1.2.0`.
30
+
31
+ The packages built on this one take it as they are: `nexus-admin` `4.0.0` asks `^4.0.0` and is unchanged since it was
32
+ published; `nexus-public` `3.0.2` asks `^3.0.0 || ^4.0.0`.
33
+
34
+ ### `NexusModule.Report` is deprecated: the backend retired it
35
+
36
+ The backend retired its Report module (`EModules.Report`, 256) on 2026-11-11: print layouts, the print door and their
37
+ history are the Configuration module's now. `NexusModule.Report` is `@deprecated` and **keeps its value**, so bit 8 is
38
+ never given to another module and an old permission mask that holds it still reads; its `NEXUS_MODULES` entry stays as
39
+ well (removing an entry changes a published constant, which only a major may do), so an old `/api/app/report/...` proxy
40
+ URL is still recognised. An app sets no `API_REPORT` any more, and a list a person picks a module from leaves it out
41
+ (`info.module !== NexusModule.Report`). This package builds no such list; `nexus-admin`'s `fallbackModules()`, used only
42
+ when the backend's module list cannot be read, still names it until `nexus-admin` is next released.
43
+
44
+ ### A tree's filter tabs
45
+
46
+ Card C-tree-filters (2026-11-10), for a permission set editor: small tabs above a checkable `TreeView` (All actions ·
47
+ View only · Add and edit · Trash and delete) that narrow its rows. 2 exports added (`TreeFilter`, `TreeFilterMatch`).
48
+
49
+ - **`TreeView` `filters`**: each a `TreeFilter` with an `id`, a `label` and a `match` asked of every leaf (none shows
50
+ all). The tree shows the leaves a filter takes and the parents that hold one. While one is on, checking a parent or
51
+ Select all checks only the leaves shown, a parent's box and count read only them, and a hidden leaf keeps its check.
52
+ The value is the same whatever tab is on: switching never changes it and never makes a form dirty.
53
+ `defaultFilter`, `rememberFilter` (`{ key, viewer }`, kept per signed-in person), `onFilterChange` and
54
+ `filtersLabel` (default "Show").
55
+ - **`TreeStore`**: `setFilter` and the option `filter`; each node gains its `shown...` counts beside the whole ones,
56
+ which the value and `counts()` still read.
57
+ - **`TabButtons` `wrap`**: the options wrap onto more rows when they do not fit (`nav` design), as the tree's tabs do on
58
+ a phone.
59
+ - Proved by `test-tree-store.mts` and nexus.template's `/demo/tree-view` "Filter tabs: a permission set".
60
+
61
+ ### A table's picture column first, and Unpin on a column its page pins
62
+
63
+ Card C-picture-first (2026-11-10): every data table draws its picture or logo column first, after the row number.
64
+
65
+ - **`TableColumn.lead`**: drawn first of the page's columns until the user moves it; when the page pins other columns on
66
+ the left, a lead column is pinned there too, at their head. `pictureColumn` sets it. The user hides, moves, pins and
67
+ unpins it like any column, and Reset columns brings it back. `leadColumns(columns)` arranges them;
68
+ `namingColumn(columns)` is the column that names a row in a question ("Delete Kathmandu?"), never a picture.
69
+ - **Unpin works on a column its page pins.** The user's sides are a `TablePins` (`null` for unpinned), read by
70
+ `columnPin(column, pinned)`; before, a page's own `pinned` came back.
71
+ - Proved by `test-table.mts`.
72
+
73
+ ### A full-size popup fits a phone screen while the address bar shows
74
+
75
+ A full-size popup is `100dvh` tall where the browser knows it (`100vh` stays for older ones), so on a phone or tablet
76
+ its footer (Save, a tab's footer) is no longer under the screen's edge while the address bar shows; the theme panel's
77
+ height follows the same rule. CSS only.
78
+
79
+ ### The round-4 review's fixes: a hasty Enter, a question read out, a remembered value kept
80
+
81
+ Cards SUB4-component-review-fix and SUB4-component-review-fix-finish (2026-11-07/08): six findings of the review of
82
+ the subscription round's work in this package. Additions only: 2 exports, both from `nexus-shared`
83
+ (`isAwaitedCrudField` and the type `CrudRecordRead`, the read-state rule the profile and the Details tab share), and
84
+ `Popup`'s `aria-describedby` prop. **One behaviour changes for every message box**: a held Enter, or one within 300 ms of the Enter
85
+ before it, no longer answers it.
86
+
87
+ - **A hasty Enter never answers a box.** The Enter that opened a question (in a form's box, with `confirm`) could press
88
+ the Confirm it gave the focus to - by the key's repeat or the second tap of a double press - and save before the
89
+ details were read. Any Enter inside a message box with `repeat`, or within 300 ms of the Enter before it, is held;
90
+ the next deliberate Enter answers as before. A check that opens a box with Enter and answers with Enter waits 300 ms.
91
+ - **The question is read out.** A message box is described (`aria-describedby`) by its message and its `details`, so
92
+ a screen reader reads them with the title as the focus enters, before Confirm.
93
+ - **A remembered choice on a long list is kept.** A picker that cannot name a record by its key is looked through page
94
+ by page; when the 20 pages ran out before the list ended, a kept value not met was counted gone and forgotten. It is
95
+ now unknown: kept, not opened. Only the list's end (as the box reads one) makes it gone, and the pages are asked in
96
+ the box's own `pageSize`, so a loader paging by its own size with no `hasMore` is no longer cut after one page.
97
+ - **A value opened from memory redraws what follows it.** An input worked out from it (`compute`), sized by it
98
+ (`spanWhen`), shown or labelled by it, drew its old state until something else changed; memory's opening and a
99
+ picker's late answer now redraw the inputs that follow, as a change does.
100
+ - **A profile field the row lacks** (`details.profile.fields`) holds its line while the record is read and says "Not
101
+ loaded" when the read fails, as the Details tab's fields do; before, it was left out of both the profile and the
102
+ Details tab, and appeared late, moving the profile.
103
+ - **A picker asked for a remembered value says it is busy**: its box is `aria-busy` and described "Loading" while the
104
+ value may still come (a search's pages stay the list's busy state), and a person in the box keeps the focus when the
105
+ value opens - any input mounted again under the focus (a late value, a load, a reset) gets it back.
106
+ - Proved by `test-input-memory.mts` (1,200 records, a loader paging by 20, followers drawn), `test-crud-profile-fields.mts`
107
+ (the read state) and nexus.template's `form-confirm.cjs` E, `crud-workspace.cjs` M and `input-memory-picker.cjs` K;
108
+ each new line seen red against the package as it was before.
109
+
110
+ ### The row a save leaves is what was sent
111
+
112
+ Card C-crud-hidden-value-after-save (2026-11-09), found by the HR leave setup (card H2): a value hidden by `showWhen`
113
+ stayed on screen after a save until the list was read again, though the server held none. A CRUD built the saved row
114
+ from the form's own values, over the row, under the answer; a page's `transform` that sends null for a hidden value
115
+ (a leave type's "Carry forward at most" while it does not carry forward) changed only the body, and a Nexus answer
116
+ leaves its nulls out, so nothing took the form's old value off the row. Additions only: 4 exports, all from
117
+ `nexus-shared` (`crudSavedRecord`, `crudHiddenOnSave` and the types `CrudSent`, `ApiFormSent`). **What can move for an
118
+ app:** the row after an Add or an Edit, on a data table and in a tree, wherever it differed from what was sent.
119
+
120
+ - **The row is what was sent** (`crudSavedRecord`): the row the form was opened on, then the body `transform` made -
121
+ when it is a record - over the form's values, then the answer. A field the body sends as null is empty on the row;
122
+ one it reshapes (a range sent as its two days) shows as sent, also when the answer is only a token. A shown field the
123
+ body leaves out keeps the form's value, as before; a body that is not a record (a list, a `FormData`) changes nothing.
124
+ - **A value `showWhen` hid when the form was saved** is on the row only as the server holds it: as the answer gives it
125
+ when the answer is a record - a field it leaves out has none - else as the body gives it. So a hidden value the server
126
+ clears by its own rule leaves the row too, with no `transform` (the demo's amenities: a free one has no charge). The
127
+ hidden box still keeps what was typed in it, and showing it again brings it back. A picture and an input worked out
128
+ from others (`compute`) hold no value of the form's and are left as they were.
129
+ - The values of a row are the ones the save sent, never ones typed while it ran.
130
+ - **`ApiForm` `onSuccess`** gets a fourth argument, `sent` (`ApiFormSent`): the values as they were sent and the body
131
+ `transform` made of them.
132
+ - Not changed: what a form sends. A value `showWhen` hides is still sent unless the page's `transform` clears it.
133
+ - Proved by `test-table.mts` (the leave type's case under the old merge and the new one, a dropped field, a token
134
+ answer, a reshaped body) and nexus.template's new `crud-saved-row.cjs`, whose answers a route makes Nexus-shaped (nulls
135
+ left out): its three row lines failed in both motion modes against the package as it was before.
136
+
137
+ ### A mail thread, its source and backend, and three params any page can use
138
+
139
+ Card C-mail-thread (2026-11-09), card 2 of the domain messages spec: mail inside an app - a thread with a subject, a
140
+ sender and its people (To, Cc, Bcc), whose replies are the chat's own lines, which a module can write into when
141
+ something happens. Built to the spec's wire before the Message module's `Mail` doors exist, and proved against a
142
+ pretend `Mail` controller in nexus.template that answers that wire. Additions only: 58 exports (45 from `nexus-shared`,
143
+ 13 from `nexus-shared/client`), compared with the build before the card by `Frontend/scripts/release-compare`; no
144
+ exported constant changed. **What can move for an app:** nothing it already draws - a chat line that names no kind is
145
+ drawn as before, and `.nx-chat-list__badge` is `inline-grid` (the same in the conversation list, where it is a flex
146
+ item; now its own width in a table cell too).
147
+
148
+ - **`MailThread`** (`nexus-shared/client`): the head - subject, time, From, To, Cc and Bcc **only as the source gives
149
+ it**, each list folded to three names and a "+2" button that opens the rest in place (`MailPeople`), the reader's own
150
+ name saying "you", the record it is about (`MailDomainLine`, or `renderDomain`) - over the chat's `ChatThread` and
151
+ `ChatComposer` on `source.threadSource(id)` through `ChatStore`, unchanged. A person copied unseen reads
152
+ `MAIL_BCC_NOTE` over the reply box; when they reply, or when anyone else's line arrives, the head is read again
153
+ (a reply makes a blind copy a Cc). `onChanged` tells the page what arrived (`reason: "notification"` when the record
154
+ may have changed, which also bumps the record slot's `revision`), `onSummary` its counts (a list's row clears its
155
+ unread), `onHead` each head read. States: bones while the head loads, a failure with Try again in the head and in
156
+ the thread, "This message is not available to you." for a 403 or 404, a closed thread read but not answered. A band
157
+ across the panel, never a box (`Nexus.Mail.css`, new); the chat panel's frame and body.
158
+ - **`ChatMessage.kind`** (`ChatMessageKind`: `chat`, `mail`, `notification`): `NexusChatBackend` reads it from a row's
159
+ `messageType` (the backend's `EChatMessageTypes` 0, 1, 2, or the word). A notification is its own run
160
+ (`ChatRun.notification`) and `ChatThread` draws it as an event line (`ChatEventNote`): the bell, who acted, when, and
161
+ the words, quiet, with no side and no bubble.
162
+ - **`ChatComposer` `notice`**: a line over the writing area that the text area is described by.
163
+ - **`MessageSource`** (`MailInterfaces.ts`, beside `ChatInterfaces.ts`; the builder's `MessageInterfaces.ts` keeps its
164
+ name): `inbox`, `thread`, `threadSource`, `compose`, `archive`, `unarchive`, `unread`, `people`, `defaults`, in this
165
+ package's words (`MailRole` `from | to | cc | bcc`, `MailList` `inbox | unread | sent | archived`).
166
+ **`MessageBackend`** / **`NexusMessageBackend`** / **`defineMessageBackend`** map them to an API (the Message
167
+ module's `Mail` controller by default; `module` and `controller` overridable, as `NexusChatBackend`), reading the
168
+ backend's numbers as words; **`apiMessageSource`** (`/client`) is the only file that calls.
169
+ **`localMessageSource`** keeps a mailbox in memory with a backend's rules (who sees a blind copy, a blind copy's reply
170
+ made a Cc, archive and unread per person, a retried send written once, every wrong input of a new message at once),
171
+ `as(userId)` to read it as somebody else and `post` for a module's notification.
172
+ - **Words and helpers**: `mailPeopleLine` (three names then "+2", never "+1"), `mailBccNote`, `mailPreviewText`,
173
+ `newMailClientId`, `mailInboxLoader` (a server-paged table's loader), `MAIL_ROLE_LABELS`, `MAIL_LIST_LABELS` and the
174
+ limits `MAIL_PEOPLE_MAX` (50), `MAIL_SUBJECT_MAX` (100), `MAIL_BODY_MAX` (4000).
175
+ - **An inbox's columns** (`mailInboxColumns` `from`, `subject` with the record as a tag before it, `lastMessage`,
176
+ `when` as "14 min ago", `unread` in the chat's badge; `mailInboxColumnList` all five, fitting a 1280px window beside a
177
+ menu) read a `MailInboxRow`'s fields, which are the answer's own names, so a CRUD of raw rows uses them too.
178
+ - **Three params any page can use**:
179
+ - **`pagedPicker(load, options)`** and **`holdPickerItems`**: a dropdown over any server search, paged, that names
180
+ every value it holds - from the pages shown, items handed over, a lookup by key (`find`), or the server's first
181
+ pages (`findPages`, 4) - so a form opened on values, or a draft brought back, never shows "Not found" for what a
182
+ page showed. `mailPeoplePicker(source)` is one for the people a message goes to.
183
+ - **`Crud` `form.createDefaults(signal)`**: an Add form's values read as it opens, over `newValues` and under the
184
+ values `create(values)` fixes; the inputs show disabled until they land and a draft comes back over them; a failed
185
+ read opens the form on `newValues` with a toast, since defaults are a help, not the record.
186
+ `mailComposeDefaults(source, picker)` is one: To, Cc and Bcc from the reader's defaults, named at once, and a new
187
+ `clientId` (give `newValues: () => ({ clientId: newMailClientId() })` too, so a form opened without its defaults
188
+ still sends one).
189
+ - **`DataTable` `rowStrong(row)`** (and a `Crud`'s `table.rowStrong`): the row's cells heavier in the text colour
190
+ (`data-strong`), nothing else, so it keeps its height and no column moves when it reads plain.
191
+ - Proved by `test-mail.mts` (146: roles, Bcc hidden also in read marks, a Bcc reply made Cc, archive, unread, a
192
+ notification, a closed thread, a new message refused with every wrong input at once and written once, paging, the
193
+ paged picker, the Nexus backend's requests and answers, a notification's run, a thread through `ChatStore`; red with
194
+ the Bcc rule taken out) and nexus.template's new `mail-thread.cjs` (82 in both motion modes, red with the strong
195
+ look or the blind copy's head refresh taken out).
196
+
197
+ ### A record's card in a message, read live from its module
198
+
199
+ Card C-domain-card (2026-11-09), card 5 of the domain messages spec: the owner's "if type is chat and it has domain type
200
+ and id then we fetch those domain info and render those info into its own components ... we fetch that person's
201
+ remaining and taken leave". A message about a record shows the record as a card, read from the module that owns it each
202
+ time it is drawn, never a copy kept in the message. Additions only: 49 exports (39 from `nexus-shared`, 10 from
203
+ `nexus-shared/client`), compared with the build before the card by `Frontend/scripts/release-compare`; no exported
204
+ constant changed. **What can move for an app:** a `MailThread` whose record's kind has a card draws the card where
205
+ `MailDomainLine`'s line was (folded to its title line on a thread narrower than 480px); a kind with no card draws the
206
+ same line as before, now `DomainLine` (`.nx-domain-line`; `.nx-mail__domain` is gone).
207
+
208
+ - **`ChatDomainInfo.card`** (`DomainCardDefinition`): `load(id, signal, context)` reads the record through its
209
+ module's card door, which checks the reader; `view(data, context)` maps the answer to `DomainCardParams`; optional
210
+ `extra(data)` (one more piece of this package's parts), `held` (what the placeholder holds the first time) and
211
+ `lifetime`. **`domainCard({ load, view })`** types `view` by `load`'s answer; `defineChatDomains` refuses a card
212
+ without both. **`domainCardOf(kind)`** finds it. This package holds no kind and no number: the app's rows do.
213
+ - **`DomainCard`** (`nexus-shared/client`) `domain`, `id`, `label`, `revision`, `viewer`, `card` (instead of the
214
+ registered one, or `null`), `kind`, `headingLevel`, `collapsible`, `defaultCollapsed`, `ref` (`reload()`): a
215
+ placeholder of the card's own parts in the shape of the kind's last card (so the record lands without moving what is
216
+ under it, at any width - the head is laid out by the card's width, never its words), the card, "You cannot open this
217
+ leave request." for 403 and 404 alike, "Could not load this leave request." with Try again (which reads once) for any
218
+ other failure or a `view` that throws, and the kind and the record's words alone for a kind with no card or a message
219
+ with no id. Read again when `revision` grows, `reload()` or **`refreshDomainCards(kind, id?)`**: the card stays,
220
+ marked "Updated 2:45 PM" when the record turned out different or "Not up to date" with Try again when the read
221
+ failed; a refusal drops it. A screen reader is told each. `MailThread` passes its notifications' `revision` and its
222
+ reader.
223
+ - **`DomainCardFrame`**: a card from `DomainCardParams` alone (title, kind, code and subtitle on one quiet line with the
224
+ link at its end, state in its tone, fields, figures with a tone mark and a hint, lines with "and 3 more", currency),
225
+ with an optional fold button. Values by kind with the reader's settings (`domainCardValueText`): `date`, `datetime`,
226
+ `days`, `money`, `number`, `person`, `text`. **`DomainLine`**: a kind's tag and a record's words.
227
+ `Nexus.Domain.css` (new): a band, never a box; the mail head draws a line above it, edge to edge.
228
+ - **Reads**: **`DomainCardReads`** / **`domainCardReads`** - one read of a record at a time however many cards draw it,
229
+ an answer kept 30 seconds per reader (`DOMAIN_CARD_LIFETIME`), a failure never kept, a read nobody waits for aborted,
230
+ a read again taking only a read started after the change (and forgetting what other readers kept of the record).
231
+ **`DomainCardRun`** (one card's read outside React: `start`, `stop`, `reload`, `retry`, a snapshot to subscribe to)
232
+ and **`useDomainCard`** for a page's own look. Words: `domainCardNoun`, `domainCardRefusedText`,
233
+ `domainCardFailedText`; shapes: `domainCardShape`, `heldDomainCardShape`, `rememberDomainCardShape`,
234
+ `DEFAULT_DOMAIN_CARD_SHAPE`.
235
+ - **`formatDays(value, { decimals, locale, empty })`** and **`dayWord`** in NumberHelpers: "1 day", "0.5 days", "2.5
236
+ days", grouped as `formatNumber` does.
237
+ - Proved by `test-domain-card.mts` (100: the registry, keys, shared and kept reads, a read again after a change, abort
238
+ and remount, every state of a run, `refreshDomainCards`, words, values by kind, shapes; red on each of nine key lines
239
+ taken out on a scratch copy), `type-checks/domain-cards.tsx` (`view` typed by `load`), and nexus.template's new
240
+ `domain-card.cjs` (88 in both motion modes: a thread's leave card from a pretend `RequestCard` controller, one read,
241
+ kept per reader, Accounts' notification read once and marked updated, a reader refused with the thread whole, a
242
+ requisition's lines, the fold and the keyboard; every state; the placeholder's height kept at seven widths; a phone;
243
+ seven themes light and dark at AA - red on a scratch copy with the band rule, the revision, the narrow fold, the
244
+ placeholder's shape or its width-led head taken out).
245
+
246
+ ### Inventory added at bit 17; no value moves
247
+
248
+ Card INV-0-comp (2026-11-09), card 1 of the Inventory spec (`owner-requests-2026-11-09/inventory-spec.md`): the owner's
249
+ Demand, Dakhila and Clearance get a backend module of their own, `EModules.Inventory = 1 << 17`, after Portfolio.
250
+
251
+ - **`NexusModule.Inventory`** = `1 << 17` (131072), the last bit. Every other value is what it was (`Portfolio` stays
252
+ 65536), so no permission bitmask an app holds changes meaning - unlike `Chat`'s insertion of 2026-09-25 below. A new
253
+ module takes the next bit at the end, never one in between; the enum's comment says so.
254
+ - **`NEXUS_MODULES`** gains `{ module: NexusModule.Inventory, key: "inventory", label: "Inventory", env: "API_INVENTORY" }`
255
+ before the app's own row: its root is read from `API_INVENTORY` on the server and its browser calls go through the
256
+ proxy at `/api/app/inventory/...`.
257
+ - No export is added or removed (`NexusModule` gains a key); compared with the build before the card, every value of
258
+ `NexusModule` and every row of `NEXUS_MODULES` is kept and only these two are new.
259
+ - Proved by `test-api.mts` (210: the value, Portfolio's unchanged, the key, the label, the environment name, a lookup by
260
+ key and by number, the browser root, the proxy, and a new check that the list names every module of the enum with an
261
+ API exactly once; red on a scratch copy with Inventory on bit 16, its environment misnamed, its key misspelt or its row
262
+ left out) and nexus.template's `api-calls.cjs` (its modules table's Inventory row, both motion modes).
263
+ - **For an app on the published package**: nothing to do until it calls the Inventory API; then set `API_INVENTORY`
264
+ (the module's `.../App/Api` root) where the app's other `API_*` roots are, and allow the module in its proxy route.
265
+
266
+ ### A message bell: the unread count on a top bar, one read however many bells
267
+
268
+ Card C-message-bell (2026-11-09), card 8 of the domain messages spec: how much internal mail waits, on every page.
269
+ Additions only: 18 exports (13 from `nexus-shared`, 5 from `nexus-shared/client`), compared with the build before the
270
+ card by `Frontend/scripts/release-compare`, none removed; no exported constant changed. **What can move for an app:** the
271
+ chat icon's unread badge is drawn by the rule `Button`'s new badge shares (`Nexus.Button.css`; the same look, moved),
272
+ and `localMessageSource`'s `unread()` now counts to 100 as the Message module's does.
273
+
274
+ - **`MessageBell`** (`nexus-shared/client`) `source`, `href`, `threadHref`, `label` ("Messages"), `interval` (a
275
+ minute), `size`, `icon`, `maxBadge` (99), `time`: one of a top bar's ghost buttons with the count over its corner in
276
+ the chat's badge - one size whatever the count, "99+" from 100 on - and named for a screen reader ("Messages: 3
277
+ unread messages", "not checked yet", "could not be checked"). A click opens a `Menu`: the newest unread threads
278
+ (subject; who wrote the newest line, the record, "14 min ago"), each a link to `href?thread=<id>`, Try again after a
279
+ failure, and "Open inbox" (with how many in all when more wait); the arrow keys walk it, Escape and a click outside
280
+ close it, and opening it reads the count again. When the count goes up, one bell of the page says it once in a polite
281
+ live region - not on every read, not as the page loads. Drawn in nexus-admin's bar through `renderUtility` with no
282
+ change to nexus-admin.
283
+ - **`useMessageUnread(source, { interval, enabled })`** and **`MessageUnreadPoller`** / **`messageUnreadPoller(source)`**:
284
+ one poller per source object, shared by every bell on the page. It reads when the first bell appears, then every
285
+ `MESSAGE_UNREAD_INTERVAL` (60 s) while the page is in front, at once when the window takes the focus or the tab is
286
+ shown again (`MESSAGE_UNREAD_WAKE_GAP` keeps the two from reading twice), never while the tab is hidden, never two
287
+ at once; a failure keeps the count read before; the last bell gone stops everything and the next one starts from what
288
+ was read. **`refreshMessageUnread(source)`** after a page reads or archives threads. The environment
289
+ (`MessageUnreadEnvironment`, `browserUnreadEnvironment`) is injected, so the rules are tested with a fake clock.
290
+ - **Words**: `mailUnreadWords`, `mailUnreadBadge`, `mailThreadHref`; **`MAIL_UNREAD_MAX`** (100, the backend's last
291
+ count). `LocalMailFailure` gains `"unread"`.
292
+ - **`Button` `badge`**: a count over a button's corner, the chat's badge, the button's size unchanged. Generic: any bar
293
+ or list button with a number on it.
294
+ - Proved by `test-message-bell.mts` (48: one read for two bells, a minute, hidden reads nothing, shown and focused,
295
+ never two on their way, the shortest interval, failures, what is said and by which bell, the last bell gone, a hidden
296
+ first draw, the words, the badge, a thread's address, the local count to 100) and nexus.template's new
297
+ `message-bell.cjs` (77 in both motion modes: a page's clock moved by a minute, a hidden tab, the bar unmoved against
298
+ the server's drawing, the menu by keyboard and pointer, the arrow, a line said once, a row that opens its thread and
299
+ lowers the count, every state, nexus-admin's bar, a phone, seven themes light and dark).
300
+
301
+ ### A mail thread's head scrolls with its lines, and its record's card folds by the thread's real width
302
+
303
+ Card C-mail-thread-head (2026-11-09, a bug card): nexus.web's request and leave cards in `MailThread` (REQ-thread-pages)
304
+ showed two faults. No export added or removed; no constant changed. **What can move for an app:** a mail thread's head is
305
+ no longer a box of its own - it scrolls with the lines; a record's card shows its figures first and folds its details
306
+ (fields, lines, what a kind adds) under them, keeping the figures; a figure's hint keeps two lines.
307
+
308
+ - **The fold follows the thread's real width.** `MailThread` measured its width once, before a window had laid out
309
+ (a closed dialog reads 0), so an inbox window opened the card folded at 1280 px. Now a ResizeObserver follows the
310
+ width: the card is drawn once a real width is known - the first decision before the browser paints, so it never
311
+ shows one way and then the other - and folds under 480 px, unfolds over it, until the reader folds or unfolds it
312
+ themselves. **`DomainCard` `collapsed` / `onCollapsedChange`**: the fold a page holds (`defaultCollapsed` stays for
313
+ one it does not).
314
+ - **The head scrolls with the lines.** The head was capped at 45% of the thread with a scroll of its own, so a leave's
315
+ or a requisition's card showed in part at 720 px tall. **`ChatThread` `lead`**: what a conversation opens with, above
316
+ its first message and in the same scroll - the lines alone stay the screen reader's log. With a lead the thread opens
317
+ at its top (its first unread line at the top when that would be out of view), older pages load from their button so
318
+ the lead stays put, and whether the reader is at the bottom is worked out again when the lead grows (a card landing).
319
+ `MailThread` passes its head as the lead: at 1280×720 a leave's card shows whole as the window opens, every part of
320
+ either card is read by scrolling the thread, and the reply box keeps its 206 px.
321
+ - **The card's fold keeps the figures.** Folded, a card is its title, its code and its figures - a leave's balance -
322
+ with "Show the details" for the rest; a card with nothing to fold has no fold button. The figures come first, the
323
+ details under them, so opening them moves nothing above. A figure's hint keeps two lines (`min-height`), so a long
324
+ one ("1.5 paid, 0.5 unpaid (1 day's pay)") does not change the card's height against its placeholder.
325
+ - Proved by nexus.template's `domain-card.cjs` (105 in both motion modes; a new section reads every part of the leave
326
+ and requisition cards in the thread's one scroll at 1280×720 and 390×844, light and dark, the reply box's room
327
+ beside it) and `mail-thread.cjs` (122; a new section opens Puja's leave thread in the inbox's window: whole at 1280
328
+ and folded on a phone, never the other way first; the head in the thread's scroll, the thread at its top with the
329
+ figures in view; the fold following a narrowed and widened window until the reader folds it), red on a scratch copy
330
+ of the parent commit's sources. The demo's leave card carries the real one's hints: the year under Available, and the
331
+ paid/unpaid split under This request.
332
+
333
+ ### A bell's refresh asked during a read is read after it, not lost
334
+
335
+ Card C-unread-requeue (2026-11-09, a bug card): nexus.web's bell (MAIL-bell) found that `MessageUnreadPoller.refresh()`
336
+ did nothing while a read was on its way; when that read had started before a read mark, an archive or an unarchive, the
337
+ bell kept the old count for up to a minute. No export added or removed; no constant changed.
338
+
339
+ - **`refresh()` (and `refreshMessageUnread(source)`, unchanged in its contract)**: with no read on its way it reads now,
340
+ as before; with one on its way it asks for exactly one more read after it - however many refreshes are asked
341
+ meanwhile, and whether that read answers or fails - so never two reads at once. While the page is hidden nothing is
342
+ read: the read asked for waits and runs at once when the page is shown, even within the two seconds a shown tab's
343
+ focus otherwise skips. The last bell gone drops it. The minute's timer counts from the read that followed.
344
+ - nexus.template's bell source now counts when asked and answers 150 ms later, as a server does, so the case can be
345
+ seen: `message-bell.cjs` presses Check now, lets Kiran write while that read is on its way, presses it again, and
346
+ the bell shows his line at once (red on the parent commit's poller, with the read mark's case beside it).
347
+ - Proved by `test-message-bell.mts` (66: three refreshes during a read make one read after it, the stale answer drawn
348
+ meanwhile, never two on their way, the minute from the read that followed, hidden then shown, a failed read followed,
349
+ the last bell gone, a bell drawn again inheriting nothing; red on the parent commit's poller and on each of five lines
350
+ of the fix broken) and `message-bell.cjs` (79 in both motion modes).
351
+ - **For an app that worked around it** (nexus.web's `recountUnread()` in `admin/mailbox.ts`, which refreshed once more
352
+ when the read on its way answered): a plain `refreshMessageUnread(source)` now does that by itself.
353
+
354
+ ### A mail thread's window opens on its subject, and its reply box keeps one row on a phone
355
+
356
+ Card C-mail-thread-fit (2026-11-09, a bug card): nexus.web's Inbox (MAIL-pages) found two faults in `MailThread` that a
357
+ page cannot mend without CSS of its own. Additions only: 3 exports (`fitToolbar`, `ToolbarFit`, `ToolbarSizes`, from
358
+ `nexus-shared`), compared with the build before the card; no exported constant changed. **What can move for an app:** a
359
+ popup given no `initialFocus` now starts on an element of its body or footer marked `data-nx-focus` before the first
360
+ control (only `MessageBox`'s buttons carried the mark, and it names it itself); every `ChatComposer` - a chat panel's
361
+ too - keeps its formatting buttons on one row.
362
+
363
+ - **Where the focus lands.** A window opened on a thread put the focus on the first control in its body, which for a
364
+ thread with no "+2" and no record is the lines' scroller (`tabIndex 0`), and opened with its ring showing. The
365
+ window now starts on the thread's subject (`tabIndex -1`, marked `data-nx-focus`, no ring: a place to start reading,
366
+ the WAI dialog pattern for a long read), a screen reader reads it first, and Tab goes on through the people, the
367
+ record's card and the scroller - which shows its ring when the keyboard reaches it. `Popup`: with no `initialFocus`,
368
+ `[data-nx-focus]` in the body or footer first, then the first control.
369
+ - **The reply box on a phone.** Under 560 px the editor's buttons took a row of their own and wrapped, three rows over a
370
+ two-line text area, leaving about 190 px for the lines at 390 px. **`MarkdownEditor` `toolbarOverflow`**: `"wrap"`
371
+ (the default, as before) or `"menu"`, one row at any width - the buttons take the room the views and the full-screen
372
+ button leave, and those that do not fit go into a "More formatting" menu at their end, with their shortcuts (which
373
+ keep working); the arrow keys reach it with the other buttons. `ChatComposer` uses `"menu"`: at 390 px the bar is
374
+ Write, Preview, B, I, S, "…", full screen; the reply box is as tall as on a wide window (206 px) and the lines get
375
+ 258 px. **`fitToolbar(items, width, sizes)`** is the arithmetic, pure.
376
+ - Proved by `test-toolbar-fit.mts` (10; red with the More button's room, the trailing divider, the all-fit case or the
377
+ gaps taken out) and nexus.template's `mail-thread.cjs` (107 in both motion modes: opened from the keyboard and by a
378
+ click, the subject focused with no ring and the scroller unringed, Tab reaching the scroller with its ring, the bar's
379
+ one row at 1280 and 390 light and dark, More formatting's tools at work, End reaching it, the composer's height the
380
+ same on a phone; red on a scratch copy of the parent commit's sources).
381
+
382
+ ### A row form asks before it saves, a picker reads another listing, a history names who
383
+
384
+ Card C-crud-row-confirm (2026-11-09): three gaps nexus.web's pay runs (P1a) worked around. One export added
385
+ (`PickerListController`, a type); no constant changed. **What can move for an app:** a row form's save now marks what
386
+ pickers and joins looked up from the controller as old, as every other CRUD change does, so they read it again where it
387
+ is next shown; and `expireLookups` marks a CRUD's own `lists` old too.
388
+
389
+ - **A row form's question: `CrudRowForm.confirm`.** The same question a form's `confirm` asks (C-form-confirm): after
390
+ every value passes its checks, what will be saved drawn in full as a record's details, Confirm holding the focus, read
391
+ out with its line and details, a hasty second Enter never answering it. Params, or a function of the record and of
392
+ what `load` read, so it names the record: "Lock Kartik 2083?" over the run's totals. `when` asks only for some saves
393
+ (the demo's Duties asks only when duties are taken away).
394
+ - **A row action that is its question alone: a `CrudRowForm` with no `inputs`.** No popup form: choosing it asks
395
+ (`confirm`, else "{label} {the record's name}?"), then sends what `load` read - else the record as the list has it -
396
+ through `transform`, and writes what `writes` names. Its buttons are the action's own word (`submitLabel`, else
397
+ `label`) and "Cancel". The API's toasts say it was done or why not; a refusal writes nothing; Cancel, Esc and the
398
+ close button send nothing; choosing it again while it asks or sends does nothing. A pay run's Lock no longer needs
399
+ `api.confirm` by hand. `inputs` is optional for this; an empty list is still a form.
400
+ - **A picker over another listing: `crudPicker` `list`, `usable` and `option`.** `list` reads a listing in place of
401
+ the controller's `activeListing` - a controller with none, such as a pay run's CRUD endpoints (`PickerListController`,
402
+ `findListing` optional: without it a value the list does not offer is not looked up). `usable` says which records
403
+ count as usable - offered, and untagged when a form holds one - in place of the backend's rule (under 30), so a draft
404
+ run (31) or an invited person (30) can be picked. `option` makes each record's option, its words from two fields or a
405
+ state's word, made once per record read; the search reads them too. `expireLookups` also marks a CRUD's own `lists`
406
+ old, so a picker reading one is read again after a change there. Without them, a picker is as before.
407
+ - **A history names who made a change**: `AuditEntry.creatorName`, when the backend's answer carries it, is said as
408
+ "by Sita Rai" in place of the start of the token. The Nexus backend's `AuditResponseContract` carries only
409
+ `CreatorUserId` (a token), so HR's histories - every module's - show "User 3fA9b2…" until it fills a `CreatorName`.
410
+ - Proved by `test-crud-row-forms.mts` (36: a question alone, the question made for the record, its words),
411
+ `test-crud-picker-plan.mts` (11: the listing read, the lookup, the usable rule) and `test-audit-entries.mts` (8), each
412
+ key line red when broken on a scratch copy (8 of 8); `type-checks/crud-row-confirm.tsx`; nexus.template's
413
+ `crud-row-form.cjs` (56 with one theme, in both motion modes: the Duties question, Back, a double Enter; Promote as a
414
+ question alone, its refusal when the role changed since; a post's history naming Sita Sharma and showing another's
415
+ token; the question at 1280×720 and 390×844, light and dark) and the new `crud-picker-list.cjs` (17: `/admin/posts?people=all`
416
+ offers the invited people, says so under their names, and picks Maya Tamang), red on a scratch copy of the parent
417
+ commit's package sources.
418
+
419
+ ### An owner's files that a record's state lets only be added to
420
+
421
+ Card C-documents-add-only (2026-11-09): since INV-2b a posted goods receipt's documents may only be added to - Drive
422
+ refuses a rename, a mark or a delete with the core's `API_X084` ("These files can only be added to now") - but
423
+ `DocumentList` still offered Rename and Delete on it. Eight exports added (`FileChange`, `FILE_CHANGES`,
424
+ `allowsFileChange`, `narrowFileChanges`, `fileChangesNote`, `ownerFileActions`, `OwnerFileActions`, `FILES_ADD_ONLY`);
425
+ no constant changed. Without the new params nothing moves.
426
+
427
+ - **`changes` and `changesReason`** on every owner's files (`OwnerFilesParams`: `DocumentList`, `PictureGallery`,
428
+ `openPictureGallery`, the picture input, `pictureColumn`): the changes a person may make here from the record's state,
429
+ within what the backend allows - `["add"]` for a posted receipt keeps Upload and offers no Rename, Delete, Make main or
430
+ Remove, nor a Replace or an Edit that would delete or mark - with the page's sentence under the files. The changes are
431
+ words (`FileChange`: `add`, `rename`, `primary`, `remove`), the core's `EFileChanges` one for one, as a category's kinds
432
+ are MIME types. `readOnly` and `lockReason` are as they were: the files shown, nothing offered.
433
+ - **A stale page learns from the refusal.** `FileAdapter.changesLeft(answer)` says which changes a refusal leaves
434
+ (`NexusDriveAdapter`: `API_X084` -> `["add"]`); after one, the list offers only those for that owner, under the server's
435
+ words, so a page read while the receipt was a draft stops offering what the server now refuses. The toast says what
436
+ was refused and why ("The file was not renamed" - "These files can only be added to now: ...").
437
+ - The buttons offered are one pure rule (`ownerFileActions`), and an action called while not allowed does nothing.
438
+ - Proved by `test-files.mts` (113: the rule for documents, pictures and a one-file category, the note, the refusal read
439
+ through the Nexus adapter), each of 8 key lines red when broken on a scratch copy; `type-checks/files-add-only.tsx`;
440
+ nexus.template's `/demo/pictures` "Documents only added to" (a receipt on the demo's Drive, Draft, Posted or Reversed,
441
+ and "Post it in another window") and its new `documents-add-only.cjs` (16 in both motion modes, light and dark at
442
+ 1280×720 and 390×844), red on a scratch copy of the parent commit's sources (7 lines in each mode); `pictures.cjs` 123.
443
+
444
+ ### A gallery whose removals wait for a review
445
+
446
+ Card C-gallery-pending-removal (2026-11-11): Drive holds the removals of a reviewed gallery (`company-public-gallery`,
447
+ whose changes Nexus support approves): a Delete of a picture the public sees only marks it `pendingRemoval`, still
448
+ served and listed, until the reviewer decides, and the person may Keep it until then. Seven exports added
449
+ (`removalHeld`, `removalHeldSentence`, `removalHeldQuestion`, `PENDING_REMOVAL_TAG`, `REMOVAL_REVIEWER`,
450
+ `OwnerFilesChange`, `OwnerFilesChangeKind`); no constant changed. Without a holding category nothing moves.
451
+
452
+ - **Records.** `StoredFile.pendingRemoval?` and `removalAt?` (Drive's `removalTime`, while one waits), and
453
+ `FileCategoryInfo.holdsRemovals?` (Drive's List); each read only when true, so every other record is as before.
454
+ - **The adapter.** `FileAdapter.remove` in a holding category answers the file marked (`removalHeld(result)` reads it);
455
+ the new optional `FileAdapter.keep(owner, fileId)` cancels the removal (`NexusDriveAdapter`: `PUT File/Keep/{id}` with
456
+ `{ ticket, id }`, a run-out ticket asked again once). An adapter without `keep` offers no Keep.
457
+ - **The views.** A waiting picture is faded with "Removal waiting for review" as a warning band across its foot, and its
458
+ buttons are View and Keep (no Delete, Make main or Edit); a waiting document's row says it with the time it was asked
459
+ and offers Keep in place of Rename and Delete; a waiting main picture's tag sits beside it. In a holding category the
460
+ Delete asks "If the public sees this picture, it stays until Nexus support approves its removal." and the answer
461
+ decides: held, the file stays marked and the toast says "Removal sent for review"; else it is gone, as before. A
462
+ one-file holding category offers no Replace or in-place Edit (its Delete frees no room until the review).
463
+ `ownerFileActions` gains `holdsRemovals`, `keeps` and the `keep` button.
464
+ - **Params.** `holdsRemovals` (told before the list is read; the backend's says it too) and `removalReviewer` (default
465
+ "Nexus support"); **`onFilesChange(files, { kind, file })`** after every change the backend took - `upload`, `edit`,
466
+ `primary`, `rename`, `delete`, `hold`, `keep` - with the files read again, for a page whose record follows its files.
467
+ `onChange(primary)` is as it was.
468
+ - Proved by `test-files.mts` (142: the records, the Delete and Keep calls, the buttons), nexus.template's `/demo/pictures`
469
+ "A reviewed gallery" (the demo's Drive holds `guest-public-gallery`, `Demo/Review` publishes, approves, declines) and the
470
+ new `gallery-held-removals.cjs` (56 in both motion modes: by keyboard, the list, seven themes light and dark at
471
+ 1280×720 and 390×844).
472
+
473
+ ### A row's picker narrowed by the form's value and the row's own
474
+
475
+ Card C-row-picker-narrow (2026-11-09): nexus.web's Nikasha (INV-3) had to type an asset line's tags, because a picker
476
+ in a line table could not be narrowed by two values at once - the form's Store and the line's Item. Nine exports added
477
+ (`narrowingsOf`, `inputNarrowingsOf`, `narrowInputParamsBy`, `NarrowingRead`, `isEmptyNarrowed`, `formNarrowingNames`,
478
+ `narrowedFollowers`, `DropdownNarrow`, `RowsFormValue`); no constant changed. **What can move for an app:** a row
479
+ input's `narrowedBy` now narrows in rows (it was ignored there), and a row redraws when a value its inputs `dependsOn`
480
+ changes; `narrowedBy` and `narrowQuery` are wider types, so code that reads `params.narrowedBy.name` reads
481
+ `narrowingsOf(params)` instead.
482
+
483
+ - **Several narrowings at once: `narrowedBy` as a list.** The options must fit every one; the box asks for the first
484
+ one still empty ("Pick a store first", then "Pick an item first"); the list loads afresh when any changes (one load key
485
+ for all). A loader is asked with `request.narrows` (every one) beside `request.narrow` (the first), and one narrowing
486
+ is asked exactly as before. `narrowInputParamsBy(params, read)` narrows by any values; `narrowInputParams` stays.
487
+ - **In rows, by the row's value and the form's: `from: "form"`.** A row input's narrowing names an input of its row
488
+ (`{ name: "itemId" }`) or of the form around the rows (`{ name: "storeId", from: "form" }`). The Form hands a rows
489
+ input the form values its rows follow (`formValues`, a prop a rows input used alone takes from the page) and redraws
490
+ its rows when one changes. A person's change clears a value picked among the old options: a row's Item clears that
491
+ row's Tags (and down the row's chain), the form's Store clears every row's (to the input's own empty value); a value
492
+ set from code - a saved document loading - keeps them. The rule is one pure function, `narrowedFollowers`, for a
493
+ form, a row and every row of a form.
494
+ - **A cell that follows its row: `dependsOn` in rows.** A change of a row value a row input names in `dependsOn` draws
495
+ the row again, so `cellParams` answers for the row as it is now: a line's Tags at most its Quantity (`{ max }`), the
496
+ picker's own rule then stopping a save when the quantity drops under the picks.
497
+ - **`crudPicker` narrowed by several values, or by its door's path.** Each value goes into the listing's path when the
498
+ path names it (`list: StockBalance/Units/{storeId}/{itemId}`), as a query when `narrowQuery` (now one name or a list)
499
+ names it, else narrows in the browser; each answer is kept per values, shared by every row that names them, and a
500
+ saved value is named from them. A path listing is never read unnarrowed.
501
+ - Proved by `test-input-narrowing.mts` (104), `test-rows-store.mts` (132) and `test-crud-picker-plan.mts` (20), each of
502
+ 11 key lines red when broken on a scratch copy; nexus.template's `/demo/tabular-form` "An issue" (a Store, lines of an
503
+ Item, a Quantity and Tags from the demo backend's `StoreUnit/Units/{storeId}/{itemId}`, and "Open a saved issue") and
504
+ its new `row-picker-narrow.cjs` (25 in both motion modes, the Item and the Tags from the keyboard, light and dark at
505
+ 1280×720 and 390×844), red on a scratch copy of the parent commit's sources (21 lines in each mode); the tabular,
506
+ group, forms, input-memory, narrowing, dropdown, form-figures and CRUD checks in both modes.
507
+
508
+ ### A mail client: three panes, docked chat windows and a compose window
509
+
510
+ Card C-mail-client (2026-11-10), card 1 of the mail client spec (the owner: "the mail project UI make UI as mail,
511
+ compose, send, inbox ... chat exist inside mail ... we separate by this types"). 36 exports added: `MailClient`,
512
+ `MailReader`, `MailComposeWindow`, `ChatPopup` with their props, the pure `MailClientHelpers.ts` (the types a rail lists,
513
+ a row's time, a thread's discussion as a chat source, the split and the drafts kept per viewer), and the wire's
514
+ `MailThreadKind`, `MailFolders`, `MailFolderCount`, `MailKindCount`. No constant changed. **What can move for an app:**
515
+ `NexusMessageBackend.readInboxRow` no longer reads a row's `kind` as what the thread opened with (that is `messageType`
516
+ or `headMessageType`): on the MAIL-hub wire `kind` is the conversation's, Mail 1, Chat 2, Domain 3, and a chat's `kind`
517
+ 2 used to read as a notification.
518
+
519
+ - **`MailClient`**: a bar (title, search, the split button, the app's `settings` slot - the ⚙ to its admin pages), the
520
+ rail (Compose; Inbox, Unread, Sent, Archived with their unread counts; Types - Mail, Chat, one per record kind the app
521
+ named in `defineChatDomains`, each with its unread count, a type with nothing in it left out), the list, and the
522
+ reading pane. Side by side, or one over the other by the reader's choice, remembered for them in this browser
523
+ (`nx-mail-split:<viewer>`); one over the other under 1000px of its own width; one pane at a time under 700px, with a
524
+ back arrow and a Folders button. It fills what holds it and never scrolls: each pane does. Params only: `source`,
525
+ `userId`, `view`/`onViewChange` and `threadId`/`onThreadChange` for a page's URL, `settings`, `chatSourceFor`,
526
+ `renderDomain`, `onChanged` (a bell's `refreshMessageUnread`), `pageSize`, `pollInterval`.
527
+ - **The list reads as mail**: avatar, sender (strong while unread, with the thread's line count), subject — last line (a
528
+ chat: its last line after its author), the type as a tag in its kind's tone, the time (today's as a time, yesterday's
529
+ as "Yesterday", older as a date, in the reader's zone and calendar), a select box and a star (only when the source
530
+ stars). Ticked rows take Archive or Move to inbox, Mark read and Mark unread in one `SplitButton`. The arrow keys,
531
+ Home and End move between rows, Enter opens (and puts the focus at the subject); more rows load as the end shows.
532
+ - **`MailReader`**: the subject, its type, From → To · Cc (· Bcc as the source gives it), when; the record's
533
+ `DomainCard`; the mails, the newest open and the earlier folded to a line each, a module's notifications as notes
534
+ among them; **Discuss (n)** (the thread's chat lines) and **Reply**. A chat is drawn by its head - who, since when, its
535
+ last line - and Open chat. Opening a conversation reads it.
536
+ - **`ChatPopup`**: a conversation's lines and composer in a window docked at the bottom right, minimisable to the dock,
537
+ several at once side by side (each next one to the left; on a screen too narrow, over each other, the last used on
538
+ top), Esc inside one closes it. A mail's Discuss opens its discussion (`mailDiscussionSource`: the chat lines alone,
539
+ whatever is written there a chat line, a chat line quoting a mail quoting nothing); a chat opens in one at once.
540
+ - **`MailComposeWindow`**: docked, minimise, maximise, close; To, Cc and Bcc (the source's people, To from its
541
+ `defaults`), subject, a Markdown body that fills the window, Send (Ctrl+Enter) and Discard. What is typed is kept per
542
+ viewer (`nx-mail-draft:<viewer>:new` or `:reply:<thread>`, every access in try/catch) until it is sent or discarded;
543
+ closing with words asks "Keep draft" or "Discard". A reply is a new mail in the thread (`ChatDraft.kind: "mail"`).
544
+ - **`Popup` `docked` and `height`** (general): a docked popup is non-modal (no backdrop, no page lock, Tab leaves it),
545
+ drawn on the body through a portal, placed along the bottom edge above the dock's bars. `height` sets any popup's
546
+ height (`--nx-popup-height`); without it a popup is `fit-content` tall as before.
547
+ - **The wire MAIL-hub answers** (the backend card may still change it, and says so in its card): an inbox row's `kind`
548
+ (1-3), `starred`, `fromAvatar`; the Inbox body's `kind` and `domainType` alone (a type); `GET Mail/Folders` answering
549
+ `{ folders: [{ list, unread }], kinds: [{ kind, domainType, unread, total }] }`; `PUT Mail/MarkRead`, `MarkUnread`,
550
+ `Star`, `Unstar` with `{ ids }`; Reply's `messageType: 1` for a mail (Chat stays the default). `MessageSource`'s
551
+ `folders`, `markRead`, `markUnread` and `star` are optional, and `MessageBackend.supports(...)` turns them off for an
552
+ older API. `localMessageSource` answers all of it (`chat` threads, `starredBy`, the failures `folders` and `marks`).
553
+ - Proved by `test-mail-client.mts` (103) and the CSS contract; nexus.template's `/demo/mail-client` (a mailbox with a
554
+ discussion, chats, two record kinds and a month of reports; Kiran writes, Keep a draft, Answer slowly, Server down,
555
+ Full window; empty, loading, failed and a 390px phone) measured by a 73-check browser pass (the client never scrolls,
556
+ one row height, the keyboard, Discuss and chat windows non-modal at the bottom right and side by side, Esc, minimise,
557
+ compose with its draft, a reply as mail, types, the record's card, stars, Archive, the remembered split, no layout
558
+ shift, no box in a box, a phone's one pane and no sideways scroll, the muted words at 4.5:1 or more on the open and
559
+ the hovered row in all seven themes light and dark, cozy density, animations on), and `popup-windows.cjs`,
560
+ `mail-thread.cjs` and the CRUD popup checks unchanged.
561
+
562
+ ### A worked-out input that can be typed in: the total sets the price
563
+
564
+ Card C-editable-calculated-amount (2026-11-10), the owner: *"if i put final 2500 inside the calculate amount then it
565
+ calculate or set the price which is very easy. this logic we have inside 06-20 ... as possible generic dynamic."*
566
+ Additions only: 6 exports, all from `nexus-shared` (`priceFromNet`, `PriceFromNetOptions`, `PriceFromNetFound`,
567
+ `PriceFromNetRefused`, `InputSolved`, `NetAmountOptions`). **One behaviour changes:** `crudInputs.netAmount()` can be
568
+ typed in by default (below); `{ editable: false }` keeps it as it was.
569
+
570
+ - **`InputCompute.solve(typed, values, withValue)`**: a worked-out input worked backwards. With it a Number input's box
571
+ is a real text box (a NumberBox: its own Tab stop, the person's number format, its prefix and suffix) instead of the
572
+ `<output>`. Typing does nothing until the person leaves the box (Tab, a click away) or presses Enter; then `solve`
573
+ answers the values of the inputs it is worked out from (`InputSolved`: `{ values, note? }`), a sentence to refuse, or
574
+ `null` to change nothing. The answer is written as the person's own change (**`FormStore.setFromPerson(values)`**:
575
+ the inputs mount again with it, their rules are asked, the form's `onFieldChange` runs, inputs narrowed by them lose
576
+ their picks, it is a change for Save, drafts and the leave guard; nothing while the values load) and the input still
577
+ holds no value of its own. The box then shows the figure worked forward again, and a line under it says what was set
578
+ ("Price set to 2,458.21."), and when the typed figure cannot be reached exactly, the nearest it now shows ("2,500.04
579
+ cannot be reached exactly; nearest is 2,500.05. Price set to 2,458.26."). A refusal is said in `--nx-danger` with the
580
+ alert icon and the box goes back. An input that is read-only or disabled is never written: that is refused by name.
581
+ Escape puts back the figure (and closes nothing); Enter with nothing new submits the form as from any box; an emptied
582
+ box or the same figure changes nothing. Read-only, disabled and loading forms keep the box read-only, disabled, blank.
583
+ - **The line under the box** shares one place with the hint (`nx-field__notes`, a one-cell grid, at least a line tall),
584
+ so nothing below moves when it comes and goes; it is `aria-live="polite"` and part of the box's description. It shows
585
+ until the figure moves on (another input changed), then the hint is back.
586
+ - **`priceFromNet(target, { quantity, discountPercentage, vatPercentage, decimals = 2, places = 2 })`**: the price that
587
+ comes to a net amount by the server's rule (`priceAmounts`). Plain division (06-20's `FindOriginalPrice`) often lands
588
+ a paisa off, because each step is rounded; this gallops from that estimate and halves among the prices at the
589
+ price's own places, each worked forward on exact decimals. The net amount never falls as the price rises, so **when
590
+ any price reaches the figure exactly, the smallest such price is answered** (`exact: true`); otherwise the price whose
591
+ net amount is nearest (a tie goes above, so the total is never short), with the net amount it reaches. A quantity of 0
592
+ or none counts as 1; a negative figure or quantity, a 100 % discount and VAT of -100 % are refused in words
593
+ (`{ ok: false, message }`). 2,500 at 10 % and 13 % is 2,458.21 exactly.
594
+ - **`crudInputs.netAmount(keys?, changes?, { editable = true, priceDecimals = 2 })`** wires `solve` to `priceFromNet`
595
+ on the `PriceKeys` (the quantity key too) and writes the price at `priceDecimals`. Its hint is "Price less the
596
+ discount, plus VAT. Type a total to set the price." (the old hint with `editable: false`). A `changes.compute` of the
597
+ page's own (a counter's server preview) has no `solve` and stays read-only, as before.
598
+ - **Not in rows** in this card: rows came with [card C-rows-solve](#a-rows-worked-out-input-typed-in-a-lines-total-sets-its-rate).
599
+ - Proved by `test-number-helpers.mts`: the owner's case and 20 more, and **560,000 targets** (every 0.01 to 100.00,
600
+ against 8 rate pairs from -5 % to 60 % off and 0 % to 13 % VAT, quantities 1, 2, 3, 7 and 1.5, and a price in 0, 2
601
+ and 3 places) against a sweep of every price: the net amount never fell, every one of the 287,075 reachable targets
602
+ was answered with its smallest exact price, every one of the 272,925 others with the nearest net amount any price
603
+ reaches, and every answer's net amount is what its price comes to (about 3.5 s). `test-form-store.mts` (the preset's
604
+ `solve`, `setFromPerson`: a change, its rule, `onFieldChange`, a new stamp, nothing while loading), the CSS contract,
605
+ and nexus.template's `/demo/form` "Worked out from other inputs" with `form-figures.cjs` (156 in each motion mode): 2,500
606
+ typed sets 2,458.21; 2,500.04 the nearest; Enter, Escape, an emptied box; a refusal; nothing below moving in compact
607
+ and cozy at 1280 and 390 wide; a percentage of marks with no money in it; the box as tall as Price with its edge and
608
+ background in three designs; the line under it at 4.85:1 or more and a refusal at 5.5:1 or more in 7 themes light and
609
+ dark. `form-confirm.cjs` unchanged (80).
610
+
611
+ ### A customisable dashboard: widgets in size presets, one call per module, only near the screen
612
+
613
+ Card C-dashboard-board (2026-11-10), card 1 of the dashboard widgets spec (the owner: "customizable dashboard ...
614
+ similar lighweight best optimize ... some can specific module base components only allow to add on same module, some
615
+ can allow to add any place ... what's new, version info and more. comming soon"). Additions only: 58 exports, 35 from
616
+ `nexus-shared` (`DashboardInterfaces.ts`, `DashboardHelpers.ts`) and 23 from `nexus-shared/client` (`DashboardBoard`,
617
+ `WidgetFrame`, `WidgetLink`, `WidgetNavigateContext`, and the looks). No constant changed. Two shared components gained a
618
+ prop: `Popup` `drawer` and `ChatFailure` `flat`; without them both draw as before.
619
+
620
+ - **`DashboardBoard`**: a bar (the place's `title`, the page's `actions`, Refresh, Customise) over a 12-column grid whose
621
+ rows are three controls tall (102px compact, 114 cozy), `grid-auto-flow: dense`. A widget takes a size preset - S 3×1,
622
+ M 6×2, L 6×4, W 12×2, T 12×4 (`WIDGET_SPANS`) - from the sizes it declares, the first its default. The board is a size
623
+ container: under 48rem of its own width a small widget takes half and a medium or large one the whole; under 30rem
624
+ every widget is the full width, in the layout's order. A layout is an ordered list of `{ code, size }`; no coordinates.
625
+ Params only: `place`, `placeModule` (default `place`), `widgets` (the app's registry), `canSee(widget)` (the module
626
+ held, the key held), `layout` / `version` (the person's, or `null`), `defaultLayout` (the company's, else the code's),
627
+ `onSave(layout, version)`, `onReset(version)`, `canCustomise`, `canSaveCompanyDefault` + `onSaveCompanyDefault(layout)`,
628
+ `loadModule(module, codes)`, `initialData` (read with the page: drawn at once), `moduleLabel`, `onNavigate(href)` (the
629
+ app's router for every link inside), `loadMargin`.
630
+ - **Edit mode** (Customise): a dashed edge on every widget, its heading the drag handle, a size menu holding only its sizes,
631
+ and Remove. **Drag** by pointer events, no library: the widget follows the pointer and the others make room as it
632
+ passes over them; Esc puts the order back; listened for on the window, since the grid moves the widget in the document
633
+ and a moved element loses a pointer capture. **Keyboard**: a focused widget moves with the arrow keys (up and down to
634
+ the nearest widget in the row above or below, `neighbourInColumn`), Home and End; Enter or Space opens its size menu;
635
+ Delete or Backspace removes it, the focus going to the widget in its place. Every move is read out. **Add widget**
636
+ opens a drawer (`Popup drawer`) listing the widgets this place may hold, by module (the place's own first), each with
637
+ its shape on a small grid, its sizes, and Add or "On the board"; closing it shows the widget added last with the focus
638
+ on it. **Save** (with "Save as the company's default" in its menu when allowed), **Cancel**, **Reset to default** (Save
639
+ then calls `onReset`). A save answered `stale` (the server's 409 with the saved layout) draws that layout and says
640
+ "Changed in another window: the layout saved there is shown."
641
+ - **Hidden widgets**: a widget the person cannot see now (`canSee`), one not allowed in the place (`placement: "own"` on
642
+ another module's board), or one the app no longer declares, is drawn nowhere and offered nowhere, but **keeps its place
643
+ in the saved layout** (`mergeHidden`: each after the drawn widget it followed), so it comes back where it was.
644
+ - **Data**: `WidgetDefinition.batch: { module, codes?, pick? }` puts a widget in its module's one call per draw
645
+ (`createWidgetBatcher`: everything asked in one task goes together, a call per module with every code its widgets
646
+ need); `load(context)` is a widget's own call; a widget with neither needs no data. **Only widgets near the screen
647
+ load** (one IntersectionObserver for the board, `rootMargin` and `scrollMargin` of `loadMargin`, so a scroller between,
648
+ as an admin shell's page area, is widened too). Each answer is kept for the page's life, or `refresh` seconds while on
649
+ screen (one 5-second timer for the board, nothing while the page is hidden); Refresh reads everything near the screen
650
+ again and the rest when it comes near. A module that fails fails its own widgets alone.
651
+ - **`WidgetFrame`**: the title, an optional link ("View all"), its own refresh, and its body in one of four states - bones
652
+ by its size while it loads (one line for S, three for M and W, seven for L and T), the `empty` sentence, the failure
653
+ with Try again (`ChatFailure flat`: the tint without an edge, no box in the frame), or its content. Its size is the
654
+ grid's, so nothing moves when the data lands. Usable alone.
655
+ - **The looks**: `StatWidget` (figures side by side as they fit, each a link if it has `href`, a tone dot), `ListWidget`
656
+ (picture or icon, words and a muted second line, a time and a tag; **only the rows that fit whole**, the rest counted
657
+ on its more line: `total`, `more: { href, label? }`, `fit`), `CalendarWidget` (the package's `Calendar` with typed
658
+ marks under each day - `holiday`, `leave`, `event` or the app's own `kinds` - a holiday's number in the danger tone,
659
+ a legend counting the month's marks, the picked day's marks beside the month or under it; AD or BS; `marksByDay`
660
+ spreads a mark over its days), `NewsWidget` (release notes: title, version and Released / Coming soon tags, the
661
+ application and day, the first lines of the Markdown notes, Read more), `VersionWidget` (the version large, the
662
+ application and build day, What's new).
663
+ - **`Popup drawer`** (general): the window's whole height at its end edge, its width from `size` (all of it on a phone),
664
+ modal, neither moving nor maximising, fading in as every popup does.
665
+ - Proved by `test-dashboard.mts` (63: the sizes, own and anywhere, canSee, a layout kept whole with hidden widgets, moving
666
+ by keyboard and pointer, the Add list, the batcher - one call per module, the cache, refresh seconds, a shared call,
667
+ failures per module, a pick that throws), the CSS contract, and nexus.template's `/demo/dashboard` measured by
668
+ `dashboard-board.cjs` (98 in each motion mode): the first draw asks each module once and nothing below the fold until
669
+ it is scrolled near (then one call of its module alone), no layout shift, the spans, rows three controls tall in compact
670
+ and cozy, no list with a half row, the keyboard (move, size menu with only its sizes, Delete, focus kept), the drawer at
671
+ the right edge the window's height (the whole width at 360px), Add, Save, Cancel, Reset, drag and Esc, a stale save, the
672
+ company's default, Kiran's hidden Request widgets back in place for Puja after Kiran's save, a failed module with Try
673
+ again, 360px with no sideways scroll and every widget the full width in order, no box inside a widget, and every word
674
+ at 4.5:1 or more (the lowest 4.85) in all seven themes light and dark, the drawer's too. `popup-windows.cjs` unchanged.
675
+
676
+ ### A row's worked-out input, typed in: a line's total sets its rate
677
+
678
+ Card C-rows-solve (2026-11-10), the follow-up C-editable-calculated-amount named: the owner's "if i put final 2500 inside
679
+ the calculate amount then it ... set the price", for a table of lines (Inventory's Dakhila). Additions only: 1 export
680
+ (`withComputeSources`, `nexus-shared`), and new members of exports already listed (`RowsStore.setFromPerson`,
681
+ `RowsInputProps.computeWith`, `crudInputs.lineAmount`, `PriceKeys.other`, `NetAmountOptions.taxed`). **One behaviour
682
+ changes:** a row input with `compute` was ignored in rows (drawn as a plain input holding a value); it is now worked out
683
+ on each row and holds none.
684
+
685
+ - **`compute` works in a tabular or group form's rows**, as in a form: `from` names the row's inputs, `values` is the
686
+ row, `withValue` is the rows' new **`computeWith`** (a `Form` hands the rows its own unless their params name one;
687
+ a new one draws every row's worked-out cells again). Read-only by default (an `<output>` on the read-only tint, no Tab
688
+ stop, named "Amount, line 2" in a table); with `solve` a Number is a box that can be typed in. A table has no line under
689
+ a cell, so the note ("Line 3: Rate set to 2,458.21.", or the nearest, or a refusal in red) goes in the line under the
690
+ rows until the figure moves on, and a hidden live region in the cell says it. Enter with a new figure sets it and
691
+ stays; with nothing new it moves down the column (on the last line it adds one), as from any cell. Escape puts the
692
+ figure back.
693
+ - **The rows hold no value under a worked-out name** (as `FormStore`'s `isComputedInput`): a loaded row's field of that
694
+ name is dropped, a value from code, an `onRowChange` or `deriveRows` patch, `newRow` or a new row's values naming it is
695
+ not taken, it has no rule and is never remembered, so the rows' value, `isChanged`, Save and the leave guard never see it.
696
+ - **`compute.from` joins the row input's `dependsOn`** (`withComputeSources`), so the row draws again when a source changes.
697
+ - **`RowsStore.setFromPerson(key, values)`**: a person's change of a row made through another cell, written as code
698
+ writes (the cells mount again with it) and followed as typing is (`onRowChange` runs for each value, narrowed followers
699
+ clear, value listeners hear it, so a parent form has unsaved changes and its leave guard asks).
700
+ - **`ComputedInput`** (inside the package, `Components/Inputs/ComputedInput.tsx`) is the one worked-out box, lifted out
701
+ of `Form.tsx` with `read`/`write` callbacks; `Form` and the rows' cells both draw it, and the Form behaves as before
702
+ (`form-figures.cjs` unchanged, 156 in each motion mode). `formInputLabel` moved with it and is still exported from the
703
+ same place.
704
+ - **Found and fixed on the way, in Form too:** a click straight into the Price after typing a total dropped the focus to
705
+ the page, because the Price box mounts again with its new value; it now lands in the box that took its place.
706
+ - **`crudInputs.lineAmount(keys?, changes?, options?)`**: `netAmount` for a table of lines - the rate (`unitPrice`)
707
+ times the `quantity`, less the discount, plus VAT - as a 9rem column, typed in per row with `priceFromNet`.
708
+ **`PriceKeys.other`** adds an amount whole after VAT (a line's other costs, rounded to 2 places: the Inventory
709
+ service's `StockMaths.Line`), taken off a typed total before the rate is worked out ("The amount cannot be less than
710
+ the other costs."). **`NetAmountOptions.taxed(withValue, values)`** says when the discount and VAT count (a purchase's
711
+ lines, not a donation's), read from `computeWith`.
712
+ - **An `<output>`'s text fills its box** (`.nx-field__output-text { flex: 1 }`), so the box's `text-align` places it: at
713
+ the end of a table's number column, as a typed figure is.
714
+ - Proved by `test-rows-store.mts` (section Z: no value held, `dependsOn` joined, `setFromPerson` with `onRowChange`, the
715
+ value listeners and a remount, other costs, `taxed`), the CSS contract, and nexus.template's `/demo/tabular-form` "A
716
+ goods receipt" with `rows-solve.cjs` (50 in each motion mode): figures on open and per keystroke, exact (Enter) and
717
+ nearest (Tab), Escape, a refusal, Tab order, a click into the Rate, Donation through the form's `computeWith`, Save
718
+ sending neither worked-out figure; 7 themes light and dark at 1280 and 390 wide, the Amount and the note at 4.5:1 or
719
+ more. `tabular-form.cjs` (85), `group-form.cjs` (61), `form-confirm.cjs` (80) and `input-memory.cjs` (22) unchanged.
720
+
721
+ ### The mail client's fixes, and a Crud form's computeWith
722
+
723
+ Card C-mail-client-fix (2026-11-10), the four gaps nexus.web's Message › Inbox (card MAIL-client-pages) found in the mail
724
+ client, and the gap C-rows-solve left for a Crud page (Inventory's Dakhila). Additions only: 5 exports (`nexus-shared`:
725
+ `followMailReadMarks`, `followMailboxReadMarks`, `MailReadMarks`, `crudComputeWith`, `sameShallow`) and new members of
726
+ exports already listed (`MailClientProps.title` may be `false`, `.description`, `.headingLevel`;
727
+ `CrudFormOptions.computeWith`). **Two behaviours change:** a `threadId` that names a chat opens its chat window, and the
728
+ client's folder counts and its `onChanged` wait for the read marks on their way.
729
+
730
+ - **A long list no longer makes the page scroll.** `.nx-mailbox__rows` scrolled but was not positioned, so each unread
731
+ row's visually hidden ", unread" (`nx-visually-hidden` is `position: absolute`) was placed against the page at its
732
+ row's place down the list: 126 unread rows made the demo page 8,253px tall instead of 3,286px, and nexus.web's Inbox
733
+ taller than the window. Every scroller of the client and the chat is now `position: relative` (the list, the rail, the
734
+ reader's body and record card; the chat's thread, list, New chat, mentions and people), and the CSS contract holds
735
+ every rule of `Nexus.Mail.css` and `Nexus.Chat.css` that scrolls to setting a position (9 today).
736
+ - **A link to a chat opens its window.** A `threadId` the page sets (the bell's `?thread=`, an e-mail copy's) that names
737
+ a chat opens its chat window as a click on its row does - once its kind is known, from its row in the list or from its
738
+ head when the list shown does not hold it - once per opening, so a window closed stays closed until the row is clicked.
739
+ - **The counts wait for the read mark.** A conversation shows read as soon as it opens, before its read mark reaches the
740
+ server; the client read its counts at once, so the Unread folder stayed one too high (nexus.web's `mailbox.ts` waited
741
+ for its marks itself). The reader's and the chat windows' read marks now go through `followMailboxReadMarks`: the
742
+ folder counts are read once no mark is on its way, and read again (with `onChanged`, the page's bell) when each is
743
+ answered. A page needs no wrapper of its own for its counts.
744
+ - **One heading on a page.** `title={false}` draws no title in the bar (the search starts it), for a page whose own
745
+ heading names the client (nexus-admin's `AdminPage` with the menu row's words); `description` draws a sentence beside
746
+ the title, clipped to the rail's width with the whole of it on hover; `headingLevel` (default 2) makes it the page's h1.
747
+ - **`CrudFormOptions.computeWith(values, { action, row })`**: what a Crud's Add and Edit forms hand their worked-out
748
+ inputs (`compute`, and the rows' `crudInputs.lineAmount` with `taxed`), worked out from the form's own values after
749
+ every change and before the browser paints. The answer is compared field by field with the last (`sameShallow`), so a
750
+ fresh object of the same figures redraws nothing; a new one redraws the worked-out cells alone, with nothing remounted
751
+ and the focus kept. A rule that throws keeps its last answer and says why in the console (`crudComputeWith`).
752
+ - Proved by `test-mail-client.mts` (112: a mark held while on its way, let go and counted again once answered or refused,
753
+ every other door kept), `test-table.mts` (490: `crudComputeWith`, `sameShallow`), the CSS contract (3,035), and
754
+ nexus.template's browser checks on a production build of their own: `mail-client.cjs` 92 (new M: 126 unread rows keep
755
+ the page's height, every hidden word inside the client, the client as the window leaves the document no taller than it;
756
+ N: a link opens the chat's window, from its head when not listed, a closed one stays closed, a row click brings it back;
757
+ O: with read marks 1.5s slower than a count, the Unread folder still drops by one; P: one heading, the bar one height),
758
+ `rows-solve.cjs` 56 in each motion mode (new L: the receipt on a Crud page, `/demo/tabular-form` "The same receipt on a
759
+ Crud page", Source switched from the keyboard redraws the lines with the same inputs and the focus kept, a donation's
760
+ line typed in, the save's total), and `form-figures.cjs` 156 in each motion mode, unchanged.
761
+
762
+ ### Details as the 2026-06-20 window drew them, a list of rows as a table, history names from the app
763
+
764
+ Card component-details-0620-look (2026-11-10), the owner's asks 1, 2 and 6 for the subscriber window, built for every
765
+ page with a details view.
766
+
767
+ - **The built-in details look** (`CrudDetails`: a Crud's details popup and workspace tab, a tree's panel, a form's
768
+ question): each field a label and its value in a box - a `--nx-border` hairline on `--nx-surface-2` - two pairs to a
769
+ line where the details are 46rem or wider and one where they are not, the label over its value under 20rem. The details
770
+ are their own size container (`nx-crud-details`), so a popup, a tab, a column beside a panel and a question's box each
771
+ get the look their width allows; every label has one width, so the values of both columns and of a field across the
772
+ line start on shared edges, and a label sits on its value's first line. Sections from the columns' `group` get a
773
+ numbered heading, "01. Contact", "02. Account", in the order they come. A `wrap` or Markdown column takes the whole
774
+ line. A form's question (`confirm`) now shows two fields to a line on a wide box, not three.
775
+ - **`TableColumn.table?: readonly TableColumn[]`**: the value is a list of rows (a subscriber's contacts, devices). The
776
+ details draw it as a small read-only table across the line - a header row, each row's number first, an option's tone
777
+ as its tag, a boolean as Yes or No in words - and say "None" for an empty list. A data table, an export and a search
778
+ show how many rows it holds (`formatCellText`).
779
+ - **`setAuditActorNames(resolver | null)`** and its type **`AuditActorNamesResolver`** = `(tokens: string[], signal:
780
+ AbortSignal) => Promise<Record<string, string>>` (`nexus-shared`): how `AuditHistory` names the person behind an entry
781
+ its answer names nobody for. It asks once for each page it shows, only for tokens it has had no answer for, and says
782
+ "by {the answer}" ("by Sita Rai (sita.rai)"); answers are kept for the page's life, a failed call keeps
783
+ "User 3fA9b2…" and is asked again by the next history, a token left out of the answer keeps it. The answer's own
784
+ `creatorName` still comes first.
785
+ - **A workspace's popup footer is the Details tab's alone.** View history, Edit, Close and the load's line show only
786
+ while the built-in Details tab is chosen; every other tab runs to the popup's foot and has its own `CrudTabFooter`. The
787
+ title bar's X and Esc still ask about unsaved changes, as do the footer's Edit and Close on Details. A details popup
788
+ without a workspace is unchanged.
789
+ - **A section that changes on its own** (`details.sections: [{ group, edit, label?, hidden? }]`, type
790
+ **`CrudDetailsSection`**): in a workspace's Details tab, a small ghost Edit at the end of the section's heading opens
791
+ the Crud's own Edit form (`edit: "edit"`) or one of its `rowForms` (`edit: "<id>"`) over the workspace, offered by the
792
+ row menu's rules (a list that offers Edit, the endpoint allowed, no lock); once saved the workspace reads the record
793
+ again (`refresh()`), the Details are drawn again in place, and the focus is back on the button. `CrudDetails` takes
794
+ `headingEnd(group)` for anything at the end of a heading, and follows a new `row` in place.
795
+ - **`openCrudForm`** and its **`CrudFormRequest`** are exported from `nexus-shared/client`: a controller's Add or Edit
796
+ form in a popup, opened from anywhere.
797
+ - **The workspace's profile picture is a circle** (`ProfilePicture`'s new `round`; the pictures window keeps the
798
+ picture's own shape and aspect).
799
+ - Proved by `test-audit-entries.mts` (25), `test-table.mts` (491), the CSS contract, and nexus.template's
800
+ `crud-workspace.cjs`, `form-confirm.cjs` and `crud-row-form.cjs` on a production build.
801
+
7
802
  ## 4.0.0
8
803
 
9
804
  **Breaking: an input's `type` is a number, a member of `EInputTypes`, and a string is refused.** Everything else in