npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@amritk/mini

v0.7.1

Published

A deliberately tiny signals-based UI layer: reactive DOM bindings plus a compilerless JSX runtime.

Downloads

1,464

Readme

@amritk/mini

A deliberately tiny signals-based UI layer: reactive DOM bindings plus a compilerless JSX runtime.

status  version  license  size  vibe coded


Overview

@amritk/mini is a minimal UI layer built on alien-signals: fine-grained reactivity plus a small, capped set of DOM helpers and a compilerless JSX runtime. It ships from the mini monorepo, alongside its native sibling @amritk/mini-lynx.

The cap is the design. There is no virtual DOM, no diffing, and no re-render. JSX (or template) builds real DOM once; dynamic values flow through the bind helpers or function-valued props; and repetition goes through list. A component function runs a single time and returns the HTMLElement it built. If a feature seems to be missing here, the correct next step is usually a real framework (Preact or Solid), not a new helper.


Installation

npm install @amritk/mini
# or
pnpm add @amritk/mini
# or
yarn add @amritk/mini
# or
bun add @amritk/mini

The reactivity rule

There is no compiler analysing your expressions, so reactivity is decided by value shape at runtime:

  • A function-valued attribute, child, or show is reactive — the runtime wraps it in an effect and re-applies it whenever the signals it reads change.
  • Any other value is static — applied once at creation, never again.

Signals are zero-argument functions, so passing one without calling it is already a live binding:

<button disabled={streaming}>      {/* reactive — tracks forever          */}
<button disabled={streaming()}>    {/* STATIC — frozen at creation!       */}
<span>{() => count() * 2}</span>   {/* reactive derived text              */}

API

Reactivity (@amritk/mini)

| Export | Purpose | |:---|:---| | signal(initial?) | A writable signal. Call with no argument to read, with one to write. | | computed(fn) | A derived, read-only signal. | | effect(fn) | Run fn and re-run it whenever the signals it reads change. Returns a stop function. | | effectScope(fn) | Group effects so a single dispose tears them all down. | | batch(fn) | Coalesce several writes into one propagation pass. | | watch(get, cb, opts?) | Fire cb(value, previous) on change, skipping the initial run — mini's watch. Pass { immediate: true } to also run once on setup (with previous as undefined). | | onCleanup(fn) | Register teardown to run when the enclosing effectScope is disposed. | | mount(container, component) | Run component in an owning effectScope, append its node, and return a dispose that removes the node and tears the scope down. The application root — use it so top-level bindings and onCleanup have an owner. | | Signal<T>, ReadonlySignal<T> | Types for the two halves of a signal. |

DOM bindings (@amritk/mini)

Each binding ties one node property to a signal-reading getter and returns the effect's stop function. They write through textContent, attributes, and classList — never innerHTML — so bound data cannot inject markup.

| Export | Purpose | |:---|:---| | bindText(node, get) | Keep node.textContent in sync. | | bindAttr(node, name, get) | Keep an attribute in sync (false/null removes it, true sets it bare). | | bindClass(node, name, get) | Toggle a single class. | | bindShow(node, get) | Show/hide via inline display. | | bindValue(node, model) | Two-way bind a text input/textarea to a string signal — mini's v-model. Holds writes during IME composition and commits on compositionend. | | bindChecked(node, model) | Two-way bind a checkbox/radio to a boolean signal — the .checked analogue of bindValue. | | bindSelect(node, model) | Two-way bind a <select> to a string signal — sets .value (the property, so the option selects) and writes back on change. | | bindHtml(node, sanitize, get) | The one sanctioned innerHTML sink; the sanitizer is a required argument at every call site. |

Structure (@amritk/mini)

| Export | Purpose | |:---|:---| | template(html) | Parse a static HTML string once; returns a clone factory that also collects data-ref nodes. | | list(container, items, key, create) | Keyed reactive list: one node per key, disposed when its key leaves. |

JSX runtime (@amritk/mini/jsx-runtime, @amritk/mini/jsx-dev-runtime)

The automatic runtime TypeScript targets when a package sets "jsx": "react-jsx" and "jsxImportSource": "@amritk/mini". Exposes jsx, jsxs, jsxDEV, and the JSX namespace, plus the Component, MaybeReactive, MiniChild, MiniChildren, ClassValue, StyleValue, and TargetedEvent types.

SVG tags are created in the SVG namespace, so <svg>/<path>/… render as real SVG. class accepts a string, a (nestable) array with falsy entries dropped, or a { name: boolean } toggle map; style accepts a cssText string or a property object (camelCase keys are kebab-cased) — each still static-or-reactive by the value-shape rule.

In a style object a bare number means pixels, as it does in React, Preact, and Solid: style={{ width: 100 }} is 100px. The properties CSS treats as unitless (opacity, zIndex, flex, lineHeight, …) and custom --* properties are passed through untouched, and a string is always taken verbatim, so reach for one whenever you want a different unit.

show and style can be used on the same element without fighting over display: hiding wins while it is in effect, and showing the element again restores whatever its own style asked for.

value, checked, and selected are written through the DOM property, not the attribute, and applied after the element's children exist. The attribute only seeds a control's default, so an attribute write stops reaching the field the moment the user types in it — and on a <select> it never selects anything at all. Writing the property means <input value={draft} /> keeps tracking after the first keystroke and <select value={picked}>…</select> selects the matching <option>. Two-way binding is still bindValue/bindChecked/bindSelect; these props are the one-way half.


Layered modules (subpath exports)

The . entry above is the whole story for the bundle-size-sensitive embed widget: its only runtime dependency is alien-signals, and it imports no subpath module. The dashboards — which are not bundle-constrained — opt into more through tree-shakeable subpath exports. Each is its own module graph with its own README section below; importing one pulls in none of the others, and the widget that imports only . pays zero bytes for any of them. Two tests enforce this: a core import-boundary walk (src/import-boundary.test.ts) and a gzipped size budget (src/core-size-budget.test.ts) on the bundled . entry — and on . plus /jsx-runtime together, which is what a JSX app really ships once the transform's own import is counted.

Composition is by explicit import in the consuming app — there is no runtime plugin registry and no mini.use(), because a registry would defeat tree-shaking. Dependencies are prop-drilled, not injected through a context.

Client router (@amritk/mini/router)

A small client-side router for the dashboards, in history or hash mode.

| Export | Purpose | |:---|:---| | createRouter({ routes, mode?, base? }) | Matches the URL against a route table into a reactive route signal; returns { route, navigate, stop }. Attaches its location listener immediately. The route state includes a parsed query record alongside the raw search. | | matchRoute(pattern, path) | Matches a /users/:id pattern (with an optional trailing * catch-all) against a pathname, returning captured params or null. Typed from the pattern when it is a literal: matchRoute('/users/:id', path) gives { id: string } \| null. | | buildPath(pattern, params) | The inverse — buildPath('/users/:id', { id })/users/42, with values encoded so they round-trip back through matchRoute. The params argument is typed from the pattern, so a forgotten or misspelt one does not compile. | | Link | An <a href> that intercepts a plain left-click and calls navigate — modified clicks, non-primary buttons, and preventDefaulted events are left to the browser. Takes navigate as a prop (navigate={router.navigate}); to may be reactive. Pass active to mark the current link — it sets aria-current="page", and appends activeClass on top of class when you supply one — and target/rel/title/id/style pass through to the anchor. | | RouterView | Renders the matched route's view (the view key by default) and swaps it on navigation — the outlet that replaces a hand-written route().route?.['view'] cast. Takes router={router}. | | Route, RouterMode, RouterOptions, RouteState, Router, NavigateOptions, RouteParams, PathParams, LinkProps, RouterViewProps | Exported types. |

import { createRouter, RouterView } from '@amritk/mini/router'

const router = createRouter({
  routes: [
    { path: '/', view: Home },
    { path: '/users/:id', view: User },
    { path: '*', view: NotFound },
  ],
})

// RouterView reads the matched route's `view` and swaps it on navigation.
const app = <RouterView router={router} fallback={NotFound} />

Control flow (@amritk/mini/flow)

The ergonomic control-flow components the core deliberately omits — each reuses a core primitive and adds nothing to .. Because mini has no compiler, JSX children are built eagerly; pass a function child to defer construction to when a branch is shown (and rebuild on re-entry), or a node to reuse and preserve its state.

| Export | Purpose | |:---|:---| | Show | <Show when={cond} fallback={…}> — mounts one branch, tears down the other (bindings included). Truthiness drives it, so when={user} works. A function child receives the narrowed value as a getter — {(user) => <b>{() => user().name}</b>} — with null/undefined removed from its type; the getter updates reactively without rebuilding the branch. | | For | <For each={items} key={…}>{(item, i) => …}</For> — keyed list backed by the core list. key defaults to an object id / primitive value / index; supply it for reordering lists. Pass fallback to render an empty state while the list has no items, and as (with class/style/ref) to render into a real element instead of the default display: contents host — needed when the container itself is styled, e.g. a divide-y list whose separators only match direct children: <For each={rows} as="ul" class="divide-y">. | | Switch / Match | <Switch fallback>…<Match when={…}>…</Match></Switch> — renders the first truthy branch; only the winner is built. | | Dynamic | <Dynamic component={tag} {...props}/> — renders a tag or component chosen at runtime (component is a tag string or a getter/signal returning the tag/component). | | ShowProps, ForProps, SwitchProps, MatchProps, DynamicProps, DynamicComponent, ChildFactory | Exported types. |

import { For, Show } from '@amritk/mini/flow'

const Todos = (todos: () => readonly Todo[]): HTMLElement => (
  <ul>
    <Show when={() => todos().length} fallback={() => <li>nothing yet</li>}>
      {() => <For each={todos} key={(t) => t.id}>{(t) => <li>{t.title}</li>}</For>}
    </Show>
  </ul>
)

Forms (@amritk/mini/forms)

Field state (value / dirty / touched / errors as signals), submit handling, and validation. Inputs wire up through the core bindValue.

| Export | Purpose | |:---|:---| | createForm({ initialValues, validate?, onSubmit? }) | Returns { values, errors, isValid, isDirty, isSubmitting, submitted, submitError, field, bind, setValue, setError, reset, handleSubmit }. Errors recompute reactively; each field withholds its message until blurred or the form is submitted. A field's type follows its initial value (string, number, or boolean), and bind wires the matching control — .checked for checkbox/radio, a coerced number for number/range, <select> on change, .value otherwise. setError(name, msg) layers a server-side error (auto-cleared on edit); a rejected onSubmit is captured in submitError instead of rejecting. | | Field | <Field form={form} name="email" label="Email" type="email" /> — a component that renders a label, control, and live error message wired to a field in one element (as="textarea"/"select", plus class/labelClass/inputClass/errorClass). | | schemaToValidator(schema) | Compiles a JSON Schema into a (values) => errors function via @amritk/runtime-validators. | | FieldState, FieldProps, FieldControl, FieldValue, FieldValues, FormConfig, Form, FormValidate, FormErrors | Exported types. |

Validation accepts either a plain (values) => errors function or a JSON Schema, which is validated through @amritk/runtime-validators (eval-free, CSP-safe) — an optional peer, so a schema-validated form costs nothing to a form that does not use one:

import { createForm, Field } from '@amritk/mini/forms'

const form = createForm({
  initialValues: { email: '' },
  validate: { type: 'object', properties: { email: { type: 'string', minLength: 1 } }, required: ['email'] },
  onSubmit: (values) => save(values),
})

// <Field> renders the label, control, and live error message in one element.
const view = () => (
  <form onSubmit={form.handleSubmit}>
    <Field form={form} name="email" label="Email" type="email" />
    <button type="submit" disabled={form.isSubmitting}>Save</button>
  </form>
)

// …or wire the pieces by hand with `form.bind` when you need full control:
//   <input ref={form.bind('email')} />
//   <span show={() => Boolean(form.field('email').error())}>{() => form.field('email').error()}</span>

@amritk/runtime-validators is an optional peer dependency — install it only if you validate with schemas.

Data (@amritk/mini/query)

A thin adapter that bridges @tanstack/query-core observers to mini signals — so caching, deduplication, retries, and invalidation come from TanStack Query rather than a bespoke resource primitive (mirroring how solid-query wraps query-core).

| Export | Purpose | |:---|:---| | createQuery(client, options) | Subscribes a QueryObserver and exposes { result, data, error, status, isPending, isLoading, isFetching, isSuccess, isError, refetch } as signals. Call it inside a component/effectScope — the subscription is cleaned up with the scope. options may be a getter, so the query key can depend on signals (() => ({ queryKey: ['user', id()] })) and refetch when they change. | | QueryResult | Exported type. |

@tanstack/query-core is an optional peer dependency — install it only if you use /query.

Hot reload (@amritk/mini/hot)

One line at the entry point that turns the dev server's full page reload into a tree swap. Vite hot-updates a module only if something accepts the update; a mini app accepts nothing, so an edit walks up the import graph to the entry, finds no boundary, and reloads the page. This subpath makes the entry that boundary.

| Export | Purpose | |:---|:---| | hotMount(container, component, hot) | mount plus the hot-module wiring: it hands the runtime the mount's dispose as the module's teardown, so an edit anywhere below the entry tears the old tree down — every effect and onCleanup fires — before the updated entry mounts the new one. Pass import.meta.hot straight through: in a production build it is undefined and the call is exactly mount, so one call site covers both modes. It also accepts at runtime before mounting, so a component that throws mid-render leaves an accept callback behind and its fix still arrives as a hot update. | | HotContext | Exported type — the structural accept/dispose pair the helper needs. Vite's import.meta.hot satisfies it, and this subpath imports no vite types of its own. |

It takes both halves: acceptHotUpdates() marks the module as the boundary — only a source transform can, since Vite looks for the literal import.meta.hot.accept( in the module text and never sees a runtime call made through a helper.

// vite.config.ts
import { defineConfig } from 'vite'

import { acceptHotUpdates } from '@amritk/mini/vite'

export default defineConfig({ plugins: [acceptHotUpdates()] })
// main.tsx — the entry module. Keep it thin: it re-runs on every edit below it.
import { hotMount } from '@amritk/mini/hot'

import { App } from './app'

hotMount(document.body, App, import.meta.hot)

Two things to know. Signal state resets on every reload — mini has no compiler and no component identity, so there is nothing to map an old component's signals onto in the new one, and a clean remount is the honest behaviour; state that should survive edits belongs in a separate store module, which keeps its instance across an update because it is not the module that changed. And do not register your own hot.dispose() — the runtime keeps one disposer per module, so a second registration silently replaces mini's and leaks the old tree; extra teardown goes in an onCleanup inside the root component, where the mount scope already owns it.

This lives in a subpath rather than in mount because the . entry is byte-budgeted for the embed widget, which ships one static bundle and has no dev server to hot-update.

Build plugins (@amritk/mini/vite)

Two Vite plugins, both here for the same reason: in a compilerless runtime some things can only be settled by reading the source, because neither the type checker nor the runtime can see them.

catchCalledSignals catches the one footgun of the reactivity rule: calling a signal in a binding (disabled={streaming()}) freezes its value at creation instead of tracking it, and the call happens before jsx() ever runs. acceptHotUpdates is the build-time half of hot reloading — Vite reads a module's text for import.meta.hot.accept( to decide whether it can stop an update there, so the call has to be in the source.

| Export | Purpose | |:---|:---| | acceptHotUpdates() | A Vite plugin. Appends import.meta.hot?.accept() to any first-party module that calls hotMount, making it the app's hot-update boundary — without it the dev server finds nothing to accept the update and reloads the whole page. Only modules that mount are touched, the line is appended (so no existing line moves and positions stay honest), and a module that already writes its own import.meta.hot.accept is left alone. apply: 'serve', so nothing is injected into a production build. | | catchCalledSignals(options?) | A Vite plugin. Walks the TypeScript AST of each .tsx module on every edit and flags a signal called inside a bindingdisabled={streaming()}, a child <span>{count()}</span>, and also sub-expression freezes like class={active() ? 'on' : 'off'}, disabled={busy() || locked}, style={{ width: w() }}, and title={`${count()} left`} (anywhere in a value that is not itself a getter). It only flags names it can see are signals (signal()/computed(), or a Signal<…>/ReadonlySignal<…> type), so one-shot helpers like id={makeId()} are left alone, and a call inside an arrow/.map callback is the correct reactive form and never flagged. In dev it warns in the terminal and shows the findings in Vite's error overlay (non-blocking — the module still loads, and the overlay clears on the next clean edit); during vite build it fails the build — one plugin for both the editor loop and the CI gate. Bare getters and thunks never match; a // mini-static-ok comment (same line or the line above) opts out a deliberate case. Pass { failOnError } to force the severity, or { overlay: false } to keep dev feedback in the terminal only. | | findCalledSignalBindings(source) | The underlying scanner (returns CalledSignalBinding[]), for a bespoke lint command or editor integration. | | CatchCalledSignalsOptions, CalledSignalBinding | Exported types. |

import { defineConfig } from 'vite'

import { acceptHotUpdates, catchCalledSignals } from '@amritk/mini/vite'

export default defineConfig({
  plugins: [catchCalledSignals(), acceptHotUpdates()],
})

vite and typescript are optional peer dependencies — needed only by this subpath, so the . core stays dependency-free.


Usage

Point your compiler at mini's JSX runtime — either per file or in tsconfig.json:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@amritk/mini"
  }
}

Then build UI from signals:

import { signal, list } from '@amritk/mini'

const Counter = (): HTMLElement => {
  const count = signal(0)
  return (
    <button type="button" onClick={() => count(count() + 1)}>
      clicked {() => count()} times
    </button>
  )
}

document.body.appendChild(Counter())

Each module has its own colocated test file (*.test.ts / *.test.tsx) — read those for canonical examples.


License

MIT