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 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";
@@ -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;
@@ -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
- scheduleRestart();
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) so in-flight work isn't lost.
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
- function scheduleRestart() {
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
- }, RESTART_DEBOUNCE_MS);
470
+ }, delayMs);
379
471
  }
380
472
  function localWsUrl(path) {
381
473
  const url = new URL(path, location.href);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "type": "module",
5
5
  "main": "dist/castle.js",
6
6
  "types": "dist/castle.d.ts",