ink-cartridge 3.8.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,15 @@
1
- # ink-cartridge
1
+ <div align="center">
2
+ <br>
3
+ <br>
4
+ <img width="440" alt="cartridge" src="static/cartridge.png">
5
+ <br>
6
+ <br>
7
+ <br>
8
+ </div>
2
9
 
3
- > A component kit for rapidly building complex, multi-page, interaction-heavy terminal applications — filling the critical gaps Ink leaves open.
10
+ <h1 align="center">Cartridge</h1>
11
+
12
+ >A component kit for rapidly building complex, multi-page, interaction-heavy terminal applications — filling the critical gaps Ink leaves open.
4
13
 
5
14
  [![CI](https://github.com/BAIGAOa/ink-cartridge/actions/workflows/ci.yml/badge.svg)](https://github.com/BAIGAOa/ink-trc/actions/workflows/ci.yml)
6
15
  [![npm version](https://img.shields.io/npm/v/ink-cartridge.svg)](https://www.npmjs.com/package/ink-cartridge)
@@ -161,4 +170,4 @@ The method `blockedKey` is poorly named — it means *pass-through*, not "block.
161
170
 
162
171
  ## License
163
172
 
164
- [MIT](LICENSE)
173
+ [MIT](LICENSE)
@@ -41,85 +41,4 @@ import type { MultiSelectInputProps, Item } from './types.js';
41
41
  * />
42
42
  * ```
43
43
  */
44
- export declare function MultiSelectInput<T, I extends Item<T> = Item<T>>({ items, selected: controlledSelected, defaultSelected, onChange, onSubmit, onSelect, onUnselect, onHighlight, indicatorComponent, checkboxComponent, itemComponent, focusId, limit: limitProp, initialIndex, storage, storageKey, }: MultiSelectInputProps<T, I>): React.FunctionComponentElement<{
45
- readonly position?: "absolute" | "relative" | "static" | undefined;
46
- readonly top?: number | string | undefined;
47
- readonly right?: number | string | undefined;
48
- readonly bottom?: number | string | undefined;
49
- readonly left?: number | string | undefined;
50
- readonly columnGap?: number | undefined;
51
- readonly rowGap?: number | undefined;
52
- readonly gap?: number | undefined;
53
- readonly margin?: number | undefined;
54
- readonly marginX?: number | undefined;
55
- readonly marginY?: number | undefined;
56
- readonly marginTop?: number | undefined;
57
- readonly marginBottom?: number | undefined;
58
- readonly marginLeft?: number | undefined;
59
- readonly marginRight?: number | undefined;
60
- readonly padding?: number | undefined;
61
- readonly paddingX?: number | undefined;
62
- readonly paddingY?: number | undefined;
63
- readonly paddingTop?: number | undefined;
64
- readonly paddingBottom?: number | undefined;
65
- readonly paddingLeft?: number | undefined;
66
- readonly paddingRight?: number | undefined;
67
- readonly flexGrow?: number | undefined;
68
- readonly flexShrink?: number | undefined;
69
- readonly flexDirection?: "row" | "column" | "row-reverse" | "column-reverse" | undefined;
70
- readonly flexBasis?: number | string | undefined;
71
- readonly flexWrap?: "nowrap" | "wrap" | "wrap-reverse" | undefined;
72
- readonly alignItems?: "flex-start" | "center" | "flex-end" | "stretch" | "baseline" | undefined;
73
- readonly alignSelf?: "flex-start" | "center" | "flex-end" | "auto" | "stretch" | "baseline" | undefined;
74
- readonly alignContent?: "flex-start" | "flex-end" | "center" | "stretch" | "space-between" | "space-around" | "space-evenly" | undefined;
75
- readonly justifyContent?: "flex-start" | "flex-end" | "space-between" | "space-around" | "space-evenly" | "center" | undefined;
76
- readonly width?: number | string | undefined;
77
- readonly height?: number | string | undefined;
78
- readonly minWidth?: number | string | undefined;
79
- readonly minHeight?: number | string | undefined;
80
- readonly maxWidth?: number | string | undefined;
81
- readonly maxHeight?: number | string | undefined;
82
- readonly aspectRatio?: number | undefined;
83
- readonly display?: "flex" | "none" | undefined;
84
- readonly borderStyle?: (keyof import("cli-boxes").Boxes | import("cli-boxes").BoxStyle) | undefined;
85
- readonly borderTop?: boolean | undefined;
86
- readonly borderBottom?: boolean | undefined;
87
- readonly borderLeft?: boolean | undefined;
88
- readonly borderRight?: boolean | undefined;
89
- readonly borderColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
90
- readonly borderTopColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
91
- readonly borderBottomColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
92
- readonly borderLeftColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
93
- readonly borderRightColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
94
- readonly borderDimColor?: boolean | undefined;
95
- readonly borderTopDimColor?: boolean | undefined;
96
- readonly borderBottomDimColor?: boolean | undefined;
97
- readonly borderLeftDimColor?: boolean | undefined;
98
- readonly borderRightDimColor?: boolean | undefined;
99
- readonly borderBackgroundColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
100
- readonly borderTopBackgroundColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
101
- readonly borderBottomBackgroundColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
102
- readonly borderLeftBackgroundColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
103
- readonly borderRightBackgroundColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
104
- readonly overflow?: "visible" | "hidden" | undefined;
105
- readonly overflowX?: "visible" | "hidden" | undefined;
106
- readonly overflowY?: "visible" | "hidden" | undefined;
107
- readonly backgroundColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
108
- } & {
109
- readonly 'aria-label'?: string;
110
- readonly 'aria-hidden'?: boolean;
111
- readonly 'aria-role'?: "button" | "checkbox" | "combobox" | "list" | "listbox" | "listitem" | "menu" | "menuitem" | "option" | "progressbar" | "radio" | "radiogroup" | "tab" | "tablist" | "table" | "textbox" | "timer" | "toolbar";
112
- readonly 'aria-state'?: {
113
- readonly busy?: boolean;
114
- readonly checked?: boolean;
115
- readonly disabled?: boolean;
116
- readonly expanded?: boolean;
117
- readonly multiline?: boolean;
118
- readonly multiselectable?: boolean;
119
- readonly readonly?: boolean;
120
- readonly required?: boolean;
121
- readonly selected?: boolean;
122
- };
123
- } & {
124
- children?: React.ReactNode | undefined;
125
- } & React.RefAttributes<import("ink").DOMElement>>;
44
+ export declare function MultiSelectInput<T, I extends Item<T> = Item<T>>({ items, selected: controlledSelected, defaultSelected, onChange, onSubmit, onSelect, onUnselect, onHighlight, indicatorComponent, checkboxComponent, itemComponent, focusId, limit: limitProp, initialIndex, storage, storageKey, }: MultiSelectInputProps<T, I>): React.JSX.Element;
@@ -33,4 +33,4 @@ import { DevProps } from "./types.js";
33
33
  *
34
34
  * @2026-06-23 3.6.1
35
35
  */
36
- export declare function DevScreen({ top: initialTop, left }: DevProps): React.JSX.Element;
36
+ export declare function DevScreen({ top: initialTop, left, allowKeys }: DevProps): React.JSX.Element;
@@ -165,10 +165,11 @@ function LayerSummary({ topComponent, readLayer }) {
165
165
  *
166
166
  * @2026-06-23 3.6.1
167
167
  */
168
- export function DevScreen({ top: initialTop, left }) {
168
+ export function DevScreen({ top: initialTop, left, allowKeys }) {
169
169
  const { currentPath, displayedOverlays, activeOverlayIds, modalQueue, activeModalId, } = useScreenSystem();
170
- const { boundKeyboard, readLayer } = useKeyboard();
171
- const modalId = useContext(ModalContext);
170
+ const { boundKeyboard, readLayer, allowModal } = useKeyboard();
171
+ const modalCtx = useContext(ModalContext);
172
+ const modalId = modalCtx?.id ?? null;
172
173
  const { rows } = useWindowSize();
173
174
  const { openModal } = useScreenSystem();
174
175
  const [offsetTop, setOffsetTop] = useState(initialTop);
@@ -200,6 +201,9 @@ export function DevScreen({ top: initialTop, left }) {
200
201
  clearTimeout(flashTimerRef.current);
201
202
  };
202
203
  }, []);
204
+ useEffect(() => {
205
+ return allowModal(allowKeys ?? []);
206
+ }, [allowModal]);
203
207
  useEffect(() => {
204
208
  const u1 = boundKeyboard(['up'], () => setOffsetTop(prev => clampTopRef.current(prev - 1)));
205
209
  const u2 = boundKeyboard(['down'], () => setOffsetTop(prev => clampTopRef.current(prev + 1)));
@@ -3,43 +3,48 @@ import { DevProps } from "./types.js";
3
3
  * Open the developer debugging modal.
4
4
  *
5
5
  * Calls {@link openModal} with the fixed modal ID `_Dev-Tool_` and
6
- * the {@link DevScreen} component. Repeated calls while the modal is
7
- * already open will throw guard with a ref or state flag to implement
8
- * a toggle (see example).
6
+ * the {@link DevScreen} component, using `persistent: true` by default
7
+ * so the panel survives screen navigation. Keyboard focus is
8
+ * automatically suspended when navigating away and restored when
9
+ * returning to the originating screen.
10
+ *
11
+ * Safe to call when the modal is already open — the underlying
12
+ * {@link openModal} treats duplicate IDs as a no-op, so a simple key
13
+ * binding without a toggle ref suffices:
14
+ *
15
+ * {@link closeDevTool} is the inverse.
9
16
  *
10
17
  * When open, DevScreen blocks all keyboard events from reaching
11
- * overlays and screens. The modal provides its own close mechanism
12
- * via the Escape key.
18
+ * overlays and screens. Pass `allowKeys` to let specific keys through
19
+ * (e.g. `allowKeys: ['ctrl+d']` to toggle the tool itself).
13
20
  *
14
- * @param top - Initial vertical position in rows (0 = top of terminal).
15
- * @param left - Horizontal position in columns.
21
+ * @param top - Initial vertical position in rows (0 = top of terminal).
22
+ * @param left - Horizontal position in columns.
23
+ * @param zindex - Optional modal z-index for stacking order.
24
+ * @param allowKeys - Keys allowed to pass through the modal to layers below.
25
+ * @param persistent - Whether the dev tool survives screen navigation. Defaults to `true`.
16
26
  *
17
- * @throws If `ScenarioManagementProvider` is not mounted, or the modal
18
- * ID `_Dev-Tool_` is already open.
27
+ * @throws If `ScenarioManagementProvider` is not mounted.
19
28
  *
20
29
  * @example
21
30
  * ```ts
22
- * // Toggle with a ref to avoid duplicate-open errors:
23
- * const devOpenRef = useRef(false);
31
+ * // Press Ctrl+D to open the dev tool (no-op if already open):
32
+ * boundKeyboard(['ctrl+d'], () => openDevTool({ top: 0, left: 0 }));
33
+ *
34
+ * // Toggle requires allowKeys so the second press can pass through:
24
35
  * boundKeyboard(['ctrl+d'], () => {
25
- * if (devOpenRef.current) {
26
- * closeDevTool();
27
- * devOpenRef.current = false;
28
- * } else {
29
- * openDevTool({ top: 0, left: 0 });
30
- * devOpenRef.current = true;
31
- * }
36
+ * try { openDevTool({ top: 0, left: 0, allowKeys: ['ctrl+d'] }); } catch {}
32
37
  * });
33
38
  * ```
39
+ * @2026-07-04 v3.8.0
34
40
  */
35
- export declare function openDevTool({ top, left, zindex }: DevProps): void;
41
+ export declare function openDevTool({ top, left, zindex, allowKeys, persistent }: DevProps): void;
36
42
  /**
37
43
  * Close the developer debugging modal.
38
44
  *
39
45
  * Calls {@link closeModal} for the fixed modal ID `_Dev-Tool_`.
40
- * Safe to call even if the modal is not currently open — acts as
41
- * a no-op in that case to support toggle patterns where the modal
42
- * may have been closed by its own Escape binding.
46
+ * Safe to call even if the modal is not currently open — the underlying
47
+ * {@link closeModal} treats non-existent IDs as a no-op.
43
48
  *
44
49
  * @example
45
50
  * ```ts
@@ -4,51 +4,58 @@ import { DevScreen } from "./dev-screen.js";
4
4
  * Open the developer debugging modal.
5
5
  *
6
6
  * Calls {@link openModal} with the fixed modal ID `_Dev-Tool_` and
7
- * the {@link DevScreen} component. Repeated calls while the modal is
8
- * already open will throw guard with a ref or state flag to implement
9
- * a toggle (see example).
7
+ * the {@link DevScreen} component, using `persistent: true` by default
8
+ * so the panel survives screen navigation. Keyboard focus is
9
+ * automatically suspended when navigating away and restored when
10
+ * returning to the originating screen.
11
+ *
12
+ * Safe to call when the modal is already open — the underlying
13
+ * {@link openModal} treats duplicate IDs as a no-op, so a simple key
14
+ * binding without a toggle ref suffices:
15
+ *
16
+ * {@link closeDevTool} is the inverse.
10
17
  *
11
18
  * When open, DevScreen blocks all keyboard events from reaching
12
- * overlays and screens. The modal provides its own close mechanism
13
- * via the Escape key.
19
+ * overlays and screens. Pass `allowKeys` to let specific keys through
20
+ * (e.g. `allowKeys: ['ctrl+d']` to toggle the tool itself).
14
21
  *
15
- * @param top - Initial vertical position in rows (0 = top of terminal).
16
- * @param left - Horizontal position in columns.
22
+ * @param top - Initial vertical position in rows (0 = top of terminal).
23
+ * @param left - Horizontal position in columns.
24
+ * @param zindex - Optional modal z-index for stacking order.
25
+ * @param allowKeys - Keys allowed to pass through the modal to layers below.
26
+ * @param persistent - Whether the dev tool survives screen navigation. Defaults to `true`.
17
27
  *
18
- * @throws If `ScenarioManagementProvider` is not mounted, or the modal
19
- * ID `_Dev-Tool_` is already open.
28
+ * @throws If `ScenarioManagementProvider` is not mounted.
20
29
  *
21
30
  * @example
22
31
  * ```ts
23
- * // Toggle with a ref to avoid duplicate-open errors:
24
- * const devOpenRef = useRef(false);
32
+ * // Press Ctrl+D to open the dev tool (no-op if already open):
33
+ * boundKeyboard(['ctrl+d'], () => openDevTool({ top: 0, left: 0 }));
34
+ *
35
+ * // Toggle requires allowKeys so the second press can pass through:
25
36
  * boundKeyboard(['ctrl+d'], () => {
26
- * if (devOpenRef.current) {
27
- * closeDevTool();
28
- * devOpenRef.current = false;
29
- * } else {
30
- * openDevTool({ top: 0, left: 0 });
31
- * devOpenRef.current = true;
32
- * }
37
+ * try { openDevTool({ top: 0, left: 0, allowKeys: ['ctrl+d'] }); } catch {}
33
38
  * });
34
39
  * ```
40
+ * @2026-07-04 v3.8.0
35
41
  */
36
- export function openDevTool({ top, left, zindex }) {
42
+ export function openDevTool({ top, left, zindex, allowKeys, persistent = true }) {
37
43
  openModal('_Dev-Tool_', DevScreen, {
38
44
  top: top,
39
- left: left
45
+ left: left,
46
+ allowKeys: allowKeys,
40
47
  }, {
41
48
  zIndex: zindex,
42
- renderNow: true
49
+ renderNow: true,
50
+ persistent,
43
51
  });
44
52
  }
45
53
  /**
46
54
  * Close the developer debugging modal.
47
55
  *
48
56
  * Calls {@link closeModal} for the fixed modal ID `_Dev-Tool_`.
49
- * Safe to call even if the modal is not currently open — acts as
50
- * a no-op in that case to support toggle patterns where the modal
51
- * may have been closed by its own Escape binding.
57
+ * Safe to call even if the modal is not currently open — the underlying
58
+ * {@link closeModal} treats non-existent IDs as a no-op.
52
59
  *
53
60
  * @example
54
61
  * ```ts
@@ -56,12 +63,5 @@ export function openDevTool({ top, left, zindex }) {
56
63
  * ```
57
64
  */
58
65
  export function closeDevTool() {
59
- try {
60
- closeModal('_Dev-Tool_');
61
- }
62
- catch {
63
- // Modal already closed — no-op. This handles the case where
64
- // the Escape key inside DevScreen closed the modal, leaving
65
- // an external toggle ref stale.
66
- }
66
+ closeModal('_Dev-Tool_');
67
67
  }
@@ -51,7 +51,8 @@ const Sep = () => (React.createElement(Box, null,
51
51
  export default function GlobalKeyDisplayBox({ top: initialTop, left }) {
52
52
  const { getGlobalKeys, boundKeyboard } = useKeyboard();
53
53
  const { closeModal } = useScreenSystem();
54
- const modalId = useContext(ModalContext);
54
+ const modalCtx = useContext(ModalContext);
55
+ const modalId = modalCtx?.id ?? null;
55
56
  const { rows } = useWindowSize();
56
57
  const [offsetTop, setOffsetTop] = useState(initialTop);
57
58
  const [expandedEntry, setExpandedEntry] = useState(null);
@@ -56,7 +56,8 @@ const Sep = () => (React.createElement(Box, null,
56
56
  export default function GlobalSequenceDisplayBox({ top: initialTop, left }) {
57
57
  const { getGlobalSequences, getGlobalPendingSequence, boundKeyboard } = useKeyboard();
58
58
  const { closeModal } = useScreenSystem();
59
- const modalId = useContext(ModalContext);
59
+ const modalCtx = useContext(ModalContext);
60
+ const modalId = modalCtx?.id ?? null;
60
61
  const { rows } = useWindowSize();
61
62
  const [offsetTop, setOffsetTop] = useState(initialTop);
62
63
  const [expandedEntry, setExpandedEntry] = useState(null);
@@ -140,7 +140,8 @@ function FocusTargetDetail({ focusId, target }) {
140
140
  export default function LayerKeyDisplayBox({ top: initialTop, left, screenComponent }) {
141
141
  const { readLayer, boundKeyboard } = useKeyboard();
142
142
  const { closeModal } = useScreenSystem();
143
- const modalId = useContext(ModalContext);
143
+ const modalCtx = useContext(ModalContext);
144
+ const modalId = modalCtx?.id ?? null;
144
145
  const { rows } = useWindowSize();
145
146
  const [offsetTop, setOffsetTop] = useState(initialTop);
146
147
  const [expandedEntry, setExpandedEntry] = useState(null);
@@ -7,6 +7,10 @@ export interface DevProps {
7
7
  /** Horizontal position in columns — 0 is the left edge. */
8
8
  left: number;
9
9
  zindex?: number;
10
+ /** Keys allowed to pass through the dev tool modal to layers below. */
11
+ allowKeys?: string[];
12
+ /** Whether the dev tool survives screen navigation. Defaults to `true`. */
13
+ persistent?: boolean;
10
14
  }
11
15
  /**
12
16
  * Props accepted by the {@link GlobalKeyDisplayBox} global-keys inspector modal.
@@ -18,17 +18,21 @@ export interface KeyboardContextValue {
18
18
  * Overloads:
19
19
  * 1. `(keys: string | string[], handler: KeyHandler, options?: BoundKeyboardOptions)` —
20
20
  * explicit keys and handler. A single string is normalized to `[string]`.
21
- * 2. `(actionId: string, options: BoundKeyboardOptions)` —
21
+ * 2. `(keys: string | string[], actionId: string, options?: BoundKeyboardOptions)` —
22
+ * explicit keys referencing a registered shortcut action by id.
23
+ * 3. `(actionId: string, options?: BoundKeyboardOptions)` —
22
24
  * uses a registered shortcut action's predefined keys and callback.
23
25
  *
24
26
  * @param keys Key name(s) to bind (e.g. `"s"`, `["s", "ctrl+q", "return"]`).
25
- * @param handler Callback receiving the raw `input` and `key` from Ink.
27
+ * @param handler Callback receiving the raw `input` and `key` from Ink,
28
+ * or a registered shortcut action id string.
26
29
  * @param options Optional binding behavior (`onlyThis`, `focusId`, `once`, `times`, `when`).
27
30
  * @returns An unbind function that removes this binding when called.
28
31
  */
29
32
  boundKeyboard: {
30
33
  (keys: string | string[], handler: KeyHandler, options?: BoundKeyboardOptions): () => void;
31
- (actionId: string, options: BoundKeyboardOptions): () => void;
34
+ (keys: string | string[], actionId: string, options?: BoundKeyboardOptions): () => void;
35
+ (actionId: string, options?: BoundKeyboardOptions): () => void;
32
36
  };
33
37
  /**
34
38
  * Mark one or more keys as "transparent" on the current layer.
@@ -1,7 +1,8 @@
1
- import { useContext, useEffect, useState } from "react";
1
+ import { useContext, useEffect, useRef, useState } from "react";
2
2
  import { KeyboardContext } from "./context.js";
3
3
  import { OverlayContext } from "../screen/OverlayContext.js";
4
4
  import { ModalContext } from "../screen/ModalContext.js";
5
+ import { useScreenSystem } from "../screen/hook.js";
5
6
  /**
6
7
  * Access the keyboard API from within a React component.
7
8
  *
@@ -23,41 +24,78 @@ import { ModalContext } from "../screen/ModalContext.js";
23
24
  */
24
25
  export function useKeyboard() {
25
26
  const ctx = useContext(KeyboardContext);
26
- const overlayId = useContext(OverlayContext);
27
- const modalId = useContext(ModalContext);
27
+ const overlayCtx = useContext(OverlayContext);
28
+ const modalCtx = useContext(ModalContext);
29
+ const overlayId = overlayCtx?.id ?? null;
30
+ const modalId = modalCtx?.id ?? null;
31
+ const { currentPath } = useScreenSystem();
28
32
  if (!ctx) {
29
33
  throw new Error("[Ink-Cartridge] useKeyboard() must be called inside a <KeyboardProvider>.");
30
34
  }
31
- // Manage the owner stack for overlay isolation.
32
- // When inside an overlay, push the overlay ID as the current owner so
33
- // that boundKeyboard, blockedKey, stop, and focus functions operate on
34
- // the overlay's own keyboard layer instead of the screen's layer.
35
- //
36
- // Destructuring _pushOwner / _popOwner to local variables gives ESLint a
37
- // simple identifier to include in the dependency array (avoiding the need
38
- // to list `ctx` as a whole, which would broaden the effect trigger).
39
35
  const { _pushOwner, _popOwner } = ctx;
36
+ const ownerPushedRef = useRef(false);
37
+ // Lifecycle: overlay mount → push, unmount → pop.
40
38
  useEffect(() => {
41
39
  if (overlayId) {
42
- _pushOwner(overlayId);
40
+ if (!ownerPushedRef.current) {
41
+ _pushOwner(overlayId);
42
+ ownerPushedRef.current = true;
43
+ }
43
44
  return () => {
44
45
  _popOwner(overlayId);
46
+ ownerPushedRef.current = false;
45
47
  };
46
48
  }
47
49
  return;
48
50
  }, [overlayId, _pushOwner, _popOwner]);
49
- // Manage the owner stack for modal isolation (symmetric to overlay).
50
- // When inside a modal, push the modal ID as the current owner so that
51
- // keyboard functions operate on the modal's own layer.
51
+ // Lifecycle: modal mount push, unmount pop.
52
52
  useEffect(() => {
53
53
  if (modalId) {
54
- _pushOwner(modalId);
54
+ if (!ownerPushedRef.current) {
55
+ _pushOwner(modalId);
56
+ ownerPushedRef.current = true;
57
+ }
55
58
  return () => {
56
59
  _popOwner(modalId);
60
+ ownerPushedRef.current = false;
57
61
  };
58
62
  }
59
63
  return;
60
64
  }, [modalId, _pushOwner, _popOwner]);
65
+ /**
66
+ * Render-time owner stack sync for persistent layers.
67
+ *
68
+ * When a persistent overlay/modal survives navigation but the user has
69
+ * navigated to a different screen, the owner must be popped from the
70
+ * stack IMMEDIATELY (during render) so that sibling screen components'
71
+ * mount effects see the correct owner when they call boundKeyboard.
72
+ * Re-push happens when the user navigates back to the origin screen.
73
+ *
74
+ * This replaces the previous useEffect-based management which was
75
+ * subject to sibling ordering: screen components' effects fire before
76
+ * the persisting layer's effect, causing bindings to leak into the
77
+ * wrong keyboard layer.
78
+ *
79
+ * @2026-07-04 v3.8.0
80
+ */
81
+ const ownerCtx = modalCtx ?? overlayCtx;
82
+ const ownerId = ownerCtx?.id ?? null;
83
+ const originComponent = ownerCtx?.originComponent;
84
+ if (originComponent && ownerId) {
85
+ const currentTop = currentPath[currentPath.length - 1];
86
+ if (currentTop === originComponent) {
87
+ if (!ownerPushedRef.current) {
88
+ _pushOwner(ownerId);
89
+ ownerPushedRef.current = true;
90
+ }
91
+ }
92
+ else {
93
+ if (ownerPushedRef.current) {
94
+ _popOwner(ownerId);
95
+ ownerPushedRef.current = false;
96
+ }
97
+ }
98
+ }
61
99
  return ctx;
62
100
  }
63
101
  /**
@@ -99,7 +137,8 @@ export function useFocusState(focusId) {
99
137
  */
100
138
  export function useModalMissListener(cb, options) {
101
139
  const ctx = useContext(KeyboardContext);
102
- const modalId = useContext(ModalContext);
140
+ const modalCtx = useContext(ModalContext);
141
+ const modalId = modalCtx?.id ?? null;
103
142
  useEffect(() => {
104
143
  if (!ctx || !modalId)
105
144
  return;
@@ -1,6 +1,10 @@
1
1
  /**
2
- * Context that provides the modal ID when rendering inside
3
- * a modal component. Null when rendering a regular screen.
2
+ * Context that provides the modal ID and origin component when rendering
3
+ * inside a modal component. Null when rendering a regular screen.
4
+ *
5
+ * The {@link originComponent} field is set only for persistent modals —
6
+ * it tracks which screen opened the modal and enables automatic keyboard
7
+ * layer push/pop as the user navigates between screens.
4
8
  *
5
9
  * Used by the keyboard system to isolate per-modal keyboard layers
6
10
  * by modal ID instead of component type (enabling multiple instances
@@ -8,4 +12,9 @@
8
12
  *
9
13
  * Architecturally symmetric to {@link OverlayContext}.
10
14
  */
11
- export declare const ModalContext: import("react").Context<string | null>;
15
+ export declare const ModalContext: import("react").Context<{
16
+ /** Unique modal identifier. */
17
+ id: string;
18
+ /** Screen that opened this modal (set only when persistent). */
19
+ originComponent?: React.ComponentType<any>;
20
+ } | null>;
@@ -1,7 +1,11 @@
1
1
  import { createContext } from 'react';
2
2
  /**
3
- * Context that provides the modal ID when rendering inside
4
- * a modal component. Null when rendering a regular screen.
3
+ * Context that provides the modal ID and origin component when rendering
4
+ * inside a modal component. Null when rendering a regular screen.
5
+ *
6
+ * The {@link originComponent} field is set only for persistent modals —
7
+ * it tracks which screen opened the modal and enables automatic keyboard
8
+ * layer push/pop as the user navigates between screens.
5
9
  *
6
10
  * Used by the keyboard system to isolate per-modal keyboard layers
7
11
  * by modal ID instead of component type (enabling multiple instances
@@ -1,9 +1,20 @@
1
1
  /**
2
- * Context that provides the overlay ID when rendering inside
3
- * an overlay component. Null when rendering a regular screen.
2
+ * Context that provides the overlay ID and origin component when rendering
3
+ * inside an overlay component. Null when rendering a regular screen.
4
+ *
5
+ * The {@link originComponent} field is set only for persistent overlays —
6
+ * it tracks which screen opened the overlay and enables automatic keyboard
7
+ * layer push/pop as the user navigates between screens.
4
8
  *
5
9
  * Used by the keyboard system to isolate per-overlay keyboard layers
6
10
  * by overlay ID instead of component type (enabling multiple instances
7
11
  * of the same component as different overlays).
12
+ *
13
+ * Architecturally symmetric to {@link ModalContext}.
8
14
  */
9
- export declare const OverlayContext: import("react").Context<string | null>;
15
+ export declare const OverlayContext: import("react").Context<{
16
+ /** Unique overlay identifier. */
17
+ id: string;
18
+ /** Screen that opened this overlay (set only when persistent). */
19
+ originComponent?: React.ComponentType<any>;
20
+ } | null>;
@@ -1,10 +1,16 @@
1
1
  import { createContext } from 'react';
2
2
  /**
3
- * Context that provides the overlay ID when rendering inside
4
- * an overlay component. Null when rendering a regular screen.
3
+ * Context that provides the overlay ID and origin component when rendering
4
+ * inside an overlay component. Null when rendering a regular screen.
5
+ *
6
+ * The {@link originComponent} field is set only for persistent overlays —
7
+ * it tracks which screen opened the overlay and enables automatic keyboard
8
+ * layer push/pop as the user navigates between screens.
5
9
  *
6
10
  * Used by the keyboard system to isolate per-overlay keyboard layers
7
11
  * by overlay ID instead of component type (enabling multiple instances
8
12
  * of the same component as different overlays).
13
+ *
14
+ * Architecturally symmetric to {@link ModalContext}.
9
15
  */
10
16
  export const OverlayContext = createContext(null);
@@ -24,7 +24,7 @@ export function CurrentScreen() {
24
24
  const entry = displayedOverlays[i];
25
25
  if (!entry)
26
26
  return overlayNode;
27
- return React.createElement(OverlayContext.Provider, { value: entry.id, key: `ovl-ctx-${entry.id}` }, overlayNode);
27
+ return React.createElement(OverlayContext.Provider, { value: { id: entry.id, originComponent: entry.originComponent }, key: `ovl-ctx-${entry.id}` }, overlayNode);
28
28
  });
29
29
  // Build modal elements with ModalContext wrappers (symmetric to overlays).
30
30
  // Uses renderedModalEntries (parallel to currentModals) for correct ID matching.
@@ -32,7 +32,10 @@ export function CurrentScreen() {
32
32
  const entry = renderedModalEntries[i];
33
33
  if (!entry)
34
34
  return modalNode;
35
- return React.createElement(ModalContext.Provider, { value: entry.id, key: `mdl-ctx-${entry.id}` }, modalNode);
35
+ return React.createElement(ModalContext.Provider, { value: { id: entry.id, originComponent: entry.originComponent }, key: `mdl-ctx-${entry.id}` }, modalNode);
36
36
  });
37
- return React.createElement(Box, { flexDirection: 'column', width: '100%', height: '100%' }, currentScreen, ...wrappedOverlays.map((w) => w), ...wrappedModals.map((w) => w));
37
+ return (React.createElement(Box, { flexDirection: 'column', width: '100%', height: '100%' },
38
+ currentScreen,
39
+ wrappedOverlays.map((w) => w),
40
+ wrappedModals.map((w) => w)));
38
41
  }
@@ -23,19 +23,23 @@ export declare function gotoScreen<C extends React.ComponentType<any>>(component
23
23
  * Open a floating overlay on top of the current screen stack.
24
24
  *
25
25
  * Multiple overlays can be open simultaneously, distinguished by unique IDs.
26
+ * Calling with an ID that already exists is a no-op — the existing overlay is
27
+ * left unchanged.
26
28
  *
27
29
  * @param id Unique identifier for this overlay.
28
30
  * @param component The overlay component (must be registered).
29
31
  * @param params Props to pass to the overlay component.
30
32
  * @param options Optional activation and zIndex settings.
31
33
  *
32
- * @throws If the provider is not mounted, the component is not registered,
33
- * or the ID is already in use.
34
+ * @throws If the provider is not mounted or the component is not registered.
34
35
  */
35
36
  export declare function openOverlay<C extends React.ComponentType<any>>(id: string, component: C, params: React.ComponentProps<C>, options?: OpenOverlayOptions): void;
36
37
  /**
37
38
  * Close a specific overlay by its ID.
38
39
  *
40
+ * If no overlay with the given ID exists, this is a no-op — safe to call
41
+ * even when the overlay may have already been closed.
42
+ *
39
43
  * @param id The ID of the overlay to close.
40
44
  *
41
45
  * @throws If the provider is not mounted.
@@ -70,13 +74,15 @@ export declare function deactivateOverlay(id: string): void;
70
74
  * The active modal receives absolute keyboard priority, consuming all
71
75
  * keyboard events before they reach overlays or screens.
72
76
  *
77
+ * Calling with an ID that already exists is a no-op — the existing modal
78
+ * is left unchanged. This supports simple toggle bindings without guard refs.
79
+ *
73
80
  * @param id Unique identifier for this modal.
74
81
  * @param component The modal component (must be registered).
75
82
  * @param params Props to pass to the modal component.
76
83
  * @param options Optional zIndex and renderNow settings.
77
84
  *
78
- * @throws If the provider is not mounted, the component is not registered,
79
- * or the ID is already in use.
85
+ * @throws If the provider is not mounted or the component is not registered.
80
86
  */
81
87
  export declare function openModal<C extends React.ComponentType<any>>(id: string, component: C, params: React.ComponentProps<C>, options?: OpenModalOptions): void;
82
88
  /**
@@ -85,6 +91,9 @@ export declare function openModal<C extends React.ComponentType<any>>(id: string
85
91
  * When the active modal is closed, the modal with the next highest zIndex
86
92
  * automatically becomes active.
87
93
  *
94
+ * If no modal with the given ID exists, this is a no-op — safe to call
95
+ * even when the modal may have already been closed.
96
+ *
88
97
  * @param id The ID of the modal to close.
89
98
  *
90
99
  * @throws If the provider is not mounted.
@@ -80,14 +80,15 @@ export function gotoScreen(component, params) {
80
80
  * Open a floating overlay on top of the current screen stack.
81
81
  *
82
82
  * Multiple overlays can be open simultaneously, distinguished by unique IDs.
83
+ * Calling with an ID that already exists is a no-op — the existing overlay is
84
+ * left unchanged.
83
85
  *
84
86
  * @param id Unique identifier for this overlay.
85
87
  * @param component The overlay component (must be registered).
86
88
  * @param params Props to pass to the overlay component.
87
89
  * @param options Optional activation and zIndex settings.
88
90
  *
89
- * @throws If the provider is not mounted, the component is not registered,
90
- * or the ID is already in use.
91
+ * @throws If the provider is not mounted or the component is not registered.
91
92
  */
92
93
  export function openOverlay(id, component, params, options) {
93
94
  if (!hasComponent(component)) {
@@ -100,11 +101,15 @@ export function openOverlay(id, component, params, options) {
100
101
  params: params,
101
102
  activate: options?.activate ?? true,
102
103
  zIndex: options?.zIndex,
104
+ persistent: options?.persistent,
103
105
  });
104
106
  }
105
107
  /**
106
108
  * Close a specific overlay by its ID.
107
109
  *
110
+ * If no overlay with the given ID exists, this is a no-op — safe to call
111
+ * even when the overlay may have already been closed.
112
+ *
108
113
  * @param id The ID of the overlay to close.
109
114
  *
110
115
  * @throws If the provider is not mounted.
@@ -147,13 +152,15 @@ export function deactivateOverlay(id) {
147
152
  * The active modal receives absolute keyboard priority, consuming all
148
153
  * keyboard events before they reach overlays or screens.
149
154
  *
155
+ * Calling with an ID that already exists is a no-op — the existing modal
156
+ * is left unchanged. This supports simple toggle bindings without guard refs.
157
+ *
150
158
  * @param id Unique identifier for this modal.
151
159
  * @param component The modal component (must be registered).
152
160
  * @param params Props to pass to the modal component.
153
161
  * @param options Optional zIndex and renderNow settings.
154
162
  *
155
- * @throws If the provider is not mounted, the component is not registered,
156
- * or the ID is already in use.
163
+ * @throws If the provider is not mounted or the component is not registered.
157
164
  */
158
165
  export function openModal(id, component, params, options) {
159
166
  if (!hasComponent(component)) {
@@ -165,7 +172,8 @@ export function openModal(id, component, params, options) {
165
172
  component,
166
173
  params: params,
167
174
  zIndex: options?.zIndex,
168
- renderNow: options?.renderNow,
175
+ renderNow: options?.renderNow ?? false,
176
+ persistent: options?.persistent,
169
177
  });
170
178
  }
171
179
  /**
@@ -174,6 +182,9 @@ export function openModal(id, component, params, options) {
174
182
  * When the active modal is closed, the modal with the next highest zIndex
175
183
  * automatically becomes active.
176
184
  *
185
+ * If no modal with the given ID exists, this is a no-op — safe to call
186
+ * even when the modal may have already been closed.
187
+ *
177
188
  * @param id The ID of the modal to close.
178
189
  *
179
190
  * @throws If the provider is not mounted.
@@ -223,6 +234,30 @@ function buildPathFrom(ancestor, target) {
223
234
  path.reverse();
224
235
  return path;
225
236
  }
237
+ /**
238
+ * Rebuild active IDs for overlays and modals after navigation.
239
+ *
240
+ * Only persistent entries survive navigation. Among them, only those
241
+ * whose originComponent matches the current top-of-path screen are
242
+ * re-activated.
243
+ *
244
+ * @2026-07-04 v3.8.0
245
+ */
246
+ function recalcActiveAfterNavigation(persistentOverlays, persistentModals, newTopScreen) {
247
+ const activeOverlayIds = new Set();
248
+ for (const o of persistentOverlays) {
249
+ if (o.originComponent === newTopScreen) {
250
+ activeOverlayIds.add(o.id);
251
+ }
252
+ }
253
+ let activeModalId = null;
254
+ for (const m of persistentModals) {
255
+ if (m.originComponent === newTopScreen) {
256
+ activeModalId = m.id;
257
+ }
258
+ }
259
+ return { activeOverlayIds, activeModalId };
260
+ }
226
261
  /**
227
262
  * Pure reducer for {@link ScreenState}.
228
263
  *
@@ -230,7 +265,9 @@ function buildPathFrom(ancestor, target) {
230
265
  * (cross-branch), openOverlay, closeOverlay, closeAllOverlays,
231
266
  * activateOverlay, and deactivateOverlay.
232
267
  *
233
- * Navigation actions (skip / back / gotoScreen) clear all open overlays.
268
+ * Navigation actions filter out non-persistent overlays/modals and
269
+ * recalculate active IDs based on whether the origin screen of each
270
+ * persistent entry is at the top of the new path.
234
271
  */
235
272
  function screenReducer(state, action) {
236
273
  switch (action.type) {
@@ -245,13 +282,18 @@ function screenReducer(state, action) {
245
282
  : state.counter + 1;
246
283
  const template = getTemplate(action.component) ?? {};
247
284
  const mergedParams = { ...template, ...action.params };
285
+ const newPath = [...state.path, action.component];
286
+ const persistentOverlays = state.overlays.filter(o => o.persistent);
287
+ const persistentModals = state.modals.filter(m => m.persistent);
288
+ const newTop = newPath[newPath.length - 1];
289
+ const { activeOverlayIds, activeModalId } = recalcActiveAfterNavigation(persistentOverlays, persistentModals, newTop);
248
290
  return {
249
- path: [...state.path, action.component],
291
+ path: newPath,
250
292
  pathParams: [...state.pathParams, mergedParams],
251
- overlays: [],
252
- activeOverlayIds: new Set(),
253
- modals: [],
254
- activeModalId: null,
293
+ overlays: persistentOverlays,
294
+ activeOverlayIds,
295
+ modals: persistentModals,
296
+ activeModalId,
255
297
  counter,
256
298
  };
257
299
  }
@@ -262,13 +304,18 @@ function screenReducer(state, action) {
262
304
  ? '[Ink-Cartridge] back() failed: already at the root node, cannot go back.'
263
305
  : `[Ink-Cartridge] back(${levels}) failed: current depth is ${state.path.length}, cannot go back ${levels} levels.`);
264
306
  }
307
+ const newPath = state.path.slice(0, -levels);
308
+ const persistentOverlays = state.overlays.filter(o => o.persistent);
309
+ const persistentModals = state.modals.filter(m => m.persistent);
310
+ const newTop = newPath[newPath.length - 1];
311
+ const { activeOverlayIds, activeModalId } = recalcActiveAfterNavigation(persistentOverlays, persistentModals, newTop);
265
312
  return {
266
- path: state.path.slice(0, -levels),
313
+ path: newPath,
267
314
  pathParams: state.pathParams.slice(0, -levels),
268
- overlays: [],
269
- activeOverlayIds: new Set(),
270
- modals: [],
271
- activeModalId: null,
315
+ overlays: persistentOverlays,
316
+ activeOverlayIds,
317
+ modals: persistentModals,
318
+ activeModalId,
272
319
  counter: state.counter + 1,
273
320
  };
274
321
  }
@@ -292,22 +339,26 @@ function screenReducer(state, action) {
292
339
  return comp === action.component ? mergedParams : tpl;
293
340
  }),
294
341
  ];
342
+ const persistentOverlays = state.overlays.filter(o => o.persistent);
343
+ const persistentModals = state.modals.filter(m => m.persistent);
344
+ const newTop = newPath[newPath.length - 1];
345
+ const { activeOverlayIds, activeModalId } = recalcActiveAfterNavigation(persistentOverlays, persistentModals, newTop);
295
346
  return {
296
347
  path: newPath,
297
348
  pathParams: newPathParams,
298
- overlays: [],
299
- activeOverlayIds: new Set(),
300
- modals: [],
301
- activeModalId: null,
349
+ overlays: persistentOverlays,
350
+ activeOverlayIds,
351
+ modals: persistentModals,
352
+ activeModalId,
302
353
  counter: state.counter + 1,
303
354
  };
304
355
  }
305
356
  case 'openOverlay': {
306
357
  if (state.overlays.some(o => o.id === action.id)) {
307
- throw new Error(`[Ink-Cartridge] Overlay with id "${action.id}" already exists. Use a unique id for each overlay.`);
358
+ return state;
308
359
  }
309
360
  if (state.modals.some(m => m.id === action.id)) {
310
- throw new Error(`[Ink-Cartridge] Cannot open overlay "${action.id}": this ID is already in use by a modal. Use a unique id.`);
361
+ return state;
311
362
  }
312
363
  const newEntry = {
313
364
  id: action.id,
@@ -315,6 +366,10 @@ function screenReducer(state, action) {
315
366
  props: action.params,
316
367
  zIndex: action.zIndex ?? state.overlays.length,
317
368
  createdAt: Date.now(),
369
+ persistent: action.persistent,
370
+ originComponent: action.persistent
371
+ ? state.path[state.path.length - 1]
372
+ : undefined,
318
373
  };
319
374
  const newOverlays = sortOverlays([...state.overlays, newEntry]);
320
375
  const newActiveIds = new Set(state.activeOverlayIds);
@@ -329,7 +384,7 @@ function screenReducer(state, action) {
329
384
  }
330
385
  case 'closeOverlay': {
331
386
  if (!state.overlays.some(o => o.id === action.id)) {
332
- throw new Error(`[Ink-Cartridge] Cannot close overlay "${action.id}": no overlay with that ID exists.`);
387
+ return state;
333
388
  }
334
389
  const newOverlays = state.overlays.filter(o => o.id !== action.id);
335
390
  const newActiveIds = new Set(state.activeOverlayIds);
@@ -371,10 +426,10 @@ function screenReducer(state, action) {
371
426
  }
372
427
  case 'openModal': {
373
428
  if (state.modals.some(m => m.id === action.id)) {
374
- throw new Error(`[Ink-Cartridge] Modal with id "${action.id}" already exists. Use a unique id for each modal.`);
429
+ return state;
375
430
  }
376
431
  if (state.overlays.some(o => o.id === action.id)) {
377
- throw new Error(`[Ink-Cartridge] Cannot open modal "${action.id}": this ID is already in use by an overlay. Use a unique id.`);
432
+ return state;
378
433
  }
379
434
  const newEntry = {
380
435
  id: action.id,
@@ -383,6 +438,10 @@ function screenReducer(state, action) {
383
438
  zIndex: action.zIndex ?? state.modals.length,
384
439
  createdAt: Date.now(),
385
440
  renderNow: action.renderNow ?? false,
441
+ persistent: action.persistent,
442
+ originComponent: action.persistent
443
+ ? state.path[state.path.length - 1]
444
+ : undefined,
386
445
  };
387
446
  const newModals = sortModals([...state.modals, newEntry]);
388
447
  // The last element (highest zIndex) is the active modal
@@ -395,7 +454,7 @@ function screenReducer(state, action) {
395
454
  }
396
455
  case 'closeModal': {
397
456
  if (!state.modals.some(m => m.id === action.id)) {
398
- throw new Error(`[Ink-Cartridge] Cannot close modal "${action.id}": no modal with that ID exists.`);
457
+ return state;
399
458
  }
400
459
  const newModals = state.modals.filter(m => m.id !== action.id);
401
460
  const activeId = newModals.length > 0 ? newModals[newModals.length - 1].id : null;
@@ -502,6 +561,7 @@ export function ScenarioManagementProvider({ children, defaultScreen, defaultPar
502
561
  params: params,
503
562
  activate: options?.activate ?? true,
504
563
  zIndex: options?.zIndex,
564
+ persistent: options?.persistent,
505
565
  });
506
566
  }, []);
507
567
  const closeOverlayInContext = useMemo(() => (id) => dispatch({ type: 'closeOverlay', id }), []);
@@ -519,6 +579,7 @@ export function ScenarioManagementProvider({ children, defaultScreen, defaultPar
519
579
  params: params,
520
580
  zIndex: options?.zIndex,
521
581
  renderNow: options?.renderNow,
582
+ persistent: options?.persistent,
522
583
  });
523
584
  }, []);
524
585
  const closeModalInContext = useMemo(() => (id) => dispatch({ type: 'closeModal', id }), []);
@@ -21,6 +21,8 @@ export interface OpenOverlayOptions {
21
21
  activate?: boolean;
22
22
  /** Visual stacking order. Smaller values render behind larger values. */
23
23
  zIndex?: number;
24
+ /** Whether this overlay survives screen navigation (skip/back/gotoScreen). */
25
+ persistent?: boolean;
24
26
  }
25
27
  /**
26
28
  * Options for {@link openModal}.
@@ -30,6 +32,8 @@ export interface OpenModalOptions {
30
32
  zIndex?: number;
31
33
  /** Whether to render the modal even when it is not the active modal (zIndex not highest). Defaults to false. */
32
34
  renderNow?: boolean;
35
+ /** Whether this modal survives screen navigation (skip/back/gotoScreen). */
36
+ persistent?: boolean;
33
37
  }
34
38
  /**
35
39
  * A single overlay entry in the multi-overlay system.
@@ -45,6 +49,15 @@ export interface OverlayEntry {
45
49
  zIndex: number;
46
50
  /** Timestamp for tie-breaking when zIndex values are equal. */
47
51
  createdAt: number;
52
+ /**
53
+ * When true, this overlay survives screen navigation (skip/back/gotoScreen).
54
+ * Non-persistent overlays are cleared on navigation. A persistent overlay
55
+ * is automatically re-activated when navigation returns to its originating
56
+ * screen and deactivated when navigating away.
57
+ */
58
+ persistent?: boolean;
59
+ /** When persistent, the screen component that opened this overlay — used to restore keyboard activation when returning to that screen. */
60
+ originComponent?: React.ComponentType<any>;
48
61
  }
49
62
  /**
50
63
  * A single modal entry in the modal system.
@@ -66,6 +79,15 @@ export interface ModalEntry {
66
79
  createdAt: number;
67
80
  /** Whether to render even when not the active modal. Defaults to false. */
68
81
  renderNow: boolean;
82
+ /**
83
+ * When true, this modal survives screen navigation (skip/back/gotoScreen).
84
+ * Non-persistent modals are cleared on navigation. A persistent modal is
85
+ * automatically re-activated when navigation returns to its originating
86
+ * screen and deactivated when navigating away.
87
+ */
88
+ persistent?: boolean;
89
+ /** When persistent, the screen component that opened this modal — used to restore activation when returning to that screen. */
90
+ originComponent?: React.ComponentType<any>;
69
91
  }
70
92
  /**
71
93
  * Internal state of the screen management provider.
@@ -125,6 +147,8 @@ export interface OpenOverlayAction {
125
147
  activate: boolean;
126
148
  /** Optional zIndex for visual stacking. */
127
149
  zIndex?: number;
150
+ /** Whether this overlay survives screen navigation. */
151
+ persistent?: boolean;
128
152
  }
129
153
  /** Action dispatched when closing a specific overlay by ID. */
130
154
  export interface CloseOverlayAction {
@@ -161,6 +185,8 @@ export interface OpenModalAction {
161
185
  zIndex?: number;
162
186
  /** Whether to render even when not active. Defaults to false. */
163
187
  renderNow?: boolean;
188
+ /** Whether this modal survives screen navigation. */
189
+ persistent?: boolean;
164
190
  }
165
191
  /** Action dispatched when closing a specific modal by ID. */
166
192
  export interface CloseModalAction {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ink-cartridge",
3
- "version": "3.8.0",
3
+ "version": "3.8.1",
4
4
  "description": "Ready-to-use Ink components and screen management system for building terminal UIs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",