knobkit 0.0.1
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/LICENSE +21 -0
- package/dist/assets/cell-renderer-CLTRlCa5-DIlwS99c.js +1 -0
- package/dist/assets/chart-D8ctp-_1.js +36 -0
- package/dist/assets/code-BMuLQBYq.js +2 -0
- package/dist/assets/column.service-C6hByxPy-XG9X0y3N.js +1 -0
- package/dist/assets/debounce-PCRWZliA-BjJpj_P7.js +32 -0
- package/dist/assets/dimension.helpers-CGKwSvw6-D_czicbS.js +1 -0
- package/dist/assets/dist-1hsZpGRf.js +23 -0
- package/dist/assets/dist-B-y4Etc5.js +1 -0
- package/dist/assets/dist-B8BXgMDk.js +10 -0
- package/dist/assets/dist-BJlXPLNt.js +1 -0
- package/dist/assets/dist-ByhR2UY_.js +1 -0
- package/dist/assets/dist-C0bxYHYH.js +2 -0
- package/dist/assets/dist-C8dagUDy.js +6 -0
- package/dist/assets/dist-CtLpohkg.js +1 -0
- package/dist/assets/dist-D00mNtIr.js +1 -0
- package/dist/assets/dist-Dh1Dvy3h.js +1 -0
- package/dist/assets/dist-DlwQ1Qqm.js +1 -0
- package/dist/assets/dist-DtZDI7jp.js +1 -0
- package/dist/assets/dist-thZFs69d.js +9 -0
- package/dist/assets/edit.utils-Dnnbd0xG-OAxDw8WC.js +1 -0
- package/dist/assets/events-BvSmBueA-4kqQ57iN.js +1 -0
- package/dist/assets/filter.button-BFwo1uvz-CyvQhOO5.js +1 -0
- package/dist/assets/header-cell-renderer-BMmXRsd_-BHbC7fao.js +1 -0
- package/dist/assets/index-Db3qZoW5-peeY7EGw.js +1 -0
- package/dist/assets/markdown-dDCgur7g.js +29 -0
- package/dist/assets/revo-grid.entry-CfI6s-uT.js +1 -0
- package/dist/assets/revogr-attribution_7.entry-6fUjzImt.js +1 -0
- package/dist/assets/revogr-clipboard_3.entry-DmI7LkER.js +2 -0
- package/dist/assets/revogr-data_4.entry-CYZIiXNw.js +1 -0
- package/dist/assets/revogr-filter-panel.entry-TmQHTQxw.js +1 -0
- package/dist/assets/table-Zn7rpfG-.js +1 -0
- package/dist/assets/text-editor-C3RUSwH5-DuDr9wKc.js +1 -0
- package/dist/assets/theme.service-BmnDvr6P-DftEgmbe.js +3 -0
- package/dist/assets/throttle-CaUDyxyU-Djj__DCp.js +1 -0
- package/dist/assets/viewport.helpers-CoCAvmZs-ByVRjjkF.js +1 -0
- package/dist/assets/viewport.store-_c579YyM-B_ZSqqka.js +1 -0
- package/dist/cli/config.d.ts +5 -0
- package/dist/cli/config.js +82 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +77 -0
- package/dist/cli/mount.d.ts +2 -0
- package/dist/cli/mount.js +26 -0
- package/dist/cli/serve.d.ts +1 -0
- package/dist/cli/serve.js +21 -0
- package/dist/client/app.d.ts +8 -0
- package/dist/client/app.js +40 -0
- package/dist/client/context.d.ts +2 -0
- package/dist/client/context.js +16 -0
- package/dist/client/mount.d.ts +3 -0
- package/dist/client/mount.js +42 -0
- package/dist/client/runtime.d.ts +19 -0
- package/dist/client/runtime.js +88 -0
- package/dist/client/view.d.ts +12 -0
- package/dist/client/view.js +1 -0
- package/dist/client/widgets/accordion/index.d.ts +5 -0
- package/dist/client/widgets/accordion/index.js +8 -0
- package/dist/client/widgets/annotated-image/index.d.ts +8 -0
- package/dist/client/widgets/annotated-image/index.js +34 -0
- package/dist/client/widgets/audio/index.d.ts +5 -0
- package/dist/client/widgets/audio/index.js +5 -0
- package/dist/client/widgets/button/index.d.ts +4 -0
- package/dist/client/widgets/button/index.js +5 -0
- package/dist/client/widgets/chart/index.d.ts +5 -0
- package/dist/client/widgets/chart/index.js +23 -0
- package/dist/client/widgets/chart/lazy.d.ts +3 -0
- package/dist/client/widgets/chart/lazy.js +9 -0
- package/dist/client/widgets/chat/index.d.ts +6 -0
- package/dist/client/widgets/chat/index.js +77 -0
- package/dist/client/widgets/checkbox/index.d.ts +6 -0
- package/dist/client/widgets/checkbox/index.js +9 -0
- package/dist/client/widgets/checkbox-group/index.d.ts +6 -0
- package/dist/client/widgets/checkbox-group/index.js +12 -0
- package/dist/client/widgets/code/index.d.ts +5 -0
- package/dist/client/widgets/code/index.js +101 -0
- package/dist/client/widgets/code/lazy.d.ts +3 -0
- package/dist/client/widgets/code/lazy.js +10 -0
- package/dist/client/widgets/dropdown/index.d.ts +5 -0
- package/dist/client/widgets/dropdown/index.js +9 -0
- package/dist/client/widgets/file/index.d.ts +6 -0
- package/dist/client/widgets/file/index.js +7 -0
- package/dist/client/widgets/frame/index.d.ts +6 -0
- package/dist/client/widgets/frame/index.js +11 -0
- package/dist/client/widgets/gallery/index.d.ts +6 -0
- package/dist/client/widgets/gallery/index.js +8 -0
- package/dist/client/widgets/highlighted-text/index.d.ts +7 -0
- package/dist/client/widgets/highlighted-text/index.js +20 -0
- package/dist/client/widgets/html/index.d.ts +4 -0
- package/dist/client/widgets/html/index.js +8 -0
- package/dist/client/widgets/image/index.d.ts +4 -0
- package/dist/client/widgets/image/index.js +4 -0
- package/dist/client/widgets/json/index.d.ts +4 -0
- package/dist/client/widgets/json/index.js +4 -0
- package/dist/client/widgets/label/index.d.ts +7 -0
- package/dist/client/widgets/label/index.js +8 -0
- package/dist/client/widgets/layout/index.d.ts +4 -0
- package/dist/client/widgets/layout/index.js +7 -0
- package/dist/client/widgets/log/index.d.ts +4 -0
- package/dist/client/widgets/log/index.js +4 -0
- package/dist/client/widgets/mic/index.d.ts +6 -0
- package/dist/client/widgets/mic/index.js +70 -0
- package/dist/client/widgets/number/index.d.ts +5 -0
- package/dist/client/widgets/number/index.js +8 -0
- package/dist/client/widgets/output/index.d.ts +6 -0
- package/dist/client/widgets/output/index.js +13 -0
- package/dist/client/widgets/output/markdown.d.ts +3 -0
- package/dist/client/widgets/output/markdown.js +8 -0
- package/dist/client/widgets/progress/index.d.ts +6 -0
- package/dist/client/widgets/progress/index.js +6 -0
- package/dist/client/widgets/radio/index.d.ts +6 -0
- package/dist/client/widgets/radio/index.js +10 -0
- package/dist/client/widgets/registry.d.ts +2 -0
- package/dist/client/widgets/registry.js +69 -0
- package/dist/client/widgets/slider/index.d.ts +6 -0
- package/dist/client/widgets/slider/index.js +9 -0
- package/dist/client/widgets/table/index.d.ts +6 -0
- package/dist/client/widgets/table/index.js +72 -0
- package/dist/client/widgets/table/lazy.d.ts +3 -0
- package/dist/client/widgets/table/lazy.js +16 -0
- package/dist/client/widgets/tabs/index.d.ts +5 -0
- package/dist/client/widgets/tabs/index.js +10 -0
- package/dist/client/widgets/text/index.d.ts +6 -0
- package/dist/client/widgets/text/index.js +10 -0
- package/dist/client/widgets/upload/index.d.ts +6 -0
- package/dist/client/widgets/upload/index.js +16 -0
- package/dist/client/widgets/video/index.d.ts +5 -0
- package/dist/client/widgets/video/index.js +5 -0
- package/dist/client/widgets/webcam/index.d.ts +6 -0
- package/dist/client/widgets/webcam/index.js +62 -0
- package/dist/client.css +1 -0
- package/dist/client.js +11 -0
- package/dist/knobkit.browser.css +2 -0
- package/dist/knobkit.browser.js +151 -0
- package/dist/lib/bound.d.ts +12 -0
- package/dist/lib/bound.js +10 -0
- package/dist/lib/controls.d.ts +9 -0
- package/dist/lib/controls.js +35 -0
- package/dist/lib/ctx.d.ts +9 -0
- package/dist/lib/ctx.js +22 -0
- package/dist/lib/declare.d.ts +18 -0
- package/dist/lib/declare.js +43 -0
- package/dist/lib/event.d.ts +2 -0
- package/dist/lib/event.js +9 -0
- package/dist/lib/index.d.ts +10 -0
- package/dist/lib/index.js +6 -0
- package/dist/lib/knobkit.d.ts +19 -0
- package/dist/lib/knobkit.js +48 -0
- package/dist/lib/on.d.ts +2 -0
- package/dist/lib/on.js +1 -0
- package/dist/lib/stream.d.ts +1 -0
- package/dist/lib/stream.js +33 -0
- package/dist/lib/types.d.ts +48 -0
- package/dist/lib/types.js +1 -0
- package/dist/lib/widget.d.ts +7 -0
- package/dist/lib/widget.js +4 -0
- package/dist/lib/widgets/annotated-image.d.ts +14 -0
- package/dist/lib/widgets/annotated-image.js +16 -0
- package/dist/lib/widgets/audio.d.ts +10 -0
- package/dist/lib/widgets/audio.js +13 -0
- package/dist/lib/widgets/button.d.ts +11 -0
- package/dist/lib/widgets/button.js +17 -0
- package/dist/lib/widgets/chart.d.ts +20 -0
- package/dist/lib/widgets/chart.js +24 -0
- package/dist/lib/widgets/chat.d.ts +26 -0
- package/dist/lib/widgets/chat.js +24 -0
- package/dist/lib/widgets/checkbox-group.d.ts +4 -0
- package/dist/lib/widgets/checkbox-group.js +5 -0
- package/dist/lib/widgets/checkbox.d.ts +4 -0
- package/dist/lib/widgets/checkbox.js +4 -0
- package/dist/lib/widgets/code.d.ts +5 -0
- package/dist/lib/widgets/code.js +10 -0
- package/dist/lib/widgets/dropdown.d.ts +4 -0
- package/dist/lib/widgets/dropdown.js +4 -0
- package/dist/lib/widgets/embed.d.ts +5 -0
- package/dist/lib/widgets/embed.js +23 -0
- package/dist/lib/widgets/file.d.ts +11 -0
- package/dist/lib/widgets/file.js +15 -0
- package/dist/lib/widgets/frame.d.ts +18 -0
- package/dist/lib/widgets/frame.js +25 -0
- package/dist/lib/widgets/gallery.d.ts +12 -0
- package/dist/lib/widgets/gallery.js +17 -0
- package/dist/lib/widgets/highlighted-text.d.ts +12 -0
- package/dist/lib/widgets/highlighted-text.js +15 -0
- package/dist/lib/widgets/html.d.ts +9 -0
- package/dist/lib/widgets/html.js +12 -0
- package/dist/lib/widgets/image.d.ts +7 -0
- package/dist/lib/widgets/image.js +12 -0
- package/dist/lib/widgets/index.d.ts +31 -0
- package/dist/lib/widgets/index.js +31 -0
- package/dist/lib/widgets/json.d.ts +7 -0
- package/dist/lib/widgets/json.js +12 -0
- package/dist/lib/widgets/label.d.ts +15 -0
- package/dist/lib/widgets/label.js +18 -0
- package/dist/lib/widgets/layout.d.ts +21 -0
- package/dist/lib/widgets/layout.js +29 -0
- package/dist/lib/widgets/log.d.ts +8 -0
- package/dist/lib/widgets/log.js +15 -0
- package/dist/lib/widgets/mic.d.ts +19 -0
- package/dist/lib/widgets/mic.js +28 -0
- package/dist/lib/widgets/number.d.ts +5 -0
- package/dist/lib/widgets/number.js +4 -0
- package/dist/lib/widgets/output.d.ts +10 -0
- package/dist/lib/widgets/output.js +13 -0
- package/dist/lib/widgets/progress.d.ts +10 -0
- package/dist/lib/widgets/progress.js +15 -0
- package/dist/lib/widgets/radio.d.ts +4 -0
- package/dist/lib/widgets/radio.js +5 -0
- package/dist/lib/widgets/slider.d.ts +6 -0
- package/dist/lib/widgets/slider.js +6 -0
- package/dist/lib/widgets/table.d.ts +32 -0
- package/dist/lib/widgets/table.js +36 -0
- package/dist/lib/widgets/text.d.ts +4 -0
- package/dist/lib/widgets/text.js +4 -0
- package/dist/lib/widgets/upload.d.ts +3 -0
- package/dist/lib/widgets/upload.js +5 -0
- package/dist/lib/widgets/value.d.ts +9 -0
- package/dist/lib/widgets/value.js +20 -0
- package/dist/lib/widgets/video.d.ts +12 -0
- package/dist/lib/widgets/video.js +14 -0
- package/dist/lib/widgets/webcam.d.ts +21 -0
- package/dist/lib/widgets/webcam.js +29 -0
- package/dist/server/context.d.ts +2 -0
- package/dist/server/context.js +10 -0
- package/dist/server/serve.d.ts +5 -0
- package/dist/server/serve.js +131 -0
- package/package.json +71 -0
- package/src/cli/config.ts +83 -0
- package/src/cli/index.ts +82 -0
- package/src/cli/mount.ts +25 -0
- package/src/cli/serve.ts +22 -0
- package/src/client/app.test.tsx +70 -0
- package/src/client/app.tsx +62 -0
- package/src/client/browser-runtime.test.ts +22 -0
- package/src/client/browser.ts +3 -0
- package/src/client/context.ts +17 -0
- package/src/client/embed.test.tsx +58 -0
- package/src/client/entry.tsx +25 -0
- package/src/client/mount.test.tsx +36 -0
- package/src/client/mount.tsx +48 -0
- package/src/client/runtime.test.ts +64 -0
- package/src/client/runtime.ts +112 -0
- package/src/client/serve-stub.ts +3 -0
- package/src/client/styles.css +131 -0
- package/src/client/view.ts +16 -0
- package/src/client/widgets/accordion/accordion.css +35 -0
- package/src/client/widgets/accordion/index.tsx +17 -0
- package/src/client/widgets/annotated-image/annotated-image.css +62 -0
- package/src/client/widgets/annotated-image/index.tsx +73 -0
- package/src/client/widgets/audio/audio.css +6 -0
- package/src/client/widgets/audio/index.tsx +6 -0
- package/src/client/widgets/button/button.css +25 -0
- package/src/client/widgets/button/index.tsx +11 -0
- package/src/client/widgets/chart/chart.css +12 -0
- package/src/client/widgets/chart/index.tsx +63 -0
- package/src/client/widgets/chart/lazy.tsx +15 -0
- package/src/client/widgets/chat/chat.css +97 -0
- package/src/client/widgets/chat/index.tsx +121 -0
- package/src/client/widgets/checkbox/checkbox.css +15 -0
- package/src/client/widgets/checkbox/index.tsx +15 -0
- package/src/client/widgets/checkbox-group/checkbox-group.css +20 -0
- package/src/client/widgets/checkbox-group/index.tsx +22 -0
- package/src/client/widgets/code/code.css +31 -0
- package/src/client/widgets/code/index.tsx +108 -0
- package/src/client/widgets/code/lazy.tsx +16 -0
- package/src/client/widgets/dropdown/dropdown.css +0 -0
- package/src/client/widgets/dropdown/index.tsx +19 -0
- package/src/client/widgets/file/file.css +26 -0
- package/src/client/widgets/file/index.tsx +12 -0
- package/src/client/widgets/frame/frame.css +17 -0
- package/src/client/widgets/frame/index.tsx +15 -0
- package/src/client/widgets/gallery/gallery.css +26 -0
- package/src/client/widgets/gallery/index.tsx +18 -0
- package/src/client/widgets/highlighted-text/highlighted-text.css +21 -0
- package/src/client/widgets/highlighted-text/index.tsx +42 -0
- package/src/client/widgets/html/index.tsx +8 -0
- package/src/client/widgets/image/index.tsx +5 -0
- package/src/client/widgets/json/index.tsx +5 -0
- package/src/client/widgets/json/json.css +0 -0
- package/src/client/widgets/label/index.tsx +20 -0
- package/src/client/widgets/label/label.css +39 -0
- package/src/client/widgets/layout/index.tsx +14 -0
- package/src/client/widgets/log/index.tsx +5 -0
- package/src/client/widgets/log/log.css +0 -0
- package/src/client/widgets/mic/index.tsx +85 -0
- package/src/client/widgets/mic/mic.css +8 -0
- package/src/client/widgets/number/index.tsx +10 -0
- package/src/client/widgets/number/number.css +0 -0
- package/src/client/widgets/output/index.tsx +19 -0
- package/src/client/widgets/output/markdown.tsx +12 -0
- package/src/client/widgets/output/output.css +75 -0
- package/src/client/widgets/progress/index.tsx +14 -0
- package/src/client/widgets/progress/progress.css +26 -0
- package/src/client/widgets/radio/index.tsx +20 -0
- package/src/client/widgets/radio/radio.css +20 -0
- package/src/client/widgets/registry.tsx +71 -0
- package/src/client/widgets/slider/index.tsx +23 -0
- package/src/client/widgets/slider/slider.css +18 -0
- package/src/client/widgets/table/index.tsx +95 -0
- package/src/client/widgets/table/lazy.tsx +23 -0
- package/src/client/widgets/table/table.css +15 -0
- package/src/client/widgets/tabs/index.tsx +28 -0
- package/src/client/widgets/tabs/tabs.css +30 -0
- package/src/client/widgets/text/index.tsx +16 -0
- package/src/client/widgets/text/text.css +21 -0
- package/src/client/widgets/upload/index.tsx +30 -0
- package/src/client/widgets/upload/upload.css +30 -0
- package/src/client/widgets/video/index.tsx +10 -0
- package/src/client/widgets/video/video.css +8 -0
- package/src/client/widgets/webcam/index.tsx +81 -0
- package/src/client/widgets/webcam/webcam.css +46 -0
- package/src/css.d.ts +1 -0
- package/src/env.d.ts +1 -0
- package/src/lib/bound.ts +30 -0
- package/src/lib/controls.ts +36 -0
- package/src/lib/ctx.ts +31 -0
- package/src/lib/declare.test.ts +46 -0
- package/src/lib/declare.ts +74 -0
- package/src/lib/event.ts +12 -0
- package/src/lib/index.ts +21 -0
- package/src/lib/knobkit.ts +57 -0
- package/src/lib/on.ts +3 -0
- package/src/lib/stream.ts +38 -0
- package/src/lib/types.ts +63 -0
- package/src/lib/widget.ts +11 -0
- package/src/lib/widgets/annotated-image.ts +34 -0
- package/src/lib/widgets/audio.ts +20 -0
- package/src/lib/widgets/button.ts +27 -0
- package/src/lib/widgets/chart.ts +44 -0
- package/src/lib/widgets/chat.ts +43 -0
- package/src/lib/widgets/checkbox-group.ts +6 -0
- package/src/lib/widgets/checkbox.ts +5 -0
- package/src/lib/widgets/code.ts +11 -0
- package/src/lib/widgets/dropdown.ts +5 -0
- package/src/lib/widgets/embed.ts +26 -0
- package/src/lib/widgets/file.ts +23 -0
- package/src/lib/widgets/frame.ts +36 -0
- package/src/lib/widgets/gallery.ts +29 -0
- package/src/lib/widgets/highlighted-text.ts +29 -0
- package/src/lib/widgets/html.ts +18 -0
- package/src/lib/widgets/image.ts +18 -0
- package/src/lib/widgets/index.ts +31 -0
- package/src/lib/widgets/json.ts +18 -0
- package/src/lib/widgets/label.ts +29 -0
- package/src/lib/widgets/layout.ts +47 -0
- package/src/lib/widgets/log.ts +22 -0
- package/src/lib/widgets/mic.ts +42 -0
- package/src/lib/widgets/number.ts +5 -0
- package/src/lib/widgets/output.ts +20 -0
- package/src/lib/widgets/progress.ts +21 -0
- package/src/lib/widgets/radio.ts +6 -0
- package/src/lib/widgets/slider.ts +7 -0
- package/src/lib/widgets/table.ts +58 -0
- package/src/lib/widgets/text.ts +5 -0
- package/src/lib/widgets/upload.ts +6 -0
- package/src/lib/widgets/value.ts +28 -0
- package/src/lib/widgets/video.ts +22 -0
- package/src/lib/widgets/webcam.ts +46 -0
- package/src/server/context.ts +12 -0
- package/src/server/serve.test.ts +121 -0
- package/src/server/serve.ts +130 -0
- package/tsconfig.base.json +14 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import "./styles.css";
|
|
2
|
+
import { createElement, useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import type { AppDecl, WidgetDecl } from "../lib/declare.js";
|
|
5
|
+
import type { Path } from "../lib/bound.js";
|
|
6
|
+
import type { Event } from "../lib/types.js";
|
|
7
|
+
import type { Store } from "./runtime.js";
|
|
8
|
+
import { VIEWS } from "./widgets/registry.js";
|
|
9
|
+
|
|
10
|
+
type ByKey = Record<string, WidgetDecl>;
|
|
11
|
+
|
|
12
|
+
// rebuild a widget's event constructors from the `type` strings in the decl, so a view's
|
|
13
|
+
// `emit(widget.changed(v))` produces the right `{ type, payload }`.
|
|
14
|
+
function rebuild(decl: WidgetDecl): Record<string, unknown> {
|
|
15
|
+
const widget: Record<string, unknown> = { type: decl.type, ...decl.props };
|
|
16
|
+
for (const [name, type] of Object.entries(decl.events)) {
|
|
17
|
+
widget[name] = (payload: unknown): Event => ({ type, payload });
|
|
18
|
+
}
|
|
19
|
+
return widget;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// One widget. It subscribes to its own key, so only the widget whose state (or enabled) changed
|
|
23
|
+
// re-renders. The view gets `emit` (events onto the local bus) and `set` (a local state edit for its
|
|
24
|
+
// own widget, e.g. an input reflecting typing — no server round-trip).
|
|
25
|
+
function Field({ wd, byKey, store, emit }: { wd: WidgetDecl; byKey: ByKey; store: Store; emit: (e: Event) => void }) {
|
|
26
|
+
const subscribe = useCallback((cb: () => void) => store.subscribe(wd.key, cb), [store, wd.key]);
|
|
27
|
+
const cell = useSyncExternalStore(subscribe, () => store.cell(wd.key));
|
|
28
|
+
const widget = useMemo(() => rebuild(wd), [wd]);
|
|
29
|
+
const set = useCallback((path: Path, value: unknown) => store.applyEdit(wd.key, "set", path, value), [store, wd.key]);
|
|
30
|
+
const slot = useCallback(
|
|
31
|
+
(key: string): ReactNode => {
|
|
32
|
+
const child = byKey[key];
|
|
33
|
+
return child ? <Field key={key} wd={child} byKey={byKey} store={store} emit={emit} /> : null;
|
|
34
|
+
},
|
|
35
|
+
[byKey, store, emit],
|
|
36
|
+
);
|
|
37
|
+
const View = VIEWS[wd.type];
|
|
38
|
+
if (!View) return null;
|
|
39
|
+
return (
|
|
40
|
+
<div className={`pu-field${cell.enabled ? "" : " pu-disabled"}${cell.busy ? " pu-busy" : ""}`}>
|
|
41
|
+
{cell.busy && <div className="pu-busy-bar" role="status" aria-label="Loading" />}
|
|
42
|
+
{createElement(View, { widget, state: cell.state, enabled: cell.enabled, emit, set, slot })}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function App({ decl, store }: { decl: AppDecl; store: Store }) {
|
|
48
|
+
const emit = (e: Event): void => store.emit(e.type, e.payload);
|
|
49
|
+
const byKey = useMemo<ByKey>(() => Object.fromEntries(decl.widgets.map((wd) => [wd.key, wd])), [decl]);
|
|
50
|
+
const rootWd = byKey[decl.root];
|
|
51
|
+
return (
|
|
52
|
+
<div className="pu-page">
|
|
53
|
+
{decl.title && <h1>{decl.title}</h1>}
|
|
54
|
+
{decl.description && <p className="pu-desc">{decl.description}</p>}
|
|
55
|
+
{rootWd && <Field wd={rootWd} byKey={byKey} store={store} emit={emit} />}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function render(decl: AppDecl, store: Store, el: Element): void {
|
|
61
|
+
createRoot(el).render(<App decl={decl} store={store} />);
|
|
62
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { test, expect } from "vitest";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const bundle = resolve(dirname(fileURLToPath(import.meta.url)), "../../dist/knobkit.browser.js");
|
|
8
|
+
|
|
9
|
+
test.skipIf(!existsSync(bundle))("the built browser runtime mounts an app with no bundler", async () => {
|
|
10
|
+
const knobkit = (await import(/* @vite-ignore */ bundle)) as typeof import("../lib/index.js");
|
|
11
|
+
|
|
12
|
+
const box = knobkit.text();
|
|
13
|
+
const out = knobkit.json();
|
|
14
|
+
const app = knobkit.knobkit({ widgets: [box, out] }).on(box.changed, (t: string) => out.set({ typed: t }));
|
|
15
|
+
|
|
16
|
+
document.body.innerHTML = '<div id="root"></div>';
|
|
17
|
+
app.mount("#root");
|
|
18
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
19
|
+
|
|
20
|
+
expect(document.querySelector("input")).not.toBeNull();
|
|
21
|
+
expect(document.querySelector("pre")).not.toBeNull();
|
|
22
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { setBoundResolver, type Bound } from "../lib/bound.js";
|
|
2
|
+
|
|
3
|
+
// Browser context binding for mount mode, where handlers run in-process. There is no AsyncLocalStorage
|
|
4
|
+
// in the browser, so the current context is a module global set around each handler run. This is
|
|
5
|
+
// correct for one in-flight handler at a time (the common case); overlapping async handlers would
|
|
6
|
+
// share it.
|
|
7
|
+
let current: Bound | undefined;
|
|
8
|
+
setBoundResolver(() => current);
|
|
9
|
+
|
|
10
|
+
export async function run(bound: Bound, fn: () => unknown): Promise<void> {
|
|
11
|
+
current = bound;
|
|
12
|
+
try {
|
|
13
|
+
await fn();
|
|
14
|
+
} finally {
|
|
15
|
+
current = undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { test, expect } from "vitest";
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { knobkit } from "../lib/knobkit.js";
|
|
5
|
+
import { text, json, button, embed } from "../lib/widgets/index.js";
|
|
6
|
+
import { declare } from "../lib/declare.js";
|
|
7
|
+
import { mount } from "./mount.js";
|
|
8
|
+
|
|
9
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
10
|
+
|
|
11
|
+
function type(el: HTMLInputElement, value: string): void {
|
|
12
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
|
13
|
+
setter.call(el, value);
|
|
14
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function countWords(box: ReturnType<typeof text>, stats: ReturnType<typeof json>) {
|
|
18
|
+
return knobkit({ widgets: [box, stats] }).on(box.changed, (t: string) =>
|
|
19
|
+
stats.set({ words: t.trim() ? t.trim().split(/\s+/).length : 0 }),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("embed merges a sub-app's handlers and setups into the parent", () => {
|
|
24
|
+
const box = text();
|
|
25
|
+
const stats = json();
|
|
26
|
+
const sub = countWords(box, stats);
|
|
27
|
+
sub.setup(() => {});
|
|
28
|
+
|
|
29
|
+
const parent = knobkit({ widgets: [button({ label: "x" }), embed(sub)] });
|
|
30
|
+
|
|
31
|
+
expect(parent.handlers.has(box.changed.type)).toBe(true);
|
|
32
|
+
expect(parent.serverEvents()).toContain(box.changed.type);
|
|
33
|
+
expect(parent.setups.length).toBe(1);
|
|
34
|
+
expect(() => parent.keyFor(box)).not.toThrow();
|
|
35
|
+
|
|
36
|
+
const decl = declare(parent.config, parent.serverEvents());
|
|
37
|
+
for (const w of decl.widgets) expect(w.props.__subapp).toBeUndefined();
|
|
38
|
+
expect(decl.widgets.some((w) => w.type === "json")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("an embedded sub-app's handler fires through the parent store", async () => {
|
|
42
|
+
const box = text();
|
|
43
|
+
const stats = json();
|
|
44
|
+
const parent = knobkit({ widgets: embed(countWords(box, stats)) });
|
|
45
|
+
document.body.innerHTML = '<div id="root"></div>';
|
|
46
|
+
|
|
47
|
+
await act(async () => {
|
|
48
|
+
mount(parent, "#root");
|
|
49
|
+
});
|
|
50
|
+
const input = document.querySelector("input") as HTMLInputElement;
|
|
51
|
+
expect(input).not.toBeNull();
|
|
52
|
+
|
|
53
|
+
await act(async () => {
|
|
54
|
+
type(input, "hello there world");
|
|
55
|
+
await tick();
|
|
56
|
+
});
|
|
57
|
+
expect(document.querySelector("pre")!.textContent).toContain('"words": 3');
|
|
58
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { io } from "socket.io-client";
|
|
2
|
+
import type { AppDecl } from "../lib/declare.js";
|
|
3
|
+
import type { EditOp, Path } from "../lib/bound.js";
|
|
4
|
+
import { createStore, type Transport } from "./runtime.js";
|
|
5
|
+
import { render } from "./app.js";
|
|
6
|
+
|
|
7
|
+
// the bundled browser entry for a served app: fetch the decl, open the socket, render. A routed event
|
|
8
|
+
// is sent as a tiny `request` (no state). The server applies state via `edit`/`enable`, re-emits
|
|
9
|
+
// produced events via `emit`, and pulls state on demand via `read` (one attribute, answered here).
|
|
10
|
+
async function boot(): Promise<void> {
|
|
11
|
+
const decl: AppDecl = await (await fetch("/api/decl")).json();
|
|
12
|
+
const socket = io({ path: "/socket.io/", transports: ["polling", "websocket"] });
|
|
13
|
+
const transport: Transport = (type, payload) => socket.emit("request", { type, payload });
|
|
14
|
+
const store = createStore(decl, transport);
|
|
15
|
+
|
|
16
|
+
socket.on("edit", (m: { key: string; op: EditOp; path: Path; value: unknown }) => store.applyEdit(m.key, m.op, m.path, m.value));
|
|
17
|
+
socket.on("enable", (m: { key: string; value: boolean }) => store.setEnabled(m.key, m.value));
|
|
18
|
+
socket.on("busy", (m: { key: string; value: boolean }) => store.setBusy(m.key, m.value));
|
|
19
|
+
socket.on("emit", (m: { type: string; payload: unknown }) => store.emit(m.type, m.payload));
|
|
20
|
+
socket.on("read", (m: { key: string; path: Path }, ack: (value: unknown) => void) => ack(store.read(m.key, m.path)));
|
|
21
|
+
|
|
22
|
+
render(decl, store, document.getElementById("root")!);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
void boot();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { test, expect } from "vitest";
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { knobkit } from "../lib/knobkit.js";
|
|
5
|
+
import { text, json } from "../lib/widgets/index.js";
|
|
6
|
+
import { mount } from "./mount.js";
|
|
7
|
+
|
|
8
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
9
|
+
|
|
10
|
+
function type(el: HTMLInputElement, value: string): void {
|
|
11
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
|
12
|
+
setter.call(el, value);
|
|
13
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("mount owns state and runs on(...) handlers locally", async () => {
|
|
17
|
+
const box = text();
|
|
18
|
+
const stats = json();
|
|
19
|
+
const app = knobkit({ widgets: [box, stats] }).on(box.changed, (t: string) =>
|
|
20
|
+
stats.set({ words: t.trim() ? t.trim().split(/\s+/).length : 0 }),
|
|
21
|
+
);
|
|
22
|
+
document.body.innerHTML = '<div id="root"></div>';
|
|
23
|
+
|
|
24
|
+
await act(async () => {
|
|
25
|
+
mount(app, "#root");
|
|
26
|
+
});
|
|
27
|
+
const input = document.querySelector("input") as HTMLInputElement;
|
|
28
|
+
expect(input).not.toBeNull();
|
|
29
|
+
|
|
30
|
+
await act(async () => {
|
|
31
|
+
type(input, "hello there world");
|
|
32
|
+
await tick();
|
|
33
|
+
});
|
|
34
|
+
expect(input.value).toBe("hello there world"); // local set drove the controlled input
|
|
35
|
+
expect(document.querySelector("pre")!.textContent).toContain('"words": 3'); // local handler ran
|
|
36
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import "./styles.css";
|
|
2
|
+
import type { Knobkit } from "../lib/knobkit.js";
|
|
3
|
+
import type { Event } from "../lib/types.js";
|
|
4
|
+
import { declare } from "../lib/declare.js";
|
|
5
|
+
import { makeBound } from "../lib/ctx.js";
|
|
6
|
+
import { createStore, type Store, type Transport } from "./runtime.js";
|
|
7
|
+
import { render } from "./app.js";
|
|
8
|
+
import { run } from "./context.js";
|
|
9
|
+
|
|
10
|
+
const isEvent = (x: any): x is Event => x != null && typeof x.type === "string" && "payload" in x;
|
|
11
|
+
|
|
12
|
+
// DOM-only, no server: the browser owns state AND runs the `on(...)` handlers locally. Same store and
|
|
13
|
+
// views as serve mode; the transport invokes the knobkit's handlers in-place with a context that reads the
|
|
14
|
+
// local store and applies edits to it directly. Produced events go straight back onto the store.
|
|
15
|
+
export function mount(knobkit: Knobkit, selector: string): void {
|
|
16
|
+
const el = document.querySelector(selector);
|
|
17
|
+
if (!el) throw new Error(`knobkit: no element matches "${selector}"`);
|
|
18
|
+
const decl = declare(knobkit.config, knobkit.serverEvents());
|
|
19
|
+
|
|
20
|
+
let store: Store;
|
|
21
|
+
const localBound = () =>
|
|
22
|
+
makeBound({
|
|
23
|
+
read: (key, path) => Promise.resolve(store.read(key, path)),
|
|
24
|
+
edit: (key, op, path, value) => store.applyEdit(key, op, path, value),
|
|
25
|
+
enable: (key, value) => store.setEnabled(key, value),
|
|
26
|
+
busy: (key, value) => store.setBusy(key, value),
|
|
27
|
+
keyFor: (w) => knobkit.keyFor(w),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const transport: Transport = (type, payload) => {
|
|
31
|
+
const bound = localBound();
|
|
32
|
+
void (async () => {
|
|
33
|
+
for (const handler of knobkit.handlers.get(type) ?? []) {
|
|
34
|
+
await run(bound, async () => {
|
|
35
|
+
const r = await handler(payload);
|
|
36
|
+
if (isEvent(r)) store.emit(r.type, r.payload);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
store = createStore(decl, transport);
|
|
43
|
+
render(decl, store, el);
|
|
44
|
+
|
|
45
|
+
void (async () => {
|
|
46
|
+
for (const fn of knobkit.setups) await run(localBound(), fn);
|
|
47
|
+
})();
|
|
48
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { test, expect } from "vitest";
|
|
2
|
+
import { knobkit } from "../lib/knobkit.js";
|
|
3
|
+
import { chat, log, text } from "../lib/widgets/index.js";
|
|
4
|
+
import { declare } from "../lib/declare.js";
|
|
5
|
+
import { createStore, type Transport } from "./runtime.js";
|
|
6
|
+
import type { Knobkit } from "../lib/knobkit.js";
|
|
7
|
+
|
|
8
|
+
function storeFor(app: Knobkit) {
|
|
9
|
+
const routed: { type: string; payload: unknown }[] = [];
|
|
10
|
+
const transport: Transport = (type, payload) => routed.push({ type, payload });
|
|
11
|
+
return { store: createStore(declare(app.config, app.serverEvents()), transport), routed };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test("applyEdit set/append/appendText mutate structured state by path", () => {
|
|
15
|
+
const convo = chat();
|
|
16
|
+
const lines = log();
|
|
17
|
+
const box = text();
|
|
18
|
+
const app = knobkit({ widgets: [convo, lines, box] });
|
|
19
|
+
const { store } = storeFor(app);
|
|
20
|
+
|
|
21
|
+
store.applyEdit(app.keyFor(box), "set", ["value"], "hi");
|
|
22
|
+
expect(store.read(app.keyFor(box), ["value"])).toBe("hi");
|
|
23
|
+
|
|
24
|
+
store.applyEdit(app.keyFor(lines), "append", ["lines"], "a");
|
|
25
|
+
store.applyEdit(app.keyFor(lines), "append", ["lines"], "b");
|
|
26
|
+
expect(store.read(app.keyFor(lines), ["lines"])).toEqual(["a", "b"]);
|
|
27
|
+
|
|
28
|
+
store.applyEdit(app.keyFor(convo), "append", ["messages"], { role: "assistant", content: "" });
|
|
29
|
+
store.applyEdit(app.keyFor(convo), "appendText", ["messages", -1, "content"], "hel");
|
|
30
|
+
store.applyEdit(app.keyFor(convo), "appendText", ["messages", -1, "content"], "lo");
|
|
31
|
+
expect(store.read(app.keyFor(convo), ["messages"])).toEqual([{ role: "assistant", content: "hello" }]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("an event routes to the transport only if it has a handler", () => {
|
|
35
|
+
const convo = chat();
|
|
36
|
+
const app = knobkit({ widgets: [convo] }).on(convo.sent, () => {});
|
|
37
|
+
const { store, routed } = storeFor(app);
|
|
38
|
+
store.emit(convo.sent.type, "hi");
|
|
39
|
+
expect(routed).toEqual([{ type: convo.sent.type, payload: "hi" }]);
|
|
40
|
+
store.emit(convo.recorded.type, new Float32Array()); // no handler registered
|
|
41
|
+
expect(routed).toHaveLength(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a disabled widget drops its own input events; enabling restores them", () => {
|
|
45
|
+
const box = text();
|
|
46
|
+
const app = knobkit({ widgets: [box] }).on(box.changed, () => {});
|
|
47
|
+
const { store, routed } = storeFor(app);
|
|
48
|
+
const key = app.keyFor(box);
|
|
49
|
+
store.setEnabled(key, false);
|
|
50
|
+
expect(store.enabled(key)).toBe(false);
|
|
51
|
+
store.emit(box.changed.type, "x");
|
|
52
|
+
expect(routed).toHaveLength(0); // dropped: authoritative
|
|
53
|
+
store.setEnabled(key, true);
|
|
54
|
+
store.emit(box.changed.type, "y");
|
|
55
|
+
expect(routed).toEqual([{ type: box.changed.type, payload: "y" }]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("read walks a path and returns undefined for a missing one", () => {
|
|
59
|
+
const convo = chat();
|
|
60
|
+
const app = knobkit({ widgets: [convo] });
|
|
61
|
+
const { store } = storeFor(app);
|
|
62
|
+
expect(store.read(app.keyFor(convo), ["messages"])).toEqual([]);
|
|
63
|
+
expect(store.read(app.keyFor(convo), ["nope"])).toBeUndefined();
|
|
64
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { AppDecl } from "../lib/declare.js";
|
|
2
|
+
import type { EditOp, Path } from "../lib/bound.js";
|
|
3
|
+
|
|
4
|
+
// Routes an event to its handler(s): serve emits it over the socket, mount runs the handlers in-place.
|
|
5
|
+
export type Transport = (type: string, payload: unknown) => void;
|
|
6
|
+
|
|
7
|
+
// A widget's renderable cell: its structured state plus its enabled/busy flags. Replaced as a unit on
|
|
8
|
+
// change so a view can subscribe to one stable reference.
|
|
9
|
+
export interface Cell {
|
|
10
|
+
state: unknown;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
busy: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Store {
|
|
16
|
+
emit(type: string, payload: unknown): void;
|
|
17
|
+
applyEdit(key: string, op: EditOp, path: Path, value: unknown): void;
|
|
18
|
+
setEnabled(key: string, value: boolean): void;
|
|
19
|
+
setBusy(key: string, value: boolean): void;
|
|
20
|
+
read(key: string, path: Path): unknown;
|
|
21
|
+
cell(key: string): Cell;
|
|
22
|
+
enabled(key: string): boolean;
|
|
23
|
+
subscribe(key: string, fn: () => void): () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readAt(node: any, path: Path): unknown {
|
|
27
|
+
for (const p of path) {
|
|
28
|
+
if (node == null) return undefined;
|
|
29
|
+
node = Array.isArray(node) && p === -1 ? node[node.length - 1] : node[p as keyof typeof node];
|
|
30
|
+
}
|
|
31
|
+
return node;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Immutably apply one structured edit at a path. -1 addresses an array's last element.
|
|
35
|
+
function editAt(node: any, path: Path, op: EditOp, value: unknown): any {
|
|
36
|
+
if (path.length === 0) {
|
|
37
|
+
if (op === "set") return value;
|
|
38
|
+
if (op === "append") return [...((node as unknown[]) ?? []), value];
|
|
39
|
+
return ((node as string) ?? "") + (value as string); // appendText
|
|
40
|
+
}
|
|
41
|
+
const [head, ...rest] = path;
|
|
42
|
+
if (Array.isArray(node)) {
|
|
43
|
+
const i = head === -1 ? node.length - 1 : (head as number);
|
|
44
|
+
const copy = node.slice();
|
|
45
|
+
copy[i] = editAt(node[i], rest, op, value);
|
|
46
|
+
return copy;
|
|
47
|
+
}
|
|
48
|
+
return { ...(node as object), [head as string]: editAt(node?.[head as keyof typeof node], rest, op, value) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The browser owns all state as structured JSON, one cell per widget. The store has no per-widget
|
|
52
|
+
// logic: events route to handlers (gated by enabled), edits apply generically by path, reads walk a
|
|
53
|
+
// path. A change notifies only that widget's subscribers — rendering is per-key.
|
|
54
|
+
export function createStore(decl: AppDecl, transport: Transport): Store {
|
|
55
|
+
const cells = new Map<string, Cell>();
|
|
56
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
57
|
+
const owner = new Map<string, string>(); // an event type -> the widget key that emits it
|
|
58
|
+
const serverEvents = new Set(decl.serverEvents);
|
|
59
|
+
|
|
60
|
+
for (const w of decl.widgets) {
|
|
61
|
+
cells.set(w.key, { state: w.state, enabled: w.enabled, busy: false });
|
|
62
|
+
for (const t of Object.values(w.events)) owner.set(t, w.key);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const notify = (key: string): void => {
|
|
66
|
+
for (const fn of listeners.get(key) ?? []) fn();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const applyEdit = (key: string, op: EditOp, path: Path, value: unknown): void => {
|
|
70
|
+
const cur = cells.get(key);
|
|
71
|
+
if (!cur) return;
|
|
72
|
+
cells.set(key, { ...cur, state: editAt(cur.state, path, op, value) });
|
|
73
|
+
notify(key);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const setEnabled = (key: string, value: boolean): void => {
|
|
77
|
+
const cur = cells.get(key);
|
|
78
|
+
if (!cur || cur.enabled === value) return;
|
|
79
|
+
cells.set(key, { ...cur, enabled: value });
|
|
80
|
+
notify(key);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const setBusy = (key: string, value: boolean): void => {
|
|
84
|
+
const cur = cells.get(key);
|
|
85
|
+
if (!cur || cur.busy === value) return;
|
|
86
|
+
cells.set(key, { ...cur, busy: value });
|
|
87
|
+
notify(key);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function emit(type: string, payload: unknown): void {
|
|
91
|
+
const src = owner.get(type);
|
|
92
|
+
const c = src ? cells.get(src) : undefined;
|
|
93
|
+
if (c && (c.enabled === false || c.busy)) return; // a disabled or busy widget drops its own input events
|
|
94
|
+
if (serverEvents.has(type)) transport(type, payload);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
emit,
|
|
99
|
+
applyEdit,
|
|
100
|
+
setEnabled,
|
|
101
|
+
setBusy,
|
|
102
|
+
read: (key, path) => readAt(cells.get(key)?.state, path),
|
|
103
|
+
cell: (key) => cells.get(key)!,
|
|
104
|
+
enabled: (key) => cells.get(key)?.enabled !== false,
|
|
105
|
+
subscribe: (key, fn) => {
|
|
106
|
+
const subs = listeners.get(key) ?? new Set<() => void>();
|
|
107
|
+
subs.add(fn);
|
|
108
|
+
listeners.set(key, subs);
|
|
109
|
+
return () => subs.delete(fn);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
@layer tokens, base, components;
|
|
2
|
+
|
|
3
|
+
@layer tokens {
|
|
4
|
+
:root {
|
|
5
|
+
--pu-bg: #f6f7f9;
|
|
6
|
+
--pu-panel: #ffffff;
|
|
7
|
+
--pu-border: #e3e6ea;
|
|
8
|
+
--pu-text: #1c1f23;
|
|
9
|
+
--pu-muted: #6b7280;
|
|
10
|
+
--pu-accent: #2563eb;
|
|
11
|
+
--pu-accent-press: #1d4ed8;
|
|
12
|
+
--pu-radius: 12px;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@layer base {
|
|
17
|
+
* {
|
|
18
|
+
box-sizing: border-box;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
body {
|
|
22
|
+
margin: 0;
|
|
23
|
+
background: var(--pu-bg);
|
|
24
|
+
color: var(--pu-text);
|
|
25
|
+
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.pu-page {
|
|
29
|
+
max-width: 720px;
|
|
30
|
+
margin: 48px auto;
|
|
31
|
+
padding: 28px 28px 32px;
|
|
32
|
+
background: var(--pu-panel);
|
|
33
|
+
border: 1px solid var(--pu-border);
|
|
34
|
+
border-radius: var(--pu-radius);
|
|
35
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 10px 28px rgba(0, 0, 0, 0.05);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.pu-page:has(.pu-layout-row),
|
|
39
|
+
.pu-page:has(.pu-layout-grid) {
|
|
40
|
+
max-width: 1040px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.pu-page h1 {
|
|
44
|
+
margin: 0 0 4px;
|
|
45
|
+
font-size: 24px;
|
|
46
|
+
font-weight: 650;
|
|
47
|
+
letter-spacing: -0.01em;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.pu-desc {
|
|
51
|
+
margin: 0 0 24px;
|
|
52
|
+
color: var(--pu-muted);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* a headless widget (e.g. mic({ control: false })) renders nothing — don't leave a gap */
|
|
56
|
+
.pu-field:empty {
|
|
57
|
+
display: none;
|
|
58
|
+
}
|
|
59
|
+
/* a disabled widget: dimmed and non-interactive (the runtime also drops its input events) */
|
|
60
|
+
.pu-disabled {
|
|
61
|
+
opacity: 0.5;
|
|
62
|
+
pointer-events: none;
|
|
63
|
+
}
|
|
64
|
+
/* a busy widget: a thin indeterminate bar across its top edge (the runtime drops its input events
|
|
65
|
+
while busy). Doesn't dim or cover content, so a chat stays readable while it streams. */
|
|
66
|
+
.pu-busy {
|
|
67
|
+
position: relative;
|
|
68
|
+
}
|
|
69
|
+
.pu-busy-bar {
|
|
70
|
+
position: absolute;
|
|
71
|
+
top: 0;
|
|
72
|
+
left: 0;
|
|
73
|
+
right: 0;
|
|
74
|
+
height: 2px;
|
|
75
|
+
border-radius: 2px;
|
|
76
|
+
background: rgba(37, 99, 235, 0.15);
|
|
77
|
+
overflow: hidden;
|
|
78
|
+
z-index: 2;
|
|
79
|
+
}
|
|
80
|
+
.pu-busy-bar::after {
|
|
81
|
+
content: "";
|
|
82
|
+
position: absolute;
|
|
83
|
+
inset: 0 auto 0 0;
|
|
84
|
+
width: 40%;
|
|
85
|
+
border-radius: 2px;
|
|
86
|
+
background: var(--pu-accent);
|
|
87
|
+
animation: pu-busy-slide 1.1s ease-in-out infinite;
|
|
88
|
+
}
|
|
89
|
+
@keyframes pu-busy-slide {
|
|
90
|
+
from {
|
|
91
|
+
transform: translateX(-100%);
|
|
92
|
+
}
|
|
93
|
+
to {
|
|
94
|
+
transform: translateX(350%);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
@media (prefers-reduced-motion: reduce) {
|
|
98
|
+
.pu-busy-bar::after {
|
|
99
|
+
animation-duration: 2.4s;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.pu-layout {
|
|
104
|
+
display: flex;
|
|
105
|
+
gap: 18px;
|
|
106
|
+
}
|
|
107
|
+
.pu-layout-col {
|
|
108
|
+
flex-direction: column;
|
|
109
|
+
}
|
|
110
|
+
.pu-layout-row {
|
|
111
|
+
flex-direction: row;
|
|
112
|
+
}
|
|
113
|
+
.pu-layout-row > .pu-field {
|
|
114
|
+
flex: 1 1 0;
|
|
115
|
+
min-width: 0;
|
|
116
|
+
}
|
|
117
|
+
.pu-layout-grid {
|
|
118
|
+
display: grid;
|
|
119
|
+
gap: 18px;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@media (max-width: 720px) {
|
|
123
|
+
.pu-page {
|
|
124
|
+
margin: 0;
|
|
125
|
+
border: none;
|
|
126
|
+
border-radius: 0;
|
|
127
|
+
box-shadow: none;
|
|
128
|
+
min-height: 100vh;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { Path } from "../lib/bound.js";
|
|
3
|
+
import type { Emit, Widget } from "../lib/types.js";
|
|
4
|
+
|
|
5
|
+
// A widget's view: a pure React component that renders the widget's structured state, emits the
|
|
6
|
+
// widget's events, and may `set` its own state locally (e.g. an input reflecting what's typed) without
|
|
7
|
+
// a server round-trip. It lives in `client` (DOM-only); the isomorphic widget it renders lives in `lib`.
|
|
8
|
+
export interface ViewProps<W extends Widget<any> = Widget<any>, S = unknown> {
|
|
9
|
+
widget: W;
|
|
10
|
+
state: S;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
emit: Emit;
|
|
13
|
+
set: (path: Path, value: unknown) => void;
|
|
14
|
+
slot: (key: string) => ReactNode;
|
|
15
|
+
}
|
|
16
|
+
export type WidgetView = (props: ViewProps<any, any>) => ReactNode;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.pu-accordion {
|
|
3
|
+
border: 1px solid var(--pu-border, #e3e6ea);
|
|
4
|
+
border-radius: 8px;
|
|
5
|
+
overflow: hidden;
|
|
6
|
+
}
|
|
7
|
+
.pu-accordion-head {
|
|
8
|
+
appearance: none;
|
|
9
|
+
width: 100%;
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
gap: 8px;
|
|
13
|
+
padding: 10px 14px;
|
|
14
|
+
background: #fafbfc;
|
|
15
|
+
border: none;
|
|
16
|
+
font: inherit;
|
|
17
|
+
font-weight: 550;
|
|
18
|
+
text-align: left;
|
|
19
|
+
cursor: pointer;
|
|
20
|
+
}
|
|
21
|
+
.pu-accordion-caret {
|
|
22
|
+
display: inline-block;
|
|
23
|
+
color: var(--pu-muted, #6b7280);
|
|
24
|
+
transition: transform 0.12s;
|
|
25
|
+
}
|
|
26
|
+
.pu-accordion-open .pu-accordion-caret {
|
|
27
|
+
transform: rotate(90deg);
|
|
28
|
+
}
|
|
29
|
+
.pu-accordion-body {
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-direction: column;
|
|
32
|
+
gap: 18px;
|
|
33
|
+
padding: 16px 14px;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import "./accordion.css";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type { ViewProps } from "../../view.js";
|
|
4
|
+
|
|
5
|
+
export function AccordionView({ widget, state, slot }: ViewProps<any, { items: string[] }>) {
|
|
6
|
+
const items = state.items ?? [];
|
|
7
|
+
const [open, setOpen] = useState(widget.open !== false);
|
|
8
|
+
return (
|
|
9
|
+
<div className={`pu-accordion${open ? " pu-accordion-open" : ""}`}>
|
|
10
|
+
<button className="pu-accordion-head" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
|
|
11
|
+
<span className="pu-accordion-caret">▸</span>
|
|
12
|
+
{widget.label as string}
|
|
13
|
+
</button>
|
|
14
|
+
{open && <div className="pu-accordion-body">{items.map((key) => slot(key))}</div>}
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|