Skip to content

The Standard Operating Manual ​

This page ships to AI coding agents verbatim, as the /ivue skill — .claude/skills/ivue/SKILL.md — because the instructions that make an agent write correct ivue turn out to be exactly the reference a human wants open in a second tab. Everything here is production-proven; the why behind each rule lives in the guide chapters.

Install it as a skill

One command copies this exact document into your project, version-locked to the ivue you have installed — agents pick it up from .claude/skills/ivue/:

sh
npm install ivue        # first — the CLI ships inside the package
npx ivue skill          # Claude Code — .claude/skills/ivue/
npx ivue skill --all    # + every agent whose footprint exists in the repo

The content is identical for every agent — only the discovery format differs. --all detects what you use (.cursor/, .github/, AGENTS.md) and never scaffolds a tool you don't; --cursor, --copilot and --agents (alias --codex — Codex CLI, Windsurf and Gemini CLI all read AGENTS.md) install their target explicitly. Evaluating before adopting? npx degit infinite-system/ivue/.claude/skills/ivue .claude/skills/ivue grabs the latest from the repo instead.

The architecture this manual encodes — every scope memoized, with polymorphism, inheritance, and performance intact — is argued in narrative form, full code included, in Bulletproof class modules. The blog is an extension of these docs, not a side channel.

ivue Reactive ​

Author reactive Vue 3 logic as a plain class $X, then export Class = Reactive($Class) through namespace X. The engine transforms the prototype once: ref-returning getters become cached Refs/Computeds, plain getters de-optimize to native getters (reactive via leaf tracking), methods become stable bound functions. Instances stay plain objects. Follow the rules below exactly — every deviation is either a compile error or a silent no-op at runtime.

The manual reads in three parts: the Reactive() instance world (the class and SFC templates, ownership, typing, watches, stores, keyed state), the static world (Static(), shared stores, and reading your own statics — everything from ivue/extras), and the style contract (naming, spacing, the self-review checklist).

Setup — ivue must be installed ​

import { Reactive } from 'ivue' resolves only when the package is a dependency. Before writing ivue code, check package.json for ivue; if it is missing, install it with the project's package manager:

sh
npm install ivue   # or: yarn add ivue / pnpm add ivue / bun add ivue

Some apps vendor the engine instead — a local module such as src/utils/ivue.ts re-exporting Reactive. If one exists, import from that path and skip the install; never add the dependency alongside a vendored copy.

The class template (copy this shape) ​

ts
import { Reactive } from 'ivue'; // in this app: 'src/utils/ivue'
import {
  ref,
  shallowRef,
  computed,
  watch,
  onMounted,
  toRef,
  type Ref,
} from 'vue';
import { useProjectStore } from 'src/stores/project.store';

class $Box {
  // Constructor runs SYNCHRONOUSLY where you `new` — in setup() that
  // means the constructor body IS setup code, and the whole toolbox
  // works here:
  // - plain watch/watchEffect land in the COMPONENT's scope (reaped
  //   on unmount);
  // - lifecycle hooks (onMounted, onUnmounted, …) register against
  //   the mounting component — full lifecycle access, zero wiring;
  // - callbacks delegate to methods (the thin-closure rule).
  // (this.$watch is ONLY for instances that OUTLIVE the component —
  // see the singleton variant below. Lifecycle hooks NEVER belong in
  // those.)
  constructor(
    public props: BoxProps,
    public emit: BoxEmits,
  ) {
    watch(
      () => this.height.value,
      (height, oldHeight) => this.onResize(height, oldHeight),
    );
    onMounted(() => this.focusBox());
  }

  // MUTABLE STATE — getter returning ref()/shallowRef(). `this` is
  // RAW: read AND write via .value. shallowRef for big structures you
  // REPLACE wholesale.
  get height() {
    return ref(4);
  }
  get rows() {
    return shallowRef<Row[]>([]);
  } // deep mutations do NOT trigger

  // TEMPLATE-REF TARGET — a ref(null); the SFC destructures it for
  // ref="boxEl".
  get boxEl() {
    return ref<HTMLElement | null>(null);
  }

  // PROPS Pattern — plain getters, one per prop the class consumes.
  // Reactively tracked through the props proxy (leaf tracking).
  get width() {
    return this.props.width;
  }
  get title() {
    return this.props.title;
  }
  get isDisabled() {
    return this.props.disabled;
  }
  get items() {
    return toRef(() => this.props.items);
  } // when you need a ref handle

  // The pattern's extra capability: refine the SUPPLIED prop into
  // the prop the template actually needs — mixing other props, state,
  // and constants, all still leaf-tracked. The template reads the
  // refinement, never the raw prop; the prop is an INPUT to the
  // model, not wired to the view.
  get displayTitle() {
    return this.title || `Box ${this.width}×${this.height.value}`;
  }

  // DERIVED — PLAIN getter, NO computed().
  // Reactive via leaf tracking; 0 bytes/instance.
  get area() {
    // prop × ref — both leaf-tracked
    return this.width * this.height.value;
  }
  get widthPx() {
    return this.width + 'px';
  }

  // computed() — SURGICAL opt-in only: expensive work,
  // render-suppression by value-equality, or a stable ref handle for
  // watch/props (~300 bytes/instance). THIN closures (see "computed()
  // and watch callbacks delegate to methods"): the computed only
  // dials a method — logic stays on the prototype, directly testable,
  // minimum footprint.
  get sortedRows() {
    return computed(() => this.sortRows());
  }
  get celsius() {
    return ref(20);
  }
  get fahrenheit() {
    return computed({
      get: () => this.celsiusToFahrenheit(),
      set: (fahrenheit: number) => this.setFromFahrenheit(fahrenheit),
    }); // writable computed — the only way to give a COMPUTED a setter.
    // A native `get x() / set x(value)` accessor pair works too;
    // pick the computed form when the member must be a ref handle
    // (v-model target, watch source, destructured state binding).
  }

  // STORE / COMPOSABLE — `$`-getter caches WHOLE, forever, per
  // instance. Resolves on first touch (after Pinia/app ready);
  // circular-import safe.
  protected get $project() {
    return useProjectStore();
  }
  get projectId() {
    return this.$project.projectId;
  }

  // METHODS — plain; engine-binds to raw (stable identity, safe as
  // handlers). Reactive-closure bodies above delegate HERE (the
  // thin-closure rule).
  grow() {
    this.height.value++;
  }

  focusBox() {
    this.boxEl.value?.focus();
  }

  sortRows() {
    return [...this.rows.value].sort(byScore);
  }

  celsiusToFahrenheit() {
    return (this.celsius.value * 9) / 5 + 32;
  }
  setFromFahrenheit(fahrenheit: number) {
    this.celsius.value = ((fahrenheit - 32) * 5) / 9;
  }

  onResize(height: number, oldHeight: number) {
    /* ... */
  }
}

export namespace Box {
  export const $Class = $Box; // raw — children `extends` this
  export let Class = Reactive($Class); // reactive — you `new` this
  // the type of every unwrapping surface (defineExpose, reactive())
  export type Instance = typeof Class.Instance;
}

A class with NO static members exports exactly this shape. Only a class that DECLARES statics anchors them — export const $Class = Static($Box) — and reads them from instance code through self; both live in the static-world sections below.

The optional Model line (domain entity graphs) ​

When classes hold and pass RAW instances of each other — entity collections, method parameters, factory returns — the namespace grows a fourth line:

ts
export namespace Task {
  export const $Class = $Task;
  export let Class = Reactive($Class);
  // raw-instance type — collections, parameters, returns
  export type Model = InstanceType<typeof Class>;
  // the type of every unwrapping surface (defineExpose, reactive())
  export type Instance = typeof Class.Instance;
}

Model is the raw-instance type (Refs stay Refs; .value access) — use it for shallowRef<Task.Model[]> collections and workloadPercent(member: Member.Model) parameters. Instance remains ONLY for unwrapping surfaces (defineExpose, reactive(), template refs); never type a raw collection with it.

The SFC wiring template (copy this shape) ​

vue
<script lang="ts" setup>
import { Box } from './Box';

const props = withDefaults(defineProps<BoxProps>(), { width: 400 });
const emit = defineEmits<BoxEmits>();

// ONE raw instance — the same object drives template, emits
// payloads, and expose. No reactive() wrapper, no unwrap view. The
// constructor runs init in setup context.
const box = new Box.Class(props, emit);

// THE STATE DESTRUCTURE — one statement, grouped. Every Ref/Computed
// the template touches is listed here; each binding IS the cached
// cell (stable identity), and setup bindings unwrap uniformly in
// EVERY template position. NEVER destructure plain getters or
// methods (snapshots a dead value).
const {
  // state refs
  height,
  celsius,
  // computed refs
  sortedRows,
  fahrenheit,
  // element refs
  boxEl,
} = box;

// Type the expose surface through Instance — it strips readonly so
// ref-writes typecheck.
defineExpose(box as Box.Instance);
</script>

<template>
  <!-- State bindings — reads AND writes compiler-unwrapped.
       fahrenheit is the writable computed: v-model writes through
       its setter. -->
  <input
    ref="boxEl"
    v-model.number="fahrenheit"
    :disabled="box.isDisabled"
  />
  <div v-if="height > 4">
    {{ box.displayTitle }} — {{ celsius }}°C is {{ fahrenheit }}°F
  </div>
  <ul :style="{ width: box.widthPx }">
    <li v-for="row in sortedRows" :key="row.id">{{ row.name }}</li>
  </ul>
  <!-- Plain getters and methods: DOTTED on the instance, no .value -->
  <button @click="box.grow()">grow — area {{ box.area }}</button>
</template>

The class carries the WHOLE contract; the namespace is identity and types ​

A class FILE is a SINGLE-FILE MODEL — the model-side twin of the single-file component. It has exactly three residents: imports, the class, the namespace. The component contract — prop types, prop defaults, their fusion, emits, and every tuning constant — lives ON THE CLASS as static getters, beside the state and behavior it governs. The namespace holds identity and TYPES only, every type DERIVED from $Class. Two worlds would make a class half extensible: a const in a namespace cannot be overridden by a subclass, is not inherited, and does not swap with Class under a global override — so a runtime declaration never lives there.

  • Contract (on the class, static) — static get propsTypes() (defineComponent-style, no defaults, returned through definePropTypes({...}) so the required: true literal survives typeof); static get propsDefaults() (plain values, annotated ExtractPropDefaultTypes<typeof $X.propsTypes> — required props are filtered out of the check automatically, and a deliberately default-free optional prop is declared key: undefined, stating the ruling in data); static get props() — the ONE fusion line, propsWithDefaults(this.propsDefaults, this.propsTypes), reading through the receiver so a subclass's props fuses ITS types and defaults; static get emits() (object-declared validators). The four contract members are ALWAYS getters — one form across every class, because a contract is what gets merged across classes (a base, a peer, a mixin) and a getter reads at first use, after every module has loaded; and it is read once per construction, so the getter costs nothing. Tuning constants and tables are static readonly fields (see "Static data is a field"); the $ prefix stays reserved for compute-once caches. Types and defaults stay two members ON PURPOSE: a variant re-tunes defaults without re-typing. A nested object prop (a knobs tree) is filled from the defaults at every depth with nestedProps(props, this.self.propsDefaults) from ivue/extras, once, in the constructor (in place — lodash's defaultsDeep with arrays taken whole); the class reads complete props and never merges in a getter. Vue itself never merges a supplied object with its default.
  • Identity (namespace) — $Class (raw, for children to extend), Class (Reactive(), for you to new), Instance (and Model when used).
  • Types (namespace) — DERIVED from the class, never hand-duplicated: Props is ExtractPropTypes<typeof $Class.props> (a generic component grafts its parameter back over the one prop a runtime map cannot carry: Omit<ExtractPropTypes<typeof $Class.props>, 'modelValue'> & { modelValue: T[] }); Emits is ExtractEmitTypes<typeof $Class.emits>; Slots; Exposed is ShallowUnwrapRef<Instance>. Domain types the contract refers to (an item shape, a variant preset) live here as namespace types; the class reads them as X.Item.

Combined — the canonical file, everything above in one shape:

ts
// Box.ts — the whole module: imports, the class, the namespace. Nothing else.
import type { ExtractPropTypes, PropType, ShallowUnwrapRef } from 'vue';
import {
  definePropTypes,
  propsWithDefaults,
  Reactive,
  type ExtractEmitTypes,
  type ExtractPropDefaultTypes,
} from 'ivue';
import { Static } from 'ivue/extras';

class $Box {
  /* Contract — STATIC: owned by the class, extended with `super` */

  /** 1 — the TYPES: a defineComponent-style object, no defaults inside.
   *  definePropTypes is an identity call that keeps `required: true` a
   *  LITERAL — a bare object widens it to boolean, which would blind the
   *  defaults check below. */
  static get propsTypes() {
    return definePropTypes({
      title: { type: String as PropType<string>, required: true },
      size: { type: Number as PropType<number> },
      maxHeight: { type: Number as PropType<number> },
      disabled: { type: Boolean as PropType<boolean> },
    });
  }

  /** 2 — the DEFAULTS: plain values, typed against the types object.
   *  Required props (`title`) are filtered out of the check
   *  automatically; every OPTIONAL prop must appear — `undefined` is the
   *  explicit "no default ON PURPOSE" ruling, stated in data. A getter,
   *  like every contract member: it may read a knob through the receiver
   *  (a subclass re-tuning DEFAULT_SIZE re-tunes the default) or merge a
   *  peer's defaults, and it is read once per construction. */
  static get propsDefaults(): ExtractPropDefaultTypes<typeof $Box.propsTypes> {
    return {
      size: this.DEFAULT_SIZE,
      maxHeight: undefined, // unset = unbounded — deliberately default-free
      disabled: false,
    };
  }

  /** 3 — the FUSION: a standard Vue props object, ready for defineProps.
   *  Reads through the receiver — a subclass's `props` fuses ITS own
   *  types and defaults. Written once per hierarchy; a subclass that ADDS
   *  props re-declares this one line so its derived `Props` widens (a
   *  static's return type is not polymorphic). */
  static get props() {
    return propsWithDefaults(this.propsDefaults, this.propsTypes);
  }

  static get emits() {
    return {
      close: (title: string) => true,
    };
  }

  /** A tuning constant: static DATA is a field, one value per class, read
   *  for free; a subclass overrides it. Annotated with its widened type
   *  because `readonly` narrows a literal to itself, and a subclass's
   *  `300` must still be a `number` to the base's `self`. */
  static readonly DEFAULT_SIZE: number = 400;

  /** The one cast per class: instance code reads its own statics here. */
  protected get self() {
    return this.constructor as typeof $Box;
  }

  // Type positions resolve non-positionally — the class names its own
  // namespace's derived types freely.
  constructor(
    public props: Box.Props,
    public emit: Box.Emits,
  ) {}

  get title() {
    return this.props.title;
  }

  get sizeLabel() {
    return `${this.props.size}px`;
  }

  close() {
    this.emit('close', this.props.title);
  }
}

export namespace Box {
  /* Identity */

  export const $Class = Static($Box); // anchor — it declares statics; children `extends` this
  export let Class = Reactive($Class); // reactive — you `new` this
  export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop

  /* Types — DERIVED from the class's statics, never hand-duplicated */

  export type Props = ExtractPropTypes<typeof $Class.props>;
  export type Emits = ExtractEmitTypes<typeof $Class.emits>;

  export interface Slots {
    default: (scope: { title: string }) => any;
  }

  /** What consumers hold through a template ref (expose unwraps refs). */
  export type Exposed = ShallowUnwrapRef<Instance>;
}

The SFC is pure wiring against the seam, and it reads the contract through Class — the mutable slot — so a global override swaps the contract together with the runner. The macros receive RUNTIME objects, so no compiler macro ever resolves a cross-file type:

ts
const props = defineProps(Box.Class.props); // non-generic: the type is inferred
const emit = defineEmits(Box.Class.emits) as Box.Emits;
defineSlots<Box.Slots>();
// generic components cast the one graft:
// defineProps(X.Class.props) as unknown as X.Props<T>

A subclass extends its contract the way it extends behavior — with super, overriding only what defines the specialization, with the reason on the line. Re-tuning a default needs ONE override; adding a prop needs the types override plus the one-line props re-declaration:

ts
class $CardBox extends Box.$Class {
  static override get propsDefaults(): typeof Box.$Class.propsDefaults {
    return {
      ...super.propsDefaults,
      size: 300, // cards are hundreds of px wide; rows were tens tall
    };
  }
}

class $TaggedBox extends Box.$Class {
  static override get propsTypes() {
    return definePropTypes({
      ...super.propsTypes,
      tag: { type: String as PropType<string> },
    });
  }
  static override get propsDefaults(): ExtractPropDefaultTypes<typeof $TaggedBox.propsTypes> {
    return { ...super.propsDefaults, tag: '' };
  }
  static override get props() {
    return propsWithDefaults(this.propsDefaults, this.propsTypes); // widens TaggedBox.Props
  }
}

The anchor rule is unchanged and now reaches every component class: a class that DECLARES statics — and the contract is statics — anchors at $Class with Static() (export const $Class = Static($Box)), and so does a subclass that overrides one. A subclass that only inherits stays raw. The anchor costs nothing on getters (native reads) and is what gives a $-cached static its compute-once semantics.

One seam, any size. A contract of forty documented props is still authored on its class — a static table scrolls like any other member, and a sibling XProps.ts would be the parallel world again (a second runtime owner the class mechanics cannot reach). Shared base surfaces are a base CLASS (class $ChooseField extends Field.$Class), never a spread-in const: inheritance is the only composition the contract uses.

Static data is a field; a static that computes, merges, or is the contract is a getter ​

A static that HOLDS data — a number, a string, a table, a shader, a list of seeds — is a static readonly field. A static that COMPUTES from other statics through the receiver — the props fusion, a default that reads a knob, a $-cached engine — is a getter. And the four contract members (propsTypes, propsDefaults, props, emits) are getters ALWAYS, whatever they hold: one form across every class, because a contract is what gets merged across classes and it is read once per construction, where a getter costs nothing and a second form would be variance for no gain. The line is what happens on read: a field is one object per class, read for free; a getter runs its body every time, and a getter that returns a literal table allocates that table on EVERY read. In a formatter called once per sample of every track, a static getter building { fromX, toX, … } afresh was a measurable share of each frame's cost; as a field it is one object for the life of the class.

ts
// ✅ data: one value per class, overridable, free to read
static readonly COUNT: number = 15;
static readonly STARTLE = { burst: 0.42, riseMs: 140, settleMs: 900 };
static readonly RIDGES: Scene.Ridge[] = [
  { key: 'far', factor: 0.1 }, { key: 'near', factor: 0.5 }
];

// ✅ computation: reads the receiver, so a subclass's override applies
static get props() { return propsWithDefaults(this.propsDefaults, this.propsTypes); }

// ❌ data as a getter: a new table on every read, for no override a field would not give
static get STARTLE() { return { burst: 0.42, riseMs: 140, settleMs: 900 }; }

Extension is unchanged: a subclass overrides the field, and extends a table by spreading super — legal in a static initializer, evaluated once, at class definition:

ts
class $HawkFlock extends Flock.$Class {
  static override readonly COUNT: number = 7;
  static override readonly STARTLE = { ...super.STARTLE, flapBoost: 2.2 };
}

The boundary, and it is the reason the getter form existed: a field's initializer runs ONCE, at class definition, in module evaluation order; a getter runs at first read, after every module has loaded. So a field may read super (the base is defined before the subclass by construction), its own class's earlier statics, and anything imported from a module OUTSIDE an import cycle. A static that reads another class which may import this one back — a table composed from a sibling's constant, a defaults map merged from a peer, a store, an engine — stays a getter, because at definition time that class can still be undefined. That is why every $-cached store and engine is a getter and stays one, and the gate's cross_module_class_reads_happen_inside_bodies check is the enforcement. A $ getter is the form for a value whose IDENTITY must be stable — a store, an engine, a memo table, a composed kit — computed once per receiver at first read; it is not the form for the contract, whose defaults must be fresh per read (an object default shared across instances is the bug Vue's factory rule exists to stop). The same for a static that probes the ENVIRONMENT — typeof CSS !== 'undefined' && 'highlights' in CSS, a window measurement, a feature flag: a field freezes the answer at module load, before a test installs the API or on a server that has none; a getter answers at the read. When in doubt about a cycle or an environment, the getter is never wrong; the field is faster only where it is safe.

One consequence of readonly to carry: it narrows a literal to itself (= 15 is the type 15), so a knob a subclass re-tunes carries its widened type (: number) or the subclass's value fails against the base's self. The forms interoperate across a hierarchy — statics are own properties of each constructor, so lookup is a chain walk, the form is per class, and super reads across the seam either way; instance members are the asymmetric case, where TypeScript refuses an accessor over a property. But the two forms make different PROMISES to a reader, and an override keeps the base's promise, whatever its form:

  • a field promises ONE object — a reader may compare it by identity or keep a memo in it. Over a base field, an override is a field ({ ...super.TABLE, mine: 1 }) or a $ getter, stable per receiver; never a plain getter, which would hand the base's readers a fresh object on every read.
  • a plain getter promises a FRESH value per read — a reader may mutate its result. Over a base getter, an override is a getter; a field only when the value is immutable, or one object is shared across every read (the defaults hazard).
  • a $ getter over a field is the one computing override a field admits: computed at first read, the identity a field promised kept.

The type checker cannot see this — both forms type the same — so the promise is the author's to keep; a gate check that reads the base's form across the import graph is an open item. The Static() anchor rule is unchanged: a class that declares statics anchors, fields included.

Instance-owned bookkeeping that is not state ​

An instance holds things that are neither reactive state nor derivation: a memo table, a queue of animations in flight, which chapter each slot currently draws, a batch's scratch. They are owned by the instance, mutated by its methods, and nothing renders from them. Their form is a readonly field holding a container that is mutated in place — a Map, an array, a plain bag — never a reassignable field (writes to a plain field trigger nothing, and the gate says so), and never a ref (a ref nobody watches is ceremony that also costs a dependency track on every read in a hot loop):

ts
// ✅ instance-owned, mutated in place, rendered by nothing
protected readonly memo = { local: new Map<number, Local>(), span: new Map<number, Span>() };
protected readonly pieces: Piece[] = [];
protected readonly slotChapters = [0, 0];

// ❌ reassigned: the gate flags it, and a reader cannot tell it from state
protected trackCache: Track[] | null = null;

When the thing must be REPLACED rather than mutated (a table rebuilt for a new element), it is a shallowRef getter like any state — the one place a ref is right, because the replacement is an event the class may need to observe.

A getter derives from state, never from the DOM ​

A plain getter is free because it is arithmetic over refs the engine tracks. A getter that QUERIES — querySelectorAll, getBoundingClientRect, a measurement — is a cost dressed as a derivation, paid on every read, and reads are what a getter invites. The track table of a scene was rebuilt from element queries on every frame of the callback path until it was cached per stage element. The form: a method that builds the table once (buildTracks(stage)), a shallowRef that holds it keyed by the element it was built for, and a getter that returns the held table. And a count, a label, a length that describes such a table derives FROM the table (this.trackList().length), never from a formula beside it — a subclass that adds a row cannot get the formula right.

Hot loops: hoist, memoise per batch, write once ​

A formatter that runs once per sample of every track is a hot loop, and the standard's free reads stop being free there. Three moves, in order of yield, all measured on a scene composing 37 tracks:

  • Memoise geometry per batch. Every track's formatter derives the same chapter, span and progress from the same value; derive it once per value per batch (a Map on the instance, cleared when the batch begins) — 8,880 scroller lookups a piece became 240.
  • Write a constant once. A track whose formatted value does not change over a batch is one inline write, not an animation; the test is exact (every formatted value equal to the first), and the browser parses no keyframes for it.
  • Hoist what the loop reads. A static read through self per iteration, a table a getter builds per read, a Math lookup — take them out of the loop into a const above it (or make the static a field, which is the same hoist done once per class).

Prefer the structural fix to the micro one: sampling the tracks every 250 ms and interpolating cut the keyframes from 35,000 to 2,000 in the same nine seconds, and no amount of hoisting inside the old loop would have found that.

Overrides say so out loud. noImplicitOverride is on: every member that overrides a base member carries the override keyword (protected override get offsetSize() { ... }). A silent override refuses to compile, and a base rename breaks every subclass at the exact overriding member instead of quietly orphaning it.

private is banned — visibility is a three-tier semantic. ivue's core promise is extend-don't-fork, and private is the one keyword that structurally revokes it: a subclass that needs a private member has exactly one option, copy the file. TypeScript's private is compile-time advisory anyway — it protects nothing at runtime and forbids only the legitimate extender. So every member picks its tier by AUDIENCE:

tieraudiencemeaning
publictemplates & consumersthe component/module surface
protectedsubclassesa seam of the hierarchy — reachable to extend, invisible to templates and consumers (TS enforces this)
privatenobodybanned — "must hide it even from subclasses" is a design smell; resolve by naming and documenting the member

The pairing with noImplicitOverride is what makes protected-everything safe rather than fragile: every subclass touchpoint is annotated override, so a base renaming or removing a protected seam breaks every extender's BUILD at the exact member — seam drift is loud, never silent. (Both halves are load-bearing: protected opens every seam, the tsconfig makes changing one detectable.)

One template, one logic owner ​

Every behavioral SFC has exactly one ivue class as its template logic owner. <script setup> is the wiring boundary only:

  • import dependencies;
  • call compiler macros (defineProps, defineEmits, defineExpose);
  • construct new X.Class(...) once;
  • destructure the Ref/Computed bindings the template consumes.

Do not place component-local ref, computed, watch, lifecycle hooks, or free functions beside that instance. State belongs in ref-getters, derivations belong in plain getters, setup work belongs in the constructor, and event handlers belong in methods — even when the handler only normalizes a DOM event before delegating to a domain model.

One DOM event, one handler, named for the event. A template never binds two events to the same method (@pointerup="x.onUp" @pointercancel="x.onUp"), and a class never registers one method for two event types. A cancel gets onPointerCancel, whose body may be one line delegating to onPointerUp; a track's touchstart and touchmove get onTrackTouchStart and onTrackTouchMove even when both only claim the touch. The reason is the override seam: a subclass that must treat a cancel differently can override onPointerCancel alone, where a shared handler would make it re-derive which event it is handling from the event object — and the standard's whole point is that behavior extends by name.

When building on a class-backed component, extend its class, not its <script setup>. Add behavior to the existing class when it belongs to the same component contract. When it is a real specialization, subclass the raw class and publish the normal namespace:

ts
class $SearchBox extends Box.$Class {
  clearSearch() {
    this.search.value = '';
  }
}

export namespace SearchBox {
  export const $Class = $SearchBox;
  export let Class = Reactive($Class);
  export type Instance = typeof Class.Instance;
}

Never create a parallel behavior layer of setup functions around an existing class. That splits ownership, hides behavior from inheritance, and makes the template depend on two architectures.

A genuinely markup-only leaf may remain classless; do not manufacture an empty class for static presentation. The moment the component owns state, derivation, setup behavior, or an event handler, it has crossed the boundary and needs one class.

The template's two access styles carry meaning: a state binding = a destructured Ref/Computed, dotted box.x = a derivation or an action (plain getter / method) — the class's own anatomy, visible at the call site. Rules that keep it clean:

  • The destructure is TOTAL: every Ref/Computed the template touches is destructured; a Ref is NEVER reached through the instance in the template (interpolating box.someRef renders via display-unwrap, but v-if="box.someRef" is always-truthy — the seam the total destructure abolishes).

  • In the <script setup> BODY, destructured bindings are refs — use .value there as everywhere else. Inside <template> only, the compiler unwraps them.

  • The remaining .value boundary: top-level component state is destructured and auto-unwrapped. Collection items and slot props are nested values, so Vue does not auto-unwrap their Ref fields; use item.title.value. This is ivue's principal syntax tradeoff, preserving direct, allocation-free reads where lists are hottest.

  • Perf escape (measured): a METHOD called in a render-hot path (per row of a large v-for) may be destructured — methods are identity-stable and the hoisted call runs at closure speed (~1.4 vs ~4 ns dotted). Reserve it for profiled hot paths; everywhere else methods stay dotted (the naming signal).

  • Instance-swapping components keep dotted access: if the component replaces its instance (model.value = new X.Class()), destructured bindings would go stale — don't destructure what you swap.

  • Don't shadow props. A destructured state binding with the same name as a defineProps prop silently shadows it in the template (setup bindings win). Rare by construction: the class consumes props through prop-getters, so prop-derived values stay DOTTED (box.width, box.widthPx) and never compete with state-binding names.

  • No logic in template expressions — name it as a derived getter.v-if="items.length && !loading && mode === 'edit'" is an anti-pattern: the condition has no name, duplicates across call sites, and its pieces can't be tested. Every combination, comparison or ternary lives on the class as a PLAIN getter whose name says what the condition MEANS — v-if="box.canEditItems". When the condition takes an argument (per-item in a v-for), the same rule wears its method form — v-if="media.fileExists(index)" — still a name, still no inline logic. In ordinary Vue this discipline costs a computed() per condition, so nobody keeps it; here a named plain getter costs zero bytes, so there is no excuse. Templates read as prose: bindings, names, and events — never expressions. The split is stage directions and script: the template says who is on stage and what happens when someone acts; the class says what everything MEANS. Structure stays in the template, meaning moves to the class.

  • The rule covers EVERY binding kind, not just v-if — the common leaks are display strings, disabled states, and class objects:

    leaked into the templatederived on the class
    interpolating sending ? 'Sending…' : 'Send to ' + recipients.lengthinterpolating model.sendButtonLabel
    :disabled="!model.canSend || sending":disabled="model.sendDisabled"
    :class="{ active: view === tab.name }":class="{ active: app.isOpen(tab.name) }"
    row.name || '—' in a v-for cellFormat.Class.orDash(row.name)
    :style width from (day.count / peak) * 100 + '%':style width from model.barWidth(day)

    Each right-hand form is a prototype member: unit-testable without mounting anything, greppable by name, typed, and hot-graftable. The one thing that stays in the template is STRUCTURE — v-if/v-else branching on a named condition or a data field (v-if="entry.nextSlug") and v-for over a collection. Branching on data is structure; COMPUTING with data is logic, and logic lives on the class.

The outliving instance (module singleton, entity) ​

For an instance that OUTLIVES any component — a module singleton, an entity created in a callback — watchers go in the instance's OWN scope, and the owner of its lifetime disposes it:

ts
import { Reactive, type ReactiveHelpers } from 'ivue';

class $Session {
  get user() {
    return ref<User | null>(null);
  }

  // Outliving instance: $watch/$watchEffect register in the
  // instance's lazy effectScope — there is no component scope here
  // to reap plain watch.
  // WATCHERS live behind a method, not inline in the constructor —
  // the constructor calls it once, and the instance can RESTART its
  // watchers after a keep-state stop (see suspend() below).
  constructor() {
    this.startWatchers();
    // If constructed INSIDE some scope, auto-wire teardown instead:
    //   getCurrentScope() && onScopeDispose(() => this.$stopEffects());
  }

  startWatchers() {
    this.$watch(
      () => this.user.value,
      (user, previousUser) => this.onUserChanged(user, previousUser),
    );
    this.$watchEffect(() => this.persist());
  }

  // SUSPEND / RESUME: { reset: false } stops the watchers ONLY — every
  // cached cell survives with its current value. startWatchers() in a
  // fresh scope resumes. (Default $stopEffects() also CLEARS the cells:
  // the next touch re-runs initializers — disposal is a reset.)
  suspend() {
    this.$stopEffects({ reset: false });
  }
  resume() {
    this.startWatchers();
  }

  // CLEANUP composes as an ORDINARY method — no hooks, no reserved
  // names, ivue never auto-calls your code. Do the non-Vue work
  // (sockets, listeners from composables), then reset the engine.
  dispose() {
    this.disconnect();
    this.$stopEffects();
  }

  onUserChanged(user: User | null, previousUser: User | null) {
    /* ... */
  }
  persist() {
    /* ... */
  }
  disconnect() {
    /* ... */
  }
}

export namespace Session {
  export const $Class = $Session; // raw — children `extends` this
  export let Class = Reactive($Class); // reactive — you `new` this
  // the type of every unwrapping surface (defineExpose, reactive())
  export type Instance = typeof Class.Instance;
}

// The engine installs $watch / $watchEffect / $stopEffects at Reactive(),
// AFTER the class body was typed — so a class that calls them merges the
// helpers into its own instance type. One line, zero runtime; the gate
// reads it as the class's second half, never as a stray type.
interface $Session extends ReactiveHelpers {}

// The owner disposes — the class's own method, like any other:
session.dispose();

DO / NEVER ​

DONEVER
class $X + export namespace X { $Class; Class = Reactive($Class); Instance }export a bare Reactive(class {...}) for anything that grows a parent/dependent
mutable state = get x() { return ref(v) }put mutable state in a plain field — writes trigger nothing
.value for every Ref/Computed inside the class and in the script bodywrite this.x = v for a Ref/Computed in the class — it clobbers the ref or no-ops
derive with a PLAIN getterwrap every derivation in computed() — pays ~300 bytes/instance for nothing
computed() only for expensive / render-suppressing / stable-handle needsreach for computed() by default
inject stores via protected get $store() { return useStore() }store = useStore() field initializer — runs at construction, breaks tests/SSR/cycles
new X.Class(props, emit) — raw instance everywherewrap in reactive(instance) or any shallow-unwrap view as the standard
destructure ALL template-touched Refs/Computeds + element refs, groupeddestructure plain getters or methods — snapshots a dead value / loses nothing but clarity
state bindings in templates; dotted box.x only for plain getters/methodsreach a Ref through the instance in a template — v-if="box.someRef" is always-truthy
labels, disabled states, and class conditions as named getters/methods (model.sendButtonLabel, model.sendDisabled)ternaries, ||/&& chains, comparisons, or string-building inside template expressions
defineExpose(box as X.Instance)defineExpose(box) raw — readonly-accessor writes will type-error for consumers
constructor runs init; register hooks/watchers thereadd an init() method expecting auto-call — ivue never calls it
plain watch in component-scoped constructors; $watch + a $stopEffects dispose path for outliving instancesdefault to this.$watch in a component-scoped class — its scope silently outlives unmount
a class that calls this.$watch / $watchEffect / $stopEffects merges the engine's helpers beside itself: interface $X extends ReactiveHelpers {} (one line, zero runtime)(this as any).$watch(...) or per-member declare $watch: … lines — the body should typecheck without a cast
compose cleanup as an ordinary method — dispose() { /* non-Vue cleanup */ this.$stopEffects(); }expect a teardown hook — ivue auto-calls NOTHING (no init(), no stopEffects())
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
protected for every internal member — subclasses reach every seamprivate anywhere in an ivue class — it forbids only the legitimate extender
instance code reads its own statics through this.self (the one cast per class); hoist const self = this.self for 2+ reads or any loopper-site (this.constructor as typeof $X) casts — each one is an unchecked class-name assertion

The unwrapping-surface typing invariant ​

Vue's expose proxy and reactive() unwrap ref READS and redirect ref WRITES into .value at runtime — but TypeScript keeps get-only accessors readonly through its homomorphic unwrap types. So a surface typed from the raw class FORBIDS writes the runtime allows. Instance (= ReactiveInstance, i.e. typeof Class.Instance) strips readonly via its writable-getter remap. It is the TYPE of every unwrapping surface.

  • Producing an exposed instance: defineExpose(box as X.Instance).
  • Consuming a template ref to it: ShallowUnwrapRef<X.Instance> (generic: ShallowUnwrapRef<X.Instance<T>>).
  • Wrapping at an interop boundary: reactive(instance as X.Instance) (concession, not the standard).

Across expose, verified live: reads arrive unwrapped; ref-writes DO redirect (there is a write path); methods arrive engine-bound to raw; and PLAIN GETTERS STAY FULLY REACTIVE — watch(() => ref.value.someDerived, cb) fires on leaf change. What does NOT survive: setup-time snapshots (const v = ref.value.x), plain data fields (never reactive), pre-mount null (template refs are null until mount — use ?. in watch getters).

Common compile errors → fixes ​

Error / symptomFix
Cannot assign to 'x' because it is a read-only property (on an exposed/reactive()/template-ref surface)type that surface through X.Instance
Type 'boolean' is not assignable to type 'Ref<boolean>'missing .value on a Ref/Computed write — x.flag.value = true
'X' is possibly null on a template ref in a watch getteradd ?. — watch(() => x.boxEl.value?.foo, cb)
template write crashes / no-ops at runtime on the raw instanceyou wrote x.Ref/Computed = v; write x.Ref/Computed.value = v

Watch rules — and WHICH watch ​

the instance is…use
component-scoped (created in setup())plain watch / watchEffect — the component scope stops them on unmount
component-outliving (module singleton, created in a callback)this.$watch / this.$watchEffect — the instance's lazy scope; disposed by $stopEffects()
  • watch(() => instance.plainGetter, cb) works on a RAW instance — no reactive() wrapper, no Ref/Computed needed. The getter body runs inside the watcher's effect, so its leaf reads subscribe directly (non-intuitive but structural).
  • The source MUST be the FUNCTION form. watch(instance.plainGetter, cb) passes a dead snapshot and never fires.
  • $stopEffects() stops the instance scope and clears cached Refs/Computeds (the next touch re-materializes — disposal is a reset); $stopEffects({ reset: false }) stops the WATCHERS only — every cached cell survives with its current value, and startWatchers() in a fresh scope resumes (the suspend/resume pattern above); instances that never $watch allocate no scope. There are NO hooks — richer cleanup is an ordinary method that does its work and then calls $stopEffects() itself. Every outliving instance needs an OWNER that calls it — or, when constructed inside some scope, auto-wire: getCurrentScope() && onScopeDispose(() => this.$stopEffects());
  • Do NOT default to this.$watch in a component-scoped constructor: the component scope cannot see the instance scope, so without $stopEffects wiring that watcher outlives unmount.
  • Lifecycle hooks (onMounted, onUnmounted, …) follow the same split: the constructor runs synchronously where you new, so in a component-scoped class they register against the mounting component — full setup toolbox. Component-coupled classes ONLY; never in stores/entities that outlive components. If the class is also constructed outside components, guard: getCurrentInstance() && onMounted(() => this.onMount());
  • Watch CALLBACKS delegate to methods (the thin-closure rule): watch(source, (newValue, oldValue) => this.onChanged(newValue, oldValue)).

computed() and watch callbacks delegate to methods ​

A reactive closure is cached per instance. Keep that closure as a small pointer to behavior on the prototype: closures connect; methods contain logic.

ts
// ✅ THIN — the closure only delegates; logic stays named and testable
get sortedItems() {
  return computed(() => this.sortItems());
}
sortItems() {
  return [...this.items.value].sort(byPrice);
}

// ✅ same rule for watch callbacks wired in constructors
watch(value, (newValue, oldValue) =>
  this.onValueChanged(newValue, oldValue),
);

// ❌ FAT — logic is anonymous and duplicated inside the cached closure
get sortedItems() {
  return computed(() => [...this.items.value].sort(byPrice));
}

Also buys: guaranteed-minimum memory (the thin closure captures nothing but the instance — a fat closure silently pins any getter-scope local for the instance's lifetime) and direct testability (instance.sortItems()). Reactivity is unaffected — reads inside the method are tracked through the computed's evaluation exactly as if inlined.

Do NOT "optimize" the arrow away to computed(this.sortItems): it works (ivue methods are lazy-bound) but Vue 3.4+ passes the previous value as the getter's first argument, so a method that later gains an optional parameter silently receives stale data. Always the arrow.

$-prefixed singleton getters are frozen caches too — keep their bodies to a single composable/service call (return useThing()), nothing more.

The store pattern: a singleton behind use(), injected by $-getter ​

Shared application state (session, navigation, toasts, the current user) is a STORE — one ivue class published as a module singleton — never a model passed down as a prop. Prop-drilling a shared model (<ChildView :app="app" />, constructor(public app: AppModel.Instance)) threads one object through every component and constructor signature it crosses; the store pattern deletes the thread.

ts
// app/AppStore.ts — the store IS an ivue class; a static owns the singleton
// (imports: Reactive from 'ivue'; Static from 'ivue/extras')
class $AppStore {
  // The ONE instance, as a `$`-static: constructed on first read, after
  // the app exists, and cached on the receiver. It constructs through the
  // namespace slot, so a test double swapped into `Class` is what gets
  // built — the store has one receiver, the slot, so nothing forks.
  protected static get $shared(): AppStore.Instance {
    return new AppStore.Class();
  }

  static use(): AppStore.Instance {
    return this.$shared;
  }

  get authenticated() {
    return ref(false);
  }

  notify(message: string) {
    /* ... */
  }
}

export namespace AppStore {
  export const $Class = Static($AppStore); // anchor — it declares statics
  export let Class = Reactive($Class); // reactive — use() does the one `new`
  export type Instance = typeof Class.Instance;
}

Consumers never receive it — they REACH for it:

ts
// any model — the `$`-getter caches the store per instance, forever
class $SubscribersModel {
  protected get $app() {
    return AppStore.Class.use();
  }

  async refresh() {
    try {
      /* ... */
    } catch (error) {
      this.$app.reportFailure(error);
    }
  }
}
vue
<script setup lang="ts">
// any component — call use() directly; no prop, no provide/inject
import { AppStore } from '../app/AppStore';

const app = AppStore.Class.use();
const { authenticated } = app;
</script>

<template>
  <button v-if="authenticated" @click="app.logout()">Lock</button>
</template>

Why this shape and not alternatives:

  • use() is lazy — the singleton constructs on first touch, after the app exists, so module-load order and circular imports stay non-events (the same late-read property as every cross-module reference). It lives in a $-static on the class — never a namespace let, which is a parallel world no subclass can reach (the gate's the_namespace_holds_identity_and_types_only check refuses it). A $-static caches per receiver, and a store reached only through X.Class.use() has one receiver, so nothing forks; LazyShared is for a REGISTRY that several receivers (subclasses) must share.
  • The $-getter is the injection point — cached whole, per instance, on first read. A model names its dependency once; every method reads this.$app with zero lookup cost and zero constructor plumbing.
  • A store may publish itself as a reactive() view — use() stays the one door; the $-static behind it returns reactive(new AppStore.Class() as AppStore.Instance) and consumers read and write with no .value. The as Instance cast is the interop form the gate sanctions (it is what makes the unwrapped writes typecheck); bare reactive(new …) is refused.
  • Tests swap the slot, not the callers — AppStore.Class = $TestStore before the first use() and every consumer, calling AppStore.Class.use(), gets the double through the same seam.
  • A store is component-OUTLIVING by definition: watchers inside it use this.$watch/$watchEffect, never plain watch, and lifecycle hooks never belong in it.
  • Pass PROPS for what is genuinely per-instance input (a row, a slug, a config knob). Reach for the STORE for what is genuinely shared. A prop named app, store, or session is the tell that a store is being drilled.

Keyed reactivity — the third state shape ​

Ref-getters express NAMED members; shallowRef expresses wholesale-replaced structures. When state is KEYED — sparse, unbounded, indexed by ids or coordinates unknown until runtime (cells by (row,col), entities by id, rows of a stream) — a getter per key is impossible. Hold collections of reactive primitives as plain values and materialize per observation:

ts
class $Sheet {
  // Plain readonly fields — the COLLECTIONS aren't reactive;
  // their VALUES are.
  protected readonly cellVersions = new Map<number, Ref<number>>();

  /**
   * READ path: get-OR-CREATE, then subscribe — observation
   * materializes.
   */
  protected trackCell(cellKey: number): void {
    let versionRef = this.cellVersions.get(cellKey);
    if (!versionRef) {
      versionRef = ref(0);
      this.cellVersions.set(cellKey, versionRef);
    }
    // subscribes whatever effect is currently running
    void versionRef.value;
  }

  /**
   * WRITE path: PEEK-ONLY — unobserved keys allocate nothing,
   * notify no one.
   */
  protected bumpCell(cellKey: number): void {
    const versionRef = this.cellVersions.get(cellKey);
    if (versionRef) versionRef.value++;
  }
}

The read/write ASYMMETRY is the pattern: reads get-or-create (cost is priced by observation), while writes to unobserved keys allocate no signal. Rules that keep it honest:

  • Ground truth lives in plain storage (typed arrays, Maps); the refs are VERSION SIGNALS, not value holders — bump to invalidate, readers re-derive.
  • Per-key cached computeds follow the same shape (Map<key, ComputedRef>), bodies delegating to methods (the thin-closure rule), and MUST have an explicit release/ eviction path — keyed overlays cannot GC on their own (the Map holds strong refs; attached watchers subscribe permanently).
  • Coarse tiers are the same pattern at lower resolution: one ref covering many keys (a block of rows, a whole-collection version counter) for subscribers that span many keys — one integer where naive design puts a million nodes.
  • No wrapper needed: ref()/computed() are first-class values from @vue/reactivity; Maps of them inside a Reactive() class compose with everything (methods stay bound and $watch works).
state shapeexpression
named membersget x() { return ref(v) }
wholesale-replaced structureget rows() { return shallowRef<Row[]>([]) }
keyed / sparse / unboundedMap<key, Ref> + get-or-create track, peek-only bump

Same invariant at three granularities — nothing exists until observed: getters price MEMBERS, keyed collections price KEYS. (Proven at 20M cells / 4.7 bytes each — see the flyweight grid.)

Generic classes (brief) ​

ReactiveClass<C> cannot carry <T> through (no higher-kinded types), but Reactive(X) === X by identity — so cast Class back to the raw constructor and apply ReactiveInstance explicitly for Instance:

ts
class $Scroller<T extends BaseItem> {
  get items() {
    return ref<T[]>([]);
  }
}

export namespace Scroller {
  export const $Class = $Scroller;
  // the cast keeps <T> available at `new` sites
  export let Class = Reactive($Class) as unknown as typeof $Class;
  export type Instance<T extends BaseItem> =
    ReactiveInstance<$Scroller<T>>;
}
// consumer of a template ref: ShallowUnwrapRef<Scroller.Instance<T>>

Circular references resolve by construction ​

The hoisted-namespace + getter convention makes late cross-module references safe without ordering discipline or forwardRef-style workarounds:

  • Cross-references (new Other.Class() in a method, a store read in a $-getter) resolve at FIRST ACCESS, when every module in the cycle has long finished loading — any load order works.
  • Each file calls Reactive() on its own class safely: it is idempotent per prototype level; a shared ancestor is transformed once, by whichever file loads first.
  • Eager top-level dereferences can still fail; the convention keeps cross-references inside late method and getter bodies. Circular extends stays impossible because it evaluates at load time and both parents cannot exist first.

Static() — the static-side sibling (from ivue/extras) ​

Reactive() owns instances. Stateless CAPABILITY classes — function bags for files, git, parsers, clocks: never constructed, only called and swapped — use Static() from the ivue/extras entry (separate, so core stays the engine):

  • Static methods bind lazily with stable identity — detachable, safe as a router/queue/listener callback, bound to the RECEIVING class.

  • Get-only statics named $… compute once PER RECEIVER. The $ prefix promises stable identity, NOT immutability — a mutable memo table is a legitimate $-cache. Non-$ static getters stay LIVE: the settings a subclass or test double overrides.

  • A SHARED STORE never lives in receiver-space. Per-receiver caching means a subclass reading this.$store silently forks a fresh copy — the registry-fork trap. The store is a static readonly FIELD on the declaring class — one reference, inherited through the prototype chain, never receiver-cached — so every receiver read (this.$store, this.constructor.$store) resolves to the one store with no special case anywhere; the $-getter pins by returning the field:

    ts
    class $Registry {
      protected static readonly sharedRegistrations = new Map<object, Registration>();
      protected static get $registrations() {
        return this.sharedRegistrations; // the field IS the pin
      }
    }

    Two questions place every static value:

    1. Should a subclass get its own copy? Yes → per-receiver $-cache. That is what memos and per-class tuning want: forking on subclass is the feature. No → it is a SHARED store (a registry, a ledger — forking is the bug), and it lives in a static readonly field as above.
    2. Shared store: can its initializer run at module load? A field initializer runs while modules are still loading, so it may only hold a dependency-free value — a bare new Map(), a literal. The moment construction needs ANOTHER module's class, the field holds a LazyShared cell instead (import { LazyShared } from 'ivue/extras'), and the $-getter reads through it:
      ts
      protected static readonly sharedBackend = new LazyShared(
        () => new SearchBackend.Class(),
      );
      protected static get $backend() {
        return this.sharedBackend.value;
      }
      Each step is safe on its own terms. Storing the cell eagerly is safe because a thunk evaluates nothing at load. Running the thunk on first read is safe because by then every import cycle has resolved. And sharing is safe because the memoized value lives INSIDE the cell — every access path, subclass receivers and per-receiver $-caches over the cell included, converges on the one constructed singleton.

THE ANCHOR RULE — a class that declares static members wraps them ONCE, at $Class, so subclasses and test doubles inherit working semantics by extending $Class bare:

ts
import { Static } from 'ivue/extras';

class $GitCommands {
  static get binary() {
    return 'git'; // LIVE knob — no $ prefix
  }
  static get $environment() {
    return { LC_ALL: 'C' }; // computed once per receiver
  }
  static stage(path: string) {
    return this.run(['add', '--', path]); // `this` = receiving class
  }
}

export namespace GitCommands {
  export const $Class = Static($GitCommands); // anchor — wrap HERE
  export let Class = $Class; // selection — kernels/tests swap this
}

Statics AND reactive instances on one class — anchor the statics, then Reactive():

ts
export namespace Settings {
  export const $Class = Static($Settings);
  export let Class = Reactive($Class); // in-place: Class === $Class
  export type Instance = typeof Class.Instance;
}

No static members → no wrapper: $Class = $X, the standard form unchanged.

Hot loops read the method through the accessor — hoist it, not the class. The bound method itself is plain-function speed (measured, Chromium, 9M calls, fresh page per variant: module function 31.7 ms, hoisted bound method 30.0 ms); the ONLY per-call cost is re-reading it through the accessor inside the loop (84.6 ms same loop — the own-property guard that buys per-receiver binding). Ordinary call frequency never notices. In a million-call loop, destructure once, INSIDE the function:

ts
// one accessor read per method — a late read of the mutable slot,
// so a swapped-in subclass is still honored
const { isDataCol, numDataValue } = FlyweightLogic.Class;
for (let row = 0; row < ROWS_1M; row++) sum += numDataValue(row, col) ?? 0;

Never hoist at module scope (captures today's Class forever, blind to swaps) and never reach for $Class as a "fast path" — the raw class skips per-receiver binding, which is the capability seam itself.

Reading your own statics — the ladder ​

Reactive(X) === X, so a namespace's Class slot IS the base class. A getter that reads statics through it therefore hard-binds to the base and silently IGNORES a subclass override — the exact opposite of what a live (non-$) static getter is for:

ts
// ❌ three members, a double cast, and the override never applies
protected get Tooltip() {
  return Tooltip.Class as unknown as typeof $Tooltip;
}
public static get TOOLTIP_DWELL_SECONDS() { return 0.4; }
protected get tooltipDwellSeconds() {
  return this.Tooltip.TOOLTIP_DWELL_SECONDS;   // base value forever
}

Measured: a subclass setting 0.1 still reads 0.4 through this shape.

Take the first rung that applies:

  1. Nothing outside the instance reads it → delete the static. A plain instance getter is zero bytes per instance and natively overridable:
    ts
    protected get tooltipDwellSeconds() { return 0.4; }
  2. Something outside reads it (a test overriding the knob, another class) → keep the static and read it through self — the one cast per class, declared beside the statics it types — DIRECTLY at each call site:
    ts
    protected get self() {
      return this.constructor as typeof $Tooltip;
    }
    
    show() {
      this.dwellTimer.start(this.self.TOOLTIP_DWELL_SECONDS);
    }
    An instance getter over a static earns its place when it genuinely derives — mixing in instance state or transforming the value; a plain read stays a direct this.self.X at the call site, so the knob keeps one name and one override surface (the static). this.constructor is the actual class — the subclass when subclassed, and an engine class that INHERITS $Class for a plain reactive instance — so statics resolve late-bound in both cases. TypeScript types constructor as bare Function, so ONE cast is unavoidable; self is where it lives. Never scatter per-site (this.constructor as typeof $X) casts: each is an unchecked assertion that the class name is right, and the copy-paste error it invites typechecks silently against the wrong statics. Rules that keep self honest:
    • Plain getter, never $self — a $-cache would spend a per-instance slot on what this.constructor hands back for free.
    • One read → this.self.X inline. Two or more reads, or any loop → hoist: const self = this.self; as the first line, then self.X throughout. Measured (Node 26): the de-opted self getter costs ~2 ns/read over an inline cast — noise for a single read — while the hoisted form runs at ~0.4 ns/iter in loops, CHEAPER than the inline cast, because the engine hoists the class as a loop constant.
    • A subclass that adds statics redeclares self with its own typeof $Sub (a covariant override); a subclass that only tunes inherited statics needs nothing — self is already late-bound.
    • self is NOT the namespace slot. this.self is the class you were constructed from; Namespace.Class is the live mutable slot a kernel may have re-pointed since. Receiver statics (constants, per-class tuning, $-caches) read through self; late-bound capability dispatch reads through Namespace.Class. Blurring them trades typo bugs for staleness bugs.
  3. Overriding must NOT happen → name the class directly, $Tooltip.TOOLTIP_DWELL_SECONDS, and let the code say so.

Never introduce a protected get <ClassName>() self-reference getter. It is a cast wearing a getter costume: it looks live and is not — self is its honest replacement.

Naming: unfold to the domain ​

Readable code is the product. In ivue classes the class shape already reads like prose — don't ruin it with letter soup:

  • No single-letter or abbreviated identifiers — including loop indices and callback parameters. row/col, not r/c; cell, cellValue, entry, versionRef, aggregate, newValue/oldValue, not c/v/e/agg/nv/ov.
  • The one-letter-many-meanings failure mode is the reason. A file where c means cell in one method, column in the next, and cellValue in a third makes every reader re-derive the type system in their head. Named after the domain, the ambiguity cannot exist.
  • Booleans are predicates (isFineTier, hasModel); counts say what they count (observerRuns, releasedCount); prior values are originalX/previousX, not old/prev alone.
  • Abbreviate only when the abbreviation IS the domain term (px, id, fx, A1-notation like startRow/endCol).
  • Tests are code — the same rules apply to specs.
  • A v-for alias is a declaration the template makes — the same rule: v-for="(cell, columnIndex) in sheet.grid[row]", never (cell, ci) in sheet.grid[r]. The template is read by the same people as the class; r, c, ci cost them the same re-derivation there.
ts
// ❌ const v = this.cellVersions.get(k);
// ✅ const versionRef = this.cellVersions.get(cellKey);

// ❌ for (let r = r1; r <= r2; r++)
// ✅ for (let row = startRow; row <= endRow; row++)

// ❌ watch(c, (nv, ov) => …)
// ✅ watch(value, (newValue, oldValue) => this.onChanged(…))

Spacing is information ​

Contiguity says "same kind of thing"; a blank line says "the kind changes, or complexity rises." Spend the signal deliberately — a blanket newline-between-everything rule makes air mean nothing.

Class members use one order: static members → constructor → state getters → prop getters → derived getters → methods. The constructor is the first instance member. Comments and invariant annotations can precede the member they describe.

Constants use one form per role:

RoleForm
Tunable or overridable class constant — a literal (annotated with its widened type), or a table ({ mouse: Selection.Class.AUTOSCROLL_MOUSE })static readonly SCREAMING_SNAKE_CASE
A constant that computes from another static through the receiver, reads another class, or probes the environmentstatic get SCREAMING_SNAKE_CASE()
The contract — propsTypes, propsDefaults, props, emitsstatic get, always
Contributor or pane identity dataInstance readonly lowerCamelCase field
Extensible constructed dependencyField assigned from a prototype createX() factory method
Any other supposed constantDefect: choose the real role or remove it

Read live statics through the receiving class. JavaScript dispatches getter and prototype method overrides while a parent constructor runs. A subclass field initializer runs only after super() returns. It cannot change parent construction. This mechanism makes getters safe tunables and createX() methods safe construction seams.

ts
// state block — CONTIGUOUS: reads as the instance's STATE TABLE
get sheet() {
  return shallowRef<Sheet | null>(null);
}
get scrollTop() {
  return ref(0);
}
get editing() {
  return ref<{ row: number; col: number } | null>(null);
}

// derived block — contiguous: the windowing math as ONE visual unit
get totalHeight() {
  return Math.min(this.naturalHeight, MAX_SCROLL_HEIGHT);
}
get startRow() {
  return Math.floor(this.virtualTop / ROW_HEIGHT);
}

/** A doc comment needs air — blank line before it. */
get offsetY() {
  const windowTop = this.virtualTop - this.startRow * ROW_HEIGHT;
  return this.scrollTop.value - windowTop;
}
  • Declaration-like getters (state refs, one-expression deriveds): contiguous within their group — a get x() { return ref(0) } is morally a field, and fields read as a struct-like table you absorb at a glance. The GROUP is the unit, not the member.
  • Blank line the moment a member carries a doc comment or multi-line logic — comments and paragraphs of code need air.
  • Blank line + // --- section --- banner between categories (state → derived → methods) — the boundary that actually matters.
  • Methods: always separated — they are paragraphs, not table rows.

Not machine-enforceable (linters can't tell a ref-getter from a method, and Prettier expands getters past the single-line exemptions) — hold it as a convention and check it in review.

Self-review checklist (run over your ivue diff) ​

  • Every mutable state member is get x() { return ref(...) } — no mutable plain fields.
  • Inside the class, every Ref/Computed read/write uses .value; every plain field matches one role in the constants table.
  • Derived values are PLAIN getters; computed() appears only for expensive / render-suppressing / stable-handle cases.
  • Stores/composables are injected via protected get $store() { return useStore() }, not field initializers.
  • The class is exported through the namespace ($Class / Class = Reactive($Class) / Instance); generics cast Class and hand-apply ReactiveInstance to Instance<T>.
  • The SFC does new X.Class(...) once — no reactive() wrapper, no unwrap view.
  • <script setup> is wiring only: no component-local Ref/Computed, watcher, lifecycle hook, or free function beside the class instance; extend an existing class-backed component through its class, never through parallel setup behavior.
  • The SFC destructures ALL template-touched Refs/Computeds + element refs (grouped: state refs / computed refs / element refs); templates use state bindings and dotted access ONLY for plain getters/methods — no Ref reached through the instance in a template, no state name shadowing a prop.
  • Template expressions carry NO logic — every &&/||/comparison/ternary condition is a NAMED plain getter, or a NAMED method when it takes an argument (v-if="box.canEditItems", v-if="media.fileExists(index)" — never v-if="a && b").
  • Nothing but Refs/Computeds/element-ref targets is destructured (never plain getters/methods); v-for item cells stay dotted with .value; instance-swapping components don't destructure at all.
  • defineExpose(x as X.Instance); consumers type the ref as ShallowUnwrapRef<X.Instance>.
  • Watch sources are the FUNCTION form; component-scoped constructors use plain watch/watchEffect; this.$watch/this.$watchEffect only for component-outliving instances — each with a dispose path ($stopEffects() owner or onScopeDispose auto-wire).
  • Lifecycle hooks / init logic live in the constructor (no init() expecting auto-call); template refs guarded with ?. where read pre-mount.
  • 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).
  • Identifiers are unfolded to domain words (row/col/cell/cellValue/versionRef…), loop indices, v-for aliases and specs included — no single-letter names, no name meaning different things in different methods.
  • 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.
  • Static members are anchored (const $Class = Static($X)); $-prefixed static getters are compute-once-per-receiver caches, static DATA is a static readonly field (a knob a subclass re-tunes annotated with its widened type, a table extended by spreading super), a static that computes through the receiver is a getter, a hierarchy agrees per name, and inheritance extends $Class — never the mutable Class.
  • Instance-owned bookkeeping that nothing renders from (memos, queues, slot ordinals) is a readonly container mutated in place — never a reassignable field, never an unwatched ref; a table that must be REPLACED lives in a shallowRef getter.
  • No plain getter queries or measures the DOM: a table built from elements is built by a method once per element and held; a count or label derives from the table it describes, never from a formula beside it.
  • A hot loop (a formatter per sample per track, a per-frame write) memoises shared geometry per batch, writes a constant once instead of animating it, and hoists what it reads; the structural fix (fewer samples, interpolation) comes before the micro one.
  • Million-call loops over a Static() class destructure the bound methods once inside the function (never module-scope, never $Class); Class.method() stays the form everywhere else.
  • 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.
  • Static members precede the constructor; the constructor precedes state, prop, and derived getters; methods come last.
  • 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.
  • A class that calls this.$watch / $watchEffect / $stopEffects merges the engine's helpers beside itself — interface $X extends ReactiveHelpers {} — so the body typechecks (never (this as any), never per-member declare lines).
  • The class carries the WHOLE contract as static getters (propsTypes, propsDefaults, the one-line props fusion, emits — always getters, one form across every class) and its tuning knobs and tables as static readonly fields and the namespace holds identity and types ONLY, every type derived from $Class; no module-level consts or TYPE declarations beside imports/class/namespace (every type a class file declares is a namespace member, read as X.Name), no const, let, or function of any kind in the namespace — contract data, tuning knobs, seed data, singletons (use()), helpers all live on the class as statics (the gate's the_namespace_holds_identity_and_types_only check enforces it), no sibling XProps.ts; the SFC reads X.Class.props / X.Class.emits; a subclass extends the contract with super and re-declares the fusion line only when it ADDS props.
  • Every member that overrides a base member carries override (with noImplicitOverride enabled).
  • No private members — internal members are protected (three-tier visibility: public = consumer surface, protected = hierarchy seam, private = banned).

Released under the MIT License.