micra.js 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms.txt ADDED
@@ -0,0 +1,148 @@
1
+ # Micra.js
2
+
3
+ > Lightweight reactive TypeScript framework for server-rendered apps and small SaaS frontends. ~5 KB gzip. No build step required.
4
+ >
5
+ > **For LLM code generation:** read the expanded version at https://github.com/denisfl/micra.js/blob/master/llms-full.txt — it includes 10 full inline recipes and an anti-pattern reference. The recipes are the canonical answers to common requests like "build a todo app" or "build a search-with-debounce".
6
+
7
+ ## When to use Micra.js instead of React/Vue
8
+
9
+ - You have a server-rendered page (Laravel, Rails, Django, etc.) and need a small amount of reactivity
10
+ - Bundle size matters (~5 KB gzip vs ~45 KB React)
11
+ - You don't need a full SPA or client-side routing
12
+ - You want to drop a `<script>` tag and go
13
+ - You need reactive directives on existing HTML without rewriting templates
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install micra.js
19
+ ```
20
+
21
+ ```ts
22
+ import * as Micra from 'micra.js'
23
+ ```
24
+
25
+ Or via CDN (no build step):
26
+
27
+ ```html
28
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
29
+ ```
30
+
31
+ This exposes a global `Micra` object.
32
+
33
+ > **CDN choice matters for AI sandboxes.** Use `cdn.jsdelivr.net` — it is in the
34
+ > Content Security Policy allowlist of Claude artifacts, ChatGPT canvas, and most
35
+ > other AI runtime environments. `unpkg.com` is NOT in those allowlists and will
36
+ > fail to load. jsDelivr auto-mirrors every npm package.
37
+
38
+ ## Core pattern
39
+
40
+ ```html
41
+ <div data-component="counter">
42
+ <span data-text="count"></span>
43
+ <button @click="increment">+</button>
44
+ </div>
45
+ ```
46
+
47
+ ```js
48
+ Micra.define('counter', {
49
+ state: { count: 0 },
50
+ increment() { this.state.count++ },
51
+ })
52
+
53
+ Micra.start()
54
+ ```
55
+
56
+ ## Directives
57
+
58
+ | Directive | Example | Description |
59
+ |------------------|--------------------------------------|--------------------------------|
60
+ | `data-text` | `data-text="name"` | Set `textContent` |
61
+ | `data-html` | `data-html="content"` | Set `innerHTML` |
62
+ | `data-if` | `data-if="count > 0"` | Mount/**unmount** from DOM |
63
+ | `data-show` | `data-show="loaded"` | Toggle `style.display` only |
64
+ | `data-bind` | `data-bind="href:url, disabled:loading"` | Bind attributes |
65
+ | `data-model` | `data-model="search"` | Two-way input binding |
66
+ | `data-each` | `data-each="items" data-key="id"` | Keyed list rendering |
67
+ | `data-ref` | `data-ref="chart"` | DOM ref via `this.refs` |
68
+ | `data-class` | `data-class="active:isActive"` | Additive class toggling |
69
+ | `data-on` | `data-on="click:save"` | Bind DOM events |
70
+ | `@event` | `@click="increment"` | Shorthand event binding |
71
+
72
+ ## API
73
+
74
+ ```ts
75
+ // Register & mount
76
+ Micra.define(name, definition)
77
+ Micra.mount(selector, definition)
78
+ Micra.start(root?) // scans DOM for [data-component] and mounts all
79
+
80
+ // Event bus
81
+ Micra.on(event, handler)
82
+ Micra.emit(event, payload?)
83
+ Micra.off(event, handler)
84
+
85
+ // DevTools
86
+ Micra.instances() // ReadonlyMap of live components
87
+ Micra.registry() // ReadonlyMap of registered definitions
88
+ Micra.debug() // prints all live components to console
89
+ ```
90
+
91
+ Inside component methods, `this` exposes:
92
+
93
+ ```ts
94
+ this.$el // root HTMLElement
95
+ this.state // reactive state proxy
96
+ this.refs // { [name]: HTMLElement } from data-ref
97
+ this.render() // force re-render
98
+ this.destroy() // unmount
99
+ this.prop(name, default?) // read data-* attributes (SSR props)
100
+ this.fetch(url, options?) // fetch helper
101
+ this.emit(event, payload?) // component-scoped event emit
102
+ this.on(event, handler) // component-scoped event subscribe
103
+ ```
104
+
105
+ ## Lifecycle hooks
106
+
107
+ ```js
108
+ Micra.define('app', {
109
+ state: {},
110
+ onCreate() { /* mounted, refs available */ },
111
+ onDestroy() { /* cleanup */ },
112
+ })
113
+ ```
114
+
115
+ ## Rules for code generation
116
+
117
+ **Rule 0 — Artifact type override.** When working inside Claude artifacts, ChatGPT
118
+ canvas, v0, Replit AI, or similar "framework-aware" runtimes: those tools default
119
+ to React. When Micra.js is the requested library, **switch the artifact/canvas type
120
+ to plain HTML** (Claude: `application/vnd.ant.html`). Do NOT translate Micra recipes
121
+ into React "because the artifact is React" — change the artifact type instead.
122
+ Micra is a standalone-HTML library; React artifacts cannot host it.
123
+
124
+ When generating Micra.js code, DO NOT:
125
+
126
+ 1. Use `getElementById`/`querySelector`/`innerHTML` for component output. Lists go through `<template data-each>` with `data-key`.
127
+ 2. Store derived values (counts, totals, filtered subsets) as state fields. They must be methods called from directives.
128
+ 3. Use `addEventListener` inside methods — it leaks past `destroy()`. Use `@event` / `data-on`.
129
+ 4. Call `this.renderList()` / `this.refresh()` / `this.update()` after mutations — Micra batches a microtask render.
130
+ 5. Write `state.user.name = x` — shallow proxy. Replace top-level: `state.user = {...state.user, name: x}`.
131
+ 6. Use `@keydown.enter` — branch on `e.key === 'Enter'` in the method.
132
+ 7. Use `data-model="filters.search"` — writes literal flat key, not nested. Keep models top-level.
133
+
134
+ Full anti-pattern list with side-by-side examples: [docs/llm-guide.md](https://github.com/denisfl/micra.js/blob/master/docs/llm-guide.md#anti-patterns-llms-gravitate-to-do-not)
135
+
136
+ ## Docs
137
+
138
+ - LLM expanded context (read first for code generation): https://github.com/denisfl/micra.js/blob/master/llms-full.txt
139
+ - Full LLM guide: https://github.com/denisfl/micra.js/blob/master/docs/llm-guide.md
140
+ - Recipes (canonical full-app examples): https://github.com/denisfl/micra.js/tree/master/docs/recipes
141
+ - Live demo docs: https://denisfl.github.io/micra.js/
142
+ - Getting started: https://github.com/denisfl/micra.js/blob/master/docs/getting-started.md
143
+ - Directives: https://github.com/denisfl/micra.js/blob/master/docs/directives.md
144
+ - API reference: https://github.com/denisfl/micra.js/blob/master/docs/api-reference.md
145
+ - Examples: https://github.com/denisfl/micra.js/blob/master/docs/examples.md
146
+ - SSR: https://github.com/denisfl/micra.js/blob/master/docs/ssr.md
147
+ - npm: https://www.npmjs.com/package/micra.js
148
+ - GitHub: https://github.com/denisfl/micra.js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "micra.js",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "Lightweight reactive UI framework for server-rendered pages — reactive state, directives, event bus. < 5 KB gzip.",
5
5
  "type": "module",
6
6
  "main": "./dist/micra.cjs.js",
@@ -16,7 +16,12 @@
16
16
  },
17
17
  "files": [
18
18
  "dist",
19
- "src"
19
+ "src",
20
+ "README.md",
21
+ "LICENSE",
22
+ "CHANGELOG.md",
23
+ "llms.txt",
24
+ "llms-full.txt"
20
25
  ],
21
26
  "scripts": {
22
27
  "build": "node build.mjs",
@@ -24,7 +29,10 @@
24
29
  "dev": "node build.mjs --watch",
25
30
  "test": "vitest run",
26
31
  "test:watch": "vitest",
27
- "test:coverage": "vitest run --coverage"
32
+ "test:coverage": "vitest run --coverage",
33
+ "docs:sync": "rm -rf site/dist && mkdir -p site/dist && cp -R dist/* site/dist/",
34
+ "docs:build": "npm run build && npm run docs:sync",
35
+ "docs:dev": "npm run docs:sync && npx serve -p 4321 site"
28
36
  },
29
37
  "devDependencies": {
30
38
  "@vitest/coverage-v8": "^4.1.7",
package/src/core/mount.ts CHANGED
@@ -15,21 +15,24 @@
15
15
  import type {
16
16
  ComponentDefinition,
17
17
  ComponentInstance,
18
+ ComponentMethods,
18
19
  EventHandler,
19
20
  InternalInstance,
20
21
  MicraElement,
22
+
21
23
  StateRecord,
22
24
  UnsubFn,
23
- } from '../types'
24
- import { warn } from '../utils/expr'
25
- import { micraFetch } from '../utils/fetch'
26
- import { on as busOn, emit as busEmit } from '../core/bus'
27
- import { createReactiveState, createScheduler } from '../core/reactive'
28
- import { applyDirectives, validateDirectives } from '../dom/directives'
29
- import { renderList } from '../dom/each'
30
- import { bindDataOn, bindAtEvents, bindModels } from '../dom/events'
31
- import { collectRefs } from '../dom/refs'
32
- import { _instances } from '../core/registry'
25
+ } from "../types";
26
+ import { warn } from "../utils/expr";
27
+ import { micraFetch } from "../utils/fetch";
28
+ import { on as busOn, emit as busEmit } from "../core/bus";
29
+ import { createReactiveState, createScheduler } from "../core/reactive";
30
+ import { applyDirectives, validateDirectives } from "../dom/directives";
31
+ import { renderList } from "../dom/each";
32
+ import { bindDataOn, bindAtEvents, bindModels } from "../dom/events";
33
+ import { collectRefs } from "../dom/refs";
34
+ import { scanComponent } from "../dom/scan";
35
+ import { _instances } from "../core/registry";
33
36
 
34
37
  /**
35
38
  * Mount a component definition onto a DOM element.
@@ -37,64 +40,82 @@ import { _instances } from '../core/registry'
37
40
  *
38
41
  * Already-mounted elements return the existing instance.
39
42
  *
43
+ * Both `S` (state) and `M` (methods) are inferred from the literal — the
44
+ * returned instance is fully typed: `inst.state.X` and `inst.someMethod()`
45
+ * are checked.
46
+ *
40
47
  * @example
41
48
  * const instance = Micra.mount('#counter', {
42
49
  * state: { count: 0 },
43
50
  * inc() { this.state.count++ },
44
51
  * })
52
+ * instance?.inc()
53
+ * instance?.state.count
45
54
  */
46
- export function mount<S extends StateRecord>(
55
+ export function mount<S extends StateRecord, M>(
47
56
  selector: string | HTMLElement,
48
- definition: ComponentDefinition<S>,
49
- ): ComponentInstance<S> | null {
57
+ definition: ComponentDefinition<S, M>,
58
+ ): ComponentInstance<S, M> | null {
50
59
  const root =
51
- typeof selector === 'string'
60
+ typeof selector === "string"
52
61
  ? document.querySelector<HTMLElement>(selector)
53
- : selector
62
+ : selector;
54
63
 
55
64
  if (!root) {
56
- warn(`"${selector}" not found`)
57
- return null
65
+ warn(`"${selector}" not found`);
66
+ return null;
58
67
  }
59
68
 
60
69
  // Already mounted — return existing instance without re-mounting
61
- if (_instances.has(root)) return _instances.get(root) as ComponentInstance<S>
70
+ if (_instances.has(root))
71
+ return _instances.get(root) as unknown as ComponentInstance<S, M>;
62
72
 
63
- const rawState: StateRecord = { ...(definition.state ?? {}) }
64
- const instance = { $el: root, refs: {} } as InternalInstance<S>
73
+ const rawState: StateRecord = { ...(definition.state ?? {}) };
74
+ const instance = { $el: root, refs: {} } as InternalInstance<S>;
65
75
 
66
76
  // Copy user-defined methods from definition to instance
67
- for (const [key, val] of Object.entries(definition as Record<string, unknown>)) {
68
- if (key === 'state' || key === 'onCreate' || key === 'onDestroy') continue
69
- if (typeof val === 'function') instance[key] = val
77
+ for (const [key, val] of Object.entries(
78
+ definition as Record<string, unknown>,
79
+ )) {
80
+ if (key === "state" || key === "onCreate" || key === "onDestroy") continue;
81
+ if (typeof val === "function") instance[key] = val;
70
82
  }
71
83
 
72
84
  // ── prop() ────────────────────────────────────────────────────────────────
73
85
  // Read data-* attributes from the root element with auto-cast.
74
86
  instance.prop = function <T>(name: string, defaultVal?: T): T | undefined {
75
- const val = root.dataset[name]
76
- if (val === undefined) return defaultVal
77
- if (val === 'true') return true as unknown as T
78
- if (val === 'false') return false as unknown as T
79
- if (val !== '' && !isNaN(Number(val))) return Number(val) as unknown as T
80
- return val as unknown as T
81
- } as ComponentInstance<S>['prop']
87
+ const val = root.dataset[name];
88
+ if (val === undefined) return defaultVal;
89
+ if (val === "true") return true as unknown as T;
90
+ if (val === "false") return false as unknown as T;
91
+ if (val !== "" && !isNaN(Number(val))) return Number(val) as unknown as T;
92
+ return val as unknown as T;
93
+ } as ComponentInstance<S>["prop"];
82
94
 
83
95
  // ── fetch(), emit(), on() ─────────────────────────────────────────────────
84
- instance.fetch = micraFetch
85
- instance.emit = busEmit
86
-
87
- instance.on = <T = unknown>(event: string, handler: EventHandler<T>): UnsubFn => {
88
- const unsub = busOn(event, handler)
89
- if (!instance.__micraSubs) instance.__micraSubs = []
90
- instance.__micraSubs.push(unsub)
91
- return unsub
92
- }
96
+ instance.fetch = micraFetch;
97
+ instance.emit = busEmit;
98
+
99
+ instance.on = <T = unknown>(
100
+ event: string,
101
+ handler: EventHandler<T>,
102
+ ): UnsubFn => {
103
+ const unsub = busOn(event, handler);
104
+ if (!instance.__micraSubs) instance.__micraSubs = [];
105
+ instance.__micraSubs.push(unsub);
106
+ return unsub;
107
+ };
93
108
 
94
109
  // ── Render ────────────────────────────────────────────────────────────────
95
- let isRendering = false
96
- const schedule = createScheduler(() => instance.render())
97
- instance.state = createReactiveState(rawState, schedule) as S
110
+ let isRendering = false;
111
+ // Track which state key triggered the current render cycle.
112
+ // 'MULTIPLE' means more than one key was written before the microtask fired.
113
+ let _triggerKey: string | null | "MULTIPLE" = null;
114
+ const schedule = createScheduler(() => instance.render());
115
+ instance.state = createReactiveState(rawState, schedule, (key) => {
116
+ if (_triggerKey === null) _triggerKey = key;
117
+ else if (_triggerKey !== key) _triggerKey = "MULTIPLE";
118
+ }) as S;
98
119
 
99
120
  // Expression state: proxy that falls back to instance methods so expressions
100
121
  // like `data-text="formatDate(item.date)"` can call component methods.
@@ -106,90 +127,106 @@ export function mount<S extends StateRecord>(
106
127
  // Both traps reject Object.prototype names ('constructor', 'toString', ...) —
107
128
  // accessing them via a directive expression returns undefined instead of
108
129
  // leaking the prototype.
109
- const boundMethods = new Map<string, Function>()
130
+ const boundMethods = new Map<string, Function>();
110
131
  const exprState = new Proxy(rawState, {
111
132
  get(target, key: string) {
112
- if (Object.prototype.hasOwnProperty.call(target, key)) return target[key]
113
- if (Object.prototype.hasOwnProperty.call(instance, key) &&
114
- typeof instance[key] === 'function') {
115
- const cached = boundMethods.get(key)
116
- if (cached) return cached
117
- const bound = (instance[key] as Function).bind(instance)
118
- boundMethods.set(key, bound)
119
- return bound
133
+ if (Object.prototype.hasOwnProperty.call(target, key)) return target[key];
134
+ if (
135
+ Object.prototype.hasOwnProperty.call(instance, key) &&
136
+ typeof instance[key] === "function"
137
+ ) {
138
+ const cached = boundMethods.get(key);
139
+ if (cached) return cached;
140
+ const bound = (instance[key] as Function).bind(instance);
141
+ boundMethods.set(key, bound);
142
+ return bound;
120
143
  }
121
- return undefined
144
+ return undefined;
122
145
  },
123
146
  has(target, key: string) {
124
- if (typeof key !== 'string') return false
125
- if (Object.prototype.hasOwnProperty.call(target, key)) return true
126
- return Object.prototype.hasOwnProperty.call(instance, key) &&
127
- typeof instance[key] === 'function'
147
+ if (typeof key !== "string") return false;
148
+ if (Object.prototype.hasOwnProperty.call(target, key)) return true;
149
+ return (
150
+ Object.prototype.hasOwnProperty.call(instance, key) &&
151
+ typeof instance[key] === "function"
152
+ );
128
153
  },
129
- })
154
+ });
130
155
 
131
- let warnedReentry = false
156
+ let warnedReentry = false;
132
157
  instance.render = function () {
133
- if (instance.__micraDestroyed) return
158
+ if (instance.__micraDestroyed) return;
159
+ const triggerKey = _triggerKey;
160
+ _triggerKey = null;
134
161
  if (isRendering) {
135
162
  if (!warnedReentry) {
136
- warn('render() re-entry detected — mutation inside a directive expression is ignored. Move state writes to a method.')
137
- warnedReentry = true
163
+ warn(
164
+ "render() re-entry detected — mutation inside a directive expression is ignored. Move state writes to a method.",
165
+ );
166
+ warnedReentry = true;
138
167
  }
139
- return
168
+ return;
140
169
  }
141
- isRendering = true
170
+ isRendering = true;
142
171
  try {
143
- applyDirectives(root, exprState, rawState, instance)
144
- renderList(root, exprState, rawState, instance)
145
- bindDataOn(root, instance)
146
- bindAtEvents(root, instance)
147
- bindModels(root, instance)
148
- collectRefs(root, instance)
172
+ // Single-pass scan, cached on the root for re-renders. Replaces what
173
+ // used to be ~10 separate querySelectorAll passes per render.
174
+ const mRoot = root as MicraElement;
175
+ const scan =
176
+ mRoot.__micraScan ?? (mRoot.__micraScan = scanComponent(root));
177
+ applyDirectives(scan, exprState, rawState, instance);
178
+ renderList(scan.each, exprState, rawState, instance, triggerKey);
179
+ bindDataOn(scan.on, instance);
180
+ bindAtEvents(scan.atEvents, instance);
181
+ bindModels(scan.model, instance);
182
+ collectRefs(scan.refs, instance);
149
183
  } finally {
150
- isRendering = false
184
+ isRendering = false;
151
185
  }
152
- }
186
+ };
153
187
 
154
188
  // ── Destroy ───────────────────────────────────────────────────────────────
155
189
  instance.destroy = function () {
156
- if (instance.__micraDestroyed) return
157
- instance.__micraDestroyed = true
190
+ if (instance.__micraDestroyed) return;
191
+ instance.__micraDestroyed = true;
158
192
 
159
193
  // Remove every DOM listener attached by bindDataOn / bindAtEvents / bindModels.
160
- instance.__micraListeners?.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
161
- instance.__micraListeners = []
194
+ instance.__micraListeners?.forEach(({ el, type, fn }) =>
195
+ el.removeEventListener(type, fn),
196
+ );
197
+ instance.__micraListeners = [];
162
198
 
163
- // Clear per-element flags & cached directive scan so a future re-mount of the same DOM works.
199
+ // Clear per-element flags & cached scan so a future re-mount of the same DOM works.
164
200
  const clearFlags = (el: Element) => {
165
- const m = el as MicraElement
166
- delete m.__micraEvents
167
- delete m.__micraAtBound
168
- delete m.__micraModel
169
- delete m.__micraCache
170
- }
171
- clearFlags(root)
172
- root.querySelectorAll('*').forEach(clearFlags)
173
-
174
- instance.__micraSubs?.forEach(unsub => unsub())
175
- instance.__micraSubs = []
176
-
177
- if (typeof (definition as Record<string, unknown>).onDestroy === 'function')
178
- (definition.onDestroy as () => void).call(instance)
179
- _instances.delete(root)
180
- }
201
+ const m = el as MicraElement;
202
+ delete m.__micraEvents;
203
+ delete m.__micraAtBound;
204
+ delete m.__micraModel;
205
+ delete m.__micraScan;
206
+ };
207
+ clearFlags(root);
208
+ root.querySelectorAll("*").forEach(clearFlags);
209
+
210
+ instance.__micraSubs?.forEach((unsub) => unsub());
211
+ instance.__micraSubs = [];
212
+
213
+ if (typeof (definition as Record<string, unknown>).onDestroy === "function")
214
+ (definition.onDestroy as () => void).call(instance);
215
+ _instances.delete(root);
216
+ };
181
217
 
182
218
  // ── Bootstrap ─────────────────────────────────────────────────────────────
183
- _instances.set(root, instance as InternalInstance)
184
- instance.render()
219
+ _instances.set(root, instance as InternalInstance);
220
+ instance.render();
185
221
 
186
- // Validate directive usage and emit dev warnings
187
- validateDirectives(root)
222
+ // Validate directive usage and emit dev warnings — reuses the same scan.
223
+ const mRoot = root as MicraElement;
224
+ if (mRoot.__micraScan) validateDirectives(mRoot.__micraScan);
188
225
 
189
- if (typeof (definition as Record<string, unknown>).onCreate === 'function')
226
+ if (typeof (definition as Record<string, unknown>).onCreate === "function")
190
227
  Promise.resolve().then(() =>
191
228
  (definition.onCreate as () => void | Promise<void>).call(instance),
192
- )
229
+ );
193
230
 
194
- return instance
231
+ return instance as unknown as ComponentInstance<S, M>;
195
232
  }
@@ -20,11 +20,12 @@ import type { StateRecord } from '../types'
20
20
  * const state = createReactiveState(raw, render)
21
21
  * state.count = 5 // triggers render() in next microtask
22
22
  */
23
- export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void): S {
23
+ export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void, onKey?: (key: string) => void): S {
24
24
  return new Proxy(obj, {
25
25
  set(target, key: string, value: unknown) {
26
26
  // Cast through StateRecord — TypeScript cannot write through a generic index
27
27
  ;(target as StateRecord)[key] = value
28
+ onKey?.(key)
28
29
  schedule()
29
30
  return true
30
31
  },
@@ -12,6 +12,7 @@
12
12
  import type {
13
13
  ComponentDefinition,
14
14
  ComponentInstance,
15
+ ComponentMethods,
15
16
  InternalInstance,
16
17
  StateRecord,
17
18
  } from '../types'
@@ -27,19 +28,28 @@ export const _instances = new Map<HTMLElement, InternalInstance>()
27
28
  /**
28
29
  * Register a component definition under `name`.
29
30
  *
31
+ * Both state shape (`S`) and method set (`M`) are inferred from the literal,
32
+ * so `this.state.X` and `this.someMethod()` are fully typed inside the
33
+ * method bodies and lifecycle hooks.
34
+ *
30
35
  * @example
31
- * define('counter', { state: { count: 0 }, inc() { this.state.count++ } })
36
+ * define('counter', {
37
+ * state: { count: 0 },
38
+ * inc() { this.state.count++ }, // this.state.count: number ✓
39
+ * reset() { this.state.count = 0 }, // this.reset() is also typed ✓
40
+ * onCreate() { this.inc() }, // ✓
41
+ * })
32
42
  */
33
- export function define<S extends StateRecord>(
43
+ export function define<S extends StateRecord, M>(
34
44
  name: string,
35
- definition: ComponentDefinition<S>,
45
+ definition: ComponentDefinition<S, M>,
36
46
  ): void {
37
- _registry.set(name, definition as ComponentDefinition)
47
+ _registry.set(name, definition as unknown as ComponentDefinition)
38
48
  }
39
49
 
40
50
  /**
41
51
  * Type-helper — returns `definition` unchanged but lets TypeScript infer `S`
42
- * from the `state` literal so all methods are typed with the correct `this`.
52
+ * and `M` from the literal so all methods are typed with the correct `this`.
43
53
  *
44
54
  * Use this when defining a component outside a `define()` call.
45
55
  *
@@ -50,9 +60,9 @@ export function define<S extends StateRecord>(
50
60
  * })
51
61
  * Micra.define('counter', counter)
52
62
  */
53
- export function defineComponent<S extends StateRecord>(
54
- definition: ComponentDefinition<S>,
55
- ): ComponentDefinition<S> {
63
+ export function defineComponent<S extends StateRecord, M>(
64
+ definition: ComponentDefinition<S, M>,
65
+ ): ComponentDefinition<S, M> {
56
66
  return definition
57
67
  }
58
68
 
@@ -61,7 +71,7 @@ export function defineComponent<S extends StateRecord>(
61
71
  * Useful for DevTools / debugging.
62
72
  */
63
73
  export function instances(): ReadonlyMap<HTMLElement, ComponentInstance> {
64
- return _instances
74
+ return _instances as unknown as ReadonlyMap<HTMLElement, ComponentInstance>
65
75
  }
66
76
 
67
77
  /**