@moku-labs/web 2.0.0 → 2.1.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.
package/dist/index.d.cts CHANGED
@@ -279,7 +279,7 @@ interface PathMatcher {
279
279
  } | null;
280
280
  }
281
281
  declare namespace types_d_exports$6 {
282
- export { Api$5 as Api, ClientRoute, CompileInput, CompiledRoute, Config$5 as Config, ExtractApi$2 as ExtractApi, ExtractRouteParams, ExtractSegmentParameter, GenerateContext, HeadConfig$1 as HeadConfig, LayoutContext, LoadContext, MatcherTable, Prettify, RouteBuilder, RouteContext, RouteDefinition, RouteHandlers, RouteMap, RouteRequire, RouteState, RouterApi, RouterConfig, RouterState, State$5 as State, TypedRoute, Urls };
282
+ export { Api$5 as Api, ClientRoute, CompileInput, CompiledRoute, Config$5 as Config, ExtractApi$2 as ExtractApi, ExtractRouteParams, ExtractSegmentParameter, GenerateContext, HeadConfig$1 as HeadConfig, LayoutContext, LoadContext, MatcherTable, Prettify, RouteBuilder, RouteContext, RouteDefinition, RouteHandlers, RouteMap, RouteRequire, RouteState, RouterApi, RouterConfig, RouterState, ScrollMode, State$5 as State, TransitionMode, TypedRoute, Urls };
283
283
  }
284
284
  /**
285
285
  * Param contribution of a single path segment. `{name:?}` / `:name?` → optional;
@@ -427,6 +427,27 @@ interface HeadConfig$1 {
427
427
  /** Arbitrary extra head fields. */
428
428
  readonly [key: string]: unknown;
429
429
  }
430
+ /**
431
+ * Named view-transition behaviour for a navigation TO a route — a closed, typed
432
+ * vocabulary the SPA kernel interprets (NOT free-form app data, so it earns a
433
+ * first-class `.transition()` method instead of a `.meta()` key):
434
+ * - `"none"` — swap with no View Transition (instant);
435
+ * - `"crossfade"` — the default root crossfade (`startViewTransition(swap)`);
436
+ * - `"slide"` / `"morph"` — a named transition; the kernel tags the transition with
437
+ * `types: ["slide"|"morph"]` so consumer CSS can target `:active-view-transition-type(slide)`
438
+ * (and shared `view-transition-name`s morph one element into another).
439
+ *
440
+ * A route's `.transition()` OVERRIDES the app-wide default (`spa.viewTransitions`).
441
+ */
442
+ type TransitionMode = "none" | "crossfade" | "slide" | "morph";
443
+ /**
444
+ * Scroll behaviour for a navigation TO a route — a closed, typed vocabulary the SPA
445
+ * kernel interprets (a first-class `.scroll()` method, not a `.meta()` key):
446
+ * - `"top"` — reset to the top of the page on the swap (the default for forward navs);
447
+ * - `"preserve"` — keep the current scroll position (e.g. opening an overlay/issue route
448
+ * over a board that must stay still). A route's `.scroll()` OVERRIDES `spa.scrollRestoration`.
449
+ */
450
+ type ScrollMode = "top" | "preserve";
430
451
  /**
431
452
  * Fluent route builder. Each chain method returns the same builder with a
432
453
  * (possibly widened) state generic. Only `.load()` widens the data type `D`.
@@ -459,6 +480,24 @@ interface RouteBuilder<S extends RouteState> extends RouteDefinition {
459
480
  * projected verbatim into `clientManifest()` and shipped to the browser.
460
481
  */
461
482
  meta(meta: Record<string, unknown>): RouteBuilder<S>;
483
+ /**
484
+ * Declare the view-transition behaviour for navigations TO this route — a typed
485
+ * framework directive (the SPA kernel reads it), overriding the app-wide
486
+ * `spa.viewTransitions` default. See {@link TransitionMode}.
487
+ *
488
+ * @example
489
+ * route("/board/{id}/issue/{issueId}").transition("morph"); // card → panel
490
+ */
491
+ transition(mode: TransitionMode): RouteBuilder<S>;
492
+ /**
493
+ * Declare the scroll behaviour for navigations TO this route — a typed framework
494
+ * directive (the SPA kernel reads it), overriding the app-wide
495
+ * `spa.scrollRestoration` default. See {@link ScrollMode}.
496
+ *
497
+ * @example
498
+ * route("/board/{id}/issue/{issueId}").scroll("preserve"); // overlay: don't move the board
499
+ */
500
+ scroll(mode: ScrollMode): RouteBuilder<S>;
462
501
  /** Attach a JSON serializer for the route's data. */
463
502
  toJson(handler: (ctx: RouteContext<S>) => unknown): RouteBuilder<S>;
464
503
  /** Override the output file-path producer. */
@@ -491,6 +530,10 @@ interface RouteDefinition {
491
530
  readonly pattern: string;
492
531
  /** Metadata bag accumulated from `.meta()` (named `_meta` to avoid clashing with the `.meta()` builder method). */
493
532
  readonly _meta: Record<string, unknown>;
533
+ /** Named view-transition behaviour from `.transition()` (read by the SPA kernel); undefined → app default. */
534
+ readonly _transition?: TransitionMode;
535
+ /** Scroll behaviour from `.scroll()` (read by the SPA kernel); undefined → app default. */
536
+ readonly _scroll?: ScrollMode;
494
537
  /** Build-time handler bag (load/render/head/generate/toJson/toFile). */
495
538
  readonly _handlers: RouteHandlers;
496
539
  }
@@ -872,7 +915,12 @@ type Api$4 = {
872
915
  composeTitle(head: HeadConfig | undefined): string;
873
916
  };
874
917
  declare namespace types_d_exports$7 {
875
- export { AnyVNode, ExtractApi$1 as ExtractApi, ISLAND_HOOK_NAMES, IslandContext, IslandDef, IslandEventHandler, IslandEvents, IslandHooks, IslandInstance, IslandRender, IslandRouteSlice, IslandSpec, IslandSpecExtras, IslandStateFactory, PageData, RenderResult, ResolvedSpaConfig, SpaApi, SpaConfig, SpaContext, SpaDataReader, SpaEmitFunction, SpaEvents, SpaKernel, SpaKernelDeps, SpaRequire, SpaState };
918
+ export { AnyVNode, ExtractApi$1 as ExtractApi, ISLAND_HOOK_NAMES, IslandContext, IslandDef, IslandEventHandler, IslandEvents, IslandHooks, IslandInstance, IslandRender, IslandRouteSlice, IslandSpec, IslandSpecExtras, IslandStateFactory, NavigateOptions, PageData, RenderResult, ResolvedSpaConfig, ScrollMode, SpaApi, SpaConfig, SpaContext, SpaDataReader, SpaEmitFunction, SpaEvents, SpaKernel, SpaKernelDeps, SpaRequire, SpaState, TransitionMode };
919
+ }
920
+ /** Per-navigation options for the programmatic navigators (`ctx.navigate` / `app.spa.navigate`). */
921
+ interface NavigateOptions {
922
+ /** Override the scroll behaviour for this single navigation (else the route/app default). */
923
+ scroll?: ScrollMode;
876
924
  }
877
925
  /** Payload map for the events `spa` emits, used to type the kernel's `emit` closure. */
878
926
  type SpaEvents = {
@@ -947,10 +995,21 @@ type SpaConfig = {
947
995
  */
948
996
  swapSelector?: string;
949
997
  /**
950
- * Use the View Transitions API for cross-fade swaps when available.
951
- * Falls back to an instant swap when unsupported. Defaults to `false`.
998
+ * The app-wide default View-Transition behaviour for navigations. Accepts a
999
+ * boolean (legacy: `true` → `"crossfade"`, `false` → `"none"`) OR a named
1000
+ * {@link TransitionMode} (`"none" | "crossfade" | "slide" | "morph"`). A route's
1001
+ * `.transition()` overrides this per-route. Falls back to an instant swap when the
1002
+ * View Transitions API is unsupported or under `prefers-reduced-motion`. Defaults
1003
+ * to `false` (`"none"`).
1004
+ */
1005
+ viewTransitions?: boolean | TransitionMode;
1006
+ /**
1007
+ * The app-wide default scroll behaviour for forward navigations: `"top"` (reset to
1008
+ * the top, the default) or `"preserve"` (keep the current scroll). A route's
1009
+ * `.scroll()` overrides this per-route. Traverse (back/forward) always restores its
1010
+ * saved position regardless. Defaults to `"top"`.
952
1011
  */
953
- viewTransitions?: boolean;
1012
+ scrollRestoration?: ScrollMode;
954
1013
  /**
955
1014
  * Show the in-house top progress bar during navigation. Defaults to `true`.
956
1015
  */
@@ -965,8 +1024,12 @@ type SpaConfig = {
965
1024
  interface ResolvedSpaConfig {
966
1025
  /** CSS selector for the swapped page region. */
967
1026
  swapSelector: string;
968
- /** Whether View Transitions are enabled. */
1027
+ /** Whether View Transitions are enabled at all (false ⇒ always instant swap). */
969
1028
  viewTransitions: boolean;
1029
+ /** The app-wide default transition mode applied when a route declares none. */
1030
+ defaultTransition: TransitionMode;
1031
+ /** The app-wide default scroll behaviour applied when a route declares none. */
1032
+ scrollRestoration: ScrollMode;
970
1033
  /** Whether the progress bar is enabled. */
971
1034
  progressBar: boolean;
972
1035
  /** Pre-registered islands. */
@@ -977,9 +1040,14 @@ interface ResolvedSpaConfig {
977
1040
  * - a Preact `VNode` — committed into the host through the lazy Preact gate (`commitVNode`);
978
1041
  * - a `Node` — replaces the host's children;
979
1042
  * - a `string` — set as the host's `innerHTML`;
1043
+ * - `null` — render NOTHING but stay mountable: the host's Preact tree is unmounted via
1044
+ * `render(null, host)` (NOT `innerHTML = ""`), so a later non-empty render re-commits
1045
+ * cleanly. This is the correct "empty" for a persistent render-island (e.g. a closed
1046
+ * overlay panel) — returning `""` instead corrupts Preact's retained vdom and the panel
1047
+ * never re-opens;
980
1048
  * - `void`/`undefined` — the render mutated the DOM itself (DOM-only islands → no Preact loaded).
981
1049
  */
982
- type RenderResult = AnyVNode | Node | string | void;
1050
+ type RenderResult = AnyVNode | Node | string | null | void;
983
1051
  /**
984
1052
  * A Preact `VNode` of ANY props shape. A render returns `h(Component, props)`, i.e. a
985
1053
  * `VNode<SomeProps>`; the props generic is invariant under `exactOptionalPropertyTypes`,
@@ -1065,6 +1133,21 @@ interface IslandContext<S = undefined> {
1065
1133
  readonly locale: string;
1066
1134
  /** Build a link to a named route by pattern substitution (same output as `app.router.toUrl`). */
1067
1135
  readonly url: (name: string, params?: Record<string, string>) => string;
1136
+ /**
1137
+ * Programmatically navigate the SPA to an internal path (same swap pipeline as a
1138
+ * link click; no-op without a DOM). Build `path` from the route map's `urls`/`ctx.url`,
1139
+ * never a literal. Pass `{ scroll: "preserve" }` to keep the current scroll for this nav.
1140
+ *
1141
+ * An ALWAYS-PRESENT member (like `set`/`flush`/`cleanup`) so every island can navigate
1142
+ * without an `app` handle — no synthesized anchor click.
1143
+ *
1144
+ * @param path - The internal destination path (e.g. `ctx.url("board", { id })`).
1145
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
1146
+ * @returns void
1147
+ * @example
1148
+ * ctx.navigate(ctx.url("issue", { id, issueId }));
1149
+ */
1150
+ readonly navigate: (path: string, options?: NavigateOptions) => void;
1068
1151
  /** The live per-instance state (the object returned by `spec.state`). `undefined` for legacy hooks-only islands. */
1069
1152
  readonly state: S;
1070
1153
  /**
@@ -1307,11 +1390,12 @@ interface SpaKernel {
1307
1390
  * Process a navigation to `path`: fetch then swap then head-sync then emit.
1308
1391
  *
1309
1392
  * @param path - The target path to navigate to.
1393
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
1310
1394
  * @returns void
1311
1395
  * @example
1312
1396
  * kernel.processNav("/about");
1313
1397
  */
1314
- processNav(path: string): void;
1398
+ processNav(path: string, options?: NavigateOptions): void;
1315
1399
  /**
1316
1400
  * Query the swap region and mount islands for matching elements.
1317
1401
  *
@@ -1343,6 +1427,12 @@ interface SpaState {
1343
1427
  destroyRouter: (() => void) | null;
1344
1428
  /** Whether the browser runtime has been booted. */
1345
1429
  started: boolean;
1430
+ /**
1431
+ * The kernel's programmatic navigator, bound at init (null until then). The island
1432
+ * context's `ctx.navigate` reads through this — the same decoupled seam `islandApis`
1433
+ * uses — so `islands.ts` never imports the kernel.
1434
+ */
1435
+ navigate: ((path: string, options?: NavigateOptions) => void) | null;
1346
1436
  /** The single shared SPA kernel instance (null until onInit builds it). */
1347
1437
  kernel: SpaKernel | null;
1348
1438
  }
@@ -1361,11 +1451,12 @@ type SpaApi = {
1361
1451
  * Programmatically navigate to a path (client runtime; no-op without a DOM).
1362
1452
  *
1363
1453
  * @param path - Target path (pathname, optionally with search/hash).
1454
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
1364
1455
  * @returns void
1365
1456
  * @example
1366
1457
  * app.spa.navigate("/about");
1367
1458
  */
1368
- navigate(path: string): void;
1459
+ navigate(path: string, options?: NavigateOptions): void;
1369
1460
  /**
1370
1461
  * Read the current resolved URL.
1371
1462
  *
@@ -3520,6 +3611,112 @@ declare const routerPlugin: import("@moku-labs/core").PluginInstance<"router", R
3520
3611
  */
3521
3612
  declare const sitePlugin: import("@moku-labs/core").PluginInstance<"site", Config$7, Record<string, never>, Api$7, {}> & Record<never, never>;
3522
3613
  //#endregion
3614
+ //#region src/plugins/spa/channel.d.ts
3615
+ /**
3616
+ * @file spa plugin — `createChannel`, a client realtime WebSocket primitive.
3617
+ *
3618
+ * A live, self-healing message channel for islands that consume a server push stream
3619
+ * (e.g. a Durable Object broadcasting board patches). It owns the parts every real-time
3620
+ * app otherwise hand-rolls: a single active socket bound to one channel `id`, refcounted
3621
+ * `subscribe` (first subscriber connects, last disconnects — so cross-island mount order
3622
+ * stops being load-bearing), exponential-backoff auto-reconnect, an optional keepalive
3623
+ * ping, a pre-seed buffer (frames that arrive before the island's snapshot loads are
3624
+ * queued and flushed on `seed`, so the connect→load race drops nothing), and an
3625
+ * optimistic `deliverLocal` echo.
3626
+ *
3627
+ * Browser-only (uses `WebSocket`); reached ONLY through an explicit `createChannel(...)`
3628
+ * call, so an app that never opens a channel never pulls this module into its graph.
3629
+ * @see {@link createChannel} for the usage example.
3630
+ */
3631
+ /** Keepalive configuration: a frame sent on an interval, and the reply to ignore. */
3632
+ interface ChannelKeepAlive {
3633
+ /** The keepalive frame to send (e.g. `"ping"`). */
3634
+ send: string;
3635
+ /** A reply frame to ignore rather than parse (e.g. `"pong"`). */
3636
+ ignore?: string;
3637
+ /** Interval between keepalive sends, in ms. Defaults to `30000`. */
3638
+ everyMs?: number;
3639
+ }
3640
+ /** Auto-reconnect backoff configuration (exponential, capped). */
3641
+ interface ChannelReconnect {
3642
+ /** First retry delay in ms (doubles each attempt). Defaults to `500`. */
3643
+ baseMs?: number;
3644
+ /** Maximum retry delay in ms (the backoff ceiling). Defaults to `8000`. */
3645
+ maxMs?: number;
3646
+ }
3647
+ /** Options for {@link createChannel}. */
3648
+ interface ChannelOptions<T> {
3649
+ /** Build the `ws(s)://` URL for a channel `id`. */
3650
+ url: (id: string) => string;
3651
+ /**
3652
+ * Parse a raw text frame into a message, or return `undefined` to drop it (malformed
3653
+ * frame / keepalive reply). Defaults to `JSON.parse` (dropping frames that fail to parse).
3654
+ */
3655
+ parse?: (raw: string) => T | undefined;
3656
+ /** Keepalive ping configuration (off when omitted). */
3657
+ keepAlive?: ChannelKeepAlive;
3658
+ /** Auto-reconnect backoff (defaults applied when omitted; pass `false` to disable). */
3659
+ reconnect?: ChannelReconnect | false;
3660
+ /**
3661
+ * Buffer frames that arrive before {@link Channel.seed} and flush them (in arrival
3662
+ * order) on seed, closing the connect→load race. Defaults to `false` (deliver live).
3663
+ */
3664
+ bufferUntilSeed?: boolean;
3665
+ }
3666
+ /** A live realtime channel (the surface returned by {@link createChannel}). */
3667
+ interface Channel<T> {
3668
+ /**
3669
+ * Subscribe to messages for channel `id`, connecting (or switching) the single
3670
+ * active socket to it. Refcounted: the first subscriber connects, the last to
3671
+ * unsubscribe disconnects. Returns an unsubscribe function.
3672
+ *
3673
+ * @param id - The channel id to subscribe to.
3674
+ * @param handler - Called with each delivered message.
3675
+ * @returns A function that removes this handler (and disconnects when it was the last).
3676
+ */
3677
+ subscribe(id: string, handler: (message: T) => void): () => void;
3678
+ /**
3679
+ * Mark the channel seeded and flush the pre-seed buffer (no-op unless
3680
+ * `bufferUntilSeed` and `id` is the active channel). Idempotent.
3681
+ *
3682
+ * @param id - The channel id whose buffer to flush (must be the active id).
3683
+ */
3684
+ seed(id: string): void;
3685
+ /**
3686
+ * Deliver a message to the local handlers immediately (the optimistic-update path —
3687
+ * the acting client reflects its own mutation without waiting for the server echo).
3688
+ *
3689
+ * @param id - The channel id (must be the active id; ignored otherwise).
3690
+ * @param message - The message to deliver locally now.
3691
+ */
3692
+ deliverLocal(id: string, message: T): void;
3693
+ /**
3694
+ * Send a raw text frame over the live socket (no-op when not open).
3695
+ *
3696
+ * @param raw - The frame to send.
3697
+ */
3698
+ send(raw: string): void;
3699
+ /**
3700
+ * The currently-subscribed channel id, or `undefined` when disconnected.
3701
+ *
3702
+ * @returns The active id, or undefined.
3703
+ */
3704
+ current(): string | undefined;
3705
+ /** Close the socket, cancel reconnect, and clear all handlers. */
3706
+ disconnect(): void;
3707
+ }
3708
+ /**
3709
+ * Create a live realtime channel — a single self-healing WebSocket bound to one channel
3710
+ * `id` at a time, with refcounted subscription, auto-reconnect, an optional keepalive, a
3711
+ * pre-seed buffer, and an optimistic local-echo path. See the file header for the model.
3712
+ *
3713
+ * @param options - The channel configuration (see {@link ChannelOptions}).
3714
+ * @returns The {@link Channel} control surface.
3715
+ * @example
3716
+ * const board = createChannel<BoardPatch>({ url: (id) => `wss://host/ws/board/${id}` });
3717
+ */
3718
+ declare function createChannel<T>(options: ChannelOptions<T>): Channel<T>;
3719
+ //#endregion
3523
3720
  //#region src/plugins/spa/islands.d.ts
3524
3721
  /**
3525
3722
  * Create a validated island definition. Accepts either the legacy hooks-only form
@@ -3749,4 +3946,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
3749
3946
  */
3750
3947
  declare const createPlugin: import("@moku-labs/core").BoundCreatePluginFunction<Config$8, Events, import("@moku-labs/core").CoreApisFromTuple<[import("@moku-labs/core").CorePluginInstance<"log", import("@moku-labs/common").LogConfig, import("@moku-labs/common").LogState, import("@moku-labs/common").LogApi>, import("@moku-labs/core").CorePluginInstance<"env", import("@moku-labs/common").EnvConfig, import("@moku-labs/common").EnvState, import("@moku-labs/common").EnvApi>]>>;
3751
3948
  //#endregion
3752
- export { types_d_exports as Build, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, type EmbedFacade, EmbedFacadeButton, type EmbedFacadeProps, type EmbedOptions, type Env, type GalleryComponent, type GalleryOptions, type GalleryProps, type GallerySlide, GalleryTrack, types_d_exports$5 as Head, type Log, types_d_exports$6 as Router, types_d_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
3949
+ export { types_d_exports as Build, type Channel, type ChannelKeepAlive, type ChannelOptions, type ChannelReconnect, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, type EmbedFacade, EmbedFacadeButton, type EmbedFacadeProps, type EmbedOptions, type Env, type GalleryComponent, type GalleryOptions, type GalleryProps, type GallerySlide, GalleryTrack, types_d_exports$5 as Head, type Log, types_d_exports$6 as Router, types_d_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createChannel, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
package/dist/index.d.mts CHANGED
@@ -277,7 +277,7 @@ interface PathMatcher {
277
277
  } | null;
278
278
  }
279
279
  declare namespace types_d_exports$6 {
280
- export { Api$5 as Api, ClientRoute, CompileInput, CompiledRoute, Config$5 as Config, ExtractApi$2 as ExtractApi, ExtractRouteParams, ExtractSegmentParameter, GenerateContext, HeadConfig$1 as HeadConfig, LayoutContext, LoadContext, MatcherTable, Prettify, RouteBuilder, RouteContext, RouteDefinition, RouteHandlers, RouteMap, RouteRequire, RouteState, RouterApi, RouterConfig, RouterState, State$5 as State, TypedRoute, Urls };
280
+ export { Api$5 as Api, ClientRoute, CompileInput, CompiledRoute, Config$5 as Config, ExtractApi$2 as ExtractApi, ExtractRouteParams, ExtractSegmentParameter, GenerateContext, HeadConfig$1 as HeadConfig, LayoutContext, LoadContext, MatcherTable, Prettify, RouteBuilder, RouteContext, RouteDefinition, RouteHandlers, RouteMap, RouteRequire, RouteState, RouterApi, RouterConfig, RouterState, ScrollMode, State$5 as State, TransitionMode, TypedRoute, Urls };
281
281
  }
282
282
  /**
283
283
  * Param contribution of a single path segment. `{name:?}` / `:name?` → optional;
@@ -425,6 +425,27 @@ interface HeadConfig$1 {
425
425
  /** Arbitrary extra head fields. */
426
426
  readonly [key: string]: unknown;
427
427
  }
428
+ /**
429
+ * Named view-transition behaviour for a navigation TO a route — a closed, typed
430
+ * vocabulary the SPA kernel interprets (NOT free-form app data, so it earns a
431
+ * first-class `.transition()` method instead of a `.meta()` key):
432
+ * - `"none"` — swap with no View Transition (instant);
433
+ * - `"crossfade"` — the default root crossfade (`startViewTransition(swap)`);
434
+ * - `"slide"` / `"morph"` — a named transition; the kernel tags the transition with
435
+ * `types: ["slide"|"morph"]` so consumer CSS can target `:active-view-transition-type(slide)`
436
+ * (and shared `view-transition-name`s morph one element into another).
437
+ *
438
+ * A route's `.transition()` OVERRIDES the app-wide default (`spa.viewTransitions`).
439
+ */
440
+ type TransitionMode = "none" | "crossfade" | "slide" | "morph";
441
+ /**
442
+ * Scroll behaviour for a navigation TO a route — a closed, typed vocabulary the SPA
443
+ * kernel interprets (a first-class `.scroll()` method, not a `.meta()` key):
444
+ * - `"top"` — reset to the top of the page on the swap (the default for forward navs);
445
+ * - `"preserve"` — keep the current scroll position (e.g. opening an overlay/issue route
446
+ * over a board that must stay still). A route's `.scroll()` OVERRIDES `spa.scrollRestoration`.
447
+ */
448
+ type ScrollMode = "top" | "preserve";
428
449
  /**
429
450
  * Fluent route builder. Each chain method returns the same builder with a
430
451
  * (possibly widened) state generic. Only `.load()` widens the data type `D`.
@@ -457,6 +478,24 @@ interface RouteBuilder<S extends RouteState> extends RouteDefinition {
457
478
  * projected verbatim into `clientManifest()` and shipped to the browser.
458
479
  */
459
480
  meta(meta: Record<string, unknown>): RouteBuilder<S>;
481
+ /**
482
+ * Declare the view-transition behaviour for navigations TO this route — a typed
483
+ * framework directive (the SPA kernel reads it), overriding the app-wide
484
+ * `spa.viewTransitions` default. See {@link TransitionMode}.
485
+ *
486
+ * @example
487
+ * route("/board/{id}/issue/{issueId}").transition("morph"); // card → panel
488
+ */
489
+ transition(mode: TransitionMode): RouteBuilder<S>;
490
+ /**
491
+ * Declare the scroll behaviour for navigations TO this route — a typed framework
492
+ * directive (the SPA kernel reads it), overriding the app-wide
493
+ * `spa.scrollRestoration` default. See {@link ScrollMode}.
494
+ *
495
+ * @example
496
+ * route("/board/{id}/issue/{issueId}").scroll("preserve"); // overlay: don't move the board
497
+ */
498
+ scroll(mode: ScrollMode): RouteBuilder<S>;
460
499
  /** Attach a JSON serializer for the route's data. */
461
500
  toJson(handler: (ctx: RouteContext<S>) => unknown): RouteBuilder<S>;
462
501
  /** Override the output file-path producer. */
@@ -489,6 +528,10 @@ interface RouteDefinition {
489
528
  readonly pattern: string;
490
529
  /** Metadata bag accumulated from `.meta()` (named `_meta` to avoid clashing with the `.meta()` builder method). */
491
530
  readonly _meta: Record<string, unknown>;
531
+ /** Named view-transition behaviour from `.transition()` (read by the SPA kernel); undefined → app default. */
532
+ readonly _transition?: TransitionMode;
533
+ /** Scroll behaviour from `.scroll()` (read by the SPA kernel); undefined → app default. */
534
+ readonly _scroll?: ScrollMode;
492
535
  /** Build-time handler bag (load/render/head/generate/toJson/toFile). */
493
536
  readonly _handlers: RouteHandlers;
494
537
  }
@@ -870,7 +913,12 @@ type Api$4 = {
870
913
  composeTitle(head: HeadConfig | undefined): string;
871
914
  };
872
915
  declare namespace types_d_exports$7 {
873
- export { AnyVNode, ExtractApi$1 as ExtractApi, ISLAND_HOOK_NAMES, IslandContext, IslandDef, IslandEventHandler, IslandEvents, IslandHooks, IslandInstance, IslandRender, IslandRouteSlice, IslandSpec, IslandSpecExtras, IslandStateFactory, PageData, RenderResult, ResolvedSpaConfig, SpaApi, SpaConfig, SpaContext, SpaDataReader, SpaEmitFunction, SpaEvents, SpaKernel, SpaKernelDeps, SpaRequire, SpaState };
916
+ export { AnyVNode, ExtractApi$1 as ExtractApi, ISLAND_HOOK_NAMES, IslandContext, IslandDef, IslandEventHandler, IslandEvents, IslandHooks, IslandInstance, IslandRender, IslandRouteSlice, IslandSpec, IslandSpecExtras, IslandStateFactory, NavigateOptions, PageData, RenderResult, ResolvedSpaConfig, ScrollMode, SpaApi, SpaConfig, SpaContext, SpaDataReader, SpaEmitFunction, SpaEvents, SpaKernel, SpaKernelDeps, SpaRequire, SpaState, TransitionMode };
917
+ }
918
+ /** Per-navigation options for the programmatic navigators (`ctx.navigate` / `app.spa.navigate`). */
919
+ interface NavigateOptions {
920
+ /** Override the scroll behaviour for this single navigation (else the route/app default). */
921
+ scroll?: ScrollMode;
874
922
  }
875
923
  /** Payload map for the events `spa` emits, used to type the kernel's `emit` closure. */
876
924
  type SpaEvents = {
@@ -945,10 +993,21 @@ type SpaConfig = {
945
993
  */
946
994
  swapSelector?: string;
947
995
  /**
948
- * Use the View Transitions API for cross-fade swaps when available.
949
- * Falls back to an instant swap when unsupported. Defaults to `false`.
996
+ * The app-wide default View-Transition behaviour for navigations. Accepts a
997
+ * boolean (legacy: `true` → `"crossfade"`, `false` → `"none"`) OR a named
998
+ * {@link TransitionMode} (`"none" | "crossfade" | "slide" | "morph"`). A route's
999
+ * `.transition()` overrides this per-route. Falls back to an instant swap when the
1000
+ * View Transitions API is unsupported or under `prefers-reduced-motion`. Defaults
1001
+ * to `false` (`"none"`).
1002
+ */
1003
+ viewTransitions?: boolean | TransitionMode;
1004
+ /**
1005
+ * The app-wide default scroll behaviour for forward navigations: `"top"` (reset to
1006
+ * the top, the default) or `"preserve"` (keep the current scroll). A route's
1007
+ * `.scroll()` overrides this per-route. Traverse (back/forward) always restores its
1008
+ * saved position regardless. Defaults to `"top"`.
950
1009
  */
951
- viewTransitions?: boolean;
1010
+ scrollRestoration?: ScrollMode;
952
1011
  /**
953
1012
  * Show the in-house top progress bar during navigation. Defaults to `true`.
954
1013
  */
@@ -963,8 +1022,12 @@ type SpaConfig = {
963
1022
  interface ResolvedSpaConfig {
964
1023
  /** CSS selector for the swapped page region. */
965
1024
  swapSelector: string;
966
- /** Whether View Transitions are enabled. */
1025
+ /** Whether View Transitions are enabled at all (false ⇒ always instant swap). */
967
1026
  viewTransitions: boolean;
1027
+ /** The app-wide default transition mode applied when a route declares none. */
1028
+ defaultTransition: TransitionMode;
1029
+ /** The app-wide default scroll behaviour applied when a route declares none. */
1030
+ scrollRestoration: ScrollMode;
968
1031
  /** Whether the progress bar is enabled. */
969
1032
  progressBar: boolean;
970
1033
  /** Pre-registered islands. */
@@ -975,9 +1038,14 @@ interface ResolvedSpaConfig {
975
1038
  * - a Preact `VNode` — committed into the host through the lazy Preact gate (`commitVNode`);
976
1039
  * - a `Node` — replaces the host's children;
977
1040
  * - a `string` — set as the host's `innerHTML`;
1041
+ * - `null` — render NOTHING but stay mountable: the host's Preact tree is unmounted via
1042
+ * `render(null, host)` (NOT `innerHTML = ""`), so a later non-empty render re-commits
1043
+ * cleanly. This is the correct "empty" for a persistent render-island (e.g. a closed
1044
+ * overlay panel) — returning `""` instead corrupts Preact's retained vdom and the panel
1045
+ * never re-opens;
978
1046
  * - `void`/`undefined` — the render mutated the DOM itself (DOM-only islands → no Preact loaded).
979
1047
  */
980
- type RenderResult = AnyVNode | Node | string | void;
1048
+ type RenderResult = AnyVNode | Node | string | null | void;
981
1049
  /**
982
1050
  * A Preact `VNode` of ANY props shape. A render returns `h(Component, props)`, i.e. a
983
1051
  * `VNode<SomeProps>`; the props generic is invariant under `exactOptionalPropertyTypes`,
@@ -1063,6 +1131,21 @@ interface IslandContext<S = undefined> {
1063
1131
  readonly locale: string;
1064
1132
  /** Build a link to a named route by pattern substitution (same output as `app.router.toUrl`). */
1065
1133
  readonly url: (name: string, params?: Record<string, string>) => string;
1134
+ /**
1135
+ * Programmatically navigate the SPA to an internal path (same swap pipeline as a
1136
+ * link click; no-op without a DOM). Build `path` from the route map's `urls`/`ctx.url`,
1137
+ * never a literal. Pass `{ scroll: "preserve" }` to keep the current scroll for this nav.
1138
+ *
1139
+ * An ALWAYS-PRESENT member (like `set`/`flush`/`cleanup`) so every island can navigate
1140
+ * without an `app` handle — no synthesized anchor click.
1141
+ *
1142
+ * @param path - The internal destination path (e.g. `ctx.url("board", { id })`).
1143
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
1144
+ * @returns void
1145
+ * @example
1146
+ * ctx.navigate(ctx.url("issue", { id, issueId }));
1147
+ */
1148
+ readonly navigate: (path: string, options?: NavigateOptions) => void;
1066
1149
  /** The live per-instance state (the object returned by `spec.state`). `undefined` for legacy hooks-only islands. */
1067
1150
  readonly state: S;
1068
1151
  /**
@@ -1305,11 +1388,12 @@ interface SpaKernel {
1305
1388
  * Process a navigation to `path`: fetch then swap then head-sync then emit.
1306
1389
  *
1307
1390
  * @param path - The target path to navigate to.
1391
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
1308
1392
  * @returns void
1309
1393
  * @example
1310
1394
  * kernel.processNav("/about");
1311
1395
  */
1312
- processNav(path: string): void;
1396
+ processNav(path: string, options?: NavigateOptions): void;
1313
1397
  /**
1314
1398
  * Query the swap region and mount islands for matching elements.
1315
1399
  *
@@ -1341,6 +1425,12 @@ interface SpaState {
1341
1425
  destroyRouter: (() => void) | null;
1342
1426
  /** Whether the browser runtime has been booted. */
1343
1427
  started: boolean;
1428
+ /**
1429
+ * The kernel's programmatic navigator, bound at init (null until then). The island
1430
+ * context's `ctx.navigate` reads through this — the same decoupled seam `islandApis`
1431
+ * uses — so `islands.ts` never imports the kernel.
1432
+ */
1433
+ navigate: ((path: string, options?: NavigateOptions) => void) | null;
1344
1434
  /** The single shared SPA kernel instance (null until onInit builds it). */
1345
1435
  kernel: SpaKernel | null;
1346
1436
  }
@@ -1359,11 +1449,12 @@ type SpaApi = {
1359
1449
  * Programmatically navigate to a path (client runtime; no-op without a DOM).
1360
1450
  *
1361
1451
  * @param path - Target path (pathname, optionally with search/hash).
1452
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
1362
1453
  * @returns void
1363
1454
  * @example
1364
1455
  * app.spa.navigate("/about");
1365
1456
  */
1366
- navigate(path: string): void;
1457
+ navigate(path: string, options?: NavigateOptions): void;
1367
1458
  /**
1368
1459
  * Read the current resolved URL.
1369
1460
  *
@@ -3518,6 +3609,112 @@ declare const routerPlugin: import("@moku-labs/core").PluginInstance<"router", R
3518
3609
  */
3519
3610
  declare const sitePlugin: import("@moku-labs/core").PluginInstance<"site", Config$7, Record<string, never>, Api$7, {}> & Record<never, never>;
3520
3611
  //#endregion
3612
+ //#region src/plugins/spa/channel.d.ts
3613
+ /**
3614
+ * @file spa plugin — `createChannel`, a client realtime WebSocket primitive.
3615
+ *
3616
+ * A live, self-healing message channel for islands that consume a server push stream
3617
+ * (e.g. a Durable Object broadcasting board patches). It owns the parts every real-time
3618
+ * app otherwise hand-rolls: a single active socket bound to one channel `id`, refcounted
3619
+ * `subscribe` (first subscriber connects, last disconnects — so cross-island mount order
3620
+ * stops being load-bearing), exponential-backoff auto-reconnect, an optional keepalive
3621
+ * ping, a pre-seed buffer (frames that arrive before the island's snapshot loads are
3622
+ * queued and flushed on `seed`, so the connect→load race drops nothing), and an
3623
+ * optimistic `deliverLocal` echo.
3624
+ *
3625
+ * Browser-only (uses `WebSocket`); reached ONLY through an explicit `createChannel(...)`
3626
+ * call, so an app that never opens a channel never pulls this module into its graph.
3627
+ * @see {@link createChannel} for the usage example.
3628
+ */
3629
+ /** Keepalive configuration: a frame sent on an interval, and the reply to ignore. */
3630
+ interface ChannelKeepAlive {
3631
+ /** The keepalive frame to send (e.g. `"ping"`). */
3632
+ send: string;
3633
+ /** A reply frame to ignore rather than parse (e.g. `"pong"`). */
3634
+ ignore?: string;
3635
+ /** Interval between keepalive sends, in ms. Defaults to `30000`. */
3636
+ everyMs?: number;
3637
+ }
3638
+ /** Auto-reconnect backoff configuration (exponential, capped). */
3639
+ interface ChannelReconnect {
3640
+ /** First retry delay in ms (doubles each attempt). Defaults to `500`. */
3641
+ baseMs?: number;
3642
+ /** Maximum retry delay in ms (the backoff ceiling). Defaults to `8000`. */
3643
+ maxMs?: number;
3644
+ }
3645
+ /** Options for {@link createChannel}. */
3646
+ interface ChannelOptions<T> {
3647
+ /** Build the `ws(s)://` URL for a channel `id`. */
3648
+ url: (id: string) => string;
3649
+ /**
3650
+ * Parse a raw text frame into a message, or return `undefined` to drop it (malformed
3651
+ * frame / keepalive reply). Defaults to `JSON.parse` (dropping frames that fail to parse).
3652
+ */
3653
+ parse?: (raw: string) => T | undefined;
3654
+ /** Keepalive ping configuration (off when omitted). */
3655
+ keepAlive?: ChannelKeepAlive;
3656
+ /** Auto-reconnect backoff (defaults applied when omitted; pass `false` to disable). */
3657
+ reconnect?: ChannelReconnect | false;
3658
+ /**
3659
+ * Buffer frames that arrive before {@link Channel.seed} and flush them (in arrival
3660
+ * order) on seed, closing the connect→load race. Defaults to `false` (deliver live).
3661
+ */
3662
+ bufferUntilSeed?: boolean;
3663
+ }
3664
+ /** A live realtime channel (the surface returned by {@link createChannel}). */
3665
+ interface Channel<T> {
3666
+ /**
3667
+ * Subscribe to messages for channel `id`, connecting (or switching) the single
3668
+ * active socket to it. Refcounted: the first subscriber connects, the last to
3669
+ * unsubscribe disconnects. Returns an unsubscribe function.
3670
+ *
3671
+ * @param id - The channel id to subscribe to.
3672
+ * @param handler - Called with each delivered message.
3673
+ * @returns A function that removes this handler (and disconnects when it was the last).
3674
+ */
3675
+ subscribe(id: string, handler: (message: T) => void): () => void;
3676
+ /**
3677
+ * Mark the channel seeded and flush the pre-seed buffer (no-op unless
3678
+ * `bufferUntilSeed` and `id` is the active channel). Idempotent.
3679
+ *
3680
+ * @param id - The channel id whose buffer to flush (must be the active id).
3681
+ */
3682
+ seed(id: string): void;
3683
+ /**
3684
+ * Deliver a message to the local handlers immediately (the optimistic-update path —
3685
+ * the acting client reflects its own mutation without waiting for the server echo).
3686
+ *
3687
+ * @param id - The channel id (must be the active id; ignored otherwise).
3688
+ * @param message - The message to deliver locally now.
3689
+ */
3690
+ deliverLocal(id: string, message: T): void;
3691
+ /**
3692
+ * Send a raw text frame over the live socket (no-op when not open).
3693
+ *
3694
+ * @param raw - The frame to send.
3695
+ */
3696
+ send(raw: string): void;
3697
+ /**
3698
+ * The currently-subscribed channel id, or `undefined` when disconnected.
3699
+ *
3700
+ * @returns The active id, or undefined.
3701
+ */
3702
+ current(): string | undefined;
3703
+ /** Close the socket, cancel reconnect, and clear all handlers. */
3704
+ disconnect(): void;
3705
+ }
3706
+ /**
3707
+ * Create a live realtime channel — a single self-healing WebSocket bound to one channel
3708
+ * `id` at a time, with refcounted subscription, auto-reconnect, an optional keepalive, a
3709
+ * pre-seed buffer, and an optimistic local-echo path. See the file header for the model.
3710
+ *
3711
+ * @param options - The channel configuration (see {@link ChannelOptions}).
3712
+ * @returns The {@link Channel} control surface.
3713
+ * @example
3714
+ * const board = createChannel<BoardPatch>({ url: (id) => `wss://host/ws/board/${id}` });
3715
+ */
3716
+ declare function createChannel<T>(options: ChannelOptions<T>): Channel<T>;
3717
+ //#endregion
3521
3718
  //#region src/plugins/spa/islands.d.ts
3522
3719
  /**
3523
3720
  * Create a validated island definition. Accepts either the legacy hooks-only form
@@ -3747,4 +3944,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
3747
3944
  */
3748
3945
  declare const createPlugin: import("@moku-labs/core").BoundCreatePluginFunction<Config$8, Events, import("@moku-labs/core").CoreApisFromTuple<[import("@moku-labs/core").CorePluginInstance<"log", import("@moku-labs/common").LogConfig, import("@moku-labs/common").LogState, import("@moku-labs/common").LogApi>, import("@moku-labs/core").CorePluginInstance<"env", import("@moku-labs/common").EnvConfig, import("@moku-labs/common").EnvState, import("@moku-labs/common").EnvApi>]>>;
3749
3946
  //#endregion
3750
- export { types_d_exports as Build, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, type EmbedFacade, EmbedFacadeButton, type EmbedFacadeProps, type EmbedOptions, type Env, type GalleryComponent, type GalleryOptions, type GalleryProps, type GallerySlide, GalleryTrack, types_d_exports$5 as Head, type Log, types_d_exports$6 as Router, types_d_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
3947
+ export { types_d_exports as Build, type Channel, type ChannelKeepAlive, type ChannelOptions, type ChannelReconnect, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, type EmbedFacade, EmbedFacadeButton, type EmbedFacadeProps, type EmbedOptions, type Env, type GalleryComponent, type GalleryOptions, type GalleryProps, type GallerySlide, GalleryTrack, types_d_exports$5 as Head, type Log, types_d_exports$6 as Router, types_d_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createChannel, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };