pict-section-form 1.0.196 → 1.0.198

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 (55) hide show
  1. package/docs/Comprehensions.md +181 -0
  2. package/docs/Comprehensions_Advanced.md +295 -0
  3. package/docs/Configuration.md +17 -0
  4. package/docs/Layouts.md +170 -0
  5. package/docs/Pict_Section_Form_Architecture.md +2 -2
  6. package/docs/README.md +1 -1
  7. package/docs/Solvers.md +66 -8
  8. package/docs/_brand.json +18 -0
  9. package/docs/_cover.md +1 -1
  10. package/docs/_sidebar.md +2 -0
  11. package/docs/_version.json +7 -0
  12. package/docs/examples/README.md +22 -16
  13. package/docs/examples/gradebook/README.md +298 -150
  14. package/docs/index.html +6 -7
  15. package/docs/input_providers/005-precise-number.md +5 -5
  16. package/docs/input_providers/009-chart.md +1 -1
  17. package/docs/input_providers/011-autofill-trigger-group.md +4 -4
  18. package/docs/input_providers/013-tab-section-selector.md +1 -1
  19. package/docs/input_providers/README.md +2 -2
  20. package/docs/retold-catalog.json +45 -5
  21. package/docs/retold-keyword-index.json +6763 -4709
  22. package/example_applications/authortopia/html/index.html +5 -5
  23. package/example_applications/change_tracking/html/index.html +4 -4
  24. package/example_applications/complex_table/.claude/launch.json +11 -0
  25. package/example_applications/complex_table/Complex-Tabular-Application.js +31 -0
  26. package/example_applications/complex_table/html/index.html +5 -5
  27. package/example_applications/complex_tuigrid/html/index.html +4 -4
  28. package/example_applications/dynamic_analysis/html/index.html +7 -7
  29. package/example_applications/gradebook/.quackage.json +9 -0
  30. package/example_applications/gradebook/Gradebook-Application.js +441 -0
  31. package/example_applications/gradebook/GradebookData.json +44 -0
  32. package/example_applications/gradebook/html/index.html +95 -0
  33. package/example_applications/gradebook/package.json +26 -0
  34. package/example_applications/ndt_field_test/html/index.html +9 -9
  35. package/example_applications/postcard_example/css/postcard.css +12 -12
  36. package/example_applications/postcard_example/css/pure.min.css +1 -1
  37. package/example_applications/scope_mathematics/Scope-Mathematics_Manifest.json +1 -1
  38. package/example_applications/scope_mathematics/html/index.html +4 -4
  39. package/example_applications/simple_distill/html/index.html +4 -4
  40. package/example_applications/simple_form/html/index.html +4 -4
  41. package/example_applications/simple_table/html/index.html +4 -4
  42. package/package.json +7 -6
  43. package/source/providers/Pict-Provider-DynamicFormSolverBehaviors.js +326 -1
  44. package/source/providers/Pict-Provider-DynamicTemplates.js +4 -0
  45. package/source/providers/dynamictemplates/Pict-DynamicTemplates-DefaultFormTemplates-ReadOnly.js +4 -4
  46. package/source/providers/dynamictemplates/Pict-DynamicTemplates-DefaultFormTemplates.js +54 -4
  47. package/source/providers/layouts/Pict-Layout-Tabular.js +936 -4
  48. package/source/services/ManifestFactory.js +276 -0
  49. package/source/templates/Pict-Template-TabularEditingControls.js +58 -0
  50. package/source/templates/Pict-Template-TabularRowLabels.js +60 -0
  51. package/source/views/Pict-View-Form-Metacontroller.js +8 -0
  52. package/source/views/support/Pict-Provider-PSF-Support.js +12 -12
  53. package/test/PictSectionForm-Basic_tests.js +138 -0
  54. package/test/PictSectionForm-Tabular-Features_tests.js +960 -0
  55. package/docs/css/docuserve.css +0 -73
@@ -0,0 +1,181 @@
1
+ # Comprehensions
2
+
3
+ The `addComprehensionEntity` solver function builds **multi-context, multi-entity
4
+ comprehensions** from form data — a JSON shape that can be inspected, diffed,
5
+ and pushed to a Meadow REST API via
6
+ [`meadow-integration load_comprehension`](https://github.com/stevenvelozo/meadow-integration).
7
+
8
+ Think of it as the "save side" of a form: a single function call lays down one
9
+ property of one record under one workflow context, and many calls compose into a
10
+ single nested tree that a downstream pipeline can read in one go.
11
+
12
+ ## Signature
13
+
14
+ ```
15
+ addComprehensionEntity(Context, Entity, GUID, Property, Value)
16
+ ```
17
+
18
+ | Parameter | Type | Meaning |
19
+ |---|---|---|
20
+ | `Context` | string (manyfest address) | Workflow bucket — `"OnSave"`, `"OnApprovalAction.Approve"`, etc. Dots create nested branches. |
21
+ | `Entity` | string | The entity name — `"Book"`, `"Recipe"`, `"Fruit"`. Opaque key (not parsed). |
22
+ | `GUID` | string | External GUID for the record. Opaque key (dots are NOT interpreted). |
23
+ | `Property` | string | The field to set on the record. Opaque key. |
24
+ | `Value` | any | The value to write. Strings, numbers, booleans, objects, arrays. |
25
+
26
+ Successive calls to the same `(Context, Entity, GUID)` **accumulate properties**
27
+ on the same record. Successive calls to the same
28
+ `(Context, Entity, GUID, Property)` **overwrite**.
29
+
30
+ ## The shape it builds
31
+
32
+ Given these solvers on a Book form:
33
+
34
+ ```js
35
+ "Solvers":
36
+ [
37
+ `addComprehensionEntity("OnSave", "Book", BookGUID, "Title", BookTitle)`,
38
+ `addComprehensionEntity("OnSave", "Book", BookGUID, "Author", BookAuthor)`,
39
+ `addComprehensionEntity("OnSave", "Book", BookGUID, "ISBN", BookISBN)`,
40
+ `addComprehensionEntity("OnApprovalAction.Submit", "Book", BookGUID, "Status", "Submitted")`,
41
+ `addComprehensionEntity("OnApprovalAction.Approve", "Book", BookGUID, "Status", "Approved")`
42
+ ]
43
+ ```
44
+
45
+ …the destination ends up looking like:
46
+
47
+ ```json
48
+ {
49
+ "OnSave": {
50
+ "Book": {
51
+ "0x73278432987": {
52
+ "Title": "The Giving Tree",
53
+ "Author": "Shel Silverstein",
54
+ "ISBN": "8675309"
55
+ }
56
+ }
57
+ },
58
+ "OnApprovalAction": {
59
+ "Submit": {
60
+ "Book": {
61
+ "0x73278432987": { "Status": "Submitted" }
62
+ }
63
+ },
64
+ "Approve": {
65
+ "Book": {
66
+ "0x73278432987": { "Status": "Approved" }
67
+ }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ Every leaf in this tree is a Meadow-shaped record keyed by external GUID — the
74
+ exact format [`load_comprehension`](https://github.com/stevenvelozo/meadow-integration)
75
+ expects.
76
+
77
+ ## Where the result lands
78
+
79
+ By default the tree is written to `AppData.FormEntityComprehensions`.
80
+
81
+ The destination is **a manyfest address** resolved against the pict instance, so
82
+ addresses like `AppData.X.Y`, `Bundle.X`, etc. all work. Change it on the
83
+ metacontroller:
84
+
85
+ ```js
86
+ // At any point after the metacontroller is registered (i.e. inside the
87
+ // application's constructor, after super() has run):
88
+ this.pict.views.PictFormMetacontroller.comprehensionDestinationAddress = 'AppData.MyWorkflowComprehensions';
89
+ ```
90
+
91
+ …or pass it in the metacontroller view options if you're constructing the
92
+ metacontroller manually:
93
+
94
+ ```js
95
+ pict.addView('PictFormMetacontroller', { ComprehensionDestinationAddress: 'AppData.MyWorkflowComprehensions' },
96
+ libPictSectionForm.PictFormMetacontroller);
97
+ ```
98
+
99
+ If the address resolves to nothing, the function materializes an object there on
100
+ the first write. If it resolves to a non-object scalar, the call logs a warning
101
+ and bails (it won't overwrite a number with an object).
102
+
103
+ ## Basic example — flat OnSave context
104
+
105
+ ```js
106
+ "Sections":
107
+ [
108
+ {
109
+ "Hash": "BookEditor",
110
+ "Solvers":
111
+ [
112
+ `addComprehensionEntity("OnSave", "Book", BookGUID, "Title", BookTitle)`,
113
+ `addComprehensionEntity("OnSave", "Book", BookGUID, "Author", BookAuthor)`,
114
+ `addComprehensionEntity("OnSave", "Book", BookGUID, "Status", "New")`
115
+ ]
116
+ }
117
+ ]
118
+ ```
119
+
120
+ After a solve, `AppData.FormEntityComprehensions.OnSave.Book[<BookGUID>]` holds
121
+ the three properties.
122
+
123
+ ## Quick gotchas
124
+
125
+ 1. **Empty GUIDs bail.** If any of `Context`, `Entity`, `GUID`, or `Property`
126
+ resolves to `null`, `undefined`, or the empty string, the call logs a warning
127
+ and returns `undefined`. Recipes with an empty `RecipeName` will not silently
128
+ create a `""` bucket — they just no-op until the user fills the name in.
129
+ 2. **Solver ordinals.** Solvers run in ascending ordinal order. Put your
130
+ `addComprehensionEntity` calls *after* any solvers they depend on (e.g. after
131
+ the `TotalCalories = SUM(...)` aggregate they read from). The complex_table
132
+ example uses ordinals 200–220 to keep them after the default-ordinal compute
133
+ solvers.
134
+ 3. **Re-solves overwrite.** Each solve re-runs every solver, so each
135
+ `addComprehensionEntity` call overwrites the property it wrote last time
136
+ with the current value. This is what you want — the comprehension always
137
+ reflects the current form state.
138
+ 4. **The destination is *not* cleared between solves.** If your form removes a
139
+ record (e.g. deletes a row from a grid), the previous comprehension for that
140
+ record stays behind. If that matters for your workflow, reset the destination
141
+ at the start of the solve cycle (`AppData.FormEntityComprehensions = {}`) or
142
+ in `marshalFromView` before the comprehension solvers fire.
143
+
144
+ ## Pushing the result
145
+
146
+ Once the comprehension is built, push it via meadow-integration:
147
+
148
+ ```js
149
+ // Browser-side -- POST the AppData blob directly to the Comprehension/Push REST endpoint.
150
+ fetch('/1.0/Comprehension/Push',
151
+ {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/json' },
154
+ body: JSON.stringify(
155
+ {
156
+ Comprehension: pict.AppData.FormEntityComprehensions.OnSave,
157
+ GUIDPrefix: 'MYAPP'
158
+ })
159
+ });
160
+ ```
161
+
162
+ Or write to a file and push from a CLI:
163
+
164
+ ```bash
165
+ npx meadow-integration load_comprehension out.json --prefix MYAPP
166
+ ```
167
+
168
+ See [meadow-integration: Comprehensions](https://github.com/stevenvelozo/meadow-integration/blob/main/docs/comprehensions.md)
169
+ for the full push semantics — GUID marshaling, foreign-key resolution, batch
170
+ upserts, idempotency.
171
+
172
+ ## See also
173
+
174
+ - [Advanced patterns](Comprehensions_Advanced.md) — mixing hashes and direct
175
+ addresses, computed contexts, per-row `MAP VAR` generation, customized
176
+ destinations.
177
+ - [Solvers](Solvers.md) — full solver function reference.
178
+ - The [Complex Table example](../example_applications/complex_table/Complex-Tabular-Application.js)
179
+ builds a complete `RecipeWorkflowComprehensions` tree with `OnSave` and
180
+ `OnApprovalAction.{Submit,Approve}` contexts off the Recipe section and the
181
+ FruitGrid recordset.
@@ -0,0 +1,295 @@
1
+ # Comprehensions — Advanced patterns
2
+
3
+ This document goes deeper than [Comprehensions](Comprehensions.md):
4
+
5
+ - **Hash vs. address arguments** — when to write `BookGUID` (bare symbol),
6
+ `"AppData.Bundle.BookGUID"` (string address), `getvalue("...")` (explicit
7
+ lookup), and when to mix them.
8
+ - **Computed contexts** — `IF`/ternary results as the `Context` argument so a
9
+ single solver routes between `OnApprovalAction.Submit` and
10
+ `OnApprovalAction.Approve`.
11
+ - **Per-row generation** with `MAP VAR` over a recordset.
12
+ - **Customized destinations** at the metacontroller level.
13
+ - **Resetting between solves**.
14
+
15
+ The complete worked example for everything here lives at
16
+ [`example_applications/complex_table/Complex-Tabular-Application.js`](../example_applications/complex_table/Complex-Tabular-Application.js)
17
+ — if you only read one thing, read that file. This page explains the *why* behind
18
+ the patterns it uses.
19
+
20
+ ## Argument resolution — hashes, addresses, and quoted strings
21
+
22
+ The solver expression parser treats each argument to `addComprehensionEntity`
23
+ the same way it would treat any other function argument:
24
+
25
+ | Argument form | What happens |
26
+ |---|---|
27
+ | `BookGUID` | **Bare symbol** — resolved from the form's manifest. Looked up first by descriptor hash, then by address against the marshal destination. |
28
+ | `Record.GUID` / `AppData.Bundle.X` | **Dotted symbol** — resolved as an address (the parser does NOT need quotes around addresses). |
29
+ | `"OnSave"` / `"Book"` | **Quoted string** — taken literally, no resolution. |
30
+ | `getvalue("AppData.X.Y")` | **Explicit lookup** — useful when you want to force address-resolution semantics on a value built up from other solvers. |
31
+ | `IF(...)` / `CONCAT(...)` | **Nested function call** — the inner function's return value becomes the argument. |
32
+
33
+ In practice you mix freely:
34
+
35
+ ```js
36
+ "Solvers":
37
+ [
38
+ // Context and Property are literals; Entity is a literal; GUID and Value
39
+ // are resolved from form data.
40
+ `addComprehensionEntity("OnSave", "Book", BookGUID, "Title", BookTitle)`,
41
+
42
+ // Same shape, but the GUID and Value come from absolute addresses rather
43
+ // than descriptor hashes. Useful if the descriptor hashes haven't been
44
+ // wired up or the data lives outside the form.
45
+ `addComprehensionEntity("OnSave", "Book", AppData.SelectedBook.IDBook, "Status", AppData.SelectedBook.Status)`,
46
+
47
+ // Pull a value through a getvalue() call -- equivalent to the line above
48
+ // but with explicit resolution syntax. Use this form when an inner
49
+ // expression already produces an address string and you want to evaluate it.
50
+ `addComprehensionEntity("OnSave", "Book", getvalue("AppData.SelectedBook.IDBook"), "Status", getvalue("AppData.SelectedBook.Status"))`,
51
+
52
+ // The property name itself is computed. CONCAT returns a string, which
53
+ // becomes the Property argument.
54
+ `addComprehensionEntity("OnSave", "Book", BookGUID, CONCAT("Field_", FieldType), FieldValue)`
55
+ ]
56
+ ```
57
+
58
+ **Rule of thumb:** quote when you want a literal, leave unquoted when you want
59
+ the parser to look the symbol up. The first three arguments are almost always
60
+ literal strings (Context, Entity name) plus one resolved value (GUID); the
61
+ fourth is almost always a literal Property; the fifth is almost always resolved.
62
+
63
+ ## Computed contexts — routing with `IF`
64
+
65
+ The `Context` argument is a manyfest address. It's also just a string that the
66
+ function uses to walk a nested object — which means a *computed* string works
67
+ fine. The complex_table example routes between `OnApprovalAction.Submit` and
68
+ `OnApprovalAction.Approve` based on a `Proprietary` boolean:
69
+
70
+ ```js
71
+ { Ordinal: 220, Expression:
72
+ `addComprehensionEntity(
73
+ IF(Proprietary, "==", 1, "OnApprovalAction.Submit", "OnApprovalAction.Approve"),
74
+ "Recipe",
75
+ RecipeName,
76
+ "Status",
77
+ IF(Proprietary, "==", 1, "Submitted", "Approved")
78
+ )`
79
+ }
80
+ ```
81
+
82
+ Both `Submit` and `Approve` branches sit under `OnApprovalAction`, which lets
83
+ downstream code key off `Object.keys(comprehension.OnApprovalAction)` to discover
84
+ which actions fired this solve.
85
+
86
+ The same trick scales to richer routing — e.g. context-per-environment:
87
+
88
+ ```js
89
+ `addComprehensionEntity(
90
+ CONCAT("OnSave.", EnvironmentName),
91
+ "Recipe", RecipeName, "Status", "Saved"
92
+ )`
93
+ ```
94
+
95
+ This produces `OnSave.Production.Recipe.<name>.Status` or
96
+ `OnSave.Staging.Recipe.<name>.Status` depending on the value of
97
+ `EnvironmentName`.
98
+
99
+ ## Per-row generation with `MAP VAR`
100
+
101
+ `MAP VAR` iterates a recordset and fires the body expression once per row, with
102
+ the row bound to a name you choose (`row` is the convention). Combined with
103
+ `addComprehensionEntity`, this fans one solver across an entire grid:
104
+
105
+ ```js
106
+ // From complex_table -- the Recipe section's solvers reach into the FruitGrid
107
+ // recordset and emit one OnSave.Fruit.<name>.<property> entry per (fruit, property)
108
+ // pair. Three MAP VARs produce 3 * N comprehension writes in a single solve.
109
+ { Ordinal: 210, Expression: `MAP VAR row FROM FruitData.FruityVice : addComprehensionEntity("OnSave", "Fruit", row.name, "Family", row.family)` },
110
+ { Ordinal: 210, Expression: `MAP VAR row FROM FruitData.FruityVice : addComprehensionEntity("OnSave", "Fruit", row.name, "Order", row.order)` },
111
+ { Ordinal: 210, Expression: `MAP VAR row FROM FruitData.FruityVice : addComprehensionEntity("OnSave", "Fruit", row.name, "Calories", row.nutritions.calories)` }
112
+ ```
113
+
114
+ Inside the body, `row.X.Y` resolves against each row in turn — so you get
115
+ deep-property access without writing per-row solvers.
116
+
117
+ After the solve runs against the bundled FruityVice data, the comprehension at
118
+ `AppData.RecipeWorkflowComprehensions.OnSave.Fruit` looks like:
119
+
120
+ ```json
121
+ {
122
+ "Apple": { "Family": "Rosaceae", "Order": "Rosales", "Calories": "52" },
123
+ "Banana": { "Family": "Musaceae", "Order": "Zingiberales", "Calories": "96" },
124
+ "Mango": { "Family": "Anacardiaceae", "Order": "Sapindales", "Calories": "60" },
125
+ ...
126
+ }
127
+ ```
128
+
129
+ (49 fruits total in the complex_table dataset.)
130
+
131
+ If you need one comprehension write per row that touches *every* property of the
132
+ row, you have two reasonable options:
133
+
134
+ 1. Multiple `MAP VAR` solvers, one per property (as above). Clear and easy to
135
+ audit.
136
+ 2. A single helper solver function registered on
137
+ `DynamicFormSolverBehaviors` that takes a row + property list and calls
138
+ `addComprehensionEntity` internally. Worth the indirection only if you have
139
+ many entities with many properties; otherwise just write the explicit
140
+ `MAP VAR`s.
141
+
142
+ ## Customizing the destination
143
+
144
+ Each metacontroller has a `comprehensionDestinationAddress` property —
145
+ mirroring the existing `viewMarshalDestination` knob — that controls where
146
+ `addComprehensionEntity` writes. The default is `AppData.FormEntityComprehensions`.
147
+
148
+ ### Option 1: in the application constructor
149
+
150
+ This is what the [complex_table example](../example_applications/complex_table/Complex-Tabular-Application.js)
151
+ does. After `super()` (which registers the metacontroller view via
152
+ `PictFormApplication`), set the destination directly:
153
+
154
+ ```js
155
+ class MyWorkflowApplication extends libPictSectionForm.PictFormApplication
156
+ {
157
+ constructor(pFable, pOptions, pServiceHash)
158
+ {
159
+ super(pFable, pOptions, pServiceHash);
160
+ this.pict.views.PictFormMetacontroller.comprehensionDestinationAddress = 'AppData.WorkflowComprehensions';
161
+ }
162
+ }
163
+ ```
164
+
165
+ ### Option 2: via metacontroller options
166
+
167
+ If you're registering the metacontroller view yourself (no `PictFormApplication`
168
+ parent), pass `ComprehensionDestinationAddress` in the options:
169
+
170
+ ```js
171
+ pict.addView(
172
+ 'PictFormMetacontroller',
173
+ { ComprehensionDestinationAddress: 'Bundle.PendingWrites' },
174
+ libPictSectionForm.PictFormMetacontroller
175
+ );
176
+ ```
177
+
178
+ ### Option 3: change it mid-flight
179
+
180
+ The property is a plain string -- reassign whenever you need different
181
+ destinations for different phases:
182
+
183
+ ```js
184
+ // Before fanning the form's solvers, redirect to a transient staging slot.
185
+ this.pict.views.PictFormMetacontroller.comprehensionDestinationAddress = 'TempData.PendingComprehension';
186
+ this.pict.PictApplication.solve();
187
+ const tmpPending = this.pict.TempData.PendingComprehension;
188
+
189
+ // Promote / discard / inspect tmpPending however you like.
190
+
191
+ // Switch back to the canonical destination for the next solve.
192
+ this.pict.views.PictFormMetacontroller.comprehensionDestinationAddress = 'AppData.WorkflowComprehensions';
193
+ ```
194
+
195
+ The destination address is resolved against the pict instance, so any subtree
196
+ works (`AppData.*`, `Bundle.*`, `TempData.*`, ...).
197
+
198
+ ## Resetting the tree between solves
199
+
200
+ `addComprehensionEntity` never deletes keys -- it only writes / overwrites. If
201
+ your form workflow expects "only emit comprehensions for *currently visible*
202
+ records," you need to clear the destination at the start of the solve cycle.
203
+ Two patterns:
204
+
205
+ ### Pattern A: low-ordinal reset solver
206
+
207
+ ```js
208
+ "Solvers":
209
+ [
210
+ // Runs before any addComprehensionEntity calls because of the explicit
211
+ // low ordinal. `getvalue` returns a reference to the live AppData branch,
212
+ // but assigning to `AppData.X` rebinds the address. In manyfest assignments
213
+ // we want to nuke the previous tree, so explicitly empty it.
214
+ { Ordinal: 1, Expression: `AppData.RecipeWorkflowComprehensions = "{}"` },
215
+ // ...then the addComprehensionEntity calls at higher ordinals
216
+ ]
217
+ ```
218
+
219
+ The string `"{}"` becomes `{}` after JSON round-trip on the assignment. If your
220
+ solver dialect doesn't coerce strings to JSON for you, do the reset in
221
+ JavaScript instead:
222
+
223
+ ### Pattern B: JS-side reset
224
+
225
+ Override `marshalFromView` or `onBeforeSolve` to zero the destination:
226
+
227
+ ```js
228
+ class MyWorkflowApplication extends libPictSectionForm.PictFormApplication
229
+ {
230
+ onBeforeSolve()
231
+ {
232
+ this.pict.AppData.RecipeWorkflowComprehensions = {};
233
+ return super.onBeforeSolve();
234
+ }
235
+ }
236
+ ```
237
+
238
+ This is the simplest version. The `addComprehensionEntity` resolver handles a
239
+ missing-or-emptied destination by re-materializing it on the next write.
240
+
241
+ ## Full reference: the complex_table sample config
242
+
243
+ The [complex_table example](../example_applications/complex_table/Complex-Tabular-Application.js)
244
+ exercises every pattern on this page in one application. The relevant pieces:
245
+
246
+ ```js
247
+ // Constructor sets a customized destination.
248
+ this.pict.views.PictFormMetacontroller.comprehensionDestinationAddress = 'AppData.RecipeWorkflowComprehensions';
249
+ ```
250
+
251
+ ```js
252
+ // Recipe section solvers -- mix of bare-symbol GUID (RecipeName) and address
253
+ // arguments, multiple OnSave properties, MAP VAR fanning over a recordset,
254
+ // and IF-routed OnApprovalAction.
255
+ Solvers:
256
+ [
257
+ // ...prior compute solvers...
258
+
259
+ // OnSave.Recipe.<RecipeName>.<Property> for the recipe-level facts.
260
+ { Ordinal: 200, Expression: `addComprehensionEntity("OnSave", "Recipe", RecipeName, "Name", RecipeName)` },
261
+ { Ordinal: 200, Expression: `addComprehensionEntity("OnSave", "Recipe", RecipeName, "Type", RecipeType)` },
262
+ { Ordinal: 200, Expression: `addComprehensionEntity("OnSave", "Recipe", RecipeName, "Description", RecipeDescription)` },
263
+ { Ordinal: 200, Expression: `addComprehensionEntity("OnSave", "Recipe", RecipeName, "Inventor", Inventor)` },
264
+ { Ordinal: 200, Expression: `addComprehensionEntity("OnSave", "Recipe", RecipeName, "TotalCalories", TotalFruitCalories)` },
265
+ { Ordinal: 200, Expression: `addComprehensionEntity("OnSave", "Recipe", RecipeName, "AverageFatPercent", AverageFatPercent)` },
266
+
267
+ // OnSave.Fruit.<fruit>.<Property> -- per-row via MAP VAR over the FruitGrid recordset.
268
+ { Ordinal: 210, Expression: `MAP VAR row FROM FruitData.FruityVice : addComprehensionEntity("OnSave", "Fruit", row.name, "Family", row.family)` },
269
+ { Ordinal: 210, Expression: `MAP VAR row FROM FruitData.FruityVice : addComprehensionEntity("OnSave", "Fruit", row.name, "Order", row.order)` },
270
+ { Ordinal: 210, Expression: `MAP VAR row FROM FruitData.FruityVice : addComprehensionEntity("OnSave", "Fruit", row.name, "Calories", row.nutritions.calories)` },
271
+
272
+ // OnApprovalAction.{Submit,Approve}.Recipe.<RecipeName> -- computed context.
273
+ { Ordinal: 220, Expression: `addComprehensionEntity(IF(Proprietary, "==", 1, "OnApprovalAction.Submit", "OnApprovalAction.Approve"), "Recipe", RecipeName, "Status", IF(Proprietary, "==", 1, "Submitted", "Approved"))` },
274
+ { Ordinal: 220, Expression: `addComprehensionEntity(IF(Proprietary, "==", 1, "OnApprovalAction.Submit", "OnApprovalAction.Approve"), "Recipe", RecipeName, "Reviewer", Inventor)` }
275
+ ]
276
+ ```
277
+
278
+ After loading the example, fill in the Recipe section and toggle the
279
+ Proprietary checkbox; inspect `_Pict.AppData.RecipeWorkflowComprehensions` in
280
+ the browser console to see the OnSave / OnApprovalAction subtrees update.
281
+
282
+ ## Where this stops being the right tool
283
+
284
+ `addComprehensionEntity` is for shaping comprehension trees inside the
285
+ solver. If you're operating outside the solver loop -- e.g. building a
286
+ comprehension from an HTTP response, transforming CSV, or merging two pre-built
287
+ comprehensions -- reach for the meadow-integration toolchain directly:
288
+
289
+ - `meadow-integration csvtransform` to map columns into entity records.
290
+ - `meadow-integration comprehensionintersect` to merge two comprehensions.
291
+ - The `Comprehension` object's `Object.assign` semantics for in-code merges.
292
+
293
+ See [meadow-integration: Comprehensions](https://github.com/stevenvelozo/meadow-integration/blob/main/docs/comprehensions.md)
294
+ for those tools. The solver helper is purpose-built for "as I edit this form,
295
+ build the comprehension that will save it."
@@ -85,6 +85,23 @@ Groups organize inputs within a section.
85
85
  | `CSSClasses` | array | No | CSS classes to apply |
86
86
  | `Visible` | boolean | No | Initial visibility (default: true) |
87
87
 
88
+ #### Tabular Group Properties
89
+
90
+ These additional properties apply only to groups with `Layout: "Tabular"`.
91
+ See [Layouts](Layouts.md) for full details and examples.
92
+
93
+ | Property | Type | Description |
94
+ |----------|------|-------------|
95
+ | `RecordManifest` | string | Reference manifest naming the columns |
96
+ | `Headers` | array | Extra stacked / clustered header rows above the prime header |
97
+ | `RowLabels` | array | Left-side label columns (template / row-number / pre-slotted; clusterable) |
98
+ | `DynamicColumns` | array | Generators that build columns at runtime from another array |
99
+ | `EditingControlsPosition` | string | `"right"` (default), `"left"`, or `"hidden"` |
100
+ | `SuppressDefaultColumnHeaderRow` | boolean | Omit the prime column-name header row |
101
+ | `RowSelection` | boolean/object | Add row checkboxes; selection persists in form data |
102
+ | `ColumnSelection` | boolean/object | Add column checkboxes; selection persists in form data |
103
+ | `ColumnSorting` | boolean | Add clickable sort controls to the prime header cells (default off) |
104
+
88
105
  ### Layout Types
89
106
 
90
107
  - `Record` - Standard form layout with rows and columns
package/docs/Layouts.md CHANGED
@@ -160,6 +160,176 @@ TabularTemplate-RowPostfix
160
160
  TabularTemplate-TablePostfix
161
161
  ```
162
162
 
163
+ ### Stacked & Clustered Headers
164
+
165
+ By default a tabular group has a single header row, one cell per column. The
166
+ optional `Headers` property adds **extra header rows stacked above** that
167
+ default ("prime") row. Each entry in `Headers` is one header row; each row is
168
+ an array of cells.
169
+
170
+ | Cell property | Type | Description |
171
+ |---------------|------|-------------|
172
+ | `Label` | string | Header text |
173
+ | `ColumnSpan` | number | Number of data columns this cell spans (default 1) — this is how you "cluster" |
174
+ | `CSSClass` | string | Optional class applied to the `<th>` |
175
+
176
+ ```json
177
+ {
178
+ "Hash": "GradebookGrid",
179
+ "Layout": "Tabular",
180
+ "RecordSetAddress": "Grades",
181
+ "RecordManifest": "GradeRowEditor",
182
+ "Headers": [
183
+ [
184
+ { "Label": "First Semester", "ColumnSpan": 3, "CSSClass": "term-banner" },
185
+ { "Label": "Second Semester", "ColumnSpan": 4, "CSSClass": "term-banner" }
186
+ ]
187
+ ]
188
+ }
189
+ ```
190
+
191
+ Each header row's `ColumnSpan` total should equal the number of data columns;
192
+ a mismatch is logged as a warning and the header will visually misalign.
193
+ Header rows render top-to-bottom in array order, directly above the prime
194
+ column-name row.
195
+
196
+ ### Row Label Columns
197
+
198
+ The `RowLabels` property adds one or more **label columns down the left side**
199
+ of the table (before the data columns). Each entry describes one label column.
200
+
201
+ | Property | Type | Description |
202
+ |----------|------|-------------|
203
+ | `Name` | string | Header text for the label column |
204
+ | `Template` | string | A Pict template resolved per row — the row record is at `Record.Value`, the row index at `Record.Key` |
205
+ | `RowNumber` | boolean | When `true`, the label is the 1-based row number |
206
+ | `SourceAddress` | string | An app-data address of a pre-slotted array; element `[rowIndex]` is the label |
207
+ | `Cluster` | boolean | When `true`, consecutive equal labels collapse into one cell with `rowspan` |
208
+ | `CSSClass` | string | Optional class applied to the label `<td>` |
209
+
210
+ Provide exactly one of `Template`, `RowNumber`, or `SourceAddress` per entry.
211
+
212
+ ```json
213
+ "RowLabels": [
214
+ { "Name": "Section", "Template": "{~D:Record.Value.Section~}", "Cluster": true },
215
+ { "Name": "Student", "Template": "{~D:Record.Value.StudentName~}" },
216
+ { "Name": "#", "RowNumber": true }
217
+ ]
218
+ ```
219
+
220
+ `Cluster: true` is what produces the "merged cell" look — a column of repeated
221
+ values (e.g. a class section) renders as a single tall cell spanning its run
222
+ of rows. Any label column may be clustered; there is no "prime" label column.
223
+
224
+ ### Dynamic Columns
225
+
226
+ `DynamicColumns` generates table columns at runtime from **another array** in
227
+ the form data — for example, one grade column per assignment. Each entry is a
228
+ generator:
229
+
230
+ | Property | Type | Description |
231
+ |----------|------|-------------|
232
+ | `SourceAddress` | string | App-data address of the array driving the columns |
233
+ | `HashTemplate` | string | Template producing each column's unique descriptor hash |
234
+ | `NameTemplate` | string | Template producing each column's header text |
235
+ | `InformaryDataAddressTemplate` | string | Template producing the per-row data address the cell binds to |
236
+ | `HeaderGroupTemplate` | string | Optional — template producing a cluster label; auto-adds a clustered super-header row |
237
+ | `DataType` | string | Data type for the generated descriptors |
238
+ | `PictForm` | object | `PictForm` block merged onto each generated descriptor (e.g. `InputType`) |
239
+ | `InsertAt` | string/object | `"End"` (default), `"Start"`, or `{ "After": "<hash>" }` |
240
+
241
+ Inside each template the **source row** is the record (`Record.Field`).
242
+
243
+ ```json
244
+ "DynamicColumns": [
245
+ {
246
+ "SourceAddress": "Assignments",
247
+ "HashTemplate": "Grade_{~D:Record.IDAssignment~}",
248
+ "NameTemplate": "{~D:Record.Title~}",
249
+ "InformaryDataAddressTemplate": "Grades.{~D:Record.IDAssignment~}",
250
+ "HeaderGroupTemplate": "{~D:Record.Topic~}",
251
+ "DataType": "Number",
252
+ "PictForm": { "InputType": "Number" }
253
+ }
254
+ ]
255
+ ```
256
+
257
+ Dynamic columns are **non-destructive**: when a source row is removed the
258
+ generated column disappears, but the underlying row data at the
259
+ `InformaryDataAddress` is left untouched — re-adding the source row brings the
260
+ column back with its data intact. The columns re-resolve automatically as the
261
+ source array changes; no manual refresh call is needed.
262
+
263
+ When `HeaderGroupTemplate` is set, an extra clustered super-header row is
264
+ synthesized automatically: consecutive generated columns sharing the same
265
+ header-group value merge into one spanning cell (e.g. assignments clustered by
266
+ topic).
267
+
268
+ ### Editing Controls Position
269
+
270
+ Tabular rows render del / up / down controls. `EditingControlsPosition`
271
+ controls where:
272
+
273
+ | Value | Behavior |
274
+ |-------|----------|
275
+ | `"right"` | Default — controls in a trailing column |
276
+ | `"left"` | Controls in a leading column, before the data columns |
277
+ | `"hidden"` | No editing controls (read-only style table) |
278
+
279
+ ```json
280
+ { "Layout": "Tabular", "EditingControlsPosition": "hidden" }
281
+ ```
282
+
283
+ ### Suppressing the Default Header Row
284
+
285
+ Set `SuppressDefaultColumnHeaderRow: true` to omit the prime column-name row
286
+ entirely — useful when custom `Headers` rows fully describe the columns.
287
+
288
+ ### Selectable Rows & Columns
289
+
290
+ `RowSelection` and `ColumnSelection` add checkboxes that let the user pick
291
+ rows / columns. The selected state is **stored in the form data**, so it
292
+ persists with a save and can be read by solvers.
293
+
294
+ Set either to `true` for defaults, or to an object:
295
+
296
+ | Property | Type | Description |
297
+ |----------|------|-------------|
298
+ | `Enabled` | boolean | Set `false` to disable (same as omitting) |
299
+ | `DataAddress` | string | Where the boolean selection array is stored (default `<GroupHash>_RowSelection` / `_ColumnSelection`) |
300
+ | `HighlightClass` | string | Class auto-applied to selected rows/columns; set to `""` for solver-driven highlighting only |
301
+ | `HeaderLabel` | string | Header text for the row-selection column |
302
+
303
+ ```json
304
+ {
305
+ "Layout": "Tabular",
306
+ "RecordSetAddress": "Grades",
307
+ "RowSelection": true,
308
+ "ColumnSelection": true
309
+ }
310
+ ```
311
+
312
+ Checking a row (or column) highlights every cell across (or down) it and
313
+ writes `true` into the selection array at the configured address. Because the
314
+ array lives in the marshalled form data it round-trips with save / load.
315
+
316
+ ### Column Sorting
317
+
318
+ `ColumnSorting: true` (off by default) injects a clickable sort control — a
319
+ `<span>` carrying a sort SVG glyph from Pict's icon registry — into every
320
+ prime header cell.
321
+
322
+ ```json
323
+ { "Layout": "Tabular", "RecordSetAddress": "Students", "ColumnSorting": true }
324
+ ```
325
+
326
+ Clicking a column's control sorts the record set ascending; clicking the
327
+ active column again toggles to descending. The glyph reflects state: a neutral
328
+ double-arrow on idle columns, an up / down arrow on the active column. Sorting
329
+ works for both static and dynamic columns (dynamic columns sort by their
330
+ `InformaryDataAddress` value). Values that parse as numbers sort numerically;
331
+ others sort lexically.
332
+
163
333
  ## RecordSet Layout
164
334
 
165
335
  Similar to tabular but renders each record as a full form section rather