@uistate/renderer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,29 @@
1
+ # License
2
+
3
+ Copyright (c) 2025 Ajdin Imsirovic
4
+
5
+ This software is licensed under a PROPRIETARY LICENSE.
6
+
7
+ ## Permitted Uses
8
+
9
+ - Personal projects
10
+ - Open-source projects
11
+ - Educational purposes
12
+
13
+ ## Restrictions
14
+
15
+ - Commercial use requires a separate license
16
+ - Modification and redistribution of this file are NOT permitted
17
+ - This file may not be included in derivative works without permission
18
+
19
+ ## Disclaimer
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
29
+ For commercial licensing inquiries, contact the author.
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # @uistate/renderer
2
+
3
+ Direct-binding reactive renderer for [@uistate/core](https://www.npmjs.com/package/@uistate/core). Bind DOM nodes to store paths with `bind-*` attributes and `set` actions. Zero build step. ~270 lines.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @uistate/renderer
9
+ ```
10
+
11
+ Peer dependency: `@uistate/core >= 5.0.0`
12
+
13
+ ## Quick Start
14
+
15
+ ```html
16
+ <body>
17
+ <h1>Count: <span bind-text="count"></span></h1>
18
+ <button set="count:decrement">−</button>
19
+ <button set="count:0">Reset</button>
20
+ <button set="count:increment">+</button>
21
+
22
+ <script type="module">
23
+ import { createEventState } from '@uistate/core';
24
+ import { mount } from '@uistate/renderer';
25
+
26
+ const store = createEventState({ count: 0 });
27
+ mount(store);
28
+ </script>
29
+ </body>
30
+ ```
31
+
32
+ A reactive counter. No React. No Babel. No bundler. No innerHTML. Just HTML attributes and two imports.
33
+
34
+ ## Three Primitives
35
+
36
+ ### 1. Delegated Actions (DOM → Store)
37
+
38
+ Attach store writes to user events. Three delegated listeners on the root handle everything — they survive DOM mutations and never need re-wiring.
39
+
40
+ | Attribute | Event | Example |
41
+ |---|---|---|
42
+ | `set` | click | `<button set="count:increment">+</button>` |
43
+ | `set-blur` | focusout | `<input set-blur="item.editing:false">` |
44
+ | `set-enter` | keydown Enter | `<input set-enter="todos:push(draft)">` |
45
+
46
+ #### Set Expressions
47
+
48
+ | Syntax | Effect |
49
+ |---|---|
50
+ | `set="count:increment"` | `store.set('count', current + 1)` |
51
+ | `set="count:decrement"` | `store.set('count', current - 1)` |
52
+ | `set="count:0"` | `store.set('count', 0)` |
53
+ | `set="ui.dark:toggle"` | `store.set('ui.dark', !current)` |
54
+ | `set="user.name:Bob"` | `store.set('user.name', 'Bob')` |
55
+ | `set="todos:push(draft)"` | Clone `draft` into `todos`, reset `draft` |
56
+ | `set="todos.t1:delete"` | Remove `t1` from `todos` |
57
+ | `set="flag:true"` | `store.set('flag', true)` |
58
+ | `set="flag:false"` | `store.set('flag', false)` |
59
+ | `set="val:null"` | `store.set('val', null)` |
60
+
61
+ ### 2. Direct Node Binding (Store → DOM)
62
+
63
+ Bind store paths directly to DOM node properties. Each binding creates one EventState subscription and performs surgical updates — no re-rendering, no diffing.
64
+
65
+ | Attribute | What it does | Example |
66
+ |---|---|---|
67
+ | `bind-text` | Sets `textContent` | `<span bind-text="user.name"></span>` |
68
+ | `bind-value` | Two-way input binding | `<input bind-value="draft.text">` |
69
+ | `bind-focus` | Focus when truthy | `<input bind-focus="item.editing">` |
70
+ | `bind-data-*` | Sets `dataset.*` | `<div bind-data-done="item.done">` |
71
+ | `bind-attr-*` | Sets any attribute | `<img bind-attr-src="user.avatar">` |
72
+
73
+ **`bind-data-*` + CSS attribute selectors** replace conditional class logic:
74
+
75
+ ```html
76
+ <div bind-data-done="todos.t1.done">...</div>
77
+ ```
78
+
79
+ ```css
80
+ [data-done="true"] { text-decoration: line-through; opacity: 0.6; }
81
+ [data-done="false"] { text-decoration: none; }
82
+ ```
83
+
84
+ No ternary operators. No `classnames()`. CSS does what CSS was designed to do.
85
+
86
+ ### 3. Keyed Collections
87
+
88
+ Render dynamic lists with `each` + `<template>`. Reconciliation is key-based: only added/removed items touch the DOM.
89
+
90
+ ```html
91
+ <div each="todos">
92
+ <template>
93
+ <div class="todo-item" bind-data-done="{_path}.done">
94
+ <button set="{_path}.done:toggle">✓</button>
95
+ <span bind-text="{_path}.text"></span>
96
+ <small bind-text="{_key}"></small>
97
+ <button set="{_path}:delete">×</button>
98
+ </div>
99
+ </template>
100
+ </div>
101
+ ```
102
+
103
+ - `{_key}` — the object key (e.g., `t1`)
104
+ - `{_path}` — the full path (e.g., `todos.t1`)
105
+
106
+ Placeholders are resolved once when the item is created. All `bind-*` and `set` attributes work inside templates.
107
+
108
+ ## API
109
+
110
+ ### `mount(store, root?)`
111
+
112
+ Scans the DOM tree rooted at `root` (default: `document.body`) for `bind-*`, `set`, and `each` attributes. Sets up event delegation, bindings, and collections. Returns a cleanup function.
113
+
114
+ ```js
115
+ import { mount } from '@uistate/renderer';
116
+
117
+ const cleanup = mount(store);
118
+ // Later: cleanup() to remove all subscriptions and listeners
119
+ ```
120
+
121
+ ### Pure Functions (testable in Node — no DOM required)
122
+
123
+ ```js
124
+ import { parseSetExpr, evalExpr, parsePush } from '@uistate/renderer';
125
+
126
+ parseSetExpr('count:increment');
127
+ // → { path: 'count', expr: 'increment' }
128
+
129
+ evalExpr('increment', 5);
130
+ // → 6
131
+
132
+ parsePush('push(draft)');
133
+ // → { source: 'draft' }
134
+ ```
135
+
136
+ These are the same functions the renderer uses internally. Because they're pure, they run in Node with zero dependencies — enabling the self-test.
137
+
138
+ ## Testing
139
+
140
+ Two test layers, both DOMless:
141
+
142
+ ### `self-test.js` — Pure function tests (zero dependencies)
143
+
144
+ Tests the renderer's internal pure functions (`parseSetExpr`, `evalExpr`, `parsePush`) in Node. No store, no DOM, no test framework, no devDependencies. Runs automatically on `npm install` via the `postinstall` hook. **39 assertions, instant feedback.**
145
+
146
+ ```bash
147
+ node self-test.js
148
+ ```
149
+
150
+ ### `tests/renderer.test.js` — Store integration tests
151
+
152
+ Tests full state workflows via `@uistate/event-test`: CRUD cycles, editing lifecycles, wildcard subscriptions, batch operations. Creates real EventState stores and exercises the same dot-path patterns the renderer drives — still without touching the DOM. **34 tests, all passing.**
153
+
154
+ ```bash
155
+ npm test
156
+ ```
157
+
158
+ The self-test is a direct consequence of the architecture: since the renderer's logic is split into pure functions and a DOM-mounting function (`mount`), the pure functions are testable without a DOM, without JSDOM, without any test framework.
159
+
160
+ ## What's Not Here
161
+
162
+ - No virtual DOM
163
+ - No template interpolation engine
164
+ - No innerHTML in the render loop
165
+ - No diffing algorithm
166
+ - No component lifecycle
167
+ - No build step
168
+ - No JSX transform
169
+
170
+ HTML is the skeleton. The store is the brain. Bindings are the nerves.
171
+
172
+ ## Philosophy
173
+
174
+ The renderer exists to prove that **the state layer is the real product**. The same `@uistate/core` store works with React, Vue, Svelte, Angular — or with this 268-line renderer that needs nothing but a browser.
175
+
176
+ ```
177
+ A React Component (for comparison): f(props, ownState, lifecycle, hooks, context, memo, refs) → VDOM → DOM
178
+ A UIstate Renderer (for comparison): mount(store) → bind-text="path" → textContent
179
+ ```
180
+
181
+ ## Author
182
+
183
+ Ajdin Imsirovic
184
+
185
+ ## License
186
+
187
+ Proprietary — see [LICENSE.md](./LICENSE.md)
package/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @uistate/renderer - Direct-binding reactive renderer for @uistate/core
3
+ *
4
+ * Bind DOM nodes to store paths with bind-* attributes and set actions.
5
+ * Zero build step. Licensed under a proprietary license — see LICENSE.md.
6
+ */
7
+
8
+ export {
9
+ parseSetExpr,
10
+ evalExpr,
11
+ parsePush,
12
+ mount
13
+ } from './renderer.js';
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@uistate/renderer",
3
+ "version": "1.0.0",
4
+ "description": "Direct-binding reactive renderer for @uistate/core. Zero build step.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./renderer": "./renderer.js"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "renderer.js",
14
+ "self-test.js",
15
+ "LICENSE.md"
16
+ ],
17
+ "scripts": {
18
+ "postinstall": "node self-test.js",
19
+ "test": "node tests/renderer.test.js",
20
+ "self-test": "node self-test.js"
21
+ },
22
+ "keywords": [
23
+ "uistate",
24
+ "renderer",
25
+ "reactive-html",
26
+ "subscribe-element",
27
+ "zero-build",
28
+ "dom-binding",
29
+ "state-management",
30
+ "path-based"
31
+ ],
32
+ "peerDependencies": {
33
+ "@uistate/core": ">=5.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "@uistate/event-test": "^1.0.0"
37
+ },
38
+ "author": "Ajdin Imsirovic",
39
+ "license": "SEE LICENSE IN LICENSE.md",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/ImsirovicAjdin/uistate-renderer"
43
+ }
44
+ }
package/renderer.js ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * @uistate/renderer — Direct-binding reactive renderer for EventState
3
+ *
4
+ * Copyright (c) 2025 Ajdin Imsirovic
5
+ *
6
+ * Three primitives:
7
+ * 1. Delegated Actions (DOM → Store): set, set-blur, set-enter
8
+ * 2. Direct Node Binding (Store → DOM): bind-text, bind-value, bind-data-*, bind-focus
9
+ * 3. Keyed Collections: each="path" + <template>
10
+ *
11
+ * No templates. No innerHTML. No interpolation. No diffing.
12
+ * Event delegation survives DOM mutations. Bindings are surgical.
13
+ */
14
+
15
+ // ── Pure helpers ──────────────────────────────────────────────────────
16
+
17
+ const BOUND = Symbol('r2');
18
+
19
+ export function parseSetExpr(raw) {
20
+ const i = raw.indexOf(':');
21
+ if (i === -1) return { path: raw.trim(), expr: null };
22
+ return { path: raw.slice(0, i).trim(), expr: raw.slice(i + 1).trim() };
23
+ }
24
+
25
+ export function evalExpr(expr, current) {
26
+ if (expr == null) return current;
27
+ if (expr === 'increment') return (Number(current) || 0) + 1;
28
+ if (expr === 'decrement') return (Number(current) || 0) - 1;
29
+ if (expr === 'toggle') return !current;
30
+ if (expr === 'true') return true;
31
+ if (expr === 'false') return false;
32
+ if (expr === 'null') return null;
33
+ const n = Number(expr);
34
+ if (!isNaN(n) && expr.trim() !== '') return n;
35
+ try { return JSON.parse(expr); } catch (_) {}
36
+ return expr;
37
+ }
38
+
39
+ export function parsePush(expr) {
40
+ if (!expr) return null;
41
+ if (expr === 'push') return { source: null };
42
+ const m = expr.match(/^push\(([^)]+)\)$/);
43
+ return m ? { source: m[1].trim() } : null;
44
+ }
45
+
46
+ // ── Mount ─────────────────────────────────────────────────────────────
47
+
48
+ export function mount(store, root = document.body) {
49
+ const subs = []; // { node, unsub }
50
+
51
+ // ── Binding helpers ──
52
+
53
+ function addBinding(path, node, updateFn) {
54
+ updateFn(store.get(path));
55
+ const unsub = store.subscribe(path, (value) => updateFn(value));
56
+ subs.push({ node, unsub });
57
+ }
58
+
59
+ function cleanupWithin(container) {
60
+ for (let i = subs.length - 1; i >= 0; i--) {
61
+ if (container.contains(subs[i].node)) {
62
+ subs[i].unsub();
63
+ subs.splice(i, 1);
64
+ }
65
+ }
66
+ }
67
+
68
+ // ── Scan for bind-* attributes ──
69
+
70
+ function scanBindings(el) {
71
+ const nodes = el instanceof Element ? [el, ...el.querySelectorAll('*')] : [];
72
+
73
+ for (const node of nodes) {
74
+ if (node[BOUND]) continue;
75
+ node[BOUND] = true;
76
+
77
+ // bind-text: one-way, textContent
78
+ if (node.hasAttribute('bind-text')) {
79
+ const p = node.getAttribute('bind-text');
80
+ addBinding(p, node, v => {
81
+ node.textContent = v != null ? String(v) : '';
82
+ });
83
+ }
84
+
85
+ // bind-value: two-way for inputs
86
+ if (node.hasAttribute('bind-value')) {
87
+ const p = node.getAttribute('bind-value');
88
+ addBinding(p, node, v => {
89
+ const s = v != null ? String(v) : '';
90
+ if (node.type === 'checkbox') {
91
+ if (node.checked !== !!v) node.checked = !!v;
92
+ } else {
93
+ if (node.value !== s) node.value = s;
94
+ }
95
+ });
96
+ node.addEventListener('input', () => {
97
+ store.set(p, node.type === 'checkbox' ? node.checked : node.value);
98
+ });
99
+ }
100
+
101
+ // bind-focus: focus element when value is truthy
102
+ if (node.hasAttribute('bind-focus')) {
103
+ const p = node.getAttribute('bind-focus');
104
+ addBinding(p, node, v => {
105
+ if (v) requestAnimationFrame(() => {
106
+ if (node.offsetParent !== null) node.focus();
107
+ });
108
+ });
109
+ }
110
+
111
+ // bind-data-* → dataset, bind-attr-* → setAttribute
112
+ for (const attr of Array.from(node.attributes)) {
113
+ if (attr.name.startsWith('bind-data-')) {
114
+ const dName = attr.name.slice(10);
115
+ const p = attr.value;
116
+ addBinding(p, node, v => {
117
+ node.dataset[dName] = v != null ? String(v) : '';
118
+ });
119
+ } else if (attr.name.startsWith('bind-attr-')) {
120
+ const aName = attr.name.slice(10);
121
+ const p = attr.value;
122
+ addBinding(p, node, v => {
123
+ if (v != null) node.setAttribute(aName, String(v));
124
+ else node.removeAttribute(aName);
125
+ });
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ // ── Delegated action execution ──
132
+
133
+ function executeSet(raw) {
134
+ const { path, expr } = parseSetExpr(raw);
135
+
136
+ // delete: remove key from parent collection
137
+ if (expr === 'delete') {
138
+ const dot = path.lastIndexOf('.');
139
+ if (dot === -1) return;
140
+ const parentPath = path.slice(0, dot);
141
+ const key = path.slice(dot + 1);
142
+ const parent = store.get(parentPath) || {};
143
+ const updated = {};
144
+ for (const k of Object.keys(parent)) {
145
+ if (k !== key) updated[k] = parent[k];
146
+ }
147
+ store.set(parentPath, updated);
148
+ return;
149
+ }
150
+
151
+ // push(sourcePath): clone source into collection, reset source
152
+ const push = parsePush(expr);
153
+ if (push) {
154
+ const sourcePath = push.source;
155
+ if (!sourcePath) return;
156
+ const src = store.get(sourcePath);
157
+ if (!src || typeof src !== 'object') return;
158
+ const key = `t_${Date.now()}`;
159
+ store.batch(() => {
160
+ store.set(`${path}.${key}`, JSON.parse(JSON.stringify(src)));
161
+ for (const k of Object.keys(src)) {
162
+ const v = src[k];
163
+ store.set(`${sourcePath}.${k}`,
164
+ typeof v === 'string' ? '' :
165
+ typeof v === 'boolean' ? false :
166
+ typeof v === 'number' ? 0 : null
167
+ );
168
+ }
169
+ });
170
+ return;
171
+ }
172
+
173
+ // normal: evaluate expression and set
174
+ store.set(path, evalExpr(expr, store.get(path)));
175
+ }
176
+
177
+ // ── Step 1: Event delegation (once, never re-wired) ──
178
+
179
+ root.addEventListener('click', e => {
180
+ const t = e.target.closest('[set]');
181
+ if (t && root.contains(t)) executeSet(t.getAttribute('set'));
182
+ });
183
+
184
+ root.addEventListener('focusout', e => {
185
+ const t = e.target.closest('[set-blur]');
186
+ if (t && root.contains(t)) executeSet(t.getAttribute('set-blur'));
187
+ });
188
+
189
+ root.addEventListener('keydown', e => {
190
+ if (e.key !== 'Enter') return;
191
+ const t = e.target.closest('[set-enter]');
192
+ if (t && root.contains(t)) {
193
+ e.preventDefault();
194
+ executeSet(t.getAttribute('set-enter'));
195
+ }
196
+ });
197
+
198
+ // ── Step 3: Keyed collections ──
199
+
200
+ function setupCollection(container) {
201
+ const collPath = container.getAttribute('each');
202
+ const tpl = container.querySelector('template');
203
+ if (!collPath || !tpl) return;
204
+
205
+ const templateHTML = tpl.innerHTML.trim();
206
+ const rendered = new Map(); // key → element
207
+ let lastKeyStr = '';
208
+
209
+ function resolve(html, key) {
210
+ return html
211
+ .replace(/\{_key\}/g, key)
212
+ .replace(/\{_path\}/g, `${collPath}.${key}`);
213
+ }
214
+
215
+ function reconcile() {
216
+ const coll = store.get(collPath) || {};
217
+ const keys = Object.keys(coll).filter(k => coll[k] != null);
218
+ const keyStr = keys.join(',');
219
+
220
+ // Fast bail: same keys, no structural change
221
+ if (keyStr === lastKeyStr) return;
222
+ lastKeyStr = keyStr;
223
+
224
+ const keySet = new Set(keys);
225
+
226
+ // Remove deleted items
227
+ for (const [k, el] of rendered) {
228
+ if (!keySet.has(k)) {
229
+ cleanupWithin(el);
230
+ el.remove();
231
+ rendered.delete(k);
232
+ }
233
+ }
234
+
235
+ // Add new items
236
+ for (const k of keys) {
237
+ if (!rendered.has(k)) {
238
+ const html = resolve(templateHTML, k);
239
+ const tmp = document.createElement('div');
240
+ tmp.innerHTML = html;
241
+ const el = tmp.firstElementChild;
242
+ if (!el) continue;
243
+ el.dataset.key = k;
244
+ container.appendChild(el);
245
+ rendered.set(k, el);
246
+ scanBindings(el);
247
+ }
248
+ }
249
+ }
250
+
251
+ // Subscribe: exact (for delete/replace) + wildcard (for child add)
252
+ const u1 = store.subscribe(collPath, reconcile);
253
+ const u2 = store.subscribe(`${collPath}.*`, reconcile);
254
+ subs.push({ node: container, unsub: u1 });
255
+ subs.push({ node: container, unsub: u2 });
256
+
257
+ reconcile();
258
+ }
259
+
260
+ // ── Init ──
261
+
262
+ root.querySelectorAll('[each]').forEach(setupCollection);
263
+ scanBindings(root);
264
+
265
+ // Return cleanup function
266
+ return () => subs.forEach(s => s.unsub());
267
+ }
package/self-test.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @uistate/renderer — self-test
3
+ *
4
+ * Standalone test of pure functions. No dependencies beyond renderer.js.
5
+ * Runs on `node self-test.js` or as a postinstall hook.
6
+ */
7
+
8
+ import {
9
+ parseSetExpr,
10
+ evalExpr,
11
+ parsePush
12
+ } from './renderer.js';
13
+
14
+ let passed = 0;
15
+ let failed = 0;
16
+
17
+ function assert(name, condition) {
18
+ if (condition) {
19
+ passed++;
20
+ } else {
21
+ failed++;
22
+ console.error(` ✗ ${name}`);
23
+ }
24
+ }
25
+
26
+ // ── parseSetExpr ────────────────────────────────────────────────────
27
+
28
+ const p1 = parseSetExpr('count:increment');
29
+ assert('parseSetExpr: path', p1.path === 'count');
30
+ assert('parseSetExpr: expr', p1.expr === 'increment');
31
+
32
+ const p2 = parseSetExpr('count');
33
+ assert('parseSetExpr: path only', p2.path === 'count');
34
+ assert('parseSetExpr: no expr → null', p2.expr === null);
35
+
36
+ const p3 = parseSetExpr('user.name:Bob');
37
+ assert('parseSetExpr: dotted path', p3.path === 'user.name');
38
+ assert('parseSetExpr: string expr', p3.expr === 'Bob');
39
+
40
+ const p4 = parseSetExpr('count:0');
41
+ assert('parseSetExpr: zero expr', p4.expr === '0');
42
+
43
+ const p5 = parseSetExpr('todos:push');
44
+ assert('parseSetExpr: push path', p5.path === 'todos');
45
+ assert('parseSetExpr: push expr', p5.expr === 'push');
46
+
47
+ const p6 = parseSetExpr('todos:push(draft)');
48
+ assert('parseSetExpr: push(source) path', p6.path === 'todos');
49
+ assert('parseSetExpr: push(source) expr', p6.expr === 'push(draft)');
50
+
51
+ const p7 = parseSetExpr('todos.t1:delete');
52
+ assert('parseSetExpr: delete path', p7.path === 'todos.t1');
53
+ assert('parseSetExpr: delete expr', p7.expr === 'delete');
54
+
55
+ const p8 = parseSetExpr('todos.t1.editing:false');
56
+ assert('parseSetExpr: editing path', p8.path === 'todos.t1.editing');
57
+ assert('parseSetExpr: editing expr', p8.expr === 'false');
58
+
59
+ const p9 = parseSetExpr(' count : increment ');
60
+ assert('parseSetExpr: trims whitespace path', p9.path === 'count');
61
+ assert('parseSetExpr: trims whitespace expr', p9.expr === 'increment');
62
+
63
+ // ── evalExpr ────────────────────────────────────────────────────────
64
+
65
+ assert('evalExpr: increment', evalExpr('increment', 5) === 6);
66
+ assert('evalExpr: increment from 0', evalExpr('increment', 0) === 1);
67
+ assert('evalExpr: increment from null', evalExpr('increment', null) === 1);
68
+ assert('evalExpr: decrement', evalExpr('decrement', 5) === 4);
69
+ assert('evalExpr: decrement from 0', evalExpr('decrement', 0) === -1);
70
+ assert('evalExpr: toggle true→false', evalExpr('toggle', true) === false);
71
+ assert('evalExpr: toggle false→true', evalExpr('toggle', false) === true);
72
+ assert('evalExpr: number 42', evalExpr('42', 0) === 42);
73
+ assert('evalExpr: number 0', evalExpr('0', 99) === 0);
74
+ assert('evalExpr: negative number', evalExpr('-5', 0) === -5);
75
+ assert('evalExpr: boolean true', evalExpr('true', 0) === true);
76
+ assert('evalExpr: boolean false', evalExpr('false', 1) === false);
77
+ assert('evalExpr: null keyword', evalExpr('null', 'x') === null);
78
+ assert('evalExpr: plain string', evalExpr('hello', '') === 'hello');
79
+ assert('evalExpr: null expr → returns current', evalExpr(null, 7) === 7);
80
+ assert('evalExpr: undefined expr → returns current', evalExpr(undefined, 7) === 7);
81
+
82
+ // ── parsePush ───────────────────────────────────────────────────────
83
+
84
+ const pp1 = parsePush('push');
85
+ assert('parsePush: bare push → source null', pp1 !== null && pp1.source === null);
86
+
87
+ const pp2 = parsePush('push(draft)');
88
+ assert('parsePush: push(draft) → source "draft"', pp2 !== null && pp2.source === 'draft');
89
+
90
+ const pp3 = parsePush('push(form.data)');
91
+ assert('parsePush: push(form.data) → source "form.data"', pp3 !== null && pp3.source === 'form.data');
92
+
93
+ assert('parsePush: non-push returns null', parsePush('increment') === null);
94
+ assert('parsePush: null returns null', parsePush(null) === null);
95
+ assert('parsePush: empty string returns null', parsePush('') === null);
96
+
97
+ // ── Results ─────────────────────────────────────────────────────────
98
+
99
+ console.log(`\n@uistate/renderer v1.0.0 — self-test`);
100
+ console.log(`✓ ${passed} assertions passed${failed ? `, ✗ ${failed} failed` : ''}\n`);
101
+
102
+ if (failed > 0) process.exit(1);