castle-web-sdk 0.4.8 → 0.4.10
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 +36 -0
- package/dist/castle.d.ts +4 -1
- package/dist/castle.js +2 -1
- package/dist/commands.d.ts +9 -0
- package/dist/haptics.d.ts +6 -0
- package/dist/haptics.js +36 -0
- package/dist/runtime.d.ts +20 -0
- package/dist/runtime.js +96 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ import { setup, initCard, Storage, Leaderboard } from "castle-web-sdk";
|
|
|
20
20
|
- [User](#user)
|
|
21
21
|
- [Pass](#pass)
|
|
22
22
|
- [Portal](#portal)
|
|
23
|
+
- [Haptics](#haptics)
|
|
23
24
|
- [Lifecycle](#lifecycle)
|
|
24
25
|
- [Setup](#setup)
|
|
25
26
|
- [CastleError](#castleerror)
|
|
@@ -269,6 +270,41 @@ link.addEventListener("pointerenter", () => {
|
|
|
269
270
|
});
|
|
270
271
|
```
|
|
271
272
|
|
|
273
|
+
## Haptics
|
|
274
|
+
|
|
275
|
+
`Haptics` plays a short device vibration ("buzz") for tactile feedback —
|
|
276
|
+
a tap confirmation, a success chime, an error shake. The host owns the
|
|
277
|
+
effect: the Castle mobile app plays a native haptic, the website uses the
|
|
278
|
+
browser's vibration API where available, and the dev server or an
|
|
279
|
+
unsupported device does nothing.
|
|
280
|
+
|
|
281
|
+
### `Haptics.play(style): Promise<HapticsResult>`
|
|
282
|
+
|
|
283
|
+
Plays a haptic in one of seven styles:
|
|
284
|
+
|
|
285
|
+
- `'light'`, `'medium'`, `'heavy'` — impact taps of increasing strength.
|
|
286
|
+
- `'selection'` — a light tick, e.g. moving through options.
|
|
287
|
+
- `'success'`, `'warning'`, `'error'` — notification patterns.
|
|
288
|
+
|
|
289
|
+
Usually you don't await it — fire it and move on:
|
|
290
|
+
|
|
291
|
+
```js
|
|
292
|
+
button.onclick = () => {
|
|
293
|
+
Haptics.play("light");
|
|
294
|
+
doTheThing();
|
|
295
|
+
};
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The returned `HapticsResult` has a `status`, if you want to branch on it:
|
|
299
|
+
|
|
300
|
+
- `'triggered'` — the host played (or accepted) the haptic.
|
|
301
|
+
- `'unavailable'` — this host or device can't play haptics (e.g. the dev
|
|
302
|
+
server, or a browser with no vibration support). Nothing was played.
|
|
303
|
+
|
|
304
|
+
Haptics respect the player's settings — if a player has muted haptics,
|
|
305
|
+
`play` does nothing and resolves `'unavailable'`. Rapid repeated calls may
|
|
306
|
+
be coalesced, so it's safe to call on frequent events.
|
|
307
|
+
|
|
272
308
|
## Lifecycle
|
|
273
309
|
|
|
274
310
|
`Lifecycle` tells the host when the deck has painted its first frame, so
|
package/dist/castle.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { isEdit } from "./context";
|
|
2
2
|
export { CastleError } from "./errors";
|
|
3
|
+
export { Haptics } from "./haptics";
|
|
4
|
+
export type { CastleHapticsApi, HapticsResult, HapticsStatus, HapticStyle } from "./haptics";
|
|
3
5
|
export { Leaderboard } from "./leaderboard";
|
|
4
6
|
export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, LeaderboardScope, LeaderboardSort, } from "./leaderboard";
|
|
5
7
|
export { Lifecycle } from "./lifecycle";
|
|
@@ -8,7 +10,8 @@ export { Pass } from "./passes";
|
|
|
8
10
|
export type { CastlePassApi, PassOfferResult, PassOfferStatus, } from "./passes";
|
|
9
11
|
export { Portal } from "./portal";
|
|
10
12
|
export type { CastlePortalApi, PortalOpenResult, PortalOpenStatus, PortalPrefetchResult, PortalPrefetchStatus, } from "./portal";
|
|
11
|
-
export { CARD_RATIO, initCard, onBeforeRestart, setup, writeFile } from "./runtime";
|
|
13
|
+
export { CARD_RATIO, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, requestReload, setup, takeReloadState, writeFile, } from "./runtime";
|
|
14
|
+
export type { FileChange, FilesChangedEvent } from "./runtime";
|
|
12
15
|
export { SharedStorage, Storage } from "./storage";
|
|
13
16
|
export { Time } from "./time";
|
|
14
17
|
export type { CastleClockZone, CastleDateParts, CastleTimeApi } from "./time";
|
package/dist/castle.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Castle Web SDK
|
|
2
2
|
export { isEdit } from "./context";
|
|
3
3
|
export { CastleError } from "./errors";
|
|
4
|
+
export { Haptics } from "./haptics";
|
|
4
5
|
export { Leaderboard } from "./leaderboard";
|
|
5
6
|
export { Lifecycle } from "./lifecycle";
|
|
6
7
|
export { Pass } from "./passes";
|
|
7
8
|
export { Portal } from "./portal";
|
|
8
|
-
export { CARD_RATIO, initCard, onBeforeRestart, setup, writeFile } from "./runtime";
|
|
9
|
+
export { CARD_RATIO, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, requestReload, setup, takeReloadState, writeFile, } from "./runtime";
|
|
9
10
|
export { SharedStorage, Storage } from "./storage";
|
|
10
11
|
export { Time } from "./time";
|
|
11
12
|
export { User } from "./user";
|
package/dist/commands.d.ts
CHANGED
|
@@ -32,6 +32,11 @@ export type PortalPrefetchStatus = "prefetching" | "rejected" | "unavailable";
|
|
|
32
32
|
export interface PortalPrefetchResult {
|
|
33
33
|
status: PortalPrefetchStatus;
|
|
34
34
|
}
|
|
35
|
+
export type HapticStyle = "light" | "medium" | "heavy" | "selection" | "success" | "warning" | "error";
|
|
36
|
+
export type HapticsStatus = "triggered" | "unavailable";
|
|
37
|
+
export interface HapticsResult {
|
|
38
|
+
status: HapticsStatus;
|
|
39
|
+
}
|
|
35
40
|
export interface CommandParams {
|
|
36
41
|
"deckStorage.load": Record<string, never>;
|
|
37
42
|
"deckStorage.update": {
|
|
@@ -71,6 +76,9 @@ export interface CommandParams {
|
|
|
71
76
|
"portal.prefetch": {
|
|
72
77
|
targetDeckId: string;
|
|
73
78
|
};
|
|
79
|
+
"haptics.play": {
|
|
80
|
+
style: HapticStyle;
|
|
81
|
+
};
|
|
74
82
|
}
|
|
75
83
|
export interface CommandResult {
|
|
76
84
|
"deckStorage.load": {
|
|
@@ -109,6 +117,7 @@ export interface CommandResult {
|
|
|
109
117
|
"pass.offer": PassOfferResult;
|
|
110
118
|
"portal.open": PortalOpenResult;
|
|
111
119
|
"portal.prefetch": PortalPrefetchResult;
|
|
120
|
+
"haptics.play": HapticsResult;
|
|
112
121
|
}
|
|
113
122
|
export type CommandName = keyof CommandParams;
|
|
114
123
|
export interface SerializedCommandError {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { HapticStyle, HapticsResult } from "./commands";
|
|
2
|
+
export type { HapticStyle, HapticsResult, HapticsStatus } from "./commands";
|
|
3
|
+
export interface CastleHapticsApi {
|
|
4
|
+
play(style: HapticStyle): Promise<HapticsResult>;
|
|
5
|
+
}
|
|
6
|
+
export declare const Haptics: CastleHapticsApi;
|
package/dist/haptics.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Haptics — a deck-facing capability for playing a device haptic (a "buzz").
|
|
2
|
+
// Like pass and portal, the deck stays capability-AGNOSTIC: it asks for a
|
|
3
|
+
// haptic and gets back one normalized outcome regardless of platform. The host
|
|
4
|
+
// owns the effect:
|
|
5
|
+
// - mobile app : plays a native haptic (Taptic Engine / VibrationEffect),
|
|
6
|
+
// subject to the host's own rate-limiting and device support
|
|
7
|
+
// - web player : best-effort navigator.vibrate; `unavailable` where the
|
|
8
|
+
// browser has no vibration API (e.g. iOS Safari)
|
|
9
|
+
// - dev CLI : no host handler, so `unavailable`
|
|
10
|
+
// A haptic is a fleeting device effect, not deck-scoped state, so unlike
|
|
11
|
+
// pass/portal the host requires no deckId. Decks usually fire-and-forget (don't
|
|
12
|
+
// await); the returned status just says whether the host could play it.
|
|
13
|
+
import { CastleError } from "./errors";
|
|
14
|
+
import { hostRequest } from "./transport";
|
|
15
|
+
const HAPTIC_STYLES = [
|
|
16
|
+
"light",
|
|
17
|
+
"medium",
|
|
18
|
+
"heavy",
|
|
19
|
+
"selection",
|
|
20
|
+
"success",
|
|
21
|
+
"warning",
|
|
22
|
+
"error",
|
|
23
|
+
];
|
|
24
|
+
export const Haptics = {
|
|
25
|
+
play,
|
|
26
|
+
};
|
|
27
|
+
async function play(style) {
|
|
28
|
+
if (!HAPTIC_STYLES.includes(style)) {
|
|
29
|
+
throw new CastleError({
|
|
30
|
+
code: "INVALID_ARGUMENT",
|
|
31
|
+
message: `Haptics.play style must be one of: ${HAPTIC_STYLES.join(", ")}.`,
|
|
32
|
+
operation: "Haptics.play",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return hostRequest("haptics.play", { style });
|
|
36
|
+
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -7,9 +7,29 @@ interface LocalResponse {
|
|
|
7
7
|
error?: string;
|
|
8
8
|
[key: string]: unknown;
|
|
9
9
|
}
|
|
10
|
+
export interface FileChange {
|
|
11
|
+
/** Deck-relative POSIX path. */
|
|
12
|
+
path: string;
|
|
13
|
+
event: "add" | "change" | "delete";
|
|
14
|
+
/**
|
|
15
|
+
* Deck-relative paths affected via the Vite import graph: the changed file
|
|
16
|
+
* itself (when imported by anything) plus every transitive importer. Empty
|
|
17
|
+
* when the file isn't part of the module graph.
|
|
18
|
+
*/
|
|
19
|
+
affected: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface FilesChangedEvent {
|
|
22
|
+
changes: FileChange[];
|
|
23
|
+
/** Union of every change's path + affected set, deduped. */
|
|
24
|
+
affected: string[];
|
|
25
|
+
}
|
|
10
26
|
export declare function setup(): void;
|
|
11
27
|
export declare function writeFile(path: string, contents: string): Promise<LocalResponse>;
|
|
12
28
|
export declare function initCard(): HTMLDivElement;
|
|
13
29
|
export declare function sendLocalCommand<C extends CommandName>(command: C, params: CommandParams[C]): Promise<CommandResponseEnvelope>;
|
|
30
|
+
export declare function onFilesChanged(listener: (event: FilesChangedEvent) => void): () => void;
|
|
14
31
|
export declare function onBeforeRestart(hook: () => void | Promise<void>): () => void;
|
|
32
|
+
export declare function onSaveReloadState(key: string, save: () => unknown): () => void;
|
|
33
|
+
export declare function takeReloadState<T = unknown>(key: string): T | null;
|
|
34
|
+
export declare function requestReload(): void;
|
|
15
35
|
export {};
|
package/dist/runtime.js
CHANGED
|
@@ -342,7 +342,10 @@ function handleLocalMessage(msg) {
|
|
|
342
342
|
});
|
|
343
343
|
}
|
|
344
344
|
else if (msg.type === "restart") {
|
|
345
|
-
|
|
345
|
+
scheduleReload(RESTART_DEBOUNCE_MS);
|
|
346
|
+
}
|
|
347
|
+
else if (msg.type === "files_changed") {
|
|
348
|
+
dispatchFilesChanged(msg);
|
|
346
349
|
}
|
|
347
350
|
else if (msg.type === "write_file_response") {
|
|
348
351
|
resolveLocalRequest(msg);
|
|
@@ -351,18 +354,106 @@ function handleLocalMessage(msg) {
|
|
|
351
354
|
resolveLocalCommand(msg);
|
|
352
355
|
}
|
|
353
356
|
}
|
|
357
|
+
// Files-changed listeners, registered by consumers (the kit engine, editors).
|
|
358
|
+
// The SDK only delivers the event; deciding what to re-read or reload is the
|
|
359
|
+
// consumer's job.
|
|
360
|
+
const filesChangedListeners = new Set();
|
|
361
|
+
export function onFilesChanged(listener) {
|
|
362
|
+
filesChangedListeners.add(listener);
|
|
363
|
+
return () => filesChangedListeners.delete(listener);
|
|
364
|
+
}
|
|
365
|
+
function dispatchFilesChanged(msg) {
|
|
366
|
+
const rawChanges = Array.isArray(msg.changes) ? msg.changes : [];
|
|
367
|
+
const changes = [];
|
|
368
|
+
for (const raw of rawChanges) {
|
|
369
|
+
if (typeof raw?.path !== "string")
|
|
370
|
+
continue;
|
|
371
|
+
const event = raw.event;
|
|
372
|
+
if (event !== "add" && event !== "change" && event !== "delete")
|
|
373
|
+
continue;
|
|
374
|
+
changes.push({
|
|
375
|
+
path: raw.path,
|
|
376
|
+
event,
|
|
377
|
+
affected: Array.isArray(raw.affected)
|
|
378
|
+
? raw.affected.filter((p) => typeof p === "string")
|
|
379
|
+
: [],
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
if (changes.length === 0)
|
|
383
|
+
return;
|
|
384
|
+
const affected = Array.isArray(msg.affected)
|
|
385
|
+
? msg.affected.filter((p) => typeof p === "string")
|
|
386
|
+
: [];
|
|
387
|
+
const event = { changes, affected };
|
|
388
|
+
for (const listener of filesChangedListeners) {
|
|
389
|
+
try {
|
|
390
|
+
listener(event);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// one consumer's failure shouldn't starve the others
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
354
397
|
// Restart (from `castle-web restart` / task agents) is debounced so a burst
|
|
355
398
|
// of reload requests -- several tasks finishing close together -- produces
|
|
356
399
|
// one reload. Before reloading, registered hooks run (the kit editor flushes
|
|
357
|
-
// its debounced unsaved edits there
|
|
400
|
+
// its debounced unsaved edits there, save-state hooks stash their state) so
|
|
401
|
+
// in-flight work isn't lost.
|
|
358
402
|
const RESTART_DEBOUNCE_MS = 1500;
|
|
403
|
+
// Consumer-requested reloads (a code file changed) coalesce on a short window:
|
|
404
|
+
// the serve already batched the fs events, so this only folds multiple
|
|
405
|
+
// consumers requesting at once.
|
|
406
|
+
const REQUEST_RELOAD_DEBOUNCE_MS = 250;
|
|
359
407
|
let restartTimer = null;
|
|
360
408
|
const beforeRestartHooks = new Set();
|
|
361
409
|
export function onBeforeRestart(hook) {
|
|
362
410
|
beforeRestartHooks.add(hook);
|
|
363
411
|
return () => beforeRestartHooks.delete(hook);
|
|
364
412
|
}
|
|
365
|
-
|
|
413
|
+
// Save-state hooks: consumers register a saver keyed by a stable string; right
|
|
414
|
+
// before a reload every saver runs and its value is stashed in sessionStorage.
|
|
415
|
+
// After the reload the consumer calls takeReloadState(key) to pick it back up.
|
|
416
|
+
// Simple stash — enough for editor selection / lightweight runtime state.
|
|
417
|
+
const RELOAD_STATE_PREFIX = "castle-reload-state:";
|
|
418
|
+
const reloadStateSavers = new Map();
|
|
419
|
+
export function onSaveReloadState(key, save) {
|
|
420
|
+
reloadStateSavers.set(key, save);
|
|
421
|
+
return () => {
|
|
422
|
+
if (reloadStateSavers.get(key) === save)
|
|
423
|
+
reloadStateSavers.delete(key);
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
export function takeReloadState(key) {
|
|
427
|
+
try {
|
|
428
|
+
const raw = sessionStorage.getItem(RELOAD_STATE_PREFIX + key);
|
|
429
|
+
if (raw === null)
|
|
430
|
+
return null;
|
|
431
|
+
sessionStorage.removeItem(RELOAD_STATE_PREFIX + key);
|
|
432
|
+
return JSON.parse(raw);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function stashReloadState() {
|
|
439
|
+
for (const [key, save] of reloadStateSavers) {
|
|
440
|
+
try {
|
|
441
|
+
const value = save();
|
|
442
|
+
if (value === undefined)
|
|
443
|
+
continue;
|
|
444
|
+
sessionStorage.setItem(RELOAD_STATE_PREFIX + key, JSON.stringify(value));
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// a failed saver shouldn't block the reload
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Reload this page after honoring the save hooks. Consumers call this when a
|
|
452
|
+
// files-changed event tells them code they run on has changed.
|
|
453
|
+
export function requestReload() {
|
|
454
|
+
scheduleReload(REQUEST_RELOAD_DEBOUNCE_MS);
|
|
455
|
+
}
|
|
456
|
+
function scheduleReload(delayMs) {
|
|
366
457
|
if (restartTimer !== null)
|
|
367
458
|
clearTimeout(restartTimer);
|
|
368
459
|
restartTimer = setTimeout(() => {
|
|
@@ -373,9 +464,10 @@ function scheduleRestart() {
|
|
|
373
464
|
catch {
|
|
374
465
|
// a failed flush shouldn't block the reload
|
|
375
466
|
}
|
|
467
|
+
stashReloadState();
|
|
376
468
|
location.reload();
|
|
377
469
|
})();
|
|
378
|
-
},
|
|
470
|
+
}, delayMs);
|
|
379
471
|
}
|
|
380
472
|
function localWsUrl(path) {
|
|
381
473
|
const url = new URL(path, location.href);
|