castle-web-sdk 0.4.6 → 0.4.8

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
@@ -19,6 +19,8 @@ import { setup, initCard, Storage, Leaderboard } from "castle-web-sdk";
19
19
  - [Time](#time)
20
20
  - [User](#user)
21
21
  - [Pass](#pass)
22
+ - [Portal](#portal)
23
+ - [Lifecycle](#lifecycle)
22
24
  - [Setup](#setup)
23
25
  - [CastleError](#castleerror)
24
26
 
@@ -223,6 +225,74 @@ if (status === "purchased" || status === "alreadyOwned") {
223
225
  }
224
226
  ```
225
227
 
228
+ ## Portal
229
+
230
+ A portal sends the player from this deck to another Castle deck,
231
+ referred to by its deck id.
232
+
233
+ ### `Portal.open(deckId): Promise<PortalOpenResult>`
234
+
235
+ Sends the player to `deckId`: the Castle app swipes the feed to it, the
236
+ website opens its page. There's nowhere to go in the editor or dev server,
237
+ so there it resolves `unavailable`. `PortalOpenResult` has a `status`:
238
+
239
+ - `'navigating'` — the host is moving to the target deck. Treat `open` as
240
+ final: this deck is torn down as it navigates away, so don't rely on code
241
+ running afterward.
242
+ - `'unavailable'` — nowhere to navigate here (the editor or dev server).
243
+
244
+ ```js
245
+ portalButton.onclick = async () => {
246
+ const { status } = await Portal.open(nextDeckId);
247
+ if (status === "unavailable") {
248
+ // No feed to navigate here — fall back to your own affordance.
249
+ }
250
+ };
251
+ ```
252
+
253
+ ### `Portal.prefetch(deckId): Promise<PortalPrefetchResult>`
254
+
255
+ A best-effort hint that the player may soon `open(deckId)`. In the Castle app
256
+ the host warms that deck (fetching it now so a later `open` transitions without
257
+ a cold load); everywhere else it's a no-op. `PortalPrefetchResult`
258
+ has a `status`:
259
+
260
+ - `'prefetching'` — the host accepted the hint (or the deck was already warm).
261
+ - `'rejected'` — the host declined, e.g. this deck has already prefetched its
262
+ limit of upcoming decks. Prefetch a few likely destinations, not everything.
263
+ - `'unavailable'` — the host doesn't prefetch here (the website, editor, or dev
264
+ server).
265
+
266
+ ```js
267
+ link.addEventListener("pointerenter", () => {
268
+ Portal.prefetch(link.dataset.deckId);
269
+ });
270
+ ```
271
+
272
+ ## Lifecycle
273
+
274
+ `Lifecycle` tells the host when the deck has painted its first frame, so
275
+ the Castle feed can reveal it right away instead of waiting out a fixed
276
+ delay.
277
+
278
+ ### `Lifecycle.ready()`
279
+
280
+ Signal that the deck has painted its first presentable frame. Idempotent
281
+ — only the first call has any effect.
282
+
283
+ Kits already wire this up to fire as soon as the first frame paints; if
284
+ you change how the deck first renders, make sure it still fires there. If
285
+ nothing calls this, the feed reveals the deck on its own after a brief
286
+ timeout.
287
+
288
+ If you call it right after kicking off your initial render, wait for the
289
+ next paint first so the host doesn't reveal a blank frame:
290
+
291
+ ```js
292
+ createRoot(root).render(<App />);
293
+ requestAnimationFrame(() => requestAnimationFrame(() => Lifecycle.ready()));
294
+ ```
295
+
226
296
  ## Setup
227
297
 
228
298
  Startup, editor-mode check, and a file-write call for editor UI.
@@ -231,8 +301,9 @@ Startup, editor-mode check, and a file-write call for editor UI.
231
301
 
232
302
  Call this once at the start of the deck, before any other SDK call.
233
303
  `setup()` initializes the SDK so the rest of the API is usable and
234
- mounts the centered 5:7 card shell around whatever the deck renders
235
- into `#root` (when the deck is being played standalone in a browser).
304
+ mounts a 5:7 Castle card around whatever the deck renders into `#root`
305
+ in play mode. The SDK preserves the card aspect ratio; Castle hosts own
306
+ the card's max size, placement, and surrounding padding.
236
307
  While running locally with `castle-web serve`, it also forwards
237
308
  `console` output to the CLI and reloads the page when `castle-web
238
309
  restart` runs.
@@ -246,9 +317,10 @@ setup();
246
317
  ### `initCard(): HTMLDivElement`
247
318
 
248
319
  Use this when the deck draws into a `<canvas>` (or anything else)
249
- rather than into the React tree at `#root`. Returns a centered,
250
- viewport-sized `<div>` with the standard Castle 5:7 card aspect ratio.
251
- The div resizes itself when the window resizes.
320
+ rather than into the React tree at `#root`. Returns a `<div>` with the
321
+ standard Castle 5:7 card aspect ratio. The div resizes itself when the
322
+ window resizes. In Castle-hosted iframes/WebViews it fills the host's
323
+ available card frame.
252
324
 
253
325
  ```js
254
326
  import { setup, initCard } from "castle-web-sdk";
@@ -264,6 +336,10 @@ card.appendChild(canvas);
264
336
  If the deck mounts a React tree into `#root` instead, you don't need
265
337
  `initCard()` — `setup()` already wraps `#root`'s children in a card.
266
338
 
339
+ Host-specific layout flags such as `CastleEmbed.feed` are deprecated.
340
+ New code should let Castle hosts provide the frame and let the SDK infer
341
+ whether it is running standalone or embedded.
342
+
267
343
  ### `CARD_RATIO`
268
344
 
269
345
  The card aspect ratio (`5 / 7`). Use this if you need to size something
package/dist/castle.d.ts CHANGED
@@ -2,8 +2,12 @@ export { isEdit } from "./context";
2
2
  export { CastleError } from "./errors";
3
3
  export { Leaderboard } from "./leaderboard";
4
4
  export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, LeaderboardScope, LeaderboardSort, } from "./leaderboard";
5
+ export { Lifecycle } from "./lifecycle";
6
+ export type { CastleLifecycleApi } from "./lifecycle";
5
7
  export { Pass } from "./passes";
6
8
  export type { CastlePassApi, PassOfferResult, PassOfferStatus, } from "./passes";
9
+ export { Portal } from "./portal";
10
+ export type { CastlePortalApi, PortalOpenResult, PortalOpenStatus, PortalPrefetchResult, PortalPrefetchStatus, } from "./portal";
7
11
  export { CARD_RATIO, initCard, onBeforeRestart, setup, writeFile } from "./runtime";
8
12
  export { SharedStorage, Storage } from "./storage";
9
13
  export { Time } from "./time";
package/dist/castle.js CHANGED
@@ -2,7 +2,9 @@
2
2
  export { isEdit } from "./context";
3
3
  export { CastleError } from "./errors";
4
4
  export { Leaderboard } from "./leaderboard";
5
+ export { Lifecycle } from "./lifecycle";
5
6
  export { Pass } from "./passes";
7
+ export { Portal } from "./portal";
6
8
  export { CARD_RATIO, initCard, onBeforeRestart, setup, writeFile } from "./runtime";
7
9
  export { SharedStorage, Storage } from "./storage";
8
10
  export { Time } from "./time";
@@ -24,6 +24,14 @@ export type PassOfferStatus = "purchased" | "alreadyOwned" | "cancelled" | "unav
24
24
  export interface PassOfferResult {
25
25
  status: PassOfferStatus;
26
26
  }
27
+ export type PortalOpenStatus = "navigating" | "unavailable";
28
+ export interface PortalOpenResult {
29
+ status: PortalOpenStatus;
30
+ }
31
+ export type PortalPrefetchStatus = "prefetching" | "rejected" | "unavailable";
32
+ export interface PortalPrefetchResult {
33
+ status: PortalPrefetchStatus;
34
+ }
27
35
  export interface CommandParams {
28
36
  "deckStorage.load": Record<string, never>;
29
37
  "deckStorage.update": {
@@ -57,6 +65,12 @@ export interface CommandParams {
57
65
  "pass.offer": {
58
66
  passId: string;
59
67
  };
68
+ "portal.open": {
69
+ targetDeckId: string;
70
+ };
71
+ "portal.prefetch": {
72
+ targetDeckId: string;
73
+ };
60
74
  }
61
75
  export interface CommandResult {
62
76
  "deckStorage.load": {
@@ -93,6 +107,8 @@ export interface CommandResult {
93
107
  hasPass: boolean;
94
108
  };
95
109
  "pass.offer": PassOfferResult;
110
+ "portal.open": PortalOpenResult;
111
+ "portal.prefetch": PortalPrefetchResult;
96
112
  }
97
113
  export type CommandName = keyof CommandParams;
98
114
  export interface SerializedCommandError {
@@ -115,3 +131,8 @@ export interface CommandResponseEnvelope {
115
131
  error?: SerializedCommandError;
116
132
  }
117
133
  export declare function isResponseEnvelope(value: unknown): value is CommandResponseEnvelope;
134
+ export type LifecycleEvent = "ready";
135
+ export interface LifecycleEnvelope {
136
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
137
+ lifecycle: LifecycleEvent;
138
+ }
package/dist/context.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  export type CastleHost = "web" | "mobile" | "dev";
2
2
  export interface CastleEmbed {
3
3
  edit?: boolean;
4
+ /**
5
+ * @deprecated Hosts should not set this for new behavior. The SDK now infers
6
+ * host-framed vs standalone presentation from its runtime context.
7
+ */
4
8
  feed?: boolean;
5
9
  host?: CastleHost;
6
10
  }
@@ -0,0 +1,4 @@
1
+ export interface CastleLifecycleApi {
2
+ ready(): void;
3
+ }
4
+ export declare const Lifecycle: CastleLifecycleApi;
@@ -0,0 +1,13 @@
1
+ // Deck-side lifecycle signals. The feed reveals the deck when it reports `ready`
2
+ // (its first presentable frame) instead of waiting out a fixed timer.
3
+ import { hostNotify } from "./transport";
4
+ let readySent = false;
5
+ function ready() {
6
+ if (readySent)
7
+ return;
8
+ readySent = true;
9
+ hostNotify("ready");
10
+ }
11
+ export const Lifecycle = {
12
+ ready,
13
+ };
@@ -0,0 +1,7 @@
1
+ import type { PortalOpenResult, PortalPrefetchResult } from "./commands";
2
+ export type { PortalOpenResult, PortalOpenStatus, PortalPrefetchResult, PortalPrefetchStatus, } from "./commands";
3
+ export interface CastlePortalApi {
4
+ open(deckId: string): Promise<PortalOpenResult>;
5
+ prefetch(deckId: string): Promise<PortalPrefetchResult>;
6
+ }
7
+ export declare const Portal: CastlePortalApi;
package/dist/portal.js ADDED
@@ -0,0 +1,35 @@
1
+ // Portal — a deck-facing capability for sending the player to another Castle
2
+ // deck ("following a portal"). The host owns the transition: in the Castle feed
3
+ // it swipes to the target deck (which may itself be a web deck or a normal
4
+ // engine deck); elsewhere (the website, the dev server, the editor) there's no
5
+ // feed to navigate, so it resolves `unavailable` and nothing happens.
6
+ //
7
+ // Navigation is effectively fire-and-forget: once the host accepts it, the
8
+ // source deck is torn down as the feed moves to the target, so a deck should
9
+ // not rely on code running after a successful `open`.
10
+ import { CastleError } from "./errors";
11
+ import { hostRequest } from "./transport";
12
+ export const Portal = {
13
+ open,
14
+ prefetch,
15
+ };
16
+ async function open(deckId) {
17
+ if (typeof deckId !== "string" || deckId.length === 0) {
18
+ throw new CastleError({
19
+ code: "INVALID_ARGUMENT",
20
+ message: "Portal.open requires a deckId.",
21
+ operation: "Portal.open",
22
+ });
23
+ }
24
+ return hostRequest("portal.open", { targetDeckId: deckId });
25
+ }
26
+ async function prefetch(deckId) {
27
+ if (typeof deckId !== "string" || deckId.length === 0) {
28
+ throw new CastleError({
29
+ code: "INVALID_ARGUMENT",
30
+ message: "Portal.prefetch requires a deckId.",
31
+ operation: "Portal.prefetch",
32
+ });
33
+ }
34
+ return hostRequest("portal.prefetch", { targetDeckId: deckId });
35
+ }
package/dist/runtime.js CHANGED
@@ -48,11 +48,6 @@ export function initCard() {
48
48
  card.id = "castle-card";
49
49
  document.body.appendChild(card);
50
50
  function resize() {
51
- if (getCastleEmbed()?.feed === true) {
52
- card.style.width = "100vw";
53
- card.style.height = "100vh";
54
- return;
55
- }
56
51
  const { w, h } = computeCardSize();
57
52
  card.style.width = w + "px";
58
53
  card.style.height = h + "px";
@@ -61,14 +56,11 @@ export function initCard() {
61
56
  window.addEventListener("resize", resize);
62
57
  return card;
63
58
  }
64
- // Constrains whatever the deck renders into #root to a centered 5:7 card.
65
- // The mobile feed host card-sizes its WebView itself, and the editor needs the
66
- // full viewport, so the card shell applies only to standalone play.
59
+ // Constrains whatever the deck renders into #root to a 5:7 card in play mode.
60
+ // Hosts own max size and padding; the SDK only preserves the card aspect ratio.
67
61
  function initPlayCard() {
68
62
  if (isEdit())
69
63
  return;
70
- if (getCastleEmbed()?.feed === true)
71
- return;
72
64
  const style = document.createElement("style");
73
65
  style.textContent = `
74
66
  html, body { background: #000; }
@@ -94,23 +86,27 @@ function initPlayCard() {
94
86
  window.addEventListener("resize", resize);
95
87
  }
96
88
  function computeCardSize() {
97
- const maxW = 450;
98
- const maxH = 630;
99
- const pad = 20;
100
- const aw = window.innerWidth - pad * 2;
101
- const ah = window.innerHeight - pad * 2;
89
+ const pad = shouldUseStandaloneChrome() ? 20 : 0;
90
+ const aw = Math.max(1, window.innerWidth - pad * 2);
91
+ const ah = Math.max(1, window.innerHeight - pad * 2);
102
92
  let w;
103
93
  let h;
104
94
  if (aw / ah < CARD_RATIO) {
105
- w = Math.min(aw, maxW);
95
+ w = aw;
106
96
  h = w / CARD_RATIO;
107
97
  }
108
98
  else {
109
- h = Math.min(ah, maxH);
99
+ h = ah;
110
100
  w = h * CARD_RATIO;
111
101
  }
112
102
  return { w, h };
113
103
  }
104
+ function shouldUseStandaloneChrome() {
105
+ if (window.parent !== window)
106
+ return false;
107
+ const embed = getCastleEmbed();
108
+ return embed?.host === "dev";
109
+ }
114
110
  function sendMsg(msg) {
115
111
  if (ws && ws.readyState === WebSocket.OPEN) {
116
112
  ws.send(JSON.stringify(msg));
@@ -1,4 +1,4 @@
1
- import { type CommandName, type CommandParams, type CommandResult } from "./commands";
1
+ import { type CommandName, type CommandParams, type CommandResult, type LifecycleEvent } from "./commands";
2
2
  type PostChannel = "mobile" | "web";
3
3
  interface ReactNativeWebViewBridge {
4
4
  postMessage: (message: string) => void;
@@ -13,4 +13,5 @@ declare global {
13
13
  }
14
14
  export declare function hostRequest<C extends CommandName>(command: C, params: CommandParams[C]): Promise<CommandResult[C]>;
15
15
  export declare function getCommandChannel(): PostChannel | "local";
16
+ export declare function hostNotify(event: LifecycleEvent): void;
16
17
  export {};
package/dist/transport.js CHANGED
@@ -17,6 +17,7 @@ const REQUEST_TIMEOUT_MS = 15000;
17
17
  // is wrong for them: they get NO timeout and resolve only when the host replies.
18
18
  const INTERACTIVE_COMMANDS = new Set([
19
19
  "pass.offer",
20
+ "portal.open",
20
21
  ]);
21
22
  let nextRequestId = 1;
22
23
  const pending = new Map();
@@ -52,6 +53,20 @@ export async function hostRequest(command, params) {
52
53
  export function getCommandChannel() {
53
54
  return resolveChannel();
54
55
  }
56
+ // Fire-and-forget counterpart to hostRequest: no reply, no timeout. The local
57
+ // dev server has no surface to react to lifecycle events, so it's skipped.
58
+ export function hostNotify(event) {
59
+ if (typeof window === "undefined")
60
+ return;
61
+ const channel = resolveChannel();
62
+ if (channel === "local")
63
+ return;
64
+ const envelope = {
65
+ castleSdk: CASTLE_SDK_PROTOCOL,
66
+ lifecycle: event,
67
+ };
68
+ sendEnvelope(channel, envelope);
69
+ }
55
70
  function resolveChannel() {
56
71
  if (typeof window === "undefined")
57
72
  return "local";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "type": "module",
5
5
  "main": "dist/castle.js",
6
6
  "types": "dist/castle.d.ts",