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

@symbo.ls/workspace-model

v3.14.4

Published

The Symbols workspace as plain data and a headless runtime — apps, the demo and SDK backends, view and dialog models, snapshot/click/input — shared by the terminal TUI and the browser Agent mode

Readme

@symbo.ls/workspace-model

The Symbols workspace as plain data and a headless runtime: the apps, the demo and SDK backends, the view and dialog models, and one runtime with snapshot() / handle() / click() / input() / viewport().

No renderer and no Node built-ins: it runs in a browser and in Node. Two renderers draw the same snapshot():

  • the terminal TUI (smbls/packages/tui) draws it into cells;
  • the workspace Agent mode draws it as DOMQL.

Because both draw one snapshot, the two screens cannot differ.

import { createWorkspaceRuntime, createDemoBackend, createSdkBackend, keyFromDom, isControlKey } from '@symbo.ls/workspace-model'

Create a runtime

// Demo data, in memory (no login, no network):
const data = createDemoBackend()

// Live data over the shell's own signed-in SDK. With `session`, nothing is
// resolved and the SDK context is not changed. `sdk.getService(name)` may
// return the service (Node) or a Promise of it (the browser shell):
const data = await createSdkBackend({
  sdk,                                  // an initialized @symbo.ls/sdk instance
  session: { user, org, workspace }     // raw server records or normalized ones
})

const rt = createWorkspaceRuntime({
  data,
  host: {
    open: (url) => window.open(url, '_blank', 'noopener'),
    switchWorkspace: (id, w) => shell.switchWorkspace(id)   // then rt.reset(newSession)
    // edit, exit, suspend: terminal only — leave them out in the browser
  },
  options: { shellUrl: 'https://my.symbols.app', lang: 'ka' }   // lang: the shell's root.lang
})
rt.start()                 // or rt.start({ app: 'tasks' })
rt.setLang('en')           // the shell's language changed: every word follows

Host contract

Every member is optional in the browser.

| Member | Meaning | |---|---| | edit(text, { ext, label }) → Promise<string\|null> | An external editor (the terminal's $EDITOR). Without it, an editor form field is a multiline value you edit in place (a <textarea>), and an app's ctx.edit() opens a one-field form. | | open(url) | o / "Open in the browser". The runtime shows a toast either way. | | exit(code) | q / ctrl+c. Without it, the keys do nothing, and help, the palette and the footer hints do not list them. | | suspend() | ctrl+z (terminal). Without it, help does not list ctrl+z. | | switchWorkspace(id, w) | The shell owns workspace switching. When set, the w picker calls it and does NOT switch the backend. The shell then calls rt.reset(session) or makes a new runtime. |

Options: channel (status text when not production), shellUrl (base for o), noTimers (tests), schedule(fn) (your own "run on a later task"), lang ('en' default, 'ka'; a region tag such as 'ka-GE' counts as its language, anything else is English — see "Language").

The API

| Method | Does | |---|---| | snapshot() | The whole screen as plain data (below). Call it in the render handler. | | on('render', fn) → off() | The change signal. Coalesced: one call per task. | | handle(ev) | A key { type: 'key', name, ch, ctrl, meta, shift } or a paste { type: 'paste', text }. keyFromDom(e) builds the key from a KeyboardEvent. | | click(payload, { double }) | A click on any snapshot node that has click. Pass that object as is. | | input(target, value, { caret }) | A text control changed. target is the node's target; caret is selectionStart (UTF-16). Also moves the model focus there. Returns true when a control took it. | | viewport({ contentRows, contentCols, docRows, top }) | Sizes for the page and scroll keys, in rows (below). | | focus('sidebar' \| 'content' \| 'ask') | Move the model focus. | | setLang(lang) → boolean, lang | Switch the language live (true when it changed); lang is the one in use. | | reset(session?) | A new workspace: drop the cached views, start on Home. | | start(route?), stop(), idle() | Start; stop the timers; resolve when no load runs (tests). | | navigate({ app, view, params }), ask(text), runAction(app, action) | Direct calls. | | state | The live state. Read snapshot() instead; state is for tests. |

Keys in a browser

The model is the source of truth; the DOM types natively (IME, paste, Georgian, emoji).

  • No text control has the focus: forward every key — const ev = keyFromDom(e); if (ev) { e.preventDefault(); rt.handle(ev) }.
  • A text control has the focus: let the control type. Forward only isControlKey(ev) keys (enter, escape, tab, up, down, page up/down, ctrl/cmd+K, ctrl/cmd+S). Send the text with input() on every input event.
  • keyFromDom returns null for ctrl/cmd + C V X Z Y A (they stay with the browser) and for IME composition.
  • Cmd counts as ctrl (Cmd+K = palette, Cmd+S = save a form; call preventDefault). Alt is meta (word jumps).

snapshot()

{
  host: 'sunfield-farms.workspace',       // header, left
  title: 'Tasks — Wheat harvest, field 7', // app label (+ record title on a detail view)
  app: 'tasks',
  route: { app, view, params },
  canGoBack: true,
  tabs: [{ key, label, active, click: { tab } }],   // views of the app; [] when < 2
  status: { kind, tone, text },
  //   kind 'toast'   tone 'success' | 'error' | 'info'  (also in `toast`)
  //   kind 'busy'    a spinner; animate with `spin` (a counter)
  //   kind 'channel' tone 'accent' | 'warning'           ('demo', 'staging', …)
  //   kind 'user'    tone 'faint'                         the user's name
  spin: 12, busy: false,
  focus: 'sidebar' | 'content' | 'ask',
  session: { user, org, workspace, workspaces },
  sidebar: {
    label: 'Agro',                        // workspace name
    focused, cursor,                      // cursor = item.index under the keyboard
    groups: [{ key, label,                // label null for the first group
      items: [{ key, label, icon, hint, active, badge, count, index, click: { app } }] }]
  },
  footer: {
    label: 'ASK AI', focused, click: { ask: true },
    input: { target: 'ask', value, caret, placeholder, focused },
    hints: [['⏎', 'send'], ['esc', 'cancel']]   // [key, label] pairs
  },
  content: null | {                       // the open view of `route`
    status: 'idle' | 'loading' | 'refreshing' | 'ready' | 'error',
    error: null | { code, message, hint },
    type: 'board' | 'list' | 'detail' | 'dashboard' | 'chat' | 'assistant',
    def: { key, label, type, columns, empty, hidden },
    data,                                 // the view's data (shapes in src/views/*.js)
    ui,                                   // the view's live ui state
    focused,                              // content has the keys (no dialog open)
    typing,                               // a text control in the view is open
    view                                  // renderer-ready parts, per type (below); null while loading/error without data
  },
  assistant: { turns: [{ role: 'user' | 'assistant', text, status, error }], streaming },
  overlays: [ … ],                        // bottom → top; the last one has the keys
  toast: null | { text, kind }
}

Show Loading <label>… when status === 'loading' and there is no data. Show error.message + error.hint + "r retry esc back" when status === 'error' and there is no data.

content.view per type

| Type | view | |---|---| | board | { columns: [{ key, label, count, empty, active, cards: [{ id, title, left, right, rightToken, selected, click: { col, row }, item }] }] } | | dashboard | { greeting, subtitle, tiles: [{ label, value, hint, state, progress }], errors, columns } — columns as on a board | | list | { columns: [{ key, label, width \| flex, align }], filtering, filter: null \| input(target 'filter'), rows: [{ group, key, index } \| { id, index, cells, dot, people, strong, selected, click: { sel }, item }] } | | detail | { top } — the record is content.data: { title, meta, fields: [[label, value]], body (markdown), sections: [{ title, empty, items: [{ author, time, text }] }] } | | chat | { channels: [{ id, label, topic, selected, click: { select: { channel } } }], channel, messages: [{ id, author, mine, time, text }], writing, composer: input(target 'composer'), writeClick: { write: true } } | | assistant | { top, follow, suggestions } — the turns are snapshot().assistant.turns (markdown) |

left on a card and state on a tile are small visuals: { kind: 'dot', state: 'idle'|'live'|'done'|'warn'|'error' }, { kind: 'progress', value: 0..1 }, { kind: 'people', names }, { kind: 'spark', values, state }, { kind: 'ticks', values }, { kind: 'text', text, token }.

Selection: selected marks the keyboard selection. A single click selects (click(payload)); a double click opens (click(payload, { double: true })).

overlays[]

| kind | Fields | |---|---| | picker (palette, workspaces) | title, width, emptyText, query: input(target 'query'), sel, items: [{ index, label, hint, token, selected, enabled, click: { pick } }] | | form | title, submitLabel, index, submitFocused, submitClick: { submit: true }, editing, externalEditor, fields: [field] | | confirm | title, message, danger, yes, no, choice (0 = yes), yesClick: { ok: true }, noClick: { ok: false } | | help | title ('Keys'), sections: [{ title, rows: [[key, label]] }] |

A form field: { key, label, type, index, focused, required, error, target: 'field:<key>', click: { field }, value } plus, per type:

  • text → input (an input node, target field:<key>);
  • select → options: [{ label, value }], choice (index). input('field:<key>', optionValue) picks one;
  • editor → multiline: true. In the browser this is a <textarea>: input('field:<key>', text).

Esc closes a dialog (handle), and so does a click on a payload that settles it (pick, submit, ok).

Input nodes

{ target, value, caret, placeholder, focused }   // caret: UTF-16 offset, for setSelectionRange

Targets: ask · query (the top dialog's filter) · field:<key> · composer (chat) · filter (a list). The renderer must set value and the caret from the snapshot after each render — ↑/↓ in the ask bar walk the history, a send clears the composer.

(ref on an input node and ui on a dialog or the content are the live model objects. The terminal keeps its scroll memory there; a DOM renderer ignores them.)

viewport()

Page and scroll keys read sizes the terminal knows from its cells. A DOM renderer gives them in rows of its line height:

rt.viewport({
  contentRows: Math.floor(contentEl.clientHeight / lineHeight),  // PgUp/PgDn page
  docRows: Math.ceil(scrollEl.scrollHeight / lineHeight),       // detail / assistant length
  top: Math.round(scrollEl.scrollTop / lineHeight)              // after a native scroll
})
// and scroll to view.top × lineHeight when it changes (j/k, g/G, PgDn)

contentRows sticks (every view opened later gets it). docRows and top apply to the open view. Lists and boards need no sizes: their page keys move 10 rows / 5 cards.

Language

Every word the package writes speaks the runtime's lang: app names, view tabs, column heads, footer hints, the help, the palette, dialog titles, form labels and errors, empty states, toasts, error messages, the Home greeting and dates. English ('en', the default) reads exactly as before; Georgian is 'ka'.

  • setLang(lang) switches at once: every word a snapshot computes follows on the next render. A loaded view holds the words of the language it loaded in, so the open view loads again and the others load again when they show. An open dialog keeps its words until it closes.
  • Data stays as written: records, names, a server's own error text, identifiers (keys, emails, paths, versions), host, format notation (YYYY-MM-DD). Server values with a known meaning — roles, board columns that still carry their standard label, task states and types, notification and room kinds, project types and statuses, note privacy, workspace types, search surfaces — show in the language; an unknown value shows as sent.
  • Dates name the month and weekday in the language (პარასკევი, 23 ოქტომბერი), from the catalog rather than Intl: Chrome ships no Georgian locale data. Numbers keep the en-US format, as the shell does.
  • Ask AI sends context.uiLanguage (the shell's assistant does the same); the server answers in it when the prompt itself has no language.
  • Errors: describeError() and tuiError(code, message, hint, { key, hintKey, vars }) carry the catalog key next to the English. The snapshot shows { code, message, hint } in the language (localizeError(t, d)).
import { createT, CATALOGS, LANGS, tOf } from '@symbo.ls/workspace-model'

const t = createT('ka')
t('palette.goTo', { app: 'შენიშვნები' })  // 'გადასვლა: შენიშვნები'
t.relTime(updatedAt)                       // '5 წთ'
t.date(Date.now(), { weekday: 'long', day: 'numeric', month: 'long' })
Object.keys(CATALOGS.ka)                   // the same keys as CATALOGS.en

The catalogs live in src/i18n/en.js (the source) and src/i18n/ka.js. App code reads words through its context: const t = tOf(ctx) — the runtime's t, or English where a context has none (the terminal's command line). An action's done(result, item, ctx) and a field's validate(value, ctx) get the context for that reason. An app object keeps its English (label, hint, view, column, action, field and option labels); the screen shows them by a stable key — app.<app>.label, app.<app>.view.<view>, app.<app>.col.<column>, app.<app>.action.<action>.field.<field>, … — and only while the app still says what the catalog's English says (t.or), so a changed label never shows a stale translation. A new app or string needs its keys in both catalogs; the tests list what is missing.

A DOM renderer loop

import { createWorkspaceRuntime, createSdkBackend, keyFromDom, isControlKey } from '@symbo.ls/workspace-model'

const rt = createWorkspaceRuntime({ data: await createSdkBackend({ sdk, session }), host })

const paint = () => {
  const s = rt.snapshot()
  // DOMQL: put `s` into state; components read it. For example:
  //   Sidebar  ← s.sidebar.groups[].items[]  onClick: () => rt.click(item.click)
  //   Tabs     ← s.tabs                      onClick: () => rt.click(tab.click)
  //   Content  ← s.content (switch on .type; .view holds the rows/cards)
  //              onClick: (e) => rt.click(card.click, { double: e.detail > 1 })
  //   AskBar   ← s.footer.input              onInput: (e) => rt.input('ask', e.target.value, { caret: e.target.selectionStart })
  //   Dialogs  ← s.overlays                  (picker query → input('query', …), fields → input(field.target, …))
  //   Status   ← s.status / s.toast
  el.state.update({ screen: s })
}
rt.on('render', paint)

window.addEventListener('keydown', (e) => {
  const ev = keyFromDom(e)
  if (!ev) return
  const inText = e.target.matches?.('input, textarea, [contenteditable]')
  if (inText && !isControlKey(ev)) return       // the control types the text
  e.preventDefault()
  rt.handle(ev)
})

rt.start()
paint()

Inside

src/
  core/runtime.js    createWorkspaceRuntime — state, keys, clicks, input, loads, actions, Ask AI, snapshot
  core/overlays.js   picker, form, confirm, help — models with key/describe/input
  core/input.js      the text input model (graphemes, readline keys, history)
  core/keys.js       keyFromDom, isControlKey
  core/scheduler.js  a portable "run on a later task" (MessageChannel)
  core/errors.js     one error shape: { code, message, hint } (+ the catalog key)
  views/*.js         board, list, detail, dashboard, chat, assistant — init/key/selected/clamp/describe
  apps/*.js          one plain object per app (README of packages/tui: "Add an app")
  data/demo.js       the in-memory farm (tests and --demo)
  data/sdk.js        createSdkBackend({ sdk, session }) + normalize
  i18n/              createT, the catalogs (en.js, ka.js), dates, error words
  util/              graphemes, initials, relTime, clock, dayMonth

Tests: npx vitest run packages/workspace-model from smbls/.