@jarenjs/app 0.49.2 → 0.66.1

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.
package/README.md CHANGED
@@ -40,6 +40,47 @@ const app = createApp({
40
40
  }, { node: document.getElementById('app') });
41
41
  ```
42
42
 
43
+ ### The same app, by code
44
+
45
+ `@jarenjs/linq/app` writes that document from typed builders — the
46
+ initial state derived from the state schema's defaults, the view the
47
+ JSLT pen's stylesheet, the actions captured over `$`/`$event`/`$payload`
48
+ and their patch pointers derived from the state shape — and answers the
49
+ state's JSON Schema beside it for `validateState`:
50
+
51
+ ```javascript
52
+ import { action, bind, defineApp, replace, transition } from '@jarenjs/linq/app';
53
+ import { rule } from '@jarenjs/linq/jslt';
54
+ import * as s from '@jarenjs/linq/schema';
55
+
56
+ const counter = defineApp({
57
+ state: s.object({ count: s.integer().default(0) }),
58
+ view: [rule('$', (v) => ['main', {},
59
+ ['h1', {}, 'Count: ', v.get('count')],
60
+ ['button', { on: { click: 'inc' } }, '+'],
61
+ ['button', { on: { click: bind('add', { payload: 10 }) } }, '+10'],
62
+ ])],
63
+ actions: {
64
+ inc: action((st) => transition({
65
+ patch: [replace((c) => c.get('count'), st.get('count').add(1))],
66
+ })),
67
+ add: action((st, x) => transition({
68
+ patch: [replace((c) => c.get('count'), st.get('count').add(x.payload))],
69
+ }), { payload: s.integer() }),
70
+ },
71
+ });
72
+
73
+ createApp(counter.document, { node: document.getElementById('app') });
74
+ ```
75
+
76
+ It is the same application — the test suite boots both and compares
77
+ them frame for frame. The mapping table, every refusal and the
78
+ member-name escape this example's `get('count')` spells are
79
+ [APP-PEN.md](../linq/docs/APP-PEN.md); this package depends on none of
80
+ it.
81
+
82
+ ## The view
83
+
43
84
  The view is a JSLT stylesheet: rules match state by location (JSONPath) and shape (JSON Schema, via `compileTypeTest`), bodies are query documents producing vnodes, and `$path`/`$root` are in scope — a rule rendering `/todos/3` can embed its own pointer in an event binding, which is why there are no payload-creator functions anywhere.
44
85
 
45
86
  ## Actions and transitions
@@ -129,6 +170,10 @@ Two IDE-shaped primitives ship ready to bind, so a two-pane surface (the studio,
129
170
  - **`createSplitterWidget({ action, grid, rail, cssVar, min, max, step })`** — a drag handle over a pane boundary. It drives a CSS ratio variable *live* during a drag (no per-move dispatch — that would flood the transaction log and undo) and commits the ratio through `action` on pointer-up only, plus keyboard resize as an ARIA separator. Register it like any widget; parameterize the grid/rail selectors, the CSS variable and the commit action so each surface binds its own.
130
171
  - **`createDocStore({ storage, key })`** — a keyed `save`/`load`/`remove`/`names`/`all` CRUD over an injected `storage` (`localStorage` in the browser, an in-memory object in tests), so the package never touches `localStorage` itself. Paired with **`encodeShare(snapshot)`** / **`decodeShare(token)`**, a Unicode-safe base64url share-link codec (a corrupt token decodes to `null`, never a throw), it is the new/save/load/delete/share pattern behind the studio and play surfaces.
131
172
 
173
+ Collection keys and document names such as `__proto__` are ordinary own
174
+ members of the document store and survive persistence. Loading a name
175
+ that has not been saved returns `undefined`, including prototype-member names.
176
+
132
177
  ## Invariants the model can't cheat
133
178
 
134
179
  ```javascript
@@ -173,7 +218,12 @@ const app = createApp({
173
218
  });
174
219
  ```
175
220
 
176
- Schema in, live form out: text/email/number/date/color inputs, textareas, checkboxes, selects with precomputed options, nested object fieldsets, arrays with add/remove buttons, inline errors, and `x-form` visibility/enablement/computed reacting per keystroke. The `viewModel` option is the general **derivation boundary**: it maps state to the view stylesheet's input before every render, so JS-computed derivations enter the render path without ever entering the state. A DOM control's value is a string, and two controls carry something else: a select over a non-string enum, and the `json` editor over a structured value. Both round-trip through JSON text and decode it in `formEventFields()`, the format's one sanctioned place for host JavaScript at the DOM boundary (APP-FORMAT §5.4) — **register it or those two controls write nothing**. Remaining 0.1 limits (documented in `src/forms.js`): a cleared number input writes `null`, and arrays need to exist in the data (give them `default: []` in the schema).
221
+ Schema in, live form out: text/email/number/date/color inputs, textareas, checkboxes, selects with precomputed options, nested object fieldsets, arrays with add/remove buttons, inline errors, and `x-form` visibility/enablement/computed reacting per keystroke. The `viewModel` option is the general **derivation boundary**: it maps state to the view stylesheet's input before every render, so JS-computed derivations enter the render path without ever entering the state. A DOM control's value is a string, and two controls carry something else: a select over a non-string enum, and the `json` editor over a structured value. Both round-trip through JSON text and decode it in `formEventFields()`, the format's one sanctioned place for host JavaScript at the DOM boundary (APP-FORMAT §5.4) — **register it or those two controls write nothing**. Remaining limits (documented in `src/forms.js`): a cleared number input writes `null`, and intermediate object/array containers need to exist in the data. `createInitialData(model)` supplies them for a new form; loaded documents must supply them too.
222
+
223
+ Nested object and array fields honor `x-form.enabled` through their native
224
+ `fieldset` disablement, including child controls and array buttons. A `readOnly`
225
+ field uses native `readonly` for text inputs and textareas, and `disabled` for
226
+ checkboxes, selects, collection fieldsets, and add/remove buttons.
177
227
 
178
228
  ## Headless and server-side
179
229
 
@@ -192,6 +242,20 @@ Also exported: `compileActions`, `compileSubs`, `createFormView`, `createFormAct
192
242
 
193
243
  Options: `node`, `document`, `effects`, `subs`, `eventFields` (named `$event` field extractors), `widgets` (registered widget definitions, forwarded to the renderer), `compileTypeTest`, `validateState`, `viewModel`, `onError` (default rethrows), `schedule` (render batching; default microtask — pass `(f) => f()` for synchronous tests). Compile failures throw `AppCompileError` (`JA0xxx`, with a `docPath` into the app document); runtime failures route `AppRuntimeError` (`JA2xxx`) through `onError`. The full code table is in [APP-FORMAT.md](docs/APP-FORMAT.md) §10.
194
244
 
245
+ ## Exports
246
+
247
+ Every subpath a consumer can import, derived from the manifest by
248
+ `npm run docs:derive` (`npm run docs:check` fails when the two drift):
249
+
250
+ <!--fact:exports.app-->
251
+ | Import | Kind | Declarations |
252
+ |---|---|---|
253
+ | `@jarenjs/app` | JavaScript | declared |
254
+ | `@jarenjs/app/schemas/jaren-app.draft-07.schema.json` | schema | — |
255
+ | `@jarenjs/app/schemas/jaren-app.schema.json` | schema | — |
256
+ | `@jarenjs/app/package.json` | metadata | — |
257
+ <!--/fact-->
258
+
195
259
  ## Development
196
260
 
197
261
  Unit tests live in `test/app/` at the repository root (`npm run test:app`). See [ROADMAP](../../docs/ROADMAP.md) for what's next: dirty-path-pruned re-rendering and time-travel tooling over the action log.
@@ -22,9 +22,11 @@
22
22
  * JavaScript at the DOM boundary (APP-FORMAT §5.4). A host that renders
23
23
  * these controls MUST register them.
24
24
  *
25
- * Remaining limitation, documented rather than hidden: a cleared number
26
- * input writes `null` (which surfaces as a validation error, not a
27
- * dispatch error).
25
+ * Remaining limitations: a cleared number input writes `null` (which
26
+ * surfaces as a validation error, not a dispatch error), and standard
27
+ * actions require intermediate object/array containers to exist in the
28
+ * data. `createInitialData` supplies those containers for a new form;
29
+ * loaded documents must supply them too.
28
30
  */
29
31
  /**
30
32
  * The event-field extractors the standard form controls need, for
@@ -38,6 +38,19 @@ valid per §2–§5. A **runtime** (the reference implementation is
38
38
  specified there, and MUST serialize dispatches per the transaction
39
39
  model of §8.
40
40
 
41
+ One producer ships in this repository: `@jarenjs/linq/app` writes these
42
+ documents from typed JavaScript builders — the state's initial value
43
+ derived from its schema's defaults, the view the JSLT pen's stylesheet,
44
+ actions captured over §3.1's three names, and patch pointers derived
45
+ from the state shape — with `defineApp()` refusing at build time what
46
+ §4 and §6 would otherwise report per dispatch. Its mapping table is
47
+ [APP-PEN.md](../../linq/docs/APP-PEN.md); §2's document below
48
+ and the `contract/catalog.load/start` action of
49
+ [CONTRACT-FORMAT §11.1](../../contract/docs/CONTRACT-FORMAT.md) are
50
+ rebuilt through it byte for byte by that package's test suite. Nothing
51
+ in this package depends on it: the format is the contract, and a
52
+ document written by hand is the same document.
53
+
41
54
  ## 2. The app document
42
55
 
43
56
  ```json
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/app",
3
3
  "private": false,
4
- "version": "0.49.2",
4
+ "version": "0.66.1",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -50,8 +50,8 @@
50
50
  "prepack": "npm run build:types"
51
51
  },
52
52
  "dependencies": {
53
- "@jarenjs/core": "^0.49.2",
54
- "@jarenjs/json": "^0.49.2",
55
- "@jarenjs/view": "^0.49.2"
53
+ "@jarenjs/core": "^0.66.1",
54
+ "@jarenjs/json": "^0.66.1",
55
+ "@jarenjs/view": "^0.66.1"
56
56
  }
57
57
  }
package/src/docstore.js CHANGED
@@ -12,6 +12,8 @@
12
12
  * forgivingly: a corrupt token decodes to `null`, never a throw.
13
13
  */
14
14
 
15
+ import { setObjectMember } from '@jarenjs/core/object';
16
+
15
17
  /**
16
18
  * @param {Object} opts
17
19
  * @param {{ read: () => any, write: (store: any) => void }} opts.storage
@@ -29,10 +31,10 @@
29
31
  export function createDocStore({ storage, key = 'experiments' }) {
30
32
  // read once; keep the SAME object reference for every write-back
31
33
  const store = storage.read() ?? {};
32
- if (store[key] === undefined || store[key] === null) store[key] = {};
34
+ if (!Object.hasOwn(store, key) || store[key] == null) setObjectMember(store, key, {});
33
35
  return {
34
- save(name, value) { store[key][name] = value; storage.write(store); },
35
- load(name) { return store[key][name]; },
36
+ save(name, value) { setObjectMember(store[key], name, value); storage.write(store); },
37
+ load(name) { return Object.hasOwn(store[key], name) ? store[key][name] : undefined; },
36
38
  remove(name) { delete store[key][name]; storage.write(store); },
37
39
  names() { return Object.keys(store[key]).sort(); },
38
40
  all() { return store[key]; },
package/src/forms.js CHANGED
@@ -23,9 +23,11 @@
23
23
  * JavaScript at the DOM boundary (APP-FORMAT §5.4). A host that renders
24
24
  * these controls MUST register them.
25
25
  *
26
- * Remaining limitation, documented rather than hidden: a cleared number
27
- * input writes `null` (which surfaces as a validation error, not a
28
- * dispatch error).
26
+ * Remaining limitations: a cleared number input writes `null` (which
27
+ * surfaces as a validation error, not a dispatch error), and standard
28
+ * actions require intermediate object/array containers to exist in the
29
+ * data. `createInitialData` supplies those containers for a new form;
30
+ * loaded documents must supply them too.
29
31
  */
30
32
 
31
33
  /** The default action names shared by both factories. */
@@ -111,6 +113,21 @@ export function createFormView(options = {}) {
111
113
  const ctl = (control) => `${root}..[?@.control == '${control}']`;
112
114
 
113
115
  const disabled = { $not: '$.enabled' };
116
+ // Selects, checkboxes and collection controls have no readonly mode.
117
+ // Disabling their fieldset also prevents edits through child controls.
118
+ const writeDisabled = { $or: [disabled, '$.readOnly'] };
119
+
120
+ /** One removal control for scalar and collection array elements. */
121
+ const remove = { $if: ['$.removable',
122
+ ['button', {
123
+ type: 'button',
124
+ class: `${cls}-remove`,
125
+ // the glyph is decoration; the accessible name is the label
126
+ 'aria-label': labels.removeItem,
127
+ title: labels.removeItem,
128
+ disabled: writeDisabled,
129
+ on: { click: { action: act.remove, with: { pointer: '$.pointer' } } },
130
+ }, options.removeLabel ?? '×']] };
114
131
 
115
132
  /** The shared field chrome around one control vnode. */
116
133
  const field = (control) => ['div', { class: `${cls}-field`, 'data-pointer': '$.pointer' },
@@ -121,15 +138,7 @@ export function createFormView(options = {}) {
121
138
  ],
122
139
  { $if: ['$.description', ['p', { class: `${cls}-description` }, '$.description']] },
123
140
  [{ $apply: '$.errors[*]' }],
124
- { $if: ['$.removable',
125
- ['button', {
126
- type: 'button',
127
- class: `${cls}-remove`,
128
- // the glyph is decoration; the accessible name is the label
129
- 'aria-label': labels.removeItem,
130
- title: labels.removeItem,
131
- on: { click: { action: act.remove, with: { pointer: '$.pointer' } } },
132
- }, options.removeLabel ?? '×']] },
141
+ remove,
133
142
  ];
134
143
 
135
144
  // every write binding carries the element flag: the standard actions
@@ -179,16 +188,17 @@ export function createFormView(options = {}) {
179
188
  // nested objects: a fieldset group
180
189
  {
181
190
  match: ctl('object'),
182
- body: ['fieldset', { class: `${cls}-group`, 'data-pointer': '$.pointer' },
191
+ body: ['fieldset', { class: `${cls}-group`, 'data-pointer': '$.pointer', disabled: writeDisabled },
183
192
  { $if: ['$.label', ['legend', {}, '$.label']] },
184
193
  [{ $apply: '$.children[*]' }],
185
194
  [{ $apply: '$.errors[*]' }],
195
+ remove,
186
196
  ],
187
197
  },
188
198
  // arrays: expanded items plus the add-item button
189
199
  {
190
200
  match: ctl('array'),
191
- body: ['fieldset', { class: `${cls}-array`, 'data-pointer': '$.pointer' },
201
+ body: ['fieldset', { class: `${cls}-array`, 'data-pointer': '$.pointer', disabled: writeDisabled },
192
202
  { $if: ['$.label', ['legend', {}, '$.label']] },
193
203
  [{ $apply: '$.items[*]' }],
194
204
  { $if: [{ $exists: '$.addValue' },
@@ -197,9 +207,11 @@ export function createFormView(options = {}) {
197
207
  class: `${cls}-add`,
198
208
  'aria-label': labels.addItem,
199
209
  title: labels.addItem,
210
+ disabled: writeDisabled,
200
211
  on: { click: { action: act.add, with: { pointer: '$.pointer', value: '$.addValue' } } },
201
212
  }, options.addLabel ?? '+']] },
202
213
  [{ $apply: '$.errors[*]' }],
214
+ remove,
203
215
  ],
204
216
  },
205
217
  // one error line per message
@@ -216,7 +228,7 @@ export function createFormView(options = {}) {
216
228
  {
217
229
  match: ctl('select'),
218
230
  body: field(['select', {
219
- disabled,
231
+ disabled: writeDisabled,
220
232
  on: {
221
233
  change: { action: act.json, with: writeWith, event: [JSON_FIELD] },
222
234
  },
@@ -228,7 +240,7 @@ export function createFormView(options = {}) {
228
240
  body: field(['input', {
229
241
  type: 'checkbox',
230
242
  checked: '$.value',
231
- disabled,
243
+ disabled: writeDisabled,
232
244
  on: { change: { action: act.check, with: writeWith } },
233
245
  }]),
234
246
  },