dreamboard 0.1.25 → 0.1.26

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.
@@ -31,8 +31,13 @@ import type { Plugin } from "vite";
31
31
  import {
32
32
  DEFAULT_REFRESH_WINDOW_MS,
33
33
  ensureFreshAccessToken,
34
+ getAccessTokenExpiry,
34
35
  } from "../auth/refresh-coordinator.js";
35
36
  import { PermanentRefreshError } from "../auth/refresh-error.js";
37
+ import {
38
+ repairLocalDevSession,
39
+ shouldRepairLocalDevSession,
40
+ } from "../auth/local-dev-session.js";
36
41
  import type { ResolvedConfig } from "../types.js";
37
42
 
38
43
  export type ResolvedBearerOk = {
@@ -259,11 +264,32 @@ export async function resolveDevBearer(
259
264
  case "unchanged":
260
265
  case "rotated":
261
266
  return { kind: "ok", token: result.credentials.accessToken };
262
- case "transient_failure":
267
+ case "transient_failure": {
268
+ const expiry = getAccessTokenExpiry(config.authToken);
269
+ const isExpired = expiry !== null && expiry.getTime() <= Date.now();
270
+ if (isExpired) {
271
+ if (shouldRepairLocalDevSession(config)) {
272
+ const repaired = await repairLocalDevSession();
273
+ if (repaired) {
274
+ return { kind: "ok", token: repaired.accessToken };
275
+ }
276
+ }
277
+ return {
278
+ kind: "permanent_invalid",
279
+ message: `Access token refresh failed: ${result.message}. Run \`dreamboard login\` to authenticate again.`,
280
+ };
281
+ }
263
282
  return { kind: "ok", token: config.authToken ?? null };
283
+ }
264
284
  }
265
285
  } catch (err) {
266
286
  if (err instanceof PermanentRefreshError) {
287
+ if (shouldRepairLocalDevSession(config)) {
288
+ const repaired = await repairLocalDevSession();
289
+ if (repaired) {
290
+ return { kind: "ok", token: repaired.accessToken };
291
+ }
292
+ }
267
293
  return { kind: "permanent_invalid", message: err.message };
268
294
  }
269
295
  throw err;
@@ -0,0 +1,101 @@
1
+ type ZoneItemElement = Pick<Element, "getAttribute" | "querySelector">;
2
+
3
+ type ZoneItemRoot = Pick<
4
+ ParentNode,
5
+ "querySelectorAll"
6
+ >;
7
+
8
+ export interface PlayableZoneItemWarning {
9
+ key: string;
10
+ message: string;
11
+ }
12
+
13
+ const PLAYABLE_ZONE_ITEM_SELECTOR =
14
+ '[data-dreamboard-zone-item][data-playable="true"]';
15
+ const INTERACTION_CARD_INPUT_SELECTOR =
16
+ "[data-dreamboard-interaction-card-input]";
17
+
18
+ export function collectPlayableZoneItemWarnings(
19
+ root: ZoneItemRoot,
20
+ ): PlayableZoneItemWarning[] {
21
+ const warnings: PlayableZoneItemWarning[] = [];
22
+ for (const item of root.querySelectorAll(PLAYABLE_ZONE_ITEM_SELECTOR)) {
23
+ const element = item as ZoneItemElement;
24
+ if (element.querySelector(INTERACTION_CARD_INPUT_SELECTOR)) {
25
+ continue;
26
+ }
27
+
28
+ const zone = element.getAttribute("data-zone") ?? "unknown";
29
+ const cardId = element.getAttribute("data-card-id") ?? "unknown";
30
+ const cardType = element.getAttribute("data-card-type") ?? null;
31
+ const key = `${zone}:${cardId}`;
32
+ const cardLabel =
33
+ cardType && cardType !== cardId
34
+ ? `'${cardId}' (${cardType})`
35
+ : `'${cardId}'`;
36
+ warnings.push({
37
+ key,
38
+ message: [
39
+ `[dreamboard] Playable card ${cardLabel} in zone '${zone}' rendered without an interaction card input.`,
40
+ "This usually means the UI rendered a raw Card/custom tile instead of <handSurface.Card>.",
41
+ "Render the surface card consistently and let Dreamboard disable unavailable interactions.",
42
+ ].join(" "),
43
+ });
44
+ }
45
+ return warnings;
46
+ }
47
+
48
+ export function installPlayableZoneItemWarnings(options: {
49
+ root?: Document;
50
+ warn?: (...args: unknown[]) => void;
51
+ MutationObserverImpl?: typeof MutationObserver;
52
+ schedule?: (callback: () => void) => void;
53
+ } = {}): () => void {
54
+ const root = options.root ?? document;
55
+ const warn = options.warn ?? console.warn.bind(console);
56
+ const MutationObserverImpl =
57
+ options.MutationObserverImpl ?? window.MutationObserver;
58
+ const schedule =
59
+ options.schedule ??
60
+ ((callback) => {
61
+ window.requestAnimationFrame(callback);
62
+ });
63
+ const emitted = new Set<string>();
64
+ let scheduled = false;
65
+
66
+ const scan = () => {
67
+ scheduled = false;
68
+ for (const warning of collectPlayableZoneItemWarnings(root)) {
69
+ if (emitted.has(warning.key)) {
70
+ continue;
71
+ }
72
+ emitted.add(warning.key);
73
+ warn(warning.message);
74
+ }
75
+ };
76
+
77
+ const requestScan = () => {
78
+ if (scheduled) {
79
+ return;
80
+ }
81
+ scheduled = true;
82
+ schedule(scan);
83
+ };
84
+
85
+ const observer = new MutationObserverImpl(requestScan);
86
+ observer.observe(root.documentElement, {
87
+ childList: true,
88
+ subtree: true,
89
+ attributes: true,
90
+ attributeFilter: [
91
+ "data-dreamboard-zone-item",
92
+ "data-playable",
93
+ "data-dreamboard-interaction-card-input",
94
+ ],
95
+ });
96
+ requestScan();
97
+
98
+ return () => {
99
+ observer.disconnect();
100
+ };
101
+ }
@@ -0,0 +1,47 @@
1
+ import path from "node:path";
2
+ import type { Plugin } from "vite";
3
+
4
+ export function createDevHmrGuardPlugin(options: {
5
+ projectRoot: string;
6
+ }): Plugin {
7
+ return {
8
+ name: "dreamboard-dev-hmr-guard",
9
+ handleHotUpdate(context) {
10
+ if (shouldHandleProjectHotUpdate(options.projectRoot, context.file)) {
11
+ return undefined;
12
+ }
13
+
14
+ context.server.config.logger.info(
15
+ `[dreamboard] ignored HMR outside project: ${path.relative(
16
+ options.projectRoot,
17
+ context.file,
18
+ )}`,
19
+ );
20
+ return [];
21
+ },
22
+ };
23
+ }
24
+
25
+ export function shouldHandleProjectHotUpdate(
26
+ projectRoot: string,
27
+ file: string,
28
+ ): boolean {
29
+ const normalizedProjectRoot = path.resolve(projectRoot);
30
+ const normalizedFile = path.resolve(file);
31
+ const relativePath = path.relative(normalizedProjectRoot, normalizedFile);
32
+ const isInsideProject =
33
+ relativePath === "" ||
34
+ (relativePath.length > 0 &&
35
+ !relativePath.startsWith("..") &&
36
+ !path.isAbsolute(relativePath));
37
+
38
+ if (!isInsideProject) {
39
+ return false;
40
+ }
41
+
42
+ const pathSegments = relativePath.split(path.sep);
43
+ return (
44
+ !pathSegments.includes(".dreamboard") &&
45
+ !pathSegments.includes("node_modules")
46
+ );
47
+ }
@@ -183,7 +183,7 @@ export class DevHostController {
183
183
  if (preferredPlayerId) {
184
184
  this.logger.warn(
185
185
  "[DevHost] Failed to bootstrap the requested player selection; retrying with the default player:",
186
- error instanceof Error ? error.message : String(error),
186
+ formatErrorForLog(error),
187
187
  );
188
188
  this.storage.persistPreferredPlayerId(null);
189
189
  try {
@@ -198,7 +198,13 @@ export class DevHostController {
198
198
  }
199
199
  this.logger.error(
200
200
  "[DevHost] Failed to bootstrap the backend session:",
201
- error instanceof Error ? error.message : String(error),
201
+ formatErrorForLog(error),
202
+ );
203
+ this.setRuntimeError(
204
+ convertProblemDetailsToRuntimeError(
205
+ error,
206
+ "Failed to bootstrap the backend session.",
207
+ ),
202
208
  );
203
209
  }
204
210
  this.notify();
@@ -262,7 +268,7 @@ export class DevHostController {
262
268
  );
263
269
  this.logger.error(
264
270
  "[DevHost] Failed to start the backend session:",
265
- error instanceof Error ? error.message : String(error),
271
+ formatErrorForLog(error),
266
272
  );
267
273
  }
268
274
  }
@@ -613,7 +619,7 @@ export class DevHostController {
613
619
  }
614
620
  this.logger.error(
615
621
  "[DevHost] Failed to switch player:",
616
- error instanceof Error ? error.message : String(error),
622
+ formatErrorForLog(error),
617
623
  );
618
624
  this.setRuntimeError(
619
625
  convertProblemDetailsToRuntimeError(
@@ -639,7 +645,13 @@ export class DevHostController {
639
645
  }
640
646
  }
641
647
 
648
+ function formatErrorForLog(error: unknown): string {
649
+ return error instanceof Error ? error.message : formatConsoleArgs([error]);
650
+ }
651
+
642
652
  type ApiErrorPayload = {
653
+ error?: string;
654
+ message?: string;
643
655
  title?: string;
644
656
  detail?: string;
645
657
  status?: number;
@@ -672,9 +684,13 @@ function convertProblemDetailsToRuntimeError(
672
684
  code: typeof violation.code === "string" ? violation.code : undefined,
673
685
  }));
674
686
 
675
- const title = payload.title?.trim() || "Game failed to start";
687
+ const title =
688
+ payload.title?.trim() ||
689
+ (payload.error === "session_invalid" ? "Session expired" : "") ||
690
+ "Game failed to start";
676
691
  const summary =
677
692
  payload.detail?.trim() ||
693
+ payload.message?.trim() ||
678
694
  (error instanceof Error ? error.message : fallbackMessage);
679
695
 
680
696
  return {
@@ -89,94 +89,136 @@ h6 {
89
89
  box-shadow: 8px 8px 0 0 #2d2d2d;
90
90
  }
91
91
 
92
- .dev-drawer-content {
93
- background-color: rgba(247, 239, 220, 0.96);
94
- color: #1f2937;
95
- }
96
-
97
- .dev-drawer-surface {
98
- background-image:
99
- linear-gradient(180deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.08)),
100
- radial-gradient(rgba(80, 58, 34, 0.09) 1px, transparent 1px);
101
- background-size: 100% 100%, 18px 18px;
102
- color: #1f2937;
103
- }
104
-
105
- .dev-drawer-header {
106
- background-color: rgba(255, 252, 244, 0.96);
107
- color: #1f2937;
108
- }
109
-
110
- .dev-drawer-label {
111
- background-color: #e5e0d8;
112
- color: #6b5a45;
113
- }
114
-
115
- .dev-drawer-session-pill {
116
- background-color: #fff3b5;
117
- color: #1f2937;
118
- }
119
-
120
- .dev-drawer-session-dot {
121
- background-color: #d97706;
122
- }
123
-
124
- .dev-drawer-close {
125
- background-color: #fff;
126
- color: #6b7280;
127
- }
128
-
129
- .dev-drawer-close:hover {
130
- background-color: #e5e0d8;
131
- }
132
-
133
- .dev-drawer-note {
134
- background-color: rgba(255, 255, 255, 0.72);
135
- color: rgba(31, 41, 55, 0.8);
136
- }
137
-
138
- .dev-drawer-card {
139
- background-color: #fffdf7;
140
- color: #1f2937;
141
- }
142
-
143
- .dev-drawer-card-title {
144
- color: #7a6956;
145
- }
146
-
147
- .dev-drawer-controls-card {
148
- background-color: #fff3b5;
149
- color: #1f2937;
150
- }
151
-
152
- .dev-drawer-feedback-card {
153
- background-color: #fffaf0;
154
- color: #1f2937;
155
- }
156
-
157
- .dev-drawer-input {
158
- background-color: #fff;
159
- color: #1f2937;
160
- }
161
-
162
- .dev-drawer-primary-button {
163
- background-color: #1f2937;
92
+ /* Calm secondary CTA inside the dev drawer — the player switcher is
93
+ * already the panel's single wobbly+shadow stamp, so the primary action
94
+ * here is intentionally quiet. */
95
+ .dev-calm-button {
96
+ display: inline-flex;
97
+ align-items: center;
98
+ justify-content: center;
99
+ height: 2.5rem;
100
+ padding: 0 1rem;
101
+ border-radius: 0.5rem;
102
+ border: 2px solid #2d2d2d;
103
+ background-color: #2d2d2d;
164
104
  color: #fff8e8;
165
- }
166
-
167
- .dev-drawer-primary-button:hover {
168
- background-color: #111827;
169
- }
170
-
171
- .dev-drawer-danger-button {
172
- background-color: #ff4d4d;
173
- color: #fff;
174
- }
175
-
176
- .dev-drawer-danger-button:hover {
177
- background-color: #e60000;
178
- }
179
-
180
- .dev-drawer-toolbar-shell {
181
- background-color: rgba(255, 255, 255, 0.72);
105
+ font-family: var(--font-sans);
106
+ font-size: 0.875rem;
107
+ font-weight: 700;
108
+ letter-spacing: 0.02em;
109
+ transition:
110
+ background-color 0.15s ease,
111
+ transform 0.05s ease;
112
+ }
113
+
114
+ .dev-calm-button:hover:not(:disabled) {
115
+ background-color: #1f1f1f;
116
+ }
117
+
118
+ .dev-calm-button:active:not(:disabled) {
119
+ transform: translateY(1px);
120
+ }
121
+
122
+ .dev-calm-button:disabled {
123
+ opacity: 0.5;
124
+ cursor: not-allowed;
125
+ }
126
+
127
+ /* Tame HostHistoryNavigator into a text link inside the dev drawer.
128
+ *
129
+ * The upstream Button ships with `wobbly-border-md` + `hard-shadow`
130
+ * baked into its base classes; twMerge cannot strip custom utilities so
131
+ * we brute-force a flat link-style treatment with !important on multiple
132
+ * selector forms. */
133
+ .dev-history-shell button,
134
+ .dev-history-shell [data-slot="button"],
135
+ .dev-history-shell .wobbly-border-md,
136
+ .dev-history-shell .hard-shadow {
137
+ height: auto !important;
138
+ padding: 0.25rem 0 !important;
139
+ background: transparent !important;
140
+ border: none !important;
141
+ box-shadow: none !important;
142
+ border-radius: 0 !important;
143
+ font-size: 0.75rem !important;
144
+ font-weight: 700 !important;
145
+ color: oklch(0.5 0 0) !important;
146
+ gap: 0.4rem !important;
147
+ text-transform: uppercase;
148
+ letter-spacing: 0.12em;
149
+ }
150
+
151
+ .dev-history-shell button:hover,
152
+ .dev-history-shell [data-slot="button"]:hover {
153
+ background: transparent !important;
154
+ color: oklch(0.25 0 0) !important;
155
+ box-shadow: none !important;
156
+ transform: none !important;
157
+ }
158
+
159
+ .dev-history-shell [data-slot="badge"] {
160
+ background-color: rgba(45, 45, 45, 0.08);
161
+ color: oklch(0.25 0 0);
162
+ border-color: transparent;
163
+ padding: 0 0.4rem;
164
+ font-size: 0.65rem;
165
+ letter-spacing: 0;
166
+ }
167
+
168
+ /* Calm copy buttons inside the Debug fold so they're text-tabular, not
169
+ * three wobbly stamps. */
170
+ .dev-host-debug-shell button,
171
+ .dev-host-debug-shell .wobbly-border-md,
172
+ .dev-host-debug-shell .hard-shadow {
173
+ border-radius: 0.375rem !important;
174
+ box-shadow: none !important;
175
+ border-width: 1px !important;
176
+ border-color: rgba(45, 45, 45, 0.12) !important;
177
+ background-color: transparent !important;
178
+ }
179
+
180
+ .dev-host-debug-shell button:hover {
181
+ background-color: rgba(45, 45, 45, 0.04) !important;
182
+ transform: none !important;
183
+ box-shadow: none !important;
184
+ }
185
+
186
+ /* Seed input — pull off the upstream wobbly+thick border in favour of a
187
+ * calm flat rectangle that matches the rest of the panel chrome. */
188
+ .dev-seed-input {
189
+ border-radius: 0.5rem !important;
190
+ border-width: 1.5px !important;
191
+ border-color: rgba(45, 45, 45, 0.18) !important;
192
+ background-color: #fff !important;
193
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
194
+ }
195
+
196
+ .dev-seed-input:focus,
197
+ .dev-seed-input:focus-visible {
198
+ border-color: rgba(45, 45, 45, 0.4) !important;
199
+ box-shadow: 0 0 0 3px rgba(45, 93, 161, 0.12) !important;
200
+ }
201
+
202
+ /* Edge handle — calm strip at rest, soft glow on hover. */
203
+ .dev-edge-handle .dev-edge-strip {
204
+ transition:
205
+ background-color 200ms ease,
206
+ box-shadow 200ms ease,
207
+ width 200ms ease,
208
+ height 200ms ease;
209
+ }
210
+
211
+ .dev-edge-handle:hover .dev-edge-strip,
212
+ .dev-edge-handle:focus-visible .dev-edge-strip {
213
+ width: 6px;
214
+ height: 7rem;
215
+ background-color: #2d5da1;
216
+ box-shadow:
217
+ 0 0 0 2px rgba(45, 93, 161, 0.18),
218
+ 0 0 14px 4px rgba(45, 93, 161, 0.55),
219
+ 0 0 28px 8px rgba(45, 93, 161, 0.25);
220
+ }
221
+
222
+ .dev-edge-handle:focus-visible {
223
+ outline: none;
182
224
  }