@drift-beacon/plugin 0.2.0 → 0.2.3

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/README.md CHANGED
@@ -201,9 +201,9 @@ Settings items have `name` (unique), `title`, `type` and optional `description`
201
201
 
202
202
  `main/src/index.ts` default-exports `definePlugin({ onStart(ctx) { … } })`. The server runs one **instance** for each user and workspace that enabled the plugin; each gets its own `ctx`.
203
203
 
204
- - **Start.** `onStart(ctx)` may be async and must finish within 15 seconds. If it throws, the instance doesn't start.
204
+ - **Start.** `onStart(ctx)` may be async and must finish within 15 seconds. Callbacks and routes work as soon as they are registered. If it throws, the instance doesn't start.
205
205
  - **Register through `ctx`.** Callbacks, routes and MQTT subscriptions made through `ctx` are released when the instance stops. Keep per-instance state inside `onStart`, never at module scope: the module is shared by every instance.
206
- - **Stop.** `ctx.onStop(fn)` callbacks run newest first, 5 seconds in total. Afterwards every `ctx` call throws `PluginError` with code `stopped`.
206
+ - **Stop.** `ctx.onStop(fn)` callbacks run newest first, 5 seconds in total, whatever stops the instance (a fault or server shutdown too, even when something else stops it meanwhile). Afterwards every `ctx` call throws `PluginError` with code `stopped`. A server that is killed rather than shut down cuts them short.
207
207
  - **Faults.** An uncaught error or unhandled rejection stops only that instance, which is retried after 5, 30 and 120 seconds.
208
208
 
209
209
  | `ctx` member | Main | UI | |
@@ -214,13 +214,15 @@ Settings items have `name` (unique), `title`, `type` and optional `description`
214
214
  | `onDataChange` | ✓ | ✓ | The data changed |
215
215
  | `sessions.onStarted`, `onEnded`, `onMarked` | ✓ | | Live session events |
216
216
  | `storage` | ✓ | ✓ | Key-value storage |
217
- | `plugins` | ✓ | ✓ | The plugins it uses and itself: status, state and commands (and events, in main code) |
217
+ | `plugins` | ✓ | ✓ | The plugins it uses and itself: status, state, events and commands |
218
218
  | `commands`, `events`, `state` | ✓ | | What it provides to other plugins |
219
219
  | `mqtt`, `routes`, `log`, `onStop` | ✓ | | Main code only |
220
220
 
221
221
  ## UI
222
222
 
223
- `connect()` performs a handshake with the web app and resolves to the UI `ctx`. It rejects with `unsupported` when the app doesn't run this UI's API version, and with `unavailable` outside Drift Beacon or after 10 seconds without an answer. `ctx.onDataChange` fires after every update (data, settings, storage, or other plugins' status and state).
223
+ `connect()` performs a handshake with the web app and resolves to the UI `ctx`. It rejects with `unsupported` when the app doesn't run this UI's API version, and with `unavailable` outside Drift Beacon or after 10 seconds without an answer. `ctx.onDataChange` fires after every update (data, settings, storage, or other plugins' status and state; not their events).
224
+
225
+ The app runs a built UI in a sandboxed frame (`allow-scripts` only): it has no storage, cookies, popups or modal dialogs, and a `<form>` never submits. Handle Enter and your button's press yourself instead of relying on a form's `submit`.
224
226
 
225
227
  With React, re-render on every update:
226
228
 
@@ -256,6 +258,8 @@ Models keep their identity when their data changes: key `React.memo` and `useMem
256
258
  const activity = ctx.activities.get(id);
257
259
  activity?.category?.name; // links
258
260
  activity?.isLive; // the current user has a live session of it
261
+ activity?.isPinned; // the current user has it pinned
262
+ activity?.goal; // { type: "duration", seconds } or { type: "count", count }, or null
259
263
  await activity?.track(); // start a span / mark a point
260
264
  activity?.data; // the plain row, a new frozen object whenever it changes
261
265
 
@@ -277,10 +281,22 @@ ctx.sessions.live({ mine: true });
277
281
  {activity.iconPath && <svg viewBox="0 0 24 24" fill="currentColor"><path d={activity.iconPath} /></svg>}
278
282
  ```
279
283
 
284
+ - From 0.2.2, activities carry their goal, its period and their pins:
285
+ - `goal` is `{ type: "duration", seconds }` or `{ type: "count", count }`, or `null` when the activity has none. A point activity's goal always counts marks; a span activity's counts sessions or totals their duration, as the user chose.
286
+ - `period` is `"day"`, `"week"`, `"month"` or `"year"`: calendar periods from local midnight, weeks starting on Sunday. `null` means progress covers all history. Local means the browser's time zone in a UI, like the app's own progress, but the server's (its `TZ` environment variable) in main code: if the server runs in UTC and the user doesn't, main code's periods are off by the whole difference, all period long. Set `TZ` on the server to the household's zone.
287
+ - `pinnedBy` lists the users who have the activity pinned (each user pins at most one). Main code sees every user's pins, a UI only its own user's. `isPinned` is the current user's pin in both.
288
+ - Progress isn't included: add up `sessions()` in the current period. Drift Beacon counts every member's completed sessions from the period's start (a span counts on the day it started), plus the time so far of the current user's live session.
289
+ - A Drift Beacon older than 0.2.2 provides none of these, to main code as well as to UIs (main code uses the models of the Drift Beacon that runs it): `goal`, `period` and `pinnedBy` are `undefined`, which is why they are optional and how to tell (from 0.2.2, `pinnedBy` is always an array), and `isPinned` is `false` in a UI but `undefined` in main code. Test `isPinned` for truthiness.
290
+
291
+ ```ts
292
+ // The activity to show: the user's live one, else the one they pinned.
293
+ const shown = ctx.sessions.live({ mine: true })[0]?.activity ?? ctx.activities.list().find((a) => a.isPinned);
294
+ ```
295
+
280
296
  | Activity | |
281
297
  |---|---|
282
- | `id`, `name`, `description`, `trackingType`, `icon`, `iconPath`, `color`, `categoryId`, `archived`, `sortOrder`, `unit` | Fields (`color` is resolved from the category when needed) |
283
- | `isSpan`, `isPoint`, `isLive`, `category` | Derived values and links |
298
+ | `id`, `name`, `description`, `trackingType`, `icon`, `iconPath`, `color`, `categoryId`, `archived`, `sortOrder`, `unit`, `goal`, `period`, `pinnedBy` | Fields (`color` is resolved from the category when needed) |
299
+ | `isSpan`, `isPoint`, `isLive`, `isPinned`, `category` | Derived values and links |
284
300
  | `sessions(filter?)`, `live({ mine? })` | Its sessions |
285
301
  | `start()`, `mark()`, `track()`, `end()` | Actions (`end()` ends the current user's live session of it) |
286
302
 
@@ -301,7 +317,7 @@ await ctx.sessions.end(sessionId); // or session.end(), activity.end()
301
317
  await ctx.sessions.discard(sessionId); // or session.discard()
302
318
  ```
303
319
 
304
- Actions run as the current user and resolve once `ctx` shows their result. Starting a session ends the user's other live session, as in the app.
320
+ Actions run as the current user and resolve once `ctx` shows their result. Starting a session ends the user's other live session, as in the app. An id that isn't a non-empty string rejects with `invalid`.
305
321
 
306
322
  | | Live session events | Data change events |
307
323
  |---|---|---|
@@ -314,7 +330,7 @@ Never trigger actions from data changes. Live events fire for everyone's session
314
330
 
315
331
  ## Storage
316
332
 
317
- Per plugin, user and workspace, shared by main code and the UI. Values must be JSON: anything else rejects with `invalid`, and setting `undefined` removes the key. `set` and `remove` apply at once and resolve when Drift Beacon has accepted the write.
333
+ Per plugin, user and workspace, shared by main code and the UI. Keys are non-empty strings and values must be JSON: anything else rejects with `invalid`, and setting `undefined` removes the key. `set` and `remove` apply at once and resolve when Drift Beacon has accepted the write.
318
334
 
319
335
  ```ts
320
336
  const faces = ctx.storage.get<Record<string, string>>("faces") ?? {};
@@ -327,7 +343,7 @@ ctx.storage.onChange((key, value) => { /* changed by main code, a UI or another
327
343
 
328
344
  `ctx.config` holds the settings from the manifest's `configuration`. `dbplugin prepare` turns them into types (`.drift-beacon/config.d.ts`), so `ctx.config.mqttTopic` is typed in main code and the UI. A setting is optional unless it is `required` or has a `default`. An instance only runs with valid settings; changing them restarts it.
329
345
 
330
- A UI can open before setup is finished. Until then its `ctx.config` holds the defaults under whatever the user has saved, unvalidated: a `required` setting can still be missing. Check before relying on one.
346
+ The app blocks opening a UI when required settings are missing or blank. This presence check does not validate every setting: if saved values fail schema validation, the UI receives defaults under the saved values while main does not run. Check values before relying on them.
331
347
 
332
348
  ## MQTT and HTTP routes
333
349
 
@@ -341,12 +357,12 @@ ctx.routes.post("webhook", async ({ body }) => ({ status: 202, body: { ok: true
341
357
  ctx.routes.get("status", () => ({ body: { live: ctx.sessions.live({ mine: true }).length } }));
342
358
  ```
343
359
 
344
- - MQTT uses the workspace's broker; `publish` rejects with `unavailable` without one.
345
- - Routes are served at `<server>/api/plugins/<plugin id>/api/<name>` (`ctx.plugin.apiPath` is the path up to `/api`). Callers send `Authorization: Bearer <workspace API key>`, which picks the user and workspace. Handlers return `{ status?, body? }`; `body` is sent as JSON.
360
+ - MQTT uses the workspace's broker; `publish` rejects with `unavailable` without one or while it isn't connected, and with `invalid` for an empty topic or a payload that isn't a string (nothing is sent).
361
+ - Routes are served at `<server>/api/plugins/<plugin id>/api/<name>` (`ctx.plugin.apiPath` is the path up to `/api`). Callers send `Authorization: Bearer <workspace API key>`, which picks the user and workspace. Handlers return `{ status?, body? }`; `body` is sent as JSON. While `onStart` runs, a route not registered yet answers 503 (retry after 5 s).
346
362
 
347
363
  ## Commands between plugins
348
364
 
349
- Main code provides commands; main code and UIs run them. A plugin declares the commands it provides in `manifest.json` and handles them; another plugin lists it in `uses` and runs them, as the same user in the same workspace.
365
+ Main code provides commands; main code, UIs and Home Assistant run them. A plugin declares the commands it provides in `manifest.json` and handles them; another plugin lists it in `uses` and runs them, as the same user in the same workspace.
350
366
 
351
367
  ```jsonc
352
368
  // magic-cube/manifest.json
@@ -371,11 +387,25 @@ const result = await ctx.plugins.get("magic-cube").command("selectPreset", { pre
371
387
  - It fails fast: `not-installed`, `disabled`, `incompatible` (outside your range) or `unavailable` (not running; it didn't run). `timeout` and `stopped` mean it may have run.
372
388
  - `timeoutMs` defaults to 10 s (at most 30 s). A command run inside another continues its chain and shares what's left of its time; the ninth step of a chain is refused with `loop`.
373
389
  - From a UI: `ctx.plugins.get(id).command(…)` as in main code. The UI waits the command's time plus 2 s, then rejects `timeout`.
374
- - `dbplugin prepare` types what you provide (`.drift-beacon/config.d.ts`): `ctx.commands.handle`, `ctx.events.emit` and `ctx.state` take only declared names, with their declared types (none for a kind you declare nothing of). Plugins you use aren't typed yet.
390
+ - In Home Assistant, each command is an action, `drift_beacon.<manifest id>_<command in snake_case>` (`drift_beacon.magic_cube_select_preset`), on a workspace's device: its fields are your input's properties; if you declare `output`, a call on one workspace can ask for the response `{ output }`. It runs as that workspace's user with `meta.caller` `{ kind: "integration" }` and 10 s, and needs no `uses`. Renaming a command or input field breaks automations as it breaks other plugins.
391
+ - `dbplugin prepare` types what you provide (`.drift-beacon/config.d.ts`): `ctx.commands.handle`, `ctx.events.emit` and `ctx.state` take only declared names, with their declared types (none for a kind you declare nothing of).
392
+ - Plugins you use aren't typed for you: keep a copy of the types you need, written from their manifest. `command<T>()` and `state.get<T>()` take the type, and an event's payload is `unknown` until you narrow it. Update the copy along with your `uses` range.
393
+
394
+ ```ts
395
+ // cartridge-reader: what it uses from magic-cube ^1.1.0, kept by hand
396
+ interface SelectPresetOutput {
397
+ activePresetId: string | null;
398
+ }
399
+
400
+ const cube = ctx.plugins.get("magic-cube");
401
+ const { activePresetId } = await cube.command<SelectPresetOutput>("selectPreset", { preset: "Focus" });
402
+ const preset = cube.state.get<string | null>("activePreset");
403
+ cube.onEvent("faceChanged", (face) => showFace(face as number));
404
+ ```
375
405
 
376
406
  ## Events, state and status between plugins
377
407
 
378
- Main code emits and publishes; UIs read state and status (not events). A plugin emits the events and publishes the state it declares in `provides`; the plugins that use it (and the plugin itself, through `ctx.plugins.self`) receive them, and see its status.
408
+ Main code emits and publishes; main code and UIs receive them. A plugin emits the events and publishes the state it declares in `provides`; the plugins that use it (and the plugin itself, through `ctx.plugins.self`) receive them, and see its status.
379
409
 
380
410
  ```jsonc
381
411
  // magic-cube/manifest.json
@@ -397,11 +427,13 @@ cube.onStatusChange(({ state, reason }) => ctx.log.info(state, reason ?? ""));
397
427
  ```
398
428
 
399
429
  - `emit` and `set` throw `invalid` for an undeclared name, a value that doesn't match its schema, or over 64 KiB (256 KiB of state in all). Received values are frozen.
400
- - Events reach listeners registered by then (from `onStart` on); earlier ones are missed. State is kept in memory while the provider runs, cleared when it stops, never persisted.
430
+ - Events reach listeners registered by then (from `onStart` on; in a UI, right after `connect()`); earlier ones are missed. State is kept in memory while the provider runs, cleared when it stops, never persisted.
431
+ - A plugin can emit 50 events a second on average, in bursts up to 100; Drift Beacon drops the rest, with a warning in your console at most every 10 s (each after the first says how many it dropped). For a value that changes faster, publish the latest as state (not limited) or send fewer, bigger events.
401
432
  - In main code, a command handler's events and state changes arrive before its caller's `await` resumes.
402
433
  - Listeners continue the chain of what caused them; past 8 steps, events are dropped (with a warning) and state changes call no callbacks.
403
434
  - `status.state` is `running`, `starting`, `unavailable`, `disabled`, `incompatible` or `not-installed`, with the resolved `version` and a `reason`.
404
435
  - In a UI, status and state arrive within about 250 ms (and `ctx.onDataChange` fires), as the latest at each update (changes close together arrive as one), so a command's effects can show just after it resolves: render from `onChange`, or use its output.
436
+ - In a UI (from 0.2.1), events come with those updates, each after the state its plugin set before it. None are replayed (reconnects, reloads, more than about 100 between two updates), every open copy of the UI gets each one, and a command a UI listener runs starts a new chain: render in UI listeners, and react with commands in main code. On an older Drift Beacon they never arrive.
405
437
 
406
438
  ## Errors and limits
407
439
 
@@ -410,7 +442,7 @@ Operations reject with `PluginError`. Check `error.code`, not `instanceof`:
410
442
  | Code | Meaning |
411
443
  |---|---|
412
444
  | `unsupported` | The app doesn't support this API version or request |
413
- | `invalid` | Bad arguments (tracking a point with `start()`, a value that isn't JSON, an undeclared event, …) |
445
+ | `invalid` | Bad arguments (an empty id or storage key, an MQTT payload that isn't a string, tracking a point with `start()`, a value that isn't JSON, an undeclared event, …) |
414
446
  | `not-found` | The activity or session doesn't exist, or the model was removed |
415
447
  | `unavailable` | Something needed isn't there: no MQTT broker, no answer from the app |
416
448
  | `stopped` | The instance has stopped |
@@ -429,7 +461,7 @@ Operations reject with `PluginError`. Check `error.code`, not `instanceof`:
429
461
 
430
462
  ## Developing against your server
431
463
 
432
- On a Drift Beacon server you run yourself, set `DEV_PLUGINS_PATH` to the folder that contains your plugin folders and start the server in development mode. It lists each plugin under **Plugins → Development**. Enable it in a workspace, then run `npm run dev` in the plugin folder: every save rebuilds main, the server restarts the plugin, and its status, logs and errors (with stack traces pointing at your source) print in your terminal. The UI updates in place.
464
+ On a Drift Beacon server you run yourself, set `DEV_PLUGINS_PATH` to the folder that contains your plugin folders and start the server in development mode. It lists each plugin under **Plugins → Repositories → Local development**. Enable it in a workspace, then run `npm run dev` in the plugin folder: every save rebuilds main, the server restarts the plugin, and its status, logs and errors (with stack traces pointing at your source) print in your terminal. The UI updates in place.
433
465
 
434
466
  ## Releasing a plugin
435
467
 
@@ -439,14 +471,31 @@ Plugins are published on GitHub. In a public repository, each immediate child fo
439
471
  2. Run `npm run release` (or `npx dbplugin pack`), which writes `releases/<id>.zip`.
440
472
  3. Create a GitHub release tagged `<id>-<version>` (for example `hello-1.0.0`) and attach `<id>.zip`.
441
473
 
442
- Users add the repository URL in **Plugins → Store** and install from there. Don't commit `dist/` or `releases/`.
474
+ Users add the repository URL in **Plugins → Repositories** and install from there. Don't commit `dist/` or `releases/`.
443
475
 
444
476
  ## Versions
445
477
 
446
478
  - `apiVersion` in the manifest must match the SDK: `dbplugin`, `vite build` and the server all check it.
447
479
  - Before 1.0, each minor version (`0.1`, `0.2`) may change the API, and a server runs only its exact version. Install the SDK with `@0.2` to stay on it.
448
- - Minor releases may add fields and new string values (such as a new `trackingType`): ignore what you don't recognise.
480
+ - Patch releases (`0.2.x`) fix bugs and may add to API 0.2. An addition that needs the app, such as UI events in 0.2.1 or activity goals and pins in 0.2.2, does nothing on an older Drift Beacon. Ignore fields and string values you don't recognise (such as a new `trackingType`).
449
481
 
450
482
  ## License
451
483
 
452
484
  MIT
485
+
486
+ ## Host theme (SDK 0.2.3)
487
+
488
+ `connect()` installs the host's theme as `--db-*` CSS variables on `<html>` before resolving. It keeps them synchronized without reloading the UI. It also sets `data-color-mode`, `color-scheme`, and the `light`/`dark` class. Use semantic tokens instead of copying the app's palette or forcing a dark class.
489
+
490
+ ```css
491
+ body { background: var(--db-background); color: var(--db-foreground); }
492
+ .card { background: var(--db-surface); color: var(--db-surface-foreground); border: 1px solid var(--db-border); }
493
+ ```
494
+
495
+ The tokens are `background`, `foreground`, `surface`, `surface-foreground`, `surface-raised`, `muted`, `border`, `accent`, `accent-foreground`, `danger`, `danger-foreground`, `success`, `success-foreground`, `warning`, `warning-foreground`, and `focus`, all prefixed with `--db-`. `background` is the containing surface; `surface` is a card; `surface-raised` is a more prominent surface. Values are complete CSS colors, not HSL channels. Activity and device colors remain domain data, separate from these UI colors.
496
+
497
+ `ctx.theme: UiTheme` is an immutable snapshot (`mode: "light" | "dark"`, `colors: UiThemeColors` using camelCase token names). `ctx.onThemeChange(callback): Unsubscribe` runs after CSS is updated, only when the theme changes. Canvas/chart renderers can subscribe here; theme-only updates do not fire `onDataChange`. Convert CSS colors to sRGB if the rendering library does not accept modern CSS color syntax.
498
+
499
+ Older hosts omit the theme. The SDK then supplies a stable dark fallback palette; an old-host reconnect restores that fallback. Missing theme fields in ordinary state updates leave the current theme unchanged. Invalid theme snapshots are ignored. Older UI bundles ignore the new fields and keep their existing appearance; rebuild and migrate their styles to adopt them.
500
+
501
+ Render the main UI after `connect()` resolves. Give loading/error UI fallback colors, and handle a rejected connection. Framework adapters should map their library's colors to these tokens; the SDK does not require React or HeroUI.
@@ -1 +1,23 @@
1
- export {};
1
+ const nonEmpty = (value, what) => typeof value === "string" && value !== "" ? null : `${what} must be a non-empty string`;
2
+ const ARGUMENTS = {
3
+ startSession: (action) => nonEmpty(action.activityId, "Activity id"),
4
+ markPoint: (action) => nonEmpty(action.activityId, "Activity id"),
5
+ endSession: (action) => nonEmpty(action.sessionId, "Session id"),
6
+ discardSession: (action) => nonEmpty(action.sessionId, "Session id"),
7
+ storageSet: (action) => nonEmpty(action.key, "Storage key"),
8
+ storageRemove: (action) => nonEmpty(action.key, "Storage key"),
9
+ mqttSubscribe: (action) => nonEmpty(action.topic, "MQTT topic"),
10
+ mqttUnsubscribe: (action) => nonEmpty(action.topic, "MQTT topic"),
11
+ mqttPublish: (action) => nonEmpty(action.topic, "MQTT topic") ??
12
+ (typeof action.payload === "string" ? null : "MQTT payload must be a string"),
13
+ };
14
+ export function checkedAction(value) {
15
+ if (typeof value !== "object" || value === null)
16
+ return null;
17
+ const action = value;
18
+ const name = action.name;
19
+ if (typeof name !== "string" || !Object.hasOwn(ARGUMENTS, name))
20
+ return null;
21
+ const problem = ARGUMENTS[name](action);
22
+ return problem === null ? { ok: true, action: action } : { ok: false, problem };
23
+ }
@@ -11,6 +11,9 @@ const ACTIVITY_FIELDS = [
11
11
  "archived",
12
12
  "sortOrder",
13
13
  "unit",
14
+ "goal",
15
+ "period",
16
+ "pinnedBy",
14
17
  ];
15
18
  const CATEGORY_FIELDS = [
16
19
  "id",
@@ -61,6 +64,9 @@ class ActivityModel {
61
64
  get isLive() {
62
65
  return this.live({ mine: true }).length > 0;
63
66
  }
67
+ get isPinned() {
68
+ return this.data.pinnedBy?.includes(this.#models.userId) ?? false;
69
+ }
64
70
  get category() {
65
71
  const categoryId = this.data.categoryId;
66
72
  return categoryId === null ? undefined : this.#models.category(categoryId);
@@ -6,9 +6,14 @@ export const PEER_LIMITS = {
6
6
  valueBytes: 64 * 1024,
7
7
  stateBytes: 256 * 1024,
8
8
  depth: 8,
9
+ eventsPerSecond: 50,
10
+ eventBurst: 100,
9
11
  };
10
12
  export const COMMAND_TIMEOUT = { defaultMs: 10_000, maxMs: 30_000, minimumMs: 100 };
11
13
  export const commandBudgetMs = (timeoutMs) => Math.min(timeoutMs ?? COMMAND_TIMEOUT.defaultMs, COMMAND_TIMEOUT.maxMs);
14
+ export const eventNameProblem = (name) => typeof name === "string" && PEER_NAME.test(name)
15
+ ? null
16
+ : `Event names are camelCase letters and digits (got "${String(name)}")`;
12
17
  export function commandArgumentsProblem(command, input, options) {
13
18
  if (typeof command !== "string" || !command)
14
19
  return "Command name must be a non-empty string";
@@ -0,0 +1,75 @@
1
+ export const THEME_COLOR_KEYS = [
2
+ "background",
3
+ "foreground",
4
+ "surface",
5
+ "surfaceForeground",
6
+ "surfaceRaised",
7
+ "muted",
8
+ "border",
9
+ "accent",
10
+ "accentForeground",
11
+ "danger",
12
+ "dangerForeground",
13
+ "success",
14
+ "successForeground",
15
+ "warning",
16
+ "warningForeground",
17
+ "focus",
18
+ ];
19
+ export function fallbackTheme(mode = "dark") {
20
+ const dark = mode === "dark";
21
+ return Object.freeze({
22
+ mode,
23
+ colors: Object.freeze({
24
+ background: dark ? "#09090b" : "#f8f8f8",
25
+ foreground: dark ? "#fafafa" : "#18181b",
26
+ surface: dark ? "#18181b" : "#ffffff",
27
+ surfaceForeground: dark ? "#fafafa" : "#18181b",
28
+ surfaceRaised: dark ? "#27272a" : "#f4f4f5",
29
+ muted: dark ? "#a1a1aa" : "#71717a",
30
+ border: dark ? "#3f3f46" : "#d4d4d8",
31
+ accent: "#2563eb",
32
+ accentForeground: "#ffffff",
33
+ danger: dark ? "#ef4444" : "#b91c1c",
34
+ dangerForeground: "#ffffff",
35
+ success: dark ? "#4ade80" : "#15803d",
36
+ successForeground: dark ? "#052e16" : "#ffffff",
37
+ warning: dark ? "#fbbf24" : "#a16207",
38
+ warningForeground: dark ? "#422006" : "#ffffff",
39
+ focus: "#3b82f6",
40
+ }),
41
+ });
42
+ }
43
+ export function readTheme(value) {
44
+ if (!value || typeof value !== "object")
45
+ return;
46
+ const { mode, colors } = value;
47
+ if ((mode !== "light" && mode !== "dark") || !colors || typeof colors !== "object")
48
+ return;
49
+ const result = {};
50
+ for (const key of THEME_COLOR_KEYS) {
51
+ const color = colors[key];
52
+ if (typeof color !== "string" ||
53
+ !color.trim() ||
54
+ color.length > 512 ||
55
+ /[;{}]/.test(color) ||
56
+ /(?:var|url)\s*\(/i.test(color))
57
+ return;
58
+ if (typeof CSS !== "undefined" && CSS.supports && !CSS.supports("color", color))
59
+ return;
60
+ result[key] = color;
61
+ }
62
+ return Object.freeze({ mode, colors: Object.freeze(result) });
63
+ }
64
+ export function applyTheme(theme) {
65
+ if (typeof document === "undefined")
66
+ return;
67
+ const root = document.documentElement;
68
+ for (const key of THEME_COLOR_KEYS) {
69
+ root.style.setProperty(`--db-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`, theme.colors[key]);
70
+ }
71
+ root.dataset.colorMode = theme.mode;
72
+ root.style.colorScheme = theme.mode;
73
+ root.classList.toggle("dark", theme.mode === "dark");
74
+ root.classList.toggle("light", theme.mode === "light");
75
+ }
package/dist/main.d.ts CHANGED
@@ -19,9 +19,15 @@ export interface MqttMessage {
19
19
  export interface MqttApi {
20
20
  /** Subscribe to a topic filter. `+` matches one level and `#` the remaining levels. */
21
21
  subscribe(topic: string, callback: (message: MqttMessage) => void): Unsubscribe;
22
- /** Rejects when no broker is configured or the broker refuses the message. */
22
+ /**
23
+ * Rejects with `invalid` when the topic is empty or the payload isn't a string (nothing is sent), and with
24
+ * `unavailable` when the workspace has no broker, the broker isn't connected, or it refuses the message.
25
+ */
23
26
  publish(topic: string, payload: string): Promise<void>;
24
- /** `unavailable` when no broker is configured for the workspace. */
27
+ /**
28
+ * `unavailable` when the workspace has no broker: none is configured, or the server couldn't connect to it when
29
+ * it was saved or when the server started.
30
+ */
25
31
  readonly status: MqttStatus;
26
32
  onStatusChange(callback: (status: MqttStatus) => void): Unsubscribe;
27
33
  }
package/dist/types.d.ts CHANGED
@@ -4,11 +4,25 @@
4
4
  * Workspace data comes as models: an `Activity`, `Category` or `Session` with its fields, links,
5
5
  * derived values and actions. Models read the latest snapshot the app sent, synchronously; there is
6
6
  * one model per row for the life of a `ctx`, and `model.data` is its plain, frozen row. Later API
7
- * versions may add fields and new values to string unions such as `trackingType`, so plugins must
8
- * ignore what they don't recognise.
7
+ * versions may add fields and new values to string unions such as `trackingType`, `goal.type` and
8
+ * `period`, so plugins must ignore what they don't recognise.
9
9
  */
10
10
  /** Stops a subscription. Subscriptions made through `ctx` also stop when the instance stops. */
11
11
  export type Unsubscribe = () => void;
12
+ /** An activity's goal: a total duration (seconds) or a number of times, per period. */
13
+ export type ActivityGoal = {
14
+ readonly type: "duration";
15
+ readonly seconds: number;
16
+ } | {
17
+ readonly type: "count";
18
+ readonly count: number;
19
+ };
20
+ /**
21
+ * The calendar period a goal resets over, starting at local midnight (weeks start on Sunday). Local is the browser's
22
+ * time zone in a UI, like the app's own progress, but the server process's (its `TZ`) in main code, which can be hours
23
+ * off the user's for the whole period.
24
+ */
25
+ export type GoalPeriod = "day" | "week" | "month" | "year";
12
26
  /** An activity's plain row: what `activity.data` returns. */
13
27
  export interface ActivityData {
14
28
  readonly id: string;
@@ -25,6 +39,24 @@ export interface ActivityData {
25
39
  /** Position within the activity's category. */
26
40
  readonly sortOrder: number;
27
41
  readonly unit: string | null;
42
+ /**
43
+ * The activity's goal (from 0.2.2), or null when it has none. A point activity's goal is always a count of marks; a
44
+ * span activity's counts sessions or totals their duration, as the user chose. Progress isn't included: work it out
45
+ * from the sessions in the current `period`. `undefined` when the Drift Beacon running the plugin predates goals
46
+ * (main code and UIs alike).
47
+ */
48
+ readonly goal?: ActivityGoal | null;
49
+ /**
50
+ * The period progress towards `goal` covers (from 0.2.2), or null when it covers all history. `undefined` when the
51
+ * Drift Beacon running the plugin predates goals.
52
+ */
53
+ readonly period?: GoalPeriod | null;
54
+ /**
55
+ * Users who have this activity pinned, sorted (from 0.2.2); a user pins at most one activity. Main code sees every
56
+ * user's pins, a UI only its own user's: use `isPinned` for the current user. `undefined` when the Drift Beacon
57
+ * running the plugin predates pins.
58
+ */
59
+ readonly pinnedBy?: readonly string[];
28
60
  }
29
61
  /** A category's plain row: what `category.data` returns. */
30
62
  export interface CategoryData {
@@ -63,6 +95,11 @@ export interface Activity extends Readonly<ActivityData>, Model<ActivityData> {
63
95
  readonly isPoint: boolean;
64
96
  /** The current user has a live session of this activity. */
65
97
  readonly isLive: boolean;
98
+ /**
99
+ * The current user has this activity pinned (from 0.2.2). Test it for truthiness: main code gets its models from the
100
+ * Drift Beacon running it, and on one older than 0.2.2 this is `undefined` (a UI bundles its own models: false).
101
+ */
102
+ readonly isPinned: boolean;
66
103
  readonly category: Category | undefined;
67
104
  /** This activity's sessions, newest first. */
68
105
  sessions(filter?: Omit<SessionFilter, "activityId">): readonly Session[];
@@ -178,13 +215,13 @@ export interface SessionsApi {
178
215
  /** Data change events. They can arrive in bursts after a sync; don't use them to trigger actions. */
179
216
  onChange(callback: (change: Change<Session>) => void): Unsubscribe;
180
217
  }
181
- /** The plugin's own key-value storage, per user and workspace. Values must be JSON-compatible. */
218
+ /** The plugin's own key-value storage, per user and workspace: non-empty string keys, JSON-compatible values. */
182
219
  export interface StorageApi {
183
220
  get<T = unknown>(key: string): T | undefined;
184
221
  keys(): readonly string[];
185
222
  /**
186
223
  * Applies locally at once and resolves when Drift Beacon has accepted the write. `undefined` removes the key;
187
- * a value that isn't JSON-compatible rejects with `invalid`.
224
+ * an empty key or a value that isn't JSON-compatible rejects with `invalid`.
188
225
  */
189
226
  set(key: string, value: unknown): Promise<void>;
190
227
  /** Applies locally at once and resolves when Drift Beacon has accepted the removal. */
@@ -237,6 +274,13 @@ export interface PluginPeer {
237
274
  command<T = unknown>(name: string, input?: Readonly<Record<string, unknown>>, options?: CommandOptions): Promise<T>;
238
275
  /** The state it publishes (manifest.json `provides.state`). */
239
276
  readonly state: PeerStateApi;
277
+ /**
278
+ * Called for each of its events with this name (manifest.json `provides.events`), with the payload (frozen). In a
279
+ * UI (from 0.2.1): from `connect()` on, with the state its plugin set before it, not replayed, errors logged, and
280
+ * every open copy of the UI gets each one; against an older Drift Beacon it's never called. In main code, see
281
+ * `MainPluginPeer.onEvent`. Throws `invalid` for a name that isn't camelCase.
282
+ */
283
+ onEvent(name: string, callback: (payload: unknown) => void): Unsubscribe;
240
284
  }
241
285
  /** The plugins this one uses. */
242
286
  export interface PluginsApi {
@@ -245,4 +289,28 @@ export interface PluginsApi {
245
289
  /** This plugin itself, as other plugins see it. */
246
290
  readonly self: PluginPeer;
247
291
  }
292
+ /** Semantic colors supplied by the UI host (SDK 0.2.3). Values are complete CSS colors. */
293
+ export interface UiThemeColors {
294
+ readonly background: string;
295
+ readonly foreground: string;
296
+ readonly surface: string;
297
+ readonly surfaceForeground: string;
298
+ readonly surfaceRaised: string;
299
+ readonly muted: string;
300
+ readonly border: string;
301
+ readonly accent: string;
302
+ readonly accentForeground: string;
303
+ readonly danger: string;
304
+ readonly dangerForeground: string;
305
+ readonly success: string;
306
+ readonly successForeground: string;
307
+ readonly warning: string;
308
+ readonly warningForeground: string;
309
+ readonly focus: string;
310
+ }
311
+ /** An immutable theme snapshot. background is the surface containing this plugin. */
312
+ export interface UiTheme {
313
+ readonly mode: "light" | "dark";
314
+ readonly colors: Readonly<UiThemeColors>;
315
+ }
248
316
  //# sourceMappingURL=types.d.ts.map
package/dist/ui.d.ts CHANGED
@@ -1,9 +1,13 @@
1
- import type { ActivitiesApi, CategoriesApi, PluginConfig, PluginInfo, PluginsApi, SessionsApi, StorageApi, Unsubscribe, UserInfo, WorkspaceInfo } from "./types.ts";
1
+ import type { ActivitiesApi, CategoriesApi, PluginConfig, PluginInfo, PluginsApi, SessionsApi, StorageApi, UiTheme, Unsubscribe, UserInfo, WorkspaceInfo } from "./types.ts";
2
2
  export { PluginError } from "./errors.ts";
3
3
  export type * from "./types.ts";
4
4
  export { API_VERSION } from "./version.ts";
5
5
  /** Everything a plugin UI can use. */
6
6
  export interface UiContext {
7
+ /** Current host theme; SDK 0.2.3 applies --db-* variables before connect resolves. Older hosts use a dark fallback. */
8
+ readonly theme: UiTheme;
9
+ /** Theme-only updates do not fire onDataChange. CSS updates before this callback runs. */
10
+ onThemeChange(callback: (theme: UiTheme) => void): Unsubscribe;
7
11
  /** The installed plugin; it can change while the UI is open, for example to a new version. */
8
12
  readonly plugin: PluginInfo;
9
13
  readonly user: UserInfo;
@@ -15,13 +19,14 @@ export interface UiContext {
15
19
  readonly sessions: SessionsApi;
16
20
  /**
17
21
  * Called after each update from the app (workspace data, config, storage, or other plugins' status and state) and
18
- * after local storage writes.
22
+ * after local storage writes; not for other plugins' events.
19
23
  */
20
24
  onDataChange(callback: () => void): Unsubscribe;
21
25
  readonly storage: StorageApi;
22
26
  /**
23
- * Its own plugin and the plugins it uses: their status and published state (within about 250 ms of a change), and
24
- * their commands. A command's effects on their state, storage or data can arrive just after it resolves.
27
+ * Its own plugin and the plugins it uses: their status and published state (within about 250 ms of a change),
28
+ * their events (with them), and their commands. A command's effects on their state, storage or data, and its events,
29
+ * can arrive just after it resolves.
25
30
  */
26
31
  readonly plugins: PluginsApi;
27
32
  }
package/dist/ui.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { PluginError } from "./errors.js";
2
+ import { checkedAction } from "./internal/actions.js";
2
3
  import { Collection } from "./internal/collection.js";
3
4
  import { deepFreeze } from "./internal/freeze.js";
4
5
  import { jsonEqual } from "./internal/json-equal.js";
5
6
  import { WorkspaceModels } from "./internal/models.js";
6
- import { commandArgumentsProblem, commandBudgetMs, samePluginStatus } from "./internal/peers.js";
7
+ import { commandArgumentsProblem, commandBudgetMs, eventNameProblem, samePluginStatus } from "./internal/peers.js";
7
8
  import { isUiMessage, UI_CHANNEL, } from "./internal/protocol.js";
9
+ import { applyTheme, fallbackTheme, readTheme } from "./internal/theme.js";
8
10
  import { API_VERSION } from "./version.js";
9
11
  export { PluginError } from "./errors.js";
10
12
  export { API_VERSION } from "./version.js";
@@ -79,6 +81,8 @@ function open(timeoutMs) {
79
81
  class UiClient {
80
82
  post;
81
83
  context;
84
+ theme;
85
+ themeListeners = new Set();
82
86
  config;
83
87
  plugin;
84
88
  rows = {
@@ -106,6 +110,8 @@ class UiClient {
106
110
  peerObjects = new Map();
107
111
  constructor(welcome, post) {
108
112
  this.post = post;
113
+ this.theme = readTheme(welcome.theme) ?? fallbackTheme();
114
+ applyTheme(this.theme);
109
115
  this.config = welcome.config;
110
116
  this.plugin = welcome.plugin;
111
117
  this.peersShared = welcome.peers !== undefined;
@@ -127,11 +133,11 @@ class UiClient {
127
133
  this.applyData(welcome.data);
128
134
  const apis = this.models.apis();
129
135
  const self = this;
130
- const subscribe = (listeners, callback) => {
131
- listeners.add(callback);
132
- return () => listeners.delete(callback);
133
- };
134
136
  this.context = {
137
+ get theme() {
138
+ return self.theme;
139
+ },
140
+ onThemeChange: (callback) => subscribe(self.themeListeners, callback),
135
141
  get plugin() {
136
142
  return self.plugin;
137
143
  },
@@ -178,10 +184,6 @@ class UiClient {
178
184
  if (existing)
179
185
  return existing;
180
186
  const view = this.viewOf(id);
181
- const subscribe = (listeners, callback) => {
182
- listeners.add(callback);
183
- return () => listeners.delete(callback);
184
- };
185
187
  const peer = Object.freeze({
186
188
  id,
187
189
  get status() {
@@ -194,6 +196,20 @@ class UiClient {
194
196
  keys: () => [...view.state.keys()],
195
197
  onChange: (callback) => subscribe(view.stateListeners, callback),
196
198
  }),
199
+ onEvent: (name, callback) => {
200
+ const invalidName = eventNameProblem(name);
201
+ if (invalidName)
202
+ throw new PluginError("invalid", invalidName);
203
+ const problem = this.unreachable(id);
204
+ if (problem)
205
+ throw problem;
206
+ let listeners = view.eventListeners.get(name);
207
+ if (!listeners) {
208
+ listeners = new Set();
209
+ view.eventListeners.set(name, listeners);
210
+ }
211
+ return subscribe(listeners, callback);
212
+ },
197
213
  });
198
214
  this.peerObjects.set(id, peer);
199
215
  return peer;
@@ -206,7 +222,13 @@ class UiClient {
206
222
  viewOf(id) {
207
223
  let view = this.peerViews.get(id);
208
224
  if (!view) {
209
- view = { status: NO_PEERS, state: new Map(), statusListeners: new Set(), stateListeners: new Set() };
225
+ view = {
226
+ status: NO_PEERS,
227
+ state: new Map(),
228
+ statusListeners: new Set(),
229
+ stateListeners: new Set(),
230
+ eventListeners: new Map(),
231
+ };
210
232
  this.peerViews.set(id, view);
211
233
  }
212
234
  return view;
@@ -270,6 +292,7 @@ class UiClient {
270
292
  receive(message) {
271
293
  if (message.type === "welcome") {
272
294
  this.plugin = message.plugin;
295
+ this.updateTheme(readTheme(message.theme) ?? fallbackTheme());
273
296
  this.receive({
274
297
  channel: message.channel,
275
298
  type: "state",
@@ -291,8 +314,17 @@ class UiClient {
291
314
  pending.resolve(message.result);
292
315
  return;
293
316
  }
317
+ if (message.type === "event") {
318
+ this.applyEvent(message);
319
+ return;
320
+ }
294
321
  if (message.type !== "state")
295
322
  return;
323
+ if (message.theme) {
324
+ const theme = readTheme(message.theme);
325
+ if (theme)
326
+ this.updateTheme(theme);
327
+ }
296
328
  if (message.config)
297
329
  this.config = message.config;
298
330
  if (message.data)
@@ -304,7 +336,25 @@ class UiClient {
304
336
  }
305
337
  if (message.peers)
306
338
  this.applyPeers(message.peers);
307
- notify(this.dataListeners, (listener) => listener());
339
+ if (message.config || message.data || message.storage || message.peers) {
340
+ notify(this.dataListeners, (listener) => listener());
341
+ }
342
+ }
343
+ updateTheme(theme) {
344
+ if (jsonEqual(this.theme, theme))
345
+ return;
346
+ this.theme = theme;
347
+ applyTheme(theme);
348
+ notify(this.themeListeners, (listener) => listener(theme));
349
+ }
350
+ applyEvent({ plugin, event, payload }) {
351
+ if (!this.listed.has(plugin))
352
+ return;
353
+ const listeners = this.peerViews.get(plugin)?.eventListeners.get(event);
354
+ if (!listeners?.size)
355
+ return;
356
+ const frozen = deepFreeze(payload);
357
+ notify(listeners, (listener) => listener(frozen));
308
358
  }
309
359
  applyData(data) {
310
360
  const changes = this.models.update({
@@ -341,6 +391,9 @@ class UiClient {
341
391
  return changed;
342
392
  }
343
393
  async write(key, value) {
394
+ const checked = checkedAction(value === undefined ? { name: "storageRemove", key } : { name: "storageSet", key, value });
395
+ if (checked?.ok === false)
396
+ throw new PluginError("invalid", checked.problem);
344
397
  const writeId = ++this.nextWrite;
345
398
  this.pendingWrites.set(key, (this.pendingWrites.get(key) ?? 0) + 1);
346
399
  this.writesSincePush.set(key, writeId);
@@ -385,13 +438,24 @@ class UiClient {
385
438
  });
386
439
  }
387
440
  }
441
+ function subscribe(listeners, callback) {
442
+ if (typeof callback !== "function")
443
+ throw new PluginError("invalid", "Callback must be a function");
444
+ listeners.add(callback);
445
+ return () => listeners.delete(callback);
446
+ }
447
+ const logListenerError = (error) => console.error("[drift-beacon] Error in plugin listener:", error);
388
448
  function notify(listeners, call) {
389
- for (const listener of listeners) {
449
+ for (const listener of [...listeners]) {
450
+ if (!listeners.has(listener))
451
+ continue;
390
452
  try {
391
- call(listener);
453
+ const result = call(listener);
454
+ if (result && typeof result.then === "function")
455
+ result.then(undefined, logListenerError);
392
456
  }
393
457
  catch (error) {
394
- console.error("[drift-beacon] Error in plugin listener:", error);
458
+ logListenerError(error);
395
459
  }
396
460
  }
397
461
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drift-beacon/plugin",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "description": "The Drift Beacon plugin SDK: APIs for plugin main code and UIs, the driftBeacon() Vite plugin and the dbplugin CLI",
5
5
  "license": "MIT",
6
6
  "author": {