akanjs 2.3.6-rc.1 → 2.3.6-rc.3

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.
Files changed (60) hide show
  1. package/client/capacitor.ts +2 -0
  2. package/client/csrTypes.ts +132 -9
  3. package/client/device.ts +13 -4
  4. package/client/frameConfig.ts +239 -0
  5. package/client/frameDebug.ts +28 -0
  6. package/client/index.ts +2 -0
  7. package/client/router.ts +9 -0
  8. package/fetch/requestStorage.ts +11 -0
  9. package/package.json +2 -2
  10. package/server/hmr/clientScript.ts +28 -0
  11. package/server/hmr/wsHub.ts +29 -0
  12. package/server/routeElementComposer.tsx +56 -0
  13. package/server/routeTreeBuilder.ts +8 -0
  14. package/server/routing/apiRouter.ts +4 -1
  15. package/server/rscClient.tsx +8 -0
  16. package/server/rscWorker.tsx +30 -5
  17. package/store/baseSt.ts +2 -2
  18. package/types/client/capacitor.d.ts +4 -0
  19. package/types/client/csrTypes.d.ts +125 -7
  20. package/types/client/frameConfig.d.ts +19 -0
  21. package/types/client/frameDebug.d.ts +3 -0
  22. package/types/client/index.d.ts +2 -0
  23. package/types/client/router.d.ts +2 -0
  24. package/types/fetch/requestStorage.d.ts +3 -0
  25. package/types/server/hmr/clientScript.d.ts +1 -1
  26. package/types/server/hmr/wsHub.d.ts +6 -0
  27. package/types/server/routeElementComposer.d.ts +8 -0
  28. package/types/server/rscClient.d.ts +3 -0
  29. package/types/store/baseSt.d.ts +2 -2
  30. package/types/ui/Layout/BottomInset.d.ts +7 -1
  31. package/types/ui/Layout/Navbar.d.ts +1 -1
  32. package/types/ui/Layout/TopInset.d.ts +7 -0
  33. package/types/ui/Layout/index.d.ts +3 -2
  34. package/types/ui/Link/types.d.ts +9 -1
  35. package/types/ui/Portal.d.ts +2 -1
  36. package/types/ui/ServerPortal.d.ts +15 -0
  37. package/types/ui/System/Client.d.ts +7 -5
  38. package/types/ui/System/SSR.d.ts +3 -2
  39. package/types/ui/System/frameCssVars.d.ts +5 -0
  40. package/types/webkit/useCsrValues.d.ts +6 -1
  41. package/types/webkit/useFrameRuntime.d.ts +111 -0
  42. package/ui/Layout/BottomInset.tsx +89 -20
  43. package/ui/Layout/BottomTab.tsx +11 -6
  44. package/ui/Layout/Navbar.tsx +38 -12
  45. package/ui/Layout/TopInset.tsx +45 -0
  46. package/ui/Layout/TopLeftAction.tsx +2 -2
  47. package/ui/Layout/index.ts +2 -0
  48. package/ui/Link/types.ts +9 -1
  49. package/ui/Portal.tsx +16 -2
  50. package/ui/ServerPortal.tsx +49 -0
  51. package/ui/System/CSR.tsx +236 -90
  52. package/ui/System/Client.tsx +90 -10
  53. package/ui/System/SSR.tsx +119 -43
  54. package/ui/System/frameCssVars.ts +25 -0
  55. package/ui/styles.css +56 -0
  56. package/webkit/bootCsr.tsx +44 -35
  57. package/webkit/useCsrValues.ts +636 -105
  58. package/webkit/useFrameRuntime.ts +539 -0
  59. package/types/ui/Layout/BottomAction.d.ts +0 -6
  60. package/ui/Layout/BottomAction.tsx +0 -15
@@ -15,6 +15,7 @@ export type CapacitorAppModule = {
15
15
  App: {
16
16
  addListener: (eventName: string, listenerFunc: (...args: unknown[]) => void) => Promise<unknown> | unknown;
17
17
  removeAllListeners: () => Promise<void> | void;
18
+ exitApp?: () => Promise<void> | void;
18
19
  getInfo: () => Promise<{ id: string; version: string; build: string; [key: string]: unknown }>;
19
20
  };
20
21
  };
@@ -86,6 +87,7 @@ export type CapacitorKeyboardModule = {
86
87
  Keyboard: {
87
88
  show: () => Promise<void> | void;
88
89
  hide: () => Promise<void> | void;
90
+ setResizeMode?: (options: { mode: "body" | "ionic" | "native" | "none" }) => Promise<void> | void;
89
91
  addListener: (eventName: string, listenerFunc: (info: CapacitorKeyboardInfo) => void) => Promise<unknown> | unknown;
90
92
  removeAllListeners: () => Promise<void> | void;
91
93
  };
@@ -7,15 +7,23 @@ import type { RouterInstance } from "./router";
7
7
  import type { ReactFont } from "./types";
8
8
 
9
9
  export type TransitionType = "none" | "fade" | "bottomUp" | "stack" | "scaleOut";
10
+ export type PageSafeAreaConfig =
11
+ | boolean
12
+ | "top"
13
+ | "bottom"
14
+ | {
15
+ top?: boolean;
16
+ bottom?: boolean;
17
+ android?: "auto" | "edge-to-edge" | "none";
18
+ };
10
19
  /** Per-page CSR configuration for transition, safe-area, and gesture behavior. */
11
20
  export interface PageConfig {
12
21
  transition?: TransitionType;
13
- safeArea?: boolean | "top" | "bottom";
14
- topInset?: boolean | number;
15
- /**
16
- * @default 48px
17
- */
18
- bottomInset?: boolean | number;
22
+ safeArea?: PageSafeAreaConfig;
23
+ /** Top chrome reservation in px. Use 0 or omit for no reservation. */
24
+ topInset?: number;
25
+ /** Bottom chrome reservation in px. Use 0 or omit for no reservation. */
26
+ bottomInset?: number;
19
27
  gesture?: boolean;
20
28
  cache?: boolean;
21
29
  /**
@@ -97,6 +105,7 @@ export interface RouteRender {
97
105
  resolveError?: () => PromiseOrObject<LayoutErrorRender | undefined>;
98
106
  resolveHead?: ResolveHead;
99
107
  getPageConfig?: () => PromiseOrObject<PageConfig | undefined>;
108
+ getLayoutPageConfig?: () => PromiseOrObject<PageConfig | undefined>;
100
109
  }
101
110
  export interface WebAppManifestIcon {
102
111
  src: string;
@@ -158,6 +167,7 @@ export interface PageModule {
158
167
  }
159
168
  export interface LayoutModule {
160
169
  default?: LayoutRender;
170
+ pageConfig?: PageConfig;
161
171
  head?: Head;
162
172
  metadata?: AkanMetadata;
163
173
  generateHead?: GenerateHead;
@@ -176,6 +186,8 @@ export interface LayoutModule {
176
186
  export type RouteModule = PageModule | LayoutModule;
177
187
  export interface Route {
178
188
  PageConfig?: PageConfig;
189
+ pageConfig?: PageConfig;
190
+ layoutPageConfig?: PageConfig;
179
191
  path: string;
180
192
  renderPage?: RouteRender;
181
193
  renderLayout?: RouteRender;
@@ -184,6 +196,8 @@ export interface Route {
184
196
 
185
197
  loader?: () => unknown;
186
198
  pageState?: PageState;
199
+ pageConfigChain?: PageConfig[];
200
+ explicitPageConfigKeys?: Partial<Record<keyof PageConfig, boolean>>;
187
201
 
188
202
  children: Map<string, Route>;
189
203
  }
@@ -226,8 +240,8 @@ export const defaultPageState: PageState = {
226
240
  bottomInset: 0,
227
241
  gesture: true,
228
242
  cache: false,
229
- topSafeAreaColor: "transparent",
230
- bottomSafeAreaColor: "transparent",
243
+ topSafeAreaColor: "var(--color-base-100, Canvas)",
244
+ bottomSafeAreaColor: "var(--color-base-100, Canvas)",
231
245
  };
232
246
 
233
247
  export interface Location {
@@ -239,9 +253,23 @@ export interface Location {
239
253
  pathRoute: PathRoute;
240
254
  hash: string;
241
255
  }
256
+ export type CsrNavigationPhase = "idle" | "preparing" | "transitioning";
257
+ export type CsrNavigationKind = "push" | "replace" | "back" | "popForward" | "popBack";
258
+ export interface NavigationIntent {
259
+ id: number;
260
+ kind: CsrNavigationKind;
261
+ from: Location;
262
+ to: Location;
263
+ scrollTop: number;
264
+ scrollToTop?: boolean;
265
+ createdAt: number;
266
+ }
242
267
  export interface LocationState {
243
268
  location: Location;
244
269
  prevLocation: Location | null;
270
+ pendingLocation?: Location | null;
271
+ navigationIntent?: NavigationIntent | null;
272
+ phase: CsrNavigationPhase;
245
273
  }
246
274
  export interface History {
247
275
  type: "initial" | "forward" | "back";
@@ -264,6 +292,9 @@ export interface RouteState {
264
292
  clientHeight: number;
265
293
  location: Location;
266
294
  prevLocation: Location | null;
295
+ pendingLocation: Location | null;
296
+ navigationIntent: NavigationIntent | null;
297
+ phase: CsrNavigationPhase;
267
298
  history: RefObject<History>;
268
299
  topSafeAreaRef: RefObject<HTMLDivElement | null>;
269
300
  bottomSafeAreaRef: RefObject<HTMLDivElement | null>;
@@ -273,6 +304,8 @@ export interface RouteState {
273
304
  onBack: RefObject<{ [K in TransitionType]?: () => Promise<void> }>;
274
305
  router: RouterInstance;
275
306
  pathRoutes: PathRoute[];
307
+ registerFrameSlot: (path: string, slot: FrameSlotRegistration, bucket?: FrameSlotBucket) => () => void;
308
+ frameLayout: FrameLayoutState;
276
309
  }
277
310
 
278
311
  export type UseCsrTransition = CsrTransitionStyles & {
@@ -293,11 +326,12 @@ export const useCsr = () => {
293
326
  };
294
327
 
295
328
  export interface PathContextType {
296
- pageType: "current" | "prev" | "cached";
329
+ pageType: "current" | "prev" | "cached" | "pending";
297
330
  location: Location;
298
331
  prefix?: string;
299
332
  gestureEnabled: boolean;
300
333
  setGestureEnabled: (enabled: boolean) => void;
334
+ registerFrameSlot: (slot: FrameSlotRegistration) => () => void;
301
335
  }
302
336
  export const pathContext = createContext<PathContextType>({} as unknown as PathContextType);
303
337
  export const usePathCtx = () => {
@@ -310,12 +344,101 @@ export interface PathRoute {
310
344
  pathSegments: string[];
311
345
  renderPage: RouteRender;
312
346
  pageState: PageState;
347
+ pageConfigChain?: PageConfig[];
348
+ explicitPageConfigKeys?: Partial<Record<keyof PageConfig, boolean>>;
313
349
  renderRootLayouts: RouteRender[];
314
350
  renderLayouts: RouteRender[];
315
351
  resolveHead?: ResolveHead;
316
352
  isSpecialRoute?: boolean;
317
353
  }
318
354
 
355
+ export type FrameSlotScope = "page" | "layout";
356
+ export type FrameSlotType = "topInset" | "bottomInset";
357
+ export type FrameSlotBucket = "active" | "pending";
358
+ export type FrameLayer = "page" | "topChrome" | "bottomChrome" | "keyboard" | "overlay";
359
+ export type FrameSlotRole = "topChrome" | "bottomChrome" | "keyboardAccessory";
360
+ export type FramePlatformProfile = "ios" | "android" | "web" | "mobileWeb";
361
+ export interface FrameViewportState {
362
+ width: number;
363
+ height: number;
364
+ visualWidth: number;
365
+ visualHeight: number;
366
+ visualOffsetTop: number;
367
+ }
368
+ export interface KeyboardFrameState {
369
+ height: number;
370
+ offset: number;
371
+ visible: boolean;
372
+ sticky: boolean;
373
+ frozen?: boolean;
374
+ source?: "native" | "visualViewport" | "fallback";
375
+ animationDuration?: number;
376
+ animationEasing?: string;
377
+ }
378
+ export interface FrameContentViewportState {
379
+ top: number;
380
+ bottom: number;
381
+ height: number;
382
+ }
383
+ export interface KeyboardAccessoryFrameState {
384
+ top: number;
385
+ bottom: number;
386
+ height: number;
387
+ visible: boolean;
388
+ slotHeight: number;
389
+ }
390
+ export interface FrameSnapshot {
391
+ location: Location;
392
+ pageState: PageState;
393
+ viewport: FrameViewportState;
394
+ frameSlots: FrameSlotRegistration[];
395
+ measuredAt: number;
396
+ }
397
+ export interface TransitionActionContext {
398
+ plan: TransitionPlan;
399
+ }
400
+ export interface TransitionAction {
401
+ type: "page" | "topChrome" | "bottomChrome" | "keyboard" | "safeArea" | (string & {});
402
+ run: (ctx: TransitionActionContext) => Promise<void> | void;
403
+ }
404
+ export interface TransitionPlan {
405
+ id: number;
406
+ intent: NavigationIntent;
407
+ type: TransitionType;
408
+ direction: "forward" | "back";
409
+ fromFrame: FrameSnapshot;
410
+ toFrame: FrameSnapshot;
411
+ actions: TransitionAction[];
412
+ duration: number;
413
+ }
414
+ export interface FrameLayerZIndex {
415
+ page: number;
416
+ previousPage: number;
417
+ cachedPage: number;
418
+ topChrome: number;
419
+ bottomChrome: number;
420
+ keyboard: number;
421
+ overlay: number;
422
+ }
423
+ export interface FrameLayoutState {
424
+ viewport: FrameViewportState;
425
+ keyboard: KeyboardFrameState;
426
+ contentViewport: FrameContentViewportState;
427
+ keyboardAccessory: KeyboardAccessoryFrameState;
428
+ platformProfile: FramePlatformProfile;
429
+ zIndex: FrameLayerZIndex;
430
+ pageStateByPath: Map<string, PageState>;
431
+ }
432
+ export interface FrameSlotRegistration {
433
+ scope?: FrameSlotScope;
434
+ type: FrameSlotType;
435
+ role?: FrameSlotRole;
436
+ height?: number;
437
+ estimatedHeight?: number;
438
+ source?: "navbar" | "topInset" | "bottomInset" | "bottomTab" | (string & {});
439
+ cache?: boolean;
440
+ }
441
+
319
442
  export interface LayoutFallbackRoute {
320
443
  path: string;
321
444
  pathSegments: string[];
package/client/device.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import type { RefObject } from "react";
3
+ import { debugFrame } from "./frameDebug";
3
4
  import type {
4
5
  CapacitorDeviceInfo,
5
6
  CapacitorHapticsModule,
@@ -114,6 +115,7 @@ export class Device {
114
115
  insets: { top: topSafeArea, bottom: bottomSafeArea },
115
116
  },
116
117
  ] = await Promise.all([CapacitorDevice.getInfo(), CapacitorDevice.getLanguageCode(), SafeArea.getSafeAreaInsets()]);
118
+ if (info.platform === "ios") await Keyboard.setResizeMode?.({ mode: "none" });
117
119
  const predefinedLangPath = window.location.pathname.split("/")[1]?.split("?")[0];
118
120
  const predefinedLang = supportLanguages.find((language) => language === predefinedLangPath);
119
121
  const device = new Device({
@@ -165,17 +167,24 @@ export class Device {
165
167
  }
166
168
  listenKeyboardChanged(onKeyboardChanged: (height: number) => void) {
167
169
  if (this.info.platform === "web") return;
170
+ let currentHeight = 0;
171
+ const emitKeyboardHeight = (event: string, height: number) => {
172
+ debugFrame("keyboard.event", { event, height, previousHeight: currentHeight });
173
+ if (currentHeight === height) return;
174
+ currentHeight = height;
175
+ onKeyboardChanged(height);
176
+ };
168
177
  void this.#keyboard.addListener("keyboardWillShow", (keyboard: CapacitorKeyboardInfo) => {
169
- onKeyboardChanged(keyboard.keyboardHeight);
178
+ emitKeyboardHeight("keyboardWillShow", keyboard.keyboardHeight);
170
179
  });
171
180
  void this.#keyboard.addListener("keyboardDidShow", (keyboard: CapacitorKeyboardInfo) => {
172
- onKeyboardChanged(keyboard.keyboardHeight);
181
+ emitKeyboardHeight("keyboardDidShow", keyboard.keyboardHeight);
173
182
  });
174
183
  void this.#keyboard.addListener("keyboardWillHide", () => {
175
- onKeyboardChanged(0);
184
+ emitKeyboardHeight("keyboardWillHide", 0);
176
185
  });
177
186
  void this.#keyboard.addListener("keyboardDidHide", () => {
178
- onKeyboardChanged(0);
187
+ emitKeyboardHeight("keyboardDidHide", 0);
179
188
  });
180
189
  }
181
190
  unlistenKeyboardChanged() {
@@ -0,0 +1,239 @@
1
+ import type { PageConfig, PageSafeAreaConfig, PageState, TransitionType } from "./csrTypes";
2
+
3
+ export type DevicePlatform = "ios" | "android" | "web" | (string & {});
4
+ export type SafeAreaInsets = { top: number; bottom: number };
5
+
6
+ export interface ResolvePageStateOptions {
7
+ configChain?: PageConfig[];
8
+ path: string;
9
+ basePath?: string;
10
+ platform: DevicePlatform;
11
+ deviceSafeArea: SafeAreaInsets;
12
+ cssSafeArea?: SafeAreaInsets;
13
+ }
14
+
15
+ const pageConfigKeys = new Set<keyof PageConfig>([
16
+ "transition",
17
+ "safeArea",
18
+ "topInset",
19
+ "bottomInset",
20
+ "gesture",
21
+ "cache",
22
+ "rscPatchHeadSafe",
23
+ "topSafeAreaColor",
24
+ "bottomSafeAreaColor",
25
+ ]);
26
+ const transitionTypes = new Set<TransitionType>(["none", "fade", "bottomUp", "stack", "scaleOut"]);
27
+
28
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
29
+ typeof value === "object" && value !== null && !Array.isArray(value);
30
+
31
+ const hasOwn = <Key extends PropertyKey>(value: object, key: Key) => Object.hasOwn(value, key);
32
+
33
+ export function validatePageConfig(routeKey: string, config?: PageConfig) {
34
+ if (config === undefined) return;
35
+ if (!isRecord(config)) throw new Error(`[route-convention] pageConfig in ${routeKey} must be an object.`);
36
+ const pageConfig = config as PageConfig;
37
+ for (const key of Object.keys(pageConfig)) {
38
+ if (!pageConfigKeys.has(key as keyof PageConfig)) {
39
+ throw new Error(`[route-convention] unsupported pageConfig option "${key}" in ${routeKey}`);
40
+ }
41
+ }
42
+ if (pageConfig.transition !== undefined && !transitionTypes.has(pageConfig.transition)) {
43
+ throw new Error(`[route-convention] unsupported pageConfig.transition "${pageConfig.transition}" in ${routeKey}`);
44
+ }
45
+ if (pageConfig.topInset !== undefined && !isValidInsetValue(pageConfig.topInset)) {
46
+ throw new Error(`[route-convention] pageConfig.topInset in ${routeKey} must be a non-negative px number.`);
47
+ }
48
+ if (pageConfig.bottomInset !== undefined && !isValidInsetValue(pageConfig.bottomInset)) {
49
+ throw new Error(`[route-convention] pageConfig.bottomInset in ${routeKey} must be a non-negative px number.`);
50
+ }
51
+ validateSafeAreaConfig(routeKey, pageConfig.safeArea);
52
+ }
53
+
54
+ function isValidInsetValue(value: unknown): value is number {
55
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
56
+ }
57
+
58
+ function validateSafeAreaConfig(routeKey: string, safeArea?: PageSafeAreaConfig) {
59
+ if (
60
+ safeArea === undefined ||
61
+ typeof safeArea === "boolean" ||
62
+ safeArea === "top" ||
63
+ safeArea === "bottom"
64
+ )
65
+ return;
66
+ if (!isRecord(safeArea)) throw new Error(`[route-convention] pageConfig.safeArea in ${routeKey} is invalid.`);
67
+ for (const key of Object.keys(safeArea)) {
68
+ if (key !== "top" && key !== "bottom" && key !== "android") {
69
+ throw new Error(`[route-convention] unsupported pageConfig.safeArea option "${key}" in ${routeKey}`);
70
+ }
71
+ }
72
+ if (safeArea.top !== undefined && typeof safeArea.top !== "boolean") {
73
+ throw new Error(`[route-convention] pageConfig.safeArea.top in ${routeKey} must be a boolean.`);
74
+ }
75
+ if (safeArea.bottom !== undefined && typeof safeArea.bottom !== "boolean") {
76
+ throw new Error(`[route-convention] pageConfig.safeArea.bottom in ${routeKey} must be a boolean.`);
77
+ }
78
+ if (
79
+ safeArea.android !== undefined &&
80
+ safeArea.android !== "auto" &&
81
+ safeArea.android !== "edge-to-edge" &&
82
+ safeArea.android !== "none"
83
+ ) {
84
+ throw new Error(`[route-convention] pageConfig.safeArea.android in ${routeKey} is invalid.`);
85
+ }
86
+ }
87
+
88
+ export function mergePageConfigs(configChain: PageConfig[] = []): PageConfig {
89
+ const merged: PageConfig = {};
90
+ for (const config of configChain) {
91
+ for (const key of pageConfigKeys) {
92
+ const value = config[key];
93
+ if (value === undefined) continue;
94
+ if (key === "safeArea" && isRecord(merged.safeArea) && isRecord(value)) {
95
+ merged.safeArea = { ...merged.safeArea, ...value };
96
+ } else {
97
+ (merged as Record<keyof PageConfig, unknown>)[key] = value;
98
+ }
99
+ }
100
+ }
101
+ return merged;
102
+ }
103
+
104
+ export function getExplicitPageConfigKeys(configChain: PageConfig[] = []): Partial<Record<keyof PageConfig, boolean>> {
105
+ const explicitKeys: Partial<Record<keyof PageConfig, boolean>> = {};
106
+ for (const config of configChain) {
107
+ for (const key of pageConfigKeys) {
108
+ if (hasOwn(config, key)) explicitKeys[key] = true;
109
+ }
110
+ }
111
+ return explicitKeys;
112
+ }
113
+
114
+ export function resolvePageState({
115
+ configChain = [],
116
+ path,
117
+ basePath,
118
+ platform,
119
+ deviceSafeArea,
120
+ cssSafeArea,
121
+ }: ResolvePageStateOptions): PageState {
122
+ const profile = getPlatformFrameProfile(platform);
123
+ const routeRole = getRouteRoleFrameDefaults(path, platform, basePath);
124
+ const config = mergePageConfigs([profile, routeRole, ...configChain]);
125
+ const explicitKeys = getExplicitPageConfigKeys(configChain);
126
+ const safeArea = resolveSafeArea({
127
+ safeArea: config.safeArea,
128
+ platform,
129
+ deviceSafeArea,
130
+ cssSafeArea,
131
+ });
132
+ const transition = config.transition ?? "none";
133
+ return {
134
+ transition,
135
+ topSafeArea: safeArea.top,
136
+ bottomSafeArea: safeArea.bottom,
137
+ topInset: config.topInset ?? 0,
138
+ bottomInset: config.bottomInset ?? 0,
139
+ gesture: explicitKeys.gesture ? (config.gesture ?? false) : transition === "none" ? false : (config.gesture ?? false),
140
+ cache: config.cache ?? false,
141
+ topSafeAreaColor: config.topSafeAreaColor ?? "var(--color-base-100, Canvas)",
142
+ bottomSafeAreaColor: config.bottomSafeAreaColor ?? "var(--color-base-100, Canvas)",
143
+ };
144
+ }
145
+
146
+ function getPlatformFrameProfile(platform: DevicePlatform): PageConfig {
147
+ if (platform === "ios") return { safeArea: true, transition: "stack" };
148
+ if (platform === "android") return { safeArea: { android: "auto" }, transition: "scaleOut", gesture: false };
149
+ return { safeArea: false, transition: "none", gesture: false };
150
+ }
151
+
152
+ function getRouteRoleFrameDefaults(path: string, platform: DevicePlatform, basePath?: string): PageConfig {
153
+ const normalizedBasePath = basePath?.replace(/^\/+|\/+$/g, "");
154
+ const depth = path
155
+ .split("/")
156
+ .filter(Boolean)
157
+ .filter((segment) => segment !== "[lang]" && segment !== ":lang")
158
+ .filter((segment) => (normalizedBasePath ? segment !== normalizedBasePath : true)).length;
159
+ if (depth <= 1) return { transition: "none", gesture: false, cache: true };
160
+ return {
161
+ transition: platform === "ios" ? "stack" : platform === "android" ? "scaleOut" : "none",
162
+ gesture: platform === "ios",
163
+ };
164
+ }
165
+
166
+ function resolveSafeArea({
167
+ safeArea,
168
+ platform,
169
+ deviceSafeArea,
170
+ cssSafeArea,
171
+ }: {
172
+ safeArea?: PageSafeAreaConfig;
173
+ platform: DevicePlatform;
174
+ deviceSafeArea: SafeAreaInsets;
175
+ cssSafeArea?: SafeAreaInsets;
176
+ }): SafeAreaInsets {
177
+ if (safeArea === false) return { top: 0, bottom: 0 };
178
+ const topEnabled =
179
+ safeArea === true ||
180
+ safeArea === "top" ||
181
+ (isRecord(safeArea) ? safeArea.top !== false : safeArea === undefined && platform !== "web");
182
+ const bottomEnabled =
183
+ safeArea === true ||
184
+ safeArea === "bottom" ||
185
+ (isRecord(safeArea) ? safeArea.bottom !== false : safeArea === undefined && platform !== "web");
186
+ if (platform === "android") {
187
+ const androidMode = isRecord(safeArea) ? (safeArea.android as "auto" | "edge-to-edge" | "none" | undefined) : "auto";
188
+ if (androidMode === "none") return { top: 0, bottom: 0 };
189
+ const source =
190
+ androidMode === "edge-to-edge"
191
+ ? maxInsets(deviceSafeArea, cssSafeArea)
192
+ : cssSafeArea && hasInset(cssSafeArea)
193
+ ? cssSafeArea
194
+ : null;
195
+ return {
196
+ top: topEnabled && source ? source.top : 0,
197
+ bottom: bottomEnabled && source ? source.bottom : 0,
198
+ };
199
+ }
200
+ return {
201
+ top: topEnabled ? deviceSafeArea.top : 0,
202
+ bottom: bottomEnabled ? deviceSafeArea.bottom : 0,
203
+ };
204
+ }
205
+
206
+ const hasInset = (insets: SafeAreaInsets) => insets.top > 0 || insets.bottom > 0;
207
+
208
+ const maxInsets = (a: SafeAreaInsets, b?: SafeAreaInsets): SafeAreaInsets => ({
209
+ top: Math.max(a.top, b?.top ?? 0),
210
+ bottom: Math.max(a.bottom, b?.bottom ?? 0),
211
+ });
212
+
213
+ export function readCssSafeAreaInsets(): SafeAreaInsets {
214
+ if (typeof window === "undefined") return { top: 0, bottom: 0 };
215
+ const style = window.getComputedStyle?.(document.documentElement);
216
+ return {
217
+ top: readCssPixel(style?.getPropertyValue("--safe-area-inset-top")) || readCssEnvProbe("top"),
218
+ bottom: readCssPixel(style?.getPropertyValue("--safe-area-inset-bottom")) || readCssEnvProbe("bottom"),
219
+ };
220
+ }
221
+
222
+ function readCssEnvProbe(side: "top" | "bottom") {
223
+ if (typeof document === "undefined") return 0;
224
+ const probe = document.createElement("div");
225
+ probe.style.position = "fixed";
226
+ probe.style.visibility = "hidden";
227
+ probe.style.pointerEvents = "none";
228
+ probe.style[side] = `env(safe-area-inset-${side})`;
229
+ document.documentElement.appendChild(probe);
230
+ const value = readCssPixel(window.getComputedStyle(probe)[side]);
231
+ probe.remove();
232
+ return value;
233
+ }
234
+
235
+ function readCssPixel(value?: string) {
236
+ if (!value) return 0;
237
+ const parsed = Number.parseFloat(value);
238
+ return Number.isFinite(parsed) ? parsed : 0;
239
+ }
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ type DebugPayload = Record<string, unknown>;
4
+
5
+ const debugSessionId = Math.random().toString(36).slice(2, 8);
6
+ let debugSeq = 0;
7
+
8
+ const isFrameDebugEnabled = () => {
9
+ if (typeof window === "undefined") return false;
10
+ const windowWithTarget = window as typeof window & { __AKAN_MOBILE_TARGET__?: unknown };
11
+ const search = new URLSearchParams(window.location.search);
12
+ return (
13
+ Boolean(windowWithTarget.__AKAN_MOBILE_TARGET__) ||
14
+ search.has("akanMobileTarget") ||
15
+ search.get("akanFrameDebug") === "1" ||
16
+ window.localStorage.getItem("akan:debug:frame") === "1"
17
+ );
18
+ };
19
+
20
+ export function debugFrame(event: string, payload: DebugPayload = {}) {
21
+ if (!isFrameDebugEnabled()) return;
22
+ debugSeq += 1;
23
+ console.info(`[akan:frame:${debugSessionId}:${debugSeq}] ${event}`, {
24
+ href: window.location.href,
25
+ now: Math.round(performance.now()),
26
+ ...payload,
27
+ });
28
+ }
package/client/index.ts CHANGED
@@ -4,6 +4,8 @@ export * from "./createFont";
4
4
  export * from "./csrTypes";
5
5
  export * from "./decorators";
6
6
  export * from "./device";
7
+ export * from "./frameConfig";
8
+ export * from "./frameDebug";
7
9
  export * from "./locale";
8
10
  export * from "./makePageProto";
9
11
  export * from "./router";
package/client/router.ts CHANGED
@@ -91,6 +91,8 @@ const getServerBasePath = (reqPathname: string, lang: string, headerBasePath: st
91
91
 
92
92
  declare global {
93
93
  var __AKAN_ROUTER__: Router | undefined;
94
+ var __AKAN_DEV_SYNC_NAVIGATION__: ((href: string, kind: "push" | "replace" | "back" | "pop") => void) | undefined;
95
+ var __AKAN_DEV_SYNC_NAVIGATION_APPLYING__: boolean | undefined;
94
96
  }
95
97
 
96
98
  export const getPathInfo = (requestUrl: string, lang: string, prefix: string) => {
@@ -232,14 +234,21 @@ class Router {
232
234
  Logger.log(`pathChange-start:${path}${hash ? `#${hash}` : ""}`);
233
235
  window.parent.postMessage({ type: "pathChange", path, pathname, hash }, "*");
234
236
  }
237
+ #postDevSyncNavigation(kind: "push" | "replace", href: string) {
238
+ if (globalThis.__AKAN_DEV_SYNC_NAVIGATION_APPLYING__) return;
239
+ const { path, search, hash } = this.#getPathInfo(href);
240
+ globalThis.__AKAN_DEV_SYNC_NAVIGATION__?.(`${path}${search ? `?${search}` : ""}${hash ? `#${hash}` : ""}`, kind);
241
+ }
235
242
  push(href: string, routeOptions?: RouteOptions) {
236
243
  this.#checkInitialized();
237
244
  this.#instance.push(href, routeOptions);
245
+ this.#postDevSyncNavigation("push", href);
238
246
  return undefined as never;
239
247
  }
240
248
  replace(href: string, routeOptions?: RouteOptions) {
241
249
  this.#checkInitialized();
242
250
  this.#instance.replace(href, routeOptions);
251
+ this.#postDevSyncNavigation("replace", href);
243
252
  return undefined as never;
244
253
  }
245
254
  back(routeOptions?: RouteOptions) {
@@ -15,6 +15,7 @@ export interface AkanDynamicUsage {
15
15
  export interface AkanRequestStore {
16
16
  request: Request;
17
17
  theme?: AkanTheme;
18
+ frameState?: unknown;
18
19
  queryCache: Map<string, Promise<unknown>>;
19
20
  policy: AkanRequestPolicy;
20
21
  dynamicUsage: AkanDynamicUsage;
@@ -91,6 +92,16 @@ export function getRequestTheme(): AkanTheme | undefined {
91
92
  return getRequestStore()?.theme;
92
93
  }
93
94
 
95
+ export function setRequestFrameState(frameState: unknown): void {
96
+ const store = getRequestStore();
97
+ if (!store) return;
98
+ store.frameState = frameState;
99
+ }
100
+
101
+ export function getRequestFrameState<FrameState = unknown>(): FrameState | undefined {
102
+ return getRequestStore()?.frameState as FrameState | undefined;
103
+ }
104
+
94
105
  export function pushRequestFallback(storeOrRequest: Request | AkanRequestStore): () => void {
95
106
  globalThis.__AKAN_REQUEST_FALLBACK_STACK__ ??= [];
96
107
  const stack = globalThis.__AKAN_REQUEST_FALLBACK_STACK__;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.3.6-rc.1",
3
+ "version": "2.3.6-rc.3",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -197,7 +197,7 @@
197
197
  "chance": "^1.1.13",
198
198
  "cordova-plugin-purchase": "^13.16.0",
199
199
  "croner": "^10.0.1",
200
- "daisyui": "^5.5.20",
200
+ "daisyui": "5.5.23",
201
201
  "ioredis": "^5.10.1",
202
202
  "mermaid": "^11.15.0",
203
203
  "postgres": "^3.4.9",