@ompchamber/ui 1.0.0

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/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@ompchamber/ui",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Preact hooks and components for OMPChamber panel plugins.",
6
+ "exports": {
7
+ ".": "./src/index.tsx",
8
+ "./components": "./src/components.tsx",
9
+ "./styles.css": "./src/styles.css"
10
+ },
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "dependencies": {
15
+ "preact": "^10.29.8",
16
+ "@ompchamber/plugin-sdk": "1.0.0"
17
+ },
18
+ "license": "UNLICENSED",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/rajebdev/ompchamber.git",
22
+ "directory": "packages/ui"
23
+ },
24
+ "homepage": "https://github.com/rajebdev/ompchamber#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/rajebdev/ompchamber/issues"
27
+ },
28
+ "keywords": [
29
+ "ompchamber",
30
+ "panel",
31
+ "plugin",
32
+ "preact",
33
+ "ui"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "engines": {
39
+ "bun": ">=1.4.0"
40
+ }
41
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The panel UI kit.
8
+ *
9
+ * Components a panel draws with, styled by the CSS variables the host's palette
10
+ * drives. They exist so panels look like the chamber and like each other without
11
+ * each one re-deriving the same markup — a second copy of a labelled field is
12
+ * the first place two panels drift apart.
13
+ *
14
+ * Class names are prefixed `oc-` and the stylesheet ships from the same package
15
+ * (`@ompchamber/ui/styles.css`), so a plugin imports one thing and gets both.
16
+ * Colours come from `var(--oc-*)`, which the host does NOT set: the frame has an
17
+ * opaque origin, so the palette arrives as `data-theme` on `<html>` and the
18
+ * stylesheet maps it. That is why this package carries CSS rather than reading
19
+ * the app's.
20
+ */
21
+
22
+ import type { ComponentChildren } from 'preact';
23
+
24
+ interface PanelProps {
25
+ title: string;
26
+ children: ComponentChildren;
27
+ }
28
+
29
+ /** A panel's outer chrome: a title bar over a scrolling body. */
30
+ export function Panel({ title, children }: PanelProps) {
31
+ return (
32
+ <div class="oc-panel">
33
+ <header class="oc-panel-header">
34
+ <h1 class="oc-panel-title">{title}</h1>
35
+ </header>
36
+ <div class="oc-panel-body">{children}</div>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ interface FieldProps {
42
+ label: string;
43
+ value: string;
44
+ /** Shown under the value, for a path or an id that would otherwise wrap badly. */
45
+ hint?: string;
46
+ }
47
+
48
+ /** One labelled fact. The label is a micro-header, the value is machine text. */
49
+ export function Field({ label, value, hint }: FieldProps) {
50
+ return (
51
+ <div class="oc-field">
52
+ <span class="oc-label">{label}</span>
53
+ <span class="oc-value" title={value}>
54
+ {value}
55
+ </span>
56
+ {hint ? <span class="oc-hint">{hint}</span> : null}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ /** A stack of `Field`s. */
62
+ export function FieldList({ children }: { children: ComponentChildren }) {
63
+ return <div class="oc-fields">{children}</div>;
64
+ }
65
+
66
+ interface TextAreaFieldProps {
67
+ id?: string;
68
+ label: string;
69
+ placeholder?: string;
70
+ /** `null` while the stored value has not been read yet. */
71
+ value: string | null;
72
+ hint?: string;
73
+ onInput: (next: string) => void;
74
+ }
75
+
76
+ /**
77
+ * A labelled textarea.
78
+ *
79
+ * Disabled while `value` is null — that is "the read has not answered yet", and
80
+ * an editable box there would let the user type into a field whose contents are
81
+ * about to be replaced by the stored value.
82
+ */
83
+ export function TextAreaField({ id, label, placeholder, value, hint, onInput }: TextAreaFieldProps) {
84
+ return (
85
+ <div class="oc-textarea-field">
86
+ <label class="oc-label" for={id}>
87
+ {label}
88
+ </label>
89
+ <textarea
90
+ id={id}
91
+ class="oc-textarea"
92
+ placeholder={placeholder}
93
+ value={value ?? ''}
94
+ disabled={value === null}
95
+ onInput={(event) => onInput(event.currentTarget.value)}
96
+ />
97
+ {hint ? <span class="oc-hint">{hint}</span> : null}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ interface ButtonProps {
103
+ children: ComponentChildren;
104
+ onClick: () => void;
105
+ disabled?: boolean;
106
+ variant?: 'default' | 'primary';
107
+ }
108
+
109
+ /** A button. `primary` is the one action a panel wants the user to take. */
110
+ export function Button({ children, onClick, disabled, variant = 'default' }: ButtonProps) {
111
+ return (
112
+ <button type="button" class={`oc-button oc-button-${variant}`} disabled={disabled} onClick={onClick}>
113
+ {children}
114
+ </button>
115
+ );
116
+ }
117
+
118
+ /** Centred placeholder for a panel with nothing to show. */
119
+ export function Empty({ children }: { children: ComponentChildren }) {
120
+ return <p class="oc-empty">{children}</p>;
121
+ }
122
+
123
+ /** A message. `error` is the only chroma a panel should use. */
124
+ export function Note({ children, tone = 'default' }: { children: ComponentChildren; tone?: 'default' | 'error' }) {
125
+ return <p class={`oc-note oc-note-${tone}`}>{children}</p>;
126
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The Preact integration for a panel plugin: one provider, four hooks.
8
+ *
9
+ * A plugin used to hand-roll this — a context, a `ready` await, a theme handler,
10
+ * a debounced session write — which meant every plugin re-derived the same two
11
+ * behaviours that are easy to get wrong:
12
+ *
13
+ * - **The provider owns BOTH bridge subscriptions.** Registering `onTheme` from
14
+ * a component only fires if that component mounts; a panel whose components
15
+ * never called `useTheme()` silently stopped following the palette.
16
+ * - **The context arrives late.** `workspacePath` and `sessionId` are resolved
17
+ * asynchronously by the chamber, so they are usually empty when `ready`
18
+ * settles. A component that captured `info` once would show "none" over a real
19
+ * workspace.
20
+ *
21
+ * Both are handled here so a plugin cannot get them wrong.
22
+ */
23
+
24
+ import { createContext } from 'preact';
25
+ import { useContext, useEffect, useState } from 'preact/hooks';
26
+ import type { ComponentChildren } from 'preact';
27
+ import { acquirePanel, type ChamberPanelApi, type PanelInfo } from '@ompchamber/plugin-sdk';
28
+
29
+ interface PanelContextValue {
30
+ api: ChamberPanelApi;
31
+ info: PanelInfo;
32
+ }
33
+
34
+ const PanelContext = createContext<PanelContextValue | null>(null);
35
+
36
+ function usePanelContext(): PanelContextValue {
37
+ const value = useContext(PanelContext);
38
+ if (!value) throw new Error('Panel hooks must be used inside <PanelProvider>');
39
+ return value;
40
+ }
41
+
42
+ /**
43
+ * The live panel info.
44
+ *
45
+ * Read through a hook rather than off the context directly so a component
46
+ * re-renders when the workspace arrives.
47
+ */
48
+ export function usePanelInfo(): PanelInfo {
49
+ return usePanelContext().info;
50
+ }
51
+
52
+ /** The bridge itself, for `api.call(...)`. */
53
+ export function usePanelApi(): ChamberPanelApi {
54
+ return usePanelContext().api;
55
+ }
56
+
57
+ /** The live palette id. */
58
+ export function useTheme(): string {
59
+ return usePanelInfo().theme;
60
+ }
61
+
62
+ export interface SessionValue {
63
+ /** `null` until the read answers — the field should be disabled, not empty. */
64
+ value: string | null;
65
+ status: 'loading' | 'idle' | 'saving' | 'saved' | 'error';
66
+ error: string | null;
67
+ update: (next: string) => void;
68
+ }
69
+
70
+ /**
71
+ * Per-session state, as a `useState` that persists.
72
+ *
73
+ * The value lives in the session store, not in the frame: the frame is destroyed
74
+ * whenever the panel is hidden, so local state would be lost on every tab
75
+ * switch. Reads once on mount, writes debounced — `input` fires per keystroke,
76
+ * and each one would otherwise be a postMessage round trip.
77
+ */
78
+ export function useSessionValue(key: string, delayMs = 400): SessionValue {
79
+ const api = usePanelApi();
80
+ const [value, setValue] = useState<string | null>(null);
81
+ const [status, setStatus] = useState<SessionValue['status']>('loading');
82
+ const [error, setError] = useState<string | null>(null);
83
+
84
+ useEffect(() => {
85
+ let cancelled = false;
86
+ setStatus('loading');
87
+ api.call<string | null>('sessionState.get', { key }).then(
88
+ (stored) => {
89
+ if (cancelled) return;
90
+ setValue(stored ?? '');
91
+ setStatus('idle');
92
+ },
93
+ (cause: unknown) => {
94
+ if (cancelled) return;
95
+ setError(cause instanceof Error ? cause.message : String(cause));
96
+ setStatus('error');
97
+ },
98
+ );
99
+ return () => {
100
+ cancelled = true;
101
+ };
102
+ }, [api, key]);
103
+
104
+ useEffect(() => {
105
+ if (value === null || status !== 'saving') return;
106
+ const timer = setTimeout(async () => {
107
+ try {
108
+ await api.call('sessionState.set', { key, value });
109
+ setStatus('saved');
110
+ } catch (cause) {
111
+ setError(cause instanceof Error ? cause.message : String(cause));
112
+ setStatus('error');
113
+ }
114
+ }, delayMs);
115
+ return () => clearTimeout(timer);
116
+ }, [api, key, value, status, delayMs]);
117
+
118
+ return {
119
+ value,
120
+ status,
121
+ error,
122
+ update: (next: string) => {
123
+ setValue(next);
124
+ setStatus('saving');
125
+ },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Mount a panel, rendering `children` once the host has answered.
131
+ *
132
+ * Renders nothing until `ready` resolves: the panel has no data to draw before
133
+ * then, and a half-drawn first frame is worse than none.
134
+ */
135
+ export function PanelProvider({ children }: { children: ComponentChildren }) {
136
+ const [value, setValue] = useState<PanelContextValue | null>(null);
137
+ const [error, setError] = useState<string | null>(null);
138
+
139
+ useEffect(() => {
140
+ let cancelled = false;
141
+ let api: ChamberPanelApi;
142
+ try {
143
+ api = acquirePanel();
144
+ } catch (cause) {
145
+ setError(cause instanceof Error ? cause.message : String(cause));
146
+ return;
147
+ }
148
+
149
+ /** Apply a palette id to the document, which is what `:root[data-theme]` keys off. */
150
+ const applyTheme = (theme: string) => document.documentElement.setAttribute('data-theme', theme);
151
+
152
+ api.ready.then(
153
+ (info) => {
154
+ if (cancelled) return;
155
+ applyTheme(info.theme);
156
+ api.onTheme = (theme) => {
157
+ applyTheme(theme);
158
+ setValue((current) => (current ? { ...current, info: { ...current.info, theme } } : current));
159
+ };
160
+ api.onContext = (next) => {
161
+ if (next.theme) applyTheme(next.theme);
162
+ setValue((current) => (current ? { ...current, info: { ...current.info, ...next } } : current));
163
+ };
164
+ setValue({ api, info });
165
+ },
166
+ (cause: unknown) => {
167
+ if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause));
168
+ },
169
+ );
170
+
171
+ return () => {
172
+ cancelled = true;
173
+ api.onTheme = undefined;
174
+ api.onContext = undefined;
175
+ };
176
+ }, []);
177
+
178
+ if (error) return <p class="oc-note oc-note-error">Could not attach to the chamber: {error}</p>;
179
+ if (!value) return null;
180
+ return <PanelContext.Provider value={value}>{children}</PanelContext.Provider>;
181
+ }
package/src/styles.css ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The UI kit's stylesheet.
3
+ *
4
+ * The frame has an opaque origin, so it cannot reach the chamber's stylesheet —
5
+ * the palette arrives as `data-theme` on `<html>` (set by `PanelProvider`) and
6
+ * this file maps it. That is the whole reason a kit ships CSS: without it, every
7
+ * panel would invent its own colours and drift from the app.
8
+ *
9
+ * The palette ids are the two the chamber ships most often. A plugin may add a
10
+ * block for a theme it wants to support; an unknown id leaves the defaults,
11
+ * which is a readable fallback rather than a broken one.
12
+ */
13
+
14
+ :root {
15
+ --oc-bg: #faf8f3;
16
+ --oc-paper: #ffffff;
17
+ --oc-ink: #141310;
18
+ --oc-muted: rgba(20, 19, 16, 0.55);
19
+ --oc-faint: rgba(20, 19, 16, 0.38);
20
+ --oc-line: rgba(20, 19, 16, 0.14);
21
+ --oc-error: #b3261e;
22
+ }
23
+
24
+ :root[data-theme='one-dark-pro-soft'] {
25
+ --oc-bg: #21252b;
26
+ --oc-paper: #282c34;
27
+ --oc-ink: #d7dae0;
28
+ --oc-muted: rgba(215, 218, 224, 0.6);
29
+ --oc-faint: rgba(215, 218, 224, 0.42);
30
+ --oc-line: rgba(215, 218, 224, 0.16);
31
+ --oc-error: #e06c75;
32
+ }
33
+
34
+ html,
35
+ body {
36
+ margin: 0;
37
+ height: 100%;
38
+ }
39
+
40
+ body {
41
+ background: var(--oc-bg);
42
+ color: var(--oc-ink);
43
+ font-family: system-ui, -apple-system, sans-serif;
44
+ font-size: 13px;
45
+ }
46
+
47
+ /* Panel chrome ------------------------------------------------------------ */
48
+
49
+ .oc-panel {
50
+ display: flex;
51
+ flex-direction: column;
52
+ height: 100%;
53
+ }
54
+
55
+ .oc-panel-header {
56
+ padding: 12px 14px;
57
+ border-bottom: 1px solid var(--oc-line);
58
+ flex-shrink: 0;
59
+ }
60
+
61
+ .oc-panel-title {
62
+ margin: 0;
63
+ font-size: 13px;
64
+ font-weight: 600;
65
+ }
66
+
67
+ .oc-panel-body {
68
+ flex: 1;
69
+ min-height: 0;
70
+ display: flex;
71
+ flex-direction: column;
72
+ gap: 12px;
73
+ padding: 12px 14px 14px;
74
+ overflow: auto;
75
+ }
76
+
77
+ /* Fields ------------------------------------------------------------------ */
78
+
79
+ .oc-fields {
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: 8px;
83
+ }
84
+
85
+ .oc-field {
86
+ display: flex;
87
+ flex-direction: column;
88
+ gap: 2px;
89
+ }
90
+
91
+ .oc-label {
92
+ font-size: 10px;
93
+ text-transform: uppercase;
94
+ letter-spacing: 0.06em;
95
+ color: var(--oc-muted);
96
+ font-family: ui-monospace, monospace;
97
+ }
98
+
99
+ .oc-value {
100
+ font-family: ui-monospace, monospace;
101
+ font-size: 12px;
102
+ word-break: break-all;
103
+ }
104
+
105
+ .oc-hint {
106
+ font-size: 11px;
107
+ color: var(--oc-muted);
108
+ }
109
+
110
+ /* Text field -------------------------------------------------------------- */
111
+
112
+ .oc-textarea-field {
113
+ flex: 1;
114
+ min-height: 0;
115
+ display: flex;
116
+ flex-direction: column;
117
+ gap: 6px;
118
+ }
119
+
120
+ .oc-textarea {
121
+ flex: 1;
122
+ min-height: 80px;
123
+ resize: none;
124
+ border: 1px solid var(--oc-line);
125
+ border-radius: 6px;
126
+ padding: 8px;
127
+ font: 12px/1.6 ui-monospace, monospace;
128
+ background: transparent;
129
+ color: inherit;
130
+ outline: none;
131
+ }
132
+
133
+ .oc-textarea:focus {
134
+ border-color: var(--oc-ink);
135
+ }
136
+
137
+ .oc-textarea:disabled {
138
+ opacity: 0.5;
139
+ }
140
+
141
+ /* Button ------------------------------------------------------------------ */
142
+
143
+ .oc-button {
144
+ font: inherit;
145
+ font-size: 12px;
146
+ padding: 5px 10px;
147
+ border-radius: 6px;
148
+ border: 1px solid var(--oc-line);
149
+ background: transparent;
150
+ color: inherit;
151
+ cursor: pointer;
152
+ }
153
+
154
+ .oc-button:hover:not(:disabled) {
155
+ background: color-mix(in srgb, var(--oc-ink) 6%, transparent);
156
+ }
157
+
158
+ .oc-button:disabled {
159
+ opacity: 0.45;
160
+ cursor: default;
161
+ }
162
+
163
+ .oc-button-primary {
164
+ background: var(--oc-ink);
165
+ color: var(--oc-bg);
166
+ border-color: var(--oc-ink);
167
+ }
168
+
169
+ /* Messages ---------------------------------------------------------------- */
170
+
171
+ .oc-empty {
172
+ margin: 0;
173
+ padding: 24px 8px;
174
+ text-align: center;
175
+ font-size: 12px;
176
+ color: var(--oc-faint);
177
+ }
178
+
179
+ .oc-note {
180
+ margin: 0;
181
+ font-size: 11px;
182
+ color: var(--oc-muted);
183
+ }
184
+
185
+ .oc-note-error {
186
+ color: var(--oc-error);
187
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Type declaration for `@ompchamber/ui/styles.css`.
3
+ *
4
+ * TypeScript will not accept a side-effect import of a `.css` file without a
5
+ * declaration beside it, and the error (`TS2882: Cannot find module or type
6
+ * declarations for side-effect import`) is the first thing a plugin author would
7
+ * hit. This is the shape TypeScript looks for — `styles.css` resolves to
8
+ * `styles.d.css.ts` — and it requires `allowArbitraryExtensions: true` in the
9
+ * consumer's tsconfig, which the plugin tsconfig in the docs sets.
10
+ *
11
+ * It is exported rather than ambient so the file is a module, which is what
12
+ * `allowArbitraryExtensions` expects; the stylesheet itself is the payload, and
13
+ * nothing is imported from here at runtime.
14
+ */
15
+
16
+ export {};