# @jucie.io/reactive

Fine-grained reactive programming with signals, computed values, and reactive stores.

## Features

- **Signals**: Simple reactive values with automatic dependency tracking
- **Computed**: Computed values that auto-track and update when dependencies change
- **Bindings**: Reactive values with explicit dependencies, sync or async
- **Sources**: Remote data you can read straight away — defaults first, the response when it lands, optimistic writes back
- **Stores**: Component-like reactive contexts with state management
- **Subscribers**: Side effects that run when reactive values change
- **Framework entries**: in Vue, reactives are refs; in React and Preact, components subscribe through hooks
- **Batched Updates**: Efficient change propagation

## Installation

```bash
npm install @jucie.io/reactive
```

## Quick Start

### Signals

Reactive primitive values:

```javascript
import { createSignal } from '@jucie.io/reactive';

const count = createSignal(0);

console.log(count()); // 0
count(5);
console.log(count()); // 5

// Update based on current value
count.update(n => n + 1);
console.log(count()); // 6

// Vue-style .value works too
count.value = 10;
console.log(count.value); // 10
```

Calling a signal with an argument (or assigning `.value`) stores that value exactly as given, **functions included**, so a signal can hold a callback or render function:

```javascript
const render = createSignal(null);
render(() => h(Panel)); // stores the function, doesn't call it
```

Use `update(fn)` to derive the next value from the current one. If `fn` returns `undefined` the value is left as is, which lets you mutate arrays and objects in place (the mutation itself notifies dependents):

```javascript
const list = createSignal([1, 2]);
list.update(l => [...l, 3]);  // replace
list.update(l => { l.push(4); }); // mutate in place
list.value.push(5);               // same, through .value
```

Setting a value equal to the current one is a no-op and doesn't notify dependents. Equal means `Object.is`, or, for plain objects and arrays, the same top-level keys holding `Object.is`-equal values. The check never goes deeper than one level. Pass `equals` to change it: `{ equals: Object.is }` compares references only. A mutation through the value (`list.value.push(5)`) always notifies. `undefined` is a valid value to set.

### Computed Values

Automatically computed values that track dependencies:

```javascript
import { createSignal, createComputed } from '@jucie.io/reactive';

const count = createSignal(0);
const doubled = createComputed(() => count() * 2);

console.log(doubled()); // 0
count(5);
console.log(doubled()); // 10
console.log(doubled.value); // 10

doubled.value = 1; // throws: computed values are read-only
```

### Stores

Declarative reactive components with automatic value unwrapping:

**Key Concepts:**
- **Unwrapped Values**: Returned signals, computeds and sources are unwrapped. Read them as properties (`counter.count`, `counter.doubled`) instead of calling them.
- **Imports, not injected helpers**: build reactives with `createSignal`, `createComputed`, `createSource` and `createBinding`, and read them through closures. Setup receives only `(extend, destroy)`; a plugin's namespace comes from `usePlugin(name)`. Only what setup returns is public.
- **Actions (`this`)**: Returned functions are bound to the store. Use `this` to read and write returned state; caller arguments map straight to parameters.

```javascript
import { defineStore, createSignal, createComputed } from '@jucie.io/reactive';

const useCounter = defineStore(() => {
  const count = createSignal(0);
  const updateCount = createSignal(0); // private — not returned, so not on the store

  const doubled = createComputed(() => count() * 2);

  function increment() {
    updateCount.update(prev => prev + 1); // private signals stay available via closure
    this.count++;                         // returned state is available on this
  }

  return { count, doubled, increment };
});

const counter = useCounter();
counter.count;      // 0 — a property, not counter.count()
counter.increment();
counter.doubled;    // 2
counter.count = 10; // assigning a signal's key writes the signal
```

`extend()` returns another store's surface, so read it by closure too:

```javascript
const useLabelled = defineStore((extend) => {
  const counter = extend(useCounter);
  return { label: createComputed(() => `Count: ${counter.count}`) };
});
```

### Store plugins

A plugin is a `Plugin` subclass whose methods are the hooks it takes part in. Every hook is optional. Each store it is installed on gets its own instance, so per-store state lives on `this`.

```javascript
import { Plugin, defineStore, installPlugin, createSignal } from '@jucie.io/reactive';

class Persist extends Plugin {
  static name = 'persist';                 // → store.$persist
  static options = { keys: [] };           // defaults

  // Every compute pass, after setup returns and before the surface is bound:
  // seed the fresh reactives. Don't keep `entries` on `this` — a rebuild replaces them.
  setup(entries) {
    const saved = JSON.parse(localStorage.getItem(this.store.name) ?? '{}');
    for (const key of this.options.keys) if (key in saved) entries[key](saved[key]);
  }

  // key list → callback with { key: value }; 'a,b' (or an array key) fires once per change, '*' watches every key
  watch() {
    return { [this.options.keys]: (values) => localStorage.setItem(this.store.name, JSON.stringify(values)) };
  }

  actions() { return { clear: () => localStorage.removeItem(this.store.name) }; }
  getters() { return { keys: () => this.options.keys }; }  // computeds; the argument is the store surface
  destroy() {}
}

const useEditor = defineStore('editor', () => ({ width: createSignal(280) }), {
  plugins: [Persist.configure({ keys: ['width'] })],
});

useEditor().$persist.clear();
```

- `this.store` is `{ name, surface, options }`; `this.options` is the class defaults, then `configure()`, then the store's options under the plugin's name.
- Actions and getters live on one non-enumerable `$name` key. A store can't return that key, and `get`/`set`/`dispatch`/`provide` can't be plugin names.
- `installPlugin(Plugin | Plugin.configure(...))` installs on every store built afterwards and returns an uninstall. A global plugin gets per-store settings from the store's options (`defineStore(name, setup, { persist: { keys } })`); `excludePlugins: ['persist']` opts a store out, and a store's own `plugins` entry wins over a global of the same name.
- Inside a setup, `usePlugin(name)` returns the namespace (the same object as `store.$name`); elsewhere it throws.

#### Persist

The built-in `Persist` plugin remembers signals across reloads, as one JSON object in a Web Storage:

```javascript
import { defineStore, createSignal, Persist } from '@jucie.io/reactive';

const useEditor = defineStore('editor', () => ({ width: createSignal(280) }), {
  plugins: [Persist.configure({ keys: ['width'] })],
});
```

Options: `keys` (the signals to remember), `key` (storage key; the store's name by default — an unnamed store needs one), `storage` (anything with `getItem`/`setItem`/`removeItem`; `localStorage` by default), `debounce` (ms after the last change before writing; `0` writes on the next effect flush). Saved values are written into the signals before anything reads them; storage that throws or holds bad JSON is ignored. `store.$persist.clear()` forgets what is saved; destroying the store flushes a pending write.

A store can also be written as an object — see [Store with Object Configuration](#store-with-object-configuration).

### Scopes

A scope is a small store that only its owner writes to. Readers read keys the way they read a store's keys, and those reads are tracked. Assigning to any key throws. Changes come from `update()` or from actions that change state outside the scope.

```javascript
import { createScope, createSignal } from '@jucie.io/reactive';

const count = createSignal(0);
const [scope, update] = createScope({ count, label: 'Clicks', increment: () => count(count() + 1) });

scope.count++;          // throws — "count" is read-only on this scope
scope.increment();      // fine — the action writes the signal, not the scope
update({ label: 'Taps' });            // fine — the owner replaces a value
update({ count: createSignal(10) }); // fine — or points the key at another reactive
```

- Every key except an action is a slot that the scope owns, holding either a plain value or a reactive. Reading the key reads through to whatever the slot holds, and stays tracked. A function becomes an action bound to the scope.
- `update()` replaces what a slot holds. It never writes to a reactive the slot holds. To change the caller's signal, change it outside the scope, directly or through an action.
- `update(partial)` stages every changed value before marking anything dirty, so a reader that re-runs sees the whole update. Unchanged values are skipped, using each signal's own `equals` (shallow by default).
- In Vue, create the scope from `@jucie.io/reactive/vue`, and any key read in a render or `computed` is tracked. Reactives you put into it must come from `/vue` too. In React or Preact, read it through `useScope(scope)`. The component then re-renders for exactly the keys it read, including keys swapped onto another reactive.
- The scope is a surface like a store's, with non-enumerable `$get(path)`, `$set(path, value)` and `$dispatch(name, ...args)`. `$set` on a scope key throws, like assigning to it does. Those three names can't be used as keys.
- Building a scope reads nothing. `destroyScope(scope)` (or `destroy(scope)`) destroys the scope's slots and leaves the reactives they hold alone.

### Framework integration

Stores and reactives are framework-agnostic. To render them, import the entry for your framework **instead of** `@jucie.io/reactive`. Each entry re-exports the whole core API.

| Entry | In a component |
|---|---|
| [`@jucie.io/reactive/vue`](extensions/vue/README.md) | Nothing extra. Every reactive created through this entry is a Vue ref, so a render, `computed` or `watch` that reads one tracks it. |
| [`@jucie.io/reactive/react`](extensions/react/README.md) | Subscribe through a hook: `useStore`, `useScope`, `useSignal`, `useComputed`, `useSource`, `useBinding`. |
| [`@jucie.io/reactive/preact`](extensions/preact/README.md) | The same hooks, built on `preact/hooks` only (no compat). |

```javascript
// stores/counter.js
import { defineStore, createSignal } from '@jucie.io/reactive/vue'; // or /react, /preact

export const useCounter = defineStore(() => ({ count: createSignal(0) }));
```

**In Vue, it matters which entry created a reactive.** A reactive made with plain `@jucie.io/reactive` isn't a ref, so Vue doesn't see it change. Create it through `/vue`, or wrap it with one of the Vue composables (`useSignal(getter)` and the others).

`vue`, `react` and `preact` are optional peer dependencies; install the one you use. The framework entries are listed under `sideEffects` in `package.json`, so a bare `import '@jucie.io/reactive/vue'` survives tree-shaking.

### Subscribers

Side effects for reactive values:

```javascript
import { createSignal, createSubscriber } from '@jucie.io/reactive';

const count = createSignal(0);

const unsubscribe = createSubscriber(
  () => count(),
  (value) => console.log('Count changed:', value)
);

count(1); // Logs: "Count changed: 1"
unsubscribe();
```

Effects are batched and run on a `setTimeout(0)`. Pass `{ immediate: true }` to also run the effect once straight away.

## Advanced Features

### Bindings

Reactive values with explicit dependencies:

```javascript
import { createSignal, createBinding } from '@jucie.io/reactive';

const firstName = createSignal('John');
const lastName = createSignal('Doe');

// Explicit dependencies — the function receives (context, previousValue)
const fullName = createBinding(
  (ctx, prev) => `${firstName()} ${lastName()}`,
  [firstName, lastName]
);

console.log(fullName()); // "John Doe"
lastName('Smith');
console.log(fullName()); // "John Smith"
```

### Async Bindings

Bindings work with async functions; reading one returns a promise:

```javascript
const userId = createSignal(1);

const userData = createBinding(
  async () => (await fetch(`/api/users/${userId()}`)).json(),
  [userId]
);

const data = await userData();
await userData(true); // force a recompute; refreshBinding(userData) does the same
```

For remote data you want to render, prefer a [Source](#sources): it is readable synchronously, so components can bind it.

### Sources

A source is a reactive over a remote value that you can **read straight away**. The first read returns the shape's defaults and starts the fetch; when the response lands it replaces the value, and everything that read the source updates.

```javascript
import { createSignal, createSource, resolveSource } from '@jucie.io/reactive';

const userId = createSignal(1);

const user = createSource(
  '/api/users/:userId',
  { id: null, display_name: 'Anonymous' }, // shape: field -> default
  { userId },                              // params
  { baseUrl: 'https://api.example.com' }   // config
);

user();                  // { id: null, display_name: 'Anonymous' }, and the GET starts
await resolveSource(user);
user.value.display_name; // 'Ada' - same as user().display_name
```

In a store, a returned source is a value on the surface, like a signal:

```javascript
const useApp = defineStore(() => {
  const profile = createSource('/api/me', { display_name: 'Anonymous' });
  return { profile };
});

useApp().profile.display_name; // 'Anonymous', then the server's value
```

Nothing is fetched until the source is first read, so a store can declare more sources than any one screen uses.

#### Shape

The shape's keys are the server's field names. A shape transforms **values**, never keys, so a write can send a field straight back. Each field is one of:

- **a descriptor** — an object made only of `default`, `get` and/or `set`:
  - `default` is what the field holds before the server answers. It is used as written, not passed through `get`.
  - `get(value, response)` transforms the value on the way in; the second argument is the whole parsed response, so a field can be built from its siblings.
  - `set(value, changes)` transforms it on the way out, when you write.
- **a function** — shorthand for `get`, with no default.
- **anything else** — the default, with no transforms. `{ default: 'sm', options: ['sm', 'lg'] }` is data, not a descriptor, because it has another key.

```javascript
const product = createSource('/api/products/:id', {
  name: '',
  sku: (value) => value.toUpperCase(),
  price: { default: 0, get: (cents) => cents / 100, set: (dollars) => dollars * 100 },
}, { id });
```

The shape is strict: keys the server sends that the shape doesn't declare are dropped. A fetch answers with the whole record, so a field the response leaves out goes back to its default — after a param change, the previous record's value would belong to a different record. `get` only ever sees a value the response actually sent.

Pass a zero-argument function to build the shape in code: `createSource(url, () => ({ ...base, extra: null }))`.

**No shape** (`null`): the source is exactly what `parse` returned — an array, a string, anything. It starts as `undefined`.

#### Params, URLs and refetching

- **Array params** append path segments: `createSource('/users', null, [userId, 'posts'])` → `/users/7/posts`.
- **Object params** fill `:tokens`; `:token?` is optional: `/users/:userId?/account`. Tokens are matched on the path only, so `http://127.0.0.1:3003/api` is fine. Arrays can't be mixed with tokens.
- **`config.query`** builds the query string; empty values are skipped.
- **`config.baseUrl`, `headers`, `auth` (sent as `Authorization`), `body` and `method`** shape the request.

Any of these may be a signal or computed. When one changes, the source refetches **if it has been read** — without telling its readers anything, since the value they hold is still current. They update when the response lands. Several changes in one tick make one request, built with the latest values.

Requests are de-duplicated: the same request already in flight is joined, and a request identical to the one the current value came from isn't sent again. A request replaced by a newer one is aborted, and its response is ignored even if it arrives last. A custom `fetch` must forward `init.signal` for the abort to stop the network request.

#### Waiting for data: `resolveSource` and `refreshSource`

```javascript
const data = await resolveSource(user);                  // one source -> its value
const [u, posts] = await resolveSource([user, userPosts]); // an array -> an array

const fresh = await refreshSource(user);                 // force a fetch
```

`resolveSource` gives the current value once nothing is pending. It starts a source that has never been read, waits for a request or write in flight (following a request that replaces it), and retries a source whose last request failed. It rejects if the request it waits on fails. Use it where code has to know — a route guard, a test.

`refreshSource` fetches again even if a value is held; a request already queued or in flight is joined instead.

#### Writes

A write is optimistic: readers see it at once, and it is committed to the server behind them.

```javascript
user({ display_name: 'Ada' });          // a partial of the shape's fields
user((prev) => ({ display_name: prev.display_name.trim() }));
user.display_name = 'Ada';              // the same, one field
useApp().profile = { display_name: 'Ada' }; // the same, through a store

await resolveSource(user({ display_name: 'Ada' })); // a write returns the source, so this waits for it
```

- Only the shape's fields are applied and sent; each goes through its `set`. A shaped source must be written with an object; a shapeless one merges objects and replaces anything else.
- The commit is a `PATCH` to the source's URL (`config.commitMethod` changes the verb), or `config.commitHandler(payload)` if you provide one — for example `commitHandler: async () => null` for a source you only ever update locally.
- If the response is the record — a plain object carrying at least one declared field — it wins. Fields it leaves out keep their current value, since a write response is often partial. `{ ok: true }` changes nothing.
- On failure only this write's fields roll back, and only where they still hold what it wrote, so a later write survives an earlier one failing. `onError(error, changes)` is called and `resolveSource` rejects.
- A write drops a fetch still in flight — its answer predates the write — and fetches again once the writes settle.

**`store.user.name = 'x'` writes nothing**: the value is a plain object. Write through the source or assign a partial to the store key.

#### Errors

A non-ok response throws an `HttpError` (with the `Response` on `.response`). A shaped source whose response isn't an object, and a request that can't be built (a missing required param), fail too. Every failure goes to `config.onError` — `(error)` for a fetch, `(error, changes)` for a write — which by default logs with `console.error`. The value stays as it was and readers aren't notified. An abort is never reported.

A read never retries a failed source (a failing endpoint would be hit on every render); `resolveSource` and a changed input do.

#### Config

| Option | Default | |
|---|---|---|
| `fetch` | global `fetch` | `(url, init)`; must return a `Response`-like object |
| `parse` | `response.json()` | Turns the response into the data the shape is applied to |
| `baseUrl`, `query`, `headers`, `auth`, `body`, `method` | — | The request; any may be reactive |
| `onError` | `console.error` | `(error)` for a fetch, `(error, changes)` for a write |
| `commitMethod` | `'PATCH'` | The verb writes use |
| `commitHandler` | — | `(payload) => result`; replaces the write request entirely |

`destroySource(source)` aborts anything in flight and takes the source out of the graph.

### Binding with Previous Value

Access the previous computed value:

```javascript
import { createSignal, createBinding } from '@jucie.io/reactive';

const count = createSignal(0);

const delta = createBinding(
  (ctx, prev) => {
    const current = count();
    return prev !== undefined ? current - prev : 0;
  },
  [count],
  { initialValue: 0 }
);

console.log(delta()); // 0
count(5);
console.log(delta()); // 5 (5 - 0)
count(8);
console.log(delta()); // 3 (8 - 5)
```

### Store with Object Configuration

An object has no closure to read from, so its computeds and hooks receive the store surface as `ctx`:

```javascript
import { defineStore } from '@jucie.io/reactive';

const useApp = defineStore({
  state: {
    count: 0,
    message: 'Hello'
  },
  computed: {
    doubled(ctx) {
      return ctx.count * 2;
    }
  },
  sources: {
    feed: { url: '/api/feed', shape: { title: '', body: '' } }
  },
  actions: {
    increment() {
      this.count++;
    }
  },
  onInit(ctx) {
    console.log('Store initialized');
  },
  onDestroy(ctx) {
    console.log('Store destroyed');
  }
});

const app = useApp();
app.count   // 0
app.message // 'Hello'
app.feed    // { title: '', body: '' }, then the response
```

Each `sources` entry is `{ url, shape, params, ...config }`. Source callbacks (`parse`, `get`, `onError`) don't receive `ctx`.

### Effects

```javascript
import { createSignal, addEffect } from '@jucie.io/reactive';

const count = createSignal(0);

addEffect(count, (value) => {
  console.log('Effect:', value);
});

count(1); // Logs: "Effect: 1"
```

### Connecting another framework

Every `create*` function takes a `framework` option, which picks the **getter factory** that builds the function you get back. The `/vue` entry registers one that backs each reactive with a Vue `customRef`. You can register your own:

```javascript
import { Reactive } from '@jucie.io/reactive';

Reactive.registerGetterFactory('my-framework', (reactive) => {
  reactive._track = () => { /* subscribe the framework's current observer */ };
  reactive.onDirty(() => { /* tell that observer the value changed */ });
  return () => reactive.get();
});

createSignal(0, { framework: 'my-framework' });
```

The returned function is what callers hold, so a full factory also handles writes (`getter(value)`) and `.value`. [`extensions/vue/src/createGetter.js`](extensions/vue/src/createGetter.js) is a complete one. `_track()` is called before every read from a store or scope surface. Those reads also happen outside components, so `_track` must never be a hook. `onDirty` fires synchronously, after everything downstream has been marked dirty.

## API Reference

### Signals

- `createSignal(initialValue, config)` - Create a reactive signal
- `signal()` / `signal.value` - Read the value (tracks dependencies)
- `signal(value)` / `signal.value = value` - Set the value as-is, functions included; no-op if unchanged
- `signal.update(fn)` - Set the value to `fn(current)`; returning `undefined` leaves it unchanged
- `destroySignal(signal)`, `isSignal(value)`, `isDirtySignal(signal)`

### Computed

- `createComputed(fn, config)` - Create a computed value with auto-tracked dependencies
- `computed()` / `computed.value` - Read the value; assigning `.value` throws
- `destroyComputed(computed)`, `isComputed(value)`, `isDirtyComputed(computed)`

### Bindings

- `createBinding(fn, dependencies, config)` - Create a binding with explicit dependencies
- `binding(true)` / `refreshBinding(binding)` - Force a recompute
- `destroyBinding(binding)`, `isBinding(value)`, `isDirtyBinding(binding)`

### Sources

- `createSource(url, shape, params, config)` - Create a source
- `source()` / `source.field` - Read the value (starts the first fetch)
- `source(changes)` / `source(prev => changes)` - Write; returns the source
- `resolveSource(source | sources[])` - The value once nothing is pending
- `refreshSource(source | sources[])` - Fetch again
- `destroySource(source)`, `isSource(value)`, `isDirtySource(source)`
- `HttpError` - Thrown for a non-ok response; `.response` is the `Response`

### Stores

- `defineStore(setupFn | config, options)` / `defineStore(name, setupFn, options)` - Create a store; returns `useStore()`
- Setup signature: `(extend, destroy) => ({ ...returned })`
- Object config keys: `state`, `computed`, `sources`, `actions`, `extend`, `onInit`, `onDestroy`
- `injectStore(name)` - Resolve a named store
- `refreshStore(useStore)`, `rebuildStore(useStore, setup)`, `destroyStore(useStore)`
- `Plugin` (base class), `Plugin.configure(options)`, `installPlugin(plugin)`, `usePlugin(name)`, `Persist`
- Store options: `plugins: [...]`, `excludePlugins: [...]`, `<pluginName>: { ...options }`
- `isStore(useStore)`

### Scopes

- `createScope(values)` - Returns `[scope, update]`; every key is read-only on `scope`
- `update(partial)` - Batched owner write; skips unchanged values (one level for plain objects and arrays)
- `destroyScope(scope)`, `isScope(value)`, `Scope.from(scope)`
- `Scope.reactivesOf(scope, key)` - The reactives a read of `key` depends on: its slot and what the slot holds
- `useScope(scope)` - React/Preact hook (from `/react` or `/preact`)

### Subscribers and Effects

- `createSubscriber(getterOrFn, effect, config)` - Run `effect(value)` when it changes; returns an unsubscribe
- `addEffect(getter, effect)` / `removeEffect(getter, effect)`
- `onDirty(getter, listener)` / `removeOnDirty(getter, listener)` - Synchronous notification when a reactive is marked dirty

### Reactive System

- `destroy(getter)` - Tear down any reactive or store, whatever it is
- `getterType(getter)` - `'signal'`, `'computed'`, `'binding'`, `'source'`, `'store'`, `'scope'` or `null`
- `Reactive.from(getter)` / `Store.from(useStore)` / `Scope.from(scope)` - The instance behind a getter, store or scope, or `null`
- `Reactive.registerGetterFactory(framework, factory)` - Connect a UI framework (see [Connecting another framework](#connecting-another-framework))
- `isReactive(value)`, `isDirty(getter)`, `markAsDirty(getter)`, `forceRecompute(getter)`
- `provideContext(key, value)`, `useContext(...keys)`, `hasContext(...keys)` - Global context, the default `context` for computeds and bindings

## Configuration Options

### Signal

```javascript
{
  debounce: 100,          // Debounce updates (ms)
  immediate: true,        // Compute immediately
  effects: [fn],          // Initial effects
  detached: false,        // Skip dependency tracking
  onAccess: (value) => {}, // Callback on access
  equals: (a, b) => bool  // What counts as unchanged on set (default: one-level shallow)
}
```

### Binding/Computed Config

```javascript
{
  debounce: 100,           // Debounce updates (ms)
  immediate: true,         // Compute immediately
  effects: [fn],           // Initial effects
  detached: false,         // Skip dependency tracking
  onAccess: (value) => {}, // Callback on access
  context: () => ctx,      // Custom context provider (the fn's first argument)
  initialValue: value      // Initial cached value
}
```

### Source Config

See [Sources → Config](#config).

### Store Options

```javascript
{
  id: 'counter',       // Same as the named form: defineStore('counter', setup)
  mode: 'development'  // Object config: use a state field's `dummy` value
}
```

## License and Usage

This software is provided under the MIT License with Commons Clause.

### ✅ What You Can Do

- Use this library freely in personal or commercial projects
- Include it in your paid products and applications
- Modify and fork for your own use
- View and learn from the source code

### ❌ What You Cannot Do

- Sell this library as a standalone product or competing state management solution
- Offer it as a paid service (SaaS) where the primary value is this library
- Create a commercial fork that competes with this project

### ⚠️ No Warranty or Support

This software is provided "as-is" without any warranty, support, or guarantees:

- No obligation to provide support or answer questions
- No obligation to accept or implement feature requests
- No obligation to review or merge pull requests
- No obligation to fix bugs or security issues
- No obligation to maintain or update the software

You are welcome to submit issues and pull requests, but there is no expectation they will be addressed. Use this software at your own risk.

See the LICENSE file for complete terms.

## Contributing

While contributions are welcome, please understand there is no obligation to review, accept, or merge them. This project is maintained at the discretion of the author.

---

Made with ⚡ by Adrian Miller
