ivue 2.2.1 → 2.3.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/README.md CHANGED
@@ -10,52 +10,35 @@
10
10
  <h3 align="center">Plain classes. Full reactivity.<br>Infinite scalability. One kilobyte.</h3>
11
11
 
12
12
  <p align="center">
13
- <a href="https://www.npmjs.com/package/ivue"><img src="https://img.shields.io/npm/v/ivue.svg" alt="npm"></a>
13
+ <a href="https://www.npmjs.com/package/ivue"><img src="https://img.shields.io/npm/v/ivue.svg" alt="npm version"></a>
14
+ <a href="https://www.npmjs.com/package/ivue"><img src="https://img.shields.io/npm/dm/ivue.svg" alt="npm downloads"></a>
14
15
  <a href="https://github.com/infinite-system/ivue/actions/workflows/ci.yml"><img src="https://github.com/infinite-system/ivue/actions/workflows/ci.yml/badge.svg?branch=main" alt="build status"></a>
16
+ <img src="https://img.shields.io/badge/coverage-100%25-34d399" alt="100% test coverage">
17
+ <img src="https://img.shields.io/badge/gzipped-1.1%20kB-818cf8" alt="1.1 kB gzipped">
18
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a>
15
19
  </p>
16
20
 
17
21
  <p align="center">
18
- <strong>Docs: <a href="https://ivue.dev/">ivue.dev</a></strong>
22
+ <a href="https://ivue.dev/">Docs</a> ·
23
+ <a href="https://ivue.dev/guide/getting-started">Getting Started</a> ·
24
+ <a href="https://ivue.dev/guide/standard">The Standard</a> ·
25
+ <a href="https://ivue.dev/examples/">Examples</a> ·
26
+ <a href="https://ivue.dev/guide/benchmarks">Benchmarks</a> ·
27
+ <a href="https://ivue.dev/blog/">Blog</a> ·
28
+ <a href="https://ivue.dev/community">Community</a>
19
29
  </p>
20
30
 
31
+ ---
32
+
21
33
  ivue builds Vue 3 reactivity out of plain TypeScript classes. Real
22
34
  inheritance, real encapsulation, real polymorphism — on ordinary objects,
23
- with nothing paid until first access. The whole engine is **1.1kb gzipped**
35
+ with nothing paid until first access. The whole engine is **1.1 kB gzipped**
24
36
  with zero dependencies.
25
37
 
26
- - **Native class API** — `extends`, `super`, getters, setters, private
27
- fields. Real inheritance, encapsulation and polymorphism, all reactive.
28
- - **Zero-cost creation** — instances are plain objects. A million of them
29
- take 22 ms — 6 to 132× faster than the alternatives.
30
- - **One kilobyte** — 1.1kb gzipped, zero dependencies, 100% test coverage.
31
- Stripped to the load-bearing core — an API you can hold in your head.
32
- - **Store or ViewModel** — the same class serves as a global store, a
33
- component ViewModel, or a domain model. One mental model everywhere.
34
- - **Composition API, fully compatible** — composables plug in through
35
- `$`-getters. The entire Vue ecosystem works inside your classes.
36
- - **TypeScript first** — writable ref-getters, fully typed instances,
37
- precise inference. The type system shaped the engine's design.
38
-
39
- ## Getting started
40
-
41
38
  ```sh
42
39
  npm i ivue vue
43
40
  ```
44
41
 
45
- ## Built for humans and AI
46
-
47
- ivue ships with an [Operating Manual](https://ivue.dev/guide/standard) —
48
- the complete authoring standard as annotated templates, rules, and a review
49
- checklist. It reads as documentation and works as a drop-in skill for AI
50
- coding agents, so generated code follows the same standard your team writes:
51
-
52
- ```sh
53
- npx ivue skill # installs .claude/skills/ivue/SKILL.md, version-locked
54
- npx ivue skill --all # + Codex/Cursor/Copilot where already in use
55
- ```
56
-
57
- ### Usage
58
-
59
42
  ```ts
60
43
  import { Reactive } from 'ivue';
61
44
  import { ref } from 'vue';
@@ -79,8 +62,6 @@ export namespace Counter {
79
62
  }
80
63
  ```
81
64
 
82
- In a component:
83
-
84
65
  ```vue
85
66
  <script setup lang="ts">
86
67
  import { Counter } from './Counter';
@@ -98,19 +79,38 @@ const { count } = counter;
98
79
 
99
80
  Full walkthrough: [Getting Started](https://ivue.dev/guide/getting-started).
100
81
 
101
- ## Hard problems, solved together
82
+ ## Built for humans and AI
102
83
 
103
- Each of these sank earlier class-reactivity attempts. ivue ships all of them
104
- as one coherent design:
84
+ ivue ships with a
85
+ [Standard Operating Manual](https://ivue.dev/guide/standard) — the complete
86
+ authoring standard as annotated templates, rules, and a review checklist. It
87
+ reads as documentation and works as a drop-in skill for AI coding agents, so
88
+ generated code follows the same standard your team writes:
105
89
 
106
- - **Bound methods** — `this.method` is always correct, always the same reference.
107
- - **Reactive inheritance** deep `super.x.value` chains resolve level-safe.
108
- - **Development parity** the same class identity, direct binding, and engine branches in development and production.
109
- - **Circular import immunity** — the namespace pattern resolves mutual references in any load order.
110
- - **Writable getter types** — ref-returning getters type as writable; instances fully inferred.
111
- - **Deterministic teardown** `$watch` scopes per instance, `$stopEffects()` cleans up.
112
- - **Minimal memory footprint** derivations are shared prototype getters, not per-instance allocations.
113
- - **Hot paths** reads hoist to native ref speed with one line where it matters.
90
+ ```sh
91
+ npx ivue skill # installs .claude/skills/ivue/SKILL.md, version-locked
92
+ npx ivue skill --all # + Codex/Cursor/Copilot where already in use
93
+ ```
94
+
95
+ Agents holding the Standard have
96
+ [derived correct patterns its own author never wrote](https://ivue.dev/blog/patterns-the-author-never-wrote)
97
+ the manual is a generator, not a catalog. The wider argument:
98
+ [Reactive framework for the AI era](https://ivue.dev/blog/reactive-framework-for-the-ai-era).
99
+
100
+ ## Why classes, why now
101
+
102
+ - **Native class API** — `extends`, `super`, getters, setters, private
103
+ fields. Real inheritance, encapsulation and polymorphism, all reactive.
104
+ - **Zero-cost creation** — instances are plain objects. A million of them
105
+ take 22 ms — 6 to 132× faster than the alternatives.
106
+ - **One kilobyte** — 1.1 kB gzipped, zero dependencies, 100% test coverage.
107
+ Stripped to the load-bearing core — an API you can hold in your head.
108
+ - **Store or ViewModel** — the same class serves as a global store, a
109
+ component ViewModel, or a domain model. One mental model everywhere.
110
+ - **Composition API, fully compatible** — composables plug in through
111
+ `$`-getters. The entire Vue ecosystem works inside your classes.
112
+ - **TypeScript first** — writable ref-getters, fully typed instances,
113
+ precise inference. The type system shaped the engine's design.
114
114
 
115
115
  ## One idea, carried through
116
116
 
@@ -120,10 +120,8 @@ plain and re-derives on every read — reactive with zero allocation. Methods
120
120
  bind themselves once, to the right `this`. Instances stay ordinary objects:
121
121
  no proxy wraps them, no work happens at construction.
122
122
 
123
- Inheritance, teardown, development parity, speed — consequences of that one move.
124
-
125
- Composables plug straight in — the entire Vue ecosystem works inside your
126
- classes:
123
+ Inheritance, teardown, development parity, speed — consequences of that one
124
+ move. And composables plug straight in:
127
125
 
128
126
  ```ts
129
127
  import { useMouse } from '@vueuse/core';
@@ -141,10 +139,73 @@ class $Pointer {
141
139
  }
142
140
  ```
143
141
 
142
+ ## Hard problems, solved together
143
+
144
+ Each of these sank earlier class-reactivity attempts. ivue ships all of them
145
+ as one coherent design:
146
+
147
+ - **Bound methods** — `this.method` is always correct, always the same reference.
148
+ - **Reactive inheritance** — deep `super.x.value` chains resolve level-safe.
149
+ - **Development parity** — the same class identity, direct binding, and engine branches in development and production.
150
+ - **[Circular import immunity](https://ivue.dev/blog/circular-imports-dissolved)** — the namespace pattern resolves mutual references in any load order.
151
+ - **Writable getter types** — ref-returning getters type as writable; instances fully inferred.
152
+ - **Deterministic teardown** — `$watch` scopes per instance, `$stopEffects()` cleans up.
153
+ - **Minimal memory footprint** — derivations are shared prototype getters, not per-instance allocations.
154
+ - **Hot paths** — reads hoist to native ref speed with one line where it matters.
155
+
156
+ ## `Static()` — capability classes <sub>(`ivue/extras`, +0.5 kB)</sub>
157
+
158
+ The same discipline for the class-level surface:
159
+ [`Static()`](https://ivue.dev/guide/static) makes static methods lazy-bound
160
+ and `$`-prefixed static getters cached **per receiver** — a lazy singleton,
161
+ an inheritance-aware store, an override seam, and a test boundary in one
162
+ declaration. It retires
163
+ [module-level state](https://ivue.dev/blog/module-level-state) outright, and
164
+ it has no Vue dependency — the identical idiom runs under Node and Bun:
165
+
166
+ ```ts
167
+ import { Static } from 'ivue/extras';
168
+
169
+ class $TextSegmentation {
170
+ protected static get $segmenter() {
171
+ return new Intl.Segmenter(undefined, { granularity: 'grapheme' });
172
+ }
173
+ }
174
+
175
+ export namespace TextSegmentation {
176
+ export const $Class = Static($TextSegmentation); // anchor — children extend this
177
+ export let Class = $Class;
178
+ }
179
+ ```
180
+
181
+ ## Proven at scale
182
+
183
+ <p align="center">
184
+ <a href="https://ivue.dev/examples/invar">
185
+ <img src="docs_v2/public/invar-editor.svg" alt="Invar — a terminal IDE built on ivue" width="720">
186
+ </a>
187
+ </p>
188
+
189
+ [**Invar**](https://ivue.dev/examples/invar) is ivue at full scale: a
190
+ complete terminal IDE — editor, workspace search, git, terminals, LSP,
191
+ agents — running on ivue classes under Bun, with no DOM and no Vue
192
+ components. **94,000 source lines, 345 classes, 35 invariant contracts,
193
+ zero import cycles**, built almost entirely by AI agents holding the
194
+ [Standard](https://ivue.dev/guide/standard) as their base discipline.
195
+
196
+ And at the other end of scale on the web: a
197
+ [1,000,000-row virtual scroller](https://ivue.dev/examples/virtual-scroller),
198
+ a [20,000,000-cell flyweight grid](https://ivue.dev/examples/flyweight-grid)
199
+ at 4.7 bytes per live cell, and
200
+ [production-grade Quasar field components](https://ivue.dev/examples/choose-field) —
201
+ all with full source on the page.
202
+
144
203
  ## The numbers
145
204
 
146
- Measured, not promised — method and live in-browser benchmarks in
147
- [the docs](https://ivue.dev/guide/benchmarks).
205
+ Measured, not promised — every number carries its method, and the
206
+ load-bearing benchmarks
207
+ [run live in your browser](https://ivue.dev/guide/benchmarks) on the
208
+ shipped engine:
148
209
 
149
210
  | creating 1,000,000 instances | time | ivue is |
150
211
  | --- | --- | --- |
@@ -164,22 +225,31 @@ Taken all the way down: a fully reactive spreadsheet model holding
164
225
  floor — because in ivue, everything costs proportional to what's *observed*,
165
226
  nothing costs proportional to what *exists*.
166
227
 
228
+ ## Go deeper
229
+
230
+ - [Fundamental Principles](https://ivue.dev/guide/principles) — the design, from first ideas
231
+ - [The Engine](https://ivue.dev/engine) — how the transform works, internals annotated
232
+ - [Advanced Patterns](https://ivue.dev/guide/namespace-pattern) — Namespace, Computed Seed, Keyed Version Signals, Flyweight, Static, Backend ivue
233
+ - [The Blog](https://ivue.dev/blog/) — 30 posts of measured argument, from [circular imports](https://ivue.dev/blog/circular-imports-dissolved) to [what JavaScript becomes](https://ivue.dev/blog/what-javascript-becomes)
234
+
235
+ ## Community
236
+
237
+ Questions, ideas, bug reports — there's no ticket queue, just the people who
238
+ write the code: [Discussions](https://github.com/infinite-system/ivue/discussions) ·
239
+ [Issues](https://github.com/infinite-system/ivue/issues) ·
240
+ [Discord](https://discord.gg/8MgZNsrfv) ·
241
+ [X @evgenykalash](https://x.com/evgenykalash) ·
242
+ [Community page](https://ivue.dev/community)
243
+
167
244
  ## A note on the size
168
245
 
169
- The complete engine is **1.1kb gzipped**: lazy prototype transformation,
246
+ The complete engine is **1.1 kB gzipped**: lazy prototype transformation,
170
247
  bound methods, inheritance, lifecycle ownership, and the public utilities.
171
248
  Development uses that same engine without a second hot-update execution path.
172
249
 
173
250
  > *Perfection is achieved, not when there is nothing more to add, but when
174
251
  > there is nothing left to take away.* — Antoine de Saint-Exupéry
175
252
 
176
- ## Documentation
177
-
178
- Full guide, principles, live benchmarks, advanced examples (a 1,000,000-row
179
- virtual scroller, a 20,000,000-cell flyweight grid, production-grade Quasar
180
- field components), and the API reference:
181
- **https://ivue.dev/**
182
-
183
253
  ## License
184
254
 
185
255
  [MIT](./LICENSE)
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `LazyShared<T>` — the safe shared-store cell for static classes.
3
+ *
4
+ * The static-class split (see `Static()`): `$`-prefixed static getters
5
+ * are compute-once-PER-RECEIVER caches — perfect for memos and per-class
6
+ * tuning, where a subclass forking its own copy is the feature. A SHARED
7
+ * store (a registry, a ledger) must never live there: per-receiver
8
+ * caching means a subclass reading `this.$store` silently forks the
9
+ * registry. The fix is a `static readonly` FIELD — one reference on the
10
+ * declaring class, inherited, never receiver-cached — but an eager field
11
+ * initializer runs at MODULE LOAD, so it may only hold dependency-free
12
+ * values; the moment it constructs another namespace's class it races
13
+ * import cycles.
14
+ *
15
+ * `LazyShared` closes the triangle. The field eagerly stores the CELL
16
+ * (load-safe — a thunk evaluates nothing), the thunk runs on first
17
+ * `.value` read (cycle-safe — every module in any import cycle has
18
+ * finished loading), and memoization mutates cell-internal state
19
+ * (fork-safe — no receiver, subclass included, can fork it):
20
+ *
21
+ * class $SearchRegistry {
22
+ * protected static readonly sharedBackend = new LazyShared(
23
+ * () => new SearchBackend.Class(),
24
+ * );
25
+ * protected static get $backend() {
26
+ * return this.sharedBackend.value; // the field IS the pin
27
+ * }
28
+ * }
29
+ *
30
+ * A thunk that reads its own cell (directly or through another cell)
31
+ * throws a NAMED cycle error instead of a bare stack overflow, and a
32
+ * failed construction leaves the cell retryable, never poisoned.
33
+ *
34
+ * Ships from `ivue/extras` (not the reactive core) so the primary `ivue`
35
+ * entry stays minimal.
36
+ */
37
+ export declare class LazyShared<T> {
38
+ private readonly make;
39
+ constructor(make: () => T);
40
+ private constructed;
41
+ private constructing;
42
+ private stored;
43
+ get value(): T;
44
+ /**
45
+ * Drop the constructed value; the next read constructs again. For
46
+ * tests and process recomposition — production code never resets.
47
+ */
48
+ reset(): void;
49
+ }
package/dist/extras.cjs CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const u=Object.hasOwn;exports.Static=function(c){const r=class extends c{},s=new Set;for(let o=c;o!==Function.prototype;o=Object.getPrototypeOf(o))for(const e of Reflect.ownKeys(o)){if(s.has(e))continue;s.add(e);const t=Object.getOwnPropertyDescriptor(o,e);if(typeof t.value=="function"){const i=t.value,n=typeof e=="string"?Symbol.for(`ivue.staticBound.${e}`):Symbol("ivue.staticBound");Object.defineProperty(r,e,{configurable:!0,enumerable:t.enumerable,get(){return u(this,n)||Object.defineProperty(this,n,{configurable:!0,value:i.bind(this)}),this[n]}})}else if(t.get&&!t.set&&typeof e=="string"&&e.startsWith("$")){const i=t.get,n=Symbol.for(`ivue.staticCache.${e}`);Object.defineProperty(r,e,{configurable:!0,enumerable:t.enumerable,get(){return u(this,n)||Object.defineProperty(this,n,{configurable:!0,value:i.call(this)}),this[n]}})}}return r};
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const u=Object.hasOwn;exports.LazyShared=class{constructor(s){this.make=s,this.constructed=!1,this.constructing=!1,this.stored=null}get value(){if(!this.constructed){if(this.constructing)throw new Error("LazyShared thunk cycle: this cell is read inside its own construction. Break the dependency between the two thunks.");this.constructing=!0;try{this.stored=this.make(),this.constructed=!0}finally{this.constructing=!1}}return this.stored}reset(){this.constructed=!1,this.stored=null}},exports.Static=function(s){const i=class extends s{},c=new Set;for(let r=s;r!==Function.prototype;r=Object.getPrototypeOf(r))for(const t of Reflect.ownKeys(r)){if(c.has(t))continue;c.add(t);const e=Object.getOwnPropertyDescriptor(r,t);if(typeof e.value=="function"){const o=e.value,n=typeof t=="string"?Symbol.for(`ivue.staticBound.${t}`):Symbol("ivue.staticBound");Object.defineProperty(i,t,{configurable:!0,enumerable:e.enumerable,get(){return u(this,n)||Object.defineProperty(this,n,{configurable:!0,value:o.bind(this)}),this[n]}})}else if(e.get&&!e.set&&typeof t=="string"&&t.startsWith("$")){const o=e.get,n=Symbol.for(`ivue.staticCache.${t}`);Object.defineProperty(i,t,{configurable:!0,enumerable:e.enumerable,get(){return u(this,n)||Object.defineProperty(this,n,{configurable:!0,value:o.call(this)}),this[n]}})}}return i};
package/dist/extras.d.ts CHANGED
@@ -7,3 +7,4 @@
7
7
  * import { Static } from 'ivue/extras';
8
8
  */
9
9
  export { Static, type ClassConstructor } from './Static';
10
+ export { LazyShared } from './LazyShared';
package/dist/extras.es.js CHANGED
@@ -1,27 +1,49 @@
1
- const a = Object.hasOwn;
2
- function u(s) {
3
- const r = class extends s {
1
+ const u = Object.hasOwn;
2
+ function a(i) {
3
+ const n = class extends i {
4
4
  }, c = /* @__PURE__ */ new Set();
5
- for (let n = s; n !== Function.prototype; n = Object.getPrototypeOf(n))
6
- for (const e of Reflect.ownKeys(n)) {
7
- if (c.has(e))
5
+ for (let r = i; r !== Function.prototype; r = Object.getPrototypeOf(r))
6
+ for (const t of Reflect.ownKeys(r)) {
7
+ if (c.has(t))
8
8
  continue;
9
- c.add(e);
10
- const t = Object.getOwnPropertyDescriptor(n, e);
11
- if (typeof t.value == "function") {
12
- const i = t.value, o = typeof e == "string" ? Symbol.for(`ivue.staticBound.${e}`) : Symbol("ivue.staticBound");
13
- Object.defineProperty(r, e, { configurable: !0, enumerable: t.enumerable, get() {
14
- return a(this, o) || Object.defineProperty(this, o, { configurable: !0, value: i.bind(this) }), this[o];
9
+ c.add(t);
10
+ const e = Object.getOwnPropertyDescriptor(r, t);
11
+ if (typeof e.value == "function") {
12
+ const o = e.value, s = typeof t == "string" ? Symbol.for(`ivue.staticBound.${t}`) : Symbol("ivue.staticBound");
13
+ Object.defineProperty(n, t, { configurable: !0, enumerable: e.enumerable, get() {
14
+ return u(this, s) || Object.defineProperty(this, s, { configurable: !0, value: o.bind(this) }), this[s];
15
15
  } });
16
- } else if (t.get && !t.set && typeof e == "string" && e.startsWith("$")) {
17
- const i = t.get, o = Symbol.for(`ivue.staticCache.${e}`);
18
- Object.defineProperty(r, e, { configurable: !0, enumerable: t.enumerable, get() {
19
- return a(this, o) || Object.defineProperty(this, o, { configurable: !0, value: i.call(this) }), this[o];
16
+ } else if (e.get && !e.set && typeof t == "string" && t.startsWith("$")) {
17
+ const o = e.get, s = Symbol.for(`ivue.staticCache.${t}`);
18
+ Object.defineProperty(n, t, { configurable: !0, enumerable: e.enumerable, get() {
19
+ return u(this, s) || Object.defineProperty(this, s, { configurable: !0, value: o.call(this) }), this[s];
20
20
  } });
21
21
  }
22
22
  }
23
- return r;
23
+ return n;
24
+ }
25
+ class h {
26
+ constructor(n) {
27
+ this.make = n, this.constructed = !1, this.constructing = !1, this.stored = null;
28
+ }
29
+ get value() {
30
+ if (!this.constructed) {
31
+ if (this.constructing)
32
+ throw new Error("LazyShared thunk cycle: this cell is read inside its own construction. Break the dependency between the two thunks.");
33
+ this.constructing = !0;
34
+ try {
35
+ this.stored = this.make(), this.constructed = !0;
36
+ } finally {
37
+ this.constructing = !1;
38
+ }
39
+ }
40
+ return this.stored;
41
+ }
42
+ reset() {
43
+ this.constructed = !1, this.stored = null;
44
+ }
24
45
  }
25
46
  export {
26
- u as Static
47
+ h as LazyShared,
48
+ a as Static
27
49
  };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `LazyShared<T>` — the safe shared-store cell for static classes.
3
+ *
4
+ * The static-class split (see `Static()`): `$`-prefixed static getters
5
+ * are compute-once-PER-RECEIVER caches — perfect for memos and per-class
6
+ * tuning, where a subclass forking its own copy is the feature. A SHARED
7
+ * store (a registry, a ledger) must never live there: per-receiver
8
+ * caching means a subclass reading `this.$store` silently forks the
9
+ * registry. The fix is a `static readonly` FIELD — one reference on the
10
+ * declaring class, inherited, never receiver-cached — but an eager field
11
+ * initializer runs at MODULE LOAD, so it may only hold dependency-free
12
+ * values; the moment it constructs another namespace's class it races
13
+ * import cycles.
14
+ *
15
+ * `LazyShared` closes the triangle. The field eagerly stores the CELL
16
+ * (load-safe — a thunk evaluates nothing), the thunk runs on first
17
+ * `.value` read (cycle-safe — every module in any import cycle has
18
+ * finished loading), and memoization mutates cell-internal state
19
+ * (fork-safe — no receiver, subclass included, can fork it):
20
+ *
21
+ * class $SearchRegistry {
22
+ * protected static readonly sharedBackend = new LazyShared(
23
+ * () => new SearchBackend.Class(),
24
+ * );
25
+ * protected static get $backend() {
26
+ * return this.sharedBackend.value; // the field IS the pin
27
+ * }
28
+ * }
29
+ *
30
+ * A thunk that reads its own cell (directly or through another cell)
31
+ * throws a NAMED cycle error instead of a bare stack overflow, and a
32
+ * failed construction leaves the cell retryable, never poisoned.
33
+ *
34
+ * Ships from `ivue/extras` (not the reactive core) so the primary `ivue`
35
+ * entry stays minimal.
36
+ */
37
+ export class LazyShared<T> {
38
+ constructor(private readonly make: () => T) {}
39
+
40
+ private constructed = false;
41
+ private constructing = false;
42
+ private stored: T | null = null;
43
+
44
+ get value(): T {
45
+ if (!this.constructed) {
46
+ if (this.constructing) {
47
+ throw new Error(
48
+ 'LazyShared thunk cycle: this cell is read inside its own ' +
49
+ 'construction. Break the dependency between the two thunks.',
50
+ );
51
+ }
52
+ this.constructing = true;
53
+ try {
54
+ this.stored = this.make();
55
+ this.constructed = true;
56
+ } finally {
57
+ this.constructing = false;
58
+ }
59
+ }
60
+ return this.stored as T;
61
+ }
62
+
63
+ /**
64
+ * Drop the constructed value; the next read constructs again. For
65
+ * tests and process recomposition — production code never resets.
66
+ */
67
+ reset(): void {
68
+ this.constructed = false;
69
+ this.stored = null;
70
+ }
71
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { LazyShared } from '../LazyShared';
3
+ import { Static } from '../Static';
4
+
5
+ describe('LazyShared', () => {
6
+ it('evaluates nothing at definition, constructs once, shares the result', () => {
7
+ let constructionCount = 0;
8
+ const cell = new LazyShared(() => {
9
+ constructionCount += 1;
10
+ return { count: constructionCount };
11
+ });
12
+ expect(constructionCount).toBe(0); // load-safe: the thunk is inert
13
+ expect(cell.value).toBe(cell.value);
14
+ expect(constructionCount).toBe(1);
15
+ });
16
+
17
+ it('every receiver converges on the ONE singleton — forking the access path is harmless', () => {
18
+ let constructionCount = 0;
19
+ class $Owner {
20
+ protected static readonly sharedStore = new LazyShared(() => {
21
+ constructionCount += 1;
22
+ return new Map<string, number>();
23
+ });
24
+ static store(): Map<string, number> {
25
+ return this.sharedStore.value;
26
+ }
27
+ }
28
+ class $SubReceiver extends $Owner {}
29
+ $Owner.store().set('planted', 7);
30
+ expect($SubReceiver.store().get('planted')).toBe(7);
31
+ expect($SubReceiver.store()).toBe($Owner.store());
32
+ expect(constructionCount).toBe(1);
33
+ });
34
+
35
+ it('even a per-receiver $-cache over the cell returns the same singleton', () => {
36
+ // The registry-fork trap: Static()'s $-getters cache per receiver,
37
+ // so parent and subclass each run the getter body once. With the
38
+ // body reading a LazyShared cell, both receivers cache the SAME
39
+ // constructed value — the fork exists only in the access path.
40
+ let constructionCount = 0;
41
+ class $Registry {
42
+ protected static readonly sharedEntries = new LazyShared(() => {
43
+ constructionCount += 1;
44
+ return new Map<string, number>();
45
+ });
46
+ static get $entries(): Map<string, number> {
47
+ return this.sharedEntries.value;
48
+ }
49
+ }
50
+ const Registry = Static($Registry);
51
+ class $SubRegistry extends Registry {}
52
+ const SubRegistry = Static($SubRegistry);
53
+ Registry.$entries.set('planted', 7);
54
+ expect(SubRegistry.$entries).toBe(Registry.$entries);
55
+ expect(SubRegistry.$entries.get('planted')).toBe(7);
56
+ expect(constructionCount).toBe(1);
57
+ });
58
+
59
+ it('reset drops the value and the next read constructs again', () => {
60
+ let constructionCount = 0;
61
+ const cell = new LazyShared(() => (constructionCount += 1));
62
+ expect(cell.value).toBe(1);
63
+ cell.reset();
64
+ expect(cell.value).toBe(2);
65
+ });
66
+
67
+ it('a thunk cycle throws a named error and leaves the cell retryable', () => {
68
+ const cellA = new LazyShared((): number => cellB.value + 1);
69
+ const cellB = new LazyShared((): number => cellA.value + 1);
70
+ expect(() => cellA.value).toThrow('LazyShared thunk cycle');
71
+ // not poisoned: break the cycle and the same cell constructs fine
72
+ let repaired = 0;
73
+ const cellC = new LazyShared((): number => (repaired += 1));
74
+ expect(cellC.value).toBe(1);
75
+ // the failed cell itself retries once its dependency resolves
76
+ expect(() => cellB.value).toThrow('LazyShared thunk cycle');
77
+ });
78
+
79
+ it('a throwing thunk does not poison the cell — the next read retries', () => {
80
+ let attempts = 0;
81
+ const cell = new LazyShared(() => {
82
+ attempts += 1;
83
+ if (attempts === 1) throw new Error('backend not ready');
84
+ return 'ready';
85
+ });
86
+ expect(() => cell.value).toThrow('backend not ready');
87
+ expect(cell.value).toBe('ready');
88
+ expect(attempts).toBe(2);
89
+ });
90
+ });
@@ -362,6 +362,70 @@ describe('Static $-cached getters', () => {
362
362
  expect(open()).toBe(3); // detached, still bound
363
363
  });
364
364
 
365
+ it('the anchor shape: $Class = Static($X), Class = Reactive($Class)', () => {
366
+ class $Panel {
367
+ constructor(public seed: number) {}
368
+ get width() {
369
+ return ref(this.seed);
370
+ }
371
+ static get glyph() {
372
+ return '#'; // live knob
373
+ }
374
+ static get $legend() {
375
+ return { marker: this.glyph.repeat(2) };
376
+ }
377
+ static describe() {
378
+ return this.$legend.marker;
379
+ }
380
+ }
381
+
382
+ const Anchor = Static($Panel);
383
+ const Panel = Reactive(Anchor);
384
+
385
+ // instance side: full Reactive semantics through the wrapper
386
+ const panel: any = new Panel(7);
387
+ const widthCell = panel.width;
388
+ expect(widthCell.value).toBe(7);
389
+ expect(panel.width).toBe(widthCell); // cached cell
390
+ panel.$stopEffects();
391
+ expect(panel.width).not.toBe(widthCell); // teardown reset works
392
+
393
+ // static side: caches + binding live on the same published class
394
+ expect(Panel.$legend).toBe(Panel.$legend);
395
+ const describe = Panel.describe;
396
+ expect(describe()).toBe('##');
397
+
398
+ // Reactive() is identity, so anchor and published class are one object
399
+ expect(Panel).toBe(Anchor);
400
+ });
401
+
402
+ it('a bare double extending the anchor inherits working semantics', () => {
403
+ class $Momentum {
404
+ static get friction() {
405
+ return 2; // knob
406
+ }
407
+ static get $atRest() {
408
+ return { threshold: this.friction * 10 };
409
+ }
410
+ static settle() {
411
+ return this.$atRest.threshold;
412
+ }
413
+ }
414
+
415
+ const Anchor = Static($Momentum);
416
+ // the downstream case: a pinch-knob double with NO wrap of its own
417
+ class TunedMomentum extends Anchor {
418
+ static override get friction() {
419
+ return 7;
420
+ }
421
+ }
422
+
423
+ expect(Anchor.$atRest.threshold).toBe(20); // parent reads FIRST
424
+ expect(TunedMomentum.$atRest.threshold).toBe(70); // child still derives itself
425
+ const settle = TunedMomentum.settle;
426
+ expect(settle()).toBe(70); // inherited method binds to the child, detached
427
+ });
428
+
365
429
  it('walks the raw inheritance chain — ancestor $-getters cache per receiver', () => {
366
430
  class $Base {
367
431
  static get scale() {
package/lib/extras.ts CHANGED
@@ -7,3 +7,4 @@
7
7
  * import { Static } from 'ivue/extras';
8
8
  */
9
9
  export { Static, type ClassConstructor } from './Static';
10
+ export { LazyShared } from './LazyShared';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ivue",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Infinite Vue – Class Based Architecture for Vue 3",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,6 +29,8 @@
29
29
  "measure:formula": "node demo/formula/measure.mjs",
30
30
  "measure:grid": "node demo/grid/measure.mjs",
31
31
  "dev:docs": "npm --prefix docs_v2 run dev -- --port 5174",
32
+ "admin": "vite newsletter/dashboard --host",
33
+ "build:admin": "vite build newsletter/dashboard",
32
34
  "test": "vitest --coverage.enabled=true --reporter=verbose --ui",
33
35
  "coverage": "vitest run --coverage",
34
36
  "bench": "vitest bench --run",
@@ -37,10 +39,17 @@
37
39
  "cypress:headed": "cypress run --component --browser chrome --headed --no-exit",
38
40
  "build": "tsc --version;tsc --p ./tsconfig.json && vite build",
39
41
  "build:demo": "tsc --p ./tsconfig.json && vite build demo",
40
- "build:docs": "npm run sync:examples && npm --prefix docs_v2 run build",
42
+ "build:docs": "npm run sync:examples && npm run sync:releases && npm run sync:blog-index && npm --prefix docs_v2 run build && npm run check:links && node docs_v2/scripts/check-related-posts.mjs",
43
+ "sync:releases": "node docs_v2/scripts/releases-page-generator.mjs",
44
+ "sync:blog-index": "node docs_v2/scripts/blog-index-generator.mjs",
45
+ "check:links": "node docs_v2/scripts/check-links.mjs",
41
46
  "render:og": "node docs_v2/scripts/brand-image-generator.mjs og",
42
47
  "render:form-header": "node docs_v2/scripts/brand-image-generator.mjs form-header",
43
48
  "render:banner": "node docs_v2/scripts/brand-image-generator.mjs blog",
49
+ "render:diagram": "node docs_v2/scripts/brand-image-generator.mjs diagram",
50
+ "render:embeds": "node docs_v2/scripts/blog-embed-shots.mjs",
51
+ "render:code-shots": "node docs_v2/scripts/blog-code-shots.mjs",
52
+ "sync:blog-dates": "node docs_v2/scripts/blog-dates-generator.mjs",
44
53
  "preview:demo": "npm run build:demo && vite preview demo --host",
45
54
  "preview:docs": "npm --prefix docs_v2 run preview",
46
55
  "release": "npm run build && npm publish",
@@ -49,7 +58,8 @@
49
58
  "sync:examples": "node -e \"require('fs').copyFileSync('lib/Reactive.ts','examples/playground/src/ivue.ts')\"",
50
59
  "dev:playground": "vite examples/playground --host",
51
60
  "build:playground": "vite build examples/playground",
52
- "preview:playground": "vite preview examples/playground --host"
61
+ "preview:playground": "vite preview examples/playground --host",
62
+ "rename:blog-slug": "node docs_v2/scripts/rename-blog-slug.mjs"
53
63
  },
54
64
  "peerDependencies": {
55
65
  "vue": "^3.2.0",
@@ -134,5 +144,6 @@
134
144
  "license": "MIT",
135
145
  "bin": {
136
146
  "ivue": "bin/ivue.mjs"
137
- }
147
+ },
148
+ "packageManager": "npm@11.6.2"
138
149
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ivue
3
- description: Use when writing or editing ivue `Reactive()` classes, converting a Vue component or composable to ivue, or resolving any `.value`-in-template, `defineExpose`/`reactive()` instance-typing, `ReactiveInstance`/`Instance`, `$watch`/`$watchEffect`, or namespace-export question — the operating manual for Vue 3 class-based reactivity where state is ref-getters, derived values are plain getters, and Refs/Computeds are `.value` everywhere.
3
+ description: Use when writing or editing ivue `Reactive()` classes, converting a Vue component or composable to ivue, or resolving any `.value`-in-template, `defineExpose`/`reactive()` instance-typing, `ReactiveInstance`/`Instance`, `$watch`/`$watchEffect`, or namespace-export question, or any `Static()` capability-class / `$`-cached-static / anchor question — the operating manual for Vue 3 class-based reactivity where state is ref-getters, derived values are plain getters, and Refs/Computeds are `.value` everywhere.
4
4
  ---
5
5
 
6
6
  # ivue `Reactive`
@@ -12,6 +12,12 @@ tracking), methods become stable bound functions. Instances stay plain objects.
12
12
  Follow the rules below exactly — every deviation is either a compile error or a
13
13
  silent no-op at runtime.
14
14
 
15
+ The manual reads in three parts: the **`Reactive()` instance world**
16
+ (the class and SFC templates, ownership, typing, watches, stores, keyed
17
+ state), the **static world** (`Static()`, shared stores, and reading
18
+ your own statics — everything from `ivue/extras`), and the **style
19
+ contract** (naming, spacing, the self-review checklist).
20
+
15
21
  ## Setup — ivue must be installed
16
22
 
17
23
  `import { Reactive } from 'ivue'` resolves only when the package is a
@@ -146,11 +152,6 @@ class $Box {
146
152
  return this.$project.projectId;
147
153
  }
148
154
 
149
- // CONSTANTS / CONFIG — plain fields ONLY. A plain field written
150
- // from a method triggers NOTHING (no Ref/Computed, no dependency
151
- // edge). Never store mutable state here.
152
- baseWidth = 400;
153
-
154
155
  // METHODS — plain; engine-binds to raw (stable identity, safe as
155
156
  // handlers). Reactive-closure bodies above delegate HERE (the
156
157
  // thin-closure rule).
@@ -186,6 +187,11 @@ export namespace Box {
186
187
  }
187
188
  ```
188
189
 
190
+ A class with NO static members exports exactly this shape. Only a class
191
+ that DECLARES statics anchors them — `export const $Class =
192
+ Static($Box)` — and reads them from instance code through `self`; both
193
+ live in the static-world sections below.
194
+
189
195
  ### The optional `Model` line (domain entity graphs)
190
196
 
191
197
  When classes hold and pass RAW instances of each other — entity
@@ -348,6 +354,23 @@ call site. Rules that keep it clean:
348
354
  nobody keeps it; here a named plain getter costs zero bytes, so there is
349
355
  no excuse. Templates read as prose: bindings, names, and events — never
350
356
  expressions.
357
+ - **The rule covers EVERY binding kind, not just `v-if`** — the common
358
+ leaks are display strings, disabled states, and class objects:
359
+
360
+ | leaked into the template | derived on the class |
361
+ | --- | --- |
362
+ | interpolating `sending ? 'Sending…' : 'Send to ' + recipients.length` | interpolating `model.sendButtonLabel` |
363
+ | `:disabled="!model.canSend \|\| sending"` | `:disabled="model.sendDisabled"` |
364
+ | `:class="{ active: view === tab.name }"` | `:class="{ active: app.isOpen(tab.name) }"` |
365
+ | `row.name \|\| '—'` in a `v-for` cell | `Format.Class.orDash(row.name)` |
366
+ | `:style` width from `(day.count / peak) * 100 + '%'` | `:style` width from `model.barWidth(day)` |
367
+
368
+ Each right-hand form is a prototype member: unit-testable without
369
+ mounting anything, greppable by name, typed, and hot-graftable. The
370
+ one thing that stays in the template is STRUCTURE — `v-if`/`v-else`
371
+ branching on a named condition or a data field (`v-if="entry.nextSlug"`)
372
+ and `v-for` over a collection. Branching on data is structure;
373
+ COMPUTING with data is logic, and logic lives on the class.
351
374
 
352
375
  ## The outliving instance (module singleton, entity)
353
376
 
@@ -417,10 +440,13 @@ session.dispose();
417
440
  | ✅ `new X.Class(props, emit)` — raw instance everywhere | ❌ wrap in `reactive(instance)` or any shallow-unwrap view as the standard |
418
441
  | ✅ destructure ALL template-touched Refs/Computeds + element refs, grouped | ❌ destructure plain getters or methods — snapshots a dead value / loses nothing but clarity |
419
442
  | ✅ state bindings in templates; dotted `box.x` only for plain getters/methods | ❌ reach a Ref through the instance in a template — `v-if="box.someRef"` is always-truthy |
443
+ | ✅ labels, disabled states, and class conditions as named getters/methods (`model.sendButtonLabel`, `model.sendDisabled`) | ❌ ternaries, `\|\|`/`&&` chains, comparisons, or string-building inside template expressions |
420
444
  | ✅ `defineExpose(box as X.Instance)` | ❌ `defineExpose(box)` raw — readonly-accessor writes will type-error for consumers |
421
445
  | ✅ constructor runs init; register hooks/watchers there | ❌ add an `init()` method expecting auto-call — ivue never calls it |
422
446
  | ✅ plain `watch` in component-scoped constructors; `$watch` + a `$stopEffects` dispose path for outliving instances | ❌ default to `this.$watch` in a component-scoped class — its scope silently outlives unmount |
423
447
  | ✅ compose cleanup as an ordinary method — `dispose() { /* non-Vue cleanup */ this.$stopEffects(); }` | ❌ expect a teardown hook — ivue auto-calls NOTHING (no `init()`, no `stopEffects()`) |
448
+ | ✅ a class with static members anchors them: `const $Class = Static($X)` (`ivue/extras`) | ❌ `extends X.Class` — the mutable slot is an eager snapshot of one generation; always extend `$Class` |
449
+ | ✅ instance code reads its own statics through `this.self` (the one cast per class); hoist `const self = this.self` for 2+ reads or any loop | ❌ per-site `(this.constructor as typeof $X)` casts — each one is an unchecked class-name assertion |
424
450
 
425
451
  ## The unwrapping-surface typing invariant
426
452
 
@@ -482,45 +508,6 @@ until mount — use `?.` in watch getters).
482
508
  - Watch CALLBACKS delegate to methods (the thin-closure rule):
483
509
  `watch(source, (newValue, oldValue) => this.onChanged(newValue, oldValue))`.
484
510
 
485
- ## Circular references resolve by construction
486
-
487
- The hoisted-namespace + getter convention makes late cross-module references
488
- safe without ordering discipline or `forwardRef`-style workarounds:
489
-
490
- - Cross-references (`new Other.Class()` in a method, a store read in a
491
- `$`-getter) resolve at FIRST ACCESS, when every module in the cycle has
492
- long finished loading — any load order works.
493
- - Each file calls `Reactive()` on its own class safely: it is idempotent per
494
- prototype level; a shared ancestor is transformed once, by
495
- whichever file loads first.
496
- - Eager top-level dereferences can still fail; the convention keeps
497
- cross-references inside late method and getter bodies. Circular `extends`
498
- stays impossible because it evaluates at load time and both parents cannot
499
- exist first.
500
-
501
- ## Generic classes (brief)
502
-
503
- `ReactiveClass<C>` cannot carry `<T>` through (no higher-kinded types), but
504
- `Reactive(X) === X` by identity — so cast `Class` back to the raw
505
- constructor and apply `ReactiveInstance` explicitly for `Instance`:
506
-
507
- ```ts
508
- class $Scroller<T extends BaseItem> {
509
- get items() {
510
- return ref<T[]>([]);
511
- }
512
- }
513
-
514
- export namespace Scroller {
515
- export const $Class = $Scroller;
516
- // the cast keeps <T> available at `new` sites
517
- export let Class = Reactive($Class) as unknown as typeof $Class;
518
- export type Instance<T extends BaseItem> =
519
- ReactiveInstance<$Scroller<T>>;
520
- }
521
- // consumer of a template ref: ShallowUnwrapRef<Scroller.Instance<T>>
522
- ```
523
-
524
511
  ## computed() and watch callbacks delegate to methods
525
512
 
526
513
  A reactive closure is cached per instance. Keep that closure as a small
@@ -561,37 +548,91 @@ silently receives stale data. Always the arrow.
561
548
  `$`-prefixed singleton getters are frozen caches too — keep their bodies to
562
549
  a single composable/service call (`return useThing()`), nothing more.
563
550
 
564
- ## Naming: unfold to the domain
551
+ ## The store pattern: a singleton behind `use()`, injected by `$`-getter
565
552
 
566
- Readable code is the product. In ivue classes the class shape already reads
567
- like prosedon't ruin it with letter soup:
553
+ Shared application state (session, navigation, toasts, the current user)
554
+ is a STORE one ivue class published as a module singleton — never a
555
+ model passed down as a prop. Prop-drilling a shared model
556
+ (`<ChildView :app="app" />`, `constructor(public app: AppModel.Instance)`)
557
+ threads one object through every component and constructor signature it
558
+ crosses; the store pattern deletes the thread.
568
559
 
569
- - **No single-letter or abbreviated identifiers** — including loop indices
570
- and callback parameters. `row`/`col`, not `r`/`c`; `cell`, `cellValue`,
571
- `entry`, `versionRef`, `aggregate`, `newValue`/`oldValue`, not
572
- `c`/`v`/`e`/`agg`/`nv`/`ov`.
573
- - **The one-letter-many-meanings failure mode is the reason.** A file where
574
- `c` means cell in one method, column in the next, and cellValue in a
575
- third makes every reader re-derive the type system in their head. Named
576
- after the domain, the ambiguity cannot exist.
577
- - **Booleans are predicates** (`isFineTier`, `hasModel`); counts say what
578
- they count (`observerRuns`, `releasedCount`); prior values are
579
- `originalX`/`previousX`, not `old`/`prev` alone.
580
- - Abbreviate only when the abbreviation IS the domain term (`px`, `id`,
581
- `fx`, A1-notation like `startRow`/`endCol`).
582
- - Tests are code — the same rules apply to specs.
560
+ ```ts
561
+ // app/AppStore.ts the store IS an ivue class; `use()` owns the singleton
562
+ class $AppStore {
563
+ get authenticated() {
564
+ return ref(false);
565
+ }
566
+
567
+ notify(message: string) {
568
+ /* ... */
569
+ }
570
+ }
571
+
572
+ export namespace AppStore {
573
+ export const $Class = $AppStore;
574
+ export let Class = Reactive($Class);
575
+ export type Instance = typeof Class.Instance;
576
+
577
+ let singleton: Instance | null = null;
578
+ export function use(): Instance {
579
+ return (singleton ??= new Class());
580
+ }
581
+ }
582
+ ```
583
+
584
+ Consumers never receive it — they REACH for it:
583
585
 
584
586
  ```ts
585
- // const v = this.cellVersions.get(k);
586
- // const versionRef = this.cellVersions.get(cellKey);
587
+ // any model the `$`-getter caches the store per instance, forever
588
+ class $SubscribersModel {
589
+ protected get $app() {
590
+ return AppStore.use();
591
+ }
587
592
 
588
- // ❌ for (let r = r1; r <= r2; r++)
589
- // ✅ for (let row = startRow; row <= endRow; row++)
593
+ async refresh() {
594
+ try {
595
+ /* ... */
596
+ } catch (error) {
597
+ this.$app.reportFailure(error);
598
+ }
599
+ }
600
+ }
601
+ ```
590
602
 
591
- // ❌ watch(c, (nv, ov) => …)
592
- // watch(value, (newValue, oldValue) => this.onChanged(…))
603
+ ```vue
604
+ <script setup lang="ts">
605
+ // any component — call use() directly; no prop, no provide/inject
606
+ import { AppStore } from '../app/AppStore';
607
+
608
+ const app = AppStore.use();
609
+ const { authenticated } = app;
610
+ </script>
611
+
612
+ <template>
613
+ <button v-if="authenticated" @click="app.logout()">Lock</button>
614
+ </template>
593
615
  ```
594
616
 
617
+ Why this shape and not alternatives:
618
+
619
+ - **`use()` is lazy** — the singleton constructs on first touch, after the
620
+ app exists, so module-load order and circular imports stay non-events
621
+ (the same late-read property as every cross-module reference).
622
+ - **The `$`-getter is the injection point** — cached whole, per instance,
623
+ on first read. A model names its dependency once; every method reads
624
+ `this.$app` with zero lookup cost and zero constructor plumbing.
625
+ - **Tests swap the slot, not the callers** — `AppStore.Class = $TestStore`
626
+ before the first `use()` (or reset the singleton) and every consumer
627
+ gets the double through the same seam.
628
+ - A store is component-OUTLIVING by definition: watchers inside it use
629
+ `this.$watch`/`$watchEffect`, never plain `watch`, and lifecycle hooks
630
+ never belong in it.
631
+ - Pass PROPS for what is genuinely per-instance input (a row, a slug, a
632
+ config knob). Reach for the STORE for what is genuinely shared. A prop
633
+ named `app`, `store`, or `session` is the tell that a store is being
634
+ drilled.
635
+
595
636
  ## Keyed reactivity — the third state shape
596
637
 
597
638
  Ref-getters express NAMED members; `shallowRef` expresses wholesale-replaced
@@ -658,12 +699,275 @@ Same invariant at three granularities — nothing exists until observed: getters
658
699
  price MEMBERS, keyed collections price KEYS. (Proven at 20M cells / 4.7
659
700
  bytes each — see the flyweight grid.)
660
701
 
702
+ ## Generic classes (brief)
703
+
704
+ `ReactiveClass<C>` cannot carry `<T>` through (no higher-kinded types), but
705
+ `Reactive(X) === X` by identity — so cast `Class` back to the raw
706
+ constructor and apply `ReactiveInstance` explicitly for `Instance`:
707
+
708
+ ```ts
709
+ class $Scroller<T extends BaseItem> {
710
+ get items() {
711
+ return ref<T[]>([]);
712
+ }
713
+ }
714
+
715
+ export namespace Scroller {
716
+ export const $Class = $Scroller;
717
+ // the cast keeps <T> available at `new` sites
718
+ export let Class = Reactive($Class) as unknown as typeof $Class;
719
+ export type Instance<T extends BaseItem> =
720
+ ReactiveInstance<$Scroller<T>>;
721
+ }
722
+ // consumer of a template ref: ShallowUnwrapRef<Scroller.Instance<T>>
723
+ ```
724
+
725
+ ## Circular references resolve by construction
726
+
727
+ The hoisted-namespace + getter convention makes late cross-module references
728
+ safe without ordering discipline or `forwardRef`-style workarounds:
729
+
730
+ - Cross-references (`new Other.Class()` in a method, a store read in a
731
+ `$`-getter) resolve at FIRST ACCESS, when every module in the cycle has
732
+ long finished loading — any load order works.
733
+ - Each file calls `Reactive()` on its own class safely: it is idempotent per
734
+ prototype level; a shared ancestor is transformed once, by
735
+ whichever file loads first.
736
+ - Eager top-level dereferences can still fail; the convention keeps
737
+ cross-references inside late method and getter bodies. Circular `extends`
738
+ stays impossible because it evaluates at load time and both parents cannot
739
+ exist first.
740
+
741
+ ## `Static()` — the static-side sibling (from `ivue/extras`)
742
+
743
+ `Reactive()` owns instances. Stateless CAPABILITY classes — function bags for
744
+ files, git, parsers, clocks: never constructed, only called and swapped — use
745
+ `Static()` from the `ivue/extras` entry (separate, so core stays the engine):
746
+
747
+ - **Static methods bind lazily with stable identity** — detachable, safe as a
748
+ router/queue/listener callback, bound to the RECEIVING class.
749
+ - **Get-only statics named `$…` compute once PER RECEIVER.** The `$` prefix
750
+ promises stable identity, NOT immutability — a mutable memo table is a
751
+ legitimate `$`-cache. Non-`$` static getters stay LIVE: the settings a
752
+ subclass or test double overrides.
753
+ - **A SHARED STORE never lives in receiver-space.** Per-receiver caching
754
+ means a subclass reading `this.$store` silently forks a fresh copy — the
755
+ registry-fork trap. The store is a `static readonly` FIELD on the
756
+ declaring class — one reference, inherited through the prototype chain,
757
+ never receiver-cached — so every receiver read (`this.$store`,
758
+ `this.constructor.$store`) resolves to the one store with no special
759
+ case anywhere; the `$`-getter pins by returning the field:
760
+ ```ts
761
+ class $Registry {
762
+ protected static readonly sharedRegistrations = new Map<object, Registration>();
763
+ protected static get $registrations() {
764
+ return this.sharedRegistrations; // the field IS the pin
765
+ }
766
+ }
767
+ ```
768
+ Two questions place every static value:
769
+
770
+ 1. **Should a subclass get its own copy?** Yes → per-receiver
771
+ `$`-cache. That is what memos and per-class tuning want: forking on
772
+ subclass is the feature. No → it is a SHARED store (a registry, a
773
+ ledger — forking is the bug), and it lives in a `static readonly`
774
+ field as above.
775
+ 2. **Shared store: can its initializer run at module load?** A field
776
+ initializer runs while modules are still loading, so it may only
777
+ hold a dependency-free value — a bare `new Map()`, a literal. The
778
+ moment construction needs ANOTHER module's class, the field holds a
779
+ `LazyShared` cell instead (`import { LazyShared } from
780
+ 'ivue/extras'`), and the `$`-getter reads through it:
781
+ ```ts
782
+ protected static readonly sharedBackend = new LazyShared(
783
+ () => new SearchBackend.Class(),
784
+ );
785
+ protected static get $backend() {
786
+ return this.sharedBackend.value;
787
+ }
788
+ ```
789
+ Each step is safe on its own terms. Storing the cell eagerly is
790
+ safe because a thunk evaluates nothing at load. Running the thunk
791
+ on first read is safe because by then every import cycle has
792
+ resolved. And sharing is safe because the memoized value lives
793
+ INSIDE the cell — every access path, subclass receivers and
794
+ per-receiver `$`-caches over the cell included, converges on the
795
+ one constructed singleton.
796
+
797
+ THE ANCHOR RULE — a class that declares static members wraps them ONCE, at
798
+ `$Class`, so subclasses and test doubles inherit working semantics by
799
+ extending `$Class` bare:
800
+
801
+ ```ts
802
+ import { Static } from 'ivue/extras';
803
+
804
+ class $GitCommands {
805
+ static get binary() {
806
+ return 'git'; // LIVE knob — no $ prefix
807
+ }
808
+ static get $environment() {
809
+ return { LC_ALL: 'C' }; // computed once per receiver
810
+ }
811
+ static stage(path: string) {
812
+ return this.run(['add', '--', path]); // `this` = receiving class
813
+ }
814
+ }
815
+
816
+ export namespace GitCommands {
817
+ export const $Class = Static($GitCommands); // anchor — wrap HERE
818
+ export let Class = $Class; // selection — kernels/tests swap this
819
+ }
820
+ ```
821
+
822
+ Statics AND reactive instances on one class — anchor the statics, then
823
+ `Reactive()`:
824
+
825
+ ```ts
826
+ export namespace Settings {
827
+ export const $Class = Static($Settings);
828
+ export let Class = Reactive($Class); // in-place: Class === $Class
829
+ export type Instance = typeof Class.Instance;
830
+ }
831
+ ```
832
+
833
+ No static members → no wrapper: `$Class = $X`, the standard form unchanged.
834
+
835
+ ## Reading your own statics — the ladder
836
+
837
+ `Reactive(X) === X`, so a namespace's `Class` slot IS the base class. A getter
838
+ that reads statics through it therefore hard-binds to the base and silently
839
+ IGNORES a subclass override — the exact opposite of what a live (non-`$`)
840
+ static getter is for:
841
+
842
+ ```ts
843
+ // ❌ three members, a double cast, and the override never applies
844
+ protected get Tooltip() {
845
+ return Tooltip.Class as unknown as typeof $Tooltip;
846
+ }
847
+ public static get TOOLTIP_DWELL_SECONDS() { return 0.4; }
848
+ protected get tooltipDwellSeconds() {
849
+ return this.Tooltip.TOOLTIP_DWELL_SECONDS; // base value forever
850
+ }
851
+ ```
852
+
853
+ Measured: a subclass setting `0.1` still reads `0.4` through this shape.
854
+
855
+ Take the first rung that applies:
856
+
857
+ 1. **Nothing outside the instance reads it** → delete the static. A plain
858
+ instance getter is zero bytes per instance and natively overridable:
859
+ ```ts
860
+ protected get tooltipDwellSeconds() { return 0.4; }
861
+ ```
862
+ 2. **Something outside reads it** (a test overriding the knob, another class)
863
+ → keep the static and read it through **`self`** — the one cast per
864
+ class, declared beside the statics it types — DIRECTLY at each call
865
+ site:
866
+ ```ts
867
+ protected get self() {
868
+ return this.constructor as typeof $Tooltip;
869
+ }
870
+
871
+ show() {
872
+ this.dwellTimer.start(this.self.TOOLTIP_DWELL_SECONDS);
873
+ }
874
+ ```
875
+ An instance getter over a static earns its place when it genuinely
876
+ derives — mixing in instance state or transforming the value; a
877
+ plain read stays a direct `this.self.X` at the call site, so the
878
+ knob keeps one name and one override surface (the static).
879
+ `this.constructor` is the actual class — the subclass when subclassed,
880
+ and an engine class that INHERITS `$Class` for a plain reactive
881
+ instance — so statics resolve late-bound in both cases.
882
+ TypeScript types `constructor` as bare
883
+ `Function`, so ONE cast is unavoidable; `self` is where it lives.
884
+ Never scatter per-site `(this.constructor as typeof $X)` casts: each
885
+ is an unchecked assertion that the class name is right, and the
886
+ copy-paste error it invites typechecks silently against the wrong
887
+ statics. Rules that keep `self` honest:
888
+ - **Plain getter, never `$self`** — a `$`-cache would spend a
889
+ per-instance slot on what `this.constructor` hands back for free.
890
+ - **One read → `this.self.X` inline. Two or more reads, or any
891
+ loop → hoist:** `const self = this.self;` as the first line, then
892
+ `self.X` throughout. Measured (Node 26): the de-opted `self` getter
893
+ costs ~2 ns/read over an inline cast — noise for a single read —
894
+ while the hoisted form runs at ~0.4 ns/iter in loops, CHEAPER than
895
+ the inline cast, because the engine hoists the class as a loop
896
+ constant.
897
+ - **A subclass that adds statics redeclares `self`** with its own
898
+ `typeof $Sub` (a covariant override); a subclass that only tunes
899
+ inherited statics needs nothing — `self` is already late-bound.
900
+ - **`self` is NOT the namespace slot.** `this.self` is the class you
901
+ were constructed from; `Namespace.Class` is the live mutable slot a
902
+ kernel may have re-pointed since. Receiver statics (constants,
903
+ per-class tuning, `$`-caches) read through `self`; late-bound
904
+ capability dispatch reads through `Namespace.Class`. Blurring them
905
+ trades typo bugs for staleness bugs.
906
+ 3. **Overriding must NOT happen** → name the class directly,
907
+ `$Tooltip.TOOLTIP_DWELL_SECONDS`, and let the code say so.
908
+
909
+ Never introduce a `protected get <ClassName>()` self-reference getter. It is a
910
+ cast wearing a getter costume: it looks live and is not — `self` is its
911
+ honest replacement.
912
+
913
+ ## Naming: unfold to the domain
914
+
915
+ Readable code is the product. In ivue classes the class shape already reads
916
+ like prose — don't ruin it with letter soup:
917
+
918
+ - **No single-letter or abbreviated identifiers** — including loop indices
919
+ and callback parameters. `row`/`col`, not `r`/`c`; `cell`, `cellValue`,
920
+ `entry`, `versionRef`, `aggregate`, `newValue`/`oldValue`, not
921
+ `c`/`v`/`e`/`agg`/`nv`/`ov`.
922
+ - **The one-letter-many-meanings failure mode is the reason.** A file where
923
+ `c` means cell in one method, column in the next, and cellValue in a
924
+ third makes every reader re-derive the type system in their head. Named
925
+ after the domain, the ambiguity cannot exist.
926
+ - **Booleans are predicates** (`isFineTier`, `hasModel`); counts say what
927
+ they count (`observerRuns`, `releasedCount`); prior values are
928
+ `originalX`/`previousX`, not `old`/`prev` alone.
929
+ - Abbreviate only when the abbreviation IS the domain term (`px`, `id`,
930
+ `fx`, A1-notation like `startRow`/`endCol`).
931
+ - Tests are code — the same rules apply to specs.
932
+
933
+ ```ts
934
+ // ❌ const v = this.cellVersions.get(k);
935
+ // ✅ const versionRef = this.cellVersions.get(cellKey);
936
+
937
+ // ❌ for (let r = r1; r <= r2; r++)
938
+ // ✅ for (let row = startRow; row <= endRow; row++)
939
+
940
+ // ❌ watch(c, (nv, ov) => …)
941
+ // ✅ watch(value, (newValue, oldValue) => this.onChanged(…))
942
+ ```
943
+
661
944
  ## Spacing is information
662
945
 
663
946
  Contiguity says "same kind of thing"; a blank line says "the kind changes,
664
947
  or complexity rises." Spend the signal deliberately — a blanket
665
948
  newline-between-everything rule makes air mean nothing.
666
949
 
950
+ Class members use one order: static members → constructor → state getters →
951
+ prop getters → derived getters → methods. The constructor is the first
952
+ instance member. Comments and invariant annotations can precede the member
953
+ they describe.
954
+
955
+ Constants use one form per role:
956
+
957
+ | Role | Form |
958
+ | --- | --- |
959
+ | Tunable or overridable class constant | `static get SCREAMING_SNAKE_CASE()` |
960
+ | Protocol or byte constant on a hot path, never overridden | `static readonly SCREAMING_SNAKE_CASE` with a one-line hot-path comment |
961
+ | Contributor or pane identity data | Instance `readonly lowerCamelCase` field |
962
+ | Extensible constructed dependency | Field assigned from a prototype `createX()` factory method |
963
+ | Any other supposed constant | Defect: choose the real role or remove it |
964
+
965
+ Read live statics through the receiving class. JavaScript dispatches getter
966
+ and prototype method overrides while a parent constructor runs. A subclass
967
+ field initializer runs only after `super()` returns. It cannot change parent
968
+ construction. This mechanism makes getters safe tunables and `createX()`
969
+ methods safe construction seams.
970
+
667
971
  ```ts
668
972
  // state block — CONTIGUOUS: reads as the instance's STATE TABLE
669
973
  get sheet() {
@@ -708,7 +1012,7 @@ convention and check it in review.
708
1012
  ## Self-review checklist (run over your ivue diff)
709
1013
 
710
1014
  - [ ] Every mutable state member is `get x() { return ref(...) }` — no mutable plain fields.
711
- - [ ] Inside the class, every Ref/Computed read/write uses `.value`; plain fields are constants/config only.
1015
+ - [ ] Inside the class, every Ref/Computed read/write uses `.value`; every plain field matches one role in the constants table.
712
1016
  - [ ] Derived values are PLAIN getters; `computed()` appears only for expensive / render-suppressing / stable-handle cases.
713
1017
  - [ ] Stores/composables are injected via `private get $store() { return useStore() }`, not field initializers.
714
1018
  - [ ] The class is exported through the namespace (`$Class` / `Class = Reactive($Class)` / `Instance`); generics cast `Class` and hand-apply `ReactiveInstance` to `Instance<T>`.
@@ -723,4 +1027,7 @@ convention and check it in review.
723
1027
  - [ ] Every `computed()`/constructor-watch CALLBACK delegates to a method (`computed(() => this.recalculate())`) — no logic inlined in reactive closures; the arrow form, never `computed(this.method)`.
724
1028
  - [ ] Identifiers are unfolded to domain words (`row`/`col`/`cell`/`cellValue`/`versionRef`…), loop indices and specs included — no single-letter names, no name meaning different things in different methods.
725
1029
  - [ ] Keyed/sparse state uses the Map-of-refs shape (get-or-create on read, peek-only bump on write, explicit release path) — never one getter per key, never a deep `reactive()` collection.
1030
+ - [ ] Static members are anchored (`const $Class = Static($X)`); `$`-prefixed static getters are compute-once-per-receiver caches, non-`$` statics stay live knobs, and inheritance extends `$Class` — never the mutable `Class`.
1031
+ - [ ] Instance reads of own statics go through `this.self` (declared once per class needing it, cast to `typeof $X`, plain getter never `$self`); 2+ reads or loops hoist `const self = this.self`; no per-site `this.constructor` casts; `Namespace.Class` reads stay reserved for late-bound capability dispatch.
1032
+ - [ ] Static members precede the constructor; the constructor precedes state, prop, and derived getters; methods come last.
726
1033
  - [ ] Spacing carries meaning: declaration-like getters contiguous within their group; blank lines only where a doc comment / multi-line body / category boundary begins; methods always separated.