@huanlin/dsh-plugin-sidebar-brand-text 0.4.2 → 0.6.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/lib/index.js CHANGED
@@ -1,235 +1,234 @@
1
- import z from "@deepseek-ai/schemastery";
2
- import { settingsNamespace } from "@deepseek-ai/dsh-settings";
3
-
4
- //#region src/config.ts
5
- /** Schemastery schema for the composition entry and the settings namespace. */
6
- const Config = z.object({
7
- name: z.string().default("DSH Local Build").description("Brand name text shown in the sidebar next to the logo."),
8
- revision: z.string().default("").description("Revision badge text shown beside the brand name. Empty string hides the badge.")
9
- });
10
- /**
11
- * Resolve a raw config object into a complete {@link BrandTextConfig}.
12
- *
13
- * Unknown keys are dropped; missing or wrong-typed keys fall back to
14
- * the defaults. This runs on every gateway read so the client always
15
- * sees a well-formed value.
16
- * @param config - raw config (entry source or settings layer).
17
- * @returns the resolved config with defaults applied.
18
- */
19
- function resolveConfig(config = {}) {
20
- return {
21
- name: typeof config.name === "string" ? config.name : DEFAULT.name,
22
- revision: typeof config.revision === "string" ? config.revision : DEFAULT.revision
23
- };
24
- }
25
- /** Defaults used when no config arrives (defensive only). */
26
- const DEFAULT = {
27
- name: "DSH Local Build",
28
- revision: ""
29
- };
30
-
31
- //#endregion
32
- //#region src/settings.ts
33
- /** Settings namespace under which brand-text config persists. */
34
- const SETTINGS_NAMESPACE = settingsNamespace("sidebar-brand-text");
35
- /**
36
- * Mirror of the dsh-settings internal `isUnloading` guard. The cordis const
37
- * enum for fiber state is erased at compile time, so the literal states are
38
- * matched numerically: 4 = DISPOSED, 5 = UNLOADING.
39
- */
40
- function isUnloading(ctx) {
41
- const state = ctx.fiber?.state;
42
- return state === 4 || state === 5;
43
- }
44
- /**
45
- * Install the `sidebar-brand-text` settings namespace and return the bridge.
46
- *
47
- * @param ctx - host context.
48
- * @param entry - raw composition-layer config seed.
49
- * @returns the settings bridge.
50
- */
51
- function installBrandTextSettings(ctx, entry) {
52
- const listeners = /* @__PURE__ */ new Set();
53
- let source = () => entry;
54
- const notify = () => {
55
- for (const listener of [...listeners]) listener();
56
- };
57
- ctx.inject(["settings"], (sctx) => {
58
- let scope;
59
- try {
60
- scope = sctx.settings.register(SETTINGS_NAMESPACE, Config, { base: entry });
61
- } catch (error) {
62
- if (!(error instanceof Error) || !error.message.includes("already registered")) throw error;
63
- ctx.logger("sidebar-brand-text")?.debug("settings namespace already registered — entry-source fallback");
64
- return;
65
- }
66
- source = () => scope.get();
67
- sctx.effect(() => () => {
68
- if (isUnloading(ctx)) return;
69
- source = () => entry;
70
- notify();
71
- });
72
- notify();
73
- scope.watch(() => {
74
- if (isUnloading(ctx)) return;
75
- notify();
76
- });
77
- });
78
- return {
79
- source: () => source(),
80
- onChange: (cb) => {
81
- listeners.add(cb);
82
- return () => {
83
- listeners.delete(cb);
84
- };
85
- }
86
- };
87
- }
88
-
89
- //#endregion
90
- //#region src/gateway.ts
91
- /** HTTP route prefix owning every sidebar-brand-text API request. */
92
- const API_PREFIX = "/sbbt/api";
93
- /** Config keys the `set` endpoint accepts (allow-list; unknown keys are dropped). */
94
- const ALLOWED_KEYS = new Set(["name", "revision"]);
95
- /**
96
- * Register the `/sbbt/api` HTTP route on the host's web server.
97
- *
98
- * @param ctx - host context carrying `webServer`.
99
- * @param bridge - the settings bridge the route reads through.
100
- */
101
- function registerBrandTextGateway(ctx, bridge) {
102
- let settings;
103
- ctx.inject(["settings"], (sctx) => {
104
- settings = sctx.settings;
105
- return () => {
106
- settings = void 0;
107
- };
108
- });
109
- ctx.effect(() => {
110
- const webServer = ctx.webServer;
111
- if (!webServer || typeof webServer.register !== "function") return () => {};
112
- return webServer.register({
113
- kind: "prefix",
114
- path: API_PREFIX,
115
- handler: async (req, res) => {
116
- if ((req.method ?? "") !== "POST") {
117
- writeJson(res, 405, envelopeError("method-not-allowed", "POST only"));
118
- return;
119
- }
120
- const origin = req.headers.origin;
121
- if (typeof origin === "string" && origin) {
122
- let originHost;
123
- try {
124
- originHost = new URL(origin).host;
125
- } catch {
126
- writeJson(res, 400, envelopeError("invalid-origin", "invalid Origin header"));
127
- return;
128
- }
129
- const reqHost = req.headers.host;
130
- if (typeof reqHost === "string" && originHost !== reqHost) {
131
- writeJson(res, 403, envelopeError("origin-not-allowed", "same-origin requests only"));
132
- return;
133
- }
134
- }
135
- if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
136
- writeJson(res, 415, envelopeError("content-type-not-supported", "application/json required"));
137
- return;
138
- }
139
- const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
140
- const method = pathname.startsWith(`${API_PREFIX}/`) ? pathname.slice(`${API_PREFIX}/`.length) : void 0;
141
- if (method === void 0 || method.includes("/")) {
142
- writeJson(res, 404, envelopeError("not-found", "unknown sidebar-brand-text API method"));
143
- return;
144
- }
145
- try {
146
- const body = await readJsonBody(req);
147
- if (method === "get") writeJson(res, 200, envelopeOk({ config: resolveConfig(bridge.source()) }));
148
- else if (method === "set") writeJson(res, 200, envelopeOk(await handleSet(body, settings, bridge)));
149
- else writeJson(res, 404, envelopeError("not-found", `unknown sidebar-brand-text API method "${method}"`));
150
- } catch (error) {
151
- writeJson(res, 500, envelopeError("internal", error instanceof Error ? error.message : String(error)));
152
- }
153
- }
154
- });
155
- }, "sidebar-brand-text: /sbbt/api routes");
156
- }
157
- /** Handle the `set` method: validate patch, write user layer, return resolved config. */
158
- async function handleSet(body, settings, bridge) {
159
- const patch = extractPatch(body);
160
- if (Object.keys(patch).length === 0) return { config: resolveConfig(bridge.source()) };
161
- if (settings === void 0) throw new Error("sidebar-brand-text: settings service is unavailable — configuration cannot be written");
162
- await settings.update(SETTINGS_NAMESPACE, patch);
163
- return { config: resolveConfig(bridge.source()) };
164
- }
165
- /** Extract and validate the patch from the request body. */
166
- function extractPatch(body) {
167
- if (typeof body !== "object" || body === null) return {};
168
- const raw = Reflect.get(body, "patch");
169
- if (typeof raw !== "object" || raw === null) return {};
170
- const normalized = {};
171
- for (const [key, value] of Object.entries(raw)) {
172
- if (!ALLOWED_KEYS.has(key)) continue;
173
- if (value === null || value === void 0) continue;
174
- if (typeof value === "string") {
175
- if (key === "name") normalized.name = value;
176
- else if (key === "revision") normalized.revision = value;
177
- }
178
- }
179
- return normalized;
180
- }
181
- /** Read and parse a JSON body from a node:http request. */
182
- async function readJsonBody(req, maxBytes = 8192) {
183
- const chunks = [];
184
- let bytes = 0;
185
- for await (const chunk of req) {
186
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
187
- bytes += buffer.length;
188
- if (bytes > maxBytes) throw new Error("request body too large");
189
- chunks.push(buffer);
190
- }
191
- const text = Buffer.concat(chunks).toString("utf8");
192
- if (text === "") return {};
193
- return JSON.parse(text);
194
- }
195
- /** Write a JSON response envelope. */
196
- function writeJson(res, status, body) {
197
- const json = JSON.stringify(body);
198
- res.writeHead(status, { "content-type": "application/json" });
199
- res.end(json);
200
- }
201
- /** Build a success envelope. */
202
- function envelopeOk(value) {
203
- return {
204
- ok: true,
205
- value
206
- };
207
- }
208
- /** Build an error envelope. */
209
- function envelopeError(code, message) {
210
- return {
211
- ok: false,
212
- error: {
213
- code,
214
- message
215
- }
216
- };
217
- }
218
-
219
- //#endregion
220
- //#region src/index.ts
221
- const name = "sidebar-brand-text";
222
- /** `webServer` is required for the HTTP gateway that backs the settings card. */
223
- const inject = ["webServer"];
224
- /**
225
- * Plugin body: install the settings bridge and register the HTTP gateway.
226
- *
227
- * @param ctx - host context carrying `webServer`.
228
- * @param config - resolved config (seed values).
229
- */
230
- function apply(ctx, config) {
231
- registerBrandTextGateway(ctx, installBrandTextSettings(ctx, resolveConfig(config)));
232
- }
233
-
234
- //#endregion
1
+ import z from "@deepseek-ai/schemastery";
2
+
3
+ //#region src/config.ts
4
+ /** Schemastery schema for the composition entry and the settings namespace. */
5
+ const Config = z.object({
6
+ name: z.string().default("DSH Local Build").description("Brand name text shown in the sidebar next to the logo."),
7
+ revision: z.string().default("").description("Revision badge text shown beside the brand name. Empty string hides the badge.")
8
+ });
9
+ /**
10
+ * Resolve a raw config object into a complete {@link BrandTextConfig}.
11
+ *
12
+ * Unknown keys are dropped; missing or wrong-typed keys fall back to
13
+ * the defaults. This runs on every gateway read so the client always
14
+ * sees a well-formed value.
15
+ * @param config - raw config (entry source or settings layer).
16
+ * @returns the resolved config with defaults applied.
17
+ */
18
+ function resolveConfig(config = {}) {
19
+ return {
20
+ name: typeof config.name === "string" ? config.name : DEFAULT.name,
21
+ revision: typeof config.revision === "string" ? config.revision : DEFAULT.revision
22
+ };
23
+ }
24
+ /** Defaults used when no config arrives (defensive only). */
25
+ const DEFAULT = {
26
+ name: "DSH Local Build",
27
+ revision: ""
28
+ };
29
+
30
+ //#endregion
31
+ //#region src/settings.ts
32
+ /** Settings namespace under which brand-text config persists. */
33
+ const SETTINGS_NAMESPACE = "sidebar-brand-text";
34
+ /**
35
+ * Mirror of the dsh-settings internal `isUnloading` guard. The cordis const
36
+ * enum for fiber state is erased at compile time, so the literal states are
37
+ * matched numerically: 4 = DISPOSED, 5 = UNLOADING.
38
+ */
39
+ function isUnloading(ctx) {
40
+ const state = ctx.fiber?.state;
41
+ return state === 4 || state === 5;
42
+ }
43
+ /**
44
+ * Install the `sidebar-brand-text` settings namespace and return the bridge.
45
+ *
46
+ * @param ctx - host context.
47
+ * @param entry - raw composition-layer config seed.
48
+ * @returns the settings bridge.
49
+ */
50
+ function installBrandTextSettings(ctx, entry) {
51
+ const listeners = /* @__PURE__ */ new Set();
52
+ let source = () => entry;
53
+ const notify = () => {
54
+ for (const listener of [...listeners]) listener();
55
+ };
56
+ ctx.inject(["settings"], (sctx) => {
57
+ let scope;
58
+ try {
59
+ scope = sctx.settings.register(SETTINGS_NAMESPACE, Config, { base: entry });
60
+ } catch (error) {
61
+ if (!(error instanceof Error) || !error.message.includes("already registered")) throw error;
62
+ ctx.logger("sidebar-brand-text")?.debug("settings namespace already registered — entry-source fallback");
63
+ return;
64
+ }
65
+ source = () => scope.get();
66
+ sctx.effect(() => () => {
67
+ if (isUnloading(ctx)) return;
68
+ source = () => entry;
69
+ notify();
70
+ });
71
+ notify();
72
+ scope.watch(() => {
73
+ if (isUnloading(ctx)) return;
74
+ notify();
75
+ });
76
+ });
77
+ return {
78
+ source: () => source(),
79
+ onChange: (cb) => {
80
+ listeners.add(cb);
81
+ return () => {
82
+ listeners.delete(cb);
83
+ };
84
+ }
85
+ };
86
+ }
87
+
88
+ //#endregion
89
+ //#region src/gateway.ts
90
+ /** HTTP route prefix owning every sidebar-brand-text API request. */
91
+ const API_PREFIX = "/sbbt/api";
92
+ /** Config keys the `set` endpoint accepts (allow-list; unknown keys are dropped). */
93
+ const ALLOWED_KEYS = new Set(["name", "revision"]);
94
+ /**
95
+ * Register the `/sbbt/api` HTTP route on the host's web server.
96
+ *
97
+ * @param ctx - host context carrying `webServer`.
98
+ * @param bridge - the settings bridge the route reads through.
99
+ */
100
+ function registerBrandTextGateway(ctx, bridge) {
101
+ let settings;
102
+ ctx.inject(["settings"], (sctx) => {
103
+ settings = sctx.settings;
104
+ return () => {
105
+ settings = void 0;
106
+ };
107
+ });
108
+ ctx.effect(() => {
109
+ const webServer = ctx.webServer;
110
+ if (!webServer || typeof webServer.register !== "function") return () => {};
111
+ return webServer.register({
112
+ kind: "prefix",
113
+ path: API_PREFIX,
114
+ handler: async (req, res) => {
115
+ if ((req.method ?? "") !== "POST") {
116
+ writeJson(res, 405, envelopeError("method-not-allowed", "POST only"));
117
+ return;
118
+ }
119
+ const origin = req.headers.origin;
120
+ if (typeof origin === "string" && origin) {
121
+ let originHost;
122
+ try {
123
+ originHost = new URL(origin).host;
124
+ } catch {
125
+ writeJson(res, 400, envelopeError("invalid-origin", "invalid Origin header"));
126
+ return;
127
+ }
128
+ const reqHost = req.headers.host;
129
+ if (typeof reqHost === "string" && originHost !== reqHost) {
130
+ writeJson(res, 403, envelopeError("origin-not-allowed", "same-origin requests only"));
131
+ return;
132
+ }
133
+ }
134
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
135
+ writeJson(res, 415, envelopeError("content-type-not-supported", "application/json required"));
136
+ return;
137
+ }
138
+ const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
139
+ const method = pathname.startsWith(`${API_PREFIX}/`) ? pathname.slice(`${API_PREFIX}/`.length) : void 0;
140
+ if (method === void 0 || method.includes("/")) {
141
+ writeJson(res, 404, envelopeError("not-found", "unknown sidebar-brand-text API method"));
142
+ return;
143
+ }
144
+ try {
145
+ const body = await readJsonBody(req);
146
+ if (method === "get") writeJson(res, 200, envelopeOk({ config: resolveConfig(bridge.source()) }));
147
+ else if (method === "set") writeJson(res, 200, envelopeOk(await handleSet(body, settings, bridge)));
148
+ else writeJson(res, 404, envelopeError("not-found", `unknown sidebar-brand-text API method "${method}"`));
149
+ } catch (error) {
150
+ writeJson(res, 500, envelopeError("internal", error instanceof Error ? error.message : String(error)));
151
+ }
152
+ }
153
+ });
154
+ }, "sidebar-brand-text: /sbbt/api routes");
155
+ }
156
+ /** Handle the `set` method: validate patch, write user layer, return resolved config. */
157
+ async function handleSet(body, settings, bridge) {
158
+ const patch = extractPatch(body);
159
+ if (Object.keys(patch).length === 0) return { config: resolveConfig(bridge.source()) };
160
+ if (settings === void 0) throw new Error("sidebar-brand-text: settings service is unavailable — configuration cannot be written");
161
+ await settings.update(SETTINGS_NAMESPACE, patch);
162
+ return { config: resolveConfig(bridge.source()) };
163
+ }
164
+ /** Extract and validate the patch from the request body. */
165
+ function extractPatch(body) {
166
+ if (typeof body !== "object" || body === null) return {};
167
+ const raw = Reflect.get(body, "patch");
168
+ if (typeof raw !== "object" || raw === null) return {};
169
+ const normalized = {};
170
+ for (const [key, value] of Object.entries(raw)) {
171
+ if (!ALLOWED_KEYS.has(key)) continue;
172
+ if (value === null || value === void 0) continue;
173
+ if (typeof value === "string") {
174
+ if (key === "name") normalized.name = value;
175
+ else if (key === "revision") normalized.revision = value;
176
+ }
177
+ }
178
+ return normalized;
179
+ }
180
+ /** Read and parse a JSON body from a node:http request. */
181
+ async function readJsonBody(req, maxBytes = 8192) {
182
+ const chunks = [];
183
+ let bytes = 0;
184
+ for await (const chunk of req) {
185
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
186
+ bytes += buffer.length;
187
+ if (bytes > maxBytes) throw new Error("request body too large");
188
+ chunks.push(buffer);
189
+ }
190
+ const text = Buffer.concat(chunks).toString("utf8");
191
+ if (text === "") return {};
192
+ return JSON.parse(text);
193
+ }
194
+ /** Write a JSON response envelope. */
195
+ function writeJson(res, status, body) {
196
+ const json = JSON.stringify(body);
197
+ res.writeHead(status, { "content-type": "application/json" });
198
+ res.end(json);
199
+ }
200
+ /** Build a success envelope. */
201
+ function envelopeOk(value) {
202
+ return {
203
+ ok: true,
204
+ value
205
+ };
206
+ }
207
+ /** Build an error envelope. */
208
+ function envelopeError(code, message) {
209
+ return {
210
+ ok: false,
211
+ error: {
212
+ code,
213
+ message
214
+ }
215
+ };
216
+ }
217
+
218
+ //#endregion
219
+ //#region src/index.ts
220
+ const name = "sidebar-brand-text";
221
+ /** `webServer` is required for the HTTP gateway that backs the settings card. */
222
+ const inject = ["webServer"];
223
+ /**
224
+ * Plugin body: install the settings bridge and register the HTTP gateway.
225
+ *
226
+ * @param ctx - host context carrying `webServer`.
227
+ * @param config - resolved config (seed values).
228
+ */
229
+ function apply(ctx, config) {
230
+ registerBrandTextGateway(ctx, installBrandTextSettings(ctx, resolveConfig(config)));
231
+ }
232
+
233
+ //#endregion
235
234
  export { Config, apply, inject, name };
@@ -1,66 +1,66 @@
1
- /**
2
- * `BrandTextSettingsController` — client-side state store for the
3
- * sidebar-brand-text config.
4
- *
5
- * Loads the config from the host's `/sbbt/api/get` route, stages edits,
6
- * and saves via `/sbbt/api/set`. The `sidebar.brand.name` slot occupant
7
- * and the `settings.plugin.item` card both read from the same store via
8
- * `bindSnapshotSelector`, so a save is instantly reflected in the sidebar
9
- * without a DOM event or page reload.
10
- *
11
- * Mirrors the ego-browser `EgoBrowserSettingsController` pattern.
12
- *
13
- * @module @huanlin/dsh-plugin-sidebar-brand-text/client/controller
14
- */
15
- import { type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client';
16
- import { type BrandTextConfig } from '../types.ts';
17
- /** The controller's snapshot state. */
18
- export interface BrandTextState {
19
- /** 'idle' | 'loading' | 'ready' */
20
- status: 'idle' | 'loading' | 'ready';
21
- /** True after a successful `/sbbt/api/get`; false when the route is unreachable. */
22
- available: boolean;
23
- /** False when the settings service is absent (read-only). */
24
- writable: boolean;
25
- /** Current draft values (edited by the card, read by the brand slot). */
26
- draft: BrandTextConfig;
27
- /** True when the draft differs from the last-saved config. */
28
- dirty: boolean;
29
- /** Apply lifecycle: 'idle' | 'saving' | 'saved' | 'error'. */
30
- applyState: {
31
- kind: 'idle';
32
- } | {
33
- kind: 'saving';
34
- } | {
35
- kind: 'saved';
36
- } | {
37
- kind: 'error';
38
- message: string;
39
- };
40
- /** Card expand state (toggled by the card header button). */
41
- _open: boolean;
42
- }
43
- /**
44
- * Controller managing the brand-text config lifecycle.
45
- *
46
- * Constructed once in the client `apply()` and shared between the
47
- * `sidebar.brand.name` slot and the `settings.plugin.item` card.
48
- */
49
- export declare class BrandTextSettingsController {
50
- readonly store: SnapshotStore<BrandTextState>;
51
- loaded: boolean;
52
- private generation;
53
- constructor();
54
- /** Fetch the config from `/sbbt/api/get` and update the store. */
55
- load(): Promise<void>;
56
- /** Stage an edit to a field (does not save). */
57
- edit(field: 'name' | 'revision', value: string): void;
58
- /** Discard staged edits and reload from the host. */
59
- discard(): void;
60
- /** Save the staged draft via `/sbbt/api/set`. */
61
- save(): Promise<void>;
62
- /** Toggle the card's expand state (mirrors ego-browser `controller.toggle()`). */
63
- toggle(): void;
64
- /** Mark the store as unavailable (route unreachable or settings service absent). */
65
- private markUnavailable;
66
- }
1
+ /**
2
+ * `BrandTextSettingsController` — client-side state store for the
3
+ * sidebar-brand-text config.
4
+ *
5
+ * Loads the config from the host's `/sbbt/api/get` route, stages edits,
6
+ * and saves via `/sbbt/api/set`. The `sidebar.brand.name` slot occupant
7
+ * and the `settings.plugin.item` card both read from the same store via
8
+ * `bindSnapshotSelector`, so a save is instantly reflected in the sidebar
9
+ * without a DOM event or page reload.
10
+ *
11
+ * Mirrors the ego-browser `EgoBrowserSettingsController` pattern.
12
+ *
13
+ * @module @huanlin/dsh-plugin-sidebar-brand-text/client/controller
14
+ */
15
+ import { type SnapshotStore } from '@deepseek-ai/dsh-client-store';
16
+ import { type BrandTextConfig } from '../types.ts';
17
+ /** The controller's snapshot state. */
18
+ export interface BrandTextState {
19
+ /** 'idle' | 'loading' | 'ready' */
20
+ status: 'idle' | 'loading' | 'ready';
21
+ /** True after a successful `/sbbt/api/get`; false when the route is unreachable. */
22
+ available: boolean;
23
+ /** False when the settings service is absent (read-only). */
24
+ writable: boolean;
25
+ /** Current draft values (edited by the card, read by the brand slot). */
26
+ draft: BrandTextConfig;
27
+ /** True when the draft differs from the last-saved config. */
28
+ dirty: boolean;
29
+ /** Apply lifecycle: 'idle' | 'saving' | 'saved' | 'error'. */
30
+ applyState: {
31
+ kind: 'idle';
32
+ } | {
33
+ kind: 'saving';
34
+ } | {
35
+ kind: 'saved';
36
+ } | {
37
+ kind: 'error';
38
+ message: string;
39
+ };
40
+ /** Card expand state (toggled by the card header button). */
41
+ _open: boolean;
42
+ }
43
+ /**
44
+ * Controller managing the brand-text config lifecycle.
45
+ *
46
+ * Constructed once in the client `apply()` and shared between the
47
+ * `sidebar.brand.name` slot and the `settings.plugin.item` card.
48
+ */
49
+ export declare class BrandTextSettingsController {
50
+ readonly store: SnapshotStore<BrandTextState>;
51
+ loaded: boolean;
52
+ private generation;
53
+ constructor();
54
+ /** Fetch the config from `/sbbt/api/get` and update the store. */
55
+ load(): Promise<void>;
56
+ /** Stage an edit to a field (does not save). */
57
+ edit(field: 'name' | 'revision', value: string): void;
58
+ /** Discard staged edits and reload from the host. */
59
+ discard(): void;
60
+ /** Save the staged draft via `/sbbt/api/set`. */
61
+ save(): Promise<void>;
62
+ /** Toggle the card's expand state (mirrors ego-browser `controller.toggle()`). */
63
+ toggle(): void;
64
+ /** Mark the store as unavailable (route unreachable or settings service absent). */
65
+ private markUnavailable;
66
+ }