objs-core 1.1.1 → 2.0.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/EXAMPLES.md ADDED
@@ -0,0 +1,1637 @@
1
+ # Objs v2.0 — Examples & Architecture Guide
2
+
3
+ All examples work as-is with `<script src="objs.js"></script>`.
4
+ Runnable paste-and-run code: [examples.js](examples.js).
5
+
6
+ ---
7
+
8
+ ## Contents
9
+
10
+ 0. [Framework comparison & migration guide](#0-framework-comparison--migration-guide)
11
+ 1. [How render works](#1-how-render-works)
12
+ 2. [Single components — atoms](#2-single-components--atoms)
13
+ 3. [Nesting & composition — three patterns](#3-nesting--composition)
14
+ 4. [Design system architecture](#4-design-system-architecture)
15
+ 5. [Real-world examples](#5-real-world-examples)
16
+ 6. [React integration](#6-react-integration)
17
+
18
+ ---
19
+
20
+ ## 0. Framework comparison & migration guide
21
+
22
+ Coming from React, Vue, or Solid? This section maps familiar patterns to their Objs equivalents, so you can start writing productive code immediately.
23
+
24
+ ### Feature comparison
25
+
26
+ | | React 18 | Vue 3 | Solid | Objs 2.0 |
27
+ |---|---|---|---|---|
28
+ | **Min + gz size** | ~45 kB | ~22 kB | ~7 kB | ~6 kB |
29
+ | **DOM update model** | Virtual DOM diff | Virtual DOM diff | Fine-grained signals | Direct — explicit state calls |
30
+ | **Reactivity** | `useState` / hooks | `ref` / `reactive` | `createSignal` | None — you call update methods |
31
+ | **Component format** | JSX function | SFC `.vue` / `setup()` | JSX function | Plain JS `states` object |
32
+ | **Build step required** | Yes | Yes | Yes | No — works as a `<script>` tag |
33
+ | **TypeScript** | Full generics | Full generics | Full generics | `.d.ts` definitions |
34
+ | **SSR** | React Server / Next.js | Nuxt | SolidStart | Built-in `o.reactRender` |
35
+ | **Routing** | React Router | Vue Router | @solidjs/router | Built-in `o.route` |
36
+ | **State sharing** | Context / Zustand | Pinia | Signals / stores | Plain observer or `o.connectRedux` |
37
+ | **Data fetching** | `useEffect` + fetch | `onMounted` + fetch | `createResource` | `o.newLoader` + `.connect()` |
38
+ | **Testing** | Jest / RTL / Vitest | Vitest | Vitest | Built-in `o.addTest` |
39
+ | **QA selectors** | `data-testid` (manual) | `data-testid` (manual) | `data-testid` (manual) | Auto via `o.autotag` |
40
+ | **Action recording** | Playwright / Cypress | Playwright / Cypress | Playwright / Cypress | Built-in `o.startRecording` |
41
+ | **Dev/prod split** | Manual | Manual | Manual | Built-in `__DEV__` + `build.js` |
42
+ | **Integrates into existing projects** | `createRoot(el)` | `createApp().mount(el)` | `render(() => …, el)` | `.appendInside(el)` |
43
+
44
+ > **Key difference:** React, Vue, and Solid track state automatically and re-render when it changes.
45
+ > Objs does not — you explicitly call `component.stateMethod(data)`, which writes only the changed DOM nodes.
46
+ > This gives you O(1) updates without a reactivity system or virtual DOM.
47
+
48
+ ---
49
+
50
+ ### Pattern 1 — Define and mount a component
51
+
52
+ The most basic operation: create a component with initial data and insert it into the page.
53
+
54
+ **React**
55
+ ```jsx
56
+ function Badge({ count }) {
57
+ return <span className="badge">{count}</span>;
58
+ }
59
+ ReactDOM.createRoot(document.getElementById('nav')).render(<Badge count={3} />);
60
+ ```
61
+
62
+ **Vue 3**
63
+ ```vue
64
+ <!-- Badge.vue -->
65
+ <template><span class="badge">{{ count }}</span></template>
66
+ <script setup>
67
+ defineProps(['count']);
68
+ </script>
69
+ ```
70
+ ```js
71
+ // main.js
72
+ import { createApp } from 'vue';
73
+ import Badge from './Badge.vue';
74
+ createApp(Badge, { count: 3 }).mount('#nav');
75
+ ```
76
+
77
+ **Solid**
78
+ ```jsx
79
+ import { render } from 'solid-js/web';
80
+ function Badge(props) {
81
+ return <span class="badge">{props.count}</span>;
82
+ }
83
+ render(() => <Badge count={3} />, document.getElementById('nav'));
84
+ ```
85
+
86
+ **Objs**
87
+ ```js
88
+ const badge = o.init({
89
+ name: 'Badge',
90
+ render: ({ count }) => ({ tag: 'span', class: 'badge', html: count }),
91
+ }).render({ count: 3 }).appendInside('#nav');
92
+ ```
93
+
94
+ ---
95
+
96
+ ### Pattern 2 — Counter (click to increment)
97
+
98
+ State that changes on user interaction.
99
+
100
+ **React**
101
+ ```jsx
102
+ function Counter() {
103
+ const [count, setCount] = React.useState(0);
104
+ return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
105
+ }
106
+ ```
107
+
108
+ **Vue 3**
109
+ ```vue
110
+ <template><button @click="count++">{{ count }}</button></template>
111
+ <script setup>
112
+ import { ref } from 'vue';
113
+ const count = ref(0);
114
+ </script>
115
+ ```
116
+
117
+ **Solid**
118
+ ```jsx
119
+ import { createSignal } from 'solid-js';
120
+ function Counter() {
121
+ const [count, setCount] = createSignal(0);
122
+ return <button onClick={() => setCount(c => c + 1)}>{count()}</button>;
123
+ }
124
+ ```
125
+
126
+ **Objs**
127
+ ```js
128
+ // State lives in the element, not in a reactive variable
129
+ const counter = o.init({
130
+ name: 'Counter',
131
+ render: { tag: 'button', html: '0' },
132
+ inc: ({ self }) => { self.html(+self.el.textContent + 1); },
133
+ }).render().appendInside('#app');
134
+
135
+ counter.on('click', () => counter.inc());
136
+ ```
137
+
138
+ > In React/Vue/Solid, the framework schedules a re-render when state changes.
139
+ > In Objs, `inc()` writes `innerHTML` directly — no scheduler, no diff.
140
+
141
+ ---
142
+
143
+ ### Pattern 3 — Props / parameterized component
144
+
145
+ Pass data in at creation time.
146
+
147
+ **React**
148
+ ```jsx
149
+ function Card({ title, price }) {
150
+ return (
151
+ <article>
152
+ <h3>{title}</h3>
153
+ <p className="price">${price}</p>
154
+ </article>
155
+ );
156
+ }
157
+ <Card title="Laptop" price={999} />
158
+ ```
159
+
160
+ **Vue 3**
161
+ ```vue
162
+ <template>
163
+ <article>
164
+ <h3>{{ title }}</h3>
165
+ <p class="price">${{ price }}</p>
166
+ </article>
167
+ </template>
168
+ <script setup>
169
+ defineProps(['title', 'price']);
170
+ </script>
171
+ ```
172
+
173
+ **Solid**
174
+ ```jsx
175
+ function Card(props) {
176
+ return (
177
+ <article>
178
+ <h3>{props.title}</h3>
179
+ <p class="price">${props.price}</p>
180
+ </article>
181
+ );
182
+ }
183
+ <Card title="Laptop" price={999} />
184
+ ```
185
+
186
+ **Objs**
187
+ ```js
188
+ const cardStates = {
189
+ name: 'Card',
190
+ render: ({ title, price }) => ({
191
+ tag: 'article',
192
+ html: `<h3>${title}</h3><p class="price">$${price}</p>`,
193
+ }),
194
+ };
195
+ o.init(cardStates).render({ title: 'Laptop', price: 999 }).appendInside('#app');
196
+ ```
197
+
198
+ ---
199
+
200
+ ### Pattern 4 — Targeted partial update
201
+
202
+ Update one part of a component without touching the rest.
203
+
204
+ In React/Vue, the framework diffs and patches. In Solid, only the signal's text node updates. In Objs, you explicitly name which DOM node to write to.
205
+
206
+ **React**
207
+ ```jsx
208
+ // The whole Card function re-runs; React reconciles the output
209
+ function Card({ title }) {
210
+ const [price, setPrice] = React.useState(999);
211
+ return (
212
+ <article>
213
+ <h3>{title}</h3>
214
+ <p className="price">${price}</p>
215
+ </article>
216
+ );
217
+ // Caller: setPrice(89) — triggers rerender, React diffs and patches <p>
218
+ }
219
+ ```
220
+
221
+ **Vue 3**
222
+ ```vue
223
+ <template>
224
+ <article>
225
+ <h3>{{ title }}</h3>
226
+ <p class="price">${{ price }}</p>
227
+ </article>
228
+ </template>
229
+ <script setup>
230
+ import { ref } from 'vue';
231
+ defineProps(['title']);
232
+ const price = ref(999);
233
+ // Caller: price.value = 89 — Vue updates only the <p> text node
234
+ </script>
235
+ ```
236
+
237
+ **Solid**
238
+ ```jsx
239
+ // Only the text node that reads price() is updated — no component re-run
240
+ function Card(props) {
241
+ const [price, setPrice] = createSignal(999);
242
+ return (
243
+ <article>
244
+ <h3>{props.title}</h3>
245
+ <p class="price">${price()}</p>
246
+ </article>
247
+ );
248
+ // Caller: setPrice(89) — only the text node inside <p> changes
249
+ }
250
+ ```
251
+
252
+ **Objs**
253
+ ```js
254
+ const cardStates = {
255
+ name: 'Card',
256
+ render: ({ title, price }) => ({
257
+ tag: 'article',
258
+ html: `<h3>${title}</h3><p class="price">$${price}</p>`,
259
+ }),
260
+ // Direct write — no reactivity system, no scheduler, no diff
261
+ setPrice: ({ self }, p) => { self.first('.price').html(`$${p}`); },
262
+ };
263
+ const card = o.init(cardStates).render({ title: 'Laptop', price: 999 }).appendInside('#app');
264
+ card.setPrice(89); // one innerHTML write, nothing else evaluated
265
+ ```
266
+
267
+ ---
268
+
269
+ ### Pattern 5 — List rendering
270
+
271
+ Render a collection of items. Handle adding and removing individual items without re-rendering the whole list.
272
+
273
+ **React**
274
+ ```jsx
275
+ function ProductList({ products }) {
276
+ return (
277
+ <ul>
278
+ {products.map(p => <li key={p.id}>{p.name}</li>)}
279
+ </ul>
280
+ );
281
+ // When products changes, React re-diffs the entire list
282
+ }
283
+ ```
284
+
285
+ **Vue 3**
286
+ ```vue
287
+ <template>
288
+ <ul>
289
+ <li v-for="p in products" :key="p.id">{{ p.name }}</li>
290
+ </ul>
291
+ </template>
292
+ <script setup>
293
+ import { ref } from 'vue';
294
+ const products = ref([]);
295
+ // When products.value changes, Vue patches the list
296
+ </script>
297
+ ```
298
+
299
+ **Solid**
300
+ ```jsx
301
+ import { createSignal } from 'solid-js';
302
+ import { For } from 'solid-js';
303
+ function ProductList() {
304
+ const [products, setProducts] = createSignal([]);
305
+ return (
306
+ <ul>
307
+ <For each={products()}>
308
+ {p => <li>{p.name}</li>}
309
+ </For>
310
+ </ul>
311
+ );
312
+ // For only patches the changed item rows
313
+ }
314
+ ```
315
+
316
+ **Objs**
317
+ ```js
318
+ const listStates = {
319
+ name: 'ProductList',
320
+ render: { tag: 'ul' },
321
+ // Initial load — creates all items, stores by ID
322
+ load: ({ self }, products) => {
323
+ self.el.innerHTML = '';
324
+ self.store.items = {};
325
+ products.forEach(p => {
326
+ const item = o.initState({ tag: 'li', html: p.name });
327
+ item.appendInside(self.el);
328
+ self.store.items[p.id] = item;
329
+ });
330
+ },
331
+ // Update one item — O(1), only that text node changes
332
+ updateName: ({ self }, { id, name }) => { self.store.items[id]?.html(name); },
333
+ // Remove one item — only that node is removed
334
+ remove: ({ self }, id) => { self.store.items[id]?.unmount(); delete self.store.items[id]; },
335
+ // Add one item — no list re-render
336
+ addItem: ({ self }, p) => {
337
+ const item = o.initState({ tag: 'li', html: p.name });
338
+ item.appendInside(self.el);
339
+ self.store.items[p.id] = item;
340
+ },
341
+ };
342
+ const list = o.init(listStates).render().appendInside('#app');
343
+ list.load(products);
344
+ list.updateName({ id: 42, name: 'New name' }); // one write
345
+ ```
346
+
347
+ ---
348
+
349
+ ### Pattern 6 — Conditional rendering (show / hide)
350
+
351
+ Show or hide a field based on a checkbox.
352
+
353
+ **React**
354
+ ```jsx
355
+ function Form() {
356
+ const [showCompany, setShowCompany] = React.useState(false);
357
+ return (
358
+ <div>
359
+ <label>
360
+ <input type="checkbox" onChange={e => setShowCompany(e.target.checked)} />
361
+ {' '}Business account
362
+ </label>
363
+ {showCompany && <input name="company" placeholder="Company name" />}
364
+ </div>
365
+ );
366
+ // React unmounts / remounts the input element when showCompany toggles
367
+ }
368
+ ```
369
+
370
+ **Vue 3**
371
+ ```vue
372
+ <template>
373
+ <div>
374
+ <label><input type="checkbox" v-model="show" /> Business account</label>
375
+ <input v-if="show" name="company" placeholder="Company name" />
376
+ </div>
377
+ </template>
378
+ <script setup>
379
+ import { ref } from 'vue';
380
+ const show = ref(false);
381
+ // v-if removes / recreates the DOM node; v-show would toggle display
382
+ </script>
383
+ ```
384
+
385
+ **Solid**
386
+ ```jsx
387
+ import { createSignal, Show } from 'solid-js';
388
+ function Form() {
389
+ const [show, setShow] = createSignal(false);
390
+ return (
391
+ <div>
392
+ <label>
393
+ <input type="checkbox" onChange={e => setShow(e.target.checked)} />
394
+ {' '}Business account
395
+ </label>
396
+ <Show when={show()}>
397
+ <input name="company" placeholder="Company name" />
398
+ </Show>
399
+ </div>
400
+ );
401
+ }
402
+ ```
403
+
404
+ **Objs**
405
+ ```js
406
+ // The company field is a full atom — show/hide are state methods on it
407
+ const companyField = o.init(FieldStates).render({ name: 'company', label: 'Company name' });
408
+ companyField.hide(); // display:none on the atom's root element
409
+
410
+ const bizBox = o.initState({
411
+ tag: 'label', class: 'field',
412
+ html: '<input type="checkbox" class="biz-check"> Business account',
413
+ });
414
+
415
+ bizBox.first('.biz-check').on('change', e => {
416
+ e.target.checked ? companyField.show() : companyField.hide();
417
+ });
418
+ // DOM node is never removed — toggled with display:none/''
419
+ // To fully unmount and remount use .unmount() / .appendInside()
420
+ ```
421
+
422
+ ---
423
+
424
+ ### Pattern 7 — Shared state between distant components
425
+
426
+ A cart badge in the header and an "Add to cart" button deep in a product card both need to read from and write to the same count.
427
+
428
+ **React** — lift state up / Context
429
+ ```jsx
430
+ const CartContext = React.createContext(null);
431
+
432
+ function App() {
433
+ const [count, setCount] = React.useState(0);
434
+ const addItem = () => setCount(c => c + 1);
435
+ return (
436
+ <CartContext.Provider value={{ count, addItem }}>
437
+ <Header /> {/* reads count via useContext — rerenders on every count change */}
438
+ <ProductList /> {/* calls addItem — triggers App rerender → Header rerender */}
439
+ </CartContext.Provider>
440
+ );
441
+ }
442
+ function Header() {
443
+ const { count } = React.useContext(CartContext);
444
+ return <nav><span className="badge">{count}</span></nav>;
445
+ }
446
+ ```
447
+
448
+ **Vue 3** — Pinia store
449
+ ```js
450
+ // store.js
451
+ import { defineStore } from 'pinia';
452
+ export const useCartStore = defineStore('cart', () => {
453
+ const count = ref(0);
454
+ const add = () => count.value++;
455
+ return { count, add };
456
+ });
457
+ // Header.vue: const { count } = storeToRefs(useCartStore()) — only badge text updates
458
+ // ProductCard.vue: useCartStore().add()
459
+ ```
460
+
461
+ **Solid** — shared signal
462
+ ```js
463
+ // cart.js
464
+ import { createSignal } from 'solid-js';
465
+ const [cartCount, setCartCount] = createSignal(0);
466
+ export const addToCart = () => setCartCount(c => c + 1);
467
+ export { cartCount };
468
+ // Header.jsx: <span class="badge">{cartCount()}</span> — only this text node updates
469
+ // ProductCard.jsx: import { addToCart }; <button onClick={addToCart}>Add</button>
470
+ ```
471
+
472
+ **Objs** — plain observer, no library
473
+ ```js
474
+ // cart.js — just a plain object
475
+ const cartStore = { count: 0, listeners: [] };
476
+ export const addToCart = () => {
477
+ cartStore.count++;
478
+ cartStore.listeners.forEach(fn => fn(cartStore.count));
479
+ };
480
+
481
+ // header.js — subscribe: only this one innerHTML write per add
482
+ const navBadge = o.init(BadgeStates).render({ count: 0 }).appendInside('.nav');
483
+ cartStore.listeners.push(n => navBadge.setCount(n));
484
+
485
+ // product-card.js — publish
486
+ card.first('.add-btn').on('click', () => addToCart());
487
+ // No parent re-renders. No context. No signals. One function call → one DOM write.
488
+ ```
489
+
490
+ ---
491
+
492
+ ### Pattern 8 — Async data fetch
493
+
494
+ Load data from an API and render it. Handle the loading state.
495
+
496
+ **React**
497
+ ```jsx
498
+ function ProductList() {
499
+ const [products, setProducts] = React.useState([]);
500
+ const [loading, setLoading] = React.useState(true);
501
+ React.useEffect(() => {
502
+ fetch('/api/products')
503
+ .then(r => r.json())
504
+ .then(data => { setProducts(data); setLoading(false); });
505
+ }, []);
506
+ if (loading) return <p>Loading…</p>;
507
+ return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
508
+ }
509
+ ```
510
+
511
+ **Vue 3**
512
+ ```vue
513
+ <template>
514
+ <p v-if="loading">Loading…</p>
515
+ <ul v-else>
516
+ <li v-for="p in products" :key="p.id">{{ p.name }}</li>
517
+ </ul>
518
+ </template>
519
+ <script setup>
520
+ import { ref, onMounted } from 'vue';
521
+ const products = ref([]);
522
+ const loading = ref(true);
523
+ onMounted(() =>
524
+ fetch('/api/products').then(r => r.json()).then(data => {
525
+ products.value = data;
526
+ loading.value = false;
527
+ })
528
+ );
529
+ </script>
530
+ ```
531
+
532
+ **Solid**
533
+ ```jsx
534
+ import { createResource, Show, For } from 'solid-js';
535
+ function ProductList() {
536
+ const [products] = createResource(() =>
537
+ fetch('/api/products').then(r => r.json())
538
+ );
539
+ return (
540
+ <Show when={!products.loading} fallback={<p>Loading…</p>}>
541
+ <ul><For each={products()}>{p => <li>{p.name}</li>}</For></ul>
542
+ </Show>
543
+ );
544
+ }
545
+ ```
546
+
547
+ **Objs**
548
+ ```js
549
+ const listStates = {
550
+ name: 'ProductList',
551
+ render: { tag: 'div', html: '<p class="loading">Loading…</p>' },
552
+ // Called by the loader when data arrives — success state
553
+ load: ({ self }, products) => {
554
+ self.el.innerHTML = '';
555
+ products.forEach(p => o.initState({ tag: 'p', html: p.name }).appendInside(self.el));
556
+ },
557
+ // Called by the loader on network error — fail state
558
+ loadFailed: ({ self }) => { self.first('.loading').html('Failed to load. Retry?'); },
559
+ };
560
+
561
+ const list = o.init(listStates).render().appendInside('#app');
562
+ const loader = o.newLoader(o.get('/api/products'));
563
+ list.connect(loader, 'load', 'loadFailed');
564
+ // loader fires the request; .connect wires success → load, failure → loadFailed
565
+ ```
566
+
567
+ ---
568
+
569
+ ### Concept map — if you think in framework X
570
+
571
+ | Your mental model | React | Vue 3 | Solid | Objs |
572
+ |---|---|---|---|---|
573
+ | Component definition | `function Foo(props)` | `<script setup>` + `<template>` | `function Foo(props)` | `states` object |
574
+ | Create & mount | `createRoot(el).render(<Foo/>)` | `createApp(Foo).mount(el)` | `render(()=><Foo/>, el)` | `o.init(states).render(props).appendInside(el)` |
575
+ | Reactive value | `const [v, setV] = useState(x)` | `const v = ref(x)` | `const [v, setV] = createSignal(x)` | State method writing directly to DOM |
576
+ | Update a value | `setV(newVal)` → re-render | `v.value = newVal` → patch | `setV(newVal)` → fine patch | `comp.setState(newVal)` → direct write |
577
+ | Read in template | `{v}` | `{{ v }}` | `{v()}` | Not needed — state methods write directly |
578
+ | Props | function parameter | `defineProps()` | function parameter | `render(props)` argument |
579
+ | Events | `onClick={handler}` | `@click="handler"` | `onClick={handler}` | `.on('click', handler)` |
580
+ | Child ref | `useRef()` | `ref="name"` | `let el` / `ref` | `self.store.child = childInstance` |
581
+ | Lifecycle: mount | `useEffect(fn, [])` | `onMounted(fn)` | `onMount(fn)` | `init` state method called after render |
582
+ | Lifecycle: unmount | `useEffect` return fn | `onBeforeUnmount(fn)` | `onCleanup(fn)` | `comp.unmount()` |
583
+ | Shared state | Context / Zustand | Pinia | Signals / store | Plain observer or `o.connectRedux` |
584
+ | Fetch on mount | `useEffect` + `useState` | `onMounted` + `ref` | `createResource` | `o.newLoader` + `.connect()` |
585
+ | Conditional render | `{flag && <El/>}` | `v-if="flag"` | `<Show when={flag}>` | `comp.show()` / `comp.hide()` |
586
+ | List render | `arr.map(x => <El key>)` | `v-for="x in arr"` | `<For each={arr}>` | `arr.forEach` in a state method |
587
+ | CSS class toggle | `className={flag?'a':'b'}` | `:class="{a: flag}"` | `classList={{a: flag}}` | `comp.toggleClass('a', flag)` |
588
+
589
+ ---
590
+
591
+ ## 1. How render works
592
+
593
+ The `render` state is the creation state. It accepts five different forms.
594
+
595
+ ### 1a. Plain object — static element
596
+
597
+ The simplest form. All keys become HTML attributes; special keys (`html`, `style`, `dataset`, `class`, `append`, `children`) are handled by `transform()`.
598
+
599
+ ```js
600
+ const badge = o.init({
601
+ name: 'Badge',
602
+ render: { tag: 'span', class: 'badge', html: '3' },
603
+ update: ({ self }, n) => { self.html(n); },
604
+ }).render();
605
+ badge.appendInside('.nav');
606
+ badge.update(7); // writes only to this span's innerHTML
607
+ ```
608
+
609
+ ### 1b. Function returning an object — dynamic attributes
610
+
611
+ The function receives `{self, o, i, ...originalProps}`. All extra keys passed to `render(props)` are merged in.
612
+
613
+ > **Note:** Inside all state functions, `self` is the ObjsInstance. Use `self.first()`, `self.html()` etc. directly — `o(self)` works too but is redundant wrapping.
614
+
615
+ ```js
616
+ const buttonStates = {
617
+ name: 'Button',
618
+ render: ({ variant = 'default', size = 'md', label }) => ({
619
+ tag: 'button',
620
+ class: `btn btn--${variant} btn--${size}`,
621
+ html: label,
622
+ }),
623
+ setVariant: ({ self }, v) => { self.el.className = `btn btn--${v}`; },
624
+ setLabel: ({ self }, l) => { self.html(l); },
625
+ setDisabled:({ self }, v) => { v ? self.attr('disabled', 'true') : self.attr('disabled', null); },
626
+ };
627
+
628
+ const btn = o.init(buttonStates).render({ variant: 'primary', label: 'Submit' });
629
+ btn.appendInside('#toolbar');
630
+ btn.setLabel('Saving…'); // only this element's innerHTML changes
631
+ btn.setDisabled(true); // only this element's disabled attribute changes
632
+ ```
633
+
634
+ ### 1c. HTML string — unwrapped template
635
+
636
+ If the string contains exactly one root element it is unwrapped; otherwise a `div` wrapper is kept.
637
+
638
+ ```js
639
+ // Single root element — unwrapped to <a>
640
+ o.initState('<a href="/" class="logo">Brand</a>').appendInside('header');
641
+ ```
642
+
643
+ ### 1d. Multiple instances — one init, many elements
644
+
645
+ Pass an **array of props** to `render()`. One ObjsInstance manages all elements.
646
+ State methods operate on **all** elements by default. Use `.select(i)` for one.
647
+
648
+ ```js
649
+ const navLinkStates = {
650
+ name: 'NavLink',
651
+ render: ({ label, path }) => ({ tag: 'a', class: 'nav-link', html: label, href: path }),
652
+ setActive: ({ self }, activePath) => {
653
+ // 'self.el' is the element being iterated — transform() handles the loop internally
654
+ // For multi-element operations, use self.find/self.forEach
655
+ self.forEach(({ el }) => {
656
+ el.classList.toggle('nav-link--active', el.getAttribute('href') === activePath);
657
+ });
658
+ },
659
+ };
660
+
661
+ const links = o.init(navLinkStates).render([
662
+ { label: 'Home', path: '/' },
663
+ { label: 'Products', path: '/products' },
664
+ { label: 'About', path: '/about' },
665
+ ]);
666
+ // links.els = [<a>Home</a>, <a>Products</a>, <a>About</a>]
667
+ links.appendInside('.nav');
668
+ links.setActive('/products'); // updates all three (each checks its own href)
669
+ links.select(0).setActive('/'); // only first link
670
+ links.all(); // back to all-elements mode
671
+ ```
672
+
673
+ ### 1e. The `append` key — live child components
674
+
675
+ Pass pre-built ObjsInstances into the render object. Their DOM elements are appended into the parent. Children are created once; only state methods need to update them later.
676
+
677
+ ```js
678
+ const iconEl = o.initState({ tag: 'span', class: 'btn-icon', html: '🛒' });
679
+ const labelEl = o.initState({ tag: 'span', class: 'btn-label', html: 'Cart' });
680
+
681
+ const cartBtn = o.init({
682
+ name: 'CartButton',
683
+ render: { tag: 'button', class: 'cart-btn', append: [iconEl, labelEl] },
684
+ // Targeted update — only the label span changes
685
+ setCount: ({ self }, n) => { self.store.label.html(n > 0 ? `Cart (${n})` : 'Cart'); },
686
+ }).render();
687
+
688
+ cartBtn.store.label = labelEl; // store reference for later updates
689
+ cartBtn.appendInside('.nav');
690
+ cartBtn.setCount(3); // one innerHTML write on labelEl, nothing else touched
691
+ ```
692
+
693
+ ### 1f. The `children` key — positional reconciliation
694
+
695
+ Unlike `append` (which always adds), `children` replaces DOM nodes at each position — like a minimal reconciler. Use it when the order or count of child components changes.
696
+
697
+ ```js
698
+ const gridStates = {
699
+ name: 'CardGrid',
700
+ render: { tag: 'div', class: 'grid' },
701
+ // Returns an object — transform() applies it to the existing element
702
+ reconcile: ({ self }, cards) => ({ children: cards.map(c => c.el) }),
703
+ };
704
+
705
+ const grid = o.init(gridStates).render().appendInside('#app');
706
+ const cards = products.map(p => o.init(cardStates).render(p));
707
+ grid.reconcile(cards);
708
+
709
+ // Reorder: only moved nodes get replaceWith(), unchanged nodes are untouched
710
+ cards.sort((a, b) => a.store.price - b.store.price);
711
+ grid.reconcile(cards);
712
+ ```
713
+
714
+ ---
715
+
716
+ ### 1g. `ref` attributes and `self.refs`
717
+
718
+ Add a `ref="name"` attribute to any element in an HTML-string render. After `init`, every such element is available on the component as `component.refs.name` — an ObjsInstance wrapper, not a raw DOM node.
719
+
720
+ ```js
721
+ const cardStates = {
722
+ name: 'ProductCard',
723
+ render: ({ title, price }) => ({
724
+ tag: 'article',
725
+ className: 'card',
726
+ html: `<h3 ref="title">${title}</h3>
727
+ <p ref="price">$${price}</p>
728
+ <button ref="addBtn">Add to cart</button>`,
729
+ }),
730
+ // Destructure refs for clean, selector-free access
731
+ setAdded: ({ self }) => {
732
+ const { addBtn } = self.refs;
733
+ addBtn.html('✓ Added').attr('disabled', '');
734
+ },
735
+ updatePrice: ({ self }, newPrice) => {
736
+ self.refs.price.html('$' + newPrice);
737
+ },
738
+ };
739
+
740
+ const card = o.init(cardStates).render({ title: 'Widget', price: 9.99 }).appendInside('#app');
741
+ // card.refs.addBtn — ObjsInstance wrapping the <button ref="addBtn">
742
+ // card.refs.title — ObjsInstance wrapping the <h3 ref="title">
743
+ card.refs.addBtn.on('click', () => card.setAdded());
744
+ ```
745
+
746
+ > In React, `useRef` accesses a single DOM node. Objs `refs` auto-collects all named children at init time — no `useRef` call per element, no `ref={myRef}` on every JSX tag.
747
+
748
+ ---
749
+
750
+ ## 2. Single components — atoms
751
+
752
+ Atoms are self-contained components with no children. They define only their own element and targeted update states.
753
+
754
+ ### Button atom
755
+
756
+ ```js
757
+ const ButtonStates = {
758
+ name: 'Button',
759
+ render: ({ label, variant = 'default', size = 'md', disabled = false }) => ({
760
+ tag: 'button',
761
+ class: `btn btn--${variant} btn--${size}`,
762
+ html: label,
763
+ ...(disabled ? { disabled: 'true' } : {}),
764
+ }),
765
+ setLabel: ({ self }, l) => { self.html(l); },
766
+ setVariant: ({ self }, v) => { self.addClass(`btn--${v}`); },
767
+ setDisabled: ({ self }, v) => { v ? self.attr('disabled', 'true') : self.attr('disabled', null); },
768
+ setLoading: ({ self }, v) => {
769
+ self.toggleClass('btn--loading', v);
770
+ v ? self.attr('disabled', 'true') : self.attr('disabled', null);
771
+ },
772
+ };
773
+ ```
774
+
775
+ ### Badge atom
776
+
777
+ ```js
778
+ const BadgeStates = {
779
+ name: 'Badge',
780
+ render: ({ count = 0, variant = 'primary' }) => ({
781
+ tag: 'span',
782
+ class: `badge badge--${variant}`,
783
+ html: String(count),
784
+ style: count === 0 ? 'display:none' : '',
785
+ }),
786
+ setCount: ({ self }, n) => {
787
+ self.html(n);
788
+ // Re-apply inline style — transform skips unchanged values
789
+ n === 0 ? self.css({ display: 'none' }) : self.css(null);
790
+ },
791
+ };
792
+ ```
793
+
794
+ ### Input field atom
795
+
796
+ ```js
797
+ const FieldStates = {
798
+ name: 'FormField',
799
+ render: ({ name, label, type = 'text', placeholder = '' }) => ({
800
+ tag: 'div',
801
+ class: 'field',
802
+ html: `<label class="field__label">${label}</label>
803
+ <input class="field__input" type="${type}" name="${name}" placeholder="${placeholder}">
804
+ <span class="field__error"></span>`,
805
+ }),
806
+ // Each state only touches its specific sub-element
807
+ setError: ({ self }, msg) => {
808
+ self.first('.field__input').addClass('field__input--error');
809
+ self.first('.field__error').html(msg || '');
810
+ },
811
+ setSuccess: ({ self }) => {
812
+ self.first('.field__input').removeClass('field__input--error').addClass('field__input--ok');
813
+ self.first('.field__error').html('');
814
+ },
815
+ setIdle: ({ self }) => {
816
+ self.first('.field__input').removeClass('field__input--error').removeClass('field__input--ok');
817
+ self.first('.field__error').html('');
818
+ },
819
+ getValue: ({ self }) => self.first('.field__input').val(),
820
+ show: ({ self }) => { self.css(null); },
821
+ hide: ({ self }) => { self.css({ display: 'none' }); },
822
+ };
823
+ ```
824
+
825
+ ---
826
+
827
+ ## 3. Nesting & composition
828
+
829
+ Three patterns for building composite components. Choose based on how the parent and children relate.
830
+
831
+ ### Pattern A — Slot pattern
832
+
833
+ Best for: containers with named regions (cards, dialogs, panels, toolbars).
834
+
835
+ Parent defines named slot containers in `html`. Children fill them via `appendInside`. Parent tracks children in `self.store`.
836
+
837
+ **When to update:** call child's own state methods directly. The parent DOM is never touched.
838
+
839
+ ```js
840
+ const CardStates = {
841
+ name: 'Card',
842
+ render: {
843
+ tag: 'article',
844
+ class: 'card',
845
+ html: `<div class="card__header"></div>
846
+ <div class="card__body"></div>
847
+ <div class="card__footer"></div>`,
848
+ },
849
+ // Each slot setter unmounts the previous occupant and mounts the new one
850
+ setHeader: ({ self }, comp) => {
851
+ self.store.header?.unmount();
852
+ self.store.header = comp;
853
+ const slot = self.first('.card__header').el;
854
+ slot.innerHTML = '';
855
+ comp.appendInside(slot);
856
+ },
857
+ setBody: ({ self }, comp) => {
858
+ self.store.body?.unmount();
859
+ self.store.body = comp;
860
+ const slot = self.first('.card__body').el;
861
+ slot.innerHTML = '';
862
+ comp.appendInside(slot);
863
+ },
864
+ setFooter: ({ self }, comp) => {
865
+ self.store.footer?.unmount();
866
+ self.store.footer = comp;
867
+ const slot = self.first('.card__footer').el;
868
+ slot.innerHTML = '';
869
+ comp.appendInside(slot);
870
+ },
871
+ };
872
+
873
+ // Assembly — each component is independent, card just hosts them
874
+ const card = o.init(CardStates).render().appendInside('#app');
875
+ const title = o.init(ButtonStates).render({ label: 'Product Title', variant: 'ghost' });
876
+ const price = o.init(BadgeStates).render({ count: 49 });
877
+ const buyBtn = o.init(ButtonStates).render({ label: 'Add to cart', variant: 'primary' });
878
+
879
+ card.setHeader(title);
880
+ card.setBody(price);
881
+ card.setFooter(buyBtn);
882
+
883
+ // Update — zero DOM above the component
884
+ title.setLabel('New Product Name'); // only touches card__header's <button>
885
+ price.setCount(39); // only touches card__body's <span>
886
+ ```
887
+
888
+ ### Pattern B — append in render
889
+
890
+ Best for: molecules assembled from known atoms at creation time. Children are immutable after render.
891
+
892
+ Children are created before the parent and passed via `append`. The parent is a structural wrapper only.
893
+
894
+ ```js
895
+ function createSearchBar() {
896
+ // Build atoms first
897
+ const input = o.init({
898
+ name: 'SearchInput',
899
+ render: { tag: 'input', class: 'search__input', placeholder: 'Search…', type: 'search' },
900
+ clear: ({ self }) => { self.val(''); },
901
+ }).render();
902
+
903
+ const btn = o.init(ButtonStates).render({ label: '🔍', variant: 'icon' });
904
+
905
+ // Parent wraps them via append — no html needed
906
+ const bar = o.init({
907
+ name: 'SearchBar',
908
+ render: { tag: 'div', class: 'search-bar', append: [input, btn] },
909
+ clear: ({ self }) => { self.store.input.clear(); },
910
+ getValue: ({ self }) => self.store.input.val(),
911
+ focus: ({ self }) => { self.store.input.el.focus(); },
912
+ }).render();
913
+
914
+ // Store references after render
915
+ bar.store.input = input;
916
+ bar.store.btn = btn;
917
+
918
+ // Wire events using stored refs — no DOM queries needed
919
+ btn.on('click', () => {
920
+ bar.el.dispatchEvent(new CustomEvent('search', { detail: bar.getValue(), bubbles: true }));
921
+ });
922
+
923
+ return bar;
924
+ }
925
+
926
+ const searchBar = createSearchBar().appendInside('.toolbar');
927
+ document.addEventListener('search', (e) => console.log('query:', e.detail));
928
+ ```
929
+
930
+ ### Pattern C — factory function with lazy child creation
931
+
932
+ Best for: dynamic lists, data-driven components, components where children change at runtime.
933
+
934
+ Children are created inside a state method (not in render). The parent stores all child references and exposes methods to add, update, or remove individual items.
935
+
936
+ ```js
937
+ const ListStates = {
938
+ name: 'ProductList',
939
+ render: { tag: 'ul', class: 'product-list' },
940
+
941
+ // Called once when data arrives
942
+ load: ({ self }, products) => {
943
+ self.el.innerHTML = ''; // clear
944
+ self.store.items = {}; // reset map
945
+
946
+ products.forEach((product) => {
947
+ const card = o.init(CardStates).render();
948
+ const titleComp = o.init(ButtonStates).render({ label: product.title, variant: 'ghost' });
949
+ const priceComp = o.init(BadgeStates).render({ count: product.price });
950
+
951
+ card.setHeader(titleComp);
952
+ card.setBody(priceComp);
953
+ card.appendInside(self.el);
954
+
955
+ // Store by product ID for O(1) targeted updates
956
+ self.store.items[product.id] = { card, titleComp, priceComp, product };
957
+ });
958
+ },
959
+
960
+ // Update a single item — only its components are touched
961
+ updatePrice: ({ self }, { id, price }) => {
962
+ self.store.items[id]?.priceComp.setCount(price);
963
+ },
964
+
965
+ // Remove a single item — only its node is removed
966
+ remove: ({ self }, id) => {
967
+ self.store.items[id]?.card.unmount();
968
+ delete self.store.items[id];
969
+ },
970
+
971
+ // Add one item without re-rendering the list
972
+ addItem: ({ self }, product) => {
973
+ const card = o.init(CardStates).render();
974
+ card.setHeader(o.init(ButtonStates).render({ label: product.title, variant: 'ghost' }));
975
+ card.setBody(o.init(BadgeStates).render({ count: product.price }));
976
+ card.appendInside(self.el);
977
+ self.store.items[product.id] = { card, product };
978
+ },
979
+ };
980
+
981
+ const list = o.init(ListStates).render().appendInside('#products');
982
+ const loader = o.newLoader(o.get('/api/products'));
983
+ list.connect(loader, 'load');
984
+
985
+ // Later — only the changed item's badge is touched
986
+ list.updatePrice({ id: 42, price: 39 });
987
+ ```
988
+
989
+ ---
990
+
991
+ ## 4. Design system architecture
992
+
993
+ Objs maps naturally to Atomic Design. Each layer uses the composition pattern appropriate to its role.
994
+
995
+ ```
996
+ Atoms → pure state objects, no children, no store
997
+ Molecules → append pattern (atoms assembled at creation)
998
+ Organisms → slot pattern (molecules mounted into named regions)
999
+ Templates → factory functions wiring organisms to data stores
1000
+ ```
1001
+
1002
+ ### Layer 1 — Atoms (reusable state objects)
1003
+
1004
+ An atom is just a states object. Export it, share it, extend it.
1005
+
1006
+ ```js
1007
+ // atoms.js
1008
+ export const ButtonStates = { name: 'Button', render: ..., setLabel: ..., setDisabled: ... };
1009
+ export const BadgeStates = { name: 'Badge', render: ..., setCount: ... };
1010
+ export const FieldStates = { name: 'FormField', render: ..., setError: ..., setSuccess: ... };
1011
+ export const AvatarStates = { name: 'Avatar', render: ({ src, alt }) => ({ tag:'img', class:'avatar', src, alt }) };
1012
+ export const SpinnerStates = { name: 'Spinner', render: { tag:'div', class:'spinner' } };
1013
+ ```
1014
+
1015
+ Atoms should have **no `self.store` usage** and **no child components**. Every state method writes directly to the element or its immediate children (via `self.first()`).
1016
+
1017
+ ### Layer 2 — Molecules (assembled atoms)
1018
+
1019
+ A molecule is a factory function that builds atoms, wires them together, and returns the parent component.
1020
+
1021
+ ```js
1022
+ // molecules.js
1023
+ import { ButtonStates, BadgeStates } from './atoms.js';
1024
+
1025
+ export function createCartButton(initialCount = 0) {
1026
+ const badge = o.init(BadgeStates).render({ count: initialCount });
1027
+ const icon = o.initState({ tag: 'span', class: 'cart-icon', html: '🛒' });
1028
+
1029
+ const btn = o.init({
1030
+ name: 'CartButton',
1031
+ render: { tag: 'button', class: 'cart-btn', append: [icon, badge] },
1032
+ setCount: ({ self }, n) => { self.store.badge.setCount(n); },
1033
+ setLoading: ({ self }, v) => { self.toggleClass('cart-btn--loading', v); },
1034
+ }).render();
1035
+
1036
+ btn.store.badge = badge;
1037
+ btn.store.icon = icon;
1038
+ return btn;
1039
+ }
1040
+
1041
+ export function createIconButton(icon, label) {
1042
+ const iconEl = o.initState({ tag: 'span', class: 'btn__icon', html: icon });
1043
+ const labelEl = o.initState({ tag: 'span', class: 'btn__label', html: label });
1044
+
1045
+ const btn = o.init({
1046
+ name: 'IconButton',
1047
+ render: { tag: 'button', class: 'btn btn--icon', append: [iconEl, labelEl] },
1048
+ setIcon: ({ self }, v) => { self.store.icon.html(v); },
1049
+ setLabel: ({ self }, v) => { self.store.label.html(v); },
1050
+ }).render();
1051
+
1052
+ btn.store.icon = iconEl;
1053
+ btn.store.label = labelEl;
1054
+ return btn;
1055
+ }
1056
+ ```
1057
+
1058
+ ### Layer 3 — Organisms (slot-based containers)
1059
+
1060
+ An organism uses the slot pattern. It defines its structure and exposes methods to mount molecules into named regions.
1061
+
1062
+ ```js
1063
+ // organisms.js
1064
+ import { createCartButton, createSearchBar } from './molecules.js';
1065
+
1066
+ export const ToolbarStates = {
1067
+ name: 'Toolbar',
1068
+ render: {
1069
+ tag: 'header',
1070
+ class: 'toolbar',
1071
+ html: `<div class="toolbar__start"></div>
1072
+ <div class="toolbar__center"></div>
1073
+ <div class="toolbar__end"></div>`,
1074
+ },
1075
+ mount: ({ self }, { slot, comp }) => {
1076
+ const slotEl = self.first(`.toolbar__${slot}`).el;
1077
+ self.store[slot]?.unmount();
1078
+ self.store[slot] = comp;
1079
+ slotEl.innerHTML = '';
1080
+ comp.appendInside(slotEl);
1081
+ },
1082
+ // Expose slot accessors
1083
+ getSlot: ({ self }, slot) => self.store[slot],
1084
+ };
1085
+
1086
+ // Template — assembles the toolbar, wires it to stores
1087
+ export function createAppToolbar(cartStore) {
1088
+ const logo = o.initState({ tag: 'a', class: 'logo', href: '/', html: 'Brand' });
1089
+ const search = createSearchBar();
1090
+ const cartBtn = createCartButton(0);
1091
+
1092
+ const toolbar = o.init(ToolbarStates).render().appendInside('body');
1093
+ toolbar.mount({ slot: 'start', comp: logo });
1094
+ toolbar.mount({ slot: 'center', comp: search });
1095
+ toolbar.mount({ slot: 'end', comp: cartBtn });
1096
+
1097
+ // Wire cart store — only the badge updates on change
1098
+ cartStore.listeners.push((items) => cartBtn.setCount(items.length));
1099
+
1100
+ return { toolbar, search, cartBtn };
1101
+ }
1102
+ ```
1103
+
1104
+ ### Extending states (variant inheritance)
1105
+
1106
+ Extend an atom's states object with `Object.assign` to create specialised variants:
1107
+
1108
+ ```js
1109
+ // atoms.js
1110
+ export const ButtonStates = { name: 'Button', render: ..., setLabel: ..., setDisabled: ... };
1111
+
1112
+ // Add a variant — extends without modifying the base
1113
+ export const SubmitButtonStates = {
1114
+ ...ButtonStates,
1115
+ name: 'SubmitButton',
1116
+ render: ({ label = 'Submit', ...rest }) => ButtonStates.render({ label, variant: 'primary', ...rest }),
1117
+ // Adds loading state on top of atom's states
1118
+ submit: ({ self }) => {
1119
+ self.setDisabled(true);
1120
+ self.setLabel('Saving…');
1121
+ self.toggleClass('btn--loading', true);
1122
+ },
1123
+ reset: ({ self }) => {
1124
+ self.setDisabled(false);
1125
+ self.setLabel('Submit');
1126
+ self.toggleClass('btn--loading', false);
1127
+ },
1128
+ };
1129
+ ```
1130
+
1131
+ ### Update efficiency summary
1132
+
1133
+ | Update target | How to do it | DOM writes |
1134
+ |---|---|---|
1135
+ | Atom attribute | `atom.setLabel('x')` | 1 — direct innerHTML or setAttribute |
1136
+ | Atom in molecule | `molecule.store.atom.setLabel('x')` | 1 — same atom write |
1137
+ | Slot in organism | `organism.store.slot.stateMethod()` | 1 or more — only that slot's subtree |
1138
+ | List item | `list.store.items[id].comp.update(data)` | 1+ — only that card |
1139
+ | All list items | `list.reconcile(newOrder)` | replaceWith per changed position only |
1140
+ | Full list reload | `list.load(newProducts)` | clears and re-creates |
1141
+
1142
+ ---
1143
+
1144
+ ## 5. Real-world examples
1145
+
1146
+ ### 5a. Site navigation menu
1147
+
1148
+ Active route highlighting, mobile toggle. Uses multi-instance render for links.
1149
+
1150
+ ```js
1151
+ o.autotag = 'qa';
1152
+
1153
+ const navLinkStates = {
1154
+ name: 'NavLink',
1155
+ render: ({ label, path }) => ({ tag: 'a', class: 'nav-link', html: label, href: path }),
1156
+ setActive: ({ self }, activePath) => {
1157
+ self.forEach(({ el }) => {
1158
+ el.classList.toggle('nav-link--active', el.getAttribute('href') === activePath);
1159
+ });
1160
+ },
1161
+ };
1162
+
1163
+ const toggleStates = {
1164
+ name: 'NavToggle',
1165
+ render: { tag: 'button', class: 'nav-toggle', html: '☰' },
1166
+ };
1167
+
1168
+ const menuStates = {
1169
+ name: 'SiteMenu',
1170
+ render: { tag: 'nav', class: 'nav' },
1171
+ init: ({ self }) => {
1172
+ const links = o.init(navLinkStates).render([
1173
+ { label: 'Home', path: '/' },
1174
+ { label: 'Products', path: '/products' },
1175
+ { label: 'About', path: '/about' },
1176
+ ]);
1177
+ const toggle = o.init(toggleStates).render();
1178
+
1179
+ links.appendInside(self.el);
1180
+ toggle.appendInside(self.el);
1181
+
1182
+ // Store for later access
1183
+ self.store.links = links;
1184
+ self.store.toggle = toggle;
1185
+
1186
+ links.setActive(window.location.pathname);
1187
+ toggle.on('click', () => self.toggleClass('nav--open'));
1188
+ },
1189
+ };
1190
+
1191
+ const menu = o.init(menuStates).render().appendInside('header');
1192
+ menu.init(); // sets up children
1193
+ ```
1194
+
1195
+ ### 5b. Product card list + cart badge
1196
+
1197
+ One shared cart store, two components subscribing to different state methods. Neither rerenders when the other updates.
1198
+
1199
+ ```js
1200
+ // ── Shared cart store (plain object with listeners) ───────────────────────
1201
+ const cartStore = { items: [], listeners: [] };
1202
+ const cartAdd = (product) => { cartStore.items.push(product); cartStore.listeners.forEach(fn => fn(cartStore.items)); };
1203
+
1204
+ // ── Cart badge in nav ─────────────────────────────────────────────────────
1205
+ const cartBadge = o.init(BadgeStates).render({ count: 0 }).appendInside('.nav');
1206
+ cartStore.listeners.push((items) => cartBadge.setCount(items.length));
1207
+
1208
+ // ── Product card ──────────────────────────────────────────────────────────
1209
+ const productCardStates = {
1210
+ name: 'ProductCard',
1211
+ render: ({ title, price }) => ({
1212
+ tag: 'article',
1213
+ class: 'card',
1214
+ html: `<h3 class="card__title">${title}</h3>
1215
+ <p class="card__price">$${price}</p>
1216
+ <button class="card__btn">Add to cart</button>`,
1217
+ }),
1218
+ // Granular: only the button changes, card root never touched
1219
+ setAdded: ({ self }) => {
1220
+ self.first('.card__btn').html('✓ Added').attr('disabled', '');
1221
+ },
1222
+ };
1223
+
1224
+ // ── Product list (factory pattern — children in self.store) ───────────────
1225
+ const productListStates = {
1226
+ name: 'ProductList',
1227
+ render: { tag: 'div', class: 'card-list' },
1228
+ load: ({ self }, products) => {
1229
+ self.store.cards = {};
1230
+ products.forEach((product) => {
1231
+ const card = o.init(productCardStates).render(product);
1232
+ card.appendInside(self.el);
1233
+ // Wire button — no DOM query needed, card has .first()
1234
+ card.first('.card__btn').on('click', () => {
1235
+ cartAdd(product);
1236
+ card.setAdded(); // only this card's button changes
1237
+ });
1238
+ self.store.cards[product.id] = card;
1239
+ });
1240
+ },
1241
+ };
1242
+
1243
+ const productList = o.init(productListStates).render().appendInside('#products');
1244
+ productList.connect(o.newLoader(o.get('/api/products')), 'load');
1245
+ ```
1246
+
1247
+ ### 5c. Overlay dialog with UTM auto-open
1248
+
1249
+ Promo dialog triggered by `?promo=CODE` URL parameter. sessionStorage prevents repeat shows.
1250
+
1251
+ ```js
1252
+ const PROMO_KEY = 'oTest-promo-shown';
1253
+
1254
+ const dialogStates = {
1255
+ name: 'PromoDialog',
1256
+ render: {
1257
+ tag: 'div', class: 'dialog-overlay', style: 'display:none',
1258
+ html: `<div class="dialog">
1259
+ <button class="dialog__close">✕</button>
1260
+ <h2 class="dialog__title"></h2>
1261
+ <p class="dialog__body"></p>
1262
+ <a class="dialog__cta" href="#">Get offer</a>
1263
+ </div>`,
1264
+ },
1265
+ open: ({ self }, { title, body, cta, ctaUrl }) => {
1266
+ self.first('.dialog__title').html(title);
1267
+ self.first('.dialog__body').html(body);
1268
+ self.first('.dialog__cta').html(cta).attr('href', ctaUrl);
1269
+ self.css({ display: 'flex' });
1270
+ sessionStorage.setItem(PROMO_KEY, '1');
1271
+ },
1272
+ close: ({ self }) => { self.css(null); },
1273
+ };
1274
+
1275
+ const dialog = o.init(dialogStates).render().appendInside('body');
1276
+ dialog.first('.dialog__close').on('click', () => dialog.close());
1277
+ dialog.on('click', (e) => { if (e.target === dialog.el) dialog.close(); });
1278
+
1279
+ const promoCode = o.getParams('promo');
1280
+ if (promoCode && !sessionStorage.getItem(PROMO_KEY)) {
1281
+ o.get('/api/promos/' + promoCode).then(r => r.json()).then(data => dialog.open(data));
1282
+ }
1283
+ ```
1284
+
1285
+ ### 5d. Filter drawer with two-way URL sync
1286
+
1287
+ Filters write to the URL and restore from it on page load. No reload needed.
1288
+
1289
+ ```js
1290
+ const getFilters = () => {
1291
+ const p = o.getParams();
1292
+ return { category: p.category || '', minPrice: p.minPrice || '', maxPrice: p.maxPrice || '' };
1293
+ };
1294
+
1295
+ const applyFilters = (filters, productsLoader) => {
1296
+ const params = new URLSearchParams(filters);
1297
+ for (const [k, v] of [...params]) { if (!v) params.delete(k); }
1298
+ history.pushState({}, '', params.toString() ? '?' + params : window.location.pathname);
1299
+ productsLoader.reload(o.get('/api/products?' + params.toString()));
1300
+ };
1301
+
1302
+ const drawerStates = {
1303
+ name: 'FilterDrawer',
1304
+ render: {
1305
+ tag: 'aside', class: 'drawer', style: 'transform:translateX(-100%)',
1306
+ html: `<button class="drawer__close">✕</button>
1307
+ <h3>Filters</h3>
1308
+ <select class="drawer__cat"><option value="">All</option><option value="electronics">Electronics</option></select>
1309
+ <input class="drawer__min" type="number" placeholder="Min $">
1310
+ <input class="drawer__max" type="number" placeholder="Max $">
1311
+ <button class="drawer__apply">Apply</button>
1312
+ <button class="drawer__reset">Reset</button>`,
1313
+ },
1314
+ open: ({ self }) => { self.css({ transform: 'translateX(0)' }); },
1315
+ close: ({ self }) => { self.css({ transform: 'translateX(-100%)' }); },
1316
+ restore: ({ self }, { category, minPrice, maxPrice }) => {
1317
+ self.first('.drawer__cat').val(category);
1318
+ self.first('.drawer__min').val(minPrice);
1319
+ self.first('.drawer__max').val(maxPrice);
1320
+ },
1321
+ getValues: ({ self }) => ({
1322
+ category: self.first('.drawer__cat').val(),
1323
+ minPrice: self.first('.drawer__min').val(),
1324
+ maxPrice: self.first('.drawer__max').val(),
1325
+ }),
1326
+ };
1327
+
1328
+ const drawer = o.init(drawerStates).render().appendInside('body');
1329
+ drawer.restore(getFilters());
1330
+
1331
+ drawer.first('.drawer__close').on('click', () => drawer.close());
1332
+ drawer.first('.drawer__apply').on('click', () => {
1333
+ applyFilters(drawer.getValues(), productsLoader);
1334
+ drawer.close();
1335
+ });
1336
+ drawer.first('.drawer__reset').on('click', () => {
1337
+ const empty = { category: '', minPrice: '', maxPrice: '' };
1338
+ applyFilters(empty, productsLoader);
1339
+ drawer.restore(empty);
1340
+ });
1341
+
1342
+ o.first('#open-filters').on('click', () => drawer.open());
1343
+ ```
1344
+
1345
+ ### 5e. Complex form — validation, conditional fields, live preview
1346
+
1347
+ Each field is an independent atom. Validation state and submit gate are pure JS, not in the DOM.
1348
+
1349
+ ```js
1350
+ const validators = {
1351
+ email: v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'Invalid email address',
1352
+ name: v => v.trim().length >= 2 || 'Minimum 2 characters',
1353
+ company: v => v.trim().length >= 1 || 'Required for business accounts',
1354
+ };
1355
+
1356
+ // ── Field atom (reuses FieldStates from section 2) ────────────────────────
1357
+ const emailField = o.init(FieldStates).render({ name: 'email', label: 'Email', placeholder: 'you@example.com' });
1358
+ const nameField = o.init(FieldStates).render({ name: 'name', label: 'Full name', placeholder: 'Jane Smith' });
1359
+ const companyField = o.init(FieldStates).render({ name: 'company', label: 'Company name', placeholder: 'Acme Inc' });
1360
+
1361
+ // ── Checkbox (simple initState — no custom methods needed) ────────────────
1362
+ const bizBox = o.initState({
1363
+ tag: 'label', class: 'field',
1364
+ html: '<input type="checkbox" name="isBusiness" class="biz-check"> Business account',
1365
+ });
1366
+
1367
+ // ── Live preview ──────────────────────────────────────────────────────────
1368
+ const previewStates = {
1369
+ name: 'FormPreview',
1370
+ render: { tag: 'div', class: 'preview', html: 'Preview: <b class="pv-name">—</b> &lt;<span class="pv-email">—</span>&gt;' },
1371
+ update: ({ self }, { name, email }) => {
1372
+ self.first('.pv-name').html(name || '—');
1373
+ self.first('.pv-email').html(email || '—');
1374
+ },
1375
+ };
1376
+ const preview = o.init(previewStates).render();
1377
+
1378
+ // ── Form organism (slot pattern) ──────────────────────────────────────────
1379
+ const formStates = {
1380
+ name: 'RegistrationForm',
1381
+ render: {
1382
+ tag: 'form', class: 'form',
1383
+ html: '<div class="form__fields"></div><div class="form__preview"></div><button type="submit" class="btn btn--primary" disabled>Submit</button>',
1384
+ },
1385
+ init: ({ self }) => {
1386
+ const fieldsRoot = self.first('.form__fields').el;
1387
+ const previewRoot = self.first('.form__preview').el;
1388
+
1389
+ emailField.appendInside(fieldsRoot);
1390
+ nameField.appendInside(fieldsRoot);
1391
+ bizBox.appendInside(fieldsRoot);
1392
+ companyField.appendInside(fieldsRoot);
1393
+ preview.appendInside(previewRoot);
1394
+
1395
+ companyField.hide();
1396
+
1397
+ self.store.fields = { emailField, nameField, companyField };
1398
+ self.store.preview = preview;
1399
+ self.store.valid = { email: false, name: false, company: true, isBiz: false };
1400
+ },
1401
+ checkSubmit: ({ self }) => {
1402
+ const v = self.store.valid;
1403
+ const ok = v.email && v.name && (!v.isBiz || v.company);
1404
+ self.first('button[type="submit"]').el.disabled = !ok;
1405
+ },
1406
+ };
1407
+
1408
+ const form = o.init(formStates).render().appendInside('#form-container');
1409
+ form.init();
1410
+
1411
+ // ── Validation wiring ─────────────────────────────────────────────────────
1412
+ const validate = (fieldComp, rule, value) => {
1413
+ const res = validators[rule](value);
1414
+ res === true ? fieldComp.setSuccess() : fieldComp.setError(res);
1415
+ return res === true;
1416
+ };
1417
+
1418
+ emailField.first('input')
1419
+ .on('blur', (e) => { form.store.valid.email = validate(emailField, 'email', e.target.value); form.checkSubmit(); })
1420
+ .on('input', (e) => { preview.update({ name: nameField.getValue(), email: e.target.value }); });
1421
+
1422
+ nameField.first('input')
1423
+ .on('blur', (e) => { form.store.valid.name = validate(nameField, 'name', e.target.value); form.checkSubmit(); })
1424
+ .on('input', (e) => { preview.update({ name: e.target.value, email: emailField.getValue() }); });
1425
+
1426
+ bizBox.first('.biz-check').on('change', (e) => {
1427
+ form.store.valid.isBiz = e.target.checked;
1428
+ if (e.target.checked) {
1429
+ companyField.show();
1430
+ } else {
1431
+ companyField.hide();
1432
+ companyField.setIdle();
1433
+ form.store.valid.company = true;
1434
+ }
1435
+ form.checkSubmit();
1436
+ });
1437
+
1438
+ companyField.first('input')
1439
+ .on('blur', (e) => { form.store.valid.company = validate(companyField, 'company', e.target.value); form.checkSubmit(); });
1440
+
1441
+ form.on('submit', (e) => {
1442
+ e.preventDefault();
1443
+ const submitBtn = form.first('button[type="submit"]');
1444
+ submitBtn.setLoading?.(true);
1445
+ o.post('/api/register', { data: Object.fromEntries(new FormData(e.target)) })
1446
+ .then(r => r.json())
1447
+ .then(() => submitBtn.setLoading?.(false));
1448
+ });
1449
+ ```
1450
+
1451
+ ---
1452
+
1453
+ ## 6. React integration
1454
+
1455
+ ### Mode 1 — Objs inside a React ref (most common)
1456
+
1457
+ Mount an Objs component into a React-managed DOM node via `useRef`. Always unmount in the cleanup function.
1458
+
1459
+ ```jsx
1460
+ function ProductSection() {
1461
+ const containerRef = React.useRef(null);
1462
+
1463
+ React.useEffect(() => {
1464
+ if (!containerRef.current) return;
1465
+
1466
+ // Create Objs components inside useEffect — NOT in component body
1467
+ const list = o.init(productListStates).render().appendInside(containerRef.current);
1468
+ list.connect(o.newLoader(o.get('/api/products')), 'load');
1469
+
1470
+ return () => list.unmount(); // required — prevents memory leak on unmount
1471
+ }, []); // empty deps — run once
1472
+
1473
+ return <div ref={containerRef} />;
1474
+ }
1475
+ ```
1476
+
1477
+ ### Mode 2 — React context bridge (shared state, no React rerender)
1478
+
1479
+ `o.withReactContext` returns a React component that calls an Objs state method when context changes. It renders nothing — it is a pure side-effect bridge.
1480
+
1481
+ ```jsx
1482
+ // CartContext.js
1483
+ export const CartContext = React.createContext({ items: [], addItem: () => {} });
1484
+
1485
+ // App.jsx
1486
+ import { CartContext } from './CartContext';
1487
+
1488
+ function App() {
1489
+ const [items, setItems] = React.useState([]);
1490
+ const addItem = React.useCallback((item) => setItems(prev => [...prev, item]), []);
1491
+
1492
+ return (
1493
+ <CartContext.Provider value={{ items, addItem }}>
1494
+ <Header />
1495
+ <ProductSection addItem={addItem} />
1496
+ </CartContext.Provider>
1497
+ );
1498
+ }
1499
+
1500
+ // ProductSection.jsx — Objs grid with React context bridge
1501
+ function ProductSection({ addItem }) {
1502
+ const containerRef = React.useRef(null);
1503
+
1504
+ React.useEffect(() => {
1505
+ if (!containerRef.current) return;
1506
+
1507
+ // All Objs components created inside useEffect
1508
+ const cartBadge = o.init(BadgeStates).render({ count: 0 });
1509
+ const grid = o.init(productListStates).render().appendInside(containerRef.current);
1510
+
1511
+ // Wire the add-to-cart action to React's state setter
1512
+ grid.store.onAdd = (product) => {
1513
+ addItem(product); // React's useState → React header rerenders with new count
1514
+ };
1515
+
1516
+ grid.connect(o.newLoader(o.get('/api/products')), 'load');
1517
+
1518
+ // Store cartBadge on the ref so the bridge component can access it
1519
+ containerRef.current._cartBadge = cartBadge;
1520
+
1521
+ return () => { grid.unmount(); cartBadge.unmount(); };
1522
+ }, []);
1523
+
1524
+ // Bridge: CartContext.items → cartBadge.setCount (no React subtree rerender)
1525
+ // Created inline — React creates it once, re-runs only when context changes
1526
+ const CartBridge = React.useMemo(
1527
+ () => o.withReactContext(
1528
+ React,
1529
+ CartContext,
1530
+ ctx => ctx.items.length,
1531
+ // cartBadge may not exist yet on first render — the bridge handles undefined gracefully
1532
+ { setCount: (n) => containerRef.current?._cartBadge?.setCount(n) },
1533
+ 'setCount'
1534
+ ),
1535
+ []
1536
+ );
1537
+
1538
+ return (
1539
+ <div>
1540
+ <CartBridge /> {/* renders null — pure side effect */}
1541
+ <div ref={containerRef} />
1542
+ </div>
1543
+ );
1544
+ }
1545
+ ```
1546
+
1547
+ ### Mode 3 — Objs element as React element
1548
+
1549
+ Use `prepareFor(React)` to export a rendered Objs element into a React render tree.
1550
+
1551
+ ```jsx
1552
+ function AnimatedBadge({ count }) {
1553
+ const badge = React.useMemo(() => o.init(BadgeStates).render({ count }), []);
1554
+
1555
+ React.useEffect(() => {
1556
+ badge.setCount(count);
1557
+ }, [count]);
1558
+
1559
+ // Returns a React element that mounts the Objs DOM node
1560
+ return badge.prepareFor(React);
1561
+ }
1562
+ ```
1563
+
1564
+ ### Mode 4 — Bolt-on Playwright test generation for existing React apps
1565
+
1566
+ Live demo: [Recording & Test Generation](examples/recording/index.html) — task app with scoped `o.startRecording(observe)`, auto-generated assertions in the export, and the dev-only manual check overlay `o.testConfirm` after replay.
1567
+
1568
+ Add one script tag to `index.html` (dev/staging only, stripped from prod via the `__DEV__` block):
1569
+
1570
+ ```html
1571
+ <script src="objs.js"></script>
1572
+ <script> o.autotag = 'qa'; </script>
1573
+ ```
1574
+
1575
+ Mark key React elements with stable QA selectors — three options:
1576
+
1577
+ ```jsx
1578
+ // A) Manual attribute
1579
+ <button data-qa="checkout-btn" onClick={handleCheckout}>Checkout</button>
1580
+
1581
+ // B) o.reactQA() utility — converts CamelCase to kebab-case automatically
1582
+ <button {...o.reactQA('CheckoutButton')} onClick={handleCheckout}>Checkout</button>
1583
+ // → <button data-qa="checkout-button">
1584
+
1585
+ // C) Inline hook (no import needed)
1586
+ const useQA = n => ({ 'data-qa': n.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '') });
1587
+ <button {...useQA('CheckoutButton')} onClick={handleCheckout}>Checkout</button>
1588
+ ```
1589
+
1590
+ From the browser console (or a QA toolbar injected into staging):
1591
+
1592
+ ```js
1593
+ o.startRecording();
1594
+ // QA tester uses the app normally — clicks, fills forms, navigates
1595
+ const rec = o.stopRecording();
1596
+ console.log(o.exportPlaywrightTest(rec, { testName: 'Checkout flow' }));
1597
+ ```
1598
+
1599
+ Generated output — paste into `tests/checkout.spec.ts`:
1600
+
1601
+ ```ts
1602
+ // Auto-generated by o.exportPlaywrightTest() — review and anonymize mocks before committing
1603
+ // Prerequisites: npm install @playwright/test && npx playwright install chromium
1604
+ // Run: npx playwright test checkout.spec.ts
1605
+ import { test, expect } from '@playwright/test';
1606
+
1607
+ test('Checkout flow', async ({ page }) => {
1608
+ // Network mocks — edit/anonymize before committing
1609
+ await page.route('**/api/cart', async route => {
1610
+ await route.fulfill({ status: 200, contentType: 'application/json',
1611
+ body: JSON.stringify({ items: [], total: 0 }) });
1612
+ });
1613
+
1614
+ // Set baseURL in playwright.config.ts: { use: { baseURL: 'https://staging.example.com' } }
1615
+ await page.goto('/checkout');
1616
+
1617
+ await page.locator('[data-qa="email-field"]').fill('jane@example.com');
1618
+ await page.locator('[data-qa="checkout-button"]').click();
1619
+
1620
+ // TODO: Add assertions before committing, e.g.:
1621
+ // await expect(page.locator('[data-qa="success-panel"]')).toBeVisible();
1622
+ // await expect(page).toHaveURL(/\/confirmation/);
1623
+ });
1624
+ ```
1625
+
1626
+ > The `data-qa` selectors set by `o.autotag` or `o.reactQA()` are stable across deploys — they don't change when class names are renamed or elements are restructured.
1627
+
1628
+ ### Key rules for React coexistence
1629
+
1630
+ | Rule | Reason |
1631
+ |---|---|
1632
+ | Create Objs components inside `useEffect`, not component body | Component body re-runs on every React render — Objs components would be recreated |
1633
+ | Always call `component.unmount()` in the useEffect return | Prevents memory leaks and orphaned event listeners |
1634
+ | Do not use `appendInside` with React-managed selectors | React may move or replace the DOM node — use `ref.current` |
1635
+ | Use `o.withReactContext` for context→Objs data flow | Zero React rerender; direct Objs state call |
1636
+ | Use React's `useState`/`useCallback` for Objs→React data flow | `addItem(product)` calls React's setter which triggers React's rerender in its own tree |
1637
+ | Use `o.reactQA(name)` for stable test selectors | `data-qa` attributes survive CSS refactors and component restructuring |