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

ashlar

v0.2.0

Published

Terminal UI framework for Solid 2.0 - universal renderer, pure-TS Yoga flexbox, differential ANSI output.

Readme

ashlar

Terminal UI framework built on Solid 2.0 reactivity, a pure-TypeScript Yoga flexbox engine, and Bun's native terminal primitives. Renders into a frame buffer with differential ANSI output — only changed cells are written to stdout.


Quick Start

import { createSignal } from "solid-js";
import { mount, useInput } from "ashlar";
import { driver } from "ashlar/bun"; // see Runtimes & Drivers for other runtimes

function App() {
  const [count, setCount] = createSignal(0);

  useInput((key) => {
    if (key.name === "up") setCount((c) => c + 1);
    if (key.name === "down") setCount((c) => Math.max(0, c - 1));
    if (key.name === "c" && key.ctrl) {
      screen.unmount();
      process.exit(0);
    }
  });

  return (
    <box flexDirection="column" padding={1}>
      <text bold color="#ff6600">
        My App
      </text>
      <box borderStyle="round" color="#666">
        <text bold>{`  Count: ${count()}`}</text>
      </box>
    </box>
  );
}

const screen = mount(() => <App />, { driver, fps: 30 });

Runtimes & Drivers

Ashlar's layout and paint paths need three ANSI-aware string operations — stringWidth, wrapAnsi, sliceAnsi. Each runtime provides them through a driver, installed process-wide by the first mount(App, { driver }) (or explicitly via setDriver()). There is no auto-detection: you pick the driver for the runtime you're bundling for.

| Runtime | Driver | | -------- | ------------------------------------------------------ | | Bun | import { driver } from "ashlar/bun" — native Bun.* | | Other JS | build a driver from portable npm packages — see below |

For Node, Deno, or another JavaScript runtime, build a driver from the pure-JS npm packages that Bun's Zig implementations are based on. Their semantics match Ashlar's expectations, including wrapAnsi stripping trailing whitespace per line. They stay out of Ashlar's dependency tree, so install them in your application:

npm install string-width wrap-ansi slice-ansi
import stringWidth from "string-width";
import wrapAnsi from "wrap-ansi";
import sliceAnsi from "slice-ansi";
import { mount, type TermDriver } from "ashlar";

const driver: TermDriver = { stringWidth, wrapAnsi, sliceAnsi };

mount(() => <App />, { driver });

Colors are not part of the driver: parsing is pure JS and identical on every runtime. ColorInput is a packed 24-bit number, a hex string (#rgb, #rrggbb — alpha forms accepted, alpha ignored), or one of the classic 16 terminal color names (red, brightCyan, … at xterm palette values, with autocomplete). Computed colors use the exported helpers instead of CSS strings: rgb(215, 119, 87) and hsl(210, 80, 60) return packed numbers that compose with lerpColor/lighten.


Demos

The demo/ workspace is an interactive gallery. Launch the menu and pick a demo with the arrow keys:

bun demo          # from the repo root

The launcher (demo/src/main.tsx) runs each selected demo in-process — every demo exposes a run() that mounts its own screen and resolves on Ctrl+C, which returns you to the menu.

| Demo | What it shows | | ---------------- | ---------------------------------------------------------------- | | alt-screen | Counter with keyboard input in the alternate screen buffer | | input | Single- and multi-line <input> with submission | | mouse | Hover/click buttons via mouse tracking | | scroll | Scrollable list — keyboard navigation + mouse wheel | | hyperlinks | OSC 8 web and file links with capability fallback | | selection | Click-drag text selection copied to clipboard via OSC 52 | | commit | screen.commit() finalizing output into native scrollback | | stream-spinner | Animated shimmer spinner + token counter on a simulated stream | | select | Keyboard- and mouse-navigable <Select> menu with custom rows | | progress | Determinate <ProgressBar> — auto-advancing, manual, and styled |


Build Configuration

bunfig.toml — preloads the Solid transform plugin so .tsx/.jsx is lowered through babel-preset-solid in universal mode:

preload = ["./scripts/preload.ts"]

[test]
preload = ["./scripts/preload.ts"]

The demo workspace preloads the same plugin via the package's ./preload export (see demo/bunfig.toml).

tsconfig.json — JSX set to preserve with jsxImportSource: "ashlar" so JSX type-checks against this package's jsx-runtime.d.ts and runtime calls resolve to its createElement.

Bun plugin (scripts/solid-plugin.ts) transforms .tsx/.jsx via babel-preset-solid with generate: "universal" and module name ashlar. It also redirects solid-js/server.js to the client runtime under Bun's node condition.

Package Exports

| Export | Path | Purpose | | ------------------------------------ | ------------------------- | ------------------------------------------------------------ | | . | src/index.ts | Main API | | ./animation | src/animation/index.ts | Linked animation groups + effects (shimmer, wave, pulse) | | ./ui | src/ui/index.ts | Higher-level components (Spinner, Select, ProgressBar) | | ./bun | src/bun.ts | Text-metrics driver for Bun (native Bun.*) | | ./preload | scripts/preload.ts | Bun plugin loader | | ./bun-plugin | scripts/solid-plugin.ts | Plugin source | | ./jsx-runtime, ./jsx-dev-runtime | jsx-runtime.d.ts | JSX type definitions |

The default workflow runs straight from src/ via the preload plugin — no build step. bun run build produces a publishable dist/ (universal .js, a JSX-preserved .jsx tree for the solid condition, and .d.ts); see scripts/build.ts.


Architecture

Solid 2.0 Signals (batched, settle on flush())
        │
        ▼
Frame Scheduler (setInterval at target fps, calls flush())
        │
        ▼
Solid Universal Renderer (targeted node mutations)
        │
        ▼
Yoga Layout Engine (pure TS, recalculates dirty subtree)
        │
        ▼
Frame Buffer (2D cell grid, paint positioned nodes)
        │
        ▼
Diff Engine (compare current vs previous frame)
        │
        ▼
ANSI Serializer (minimal escape sequences, SGR delta tracking)
        │
        ▼
stdout.write() (wrapped in DEC 2026 synchronized output)

Signal updates queue without propagating. The frame scheduler calls flush() at the target frame rate (default 30fps), settling the entire reactive graph in one pass. This produces exactly one layout + paint + diff + serialize cycle per frame regardless of how many signals changed between frames.

Key events schedule a render on the next microtask via queueMicrotask — rapid keypresses within the same event-loop turn are coalesced into a single render. Mouse events are dispatched to handlers but render on the next scheduled frame.


mount() and Screen

mount() is the top-level entry point. It creates the root TermNode, starts the frame scheduler, sets up input handling, and manages the terminal lifecycle.

function mount(code: () => TermNode, options?: ScreenOptions): Screen;

ScreenOptions

| Option | Type | Default | Description | | --------------- | ------------------------ | ----------------------- | -------------------------------------------- | | driver | TermDriver | required on first mount | Text-metrics driver (see Runtimes & Drivers) | | altScreen | boolean | false | Use alternate screen buffer | | cols | number | stdout.columns | Terminal columns | | rows | number | stdout.rows | Terminal rows | | fps | number | 30 | Target frame rate | | enableInput | boolean | true | Enable input handling | | mouse | boolean | false | Enable mouse tracking | | kittyKeyboard | boolean | auto | Enable kitty keyboard protocol | | syncOutput | boolean | auto | Wrap frames in DEC 2026 markers | | write | (data: string) => void | process.stdout.write | Custom output function |

Screen Interface

interface Screen {
  root: TermNode;
  input: InputBus;
  mouse: boolean;
  commit(content: () => TermNode): void;
  refresh(): void;
  resize(cols: number, rows: number): void;
  setMouse(enabled: boolean): void;
  unmount(): void;
}
  • commit(content) — renders a component once to ANSI, writes it into scrollback above the active region, then disposes the tree. Used to finalize completed output. Primary buffer only.
  • setMouse(enabled) — toggle mouse tracking at runtime.
  • refresh() — force an immediate render, bypassing the scheduler.
  • resize(cols, rows) — update dimensions and re-render (also hooked to process.stdout resize events automatically).
  • unmount() — stop the frame loop, dispose the Solid tree, restore the terminal. Also registered on SIGINT/SIGTERM/exit so the terminal is restored on unexpected process death.

Primary vs Alternate Buffer

By default mount() renders in the primary buffer: the UI occupies the bottom N rows and output committed via screen.commit() scrolls into native terminal scrollback (preserving search, selection, copy-paste). With altScreen: true the renderer takes over the full screen; commit() is a no-op.


Intrinsic Elements

All elements are JSX intrinsics. Colors accept any Bun.ColorInput value (CSS names, hex, rgb, etc.). Full prop reference lives in jsx-runtime.d.ts.

  • <box> — flexbox container with layout, sizing, spacing, visual, and mouse-event props, plus ref.
  • <text> — styled text with wrapping/truncation and the usual SGR styling props.
  • <input> — controlled text input with built-in keyboard editing; the screen routes keypresses to the focused <input>.
  • <hyperlink> — OSC 8 terminal hyperlink with plain-text fallback.

Animation (ashlar/animation)

A linked animation is a paint-time color field: <Animated> runs an Effect as a filter over the region its children paint, recoloring glyph cells by screen position after the subtree paints. A shimmer (or wave, or pulse) therefore sweeps across heterogeneous children — text, a spinner glyph, nested boxes — as one continuous sequence, and the children stay plain (<text>, <Spinner>) with no animation-specific markup or wrappers.

import { Shimmer } from "ashlar/animation";
import { Spinner } from "ashlar/ui";

<Shimmer baseColor="#666" highlightColor="#fff">
  <Spinner />
  <text>{` ${verb}…`}</text>
</Shimmer>;

Named wrappers — the three built-in effects, each running across all of its children as one linked region:

| Wrapper | Effect | | ----------- | -------------------------------------------------------------- | | <Shimmer> | A highlight crest sweeping by column, soft two-cell falloff | | <Wave> | A sine gradient scrolling along the columns between two colors | | <Pulse> | The whole region breathing between two colors in unison |

Each takes its own color and timing props; all three also accept paused (freeze at the resting color) and time (share an external useClock signal).

<Animated effect={…}> — the generic form. The effect is data: the shimmer(), wave(), and pulse() factories return an Effect(env: { t, paused }) => (x, y, w, h) => packedColor | undefined (return undefined to leave a cell's painted color untouched). New effects are just new factories; they need no new components. Effect colors may be accessors, so they can themselves animate — e.g. lerping baseColor/highlightColor toward red as a stream stalls (the stream-spinner demo does exactly this). paused freezes the effect at its resting color.

Hooks & helpers

  • useClock(ms?) — a shared tick signal (a setInterval that increments a signal, auto-cleared on dispose). Components that need time-derived values should share one clock rather than each running a timer.
  • useStalled(time, getLength, opts?) — watches a length accessor against the clock and returns { isStalled(), intensity() }, a smoothed 0→1 ramp for fading a stalled stream's color (delayMs/rampMs tune the onset and slope).
  • Color utilities — lerpColor(from, to, t), lighten(color), and packColor(color) (any Bun.ColorInput → packed 24-bit int).

Mechanically, <Animated> sets a colorField on its <box>; the paint pipeline recolors that box's glyph cells each frame (see applyColorField in pipeline.ts). Because the field is a normal reactive prop, a clock tick marks the box dirty and the region repaints.


Components (ashlar/ui)

Higher-level components built on the renderer primitives.

  • <Select> — a keyboard- and mouse-navigable list. Uncontrolled by default, or pass index to drive the highlight from the parent; a children render prop customizes row rendering.
  • <ProgressBar> — a determinate bar driven by a 0–1 value. For an indeterminate "busy" state, reach for <Spinner> instead.
  • <Spinner> — a bare animated glyph. It carries no animation awareness — dropped inside an <Animated>/<Shimmer>/… region, the group's paint filter recolors its glyph along with the text beside it, so an effect sweeps across the spinner and its label as one. glyphs selects the frame sequence (a SPINNER_GLYPHS preset or your own).

Input System

useInput, useMouse, and usePaste subscribe to input inside any component under mount():

import { useInput, useMouse, usePaste, scrollBy } from "ashlar";

useInput((key) => {
  if (key.name === "up") scrollBy(ref, -1);
  if (key.name === "c" && key.ctrl) screen.unmount();
});
useMouse((event) => { if (event.type === "up") /* click at event.x,event.y */ });
usePaste((text) => setValue((v) => v + text));

ParsedKey covers both legacy xterm/CSI sequences and the kitty keyboard protocol (CSI u). RawMouseEvent carries type, button, x/y (0-indexed), modifiers, and optional scroll. The bus is also exposed directly on screen.input (addKeyHandler / addMouseHandler / addPasteHandler, each returning an unsubscribe fn) for use outside components.

When mouse is enabled, clicks are hit-tested to the deepest node at the coordinates (cached screenRect per node). Click-and-drag selects frame-buffer text and copies it to the clipboard via OSC 52 on mouse-up.


Scrolling

Containers with overflow="scroll" scroll via mouse wheel (when mouse: true) and programmatically via refs:

import { scrollBy, scrollTo, scrollToBottom, type TermNode } from "ashlar";

let ref!: TermNode;
<box overflow="scroll" height={15} ref={ref} borderStyle="round">
  …
</box>;

scrollBy(ref, -1); // up one row
scrollTo(ref, 0); // jump to top
scrollToBottom(ref); // jump to bottom

Terminal Capabilities

terminalCaps is a synchronous capability object detected on import from environment variables (TERM_PROGRAM, TERM, KITTY_WINDOW_ID, WT_SESSION, COLORTERM, VTE_VERSION, tmux): hyperlinks, kittyKeyboard, mouse, syncOutput, trueColor. Defaults are conservative — sync output and hyperlinks are disabled inside tmux.

Several primitives come from Bun directly: Bun.stringWidth(), Bun.sliceAnsi(), Bun.wrapAnsi(), Bun.stripANSI(), Bun.color() — no string-width/slice-ansi/wrap-ansi/chalk dependencies, and no native deps (the Yoga port is pure TypeScript).


Development

bun install          # install + symlink the demo workspace
bun test             # run the suite
bun run typecheck    # tsgo --noEmit
bun run fix          # biome format + lint --write
bun run demo         # launch the demo gallery
bun run build        # emit dist/ for publishing

Dependencies

Runtime: solid-js (2.0). Dev: @babel/core, babel-preset-solid (2.0), @babel/preset-typescript, @biomejs/biome, typescript/tsgo, bun-types.