@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 CHANGED
@@ -1,19 +1,18 @@
1
1
  {
2
2
  "name": "@ompchamber/ui",
3
- "version": "1.0.0",
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": "1.0.0"
15
+ "@ompchamber/plugin-sdk": "2.0.0"
17
16
  },
18
17
  "license": "UNLICENSED",
19
18
  "repository": {
@@ -6,17 +6,19 @@
6
6
  /**
7
7
  * The panel UI kit.
8
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.
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
- * 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.
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="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>
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="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}
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="oc-fields">{children}</div>;
62
+ return <div class="flex flex-col gap-3">{children}</div>;
64
63
  }
65
64
 
66
65
  interface TextAreaFieldProps {
67
- id?: string;
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: (next: string) => void;
71
+ onInput: (value: string) => void;
74
72
  }
75
73
 
76
74
  /**
77
75
  * A labelled textarea.
78
76
  *
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.
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, placeholder, value, hint, onInput }: TextAreaFieldProps) {
81
+ export function TextAreaField({ id, label, value, placeholder, hint, onInput }: TextAreaFieldProps) {
84
82
  return (
85
- <div class="oc-textarea-field">
86
- <label class="oc-label" for={id}>
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
- class="oc-textarea"
89
+ value={value}
92
90
  placeholder={placeholder}
93
- value={value ?? ''}
94
- disabled={value === null}
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="oc-hint">{hint}</span> : null}
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. `primary` is the one action a panel wants the user to take. */
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 type="button" class={`oc-button oc-button-${variant}`} disabled={disabled} onClick={onClick}>
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 <p class="oc-empty">{children}</p>;
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
- return <p class={`oc-note oc-note-${tone}`}>{children}</p>;
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 Preact integration for a panel plugin: one provider, four hooks.
7
+ * The UI kit a plugin renders with, and the host services it reads through.
8
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:
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
- * - **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.
15
+ * Two rules the hooks exist to enforce, because a plugin gets them wrong:
20
16
  *
21
- * Both are handled here so a plugin cannot get them wrong.
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 { 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';
25
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
28
26
 
29
- interface PanelContextValue {
30
- api: ChamberPanelApi;
31
- info: PanelInfo;
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
- const PanelContext = createContext<PanelContextValue | null>(null);
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
- function usePanelContext(): PanelContextValue {
37
- const value = useContext(PanelContext);
38
- if (!value) throw new Error('Panel hooks must be used inside <PanelProvider>');
39
- return value;
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 info.
86
+ * The live panel context, re-rendering on every change.
44
87
  *
45
- * Read through a hook rather than off the context directly so a component
46
- * re-renders when the workspace arrives.
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(): PanelInfo {
49
- return usePanelContext().info;
50
- }
91
+ export function usePanelInfo(): PanelContext {
92
+ const [context, setContext] = useState<PanelContext>(() => requireServices().context());
51
93
 
52
- /** The bridge itself, for `api.call(...)`. */
53
- export function usePanelApi(): ChamberPanelApi {
54
- return usePanelContext().api;
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 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.
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 api = usePanelApi();
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
- let cancelled = false;
145
+ editing.current = false;
86
146
  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]);
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(async () => {
166
+ const timer = setTimeout(() => {
107
167
  try {
108
- await api.call('sessionState.set', { key, value });
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
- }, [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
- };
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
- * 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);
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
- 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
-
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
- 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>;
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
- }
@@ -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 {};