@indietabletop/appkit 6.1.6 → 7.0.0-rc.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.
@@ -1,6 +1,10 @@
1
- import { createContext, type ReactNode, useContext } from "react";
1
+ import { type ReactNode } from "react";
2
2
  import type { IndieTabletopClient } from "../client.ts";
3
+ import { createStrictContext } from "../createStrictContext.ts";
3
4
  import type { AppHrefs } from "../hrefs.ts";
5
+ import type { ModernIDB } from "../ModernIDB/index.ts";
6
+ import type { DatabaseAppMachineMethods } from "../store/index.tsx";
7
+ import type { AppkitFormatters } from "./formatters.tsx";
4
8
 
5
9
  export type AppConfig = {
6
10
  app: {
@@ -16,15 +20,56 @@ export type AppConfig = {
16
20
  */
17
21
  icon: string;
18
22
  };
23
+
24
+ /**
25
+ * Usually the value of `import.meta.env.DEV` when using Vite.
26
+ *
27
+ * This is used primarily by the DocumentTitle component, to make it clear
28
+ * which browser tab is in DEV mode.
29
+ */
19
30
  isDev: boolean;
31
+
32
+ /**
33
+ * A reference to the ITC client.
34
+ *
35
+ * This is necessary in order for prefab components to make thier own calls.
36
+ */
20
37
  client: IndieTabletopClient;
38
+
39
+ /**
40
+ * A hrefs configuration.
41
+ *
42
+ * This is necessary so that prefab components can navigate around the app
43
+ * without hard-coded values.
44
+ */
21
45
  hrefs: AppHrefs;
46
+
47
+ /**
48
+ * Customize input placeholders so that they fit their given app thematically.
49
+ */
22
50
  placeholders: {
23
51
  email: string;
24
52
  };
53
+
54
+ /**
55
+ * Formatters accessible to prefab components.
56
+ *
57
+ * You might want to create different formatters based on the app's locale.
58
+ */
59
+ fmt: AppkitFormatters;
60
+
61
+ database: ModernIDB<any, any> & DatabaseAppMachineMethods;
25
62
  };
26
63
 
27
- const AppConfigContext = createContext<AppConfig | null>(null);
64
+ const [AppConfigContext, useAppConfig] = createStrictContext<AppConfig>();
65
+
66
+ export { createFormatters } from "./formatters.tsx";
67
+
68
+ export { useAppConfig };
69
+
70
+ export function useClient() {
71
+ return useAppConfig().client;
72
+ }
28
73
 
29
74
  export function AppConfigProvider(props: {
30
75
  config: AppConfig;
@@ -33,29 +78,3 @@ export function AppConfigProvider(props: {
33
78
  const { config, children } = props;
34
79
  return <AppConfigContext value={config}>{children}</AppConfigContext>;
35
80
  }
36
-
37
- export function useAppConfig() {
38
- const config = useContext(AppConfigContext);
39
-
40
- if (!config) {
41
- throw new Error(
42
- `Attempting to retrieve app config, but none was found within context. ` +
43
- `Make sure that AppConfigProvider is used in the component hierarchy.`,
44
- );
45
- }
46
-
47
- return config;
48
- }
49
-
50
- export function useClient() {
51
- const config = useContext(AppConfigContext);
52
-
53
- if (!config?.client) {
54
- throw new Error(
55
- `Attempting to retrieve ITC client, but none was found within context. ` +
56
- `Make sure that AppConfigProvider is used in the component hierarchy.`,
57
- );
58
- }
59
-
60
- return config.client;
61
- }
@@ -0,0 +1,43 @@
1
+ // English only uses "one" and "other"
2
+ type EnglishCardinalRule = Extract<Intl.LDMLPluralRule, "one" | "other">;
3
+
4
+ export type AppkitFormatters = ReturnType<typeof createFormatters>;
5
+
6
+ // Currently, we only support localizing English variants. Not translating the
7
+ // app into other locales.
8
+ type EnglishLocale = "en" | `en-${string}`;
9
+
10
+ /**
11
+ * Creates formatters that can be used across the app.
12
+ */
13
+ export function createFormatters(locale: EnglishLocale) {
14
+ const cardinalRules = new Intl.PluralRules(locale);
15
+
16
+ const dateTimeFmt = new Intl.DateTimeFormat(locale);
17
+
18
+ const conjunctionFmt = new Intl.ListFormat(locale, {
19
+ style: "long",
20
+ type: "conjunction",
21
+ });
22
+
23
+ const disjunctionFmt = new Intl.ListFormat(locale, {
24
+ style: "long",
25
+ type: "disjunction",
26
+ });
27
+
28
+ return {
29
+ plural(count: number, rules: Record<EnglishCardinalRule, string>) {
30
+ const tag = cardinalRules.select(count) as EnglishCardinalRule;
31
+ return rules[tag].replace("#", count.toString());
32
+ },
33
+ dateTime(date: Date | string | number) {
34
+ return dateTimeFmt.format(new Date(date));
35
+ },
36
+ conjunction(items: string[]) {
37
+ return conjunctionFmt.format(items);
38
+ },
39
+ disjunction(items: string[]) {
40
+ return disjunctionFmt.format(items);
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,20 @@
1
+ import { useCurrentUser } from "@indietabletop/appkit";
2
+ import { useMemo, type ReactNode } from "react";
3
+ import { SWRConfig } from "swr";
4
+
5
+ /**
6
+ * Links SWR cache to the current user. This makes sure that if a user logs
7
+ * out and continues using the app in an anonymous state, they will not be
8
+ * shown stale data.
9
+ */
10
+ export function CacheProvider(props: { children: ReactNode }) {
11
+ const currentUser = useCurrentUser();
12
+ const cacheKey = currentUser ? currentUser.id : "anonymous";
13
+
14
+ const provider = useMemo(() => {
15
+ console.info(`Using SWR cache for "${cacheKey}"`);
16
+ return () => new Map();
17
+ }, [cacheKey]);
18
+
19
+ return <SWRConfig value={{ provider }}>{props.children}</SWRConfig>;
20
+ }
@@ -0,0 +1,67 @@
1
+ import { assignInlineVars } from "@vanilla-extract/dynamic";
2
+ import preview from "../../.storybook/preview.tsx";
3
+ import { SyncIcon } from "./SyncIcon.tsx";
4
+ import { syncTheme } from "./style.css.ts";
5
+
6
+ const meta = preview.meta({
7
+ title: "Components/SyncIcon",
8
+ component: SyncIcon,
9
+ });
10
+
11
+ export const Inactive = meta.story({
12
+ args: {
13
+ status: "INACTIVE",
14
+ },
15
+ });
16
+
17
+ export const Authenticating = meta.story({
18
+ args: {
19
+ status: "AUTHENTICATING",
20
+ },
21
+ });
22
+
23
+ export const Idle = meta.story({
24
+ args: {
25
+ status: "IDLE",
26
+ },
27
+ });
28
+
29
+ export const Error = meta.story({
30
+ args: {
31
+ status: "ERROR",
32
+ },
33
+ });
34
+
35
+ export const Syncing = meta.story({
36
+ args: {
37
+ status: "SYNCING",
38
+ },
39
+ });
40
+
41
+ export const Waiting = meta.story({
42
+ args: {
43
+ status: "WAITING",
44
+ },
45
+ });
46
+
47
+ export const Overrides = meta.story({
48
+ args: {
49
+ status: "AUTHENTICATING",
50
+ },
51
+ decorators: [
52
+ (Story) => {
53
+ return (
54
+ <div
55
+ style={assignInlineVars(syncTheme, {
56
+ idle: "black",
57
+ inactive: "black",
58
+ syncing: "black",
59
+ error: "black",
60
+ })}
61
+ >
62
+ <Story />
63
+ </div>
64
+ );
65
+ },
66
+ ],
67
+ });
@@ -0,0 +1,102 @@
1
+ import { cx } from "../class-names.ts";
2
+ import type { SyncState } from "../store/index.tsx";
3
+ import { icon, syncTheme } from "./style.css.ts";
4
+
5
+ export { syncTheme };
6
+
7
+ function Icon(props: { variant: SyncState }) {
8
+ switch (props.variant) {
9
+ case "IDLE": {
10
+ return (
11
+ <g className={icon.group}>
12
+ <circle className={icon.idle} />
13
+
14
+ <path
15
+ d="M9.0026 14L16.0737 6.92893L14.6595 5.51472L9.0026 11.1716L6.17421 8.3431L4.75999 9.7574L9.0026 14Z"
16
+ className={icon.shape}
17
+ />
18
+ </g>
19
+ );
20
+ }
21
+
22
+ case "AUTHENTICATING":
23
+ case "WAITING": {
24
+ return (
25
+ <g className={icon.group}>
26
+ <circle className={icon.syncing} />
27
+
28
+ <g>
29
+ <circle cx="14" cy="10" className={icon.bounce} />
30
+ <circle cx="10" cy="10" className={icon.bounce} />
31
+ <circle cx="6" cy="10" className={icon.bounce} />
32
+ </g>
33
+ </g>
34
+ );
35
+ }
36
+
37
+ case "SYNCING": {
38
+ return (
39
+ <g className={icon.group}>
40
+ <circle className={icon.idle} />
41
+
42
+ <path
43
+ d="M14.8201 15.0761C16.1628 13.8007 17 11.9981 17 10C17 6.13401 13.866 3 10 3C8.9391 3 7.9334 3.23599 7.03241 3.65834L8.0072 5.41292C8.6177 5.14729 9.2917 5 10 5C12.7614 5 15 7.23858 15 10H12L14.8201 15.0761ZM12.9676 16.3417L11.9928 14.5871C11.3823 14.8527 10.7083 15 10 15C7.23858 15 5 12.7614 5 10H8L5.17993 4.92387C3.83719 6.19929 3 8.0019 3 10C3 13.866 6.13401 17 10 17C11.0609 17 12.0666 16.764 12.9676 16.3417Z"
44
+ className={icon.shape}
45
+ />
46
+ </g>
47
+ );
48
+ }
49
+
50
+ case "ERROR": {
51
+ return (
52
+ <g className={icon.group}>
53
+ <circle className={icon.error} />
54
+
55
+ <path
56
+ d="M10 8.58579L7.17157 5.75735L5.75736 7.17156L8.5858 9.99999L5.75736 12.8284L7.17157 14.2426L10 11.4142L12.8284 14.2426L14.2426 12.8284L11.4142 9.99999L14.2426 7.17156L12.8284 5.75735L10 8.58579Z"
57
+ className={icon.shape}
58
+ />
59
+ </g>
60
+ );
61
+ }
62
+
63
+ case "INACTIVE": {
64
+ return (
65
+ <g className={icon.group}>
66
+ <circle className={icon.inactive} />
67
+
68
+ <path
69
+ d="M9 13V15H11V13H9ZM11 11.3551C12.4457 10.9248 13.5 9.5855 13.5 8C13.5 6.067 11.933 4.5 10 4.5C8.302 4.5 6.88637 5.70919 6.56731 7.31346L8.5288 7.70577C8.6656 7.01823 9.2723 6.5 10 6.5C10.8284 6.5 11.5 7.17157 11.5 8C11.5 8.8284 10.8284 9.5 10 9.5C9.4477 9.5 9 9.9477 9 10.5V12H11V11.3551Z"
70
+ className={icon.shape}
71
+ />
72
+ </g>
73
+ );
74
+ }
75
+
76
+ default: {
77
+ return null;
78
+ }
79
+ }
80
+ }
81
+
82
+ export function SyncIcon(props: {
83
+ status: SyncState;
84
+ title?: string;
85
+ className?: string;
86
+ }) {
87
+ const { status, title } = props;
88
+
89
+ return (
90
+ <svg
91
+ {...cx(props, icon.container({ rotate: status === "SYNCING" }))}
92
+ width="20px"
93
+ height="20px"
94
+ viewBox="0 0 20 20"
95
+ version="1.1"
96
+ xmlns="http://www.w3.org/2000/svg"
97
+ >
98
+ <title>{title}</title>
99
+ <Icon variant={status} />
100
+ </svg>
101
+ );
102
+ }
@@ -0,0 +1,222 @@
1
+ import {
2
+ Disclosure,
3
+ DisclosureContent,
4
+ DisclosureProvider,
5
+ } from "@ariakit/react";
6
+ import { useAppConfig } from "../AppConfig/AppConfig.tsx";
7
+ import {
8
+ useLastSuccessfulSyncTs,
9
+ useSyncLog,
10
+ useSyncState,
11
+ type SyncState,
12
+ } from "../store/index.tsx";
13
+ import type { SyncAttempt } from "../store/types.ts";
14
+ import type { FailurePayload } from "../types.ts";
15
+ import { log } from "./style.css.ts";
16
+ import { SyncIcon } from "./SyncIcon.tsx";
17
+
18
+ function SyncActionSuccess(props: {
19
+ heading: string;
20
+ items: { id: string; name: string; deleted: boolean }[];
21
+ }) {
22
+ const { heading, items } = props;
23
+
24
+ return (
25
+ <div>
26
+ <div>
27
+ <strong>{heading}</strong>
28
+ </div>
29
+
30
+ <div style={{ marginBlockStart: "0.25rem" }}>
31
+ {items.length > 0 ?
32
+ <ul
33
+ style={{
34
+ listStyle: "initial",
35
+ paddingInlineStart: "1.5rem",
36
+ }}
37
+ >
38
+ {items.map((item) => {
39
+ const name = item.name || "Unnamed item";
40
+
41
+ if (item.deleted) {
42
+ return (
43
+ <li key={item.id}>
44
+ Synced deletion of {name} (<code>{item.id}</code>)
45
+ </li>
46
+ );
47
+ }
48
+
49
+ return (
50
+ <li key={item.id}>
51
+ Synced {name} (<code>{item.id}</code>)
52
+ </li>
53
+ );
54
+ })}
55
+ </ul>
56
+ : <div style={{ opacity: 0.5 }}>
57
+ <em>Everything up to date.</em>
58
+ </div>
59
+ }
60
+ </div>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ const failureToLabel: Record<FailurePayload["type"], string> = {
66
+ NETWORK_ERROR: "A network error occurred.",
67
+ API_ERROR: "Server responded with error ",
68
+ UNKNOWN_ERROR: "An unexpected error ocurred. Try restarting the app.",
69
+ VALIDATION_ERROR: "Data validation error occurred. Try restarting the app.",
70
+ };
71
+
72
+ function SyncActionFailure(props: {
73
+ heading: string;
74
+ failure: FailurePayload;
75
+ }) {
76
+ const { heading, failure } = props;
77
+ const label = failureToLabel[failure.type];
78
+ return (
79
+ <div>
80
+ {heading}
81
+ {": "}
82
+ {failure.type === "API_ERROR" ?
83
+ ` ${label} ${failure.code}.`
84
+ : ` ${label}`}
85
+ </div>
86
+ );
87
+ }
88
+
89
+ const syncStateToLabel: Record<SyncState, string> = {
90
+ WAITING: "Waiting for additional changes...",
91
+ IDLE: "Sync enabled. Waiting for changes.",
92
+ AUTHENTICATING: "Verifying identity...",
93
+ ERROR: "Last sync unsuccessful. Waiting for changes.",
94
+ INACTIVE: "Sync not enabled. Log in or join to enable.",
95
+ SYNCING: "Syncing...",
96
+ };
97
+
98
+ export function SyncLogList(props: {
99
+ syncState: SyncState;
100
+ syncLog: SyncAttempt[];
101
+ lastSuccessfulSyncTs: number | null;
102
+ }) {
103
+ const { syncLog, syncState, lastSuccessfulSyncTs } = props;
104
+ const { fmt } = useAppConfig();
105
+
106
+ return (
107
+ <div>
108
+ <div className={log.item}>
109
+ <SyncIcon status={syncState} />
110
+
111
+ <div>
112
+ <div>{syncStateToLabel[syncState]}</div>
113
+
114
+ <div className={log.itemTimestamp}>
115
+ {lastSuccessfulSyncTs ?
116
+ `Last successful sync at: ${fmt.dateTime(lastSuccessfulSyncTs)}`
117
+ : "Not synced yet."}
118
+ </div>
119
+ </div>
120
+ </div>
121
+
122
+ {syncLog.map((logItem) => {
123
+ const isFailure =
124
+ logItem.pull?.type === "FAILURE" || logItem.push?.type == "FAILURE";
125
+
126
+ const itemsSynced =
127
+ (logItem.pull?.type === "SUCCESS" ?
128
+ logItem.pull.value.pulled.length
129
+ : 0) +
130
+ (logItem.push?.type === "SUCCESS" ?
131
+ logItem.push.value.pushed.length + logItem.push.value.pulled.length
132
+ : 0);
133
+
134
+ return (
135
+ <DisclosureProvider key={logItem.startedTs}>
136
+ <Disclosure className={log.item}>
137
+ {isFailure ?
138
+ <>
139
+ <SyncIcon status="ERROR" />
140
+ <div>
141
+ Sync attempt failed
142
+ <div className={log.itemTimestamp}>
143
+ {fmt.dateTime(logItem.startedTs)}
144
+ </div>
145
+ </div>
146
+ </>
147
+ : <>
148
+ <SyncIcon status="IDLE" />
149
+ <div>
150
+ {"Sync successful. "}
151
+ {itemsSynced === 0 ?
152
+ "Everything up to date."
153
+ : fmt.plural(itemsSynced, {
154
+ one: "1 item synced.",
155
+ other: "# items synced.",
156
+ })
157
+ }
158
+ <div className={log.itemTimestamp}>
159
+ {fmt.dateTime(logItem.startedTs)}
160
+ </div>
161
+ </div>
162
+ </>
163
+ }
164
+ </Disclosure>
165
+
166
+ <DisclosureContent className={log.disclosureContent}>
167
+ {logItem.pull && (
168
+ <>
169
+ {logItem.pull.type === "SUCCESS" ?
170
+ <SyncActionSuccess
171
+ heading="Pull successful"
172
+ items={logItem.pull.value.pulled}
173
+ />
174
+ : <SyncActionFailure
175
+ heading="Pull failed"
176
+ failure={logItem.pull.failure}
177
+ />
178
+ }
179
+ </>
180
+ )}
181
+
182
+ {logItem.push && (
183
+ <>
184
+ {logItem.push.type === "SUCCESS" ?
185
+ <>
186
+ <SyncActionSuccess
187
+ heading="Push successful"
188
+ items={logItem.push.value.pushed}
189
+ />
190
+ <SyncActionSuccess
191
+ heading="Patch successful"
192
+ items={logItem.push.value.pulled}
193
+ />
194
+ </>
195
+ : <SyncActionFailure
196
+ heading="Push failed"
197
+ failure={logItem.push.failure}
198
+ />
199
+ }
200
+ </>
201
+ )}
202
+ </DisclosureContent>
203
+ </DisclosureProvider>
204
+ );
205
+ })}
206
+ </div>
207
+ );
208
+ }
209
+
210
+ export function SyncLog() {
211
+ const syncState = useSyncState();
212
+ const syncLog = useSyncLog();
213
+ const lastSuccessfulSyncTs = useLastSuccessfulSyncTs();
214
+
215
+ return (
216
+ <SyncLogList
217
+ syncState={syncState}
218
+ syncLog={syncLog}
219
+ lastSuccessfulSyncTs={lastSuccessfulSyncTs}
220
+ />
221
+ );
222
+ }
@@ -0,0 +1,219 @@
1
+ import preview from "../../.storybook/preview.tsx";
2
+ import { Failure, Success } from "../async-op.ts";
3
+ import { SyncLogList } from "./SyncLog.tsx";
4
+
5
+ const meta = preview.meta({
6
+ title: "Components/SyncLogList",
7
+ component: SyncLogList,
8
+ });
9
+
10
+ export const Inactive = meta.story({
11
+ args: {
12
+ syncState: "INACTIVE",
13
+ lastSuccessfulSyncTs: null,
14
+ syncLog: [],
15
+ },
16
+ });
17
+
18
+ export const Syncing = meta.story({
19
+ args: {
20
+ syncState: "SYNCING",
21
+ lastSuccessfulSyncTs: 1773753718052,
22
+ syncLog: [
23
+ {
24
+ startedTs: 1773753718052,
25
+ pull: null,
26
+ push: new Success({
27
+ pushed: [
28
+ {
29
+ id: "123",
30
+ name: "Test Item",
31
+ updatedTs: 1773753718052,
32
+ deleted: false,
33
+ },
34
+ ],
35
+ pulled: [],
36
+ }),
37
+ },
38
+ ],
39
+ },
40
+ });
41
+
42
+ export const Idle = meta.story({
43
+ args: {
44
+ syncState: "IDLE",
45
+ lastSuccessfulSyncTs: 1773753718052,
46
+ syncLog: [],
47
+ },
48
+ });
49
+
50
+ export const Waiting = meta.story({
51
+ args: {
52
+ syncState: "WAITING",
53
+ lastSuccessfulSyncTs: 1773753718052,
54
+ syncLog: [],
55
+ },
56
+ });
57
+
58
+ export const Authenticating = meta.story({
59
+ args: {
60
+ syncState: "AUTHENTICATING",
61
+ lastSuccessfulSyncTs: null,
62
+ syncLog: [],
63
+ },
64
+ });
65
+
66
+ export const Error = meta.story({
67
+ args: {
68
+ syncState: "ERROR",
69
+ lastSuccessfulSyncTs: 1773753718052,
70
+ syncLog: [
71
+ {
72
+ startedTs: 1773753718052,
73
+ pull: null,
74
+ push: new Failure({ type: "API_ERROR" as const, code: 500 }),
75
+ },
76
+ ],
77
+ },
78
+ });
79
+
80
+ export const SuccessfulPullAndPush = meta.story({
81
+ args: {
82
+ syncState: "IDLE",
83
+ lastSuccessfulSyncTs: 1773753718052,
84
+ syncLog: [
85
+ {
86
+ startedTs: 1773753718052,
87
+ pull: new Success({
88
+ pulled: [
89
+ {
90
+ id: "p1",
91
+ name: "Pulled Item",
92
+ updatedTs: 1773753718052,
93
+ deleted: false,
94
+ },
95
+ ],
96
+ }),
97
+ push: new Success({
98
+ pushed: [
99
+ {
100
+ id: "s1",
101
+ name: "Pushed Item",
102
+ updatedTs: 1773753718052,
103
+ deleted: false,
104
+ },
105
+ ],
106
+ pulled: [
107
+ {
108
+ id: "s2",
109
+ name: "Patch Item",
110
+ updatedTs: 1773753718052,
111
+ deleted: false,
112
+ },
113
+ ],
114
+ }),
115
+ },
116
+ ],
117
+ },
118
+ });
119
+
120
+ export const AllUpToDate = meta.story({
121
+ args: {
122
+ syncState: "IDLE",
123
+ lastSuccessfulSyncTs: 1773753718052,
124
+ syncLog: [
125
+ {
126
+ startedTs: 1773753718052,
127
+ pull: new Success({ pulled: [] }),
128
+ push: new Success({ pushed: [], pulled: [] }),
129
+ },
130
+ ],
131
+ },
132
+ });
133
+
134
+ export const PullFailure = meta.story({
135
+ args: {
136
+ syncState: "ERROR",
137
+ lastSuccessfulSyncTs: 1773753718052,
138
+ syncLog: [
139
+ {
140
+ startedTs: 1773753718052,
141
+ pull: new Failure({ type: "NETWORK_ERROR" as const }),
142
+ push: null,
143
+ },
144
+ ],
145
+ },
146
+ });
147
+
148
+ export const DeletedAndUnnamedItems = meta.story({
149
+ args: {
150
+ syncState: "IDLE",
151
+ lastSuccessfulSyncTs: 1773753718052,
152
+ syncLog: [
153
+ {
154
+ startedTs: 1773753718052,
155
+ pull: null,
156
+ push: new Success({
157
+ pushed: [
158
+ {
159
+ id: "d1",
160
+ name: "Removed Campaign",
161
+ updatedTs: 1773753718052,
162
+ deleted: true,
163
+ },
164
+ {
165
+ id: "d2",
166
+ name: "",
167
+ updatedTs: 1773753718052,
168
+ deleted: false,
169
+ },
170
+ ],
171
+ pulled: [],
172
+ }),
173
+ },
174
+ ],
175
+ },
176
+ });
177
+
178
+ export const MultipleSyncEntries = meta.story({
179
+ args: {
180
+ syncState: "IDLE",
181
+ lastSuccessfulSyncTs: 1773753718052,
182
+ syncLog: [
183
+ {
184
+ startedTs: 1773753718052,
185
+ pull: new Success({ pulled: [] }),
186
+ push: new Success({ pushed: [], pulled: [] }),
187
+ },
188
+ {
189
+ startedTs: 1773753608052,
190
+ pull: new Success({
191
+ pulled: [
192
+ {
193
+ id: "m1",
194
+ name: "Synced Item",
195
+ updatedTs: 1773753608052,
196
+ deleted: false,
197
+ },
198
+ ],
199
+ }),
200
+ push: new Success({
201
+ pushed: [
202
+ {
203
+ id: "m2",
204
+ name: "Pushed Item",
205
+ updatedTs: 1773753608052,
206
+ deleted: false,
207
+ },
208
+ ],
209
+ pulled: [],
210
+ }),
211
+ },
212
+ {
213
+ startedTs: 1773753508052,
214
+ pull: new Failure({ type: "NETWORK_ERROR" as const }),
215
+ push: null,
216
+ },
217
+ ],
218
+ },
219
+ });
@@ -0,0 +1,126 @@
1
+ import {
2
+ createThemeContract,
3
+ fallbackVar,
4
+ keyframes,
5
+ style,
6
+ } from "@vanilla-extract/css";
7
+ import { recipe } from "@vanilla-extract/recipes";
8
+
9
+ const bounce = keyframes({
10
+ "0%": { translate: "0 0" },
11
+ "25%": { translate: "0 -10%" },
12
+ "50%": { translate: "0 0" },
13
+ "100%": { translate: "0 0" },
14
+ });
15
+
16
+ const rotation = keyframes({
17
+ from: { transform: `rotate(0deg)` },
18
+ to: { transform: `rotate(360deg)` },
19
+ });
20
+
21
+ export const syncTheme = createThemeContract({
22
+ idle: null,
23
+ inactive: null,
24
+ syncing: null,
25
+ error: null,
26
+ });
27
+
28
+ const backplate = style({
29
+ cx: 10,
30
+ cy: 10,
31
+ r: 10,
32
+ });
33
+
34
+ export const icon = {
35
+ container: recipe({
36
+ base: {},
37
+
38
+ variants: {
39
+ rotate: {
40
+ true: {
41
+ animation: `${rotation} linear 2s both infinite`,
42
+ },
43
+ },
44
+ },
45
+ }),
46
+
47
+ group: style({
48
+ stroke: "none",
49
+ fill: "none",
50
+ fillRule: "evenodd",
51
+ }),
52
+
53
+ shape: style({
54
+ fill: "white",
55
+ }),
56
+
57
+ idle: style([
58
+ backplate,
59
+ {
60
+ fill: fallbackVar(syncTheme.idle, "green"),
61
+ },
62
+ ]),
63
+
64
+ inactive: style([
65
+ backplate,
66
+ {
67
+ fill: fallbackVar(syncTheme.inactive, "blue"),
68
+ },
69
+ ]),
70
+
71
+ error: style([
72
+ backplate,
73
+ {
74
+ fill: fallbackVar(syncTheme.error, "red"),
75
+ },
76
+ ]),
77
+
78
+ syncing: style([
79
+ backplate,
80
+ {
81
+ fill: fallbackVar(syncTheme.syncing, "purple"),
82
+ },
83
+ ]),
84
+
85
+ bounce: style({
86
+ vars: {
87
+ "--duration": "1.5s",
88
+ },
89
+
90
+ animation: `${bounce} var(--duration) both infinite`,
91
+ r: 1,
92
+ fill: "white",
93
+
94
+ selectors: {
95
+ "&:nth-child(2)": {
96
+ animationDelay: "calc(var(--duration) * -0.1)",
97
+ },
98
+ "&:nth-child(3)": {
99
+ animationDelay: "calc(var(--duration) * -0.2)",
100
+ },
101
+ },
102
+ }),
103
+ };
104
+
105
+ export const log = {
106
+ item: style({
107
+ display: "grid",
108
+ gridTemplateColumns: "auto minmax(0, 1fr)",
109
+ alignItems: "center",
110
+ gap: "1rem",
111
+ padding: "1rem 0",
112
+ textAlign: "start",
113
+ borderBlockStart: "1px solid hsl(0 0% 90%)",
114
+ inlineSize: "100%",
115
+ }),
116
+
117
+ disclosureContent: style({
118
+ display: "grid",
119
+ gap: "1rem",
120
+ borderInlineStart: "1px solid hsl(0 0% 90%)",
121
+ paddingInlineStart: "1rem",
122
+ marginBlockEnd: "1.5rem",
123
+ }),
124
+
125
+ itemTimestamp: style({ opacity: 0.5 }),
126
+ };
@@ -0,0 +1,15 @@
1
+ import { useSyncState } from "../store/index.tsx";
2
+ import { SyncIcon } from "../Sync/SyncIcon.tsx";
3
+ import { accountIcon } from "./style.css.ts";
4
+
5
+ export function AccountIcon(props: { imgSrc: string }) {
6
+ const { imgSrc } = props;
7
+ const syncState = useSyncState();
8
+
9
+ return (
10
+ <div className={accountIcon.container}>
11
+ <SyncIcon status={syncState} className={accountIcon.syncIcon} />
12
+ <img src={imgSrc} alt="" className={accountIcon.image} />
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,3 @@
1
+ <svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M28 0C43.464 0 56 12.536 56 28C56 31.3019 55.4265 34.4697 54.377 37.4111C52.2143 35.3015 49.2598 34 46 34C39.3726 34 34 39.3726 34 46C34 49.2598 35.3015 52.2143 37.4111 54.377C34.4697 55.4265 31.3019 56 28 56C12.536 56 0 43.464 0 28C0 12.536 12.536 0 28 0Z" fill="white"/>
3
+ </svg>
@@ -1,5 +1,29 @@
1
1
  import { style } from "@vanilla-extract/css";
2
2
  import { Hover, MinWidth } from "../media.ts";
3
+ import iconMask from "./iconMask.svg";
4
+
5
+ export const accountIcon = {
6
+ container: style({
7
+ position: "relative",
8
+ marginInline: "auto",
9
+
10
+ inlineSize: "56px",
11
+ blockSize: "56px",
12
+ }),
13
+
14
+ syncIcon: style({
15
+ position: "absolute",
16
+ right: "0",
17
+ bottom: "0",
18
+ }),
19
+
20
+ image: style({
21
+ mask: `url("${iconMask}") no-repeat`,
22
+ inlineSize: "100%",
23
+ blockSize: "100%",
24
+ borderRadius: "50%",
25
+ }),
26
+ };
3
27
 
4
28
  export const page = style({
5
29
  backgroundColor: "white",
@@ -0,0 +1,15 @@
1
+ import { createContext, use } from "react";
2
+
3
+ export function createStrictContext<T>() {
4
+ const Context = createContext<T | null>(null);
5
+
6
+ const useStrictContext = () => {
7
+ const value = use(Context);
8
+ if (!value) {
9
+ throw new Error(`Value not found in context.`);
10
+ }
11
+ return value;
12
+ };
13
+
14
+ return [Context, useStrictContext] as const;
15
+ }
package/lib/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  // Components
2
+ export * from "./account/AccountIcon.tsx";
2
3
  export * from "./account/CurrentUserFetcher.tsx";
3
4
  export * from "./account/JoinCard.tsx";
4
5
  export * from "./account/LoginCard.tsx";
5
6
  export * from "./account/PasswordResetCard.tsx";
6
7
  export * from "./AppConfig/AppConfig.tsx";
7
8
  export * from "./AuthCard/AuthCard.tsx";
9
+ export * from "./CacheProvider.tsx";
8
10
  export * from "./DialogTrigger/index.tsx";
9
11
  export * from "./DocumentTitle/DocumentTitle.tsx";
10
12
  export * from "./ExternalLink.tsx";
@@ -25,6 +27,8 @@ export * from "./ServiceWorkerHandler.tsx";
25
27
  export * from "./ShareButton/ShareButton.tsx";
26
28
  export * from "./SubscribeCard/SubscribeByEmailCard.tsx";
27
29
  export * from "./SubscribeCard/SubscribeByPledgeCard.tsx";
30
+ export * from "./Sync/SyncIcon.tsx";
31
+ export * from "./Sync/SyncLog.tsx";
28
32
 
29
33
  // Hooks
30
34
  export * from "./RulesetResolver.ts";
@@ -1,54 +1,109 @@
1
1
  import type { UserGameData } from "@indietabletop/types";
2
- import { createActorContext } from "@xstate/react";
3
- import { createContext, useContext, useMemo, type ReactNode } from "react";
4
- import { fromCallback, fromPromise } from "xstate";
2
+ import { useActorRef, useSelector } from "@xstate/react";
3
+ import { createContext, useMemo, type ReactNode } from "react";
4
+ import { Actor, fromCallback, fromPromise } from "xstate";
5
+ import { useAppConfig } from "../AppConfig/AppConfig.tsx";
5
6
  import { Failure, Success } from "../async-op.ts";
6
7
  import type { GameCode, IndieTabletopClient } from "../client.ts";
8
+ import { createStrictContext } from "../createStrictContext.ts";
7
9
  import type { ModernIDB } from "../ModernIDB/ModernIDB.ts";
8
10
  import type { ModernIDBIndexes, ModernIDBSchema } from "../ModernIDB/types.ts";
9
11
  import type { CurrentUser } from "../types.ts";
10
12
  import {
11
13
  machine,
14
+ type AppMachine,
12
15
  type PullChangesInput,
13
16
  type PushChangesInput,
14
17
  } from "./store.ts";
15
18
  import type { MachineEvent, PullResult, PushResult } from "./types.ts";
16
19
  import { toSyncedItems } from "./utils.ts";
17
20
 
18
- export type AppActions = {
19
- clientLogout: (params: { serverUser: CurrentUser }) => Promise<void>;
20
- serverLogout: () => Promise<void>;
21
- logout: () => Promise<void>;
22
- push: () => void;
23
- pull: () => void;
24
- };
21
+ export type AppActions = ReturnType<typeof useCreateActions>;
25
22
 
26
- export const AppActionsContext = createContext<null | AppActions>(null);
23
+ type AppActor = Actor<AppMachine>;
27
24
 
28
- const {
29
- Provider: InternalMachineProvider,
30
- useSelector,
31
- useActorRef,
32
- } = createActorContext(machine);
25
+ export const [AppMachineContext, useAppMachineContext] = createStrictContext<{
26
+ actorRef: AppActor;
27
+ actions: AppActions;
28
+ }>();
33
29
 
34
- export { useActorRef, useSelector };
30
+ export const AppActionsContext = createContext<null | AppActions>(null);
35
31
 
36
32
  export function useCurrentUser() {
37
- return useSelector((s) => s.context.currentUser);
33
+ const { actorRef } = useAppMachineContext();
34
+ return useSelector(actorRef, (s) => s.context.currentUser);
38
35
  }
39
36
 
40
37
  export function useSessionInfo() {
41
- return useSelector((s) => s.context.sessionInfo);
38
+ const { actorRef } = useAppMachineContext();
39
+ return useSelector(actorRef, (s) => s.context.sessionInfo);
42
40
  }
43
41
 
44
- export function useAppActions() {
45
- const actions = useContext(AppActionsContext);
42
+ /**
43
+ * Checks if the app is in an authenticated state.
44
+ *
45
+ * This means that the user that might have been provided in local storage
46
+ * has been checked with the server and they a) have a valid session and
47
+ * b) are matching the current user.
48
+ */
49
+ export function useIsAuthenticated() {
50
+ const { actorRef } = useAppMachineContext();
51
+ return useSelector(actorRef, (s) => s.matches("authenticated"));
52
+ }
53
+
54
+ export function useSyncLog() {
55
+ const { actorRef } = useAppMachineContext();
56
+ return useSelector(actorRef, (s) => s.context.syncLog);
57
+ }
58
+
59
+ export function useLastSuccessfulSyncTs() {
60
+ const { actorRef } = useAppMachineContext();
61
+ return useSelector(actorRef, (s) => s.context.lastSuccessfulSyncTs);
62
+ }
63
+
64
+ export type SyncState = ReturnType<typeof useSyncState>;
65
+
66
+ /**
67
+ * Returns the app's current sync state.
68
+ */
69
+ export function useSyncState() {
70
+ const { actorRef } = useAppMachineContext();
71
+ return useSelector(actorRef, (s) => {
72
+ switch (true) {
73
+ case s.matches({ authenticated: { sync: "syncing" } }): {
74
+ return "SYNCING";
75
+ }
76
+
77
+ case s.matches({ authenticated: { sync: "waiting" } }): {
78
+ return "WAITING";
79
+ }
80
+
81
+ case s.matches({ authenticated: { sync: "idle" } }): {
82
+ const lastSync = s.context.syncLog[0];
83
+
84
+ if (
85
+ lastSync?.pull?.type === "FAILURE" ||
86
+ lastSync?.push?.type === "FAILURE"
87
+ ) {
88
+ return "ERROR";
89
+ }
46
90
 
47
- if (!actions) {
48
- throw new Error(`Missing context value for app actions.`);
49
- }
91
+ return "IDLE";
92
+ }
93
+
94
+ case s.matches("unauthenticated") && !!s.context.currentUser: {
95
+ return "AUTHENTICATING";
96
+ }
97
+
98
+ default: {
99
+ return "INACTIVE";
100
+ }
101
+ }
102
+ });
103
+ }
50
104
 
51
- return actions;
105
+ export function useAppActions() {
106
+ return useAppMachineContext().actions;
52
107
  }
53
108
 
54
109
  export type DatabaseAppMachineMethods = {
@@ -62,7 +117,7 @@ export type DatabaseAppMachineMethods = {
62
117
  clearAll(): Promise<Success<string> | Failure<string>>;
63
118
  };
64
119
 
65
- export function createAppMachineProvider<
120
+ export function createMachine<
66
121
  Schema extends ModernIDBSchema,
67
122
  Indexes extends ModernIDBIndexes<Schema>,
68
123
  >(options: {
@@ -180,62 +235,60 @@ export function createAppMachineProvider<
180
235
  },
181
236
  );
182
237
 
183
- // Bound provider components
184
-
185
- function AppActionsProvider(props: { children: ReactNode }) {
186
- const { children } = props;
187
- const app = useActorRef();
188
-
189
- const actions: AppActions = useMemo(() => {
190
- async function clientLogout(params: { serverUser: CurrentUser }) {
191
- await database.clearAll();
192
- app.send({ type: "serverUser", ...params });
193
-
194
- // Get new session info
195
- await client.refreshTokens();
196
- }
197
-
198
- async function serverLogout() {
199
- await client.logout();
200
- }
201
-
202
- async function logout() {
203
- await client.logout();
204
- await database.clearAll();
205
- app.send({ type: "reset" });
206
- }
238
+ return machine.provide({
239
+ actors: { auth, sync, pullChanges, pushChanges },
240
+ });
241
+ }
207
242
 
208
- function push() {
209
- app.send({ type: "push" });
210
- }
243
+ function useCreateActions(app: AppActor) {
244
+ const { database, client } = useAppConfig();
245
+
246
+ return useMemo(() => {
247
+ async function clientLogout(params: { serverUser: CurrentUser }) {
248
+ await database.clearAll();
249
+ app.send({ type: "serverUser", ...params });
250
+
251
+ // Get new session info
252
+ await client.refreshTokens();
253
+ }
254
+
255
+ async function serverLogout() {
256
+ await client.logout();
257
+ }
258
+
259
+ async function logout() {
260
+ await client.logout();
261
+ await database.clearAll();
262
+ app.send({ type: "reset" });
263
+ }
264
+
265
+ function push() {
266
+ app.send({ type: "push" });
267
+ }
268
+
269
+ function pull() {
270
+ app.send({ type: "pull" });
271
+ }
272
+
273
+ return {
274
+ pull,
275
+ push,
276
+ logout,
277
+ serverLogout,
278
+ clientLogout,
279
+ };
280
+ }, [app]);
281
+ }
211
282
 
212
- function pull() {
213
- app.send({ type: "pull" });
214
- }
283
+ export function AppMachineProvider(props: {
284
+ value: typeof machine;
285
+ children: ReactNode;
286
+ }) {
287
+ const actorRef = useActorRef(props.value);
288
+ const actions = useCreateActions(actorRef);
289
+ const context = useMemo(() => ({ actorRef, actions }), [actorRef, actions]);
215
290
 
216
- return {
217
- pull,
218
- push,
219
- logout,
220
- serverLogout,
221
- clientLogout,
222
- };
223
- }, [app]);
224
-
225
- return <AppActionsContext value={actions}>{children}</AppActionsContext>;
226
- }
227
-
228
- return function AppMachineProvider(props: { children: ReactNode }) {
229
- const { children } = props;
230
-
231
- return (
232
- <InternalMachineProvider
233
- logic={machine.provide({
234
- actors: { auth, sync, pullChanges, pushChanges },
235
- })}
236
- >
237
- <AppActionsProvider>{children}</AppActionsProvider>
238
- </InternalMachineProvider>
239
- );
240
- };
291
+ return (
292
+ <AppMachineContext value={context}>{props.children}</AppMachineContext>
293
+ );
241
294
  }
@@ -150,8 +150,9 @@ const config = setup({
150
150
  }),
151
151
 
152
152
  patchSessionInfo: assign(({ context }, newSessionInfo: SessionInfo) => {
153
- const sessionInfo = context.sessionInfo
154
- ? { ...context.sessionInfo, expiresTs: newSessionInfo.expiresTs }
153
+ const sessionInfo =
154
+ context.sessionInfo ?
155
+ { ...context.sessionInfo, expiresTs: newSessionInfo.expiresTs }
155
156
  : newSessionInfo;
156
157
 
157
158
  safeStorage.setItem("sessionInfo", sessionInfo);
@@ -225,6 +226,8 @@ const config = setup({
225
226
  },
226
227
  });
227
228
 
229
+ export type AppMachine = typeof machine;
230
+
228
231
  export const machine = config.createMachine({
229
232
  /** @xstate-layout N4IgpgJg5mDOIC5QEMAOqDEAnOYAuA2gAwC6ioqA9rAJZ42UB25IAHogLQBMRAbAHQBOQbwCMggOy8ALES7DRAGhABPTvIn8AzAA5B0rdN4BWCRKKDRvAL7XlaVPwCujZE7wALMI3oBjZHiQGL5OWDg+AKqwYFjEZEggVLT0TCzsCEZc-FyiXHqiOtJcvLwSymoI3Fqi-LkiIjkSIrIStvbozq7uXj40-oEQwaHheFExBKLxFNR0DMwJ6dJL-GJ1gkTSwubS5Yj6mjrG602F0ietdiAOnW6e3n4BQSFh92OxXFOJMynzoIs6uwQljaVw6LluPQeAww0SwADcYm84iwkrNUgtEHkiICOKI8SDrhD7n1HoNniMkaQUd85mlELkctktLxDkQdDoJFYuGVVPTREZaoZjMKdNzRFILu1HETev0nsNXrCJp9UT86QhRVkiHVjDIiHwSlxAXiBaJDEVqkRzNVpASOjKofKXpElR9qclaRiENjed67dLusS5YNorBaEwAJKMABmlGRCVVnr+9JKAkE3LyPF4zN0Sl9JoEZukwuMos5Ev9-AdJOhofDjAAoqxUDQcBB49MPejkwhDDUtERqjwDBIuMYszitOnsmaOdJcqW5zZLoTA7LSfxYCpGL5N9vfDRGFAMBAmGBN3hHlX7WvHRA9zuHwejx2vl3fmxMcVpPxjOLNgOeISAYgLcsY-DmJIxjVJYg6iBslbVsGT5Poex6oE4sAeK+ibdp+CDwdqQjSHogimPopiGICxaaAU6bchseS6IIiG3jWkAoVuO5ofwGEADZ8WhJ5nvwh5wpQADW56rnc64DJx+48fxglHggYmUP0cxxDhNJ4ekuQbPwhz6AYA6FCUWjGgIHLCLopj8tqJmsbJd4KdxR68U4AlCTEWCUFgvF8QEsZYAAtteAYuex95cbusVKV5KlQGpjDiZpTDaVSCa6R++n8j+YjmKYo7ATovDGgY2SbCY2YkUczmQtFbnPlAnlYUJp6MOe6lSRFVZschsWoR5GHtap6npYwmUqjl6pWBIOhGdq1r6pyYqAgOtR8MY2q6HkYiag1QYbkN8UjZhHg+WE-mBcF-nhTJjWDfuw2taNl3jalGkBFppA6e+c26tZpksiRFjzgCvpcFwP7QUU46CDoQH8sYR1yRxQ00BAfFgBg73-WiuX0usAh-sBghaOYZWFICUg-nR0HajDejAWjrmY9juPKQTapejkxRLbk7KDoawqAtmgi-tDxhLNBZljmzTUczjGA80m+EFGI2haDrZhyKKxZGr6SNaNrOvjkU2aiqIivPY+ADuyCzEeeMXWren0lIWRkwBFhIzyFQslkdE8DLXBTtmkqgpFT0nS9jvO8erCwJegRVtGgRYAAFKIO1EAAlBgj3HfJQ0J-QL5ZZ2hNzUUWTcrwMMSNBZEmYCpaaI3lN8PqPDAbaIKMJQEBwCwDjujXXrcDLQgiOIdNyAoOKrLUBjjloPDHFOlbggNpIT7zPa4sBEH6gObImBy+qQxUHBGDUueitmMhmufqMrjeUXBgf6vpAbTI6CIKYAC9RLK+jvlIFYa9zakUcu-KU-Uv4bkPGAQSUAaAACMcY-w9kCQy4ckZ4gUGRcQgIKbgRRpsM0RYmhR2LujGK+4cFEw1KOABQDyYRwppOZYMgtC6m1DIUUSNlwIKQnHR8Z0oDMPVOmIg7DgEU1ATicUi0+FwxkLBeCdDP6x1Li9KRnlvJHhkV6EQi0CEcJAbwbh+YCrbW1MtWQiMWIfxjiXDGBjFLnTGtI7KAMvT8jyPwIw1ReBskKFYaCFUjI6z4Pwng1DCq2wkXFbxrVYqQFMT2cQeJT4imECRIoAd6Sm34SYfUiMzDh3HDoFJ+jJHpP4NGJ2OMIDZPwokzQ9l5xHHgrnBaoEpy1DMCRGGMMRBmFEdHRBejPGPixtg-xk8ew5AKNoJmHIdDdw5LTRu2hdTimbsAsU9T5m7nLmhDp+kIbaE5Drfko4ii5D2VkcpRzhTAW5FoWwtggA */
230
233
  id: "app",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indietabletop/appkit",
3
- "version": "6.1.6",
3
+ "version": "7.0.0-rc.0",
4
4
  "description": "A collection of modules used in apps built by Indie Tabletop Club",
5
5
  "private": false,
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  "test": "vitest run",
12
12
  "test:dev": "vitest watch",
13
13
  "test:release": "npm run test && npm run typecheck",
14
- "storybook": "storybook dev",
14
+ "storybook": "storybook dev --port 11111",
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "exports": {
@@ -31,32 +31,33 @@
31
31
  "react": "^18.0.0 || ^19.0.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@storybook/addon-docs": "^10.1.11",
35
- "@storybook/addon-links": "^10.1.11",
36
- "@storybook/react-vite": "^10.1.11",
37
- "@types/react": "^19.2.8",
38
- "msw": "^2.12.7",
34
+ "@storybook/addon-docs": "^10.2.19",
35
+ "@storybook/addon-links": "^10.2.19",
36
+ "@storybook/react-vite": "^10.2.19",
37
+ "@types/react": "^19.2.14",
38
+ "fake-indexeddb": "^6.2.5",
39
+ "msw": "^2.12.12",
39
40
  "msw-storybook-addon": "^2.0.6",
40
- "np": "^10.2.0",
41
- "storybook": "^10.1.11",
41
+ "np": "^11.0.2",
42
+ "storybook": "^10.2.19",
42
43
  "typescript": "^5.9.3",
43
44
  "vite": "^7.3.1",
44
- "vitest": "^4.0.17"
45
+ "vitest": "^4.1.0"
45
46
  },
46
47
  "dependencies": {
47
- "@ariakit/react": "^0.4.21",
48
- "@indietabletop/tooling": "^5.2.0",
49
- "@indietabletop/types": "^1.3.1",
50
- "@vanilla-extract/css": "^1.18.0",
48
+ "@ariakit/react": "^0.4.23",
49
+ "@indietabletop/tooling": "^6.0.0",
50
+ "@indietabletop/types": "^1.5.0",
51
+ "@vanilla-extract/css": "^1.19.0",
51
52
  "@vanilla-extract/dynamic": "^2.1.5",
52
53
  "@vanilla-extract/recipes": "^0.5.7",
53
54
  "@vanilla-extract/sprinkles": "^1.6.5",
54
- "@xstate/react": "^6.0.0",
55
- "nanoid": "^5.1.6",
55
+ "@xstate/react": "^6.1.0",
56
+ "nanoid": "^5.1.7",
56
57
  "superstruct": "^2.0.2",
57
- "swr": "^2.3.8",
58
+ "swr": "^2.4.1",
58
59
  "wouter": "^3.9.0",
59
- "xstate": "^5.25.0"
60
+ "xstate": "^5.28.0"
60
61
  },
61
62
  "msw": {
62
63
  "workerDirectory": [