@sanity/workbench 0.1.0-alpha.13 → 0.1.0-alpha.15

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.
@@ -3,15 +3,23 @@ import { raise, sendTo, setup, type ActorOptions } from "xstate";
3
3
 
4
4
  import { authLogic } from "./auth.machine";
5
5
  import { inspect } from "./inspect";
6
+ import {
7
+ type SystemPreferencesAdapter,
8
+ systemPreferencesLogic,
9
+ } from "./system-preferences.machine";
6
10
  import {
7
11
  telemetryLogic,
8
12
  type WorkbenchUserProperties,
9
13
  } from "./telemetry.machine";
10
14
 
11
- type OSInput = WorkbenchUserProperties;
15
+ type OSInput = WorkbenchUserProperties & {
16
+ systemPreferencesAdapter?: SystemPreferencesAdapter;
17
+ };
18
+
12
19
  type OSContext = {
13
20
  instance: SanityInstance;
14
21
  userProperties: WorkbenchUserProperties;
22
+ systemPreferencesAdapter: SystemPreferencesAdapter | undefined;
15
23
  };
16
24
 
17
25
  /**
@@ -28,7 +36,12 @@ export interface OSBaseInput extends Pick<OSContext, "instance"> {}
28
36
  * import { os, createOSOptions } from "@sanity/workbench/system";
29
37
  * import { useActor } from "@xstate/react";
30
38
  *
31
- * const [state, send] = useActor(os, createOSOptions());
39
+ * const [state, send] = useActor(os, createOSOptions({
40
+ * version: "1.0.0",
41
+ * organizationId: "...",
42
+ * environment: "production",
43
+ * userAgent: navigator.userAgent,
44
+ * }));
32
45
  * ```
33
46
  */
34
47
  export const os = setup({
@@ -43,11 +56,13 @@ export const os = setup({
43
56
  children: {} as {
44
57
  auth: "auth";
45
58
  telemetry: "telemetry";
59
+ "system-preferences": "systemPreferences";
46
60
  },
47
61
  },
48
62
  actors: {
49
63
  auth: authLogic,
50
64
  telemetry: telemetryLogic,
65
+ systemPreferences: systemPreferencesLogic,
51
66
  },
52
67
  guards: {
53
68
  hasTag: (_, params: { hasTag: boolean }) => params.hasTag,
@@ -72,6 +87,7 @@ export const os = setup({
72
87
  environment: input.environment,
73
88
  userAgent: input.userAgent,
74
89
  },
90
+ systemPreferencesAdapter: input.systemPreferencesAdapter,
75
91
  }),
76
92
  initial: "booting",
77
93
  invoke: [
@@ -121,6 +137,12 @@ export const os = setup({
121
137
  },
122
138
  ],
123
139
  },
140
+ {
141
+ id: "system-preferences",
142
+ systemId: "system-preferences",
143
+ src: "systemPreferences",
144
+ input: ({ context }) => ({ adapter: context.systemPreferencesAdapter }),
145
+ },
124
146
  ],
125
147
  states: {
126
148
  booting: {
@@ -152,8 +174,11 @@ export const os = setup({
152
174
  });
153
175
 
154
176
  /**
155
- * Creates a set of default options for the OS machine, including passing
156
- * the sanity configuration for the internal instance.
177
+ * Creates a set of default options for the OS machine. Forwards the workbench
178
+ * user properties to the machine's input and wires the structured-logging
179
+ * `inspect` callback. Browser-specific concerns (color-scheme seeding,
180
+ * persistence) live in the {@link SystemPreferencesAdapter} the host passes
181
+ * via `systemPreferencesAdapter`.
157
182
  * @public
158
183
  */
159
184
  export function createOSOptions(input: OSInput) {
@@ -0,0 +1,215 @@
1
+ import { assign, fromCallback, sendTo, setup } from "xstate";
2
+
3
+ type ColorScheme = "light" | "dark";
4
+
5
+ type ColorSchemePreference = "system" | ColorScheme;
6
+
7
+ /**
8
+ * The system-preferences machine's context. Consumers read fields directly
9
+ * (e.g. via `useSelector`) rather than going through a resolver utility —
10
+ * the action handlers keep `colorScheme` in sync with `preferredColorScheme`
11
+ * and `osColorScheme`.
12
+ *
13
+ * Note: `colorScheme` is intentionally stored as derived state in context so
14
+ * consumers can read the resolved value directly without a utility. The
15
+ * action handlers below are the single source of truth for the resolution.
16
+ * @public
17
+ */
18
+ export type SystemPreferencesContext = {
19
+ /** User's color scheme choice; `system` defers to the OS preference. */
20
+ preferredColorScheme: ColorSchemePreference;
21
+ /** Last reported OS color scheme. Used to resolve `colorScheme` when the
22
+ * preference is `system`. */
23
+ osColorScheme: ColorScheme;
24
+ /** Resolved color scheme — what the UI should render. Recomputed by the
25
+ * action handlers whenever an input changes. */
26
+ colorScheme: ColorScheme;
27
+ };
28
+
29
+ const DEFAULT_PREFERRED_COLOR_SCHEME: ColorSchemePreference = "system";
30
+ const DEFAULT_OS_COLOR_SCHEME: ColorScheme = "light";
31
+
32
+ /**
33
+ * Events the system-preferences machine accepts. Both originate from the
34
+ * host's adapter — `preferredColorScheme.set` is also forwarded back to it
35
+ * so it can persist the user's choice.
36
+ * @public
37
+ */
38
+ export type SystemPreferencesEvent =
39
+ | {
40
+ type: "preferredColorScheme.set";
41
+ preferredColorScheme: ColorSchemePreference;
42
+ }
43
+ | {
44
+ type: "osColorScheme.set";
45
+ osColorScheme: ColorScheme;
46
+ };
47
+
48
+ /**
49
+ * Adapter interface the host implements to give the system-preferences
50
+ * machine access to the user's environment (OS color scheme, persistent
51
+ * storage, cross-context change notifications).
52
+ *
53
+ * The package owns all orchestration — synchronous seeding, idempotent
54
+ * persistence, mapping cleared storage to `"system"`. The host's
55
+ * implementation is pure DOM/storage glue: read, write, subscribe.
56
+ * @public
57
+ */
58
+ export type SystemPreferencesAdapter = {
59
+ /** Read the user's stored preference. `null` means "no preference set"
60
+ * (the machine treats this as `"system"`). */
61
+ getPreferredColorScheme: () => ColorSchemePreference | null;
62
+ /** Write the user's preference to persistent storage. */
63
+ persistPreferredColorScheme: (value: ColorSchemePreference) => void;
64
+ /** Subscribe to cross-context preference changes (e.g. another tab
65
+ * writing to the shared storage). The callback receives the new stored
66
+ * value (or `null` if it was cleared). Returns an unsubscribe function. */
67
+ subscribePreferredColorScheme: (
68
+ callback: (next: ColorSchemePreference | null) => void,
69
+ ) => () => void;
70
+ /** Read the current OS-detected color scheme. */
71
+ getOsColorScheme: () => ColorScheme;
72
+ /** Subscribe to OS color-scheme changes (e.g. user flipping dark mode).
73
+ * Returns an unsubscribe function. */
74
+ subscribeOsColorScheme: (callback: (next: ColorScheme) => void) => () => void;
75
+ };
76
+
77
+ const resolveColorScheme = (
78
+ preferred: ColorSchemePreference,
79
+ osColorScheme: ColorScheme,
80
+ ): ColorScheme => (preferred === "system" ? osColorScheme : preferred);
81
+
82
+ // Internal bridge actor — subscribes to the host's adapter for runtime
83
+ // changes and persists user-driven preference changes when the parent
84
+ // forwards them. No-op if the host didn't pass an adapter (SSR, tests).
85
+ type AdapterBridgeReceiveEvent = Extract<
86
+ SystemPreferencesEvent,
87
+ { type: "preferredColorScheme.set" }
88
+ >;
89
+
90
+ const adapterBridgeLogic = fromCallback<
91
+ AdapterBridgeReceiveEvent,
92
+ SystemPreferencesAdapter | undefined,
93
+ SystemPreferencesEvent
94
+ >(({ input: adapter, sendBack, receive }) => {
95
+ if (!adapter) return;
96
+
97
+ const unsubscribePreferredColorScheme = adapter.subscribePreferredColorScheme(
98
+ (next) => {
99
+ // Falls back to `"system"` so an external clear (another tab deleting
100
+ // the key) resets the preference rather than silently keeping the
101
+ // previous value.
102
+ sendBack({
103
+ type: "preferredColorScheme.set",
104
+ preferredColorScheme: next ?? "system",
105
+ });
106
+ },
107
+ );
108
+
109
+ const unsubscribeOs = adapter.subscribeOsColorScheme((next) => {
110
+ sendBack({ type: "osColorScheme.set", osColorScheme: next });
111
+ });
112
+
113
+ receive((event) => {
114
+ // Idempotent: skip writes that match the current stored value so a
115
+ // `preferredColorScheme.set` originating from cross-context change
116
+ // doesn't loop back into another write.
117
+ if (adapter.getPreferredColorScheme() === event.preferredColorScheme)
118
+ return;
119
+
120
+ adapter.persistPreferredColorScheme(event.preferredColorScheme);
121
+ });
122
+
123
+ return () => {
124
+ unsubscribePreferredColorScheme();
125
+ unsubscribeOs();
126
+ };
127
+ });
128
+
129
+ /**
130
+ * @internal
131
+ */
132
+ export const systemPreferencesLogic = setup({
133
+ types: {
134
+ input: {} as { adapter?: SystemPreferencesAdapter },
135
+ context: {} as SystemPreferencesContext & {
136
+ adapter: SystemPreferencesAdapter | undefined;
137
+ },
138
+ events: {} as SystemPreferencesEvent,
139
+ },
140
+ actors: {
141
+ adapterBridge: adapterBridgeLogic,
142
+ },
143
+ actions: {
144
+ setPreferredColorScheme: assign({
145
+ preferredColorScheme: (
146
+ _,
147
+ params: { preferredColorScheme: ColorSchemePreference },
148
+ ) => params.preferredColorScheme,
149
+ colorScheme: (
150
+ { context },
151
+ params: { preferredColorScheme: ColorSchemePreference },
152
+ ) =>
153
+ resolveColorScheme(params.preferredColorScheme, context.osColorScheme),
154
+ }),
155
+ setOsColorScheme: assign({
156
+ osColorScheme: (_, params: { osColorScheme: ColorScheme }) =>
157
+ params.osColorScheme,
158
+ colorScheme: ({ context }, params: { osColorScheme: ColorScheme }) =>
159
+ resolveColorScheme(context.preferredColorScheme, params.osColorScheme),
160
+ }),
161
+ },
162
+ }).createMachine({
163
+ id: "system-preferences",
164
+ initial: "ready",
165
+ context: ({ input }) => {
166
+ const adapter = input.adapter;
167
+ const preferredColorScheme =
168
+ adapter?.getPreferredColorScheme() ?? DEFAULT_PREFERRED_COLOR_SCHEME;
169
+ const osColorScheme =
170
+ adapter?.getOsColorScheme() ?? DEFAULT_OS_COLOR_SCHEME;
171
+
172
+ return {
173
+ adapter,
174
+ preferredColorScheme,
175
+ osColorScheme,
176
+ colorScheme: resolveColorScheme(preferredColorScheme, osColorScheme),
177
+ };
178
+ },
179
+ invoke: {
180
+ id: "adapter",
181
+ src: "adapterBridge",
182
+ input: ({ context }) => context.adapter,
183
+ },
184
+ states: {
185
+ ready: {
186
+ on: {
187
+ "preferredColorScheme.set": {
188
+ actions: [
189
+ {
190
+ type: "setPreferredColorScheme",
191
+ params: ({ event }) => ({
192
+ preferredColorScheme: event.preferredColorScheme,
193
+ }),
194
+ },
195
+ // Forward to the adapter bridge so it can persist the user's
196
+ // choice. The bridge guards against redundant writes, so
197
+ // round-tripping a `preferredColorScheme.set` it sourced itself
198
+ // (e.g. from a `storage` event in another tab) is a no-op.
199
+ sendTo("adapter", ({ event }) => event),
200
+ ],
201
+ },
202
+ "osColorScheme.set": {
203
+ actions: [
204
+ {
205
+ type: "setOsColorScheme",
206
+ params: ({ event }) => ({
207
+ osColorScheme: event.osColorScheme,
208
+ }),
209
+ },
210
+ ],
211
+ },
212
+ },
213
+ },
214
+ },
215
+ });