knobkit 0.0.1 → 0.0.3

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.
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # <img src="https://raw.githubusercontent.com/knobkit/knobkit/main/design/logo.svg" height="28" alt="" /> knobkit
2
+
3
+ [![npm version](https://img.shields.io/npm/v/knobkit.svg)](https://www.npmjs.com/package/knobkit) [![license](https://img.shields.io/npm/l/knobkit.svg)](https://github.com/knobkit/knobkit/blob/main/LICENSE)
4
+
5
+ **Your AI app, live as you type.** Declare widgets, write event handlers — done. The same file runs
6
+ entirely in the browser — no server at all — or with handlers on a stateless Node server. Swap one line.
7
+
8
+ ```ts
9
+ import { knobkit, chat } from "knobkit";
10
+
11
+ const convo = chat({ placeholder: "Say something…" });
12
+
13
+ const app = knobkit({
14
+ title: "Echo bot",
15
+ description: "Whatever you send comes back reversed.",
16
+ widgets: [convo],
17
+ });
18
+
19
+ app.on(convo.sent, async ({ text }) => {
20
+ convo.say({ role: "user", content: text });
21
+ convo.say({ role: "assistant", content: [...text].reverse().join("") });
22
+ });
23
+
24
+ app.serve();
25
+ ```
26
+
27
+ That's a complete app. `app.serve()` runs the handler on Node; change that one line to
28
+ `app.mount("#root")` and the identical file runs fully client-side. For a real model-backed chatbot
29
+ (streaming, history), see [`examples/`](https://github.com/knobkit/knobkit/tree/main/examples).
30
+
31
+ ## Why not Gradio or Streamlit?
32
+
33
+ Same authoring feel — declare inputs, write a function, get an app — different architecture:
34
+
35
+ - **The browser owns all state.** A knobkit server keeps nothing. A handler pulls only the
36
+ attributes it actually reads and writes back structured edits. No sessions, no sticky state —
37
+ restart, redeploy, or scale the server and no one's app breaks.
38
+ - **One file, two tiers.** Prototype entirely in the browser (`mount` apps build to static files —
39
+ host them anywhere, run WebGPU models client-side), then move handlers to a server when you need
40
+ secrets, large models, or native deps — by changing the last line.
41
+ - **Events, not reruns.** Handlers are plain `on(event, async fn)` functions. No full-script
42
+ re-execution on every interaction, no reactive graph to fight, no widget-key gymnastics.
43
+ - **Data stays where it lives.** A request crossing the wire is just `{ type, payload }` — never a
44
+ copy of state. An attribute a handler never reads (say, generated audio) never leaves the browser.
45
+
46
+ ## Quick start
47
+
48
+ ```bash
49
+ npm create knobkit@latest my-app # choose mount (browser) or serve (node)
50
+ cd my-app
51
+ npm install
52
+ npm run dev
53
+ ```
54
+
55
+ Skip the prompts with `npm create knobkit@latest my-app -- --mount` (or `--serve`). Already have a
56
+ project? `npm install knobkit`. Requires Node ≥ 22.
57
+
58
+ ## CLI
59
+
60
+ The `knobkit` package installs a `knobkit` command:
61
+
62
+ ```bash
63
+ knobkit dev # dev server — auto-detects mount vs serve
64
+ knobkit build # build a browser (mount) app to dist/
65
+ knobkit serve # run a server (serve) app
66
+ ```
67
+
68
+ The entry file is the `"main"` field of your package.json — like `vite`, the manifest names the
69
+ entry. Pass a file to run something else: `knobkit dev other.tsx`. Flags: `--mount` / `--serve`
70
+ force a mode, `--port <n>` sets the dev-server port. Otherwise `knobkit dev` detects the tier from
71
+ whether your entry file ends in `mount()` or `serve()`.
72
+
73
+ ## Concepts
74
+
75
+ - **Widgets** hold structured state and render themselves. Handlers interact through widget methods:
76
+ - **read** with async getters — `await convo.history()`, `await box.value()` — pulled from the browser.
77
+ - **write** with structured edits — `convo.say(m)`, `convo.append(token)`, `out.set(v)`, `log.push(line)`.
78
+ - **produce** by `return`ing an event from a handler (it's re-emitted, like a user action).
79
+ - **Events** are plain serializable `{ type, payload }`. `widget.sent("hi")` builds one.
80
+ - **`on(event, handler)`** registers a handler against a widget's event (`convo.sent`, `go.clicked`).
81
+ - **`setup(fn)`** runs once per session — in the browser on `mount`, per connection on `serve` — with a
82
+ live context, so async startup (load model weights, fetch a user's data) happens here. The page
83
+ renders first; setup is non-blocking.
84
+ - **`busy`** marks a transient working span on a widget — `widget.busy(handler)` wraps an async handler,
85
+ or `widget.busyStart()`/`busyEnd()` bracket one by hand (e.g. a `setup()` load). The widget shows a
86
+ thin indeterminate bar and drops its input events while busy. `disable()`/`enable()` is the persistent
87
+ version (dimmed).
88
+ - **`mount` vs `serve`** is the only thing that changes between in-browser and client/server — the
89
+ widgets, handlers, and methods are identical.
90
+
91
+ ## The two runtimes
92
+
93
+ | | `mount("#root")` | `serve()` |
94
+ |---|---|---|
95
+ | State | in the browser | in the browser |
96
+ | `on(...)` handlers | run in the browser | run on a stateless Node server |
97
+ | Transport | local function call | socket.io |
98
+ | Use when | everything fits client-side (incl. WebGPU models) | the handler needs the server (large models, secrets, native deps) |
99
+
100
+ ## Widgets
101
+
102
+ Inputs: `text`, `number`, `slider`, `dropdown`, `checkbox`, `checkboxGroup`, `radio`, `upload`, `mic`,
103
+ `webcam`, `chat`, `button`.
104
+ Outputs: `output` (plain text or `format: "markdown"`), `json`, `log`, `label`, `html`, `image`,
105
+ `gallery`, `annotatedImage` (boxes/labels over an image), `highlightedText` (spans over text), `audio`,
106
+ `video`, `file` (download), `progress`, `chart` (bar/line/area), `frame` (embed a URL or a sandboxed
107
+ HTML document in an isolated iframe).
108
+ Both (editable or read-only): `code` (syntax-highlighted editor/viewer), `table` (data grid).
109
+ Custom: `widget({ state, … })`.
110
+
111
+ ## Layout
112
+
113
+ `widgets` is the widget tree. An array is a vertical stack; `row`, `col`, and `grid` are containers
114
+ that nest other widgets — composed with the widget objects themselves, no keys or strings:
115
+
116
+ ```ts
117
+ import { knobkit, upload, dropdown, button, output, row, col } from "knobkit";
118
+
119
+ knobkit({ widgets: col(photo, row(size, go), caption) });
120
+ knobkit({ widgets: grid([a, b, c, d], { cols: 2 }) });
121
+ ```
122
+
123
+ `tabs` and `accordion` are containers too — `tabs([{ label, content }, …])`,
124
+ `accordion({ label, open }, …children)`. Containers are themselves widgets whose state is their
125
+ arrangement, so a handler can restructure the UI at runtime — `panel.add(chart)`, `panel.remove(sidebar)`.
126
+
127
+ ## Examples
128
+
129
+ In [`examples/`](https://github.com/knobkit/knobkit/tree/main/examples) — each is a single
130
+ `demo.tsx`. Run one with `pnpm -F knobkit-example-<name> dev`.
131
+
132
+ [`examples/playground`](https://github.com/knobkit/knobkit/tree/main/examples/playground) is an
133
+ in-browser playground: a `code` editor on the right runs live as a mounted knobkit app on the left,
134
+ with a `dropdown` of built-in examples — itself a knobkit app built from `code`/`dropdown`/layout.
135
+ Run it with `pnpm -F knobkit-example-playground dev`.
136
+
137
+ ## Develop
138
+
139
+ Requires Node ≥ 22 and pnpm.
140
+
141
+ ```bash
142
+ pnpm install
143
+ pnpm -F knobkit build # build the library + browser client bundle
144
+ pnpm -F knobkit test # vitest
145
+ pnpm typecheck # all packages
146
+ ```
147
+
148
+ See [CLAUDE.md](https://github.com/knobkit/knobkit/blob/main/CLAUDE.md) for the architecture and how
149
+ to add a widget.
150
+
151
+ ## License
152
+
153
+ [MIT](https://github.com/knobkit/knobkit/blob/main/LICENSE)
@@ -2,6 +2,7 @@ import { existsSync, writeFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname, relative, resolve } from "node:path";
4
4
  import { searchForWorkspaceRoot } from "vite";
5
+ import { FAVICON_TAG } from "../lib/favicon.js";
5
6
  export function ensureTsconfig(root) {
6
7
  const path = resolve(root, "tsconfig.json");
7
8
  if (existsSync(path))
@@ -41,6 +42,7 @@ export function indexHtml(entry) {
41
42
  <meta charset="utf-8" />
42
43
  <meta name="viewport" content="width=device-width, initial-scale=1" />
43
44
  <title>knobkit</title>
45
+ ${FAVICON_TAG}
44
46
  </head>
45
47
  <body>
46
48
  <div id="root"></div>
package/dist/cli/index.js CHANGED
@@ -7,11 +7,11 @@ import { runServe } from "./serve.js";
7
7
  const HELP = `knobkit — build a web app from widgets and event handlers
8
8
 
9
9
  Usage:
10
- knobkit dev [file] Start a dev server (auto-detects mount vs serve)
11
- knobkit build [file] Build a browser (mount) app to dist/
12
- knobkit serve [file] Run a server (serve) app (same as: knobkit dev --serve)
10
+ knobkit dev Start a dev server (auto-detects mount vs serve)
11
+ knobkit build Build a browser (mount) app to dist/
12
+ knobkit serve Run a server (serve) app (same as: knobkit dev --serve)
13
13
 
14
- file defaults to demo.tsx
14
+ The entry file is "main" in package.json; pass a file to override (knobkit dev other.tsx)
15
15
 
16
16
  Flags:
17
17
  --mount Force browser (mount) mode
@@ -19,8 +19,7 @@ Flags:
19
19
  --port <n> Dev server port (mount)
20
20
  `;
21
21
  function parse(argv) {
22
- const out = { file: "demo.tsx", mount: false, serve: false };
23
- let sawFile = false;
22
+ const out = { mount: false, serve: false };
24
23
  for (let i = 0; i < argv.length; i++) {
25
24
  const a = argv[i];
26
25
  if (a === "--mount")
@@ -31,13 +30,35 @@ function parse(argv) {
31
30
  out.port = Number(argv[++i]);
32
31
  else if (a.startsWith("--port="))
33
32
  out.port = Number(a.slice("--port=".length));
34
- else if (!a.startsWith("-") && !sawFile) {
33
+ else if (!a.startsWith("-") && out.file === undefined)
35
34
  out.file = a;
36
- sawFile = true;
37
- }
38
35
  }
39
36
  return out;
40
37
  }
38
+ function entry(root, args) {
39
+ if (args.file) {
40
+ const file = resolve(root, args.file);
41
+ if (!existsSync(file)) {
42
+ process.stderr.write(`knobkit: no such file: ${args.file}\n`);
43
+ process.exit(1);
44
+ }
45
+ return file;
46
+ }
47
+ const pkgPath = resolve(root, "package.json");
48
+ const main = existsSync(pkgPath)
49
+ ? JSON.parse(readFileSync(pkgPath, "utf8")).main
50
+ : undefined;
51
+ if (!main) {
52
+ process.stderr.write(`knobkit: no entry file — pass one (knobkit dev app.tsx) or set "main" in package.json\n`);
53
+ process.exit(1);
54
+ }
55
+ const file = resolve(root, main);
56
+ if (!existsSync(file)) {
57
+ process.stderr.write(`knobkit: package.json "main" points to a missing file: ${main}\n`);
58
+ process.exit(1);
59
+ }
60
+ return file;
61
+ }
41
62
  function mode(file, args) {
42
63
  if (args.serve)
43
64
  return "serve";
@@ -57,11 +78,7 @@ async function main() {
57
78
  }
58
79
  const args = parse(rest);
59
80
  const root = process.cwd();
60
- const file = resolve(root, args.file);
61
- if (!existsSync(file)) {
62
- process.stderr.write(`knobkit: no such file: ${args.file}\n`);
63
- process.exit(1);
64
- }
81
+ const file = entry(root, args);
65
82
  ensureTsconfig(root);
66
83
  if (cmd === "build")
67
84
  return buildMount(root, file);
@@ -0,0 +1 @@
1
+ export declare const FAVICON_TAG: string;
@@ -0,0 +1,15 @@
1
+ // The K-Tile logo (design/logo.svg) as a data URI, so served and mounted apps get a favicon
2
+ // without serving an extra file.
3
+ const SVG = [
4
+ "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E",
5
+ "%3Crect x='4' y='4' width='56' height='56' rx='14' fill='%232563eb'/%3E",
6
+ "%3Cg stroke='%23fff' stroke-width='7' stroke-linecap='round'%3E",
7
+ "%3Cline x1='23' y1='16' x2='23' y2='48'/%3E",
8
+ "%3Cline x1='23' y1='33' x2='42' y2='18'/%3E",
9
+ "%3Cline x1='23' y1='33' x2='42' y2='46'/%3E",
10
+ "%3C/g%3E",
11
+ "%3Ccircle cx='23' cy='33' r='6.5' fill='%23fff'/%3E",
12
+ "%3Ccircle cx='23' cy='33' r='2.8' fill='%231d4ed8'/%3E",
13
+ "%3C/svg%3E",
14
+ ].join("");
15
+ export const FAVICON_TAG = `<link rel="icon" href="data:image/svg+xml,${SVG}" />`;
@@ -5,6 +5,7 @@ import { readFile } from "node:fs/promises";
5
5
  import { Server } from "socket.io";
6
6
  import { declare } from "../lib/declare.js";
7
7
  import { makeBound } from "../lib/ctx.js";
8
+ import { FAVICON_TAG } from "../lib/favicon.js";
8
9
  import { run } from "./context.js";
9
10
  const CLIENT_JS = fileURLToPath(new URL("../../dist/client.js", import.meta.url));
10
11
  const CLIENT_CSS = fileURLToPath(new URL("../../dist/client.css", import.meta.url));
@@ -13,7 +14,7 @@ const isEvent = (x) => x != null && typeof x.type === "string" && "payload" in x
13
14
  function html(decl, loading) {
14
15
  return `<!doctype html><html lang="en"><head><meta charset="utf-8" />
15
16
  <meta name="viewport" content="width=device-width, initial-scale=1" />
16
- <title>${decl.title ?? "knobkit"}</title><link rel="stylesheet" href="/client.css" /></head>
17
+ <title>${decl.title ?? "knobkit"}</title>${FAVICON_TAG}<link rel="stylesheet" href="/client.css" /></head>
17
18
  <body><div id="root">${loading}</div><script type="module" src="/client.js"></script></body></html>`;
18
19
  }
19
20
  // node-only. The browser owns state and renders; the server is stateless. It serves the decl + client
package/package.json CHANGED
@@ -1,9 +1,32 @@
1
1
  {
2
2
  "name": "knobkit",
3
- "version": "0.0.1",
4
- "description": "Rapid web app developmentdescribe inputs and a function, get a web app.",
3
+ "version": "0.0.3",
4
+ "description": "Your AI app, live as you type declare widgets, write event handlers, done. The same file runs in the browser or on a stateless Node server.",
5
+ "keywords": [
6
+ "widgets",
7
+ "ui",
8
+ "framework",
9
+ "declarative",
10
+ "web-app",
11
+ "react",
12
+ "events",
13
+ "isomorphic",
14
+ "rapid-prototyping"
15
+ ],
5
16
  "license": "MIT",
6
17
  "author": "Alain Brown",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/knobkit/knobkit.git",
21
+ "directory": "packages/knobkit"
22
+ },
23
+ "homepage": "https://github.com/knobkit/knobkit#readme",
24
+ "bugs": {
25
+ "url": "https://github.com/knobkit/knobkit/issues"
26
+ },
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
7
30
  "type": "module",
8
31
  "types": "./dist/lib/index.d.ts",
9
32
  "exports": {
package/src/cli/config.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync, writeFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname, relative, resolve } from "node:path";
4
4
  import { searchForWorkspaceRoot, type InlineConfig, type Plugin } from "vite";
5
+ import { FAVICON_TAG } from "../lib/favicon.js";
5
6
 
6
7
  export function ensureTsconfig(root: string): void {
7
8
  const path = resolve(root, "tsconfig.json");
@@ -41,6 +42,7 @@ export function indexHtml(entry: string): string {
41
42
  <meta charset="utf-8" />
42
43
  <meta name="viewport" content="width=device-width, initial-scale=1" />
43
44
  <title>knobkit</title>
45
+ ${FAVICON_TAG}
44
46
  </head>
45
47
  <body>
46
48
  <div id="root"></div>
package/src/cli/index.ts CHANGED
@@ -8,11 +8,11 @@ import { runServe } from "./serve.js";
8
8
  const HELP = `knobkit — build a web app from widgets and event handlers
9
9
 
10
10
  Usage:
11
- knobkit dev [file] Start a dev server (auto-detects mount vs serve)
12
- knobkit build [file] Build a browser (mount) app to dist/
13
- knobkit serve [file] Run a server (serve) app (same as: knobkit dev --serve)
11
+ knobkit dev Start a dev server (auto-detects mount vs serve)
12
+ knobkit build Build a browser (mount) app to dist/
13
+ knobkit serve Run a server (serve) app (same as: knobkit dev --serve)
14
14
 
15
- file defaults to demo.tsx
15
+ The entry file is "main" in package.json; pass a file to override (knobkit dev other.tsx)
16
16
 
17
17
  Flags:
18
18
  --mount Force browser (mount) mode
@@ -21,29 +21,52 @@ Flags:
21
21
  `;
22
22
 
23
23
  interface Args {
24
- file: string;
24
+ file?: string;
25
25
  mount: boolean;
26
26
  serve: boolean;
27
27
  port?: number;
28
28
  }
29
29
 
30
30
  function parse(argv: string[]): Args {
31
- const out: Args = { file: "demo.tsx", mount: false, serve: false };
32
- let sawFile = false;
31
+ const out: Args = { mount: false, serve: false };
33
32
  for (let i = 0; i < argv.length; i++) {
34
33
  const a = argv[i];
35
34
  if (a === "--mount") out.mount = true;
36
35
  else if (a === "--serve") out.serve = true;
37
36
  else if (a === "--port") out.port = Number(argv[++i]);
38
37
  else if (a.startsWith("--port=")) out.port = Number(a.slice("--port=".length));
39
- else if (!a.startsWith("-") && !sawFile) {
40
- out.file = a;
41
- sawFile = true;
42
- }
38
+ else if (!a.startsWith("-") && out.file === undefined) out.file = a;
43
39
  }
44
40
  return out;
45
41
  }
46
42
 
43
+ function entry(root: string, args: Args): string {
44
+ if (args.file) {
45
+ const file = resolve(root, args.file);
46
+ if (!existsSync(file)) {
47
+ process.stderr.write(`knobkit: no such file: ${args.file}\n`);
48
+ process.exit(1);
49
+ }
50
+ return file;
51
+ }
52
+ const pkgPath = resolve(root, "package.json");
53
+ const main = existsSync(pkgPath)
54
+ ? (JSON.parse(readFileSync(pkgPath, "utf8")) as { main?: string }).main
55
+ : undefined;
56
+ if (!main) {
57
+ process.stderr.write(
58
+ `knobkit: no entry file — pass one (knobkit dev app.tsx) or set "main" in package.json\n`,
59
+ );
60
+ process.exit(1);
61
+ }
62
+ const file = resolve(root, main);
63
+ if (!existsSync(file)) {
64
+ process.stderr.write(`knobkit: package.json "main" points to a missing file: ${main}\n`);
65
+ process.exit(1);
66
+ }
67
+ return file;
68
+ }
69
+
47
70
  function mode(file: string, args: Args): "mount" | "serve" {
48
71
  if (args.serve) return "serve";
49
72
  if (args.mount) return "mount";
@@ -63,11 +86,7 @@ async function main(): Promise<void> {
63
86
 
64
87
  const args = parse(rest);
65
88
  const root = process.cwd();
66
- const file = resolve(root, args.file);
67
- if (!existsSync(file)) {
68
- process.stderr.write(`knobkit: no such file: ${args.file}\n`);
69
- process.exit(1);
70
- }
89
+ const file = entry(root, args);
71
90
  ensureTsconfig(root);
72
91
 
73
92
  if (cmd === "build") return buildMount(root, file);
@@ -0,0 +1,16 @@
1
+ // The K-Tile logo (design/logo.svg) as a data URI, so served and mounted apps get a favicon
2
+ // without serving an extra file.
3
+ const SVG = [
4
+ "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E",
5
+ "%3Crect x='4' y='4' width='56' height='56' rx='14' fill='%232563eb'/%3E",
6
+ "%3Cg stroke='%23fff' stroke-width='7' stroke-linecap='round'%3E",
7
+ "%3Cline x1='23' y1='16' x2='23' y2='48'/%3E",
8
+ "%3Cline x1='23' y1='33' x2='42' y2='18'/%3E",
9
+ "%3Cline x1='23' y1='33' x2='42' y2='46'/%3E",
10
+ "%3C/g%3E",
11
+ "%3Ccircle cx='23' cy='33' r='6.5' fill='%23fff'/%3E",
12
+ "%3Ccircle cx='23' cy='33' r='2.8' fill='%231d4ed8'/%3E",
13
+ "%3C/svg%3E",
14
+ ].join("");
15
+
16
+ export const FAVICON_TAG = `<link rel="icon" href="data:image/svg+xml,${SVG}" />`;
@@ -7,6 +7,7 @@ import type { KnobkitServer, Event } from "../lib/types.js";
7
7
  import type { Knobkit } from "../lib/knobkit.js";
8
8
  import { declare, type AppDecl } from "../lib/declare.js";
9
9
  import { makeBound } from "../lib/ctx.js";
10
+ import { FAVICON_TAG } from "../lib/favicon.js";
10
11
  import { run } from "./context.js";
11
12
 
12
13
  const CLIENT_JS = fileURLToPath(new URL("../../dist/client.js", import.meta.url));
@@ -18,7 +19,7 @@ const isEvent = (x: any): x is Event => x != null && typeof x.type === "string"
18
19
  function html(decl: AppDecl, loading: string): string {
19
20
  return `<!doctype html><html lang="en"><head><meta charset="utf-8" />
20
21
  <meta name="viewport" content="width=device-width, initial-scale=1" />
21
- <title>${decl.title ?? "knobkit"}</title><link rel="stylesheet" href="/client.css" /></head>
22
+ <title>${decl.title ?? "knobkit"}</title>${FAVICON_TAG}<link rel="stylesheet" href="/client.css" /></head>
22
23
  <body><div id="root">${loading}</div><script type="module" src="/client.js"></script></body></html>`;
23
24
  }
24
25