akanjs 3.0.0-alpha.5 → 3.0.0-alpha.6

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 (58) hide show
  1. package/client/cn.ts +7 -0
  2. package/client/csrTypes.ts +2 -0
  3. package/client/frameDebug.ts +3 -2
  4. package/package.json +1 -1
  5. package/server/webRouter.ts +96 -0
  6. package/types/client/cn.d.ts +5 -0
  7. package/types/client/csrTypes.d.ts +2 -0
  8. package/types/ui/Dropdown.d.ts +2 -0
  9. package/types/ui/Layout/BottomInset.d.ts +2 -1
  10. package/types/ui/Layout/index.d.ts +1 -1
  11. package/types/ui/Signal/style.d.ts +1 -1
  12. package/types/ui/UiOverride/context.d.ts +12 -2
  13. package/types/ui/index.d.ts +2 -1
  14. package/types/ui/overlayLayer.d.ts +24 -0
  15. package/types/ui/recipe/badgeRecipe.d.ts +7 -3
  16. package/types/ui/recipe/buttonRecipe.d.ts +7 -3
  17. package/types/ui/recipe/inputRecipe.d.ts +5 -1
  18. package/types/webkit/index.d.ts +1 -0
  19. package/types/webkit/lazy.d.ts +12 -0
  20. package/types/webkit/useCsrValues.d.ts +3 -3
  21. package/types/webkit/useEscapeKey.d.ts +5 -0
  22. package/types/webkit/useFrameRuntime.d.ts +6 -1
  23. package/ui/Badge.tsx +2 -2
  24. package/ui/BottomSheet.tsx +5 -0
  25. package/ui/Button.tsx +3 -1
  26. package/ui/Constant/Doc.tsx +1 -1
  27. package/ui/Data/ListContainer.tsx +1 -1
  28. package/ui/DatePicker.tsx +4 -3
  29. package/ui/Dialog/Modal.tsx +12 -15
  30. package/ui/DraggableList.tsx +3 -1
  31. package/ui/Dropdown.tsx +33 -11
  32. package/ui/Field.tsx +14 -8
  33. package/ui/Input.tsx +7 -5
  34. package/ui/Layout/BottomInset.tsx +6 -1
  35. package/ui/Loading/ProgressBar.tsx +8 -1
  36. package/ui/Menu.tsx +7 -8
  37. package/ui/Model/EditModal.tsx +37 -2
  38. package/ui/Model/SureToRemove.tsx +2 -1
  39. package/ui/Model/index_.tsx +42 -15
  40. package/ui/ObjectId.tsx +3 -4
  41. package/ui/Pagination.tsx +3 -4
  42. package/ui/Popconfirm.tsx +8 -7
  43. package/ui/Select.tsx +4 -3
  44. package/ui/System/CSR.tsx +6 -2
  45. package/ui/ToggleSelect.tsx +4 -3
  46. package/ui/Tooltip.tsx +2 -1
  47. package/ui/UiOverride/context.ts +12 -2
  48. package/ui/index.ts +8 -1
  49. package/ui/overlayLayer.ts +39 -0
  50. package/ui/recipe/badgeRecipe.ts +22 -3
  51. package/ui/recipe/buttonRecipe.ts +51 -2
  52. package/ui/recipe/factory.ts +2 -2
  53. package/ui/recipe/inputRecipe.ts +18 -4
  54. package/webkit/index.ts +1 -0
  55. package/webkit/lazy.tsx +22 -2
  56. package/webkit/useCsrValues.ts +121 -4
  57. package/webkit/useEscapeKey.tsx +42 -0
  58. package/webkit/useFrameRuntime.ts +65 -32
package/client/cn.ts CHANGED
@@ -38,10 +38,17 @@ export const colorTokens = [
38
38
  "ring",
39
39
  ];
40
40
 
41
+ /**
42
+ * Akan's semantic radius tokens (`--radius-box` / `--radius-field` in ui/styles.css). Without them
43
+ * `cn("rounded-field", "rounded-full")` keeps both classes and stylesheet order decides the winner.
44
+ */
45
+ export const radiusTokens = ["box", "field"];
46
+
41
47
  const twMerge = extendTailwindMerge({
42
48
  extend: {
43
49
  theme: {
44
50
  color: colorTokens,
51
+ radius: radiusTokens,
45
52
  },
46
53
  },
47
54
  });
@@ -455,6 +455,7 @@ export interface FrameLayoutState {
455
455
  keyboard: KeyboardFrameState;
456
456
  contentViewport: FrameContentViewportState;
457
457
  keyboardAccessory: KeyboardAccessoryFrameState;
458
+ contentAnchor?: "bottom";
458
459
  platformProfile: FramePlatformProfile;
459
460
  zIndex: FrameLayerZIndex;
460
461
  pageStateByPath: Map<string, PageState>;
@@ -463,6 +464,7 @@ export interface FrameSlotRegistration {
463
464
  scope?: FrameSlotScope;
464
465
  type: FrameSlotType;
465
466
  role?: FrameSlotRole;
467
+ contentAnchor?: "bottom";
466
468
  height?: number;
467
469
  estimatedHeight?: number;
468
470
  source?: "navbar" | "topInset" | "bottomInset" | "bottomTab" | (string & {});
@@ -20,9 +20,10 @@ const isFrameDebugEnabled = () => {
20
20
  export function debugFrame(event: string, payload: DebugPayload = {}) {
21
21
  if (!isFrameDebugEnabled()) return;
22
22
  debugSeq += 1;
23
- console.info(`[akan:frame:${debugSessionId}:${debugSeq}] ${event}`, {
23
+ const details = {
24
24
  href: window.location.href,
25
25
  now: Math.round(performance.now()),
26
26
  ...payload,
27
- });
27
+ };
28
+ console.info(`[akan:frame:${debugSessionId}:${debugSeq}] ${event}`, details, JSON.stringify(details));
28
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.5",
3
+ "version": "3.0.0-alpha.6",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
3
4
  import { getEnv } from "akanjs/base";
4
5
  import {
5
6
  type AkanI18nConfig,
@@ -61,6 +62,18 @@ export const DEFAULT_HTML_RESULT_CACHE_MAX_BODY_BYTES = 2 * 1024 * 1024;
61
62
  const ROUTE_CACHE_SWEEP_INTERVAL_MS = 60_000;
62
63
  const APPLE_APP_SITE_ASSOCIATION_PATH = "/.well-known/apple-app-site-association";
63
64
  const ANDROID_ASSET_LINKS_PATH = "/.well-known/assetlinks.json";
65
+ const FIREBASE_MESSAGING_SW_PATH = "/firebase-messaging-sw.js";
66
+ const FIREBASE_WEB_SDK_VERSION = "12.13.0";
67
+
68
+ interface FirebaseClientEnvConfig {
69
+ apiKey: string;
70
+ authDomain?: string;
71
+ projectId: string;
72
+ storageBucket?: string;
73
+ messagingSenderId: string;
74
+ appId: string;
75
+ vapidKey?: string;
76
+ }
64
77
 
65
78
  export function createRscRedirectResponse(
66
79
  location: string,
@@ -488,6 +501,16 @@ export class WebRouter {
488
501
  WebRouter.#deepLinkAssociationResponse(ANDROID_ASSET_LINKS_PATH, this.#artifact, {
489
502
  cacheControl: this.#prodMode ? "public, max-age=3600" : "no-store",
490
503
  }) ?? new Response("Not Found", { status: 404 }),
504
+ [FIREBASE_MESSAGING_SW_PATH]: async () => {
505
+ this.#requestStats.staticAsset += 1;
506
+ const firebaseConfig = await WebRouter.#resolveFirebaseClientConfig();
507
+ return new Response(WebRouter.#createFirebaseMessagingServiceWorker(firebaseConfig), {
508
+ headers: {
509
+ "Content-Type": "application/javascript; charset=utf-8",
510
+ "Cache-Control": "no-store",
511
+ },
512
+ });
513
+ },
491
514
  "/*": async (req) => {
492
515
  const url = new URL(req.url);
493
516
  if (WebRouter.#isImageOptimizerPath(url.pathname)) {
@@ -1008,6 +1031,79 @@ export class WebRouter {
1008
1031
  return process.env.AKAN_APP_DIR ?? path.dirname(Bun.main);
1009
1032
  }
1010
1033
 
1034
+ static async #resolveFirebaseClientConfig(): Promise<FirebaseClientEnvConfig | null> {
1035
+ const envPath = path.join(WebRouter.#resolveAppDir(), "env", "env.client.ts");
1036
+ if (!fs.existsSync(envPath)) return null;
1037
+ try {
1038
+ const envUrl = pathToFileURL(envPath);
1039
+ envUrl.searchParams.set("t", String(Date.now()));
1040
+ const envModule = (await import(envUrl.href)) as { env?: { firebase?: unknown } };
1041
+ return WebRouter.#normalizeFirebaseClientConfig(envModule.env?.firebase);
1042
+ } catch {
1043
+ return null;
1044
+ }
1045
+ }
1046
+
1047
+ static #normalizeFirebaseClientConfig(config: unknown): FirebaseClientEnvConfig | null {
1048
+ if (!config || typeof config !== "object") return null;
1049
+ const value = config as Partial<Record<keyof FirebaseClientEnvConfig, unknown>>;
1050
+ if (
1051
+ typeof value.apiKey !== "string" ||
1052
+ typeof value.projectId !== "string" ||
1053
+ typeof value.messagingSenderId !== "string" ||
1054
+ typeof value.appId !== "string"
1055
+ ) {
1056
+ return null;
1057
+ }
1058
+ return {
1059
+ apiKey: value.apiKey,
1060
+ ...(typeof value.authDomain === "string" ? { authDomain: value.authDomain } : {}),
1061
+ projectId: value.projectId,
1062
+ ...(typeof value.storageBucket === "string" ? { storageBucket: value.storageBucket } : {}),
1063
+ messagingSenderId: value.messagingSenderId,
1064
+ appId: value.appId,
1065
+ };
1066
+ }
1067
+
1068
+ static #createFirebaseMessagingServiceWorker(config: FirebaseClientEnvConfig | null): string {
1069
+ const configJson = JSON.stringify(config);
1070
+ return `/* Generated by Akan.js. Do not edit. */
1071
+ const firebaseConfig = ${configJson};
1072
+
1073
+ if (firebaseConfig) {
1074
+ importScripts("https://www.gstatic.com/firebasejs/${FIREBASE_WEB_SDK_VERSION}/firebase-app-compat.js");
1075
+ importScripts("https://www.gstatic.com/firebasejs/${FIREBASE_WEB_SDK_VERSION}/firebase-messaging-compat.js");
1076
+
1077
+ firebase.initializeApp(firebaseConfig);
1078
+ const messaging = firebase.messaging();
1079
+
1080
+ const notificationUrl = (payload) =>
1081
+ payload?.data?.url || payload?.fcmOptions?.link || payload?.notification?.click_action;
1082
+
1083
+ messaging.onBackgroundMessage((payload) => {
1084
+ const title = payload?.notification?.title || "";
1085
+ const options = {
1086
+ body: payload?.notification?.body,
1087
+ icon: payload?.notification?.icon,
1088
+ image: payload?.notification?.image,
1089
+ data: {
1090
+ url: notificationUrl(payload),
1091
+ FCM_MSG: payload,
1092
+ },
1093
+ };
1094
+ self.registration.showNotification(title, options);
1095
+ });
1096
+ }
1097
+
1098
+ self.addEventListener("notificationclick", (event) => {
1099
+ const url = event.notification?.data?.url || event.notification?.data?.FCM_MSG?.data?.url;
1100
+ event.notification?.close();
1101
+ if (!url) return;
1102
+ event.waitUntil(clients.openWindow(url));
1103
+ });
1104
+ `;
1105
+ }
1106
+
1011
1107
  static #normalizeArtifact(artifact: BaseBuildArtifact, artifactDir: string): BaseBuildArtifact {
1012
1108
  const normalizedArtifactDir = path.resolve(artifactDir);
1013
1109
  const pagesBundlePath = WebRouter.#resolveArtifactPath(artifact.pagesBundlePath, normalizedArtifactDir);
@@ -6,6 +6,11 @@ import { type ClassNameValue } from "tailwind-merge";
6
6
  * `cn("bg-primary", "bg-open")` correctly resolves to `"bg-open"` instead of keeping both.
7
7
  */
8
8
  export declare const colorTokens: string[];
9
+ /**
10
+ * Akan's semantic radius tokens (`--radius-box` / `--radius-field` in ui/styles.css). Without them
11
+ * `cn("rounded-field", "rounded-full")` keeps both classes and stylesheet order decides the winner.
12
+ */
13
+ export declare const radiusTokens: string[];
9
14
  /** The one class-combining function: joins strings/arrays/conditional parts (`cond && "x"`) and
10
15
  * resolves Tailwind conflicts with a shared tailwind-merge instance that knows Akan's semantic
11
16
  * color tokens. clsx-style object syntax (`{ x: cond }`) is not supported — write `cond && "x"`. */
@@ -428,6 +428,7 @@ export interface FrameLayoutState {
428
428
  keyboard: KeyboardFrameState;
429
429
  contentViewport: FrameContentViewportState;
430
430
  keyboardAccessory: KeyboardAccessoryFrameState;
431
+ contentAnchor?: "bottom";
431
432
  platformProfile: FramePlatformProfile;
432
433
  zIndex: FrameLayerZIndex;
433
434
  pageStateByPath: Map<string, PageState>;
@@ -436,6 +437,7 @@ export interface FrameSlotRegistration {
436
437
  scope?: FrameSlotScope;
437
438
  type: FrameSlotType;
438
439
  role?: FrameSlotRole;
440
+ contentAnchor?: "bottom";
439
441
  height?: number;
440
442
  estimatedHeight?: number;
441
443
  source?: "navbar" | "topInset" | "bottomInset" | "bottomTab" | (string & {});
@@ -1,4 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
+ /** Put this on a menu item that runs its own interaction (a switch, a copy button) to keep the menu open. */
3
+ export declare const DROPDOWN_KEEP_OPEN_ATTR = "data-dropdown-keep-open";
2
4
  export interface DropdownProps {
3
5
  /** Button/trigger content. */
4
6
  value: ReactNode;
@@ -4,10 +4,11 @@ export interface BottomInsetProps {
4
4
  className?: string;
5
5
  children: ReactNode;
6
6
  keyboardSticky?: boolean;
7
+ contentAnchor?: "bottom";
7
8
  role?: "bottomChrome" | "keyboardAccessory";
8
9
  estimatedHeight?: number;
9
10
  frameScope?: FrameSlotRegistration["scope"];
10
11
  frameSource?: FrameSlotRegistration["source"];
11
12
  frameCache?: boolean;
12
13
  }
13
- export declare const BottomInset: ({ className, children, keyboardSticky, role, estimatedHeight, frameScope, frameSource, frameCache, }: BottomInsetProps) => import("react/jsx-runtime").JSX.Element;
14
+ export declare const BottomInset: ({ className, children, keyboardSticky, contentAnchor, role, estimatedHeight, frameScope, frameSource, frameCache, }: BottomInsetProps) => import("react/jsx-runtime").JSX.Element;
@@ -4,7 +4,7 @@ export declare const Layout: {
4
4
  Navbar: ({ back, className, height, children, title, left, right }: import("./Navbar.d.ts").NavbarProps) => import("react/jsx-runtime").JSX.Element;
5
5
  TopInset: ({ className, children, estimatedHeight }: import("./TopInset.d.ts").TopInsetProps) => import("react/jsx-runtime").JSX.Element;
6
6
  BottomTab: ({ className, tabs, height }: import("./BottomTab.d.ts").BottomTabProps) => import("react/jsx-runtime").JSX.Element;
7
- BottomInset: ({ className, children, keyboardSticky, role, estimatedHeight, frameScope, frameSource, frameCache, }: import("./BottomInset.d.ts").BottomInsetProps) => import("react/jsx-runtime").JSX.Element;
7
+ BottomInset: ({ className, children, keyboardSticky, contentAnchor, role, estimatedHeight, frameScope, frameSource, frameCache, }: import("./BottomInset.d.ts").BottomInsetProps) => import("react/jsx-runtime").JSX.Element;
8
8
  Template: ({ className, children }: import("./Template.d.ts").TemplateProps) => import("react/jsx-runtime").JSX.Element;
9
9
  Unit: ({ className, children, href }: import("./Unit.d.ts").UnitProps) => import("react/jsx-runtime").JSX.Element;
10
10
  View: ({ className, children }: import("./View.d.ts").ViewProps) => import("react/jsx-runtime").JSX.Element;
@@ -11,4 +11,4 @@ export declare const signalUi: {
11
11
  export declare const getEndpointBadgeClassName: (type: string) => string;
12
12
  export declare const getGuardBadgeClassName: (guard: string) => string;
13
13
  export declare const getStatusBadgeClassName: (status: string) => string;
14
- export declare const getStatusTextareaClassName: (status: string) => "" | "pointer-events-none opacity-50" | "border-destructive text-destructive" | "border-primary";
14
+ export declare const getStatusTextareaClassName: (status: string) => "" | "border-primary" | "pointer-events-none opacity-50" | "border-destructive text-destructive";
@@ -16,7 +16,7 @@ import type { ModalProps } from "../Modal.d.ts";
16
16
  import type { PaginationProps } from "../Pagination.d.ts";
17
17
  import type { PopconfirmProps } from "../Popconfirm.d.ts";
18
18
  import type { ItemProps as RadioItemProps, RadioProps } from "../Radio.d.ts";
19
- import type { BadgeVariants, ButtonVariants } from "../recipe.d.ts";
19
+ import type { BadgeVariants, ButtonVariants, InputSurfaceVariants } from "../recipe.d.ts";
20
20
  import type { SelectProps } from "../Select.d.ts";
21
21
  import type { TableProps } from "../Table.d.ts";
22
22
  import type { MultiProps as ToggleSelectMultiProps, ToggleSelectProps } from "../ToggleSelect.d.ts";
@@ -75,11 +75,21 @@ export type AkanModalComponent = AkanUiOverrides["Modal"];
75
75
  * manifest: `override({ recipes: { button: neonButtonRecipe } })`. A recipe swap changes
76
76
  * only the className factory — the component's structure/behavior (async states, focus,
77
77
  * a11y) is untouched. Each replacement must accept the framework recipe's full variant
78
- * contract, so every call site keeps working.
78
+ * contract, so every call site keeps working (a replacement with *extra* optional axes is
79
+ * assignable — contravariance — but those axes are only reachable from code that knows the
80
+ * replacement's own type).
81
+ *
82
+ * Scope: a recipe slot is a **client-side, route-scoped restyle**. It reaches framework
83
+ * client components, which resolve through `useUiRecipe(...)`. It never reaches a raw
84
+ * `xRecipe(...)` call in app JSX (statically imported — no context), nor server components
85
+ * (`Unit`/`View`), which intentionally render the canonical framework recipe. Extending the
86
+ * *vocabulary* is not this slot's job: add the axis to the framework recipe, or author an
87
+ * app recipe in `apps/<app>/ui/Recipe/`.
79
88
  */
80
89
  export interface AkanUiRecipes {
81
90
  button: (variants?: ButtonVariants, className?: ClassValue) => string;
82
91
  badge: (variants?: BadgeVariants, className?: ClassValue) => string;
92
+ input: (variants?: InputSurfaceVariants, className?: ClassValue) => string;
83
93
  }
84
94
  /** Shape of an `_overrides.tsx` manifest: component slots plus an optional recipe-slot map. */
85
95
  export type AkanUiOverrideManifest = Partial<AkanUiOverrides> & {
@@ -12,7 +12,7 @@ export { DatePicker } from "./DatePicker.d.ts";
12
12
  export { Dialog } from "./Dialog.d.ts";
13
13
  export { DragAction } from "./DragAction.d.ts";
14
14
  export { DraggableList } from "./DraggableList.d.ts";
15
- export { Dropdown } from "./Dropdown.d.ts";
15
+ export { DROPDOWN_KEEP_OPEN_ATTR, Dropdown } from "./Dropdown.d.ts";
16
16
  export { Empty } from "./Empty.d.ts";
17
17
  export { Field } from "./Field.d.ts";
18
18
  export { FontFace } from "./FontFace.d.ts";
@@ -29,6 +29,7 @@ export { Modal } from "./Modal.d.ts";
29
29
  export { Model } from "./Model.d.ts";
30
30
  export { More } from "./More.d.ts";
31
31
  export { ObjectId } from "./ObjectId.d.ts";
32
+ export { isOwnOverlayClick, OVERLAY_LAYER_ATTR, OverlayOwnerProvider, useOverlayLayerProps, useOverlayScope, } from "./overlayLayer.d.ts";
32
33
  export { Pagination } from "./Pagination.d.ts";
33
34
  export { Popconfirm } from "./Popconfirm.d.ts";
34
35
  export { Portal } from "./Portal.d.ts";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Overlay surfaces render through `createPortal(document.body)`, so they leave the DOM subtree of the
3
+ * component that opened them, and a dismiss check asking `ref.current.contains(target)` reads every
4
+ * click inside a dialog it opened *itself* as an outside click.
5
+ *
6
+ * React context reaches through a portal where the DOM does not, so an overlay can read which
7
+ * dismissable scope rendered it and stamp that owner on the roots it portals out. A scope then
8
+ * recognises its own overlays exactly, instead of guessing from how close a dialog happens to be.
9
+ */
10
+ export declare const OVERLAY_LAYER_ATTR = "data-akan-overlay";
11
+ /** Wrap the content a dismissable container owns, with the scope from {@link useOverlayScope}. */
12
+ export declare const OverlayOwnerProvider: import("react").Provider<string>;
13
+ /** Scope a dismissable container hands to its content. Nests as a `parent/child` path. */
14
+ export declare const useOverlayScope: (id: string) => string;
15
+ /** Props an overlay spreads onto every root it portals out of its own tree. */
16
+ export declare const useOverlayLayerProps: () => {
17
+ "data-akan-overlay": string;
18
+ };
19
+ /**
20
+ * True when the click landed in an overlay that `scope` — or a scope nested inside it — rendered.
21
+ * An overlay nobody owns, and one owned by an unrelated scope, both read as an ordinary outside
22
+ * click, so a menu does not linger behind a dialog it has nothing to do with.
23
+ */
24
+ export declare const isOwnOverlayClick: (target: EventTarget | null, scope: string) => boolean;
@@ -1,11 +1,15 @@
1
- /** 뱃지 look — 시맨틱 variant. `<Badge>` 가 소비하며, recipes.badge 슬롯으로 교체 가능. */
1
+ /** 뱃지 look — 시맨틱 variant × size, outline 플래그는 색을 유지한 외곽선 스타일. `<Badge>` 가 소비하며, recipes.badge 슬롯으로 교체 가능. */
2
2
  export declare const badgeRecipe: (variants?: Omit<({
3
- variant?: "default" | "error" | "info" | "warning" | "success" | "primary" | "secondary" | "accent" | "outline" | undefined;
3
+ size?: "lg" | "md" | "sm" | "xs" | undefined;
4
+ outline?: boolean | undefined;
5
+ variant?: "default" | "error" | "info" | "warning" | "success" | "primary" | "secondary" | "accent" | "neutral" | "outline" | undefined;
4
6
  } & {
5
7
  class?: import("tailwind-variants").ClassValue;
6
8
  className?: never;
7
9
  }) | ({
8
- variant?: "default" | "error" | "info" | "warning" | "success" | "primary" | "secondary" | "accent" | "outline" | undefined;
10
+ size?: "lg" | "md" | "sm" | "xs" | undefined;
11
+ outline?: boolean | undefined;
12
+ variant?: "default" | "error" | "info" | "warning" | "success" | "primary" | "secondary" | "accent" | "neutral" | "outline" | undefined;
9
13
  } & {
10
14
  class?: never;
11
15
  className?: import("tailwind-variants").ClassValue;
@@ -1,13 +1,17 @@
1
- /** 버튼 look — 시맨틱 variant × size. `<Button>` 이 소비하며, `_overrides.tsx` 의 recipes.button 슬롯으로 교체 가능. */
1
+ /** 버튼 look — 시맨틱 variant × size × shape, outline 플래그는 색을 유지한 외곽선 스타일. `<Button>` 이 소비하며, `_overrides.tsx` 의 recipes.button 슬롯으로 교체 가능. */
2
2
  export declare const buttonRecipe: (variants?: Omit<({
3
3
  size?: "lg" | "md" | "sm" | "xs" | "icon" | undefined;
4
- variant?: "link" | "info" | "warning" | "success" | "primary" | "secondary" | "accent" | "destructive" | "outline" | "ghost" | undefined;
4
+ outline?: boolean | undefined;
5
+ variant?: "link" | "default" | "info" | "warning" | "success" | "primary" | "secondary" | "accent" | "destructive" | "neutral" | "outline" | "ghost" | undefined;
6
+ shape?: "default" | "circle" | "square" | undefined;
5
7
  } & {
6
8
  class?: import("tailwind-variants").ClassValue;
7
9
  className?: never;
8
10
  }) | ({
9
11
  size?: "lg" | "md" | "sm" | "xs" | "icon" | undefined;
10
- variant?: "link" | "info" | "warning" | "success" | "primary" | "secondary" | "accent" | "destructive" | "outline" | "ghost" | undefined;
12
+ outline?: boolean | undefined;
13
+ variant?: "link" | "default" | "info" | "warning" | "success" | "primary" | "secondary" | "accent" | "destructive" | "neutral" | "outline" | "ghost" | undefined;
14
+ shape?: "default" | "circle" | "square" | undefined;
11
15
  } & {
12
16
  class?: never;
13
17
  className?: import("tailwind-variants").ClassValue;
@@ -1,11 +1,15 @@
1
- /** 입력 표면 look — Input/TextArea 가 공유하는 필드 셸. kind 로 한 줄 필드(field)/멀티라인(area) 고른다. */
1
+ /** 입력 표면 look — Input/TextArea/Select 가 공유하는 필드 셸. kind 로 한 줄 필드(field)/멀티라인(area), tone 으로 강조/오류 상태를 고른다. */
2
2
  export declare const inputRecipe: (variants?: Omit<({
3
+ size?: "xl" | "lg" | "md" | "sm" | "xs" | undefined;
3
4
  kind?: "field" | "area" | undefined;
5
+ tone?: "default" | "error" | "primary" | undefined;
4
6
  } & {
5
7
  class?: import("tailwind-variants").ClassValue;
6
8
  className?: never;
7
9
  }) | ({
10
+ size?: "xl" | "lg" | "md" | "sm" | "xs" | undefined;
8
11
  kind?: "field" | "area" | undefined;
12
+ tone?: "default" | "error" | "primary" | undefined;
9
13
  } & {
10
14
  class?: never;
11
15
  className?: import("tailwind-variants").ClassValue;
@@ -8,6 +8,7 @@ export { useCodepush } from "./useCodepush.d.ts";
8
8
  export { useContact } from "./useContact.d.ts";
9
9
  export { useCsrValues } from "./useCsrValues.d.ts";
10
10
  export { useDebounce } from "./useDebounce.d.ts";
11
+ export { useEscapeKey } from "./useEscapeKey.d.ts";
11
12
  export { useFetch, useFetchFn } from "./useFetch.d.ts";
12
13
  export { useGeoLocation } from "./useGeoLocation.d.ts";
13
14
  export { useHistory } from "./useHistory.d.ts";
@@ -1,6 +1,18 @@
1
1
  import type { ReactNode } from "react";
2
+ /**
3
+ * `suspense` puts a Suspense boundary around the lazy component, so a chunk that resolves *after* the
4
+ * page is painted — a modal body, a dropdown menu — suspends only itself. Without one the suspension
5
+ * travels up to the nearest boundary, which is the route (`server/routeElementComposer.tsx`), and the
6
+ * whole page repaints as its loading fallback on the first open.
7
+ *
8
+ * It is opt-in rather than the default because a boundary also changes server rendering: under the
9
+ * default `renderMode: "stream"` the boundary's subtree leaves the shell as `loading` and arrives later
10
+ * in the stream. That is fine for an interaction shell and wrong for a page body, which SEO snapshots,
11
+ * prerendering and pre-hydration E2E read out of the shell.
12
+ */
2
13
  type LazyOption = {
3
14
  ssr?: boolean;
15
+ suspense?: boolean;
4
16
  loading?: () => ReactNode;
5
17
  };
6
18
  type LoadedOf<Loaded> = Loaded extends {
@@ -1,8 +1,8 @@
1
- import { type NavigationIntent, type PathRoute, type RouteGuide, type RouterInstance, type TransitionType } from "akanjs/client";
1
+ import { type NavigationIntent, type PageTransition, type PathRoute, type RouteGuide, type RouterInstance, type TransitionType } from "akanjs/client";
2
2
  export declare const useCsrValues: (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]) => {
3
+ page: PageTransition | null;
3
4
  topSafeArea: import("akanjs/client").SafeAreaTransition | null;
4
- page: import("akanjs/client").PageTransition | null;
5
- prevPage: import("akanjs/client").PageTransition | null;
5
+ prevPage: PageTransition | null;
6
6
  topInset: import("akanjs/client").ContainerTransition | null;
7
7
  bottomInset: import("akanjs/client").ContainerTransition | null;
8
8
  topLeftAction: import("akanjs/client").ContainerTransition | null;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Close `onEscape` on Escape while `active`, but only while this surface is the topmost active one.
3
+ * The callback is read through a ref, so a fresh closure per render does not reshuffle the stack.
4
+ */
5
+ export declare const useEscapeKey: (active: boolean, onEscape: () => void) => void;
@@ -4,7 +4,10 @@ export type FrameSlotBucket = "active" | "pending";
4
4
  export type FrameSlotMapByBucket = Record<FrameSlotBucket, FrameSlotMap>;
5
5
  export declare const PENDING_FRAME_READY_TIMEOUT_MS = 80;
6
6
  export declare const PENDING_FRAME_READY_MAX_TIMEOUT_MS = 120;
7
- export declare const KEYBOARD_FALLBACK_ANIMATION_DURATION_MS = 285;
7
+ export declare const KEYBOARD_SHOW_ANIMATION_DURATION_MS = 420;
8
+ export declare const KEYBOARD_HIDE_ANIMATION_DURATION_MS = 90;
9
+ export declare const KEYBOARD_SHOW_ANIMATION_EASING = "cubic-bezier(0.16, 1, 0.3, 1)";
10
+ export declare const KEYBOARD_HIDE_ANIMATION_EASING = "cubic-bezier(0.7, 0, 0.84, 0)";
8
11
  export declare const FRAME_Z_INDEX: {
9
12
  readonly page: 10;
10
13
  readonly previousPage: 0;
@@ -39,6 +42,7 @@ export declare function useFrameViewport(): {
39
42
  export declare function getKeyboardAccessorySlots(path: string, frameSlots: FrameSlotMap): FrameSlotRegistration[];
40
43
  export declare function resolveKeyboardAccessoryHeight(path: string, frameSlots: FrameSlotMap): number;
41
44
  export declare function hasKeyboardStickySlot(path: string, frameSlots: FrameSlotMap): boolean;
45
+ export declare function hasBottomAnchoredKeyboardSlot(path: string, frameSlots: FrameSlotMap): boolean;
42
46
  export declare function resolveKeyboardFrame({ keyboardHeight, bottomSafeArea, visualViewportKeyboardHeight, platformProfile, sticky, freeze, }: {
43
47
  keyboardHeight: number;
44
48
  bottomSafeArea: number;
@@ -57,6 +61,7 @@ export declare function useKeyboardFrame({ bottomSafeArea, sticky, viewport, pla
57
61
  bottomSafeArea: number;
58
62
  sticky: boolean;
59
63
  viewport: {
64
+ height: number;
60
65
  visualHeight: number;
61
66
  visualOffsetTop: number;
62
67
  };
package/ui/Badge.tsx CHANGED
@@ -7,10 +7,10 @@ export { badgeRecipe, type BadgeVariants };
7
7
 
8
8
  export type BadgeProps = HTMLAttributes<HTMLSpanElement> & BadgeVariants;
9
9
 
10
- const DefaultBadge = ({ className, variant, ...rest }: BadgeProps) => {
10
+ const DefaultBadge = ({ className, variant, size, outline, ...rest }: BadgeProps) => {
11
11
 
12
12
  const recipe = useUiRecipe("badge") ?? badgeRecipe;
13
- return <span className={recipe({ variant }, className)} {...rest} />;
13
+ return <span className={recipe({ variant, size, outline }, className)} {...rest} />;
14
14
  };
15
15
 
16
16
  /** Status/label pill. Route-overridable via `page/**\/_overrides.tsx` (slot `Badge`). */
@@ -3,6 +3,7 @@ import { useDrag } from "@use-gesture/react";
3
3
  import { cn } from "akanjs/client";
4
4
  import { st } from "akanjs/store";
5
5
  import { animated } from "akanjs/ui";
6
+ import { useEscapeKey } from "akanjs/webkit";
6
7
  import { forwardRef, type ReactNode, useEffect, useImperativeHandle, useRef } from "react";
7
8
  import { BiX } from "react-icons/bi";
8
9
  import { config, useSpring } from "react-spring";
@@ -54,6 +55,10 @@ export const BottomSheet = forwardRef<BottomSheetRef, BottomSheetProps>(
54
55
  close: closeModal,
55
56
  }));
56
57
 
58
+ useEscapeKey(open, () => {
59
+ void closeModal();
60
+ });
61
+
57
62
  useEffect(() => {
58
63
  if (open) void openModal();
59
64
  else void closeModal();
package/ui/Button.tsx CHANGED
@@ -61,6 +61,8 @@ const DefaultButton = <Result = unknown>({
61
61
  className,
62
62
  variant,
63
63
  size,
64
+ shape,
65
+ outline,
64
66
  type = "button",
65
67
  loadingMode = "hold",
66
68
  showError = true,
@@ -84,7 +86,7 @@ const DefaultButton = <Result = unknown>({
84
86
  <button
85
87
  type={type}
86
88
  className={recipe(
87
- { variant, size },
89
+ { variant, size, shape, outline },
88
90
 
89
91
  cn(loadingMode === "hold" && "relative", busy && !rest.disabled && "disabled:opacity-100", className),
90
92
  )}
@@ -238,7 +238,7 @@ const EnumList = ({ enums = getConstantSchemaDoc().enums }: EnumProps) => {
238
238
  const { l } = usePage();
239
239
  return (
240
240
  <div className="overflow-x-auto rounded-xl bg-muted p-3">
241
- <table className="table">
241
+ <table className={tableClass}>
242
242
  <thead>
243
243
  <tr>
244
244
  <th>Key</th>
@@ -268,7 +268,7 @@ export default function ListContainer<
268
268
  <div className={cn("m-4", className)}>
269
269
  <div className="mb-3 flex flex-wrap justify-between">
270
270
  <div className="flex pb-1">
271
- <p className="prose text-lg">
271
+ <p className="text-lg">
272
272
  {title ?? l._(`${sliceName}.modelName`)}({modelInsight.count})
273
273
  </p>
274
274
  <div className="ml-3 flex items-center">
package/ui/DatePicker.tsx CHANGED
@@ -5,6 +5,7 @@ import { lazy } from "akanjs/webkit";
5
5
  import { type FocusEvent, useEffect, useRef } from "react";
6
6
  import { AiOutlineSwapRight } from "react-icons/ai";
7
7
 
8
+ import { inputRecipe } from "./recipe";
8
9
  import { createOverridable } from "./UiOverride";
9
10
 
10
11
  const reactDatePickerPackage = "react-datepicker";
@@ -58,7 +59,7 @@ const DefaultDatePicker = ({
58
59
 
59
60
  return (
60
61
  <ReactDatePicker
61
- className={cn("input text-center", className)}
62
+ className={inputRecipe({}, ["text-center", className])}
62
63
  selected={value ? value.toDate() : new Date()}
63
64
  disabledKeyboardNavigation
64
65
  onFocus={(e: FocusEvent<HTMLInputElement>) => {
@@ -110,9 +111,9 @@ const DefaultRangePicker = ({
110
111
  onChange([value[0] ?? dayjs(), dayjs(date)]);
111
112
  };
112
113
 
113
- const pickerClassName = "m-0 input focus:outline-hidden z-50 p-3 text-center h-full w-full ";
114
+ const pickerClassName = "m-0 h-full w-full border-none bg-transparent p-3 text-center focus:outline-hidden z-50";
114
115
  return (
115
- <div className={cn("input flex h-full w-fit items-center gap-2 p-0", className)}>
116
+ <div className={inputRecipe({}, ["flex h-full w-fit items-center gap-2 p-0", className])}>
116
117
  <ReactDatePicker
117
118
  className={pickerClassName}
118
119
  selected={value[0] ? value[0].toDate() : undefined}
@@ -2,11 +2,13 @@
2
2
  import { useDrag } from "@use-gesture/react";
3
3
  import { cn, usePage } from "akanjs/client";
4
4
  import { animated } from "akanjs/ui";
5
+ import { useEscapeKey } from "akanjs/webkit";
5
6
  import { type ReactNode, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
6
7
  import { createPortal } from "react-dom";
7
8
  import { BiX } from "react-icons/bi";
8
9
  import { config, useSpring } from "react-spring";
9
10
  import { buttonRecipe } from "../Button";
11
+ import { useOverlayLayerProps } from "../overlayLayer";
10
12
 
11
13
  import { DialogContext } from "./context";
12
14
 
@@ -35,6 +37,8 @@ export const Modal = ({ className, bodyClassName, confirmClose, children, onCanc
35
37
  const focusedElementRef = useRef<HTMLElement | null>(null);
36
38
  const titleId = useId();
37
39
  const contentId = useId();
40
+
41
+ const overlayLayerProps = useOverlayLayerProps();
38
42
  const [{ translate }, api] = useSpring(() => ({ translate: 1 }));
39
43
  const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
40
44
  const [isMounted, setIsMounted] = useState(open);
@@ -160,20 +164,7 @@ export const Modal = ({ className, bodyClassName, confirmClose, children, onCanc
160
164
  };
161
165
  }, [isMounted, portalElement]);
162
166
 
163
- useEffect(() => {
164
- if (!isMounted) return;
165
-
166
- const onKeyDown = (event: KeyboardEvent) => {
167
- if (event.key !== "Escape") return;
168
- event.preventDefault();
169
- requestClose();
170
- };
171
-
172
- window.addEventListener("keydown", onKeyDown);
173
- return () => {
174
- window.removeEventListener("keydown", onKeyDown);
175
- };
176
- }, [isMounted, requestClose]);
167
+ useEscapeKey(isMounted, requestClose);
177
168
 
178
169
  useEffect(() => {
179
170
  return () => {
@@ -186,17 +177,23 @@ export const Modal = ({ className, bodyClassName, confirmClose, children, onCanc
186
177
  return createPortal(
187
178
  <>
188
179
  <div
180
+ {...overlayLayerProps}
189
181
  className={cn("fixed inset-0 z-10", showBackground && "animate-fadeIn bg-black/50 backdrop-blur-md")}
190
182
  onClick={(event) => {
191
183
  if (event.target !== event.currentTarget) return;
192
184
  requestClose();
193
185
  }}
194
186
  />
195
- <div className="fixed top-1/2 left-1/2 z-10 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
187
+ <div
188
+ {...overlayLayerProps}
189
+ className="fixed top-1/2 left-1/2 z-10 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center"
190
+ >
196
191
  <div className="z-10">
197
192
  <animated.div
198
193
  ref={ref}
199
194
  style={{ translateY, opacity }}
195
+
196
+ className="outline-none"
200
197
  role="dialog"
201
198
  aria-modal="true"
202
199
  aria-labelledby={title ? titleId : undefined}