mutts 1.0.12 → 1.0.14

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.
Files changed (61) hide show
  1. package/BROWSER_ASYNC_POLYFILL.md +79 -0
  2. package/README.md +7 -4
  3. package/dist/browser.cjs +150 -27
  4. package/dist/browser.cjs.map +1 -1
  5. package/dist/browser.d.ts +1440 -2
  6. package/dist/browser.dev.cjs +17 -3
  7. package/dist/browser.dev.cjs.map +1 -1
  8. package/dist/browser.dev.d.ts +2 -2
  9. package/dist/browser.dev.esm.js +2 -2
  10. package/dist/browser.esm.js +137 -28
  11. package/dist/browser.esm.js.map +1 -1
  12. package/dist/chunks/{index-yK0HVxHv.cjs → index-BnTNC9eC.cjs} +347 -156
  13. package/dist/chunks/index-BnTNC9eC.cjs.map +1 -0
  14. package/dist/chunks/{index-BUop6B2U.esm.js → index-CAWVZL7P.esm.js} +345 -154
  15. package/dist/chunks/index-CAWVZL7P.esm.js.map +1 -0
  16. package/dist/chunks/node-Df_5r_WA.cjs +187 -0
  17. package/dist/chunks/node-Df_5r_WA.cjs.map +1 -0
  18. package/dist/chunks/node-DuIduHw3.esm.js +185 -0
  19. package/dist/chunks/node-DuIduHw3.esm.js.map +1 -0
  20. package/dist/chunks/{proxy-D2C49sXH.esm.js → proxy-C2lnvvbx.esm.js} +943 -272
  21. package/dist/chunks/proxy-C2lnvvbx.esm.js.map +1 -0
  22. package/dist/chunks/{proxy-BvM4yewA.cjs → proxy-HA_QQnd5.cjs} +959 -273
  23. package/dist/chunks/proxy-HA_QQnd5.cjs.map +1 -0
  24. package/dist/debug.cjs +571 -173
  25. package/dist/debug.cjs.map +1 -1
  26. package/dist/debug.d.ts +96 -80
  27. package/dist/debug.esm.js +567 -173
  28. package/dist/debug.esm.js.map +1 -1
  29. package/dist/devtools/panel.js.map +1 -1
  30. package/dist/mutts.umd.js +4351 -3366
  31. package/dist/mutts.umd.js.map +1 -1
  32. package/dist/mutts.umd.min.js +1 -1
  33. package/dist/mutts.umd.min.js.map +1 -1
  34. package/dist/node.cjs +18 -4
  35. package/dist/node.cjs.map +1 -1
  36. package/dist/node.d.ts +2 -2
  37. package/dist/node.dev.cjs +18 -4
  38. package/dist/node.dev.cjs.map +1 -1
  39. package/dist/node.dev.d.ts +2 -2
  40. package/dist/node.dev.esm.js +3 -3
  41. package/dist/node.esm.js +3 -3
  42. package/dist/{types-Bx2PhORg.d.ts → types.d.ts} +42 -15
  43. package/docs/ai/api-reference.md +105 -13
  44. package/docs/ai/manual.md +77 -29
  45. package/docs/debug-getReason.md +161 -0
  46. package/docs/flavored.md +98 -1
  47. package/docs/reactive/advanced.md +184 -12
  48. package/docs/reactive/attend.md +32 -0
  49. package/docs/reactive/core.md +40 -6
  50. package/docs/reactive/debugging.md +40 -15
  51. package/docs/reactive.md +4 -1
  52. package/package.json +13 -9
  53. package/dist/chunks/index-BUop6B2U.esm.js.map +0 -1
  54. package/dist/chunks/index-yK0HVxHv.cjs.map +0 -1
  55. package/dist/chunks/node-Bo7WU5S2.esm.js +0 -96
  56. package/dist/chunks/node-Bo7WU5S2.esm.js.map +0 -1
  57. package/dist/chunks/node-Dd0esp5F.cjs +0 -98
  58. package/dist/chunks/node-Dd0esp5F.cjs.map +0 -1
  59. package/dist/chunks/proxy-BvM4yewA.cjs.map +0 -1
  60. package/dist/chunks/proxy-D2C49sXH.esm.js.map +0 -1
  61. package/dist/index.d.ts +0 -1322
package/docs/ai/manual.md CHANGED
@@ -6,14 +6,14 @@
6
6
 
7
7
  ## 1. MENTAL MODEL
8
8
 
9
- Proxy-based **fine-grained reactivity** — changes propagate synchronously through dependency-tracked effects. No VDOM, no scheduler, no hooks.
9
+ Proxy-based **fine-grained reactivity** — changes propagate synchronously through dependency-tracked effects. No VDOM, no async render scheduler, no hooks.
10
10
 
11
11
  | Concept | Mutts |
12
12
  |---------|-------|
13
13
  | State | `reactive(obj)` — proxy wraps, tracks reads/writes |
14
14
  | Reactions | `effect(() => {})` — auto-tracks deps, re-runs on change |
15
15
  | Computed | `memoize(fn)` — cached, invalidates on tracked deps |
16
- | Collections | `project(arr, fn)` — per-entry effects, not `.map()` |
16
+ | Collections | `morph(arr, fn)` — per-entry reactive transform |
17
17
  | Async context | `Zone` + `asyncZone` — propagates across await |
18
18
  | Batching | `atomic(() => {})` wraps, `atom(() => {})` runs immediately — fire effects once after all mutations |
19
19
  | Cleanup | Return fn from effect — runs before re-run/disposal |
@@ -22,11 +22,11 @@ Proxy-based **fine-grained reactivity** — changes propagate synchronously thro
22
22
 
23
23
  ```ts
24
24
  import { reactive, effect, memoize, morph, attend, lift, cleanup,
25
- atom, atomic, defer, untracked, unreactive, watch, when, biDi, caught, why,
26
- cleanedBy, organized,
25
+ atom, atomic, defer, untracked, unreactive, watch, when, biDi, caught,
26
+ organized, organize, resource, root,
27
27
  Zone, asyncZone, ZoneHistory, ZoneAggregator,
28
28
  decorator, mixin, Eventful, Destroyable, flavored, Indexable, chainPromise,
29
- reactiveOptions, isReactive, unwrap, getState
29
+ reactiveOptions, isReactive, unwrap, getState, ReactiveBase
30
30
  } from 'mutts'
31
31
  ```
32
32
 
@@ -107,14 +107,14 @@ array.splice(0) // also works — explicit clear
107
107
  ```
108
108
 
109
109
  ### TRAP 6: Using .map() for reactive transforms
110
- `.map()` is static — full rebuild on any change. Use `project()` for per-entry reactivity.
110
+ `.map()` is static — full rebuild on any change. Use `morph()` for per-entry reactivity.
111
111
 
112
112
  ```ts
113
113
  // BAD — full rebuild
114
114
  const doubled = items.map(x => x.value * 2)
115
115
 
116
116
  // GOOD — per-entry effects
117
- const doubled = project(items, ({ get }) => get().value * 2)
117
+ const doubled = morph(items, (value) => value * 2)
118
118
  ```
119
119
 
120
120
  ### TRAP 7: Deep watch overhead
@@ -164,7 +164,11 @@ state.count++ // triggers
164
164
  stop() // disposes
165
165
  ```
166
166
 
167
- - **Access object**: `{ reaction, tracked(fn), ascend(fn) }` — `reaction` = false on first run, true after. `tracked` restores context in async. `ascend` tracks in parent effect.
167
+ - **Access object**: `{ reaction, tracked(fn), ascend(fn), signal }`
168
+ - `reaction` is `false` on first run, then `true` or a `CleanupReason` on re-runs.
169
+ - `tracked` restores effect tracking in async or escaped callbacks.
170
+ - `ascend` records dependencies on the parent effect context.
171
+ - `signal` aborts when the effect is cleaned up or invalidated.
168
172
  - **Parent-child**: effects inside effects are children — parent disposal cascades.
169
173
  - **GC**: unreferenced top-level effects may GC — store `stop` to keep alive.
170
174
  - **Modifiers**: `effect.opaque(() => {})` (identity-only), `effect.named('x')(() => {})` (debug label). Chainable.
@@ -198,6 +202,8 @@ const updateBoth = atomic((a, b) => { state.a = a; state.b = b }) // effects fir
198
202
  // Decorator: @atomic on methods
199
203
  ```
200
204
 
205
+ `atomic()` wraps for later invocation but still executes synchronously when called. Internally it behaves like `batch(fn, { immediate: true })`, so nested atomic calls join the active batch by default instead of creating an implicit child batch.
206
+
201
207
  ### 3.7 atom() — Immediate atomic execution
202
208
 
203
209
  ```ts
@@ -205,6 +211,16 @@ atom(() => { state.a = 1; state.b = 2 }) // runs now, effects fire once
205
211
  // Unlike atomic() which wraps for later, atom() executes immediately
206
212
  ```
207
213
 
214
+ The explicit batching API is now:
215
+
216
+ ```ts
217
+ batch(fnOrEffects, { immediate?: boolean, contained?: boolean, caller?: EffectTrigger })
218
+ ```
219
+
220
+ - `batch(fn)` joins the current batch when nested
221
+ - `batch(fn, { immediate: true })` runs `fn` now and queues its consequences into the active batch
222
+ - `batch(fn, { contained: true })` creates an isolated nested batch that flushes before returning
223
+
208
224
  ### 3.8 defer() — Avoid cycles
209
225
 
210
226
  ```ts
@@ -231,16 +247,15 @@ input.addEventListener('input', () => provide(input.value))
231
247
  ```ts
232
248
  watch(() => state.count, (newVal, oldVal) => {}) // specific derivation
233
249
  watch(state, () => {}) // any property
234
- watch(state, () => {}, { deep: true }) // deep (expensive)
250
+ watch.deep(state, () => {}) // deep (expensive)
251
+ watch.immediate(() => state.count, (newVal, oldVal) => {}) // immediate first call
235
252
  ```
236
253
 
237
- ### 3.10 cleanedBy() — Attach cleanup to objects
254
+ `watch` has 3 useful shapes:
238
255
 
239
- ```ts
240
- const obj = cleanedBy({ data: [] }, () => console.log('cleaned up'))
241
- obj[cleanup]() // triggers cleanup
242
- // Chains: if obj already has a cleanup, both run
243
- ```
256
+ - `watch(() => value, callback)`
257
+ - `watch.deep(() => object, callback)`
258
+ - `watch(object, callback)`
244
259
 
245
260
  ### 3.11 Lazy computed values
246
261
 
@@ -258,9 +273,25 @@ effect(() => console.log(d.value)) // recomputes immediately, .value is reactive
258
273
 
259
274
  ```ts
260
275
  await when(() => state.loaded) // resolves when truthy
261
- await when(() => state.ready, { timeout: 5000 }) // rejects after 5s
276
+ await when(() => state.ready, 5000) // rejects after 5s
277
+ ```
278
+
279
+ ### 3.13 resource() — Async reactive state
280
+
281
+ ```ts
282
+ const user = resource(async () => fetchUser(state.userId), { initialValue: null })
283
+
284
+ effect(() => {
285
+ if (user.loading) return
286
+ console.log(user.value)
287
+ })
288
+
289
+ user.reload()
290
+ await user.promise
262
291
  ```
263
292
 
293
+ `resource()` returns `{ value, loading, error, latest, reload(), promise }`.
294
+
264
295
  ---
265
296
 
266
297
  ## 4. COLLECTIONS
@@ -280,15 +311,22 @@ Array methods (`push`, `splice`, `sort`, etc.) all trigger reactivity.
280
311
 
281
312
  All transforms return reactive results. Cleanup via `result[cleanup]()`.
282
313
 
283
- ### 5.1 project() — Per-entry reactive map
314
+ ### 5.1 morph() — Per-entry reactive map
284
315
 
285
316
  ```ts
286
- const names = project(users, ({ get }) => get().name.toUpperCase())
317
+ const names = morph(users, (user) => user.name.toUpperCase())
287
318
  // names[0] recomputes ONLY when users[0] changes
288
- // Variants: project.record(), project.map() auto-dispatches by source type
319
+ // Use morph.pure(...) when the callback has no reactive reads
289
320
  ```
290
321
 
291
- **Access object**: `{ get(), set(v), key, source, old, value }`
322
+ For arrays the callback is `(value, access?) => mapped`.
323
+ For Maps and records it also receives the key.
324
+
325
+ The mapper can also be captioned, even though it is the second argument:
326
+
327
+ ```ts
328
+ const names = morph`user:names`(users, (user) => user.name.toUpperCase())
329
+ ```
292
330
 
293
331
  ### 5.2 attend() — Per-key lifecycle
294
332
 
@@ -297,9 +335,17 @@ attend(reactiveRecord, (key) => {
297
335
  console.log(`${key} = ${reactiveRecord[key]}`)
298
336
  return () => console.log(`cleanup: ${key}`) // disposed when key disappears
299
337
  })
300
- // Works with: arrays, Maps, Sets, or raw () => Iterable<Key>
301
338
  ```
302
339
 
340
+ `attend` also supports callback captions on its second argument:
341
+
342
+ ```ts
343
+ attend`record:entries`(reactiveRecord, (key) => {
344
+ console.log(`${key} = ${reactiveRecord[key]}`)
345
+ })
346
+ ```
347
+ Works with: arrays, Maps, Sets, or raw `() => Iterable<Key>`.
348
+
303
349
  ### 5.3 organized() — Per-key record transform
304
350
 
305
351
  ```ts
@@ -309,7 +355,7 @@ const doubled = organized(source, (access, target) => {
309
355
  })
310
356
  ```
311
357
 
312
- ### 5.6 lift() — Sync a computed array/object
358
+ ### 5.4 lift() — Sync a computed array/object
313
359
 
314
360
  ```ts
315
361
  const filtered = lift(() => items.filter(x => x.active))
@@ -318,7 +364,7 @@ const filtered = lift(() => items.filter(x => x.active))
318
364
 
319
365
  **lift vs deep touching**: Deep touching handles `state.items = newArray` (replacement diffs). `lift` is for **derived collections** (filter/map/reshape) where there's no single assignment — the output is recomputed from scratch.
320
366
 
321
- **lift vs memoize**: `memoize` is lazy (invalidate → recompute on next read), returns raw values, keyed by args. `lift` is eager (recompute immediately), returns a **stable reactive proxy** with per-element diffing. Use `lift` for derived collections consumed by `project()`/effects; use `memoize` for parameterized caching or lazy evaluation.
367
+ **lift vs memoize**: `memoize` is lazy (invalidate → recompute on next read), returns raw values, keyed by args. `lift` is eager (recompute immediately), returns a **stable reactive proxy** with per-element diffing. Use `lift` for derived collections consumed by effects; use `memoize` for parameterized caching or lazy evaluation.
322
368
 
323
369
  ---
324
370
 
@@ -362,10 +408,10 @@ effect(() => {
362
408
  ```ts
363
409
  import { reactiveOptions } from 'mutts'
364
410
 
365
- // Cycle detection
366
- reactiveOptions.cycleHandling = 'development' // default: graph-based, throws
367
- reactiveOptions.cycleHandling = 'debug' // full transitive closure, detailed paths
368
- reactiveOptions.cycleHandling = 'production' // heuristic via maxTriggerPerBatch
411
+ // Scheduler / cycle detection
412
+ reactiveOptions.scheduler = 'ordered' // default: causal graph ordering, eager cycles
413
+ reactiveOptions.scheduler = 'raw' // fastest FIFO mode, heuristic cycles
414
+ reactiveOptions.scheduler = 'debug' // ordered mode plus heavier diagnostics
369
415
 
370
416
  // Lifecycle hooks (all wrapped via optionCall for safety)
371
417
  reactiveOptions.enter = (effect) => {} // before effect runs
@@ -387,6 +433,8 @@ const graph = buildReactivityGraph()
387
433
 
388
434
  **ReactiveError.debugInfo**: `causalChain`, `creationStack`, `cycle` (for CYCLE_DETECTED).
389
435
 
436
+ `onEffectThrow` is still exported as a deprecated alias of `caught`.
437
+
390
438
  ---
391
439
 
392
440
  ## 9. ZONES (Async Context)
@@ -484,7 +532,7 @@ const theme = await chainPromise(api.getUser('123')).getProfile().getSettings().
484
532
  ## 11. PHILOSOPHY
485
533
 
486
534
  - **Affirmative state**: Declare `Y = f(X)`. Don't say "when X changes, update Y".
487
- - **Events are legacy**: Use reactive derivations (`effect`, `memoize`, `project`) for internal logic. Events only for DOM/external APIs.
535
+ - **Events are legacy**: Use reactive derivations (`effect`, `memoize`, `morph`) for internal logic. Events only for DOM/external APIs.
488
536
  - **Cleanup ≠ undo**: Cleanup releases subscriptions, does NOT undo side effects.
489
537
 
490
538
  ---
@@ -504,5 +552,5 @@ const theme = await chainPromise(api.getUser('123')).getProfile().getSettings().
504
552
  | `isReactive(obj)` to check | `obj._mutts_isReactive` |
505
553
  | Store `stop = effect(...)` | letting effect GC unintentionally |
506
554
  | `import { x } from 'mutts'` | `import { x } from 'mutts/reactive'` (no subpaths) |
507
- | `cleanedBy(obj, fn)` for cleanup | manual `obj[cleanup] = fn` |
555
+ | `caught(handler)` for effect-local error handling | ad-hoc try/catch around reactive graph edges |
508
556
  | `memoize(() => a + b)` for computed | `effect` + manual state sync |
@@ -0,0 +1,161 @@
1
+ # `__MUTTS_DEBUG__.getReason()`
2
+
3
+ ## Overview
4
+
5
+ The `getReason()` function allows you to access the reason why the current effect is being executed from within the effect itself. This is useful for debugging and understanding what triggered an effect to re-run.
6
+
7
+ ## Usage
8
+
9
+ ```typescript
10
+ import * as mutts from 'mutts'
11
+
12
+ // DevTools are automatically enabled in development mode
13
+ const debug = (globalThis as any).__MUTTS_DEBUG__
14
+
15
+ const state = mutts.reactive({ count: 0 })
16
+
17
+ mutts.effect(() => {
18
+ const reason = debug.getReason()
19
+
20
+ if (!reason) {
21
+ console.log('First run - no reason')
22
+ } else if (reason.type === 'propChange') {
23
+ console.log(`Re-run due to ${reason.triggers.length} property changes:`)
24
+ for (const trigger of reason.triggers) {
25
+ console.log(` - ${trigger.evolution.type} on ${trigger.evolution.prop}`)
26
+ }
27
+ }
28
+
29
+ console.log('Count:', state.count)
30
+ })
31
+
32
+ // Trigger changes
33
+ state.count = 1 // Shows propChange reason
34
+ ```
35
+
36
+ ## Return Value
37
+
38
+ Returns `undefined` or a `CleanupReason` object:
39
+
40
+ ### `undefined`
41
+ - Returned on the first run of an effect
42
+ - No cleanup/reason is available for initial execution
43
+
44
+ ### `CleanupReason` types
45
+
46
+ #### `{ type: 'propChange', triggers: PropTrigger[] }`
47
+ The effect was re-run because one or more reactive properties changed.
48
+
49
+ - `triggers`: Array of property changes that triggered this re-run
50
+ - `obj`: The reactive object that changed
51
+ - `evolution`: Details about what changed
52
+ - `type`: `'set' | 'del' | 'add' | 'invalidate' | 'bunch'`
53
+ - `prop`: The property that changed (or method name for 'bunch')
54
+ - `dependency`: Stack trace from when the dependency was created (if lineage tracking enabled)
55
+ - `touch`: Stack trace from when the property was modified (if lineage tracking enabled)
56
+
57
+ #### `{ type: 'stopped', detail?: string }`
58
+ The effect was explicitly stopped via its cleanup function.
59
+
60
+ #### `{ type: 'gc' }`
61
+ The effect was cleaned up by garbage collection.
62
+
63
+ #### `{ type: 'error', error: unknown }`
64
+ The effect is being re-run due to an error in a previous run.
65
+
66
+ #### `{ type: 'lineage', parent: CleanupReason }`
67
+ A parent effect was cleaned up, causing this child effect to also be cleaned up.
68
+
69
+ #### `{ type: 'invalidate', cause: CleanupReason }`
70
+ The effect was invalidated for some other reason.
71
+
72
+ #### `{ type: 'multiple', reasons: CleanupReason[] }`
73
+ Multiple reasons combined (rare, usually from complex cleanup scenarios).
74
+
75
+ ## Examples
76
+
77
+ ### Debugging Multiple Dependencies
78
+
79
+ ```typescript
80
+ const state = mutts.reactive({
81
+ user: { name: 'John' },
82
+ posts: [{ title: 'Hello' }]
83
+ })
84
+
85
+ mutts.effect(() => {
86
+ const reason = debug.getReason()
87
+
88
+ if (reason?.type === 'propChange') {
89
+ console.log(`Effect triggered by ${reason.triggers.length} changes:`)
90
+ reason.triggers.forEach(trigger => {
91
+ if (trigger.evolution.type === 'set') {
92
+ console.log(` Property '${trigger.evolution.prop}' changed`)
93
+ } else if (trigger.evolution.type === 'add') {
94
+ console.log(` Property '${trigger.evolution.prop}' added`)
95
+ }
96
+ })
97
+ }
98
+
99
+ console.log(`User: ${state.user.name}, Posts: ${state.posts.length}`)
100
+ })
101
+
102
+ // Multiple simultaneous changes
103
+ mutts.untracked(() => {
104
+ state.user.name = 'Jane'
105
+ state.posts.push({ title: 'New Post' })
106
+ })
107
+ ```
108
+
109
+ ### Conditional Logic Based on Reason
110
+
111
+ ```typescript
112
+ mutts.effect(() => {
113
+ const reason = debug.getReason()
114
+
115
+ if (!reason) {
116
+ // First run - expensive initialization
117
+ console.log('Initializing...')
118
+ // setup expensive resources
119
+ } else if (reason.type === 'propChange') {
120
+ // Re-run - can optimize based on what changed
121
+ const userChanged = reason.triggers.some(t =>
122
+ t.evolution.prop === 'name'
123
+ )
124
+ if (userChanged) {
125
+ console.log('User name changed - updating UI')
126
+ }
127
+ }
128
+
129
+ console.log(`User: ${state.user.name}`)
130
+ })
131
+ ```
132
+
133
+ ## Notes
134
+
135
+ - The reason is only available during the effect's execution
136
+ - After the effect completes, the reason is cleared
137
+ - The reason reflects why the *previous* run was cleaned up, not why the current run started
138
+ - In TypeScript, you can type the return value as:
139
+ ```typescript
140
+ type CleanupReason = import('mutts').CleanupReason
141
+ const reason: CleanupReason | undefined = debug.getReason()
142
+ ```
143
+
144
+ ## Configuration
145
+
146
+ Reason gathering is controlled by `options.introspection.gatherReasons`:
147
+
148
+ ```typescript
149
+ import { reactiveOptions } from 'mutts'
150
+
151
+ // Disable reason gathering for production (performance)
152
+ reactiveOptions.introspection = null
153
+
154
+ // Or customize what lineage information is captured
155
+ reactiveOptions.introspection = {
156
+ gatherReasons: { lineages: 'touch' }, // 'none' | 'touch' | 'dependency' | 'both'
157
+ logErrors: true,
158
+ enableHistory: true,
159
+ historySize: 50,
160
+ }
161
+ ```
package/docs/flavored.md CHANGED
@@ -1,7 +1,9 @@
1
- # Flavored Functions
1
+ # Flavored and Captioned Functions
2
2
 
3
3
  The `flavored` utility creates extensible functions with chainable property modifiers. It enables a fluent API where properties return specialized variants of the base function.
4
4
 
5
+ The `captioned` utility is a sibling concept for callback-oriented APIs. It adds a tagged-template call form that can attach a runtime caption to one callback argument before delegating to the base function.
6
+
5
7
  ## Overview
6
8
 
7
9
  Flavored functions allow you to:
@@ -10,6 +12,13 @@ Flavored functions allow you to:
10
12
  - Use either automatic option merging (`flavorOptions`) or custom argument transformation (`createFlavor`)
11
13
  - Return hand-made functions for complete control (the generic case)
12
14
 
15
+ Captioned functions allow you to:
16
+ - Keep the normal callback-first call form
17
+ - Add a tagged-template call form like `` effect`render:${id}`(fn) ``
18
+ - Warn when a callback-first API receives an anonymous callback without a caption
19
+ - Preserve captioning across flavored variants created with `createFlavor` or `flavorOptions`
20
+ - Target callbacks that are not in argument position `0`
21
+
13
22
  ## Basic Usage
14
23
 
15
24
  ### Creating a Flavored Function
@@ -62,6 +71,57 @@ effect.named('myEffect')(fn) // With name option
62
71
  effect.opaque.named('x')(fn) // Chained
63
72
  ```
64
73
 
74
+ ### `captioned(fn, options?)`
75
+
76
+ Creates a callback-oriented function that also accepts a tagged-template call form. The caption is applied to the configured callback argument before the base function runs.
77
+
78
+ **Use when:** Your API takes a callback argument and you want ergonomic call-site naming without turning naming itself into a flavor.
79
+
80
+ **Parameters:**
81
+ - `fn` - The callback-first base function
82
+ - `options.callbackIndex` - Which argument should be treated as the callback to rename/warn about. Defaults to `0`
83
+ - `options.name` - Human-readable label used in warning messages
84
+ - `options.rename` - Optional function to customize how the caption is applied to the callback
85
+ - `options.warn` - Optional warning sink for anonymous uncaptained callbacks
86
+ - `options.shouldWarnAnonymous` - Optional predicate to suppress warnings for specific argument shapes
87
+
88
+ **Example:**
89
+ ```typescript
90
+ import { captioned } from 'mutts'
91
+
92
+ const run = captioned(
93
+ (callback: () => void) => {
94
+ callback()
95
+ },
96
+ { name: 'run' }
97
+ )
98
+
99
+ run(function namedTask() {})
100
+ run`task:${42}`(() => {})
101
+ ```
102
+
103
+ The two call forms are:
104
+
105
+ ```typescript
106
+ run(callback)
107
+ run`caption`(callback)
108
+ ```
109
+
110
+ If `run(callback)` receives an anonymous callback, `captioned` may warn depending on its `shouldWarnAnonymous` policy.
111
+
112
+ You can also target callbacks that are not the first argument:
113
+
114
+ ```typescript
115
+ const attendLike = captioned(
116
+ (source: string[], callback: (value: string) => void) => {
117
+ for (const value of source) callback(value)
118
+ },
119
+ { name: 'attendLike', callbackIndex: 1 }
120
+ )
121
+
122
+ attendLike`items`(['a', 'b'], () => {})
123
+ ```
124
+
65
125
  ### `flavorOptions(fn, defaultOptions)`
66
126
 
67
127
  Creates a flavored variant that merges options with the last argument.
@@ -192,6 +252,43 @@ calculator.multiply(3, 4) // 12
192
252
  calculator.double()(3, 4) // 14 (6 + 8)
193
253
  ```
194
254
 
255
+ ## Combining `flavored` and `captioned`
256
+
257
+ They solve different problems:
258
+
259
+ - `flavored` changes how a function is configured via chainable properties
260
+ - `captioned` changes how a callback-first function can be called
261
+
262
+ They compose naturally:
263
+
264
+ ```typescript
265
+ const watch = captioned(
266
+ flavored(baseWatch, {
267
+ get immediate() {
268
+ return flavorOptions(this, { immediate: true })
269
+ }
270
+ }),
271
+ { name: 'watch' }
272
+ )
273
+
274
+ watch(() => state.count, changed)
275
+ watch`counter:watch`(() => state.count, changed)
276
+ watch.immediate`counter:watch`(() => state.count, changed)
277
+ ```
278
+
279
+ For callback arguments in another position, use `callbackIndex`:
280
+
281
+ ```typescript
282
+ const attend = captioned(baseAttend, {
283
+ name: 'attend',
284
+ callbackIndex: 1,
285
+ })
286
+
287
+ attend`entries`(source, (key) => {
288
+ console.log(key)
289
+ })
290
+ ```
291
+
195
292
  ## TypeScript Considerations
196
293
 
197
294
  Flavored functions use proxies and require type assertions for complex chaining scenarios: