@ompchamber/ui 1.0.0 → 2.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/package.json +3 -4
- package/src/components.tsx +54 -42
- package/src/index.tsx +156 -112
- package/src/session-value.test.tsx +162 -0
- package/src/styles.css +0 -187
- package/src/styles.d.css.ts +0 -16
package/package.json
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ompchamber/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Preact hooks and components for OMPChamber panel plugins.",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.tsx",
|
|
8
|
-
"./components": "./src/components.tsx"
|
|
9
|
-
"./styles.css": "./src/styles.css"
|
|
8
|
+
"./components": "./src/components.tsx"
|
|
10
9
|
},
|
|
11
10
|
"files": [
|
|
12
11
|
"src"
|
|
13
12
|
],
|
|
14
13
|
"dependencies": {
|
|
15
14
|
"preact": "^10.29.8",
|
|
16
|
-
"@ompchamber/plugin-sdk": "
|
|
15
|
+
"@ompchamber/plugin-sdk": "2.0.0"
|
|
17
16
|
},
|
|
18
17
|
"license": "UNLICENSED",
|
|
19
18
|
"repository": {
|
package/src/components.tsx
CHANGED
|
@@ -6,17 +6,19 @@
|
|
|
6
6
|
/**
|
|
7
7
|
* The panel UI kit.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* These are ordinary host components, not a sandboxed mini-app's widgets: a
|
|
10
|
+
* plugin renders inside the chamber's own tree, so the kit uses the chamber's
|
|
11
|
+
* theme variables and Tailwind utilities directly and looks native in every
|
|
12
|
+
* palette with no CSS bridge.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* the
|
|
14
|
+
* The kit exists so a plugin does not re-invent the same six shapes — a titled
|
|
15
|
+
* panel, a label/value row, a text field, a button, an empty state, a note —
|
|
16
|
+
* and so those shapes stay consistent across plugins the way they are within
|
|
17
|
+
* the chamber itself.
|
|
18
|
+
*
|
|
19
|
+
* Every component takes its colours from the theme variables (`bg-paper`,
|
|
20
|
+
* `text-ink`, `border-ink/10`), never a literal, which is what keeps a plugin
|
|
21
|
+
* legible in the light and dark palettes without knowing they exist.
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
24
|
import type { ComponentChildren } from 'preact';
|
|
@@ -29,11 +31,11 @@ interface PanelProps {
|
|
|
29
31
|
/** A panel's outer chrome: a title bar over a scrolling body. */
|
|
30
32
|
export function Panel({ title, children }: PanelProps) {
|
|
31
33
|
return (
|
|
32
|
-
<div class="
|
|
33
|
-
<
|
|
34
|
-
<
|
|
35
|
-
</
|
|
36
|
-
<div class="
|
|
34
|
+
<div class="flex flex-col h-full w-full bg-paper text-ink">
|
|
35
|
+
<div class="flex-shrink-0 border-b border-ink/10 px-3 py-2">
|
|
36
|
+
<span class="text-xs font-semibold text-ink">{title}</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="flex-1 min-h-0 overflow-auto p-3">{children}</div>
|
|
37
39
|
</div>
|
|
38
40
|
);
|
|
39
41
|
}
|
|
@@ -41,60 +43,55 @@ export function Panel({ title, children }: PanelProps) {
|
|
|
41
43
|
interface FieldProps {
|
|
42
44
|
label: string;
|
|
43
45
|
value: string;
|
|
44
|
-
/** Shown under the value, for a path or an id that would otherwise wrap badly. */
|
|
45
46
|
hint?: string;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/** One labelled fact. The label is a micro-header, the value is machine text. */
|
|
49
50
|
export function Field({ label, value, hint }: FieldProps) {
|
|
50
51
|
return (
|
|
51
|
-
<div class="
|
|
52
|
-
<span class="
|
|
53
|
-
<span class="
|
|
54
|
-
|
|
55
|
-
</span>
|
|
56
|
-
{hint ? <span class="oc-hint">{hint}</span> : null}
|
|
52
|
+
<div class="flex flex-col gap-0.5">
|
|
53
|
+
<span class="text-[10px] uppercase font-mono text-ink/40">{label}</span>
|
|
54
|
+
<span class="text-xs font-mono text-ink break-all">{value}</span>
|
|
55
|
+
{hint ? <span class="text-[11px] text-ink/50">{hint}</span> : null}
|
|
57
56
|
</div>
|
|
58
57
|
);
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
/** A stack of `Field`s. */
|
|
62
61
|
export function FieldList({ children }: { children: ComponentChildren }) {
|
|
63
|
-
return <div class="
|
|
62
|
+
return <div class="flex flex-col gap-3">{children}</div>;
|
|
64
63
|
}
|
|
65
64
|
|
|
66
65
|
interface TextAreaFieldProps {
|
|
67
|
-
id
|
|
66
|
+
id: string;
|
|
68
67
|
label: string;
|
|
68
|
+
value: string;
|
|
69
69
|
placeholder?: string;
|
|
70
|
-
/** `null` while the stored value has not been read yet. */
|
|
71
|
-
value: string | null;
|
|
72
70
|
hint?: string;
|
|
73
|
-
onInput: (
|
|
71
|
+
onInput: (value: string) => void;
|
|
74
72
|
}
|
|
75
73
|
|
|
76
74
|
/**
|
|
77
75
|
* A labelled textarea.
|
|
78
76
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
77
|
+
* Controlled through `onInput` rather than owning its own state, so the caller
|
|
78
|
+
* decides what a keystroke means — the session-persisting hook debounces the
|
|
79
|
+
* write, while a purely local field can keep the value in the caller.
|
|
82
80
|
*/
|
|
83
|
-
export function TextAreaField({ id, label,
|
|
81
|
+
export function TextAreaField({ id, label, value, placeholder, hint, onInput }: TextAreaFieldProps) {
|
|
84
82
|
return (
|
|
85
|
-
<div class="
|
|
86
|
-
<label class="
|
|
83
|
+
<div class="flex flex-col gap-1">
|
|
84
|
+
<label for={id} class="text-[10px] uppercase font-mono text-ink/40">
|
|
87
85
|
{label}
|
|
88
86
|
</label>
|
|
89
87
|
<textarea
|
|
90
88
|
id={id}
|
|
91
|
-
|
|
89
|
+
value={value}
|
|
92
90
|
placeholder={placeholder}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
onInput={(event) => onInput(event.currentTarget.value)}
|
|
91
|
+
onInput={(event) => onInput((event.currentTarget as HTMLTextAreaElement).value)}
|
|
92
|
+
class="w-full min-h-20 resize-y bg-canvas border border-ink/15 rounded px-2.5 py-1.5 text-xs font-mono text-ink placeholder-ink/35 focus:outline-none focus:border-ink/40"
|
|
96
93
|
/>
|
|
97
|
-
{hint ? <span class="
|
|
94
|
+
{hint ? <span class="text-[11px] text-ink/50">{hint}</span> : null}
|
|
98
95
|
</div>
|
|
99
96
|
);
|
|
100
97
|
}
|
|
@@ -103,13 +100,23 @@ interface ButtonProps {
|
|
|
103
100
|
children: ComponentChildren;
|
|
104
101
|
onClick: () => void;
|
|
105
102
|
disabled?: boolean;
|
|
103
|
+
/** The one action a panel wants the user to take. */
|
|
106
104
|
variant?: 'default' | 'primary';
|
|
107
105
|
}
|
|
108
106
|
|
|
109
|
-
/** A button.
|
|
107
|
+
/** A button. */
|
|
110
108
|
export function Button({ children, onClick, disabled, variant = 'default' }: ButtonProps) {
|
|
109
|
+
const tone =
|
|
110
|
+
variant === 'primary'
|
|
111
|
+
? 'bg-ink text-canvas border-ink hover:opacity-90'
|
|
112
|
+
: 'border-ink/15 hover:bg-ink/5 text-ink';
|
|
111
113
|
return (
|
|
112
|
-
<button
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={onClick}
|
|
117
|
+
disabled={disabled}
|
|
118
|
+
class={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded border transition-colors disabled:opacity-40 disabled:cursor-default ${tone}`}
|
|
119
|
+
>
|
|
113
120
|
{children}
|
|
114
121
|
</button>
|
|
115
122
|
);
|
|
@@ -117,10 +124,15 @@ export function Button({ children, onClick, disabled, variant = 'default' }: But
|
|
|
117
124
|
|
|
118
125
|
/** Centred placeholder for a panel with nothing to show. */
|
|
119
126
|
export function Empty({ children }: { children: ComponentChildren }) {
|
|
120
|
-
return
|
|
127
|
+
return (
|
|
128
|
+
<div class="h-full flex items-center justify-center px-6 text-center">
|
|
129
|
+
<p class="text-xs text-ink/45 font-mono">{children}</p>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
121
132
|
}
|
|
122
133
|
|
|
123
134
|
/** A message. `error` is the only chroma a panel should use. */
|
|
124
135
|
export function Note({ children, tone = 'default' }: { children: ComponentChildren; tone?: 'default' | 'error' }) {
|
|
125
|
-
|
|
136
|
+
const colour = tone === 'error' ? 'text-error' : 'text-ink/60';
|
|
137
|
+
return <p class={`text-[11px] ${colour}`}>{children}</p>;
|
|
126
138
|
}
|
package/src/index.tsx
CHANGED
|
@@ -4,54 +4,100 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* The
|
|
7
|
+
* The UI kit a plugin renders with, and the host services it reads through.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Everything here runs IN THE HOST'S TREE — same Preact instance, same
|
|
10
|
+
* document, same theme — so the kit is plain Preact components, not a
|
|
11
|
+
* message-passing client. What it still must not do is reach into the host's
|
|
12
|
+
* own source: this is a published package, so the host INJECTS its services
|
|
13
|
+
* once at boot via `configureUiKit`, and the hooks read them.
|
|
12
14
|
*
|
|
13
|
-
*
|
|
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.
|
|
15
|
+
* Two rules the hooks exist to enforce, because a plugin gets them wrong:
|
|
20
16
|
*
|
|
21
|
-
*
|
|
17
|
+
* - **Context arrives late.** The active session and workspace are resolved
|
|
18
|
+
* asynchronously, so a component that captured them once would show "none"
|
|
19
|
+
* over a real workspace. Every hook subscribes and re-renders.
|
|
20
|
+
* - **Session state is not component state.** The component is unmounted when
|
|
21
|
+
* its panel is hidden, so a value kept in `useState` alone would be lost on
|
|
22
|
+
* every tab switch. `useSessionValue` reads the chamber's store.
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
|
-
import {
|
|
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';
|
|
25
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
/** What the host seeds every plugin component with. */
|
|
28
|
+
export interface PanelContext {
|
|
29
|
+
sessionId: string | null;
|
|
30
|
+
workspacePath: string | null;
|
|
31
|
+
theme: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The host's own services, injected once.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately narrow: each field is something a plugin genuinely cannot do
|
|
38
|
+
* itself (read the chamber's per-session store, know the live palette, resolve
|
|
39
|
+
* the active workspace). Anything a plugin could read from the DOM it should.
|
|
40
|
+
*/
|
|
41
|
+
export interface UiKitServices {
|
|
42
|
+
/** The current panel context. */
|
|
43
|
+
context(): PanelContext;
|
|
44
|
+
/** Subscribe to context changes (a new session, a palette switch). */
|
|
45
|
+
subscribe(listener: (context: PanelContext) => void): () => void;
|
|
46
|
+
/** Read one per-session value. */
|
|
47
|
+
getSessionValue(sessionId: string | null, key: string): string | null;
|
|
48
|
+
/** Write one per-session value. */
|
|
49
|
+
setSessionValue(sessionId: string | null, key: string, value: string): void;
|
|
50
|
+
/**
|
|
51
|
+
* Subscribe to writes of ONE per-session value, from any component.
|
|
52
|
+
*
|
|
53
|
+
* Optional so a host built before this seam still runs a newer kit — the
|
|
54
|
+
* hook degrades to read-on-mount, which is what every host did.
|
|
55
|
+
*
|
|
56
|
+
* It exists because `getSessionValue` alone makes two readers of one key
|
|
57
|
+
* disagree: a component reads the store once and then holds its own copy, so
|
|
58
|
+
* a field that WRITES a value and a readout that DISPLAYS it are two private
|
|
59
|
+
* copies of the same fact, and the readout keeps the value it read at mount
|
|
60
|
+
* forever. That is not a stale pixel — it is the plugin's own UI contradicting
|
|
61
|
+
* itself, which is exactly what the bundled example does (a note field and a
|
|
62
|
+
* character count of that note).
|
|
63
|
+
*/
|
|
64
|
+
subscribeSessionValue?(sessionId: string | null, key: string, listener: () => void): () => void;
|
|
65
|
+
/** Read a text file inside the active workspace. Rejects with the reason. */
|
|
66
|
+
readWorkspaceFile(workspacePath: string | null, relativePath: string): Promise<string>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let services: UiKitServices | null = null;
|
|
35
70
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
71
|
+
/** Called ONCE by the host at boot. A plugin must never call this. */
|
|
72
|
+
export function configureUiKit(next: UiKitServices): void {
|
|
73
|
+
services = next;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function requireServices(): UiKitServices {
|
|
77
|
+
if (!services) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'The OMPChamber UI kit has no host services — this component must be rendered by the OMPChamber app.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return services;
|
|
40
83
|
}
|
|
41
84
|
|
|
42
85
|
/**
|
|
43
|
-
* The live panel
|
|
86
|
+
* The live panel context, re-rendering on every change.
|
|
44
87
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
88
|
+
* `useState` is seeded from the service and updated by its subscription, which
|
|
89
|
+
* is what makes a workspace that arrives after the first paint show up.
|
|
47
90
|
*/
|
|
48
|
-
export function usePanelInfo():
|
|
49
|
-
|
|
50
|
-
}
|
|
91
|
+
export function usePanelInfo(): PanelContext {
|
|
92
|
+
const [context, setContext] = useState<PanelContext>(() => requireServices().context());
|
|
51
93
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const api = requireServices();
|
|
96
|
+
setContext(api.context());
|
|
97
|
+
return api.subscribe(setContext);
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
return context;
|
|
55
101
|
}
|
|
56
102
|
|
|
57
103
|
/** The live palette id. */
|
|
@@ -70,42 +116,57 @@ export interface SessionValue {
|
|
|
70
116
|
/**
|
|
71
117
|
* Per-session state, as a `useState` that persists.
|
|
72
118
|
*
|
|
73
|
-
* The
|
|
74
|
-
*
|
|
75
|
-
* switch. Reads once on mount, writes debounced — `input` fires per keystroke,
|
|
76
|
-
* and each one would otherwise be a postMessage round trip.
|
|
119
|
+
* The write is debounced: `onInput` fires per keystroke, and each one would
|
|
120
|
+
* otherwise be a store write and a request.
|
|
77
121
|
*/
|
|
78
122
|
export function useSessionValue(key: string, delayMs = 400): SessionValue {
|
|
79
|
-
const
|
|
123
|
+
const { sessionId } = usePanelInfo();
|
|
80
124
|
const [value, setValue] = useState<string | null>(null);
|
|
81
125
|
const [status, setStatus] = useState<SessionValue['status']>('loading');
|
|
82
126
|
const [error, setError] = useState<string | null>(null);
|
|
127
|
+
// Distinguishes "this component's own edit" from "someone else wrote the
|
|
128
|
+
// slot". Only the former may set `saving`, or an unrelated write would mark a
|
|
129
|
+
// field dirty and write it straight back.
|
|
130
|
+
const editing = useRef(false);
|
|
131
|
+
|
|
132
|
+
const read = useCallback((): string => {
|
|
133
|
+
try {
|
|
134
|
+
const stored = requireServices().getSessionValue(sessionId, key) ?? '';
|
|
135
|
+
setError(null);
|
|
136
|
+
return stored;
|
|
137
|
+
} catch (cause) {
|
|
138
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
139
|
+
setStatus('error');
|
|
140
|
+
return '';
|
|
141
|
+
}
|
|
142
|
+
}, [sessionId, key]);
|
|
83
143
|
|
|
84
144
|
useEffect(() => {
|
|
85
|
-
|
|
145
|
+
editing.current = false;
|
|
86
146
|
setStatus('loading');
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
};
|
|
102
|
-
}, [
|
|
147
|
+
setValue(read());
|
|
148
|
+
setStatus((current) => (current === 'error' ? current : 'idle'));
|
|
149
|
+
}, [read]);
|
|
150
|
+
|
|
151
|
+
// Follow writes from anywhere else — the header readout showing a note's
|
|
152
|
+
// length, a second view of the same value. Skipped while THIS component is
|
|
153
|
+
// mid-edit, because its own state is the newer truth until the debounce
|
|
154
|
+
// lands.
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const api = requireServices();
|
|
157
|
+
if (!api.subscribeSessionValue) return;
|
|
158
|
+
return api.subscribeSessionValue(sessionId, key, () => {
|
|
159
|
+
if (editing.current) return;
|
|
160
|
+
setValue(read());
|
|
161
|
+
});
|
|
162
|
+
}, [sessionId, key, read]);
|
|
103
163
|
|
|
104
164
|
useEffect(() => {
|
|
105
165
|
if (value === null || status !== 'saving') return;
|
|
106
|
-
const timer = setTimeout(
|
|
166
|
+
const timer = setTimeout(() => {
|
|
107
167
|
try {
|
|
108
|
-
|
|
168
|
+
requireServices().setSessionValue(sessionId, key, value);
|
|
169
|
+
editing.current = false;
|
|
109
170
|
setStatus('saved');
|
|
110
171
|
} catch (cause) {
|
|
111
172
|
setError(cause instanceof Error ? cause.message : String(cause));
|
|
@@ -113,69 +174,52 @@ export function useSessionValue(key: string, delayMs = 400): SessionValue {
|
|
|
113
174
|
}
|
|
114
175
|
}, delayMs);
|
|
115
176
|
return () => clearTimeout(timer);
|
|
116
|
-
}, [
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
},
|
|
126
|
-
};
|
|
177
|
+
}, [sessionId, key, value, status, delayMs]);
|
|
178
|
+
|
|
179
|
+
const update = useCallback((next: string) => {
|
|
180
|
+
editing.current = true;
|
|
181
|
+
setValue(next);
|
|
182
|
+
setStatus('saving');
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
return { value, status, error, update };
|
|
127
186
|
}
|
|
128
187
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
188
|
+
export interface WorkspaceFile {
|
|
189
|
+
content: string | null;
|
|
190
|
+
loading: boolean;
|
|
191
|
+
error: string | null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Read one text file inside the active workspace. */
|
|
195
|
+
export function useWorkspaceFile(relativePath: string): WorkspaceFile {
|
|
196
|
+
const { workspacePath } = usePanelInfo();
|
|
197
|
+
const [content, setContent] = useState<string | null>(null);
|
|
198
|
+
const [loading, setLoading] = useState(true);
|
|
137
199
|
const [error, setError] = useState<string | null>(null);
|
|
138
200
|
|
|
139
201
|
useEffect(() => {
|
|
140
202
|
let cancelled = false;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
203
|
+
setLoading(true);
|
|
204
|
+
setError(null);
|
|
205
|
+
requireServices()
|
|
206
|
+
.readWorkspaceFile(workspacePath, relativePath)
|
|
207
|
+
.then(
|
|
208
|
+
(text) => {
|
|
209
|
+
if (cancelled) return;
|
|
210
|
+
setContent(text);
|
|
211
|
+
setLoading(false);
|
|
212
|
+
},
|
|
213
|
+
(cause: unknown) => {
|
|
214
|
+
if (cancelled) return;
|
|
215
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
216
|
+
setLoading(false);
|
|
217
|
+
},
|
|
218
|
+
);
|
|
171
219
|
return () => {
|
|
172
220
|
cancelled = true;
|
|
173
|
-
api.onTheme = undefined;
|
|
174
|
-
api.onContext = undefined;
|
|
175
221
|
};
|
|
176
|
-
}, []);
|
|
222
|
+
}, [workspacePath, relativePath]);
|
|
177
223
|
|
|
178
|
-
|
|
179
|
-
if (!value) return null;
|
|
180
|
-
return <PanelContext.Provider value={value}>{children}</PanelContext.Provider>;
|
|
224
|
+
return { content, loading, error };
|
|
181
225
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `useSessionValue` and the components that read the same slot.
|
|
8
|
+
*
|
|
9
|
+
* The defect this pins: the hook read the store ONCE per mount and then held a
|
|
10
|
+
* private copy, so two components reading one key never saw each other's
|
|
11
|
+
* writes. A field that WRITES a value and a readout that DISPLAYS it therefore
|
|
12
|
+
* disagreed permanently — the readout kept whatever it read at mount while the
|
|
13
|
+
* field showed what the user had typed. That is the bundled example plugin's
|
|
14
|
+
* own shape (a note field and a character count of that note), so the plugin
|
|
15
|
+
* contradicted itself on screen.
|
|
16
|
+
*
|
|
17
|
+
* Two rules the fix must keep:
|
|
18
|
+
*
|
|
19
|
+
* - a write by ANOTHER component re-reads the slot and re-renders;
|
|
20
|
+
* - a write by THIS component does NOT: its own state is the newer truth until
|
|
21
|
+
* the debounce lands, and re-reading would fight the user's keystrokes.
|
|
22
|
+
*
|
|
23
|
+
* Rendered with `h()` (no JSX) against happy-dom, with a fake services seam —
|
|
24
|
+
* the kit is a published package and must not reach into the host's source.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
28
|
+
import { Window } from 'happy-dom';
|
|
29
|
+
import { h, render } from 'preact';
|
|
30
|
+
import { act } from 'preact/test-utils';
|
|
31
|
+
import { configureUiKit, useSessionValue, type PanelContext, type UiKitServices } from '@ompchamber/ui';
|
|
32
|
+
|
|
33
|
+
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
|
|
34
|
+
const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
35
|
+
|
|
36
|
+
const CONTEXT: PanelContext = { sessionId: 's1', workspacePath: '/ws', theme: 'paper' };
|
|
37
|
+
|
|
38
|
+
/** The store behind the fake seam: one slot per session+key, as the host has. */
|
|
39
|
+
function makeServices() {
|
|
40
|
+
const store = new Map<string, string>();
|
|
41
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
42
|
+
const slotKey = (key: string) => `${CONTEXT.sessionId}:${key}`;
|
|
43
|
+
|
|
44
|
+
const services: UiKitServices = {
|
|
45
|
+
context: () => CONTEXT,
|
|
46
|
+
subscribe: () => () => {},
|
|
47
|
+
getSessionValue: (_sessionId, key) => store.get(slotKey(key)) ?? null,
|
|
48
|
+
setSessionValue: (_sessionId, key, value) => {
|
|
49
|
+
store.set(slotKey(key), value);
|
|
50
|
+
for (const listener of [...(listeners.get(slotKey(key)) ?? [])]) listener();
|
|
51
|
+
},
|
|
52
|
+
subscribeSessionValue: (_sessionId, key, listener) => {
|
|
53
|
+
const bucket = listeners.get(slotKey(key)) ?? new Set();
|
|
54
|
+
bucket.add(listener);
|
|
55
|
+
listeners.set(slotKey(key), bucket);
|
|
56
|
+
return () => bucket.delete(listener);
|
|
57
|
+
},
|
|
58
|
+
readWorkspaceFile: async () => '',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** What a DIFFERENT component's write looks like, reaching every reader. */
|
|
62
|
+
const externalWrite = (key: string, value: string) => services.setSessionValue(CONTEXT.sessionId, key, value);
|
|
63
|
+
|
|
64
|
+
return { services, store, externalWrite };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let container: HTMLElement;
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
const win = new Window({ url: 'http://localhost' });
|
|
71
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
72
|
+
for (const key of DOM_GLOBALS) {
|
|
73
|
+
if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
|
|
74
|
+
target[key] = (win as unknown as Record<string, unknown>)[key];
|
|
75
|
+
}
|
|
76
|
+
container = document.createElement('div') as unknown as HTMLElement;
|
|
77
|
+
document.body.appendChild(container as never);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
render(null, container);
|
|
82
|
+
container.remove();
|
|
83
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
84
|
+
for (const key of DOM_GLOBALS) {
|
|
85
|
+
if (key in nativeGlobals) target[key] = nativeGlobals[key];
|
|
86
|
+
else delete target[key];
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('useSessionValue', () => {
|
|
91
|
+
test('a readout follows a write made by ANOTHER component', async () => {
|
|
92
|
+
const { services, externalWrite } = makeServices();
|
|
93
|
+
configureUiKit(services);
|
|
94
|
+
|
|
95
|
+
/** Reads the slot without ever writing it — the readout's shape. */
|
|
96
|
+
function Readout() {
|
|
97
|
+
const { value } = useSessionValue('note', 10);
|
|
98
|
+
return h('span', { id: 'readout' }, String((value ?? '').length));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await act(async () => {
|
|
102
|
+
render(h(Readout, {}), container);
|
|
103
|
+
});
|
|
104
|
+
expect(container.textContent).toBe('0');
|
|
105
|
+
|
|
106
|
+
// The field component writes; the readout must catch up.
|
|
107
|
+
await act(async () => {
|
|
108
|
+
externalWrite('note', 'hello');
|
|
109
|
+
});
|
|
110
|
+
expect(container.textContent).toBe('5');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('a component mid-edit keeps its own text until the debounce lands', async () => {
|
|
114
|
+
const { services } = makeServices();
|
|
115
|
+
configureUiKit(services);
|
|
116
|
+
|
|
117
|
+
function Field() {
|
|
118
|
+
const { value, update } = useSessionValue('note', 10_000);
|
|
119
|
+
return h('input', { id: 'field', value: value ?? '', onInput: (event: Event) => update((event.target as HTMLInputElement).value) });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await act(async () => {
|
|
123
|
+
render(h(Field, {}), container);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const field = container.querySelector('#field') as HTMLInputElement;
|
|
127
|
+
await act(async () => {
|
|
128
|
+
field.value = 'typed';
|
|
129
|
+
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// A long debounce: the store still holds nothing, and the field must NOT
|
|
133
|
+
// re-read it — that would erase the keystroke on screen.
|
|
134
|
+
expect(field.value).toBe('typed');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a write from another component does not mark this field dirty', async () => {
|
|
138
|
+
const { services, externalWrite, store } = makeServices();
|
|
139
|
+
configureUiKit(services);
|
|
140
|
+
|
|
141
|
+
/** A field that would write its value back if an outside write set `saving`. */
|
|
142
|
+
function Field() {
|
|
143
|
+
const { value, update } = useSessionValue('note', 10);
|
|
144
|
+
return h('input', { id: 'field', value: value ?? '', onInput: (event: Event) => update((event.target as HTMLInputElement).value) });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await act(async () => {
|
|
148
|
+
render(h(Field, {}), container);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await act(async () => {
|
|
152
|
+
externalWrite('note', 'from elsewhere');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// The field shows the outside value and has not overwritten it.
|
|
156
|
+
expect((container.querySelector('#field') as HTMLInputElement).value).toBe('from elsewhere');
|
|
157
|
+
const settled = Promise.withResolvers<void>();
|
|
158
|
+
setTimeout(() => settled.resolve(), 40);
|
|
159
|
+
await settled.promise;
|
|
160
|
+
expect(store.get(`${CONTEXT.sessionId}:note`)).toBe('from elsewhere');
|
|
161
|
+
});
|
|
162
|
+
});
|
package/src/styles.css
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
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
|
-
}
|
package/src/styles.d.css.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
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 {};
|