@drift-beacon/plugin 0.2.1 → 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
@@ -222,6 +222,8 @@ Settings items have `name` (unique), `title`, `type` and optional `description`
222
222
 
223
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
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`.
226
+
225
227
  With React, re-render on every update:
226
228
 
227
229
  ```tsx
@@ -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
 
@@ -461,8 +477,25 @@ Users add the repository URL in **Plugins → Repositories** and install from th
461
477
 
462
478
  - `apiVersion` in the manifest must match the SDK: `dbplugin`, `vite build` and the server all check it.
463
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.
464
- - 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, does nothing on an older Drift Beacon. Ignore fields and string values you don't recognise (such as a new `trackingType`).
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`).
465
481
 
466
482
  ## License
467
483
 
468
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.
@@ -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);
@@ -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/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[];
@@ -252,4 +289,28 @@ export interface PluginsApi {
252
289
  /** This plugin itself, as other plugins see it. */
253
290
  readonly self: PluginPeer;
254
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
+ }
255
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;
package/dist/ui.js CHANGED
@@ -6,6 +6,7 @@ import { jsonEqual } from "./internal/json-equal.js";
6
6
  import { WorkspaceModels } from "./internal/models.js";
7
7
  import { commandArgumentsProblem, commandBudgetMs, eventNameProblem, samePluginStatus } from "./internal/peers.js";
8
8
  import { isUiMessage, UI_CHANNEL, } from "./internal/protocol.js";
9
+ import { applyTheme, fallbackTheme, readTheme } from "./internal/theme.js";
9
10
  import { API_VERSION } from "./version.js";
10
11
  export { PluginError } from "./errors.js";
11
12
  export { API_VERSION } from "./version.js";
@@ -80,6 +81,8 @@ function open(timeoutMs) {
80
81
  class UiClient {
81
82
  post;
82
83
  context;
84
+ theme;
85
+ themeListeners = new Set();
83
86
  config;
84
87
  plugin;
85
88
  rows = {
@@ -107,6 +110,8 @@ class UiClient {
107
110
  peerObjects = new Map();
108
111
  constructor(welcome, post) {
109
112
  this.post = post;
113
+ this.theme = readTheme(welcome.theme) ?? fallbackTheme();
114
+ applyTheme(this.theme);
110
115
  this.config = welcome.config;
111
116
  this.plugin = welcome.plugin;
112
117
  this.peersShared = welcome.peers !== undefined;
@@ -129,6 +134,10 @@ class UiClient {
129
134
  const apis = this.models.apis();
130
135
  const self = this;
131
136
  this.context = {
137
+ get theme() {
138
+ return self.theme;
139
+ },
140
+ onThemeChange: (callback) => subscribe(self.themeListeners, callback),
132
141
  get plugin() {
133
142
  return self.plugin;
134
143
  },
@@ -283,6 +292,7 @@ class UiClient {
283
292
  receive(message) {
284
293
  if (message.type === "welcome") {
285
294
  this.plugin = message.plugin;
295
+ this.updateTheme(readTheme(message.theme) ?? fallbackTheme());
286
296
  this.receive({
287
297
  channel: message.channel,
288
298
  type: "state",
@@ -310,6 +320,11 @@ class UiClient {
310
320
  }
311
321
  if (message.type !== "state")
312
322
  return;
323
+ if (message.theme) {
324
+ const theme = readTheme(message.theme);
325
+ if (theme)
326
+ this.updateTheme(theme);
327
+ }
313
328
  if (message.config)
314
329
  this.config = message.config;
315
330
  if (message.data)
@@ -321,7 +336,16 @@ class UiClient {
321
336
  }
322
337
  if (message.peers)
323
338
  this.applyPeers(message.peers);
324
- 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));
325
349
  }
326
350
  applyEvent({ plugin, event, payload }) {
327
351
  if (!this.listed.has(plugin))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drift-beacon/plugin",
3
- "version": "0.2.1",
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": {