@plmbr/notebook-intelligence 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +412 -0
  3. package/lib/api.d.ts +288 -0
  4. package/lib/api.js +927 -0
  5. package/lib/cell-output-bundle.d.ts +25 -0
  6. package/lib/cell-output-bundle.js +129 -0
  7. package/lib/cell-output-toolbar.d.ts +26 -0
  8. package/lib/cell-output-toolbar.js +188 -0
  9. package/lib/chat-progress-feedback.d.ts +3 -0
  10. package/lib/chat-progress-feedback.js +27 -0
  11. package/lib/chat-sidebar.d.ts +92 -0
  12. package/lib/chat-sidebar.js +3452 -0
  13. package/lib/command-ids.d.ts +39 -0
  14. package/lib/command-ids.js +44 -0
  15. package/lib/components/ask-user-question.d.ts +2 -0
  16. package/lib/components/ask-user-question.js +85 -0
  17. package/lib/components/checkbox.d.ts +2 -0
  18. package/lib/components/checkbox.js +30 -0
  19. package/lib/components/claude-mcp-panel.d.ts +2 -0
  20. package/lib/components/claude-mcp-panel.js +275 -0
  21. package/lib/components/claude-mcp-paste.d.ts +7 -0
  22. package/lib/components/claude-mcp-paste.js +104 -0
  23. package/lib/components/claude-session-picker.d.ts +8 -0
  24. package/lib/components/claude-session-picker.js +127 -0
  25. package/lib/components/form-dialog.d.ts +25 -0
  26. package/lib/components/form-dialog.js +35 -0
  27. package/lib/components/launcher-picker.d.ts +6 -0
  28. package/lib/components/launcher-picker.js +135 -0
  29. package/lib/components/mcp-util.d.ts +2 -0
  30. package/lib/components/mcp-util.js +37 -0
  31. package/lib/components/notebook-generation-popover.d.ts +7 -0
  32. package/lib/components/notebook-generation-popover.js +60 -0
  33. package/lib/components/pill.d.ts +2 -0
  34. package/lib/components/pill.js +5 -0
  35. package/lib/components/plugins-panel.d.ts +3 -0
  36. package/lib/components/plugins-panel.js +466 -0
  37. package/lib/components/settings-panel.d.ts +11 -0
  38. package/lib/components/settings-panel.js +742 -0
  39. package/lib/components/skills-panel.d.ts +2 -0
  40. package/lib/components/skills-panel.js +1264 -0
  41. package/lib/handler.d.ts +8 -0
  42. package/lib/handler.js +36 -0
  43. package/lib/icons.d.ts +45 -0
  44. package/lib/icons.js +54 -0
  45. package/lib/index.d.ts +8 -0
  46. package/lib/index.js +2079 -0
  47. package/lib/markdown-renderer.d.ts +10 -0
  48. package/lib/markdown-renderer.js +64 -0
  49. package/lib/notebook-generation-toolbar.d.ts +16 -0
  50. package/lib/notebook-generation-toolbar.js +197 -0
  51. package/lib/notebook-generation.d.ts +8 -0
  52. package/lib/notebook-generation.js +12 -0
  53. package/lib/open-file-refresh-watcher-env.d.ts +4 -0
  54. package/lib/open-file-refresh-watcher-env.js +33 -0
  55. package/lib/open-file-refresh-watcher.d.ts +97 -0
  56. package/lib/open-file-refresh-watcher.js +190 -0
  57. package/lib/shell-utils.d.ts +6 -0
  58. package/lib/shell-utils.js +9 -0
  59. package/lib/task-target-notebook.d.ts +2 -0
  60. package/lib/task-target-notebook.js +28 -0
  61. package/lib/terminal-drag-format.d.ts +9 -0
  62. package/lib/terminal-drag-format.js +23 -0
  63. package/lib/terminal-drag.d.ts +12 -0
  64. package/lib/terminal-drag.js +268 -0
  65. package/lib/tokens.d.ts +149 -0
  66. package/lib/tokens.js +88 -0
  67. package/lib/tour/tour-anchors.d.ts +18 -0
  68. package/lib/tour/tour-anchors.js +18 -0
  69. package/lib/tour/tour-config.d.ts +66 -0
  70. package/lib/tour/tour-config.js +99 -0
  71. package/lib/tour/tour-defaults.json +58 -0
  72. package/lib/tour/tour-events.d.ts +19 -0
  73. package/lib/tour/tour-events.js +30 -0
  74. package/lib/tour/tour-overlay.d.ts +6 -0
  75. package/lib/tour/tour-overlay.js +350 -0
  76. package/lib/tour/tour-state.d.ts +20 -0
  77. package/lib/tour/tour-state.js +81 -0
  78. package/lib/tour/tour-steps.d.ts +33 -0
  79. package/lib/tour/tour-steps.js +216 -0
  80. package/lib/utils.d.ts +53 -0
  81. package/lib/utils.js +385 -0
  82. package/package.json +258 -0
  83. package/schema/plugin.json +42 -0
  84. package/src/api.ts +1424 -0
  85. package/src/cell-output-bundle.ts +176 -0
  86. package/src/cell-output-toolbar.ts +232 -0
  87. package/src/chat-progress-feedback.ts +35 -0
  88. package/src/chat-sidebar.tsx +5147 -0
  89. package/src/command-ids.ts +67 -0
  90. package/src/components/ask-user-question.tsx +151 -0
  91. package/src/components/checkbox.tsx +62 -0
  92. package/src/components/claude-mcp-panel.tsx +543 -0
  93. package/src/components/claude-mcp-paste.ts +132 -0
  94. package/src/components/claude-session-picker.tsx +214 -0
  95. package/src/components/form-dialog.tsx +75 -0
  96. package/src/components/launcher-picker.tsx +237 -0
  97. package/src/components/mcp-util.ts +53 -0
  98. package/src/components/notebook-generation-popover.tsx +127 -0
  99. package/src/components/pill.tsx +15 -0
  100. package/src/components/plugins-panel.tsx +774 -0
  101. package/src/components/settings-panel.tsx +1631 -0
  102. package/src/components/skills-panel.tsx +2084 -0
  103. package/src/handler.ts +51 -0
  104. package/src/icons.ts +71 -0
  105. package/src/index.ts +2583 -0
  106. package/src/markdown-renderer.tsx +153 -0
  107. package/src/notebook-generation-toolbar.tsx +281 -0
  108. package/src/notebook-generation.ts +23 -0
  109. package/src/open-file-refresh-watcher-env.ts +52 -0
  110. package/src/open-file-refresh-watcher.ts +260 -0
  111. package/src/shell-utils.ts +10 -0
  112. package/src/svg.d.ts +4 -0
  113. package/src/task-target-notebook.ts +37 -0
  114. package/src/terminal-drag-format.ts +29 -0
  115. package/src/terminal-drag.ts +382 -0
  116. package/src/tokens.ts +171 -0
  117. package/src/tour/tour-anchors.ts +21 -0
  118. package/src/tour/tour-config.ts +160 -0
  119. package/src/tour/tour-events.ts +34 -0
  120. package/src/tour/tour-overlay.tsx +474 -0
  121. package/src/tour/tour-state.ts +87 -0
  122. package/src/tour/tour-steps.ts +281 -0
  123. package/src/utils.ts +455 -0
  124. package/style/base.css +3238 -0
  125. package/style/icons/cell-toolbar-bug.svg +5 -0
  126. package/style/icons/cell-toolbar-chat.svg +5 -0
  127. package/style/icons/cell-toolbar-sparkle.svg +5 -0
  128. package/style/icons/claude.svg +1 -0
  129. package/style/icons/copilot-warning.svg +1 -0
  130. package/style/icons/copilot.svg +1 -0
  131. package/style/icons/copy.svg +1 -0
  132. package/style/icons/openai.svg +1 -0
  133. package/style/icons/opencode.svg +1 -0
  134. package/style/icons/sparkles-warning.svg +5 -0
  135. package/style/icons/sparkles.svg +1 -0
  136. package/style/index.css +1 -0
  137. package/style/index.js +1 -0
@@ -0,0 +1,160 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+
3
+ /**
4
+ * Admin tour-copy overrides — frontend half.
5
+ *
6
+ * The backend reads a YAML/JSON file (path from `NBI_TOUR_CONFIG_PATH`)
7
+ * and ships the validated overrides via the capabilities response. This
8
+ * module overlays them on the built-in `ALL_TOUR_STEPS` so the same
9
+ * tour code runs against either the default copy or an admin-supplied
10
+ * customization, without rebuilding the extension.
11
+ *
12
+ * Defense in depth: the backend already validated shape and length;
13
+ * the frontend re-applies the same caps here so a backend bug or
14
+ * future server-side regression can't crash the picker layout.
15
+ */
16
+
17
+ import { ITourStep } from './tour-steps';
18
+
19
+ // Per-field length caps. Mirror notebook_intelligence/tour_config.py.
20
+ const MAX_TITLE_CHARS = 80;
21
+ const MAX_DESCRIPTION_CHARS = 400;
22
+ const MAX_BUTTON_LABEL_CHARS = 24;
23
+ const MAX_COMMAND_LABEL_CHARS = 40;
24
+
25
+ export interface ITourStepOverride {
26
+ title?: string;
27
+ description?: string;
28
+ enabled?: boolean;
29
+ // Launcher-tiles step only: templates rendered with `{launchers}`
30
+ // substituted by the comma-joined list of installed CLI tools.
31
+ description_singular?: string;
32
+ description_plural?: string;
33
+ }
34
+
35
+ export interface ITourUiOverride {
36
+ skip?: string;
37
+ next?: string;
38
+ back?: string;
39
+ done?: string;
40
+ }
41
+
42
+ export interface ITourCommandOverride {
43
+ label?: string;
44
+ }
45
+
46
+ export interface ITourOverrides {
47
+ steps?: Record<string, ITourStepOverride>;
48
+ ui?: ITourUiOverride;
49
+ command?: ITourCommandOverride;
50
+ }
51
+
52
+ function clamp(value: string, limit: number): string {
53
+ return value.length <= limit ? value : value.slice(0, limit);
54
+ }
55
+
56
+ /**
57
+ * Apply admin-supplied overrides to the canonical step list. Returns a
58
+ * new array; the input is not mutated.
59
+ *
60
+ * Override semantics:
61
+ * - `enabled: false` drops the step entirely (filtered out here, in
62
+ * addition to whatever `requires()` says).
63
+ * - `title` / `description` replace the corresponding fields when set.
64
+ * - For `launcher-tiles`, the dynamic thunk continues to run; the
65
+ * templates feed into it through the per-step override dict so the
66
+ * thunk can use them at resolve time.
67
+ */
68
+ export function applyTourOverrides(
69
+ steps: readonly ITourStep[],
70
+ overrides: ITourOverrides | undefined
71
+ ): ITourStep[] {
72
+ if (!overrides || !overrides.steps) {
73
+ return steps.map(step => ({ ...step }));
74
+ }
75
+ const stepOverrides = overrides.steps;
76
+ const result: ITourStep[] = [];
77
+ for (const step of steps) {
78
+ const override = stepOverrides[step.id];
79
+ if (!override) {
80
+ result.push({ ...step });
81
+ continue;
82
+ }
83
+ if (override.enabled === false) {
84
+ continue;
85
+ }
86
+ const next: ITourStep = { ...step };
87
+ // Reject empty strings: an empty title would leave the dialog's
88
+ // aria-labelledby target with no accessible name, and an empty
89
+ // description would leave aria-describedby pointing at nothing.
90
+ if (typeof override.title === 'string' && override.title.length > 0) {
91
+ next.title = clamp(override.title, MAX_TITLE_CHARS);
92
+ }
93
+ if (
94
+ typeof override.description === 'string' &&
95
+ override.description.length > 0 &&
96
+ // The launcher-tiles thunk is overridden via templates, not the
97
+ // plain `description` field; the backend validator rejects it
98
+ // outright, but ignore it here too in case an older payload slips
99
+ // through.
100
+ step.id !== 'launcher-tiles'
101
+ ) {
102
+ next.description = clamp(override.description, MAX_DESCRIPTION_CHARS);
103
+ }
104
+ result.push(next);
105
+ }
106
+ return result;
107
+ }
108
+
109
+ /**
110
+ * Pull the per-step override for the launcher-tiles step's templates
111
+ * (if any). Used by the launcher-tiles description thunk.
112
+ */
113
+ export function launcherTileTemplates(overrides: ITourOverrides | undefined): {
114
+ singular?: string;
115
+ plural?: string;
116
+ } {
117
+ const step = overrides?.steps?.['launcher-tiles'];
118
+ if (!step) {
119
+ return {};
120
+ }
121
+ const result: { singular?: string; plural?: string } = {};
122
+ if (typeof step.description_singular === 'string') {
123
+ result.singular = clamp(step.description_singular, MAX_DESCRIPTION_CHARS);
124
+ }
125
+ if (typeof step.description_plural === 'string') {
126
+ result.plural = clamp(step.description_plural, MAX_DESCRIPTION_CHARS);
127
+ }
128
+ return result;
129
+ }
130
+
131
+ /**
132
+ * Resolve a UI button label: admin override if set, otherwise the
133
+ * default supplied by the caller.
134
+ */
135
+ export function uiLabel(
136
+ overrides: ITourOverrides | undefined,
137
+ key: keyof ITourUiOverride,
138
+ fallback: string
139
+ ): string {
140
+ const raw = overrides?.ui?.[key];
141
+ if (typeof raw === 'string' && raw.length > 0) {
142
+ return clamp(raw, MAX_BUTTON_LABEL_CHARS);
143
+ }
144
+ return fallback;
145
+ }
146
+
147
+ /**
148
+ * Resolve the command-palette label for the tour command: admin
149
+ * override if set, otherwise the default supplied by the caller.
150
+ */
151
+ export function commandLabel(
152
+ overrides: ITourOverrides | undefined,
153
+ fallback: string
154
+ ): string {
155
+ const raw = overrides?.command?.label;
156
+ if (typeof raw === 'string' && raw.length > 0) {
157
+ return clamp(raw, MAX_COMMAND_LABEL_CHARS);
158
+ }
159
+ return fallback;
160
+ }
@@ -0,0 +1,34 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+
3
+ /**
4
+ * Decoupled trigger channel for the first-run tour.
5
+ *
6
+ * The JupyterLab command (`notebook-intelligence:show-tour`) is
7
+ * registered in `src/index.ts` so it shows up in the command palette
8
+ * and can be invoked from anywhere. The tour itself is rendered inside
9
+ * the chat sidebar (`src/chat-sidebar.tsx`) so it can access React
10
+ * state cleanly. Wiring those two together via DOM CustomEvents keeps
11
+ * index.ts free of any direct sidebar import and lets the tour be
12
+ * triggered from any future surface (a Settings dialog entry, a Help
13
+ * menu, a banner) by dispatching the same event.
14
+ *
15
+ * Dispatched on `document` to match the existing `copilotSidebar:*`
16
+ * channel convention elsewhere in this codebase.
17
+ */
18
+
19
+ export const TOUR_START_EVENT = 'nbi:show-tour';
20
+ export const TOUR_STOP_EVENT = 'nbi:hide-tour';
21
+
22
+ export function dispatchShowTour(): void {
23
+ if (typeof document === 'undefined') {
24
+ return;
25
+ }
26
+ document.dispatchEvent(new CustomEvent(TOUR_START_EVENT));
27
+ }
28
+
29
+ export function dispatchHideTour(): void {
30
+ if (typeof document === 'undefined') {
31
+ return;
32
+ }
33
+ document.dispatchEvent(new CustomEvent(TOUR_STOP_EVENT));
34
+ }
@@ -0,0 +1,474 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+
3
+ import React, {
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState
9
+ } from 'react';
10
+ import { createPortal } from 'react-dom';
11
+
12
+ import {
13
+ TourPlacement,
14
+ IResolvedTourStep,
15
+ activeTourSteps
16
+ } from './tour-steps';
17
+ import { markTourCompleted } from './tour-state';
18
+ import { NBIAPI } from '../api';
19
+ import { uiLabel } from './tour-config';
20
+
21
+ const TOOLTIP_GAP_PX = 12;
22
+ const TOOLTIP_WIDTH_PX = 320;
23
+ // Fallback used on the first render before the tooltip is measured.
24
+ // Chosen to be larger than the typical rendered height so the initial
25
+ // clamp errs toward staying on-screen rather than overshooting.
26
+ const TOOLTIP_HEIGHT_FALLBACK_PX = 200;
27
+
28
+ /**
29
+ * Compute the on-screen position of the tooltip given the anchor's
30
+ * bounding rect, the requested placement, and the tooltip's measured
31
+ * height. The clamp keeps the box fully on-screen even when the anchor
32
+ * sits near a viewport edge.
33
+ *
34
+ * Placement semantics: 'top' puts the tooltip above the anchor (its
35
+ * bottom edge gap-px above anchor.top), 'bottom' puts it below, 'left'
36
+ * to the left, 'right' to the right. The previous version omitted
37
+ * tooltip width/height from the top/left placements, which let the box
38
+ * overlap the anchor instead of sitting outside it.
39
+ */
40
+ function computeTooltipPosition(
41
+ anchorRect: DOMRect,
42
+ placement: Exclude<TourPlacement, 'center'>,
43
+ tooltipHeight: number
44
+ ): { top: number; left: number } {
45
+ let top = 0;
46
+ let left = 0;
47
+ switch (placement) {
48
+ case 'top':
49
+ top = anchorRect.top - TOOLTIP_GAP_PX - tooltipHeight;
50
+ left = anchorRect.left + anchorRect.width / 2 - TOOLTIP_WIDTH_PX / 2;
51
+ break;
52
+ case 'bottom':
53
+ top = anchorRect.bottom + TOOLTIP_GAP_PX;
54
+ left = anchorRect.left + anchorRect.width / 2 - TOOLTIP_WIDTH_PX / 2;
55
+ break;
56
+ case 'left':
57
+ top = anchorRect.top + anchorRect.height / 2 - tooltipHeight / 2;
58
+ left = anchorRect.left - TOOLTIP_GAP_PX - TOOLTIP_WIDTH_PX;
59
+ break;
60
+ case 'right':
61
+ top = anchorRect.top + anchorRect.height / 2 - tooltipHeight / 2;
62
+ left = anchorRect.right + TOOLTIP_GAP_PX;
63
+ break;
64
+ }
65
+ // Clamp inside the viewport with an 8px margin so the tooltip never
66
+ // disappears off-screen near the edges. The vertical clamp accounts
67
+ // for tooltip height so the bottom edge stays visible even when the
68
+ // anchor is near the bottom of the viewport.
69
+ const margin = 8;
70
+ left = Math.max(
71
+ margin,
72
+ Math.min(left, window.innerWidth - TOOLTIP_WIDTH_PX - margin)
73
+ );
74
+ top = Math.max(
75
+ margin,
76
+ Math.min(top, window.innerHeight - tooltipHeight - margin)
77
+ );
78
+ return { top, left };
79
+ }
80
+
81
+ function findAnchor(anchorId: string | null): HTMLElement | null {
82
+ if (!anchorId) {
83
+ return null;
84
+ }
85
+ return document.querySelector<HTMLElement>(`[data-tour-id="${anchorId}"]`);
86
+ }
87
+
88
+ interface ITourOverlayProps {
89
+ // Render-control hooks. The parent owns the visibility decision so
90
+ // the overlay stays a pure presentation component.
91
+ onClose: () => void;
92
+ }
93
+
94
+ export function TourOverlay(props: ITourOverlayProps): JSX.Element | null {
95
+ // Compute the list of steps once. Capabilities can change at runtime
96
+ // (e.g. user switches Claude mode mid-tour) but recomputing the list
97
+ // mid-flight would change indices under the user; freeze on mount.
98
+ const steps = useMemo<IResolvedTourStep[]>(() => activeTourSteps(), []);
99
+ // Freeze the admin overrides snapshot at mount, alongside `steps`, so
100
+ // a config push mid-tour doesn't relabel the active overlay's buttons.
101
+ const labels = useMemo(() => {
102
+ const overrides = NBIAPI.config.tourOverrides;
103
+ return {
104
+ skip: uiLabel(overrides, 'skip', 'Skip tour'),
105
+ next: uiLabel(overrides, 'next', 'Next'),
106
+ back: uiLabel(overrides, 'back', 'Back'),
107
+ done: uiLabel(overrides, 'done', 'Done')
108
+ };
109
+ }, []);
110
+ const [index, setIndex] = useState(0);
111
+ const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
112
+ const [tooltipHeight, setTooltipHeight] = useState<number>(
113
+ TOOLTIP_HEIGHT_FALLBACK_PX
114
+ );
115
+ const tooltipRef = useRef<HTMLDivElement | null>(null);
116
+ const rootRef = useRef<HTMLDivElement | null>(null);
117
+ const previouslyFocusedRef = useRef<HTMLElement | null>(null);
118
+
119
+ const step = steps[index];
120
+
121
+ // Save focus on mount, restore on unmount. Pairs with the focus trap
122
+ // below so the tour leaves focus where it found it (e.g. the prompt
123
+ // textarea) instead of dumping the user to document.body.
124
+ useEffect(() => {
125
+ previouslyFocusedRef.current =
126
+ (document.activeElement as HTMLElement | null) ?? null;
127
+ return () => {
128
+ const prev = previouslyFocusedRef.current;
129
+ if (prev && document.contains(prev) && typeof prev.focus === 'function') {
130
+ prev.focus();
131
+ }
132
+ };
133
+ }, []);
134
+
135
+ // Measure the tooltip *before* paint so the next frame clamps its
136
+ // position against the actual rendered height. useLayoutEffect (not
137
+ // useEffect) prevents the user seeing a frame at the fallback height.
138
+ // Re-measure only on step change so a no-op render doesn't pay the
139
+ // getBoundingClientRect tax.
140
+ useLayoutEffect(() => {
141
+ if (!tooltipRef.current) {
142
+ return;
143
+ }
144
+ const h = tooltipRef.current.getBoundingClientRect().height;
145
+ if (h && Math.abs(h - tooltipHeight) > 0.5) {
146
+ setTooltipHeight(h);
147
+ }
148
+ // tooltipHeight intentionally omitted from deps: this effect only
149
+ // re-runs on step change and the > 0.5px guard already prevents
150
+ // infinite loops if measurement converges.
151
+ }, [step?.id, index]);
152
+
153
+ // Resolve the anchor synchronously before the next paint. If the
154
+ // anchor isn't in the DOM yet (parent React tree may not have
155
+ // committed), retry across a handful of animation frames before
156
+ // giving up; missing anchors auto-advance so the user doesn't land
157
+ // on a blank step. useLayoutEffect on the synchronous path
158
+ // eliminates the one-frame "wrong position" flash on step change.
159
+ useLayoutEffect(() => {
160
+ if (!step) {
161
+ return;
162
+ }
163
+ if (!step.anchorId) {
164
+ setAnchorRect(null);
165
+ return;
166
+ }
167
+ // Clear the stale rect from the previous step immediately so a
168
+ // late paint never shows the spotlight in the old position.
169
+ setAnchorRect(null);
170
+
171
+ let rafId = 0;
172
+ let attempts = 0;
173
+ const MAX_ATTEMPTS = 10;
174
+ let removeListeners = () => {};
175
+
176
+ const tryResolve = () => {
177
+ const anchor = findAnchor(step.anchorId);
178
+ if (anchor) {
179
+ const updateRect = () => setAnchorRect(anchor.getBoundingClientRect());
180
+ updateRect();
181
+ window.addEventListener('resize', updateRect);
182
+ window.addEventListener('scroll', updateRect, true);
183
+ removeListeners = () => {
184
+ window.removeEventListener('resize', updateRect);
185
+ window.removeEventListener('scroll', updateRect, true);
186
+ };
187
+ return;
188
+ }
189
+ attempts += 1;
190
+ if (attempts >= MAX_ATTEMPTS) {
191
+ // Genuinely missing; auto-advance so the user doesn't land on a
192
+ // blank step.
193
+ setIndex(i => i + 1);
194
+ return;
195
+ }
196
+ rafId = requestAnimationFrame(tryResolve);
197
+ };
198
+ // Attempt the first resolve synchronously so a happy path step
199
+ // change paints in the right place on the very next frame.
200
+ tryResolve();
201
+
202
+ return () => {
203
+ cancelAnimationFrame(rafId);
204
+ removeListeners();
205
+ };
206
+ }, [step?.anchorId, index]);
207
+
208
+ // Stable callback refs so the keydown handler can stay bound for the
209
+ // overlay's lifetime without re-binding per step. Closing over `index`
210
+ // directly would make the deps load-bearing in a non-obvious way.
211
+ const advanceRef = useRef(() => {});
212
+ const backRef = useRef(() => {});
213
+ const finishRef = useRef(() => {});
214
+
215
+ // Keyboard nav: Esc dismisses, Enter / Right advance, Left back.
216
+ // Tab/Shift-Tab are trapped inside the dialog (paired with
217
+ // aria-modal="true" so screen readers' modal semantics match reality).
218
+ // Ignore key presses targeted at editable elements so the prompt
219
+ // textarea's history-nav arrow keys still work if focus somehow
220
+ // landed there before the tour mounted.
221
+ useEffect(() => {
222
+ const handler = (e: KeyboardEvent) => {
223
+ const target = e.target as HTMLElement | null;
224
+ const inEditable =
225
+ !!target &&
226
+ (target.tagName === 'INPUT' ||
227
+ target.tagName === 'TEXTAREA' ||
228
+ target.isContentEditable);
229
+ // Tab/Shift-Tab focus trap is enforced even when target is an
230
+ // editable element so focus can't escape the dialog through the
231
+ // textarea below it.
232
+ if (e.key === 'Tab') {
233
+ const root = rootRef.current;
234
+ if (!root) {
235
+ return;
236
+ }
237
+ const focusables = Array.from(
238
+ root.querySelectorAll<HTMLElement>(
239
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
240
+ )
241
+ ).filter(el => !el.hasAttribute('disabled'));
242
+ if (focusables.length === 0) {
243
+ return;
244
+ }
245
+ const first = focusables[0];
246
+ const last = focusables[focusables.length - 1];
247
+ const active = document.activeElement as HTMLElement | null;
248
+ // If focus is outside the dialog entirely (e.g. clicked through
249
+ // the scrim somehow), pull it back to the first focusable.
250
+ if (!active || !root.contains(active)) {
251
+ e.preventDefault();
252
+ first.focus();
253
+ return;
254
+ }
255
+ if (e.shiftKey && active === first) {
256
+ e.preventDefault();
257
+ last.focus();
258
+ } else if (!e.shiftKey && active === last) {
259
+ e.preventDefault();
260
+ first.focus();
261
+ }
262
+ return;
263
+ }
264
+ if (inEditable) {
265
+ return;
266
+ }
267
+ if (e.key === 'Escape') {
268
+ e.preventDefault();
269
+ finishRef.current();
270
+ } else if (e.key === 'ArrowRight' || e.key === 'Enter') {
271
+ e.preventDefault();
272
+ advanceRef.current();
273
+ } else if (e.key === 'ArrowLeft') {
274
+ e.preventDefault();
275
+ backRef.current();
276
+ }
277
+ };
278
+ window.addEventListener('keydown', handler);
279
+ return () => window.removeEventListener('keydown', handler);
280
+ }, []);
281
+
282
+ // If we run out of steps (e.g. every anchor disappeared) close the
283
+ // tour on the next tick rather than calling setState inside the
284
+ // render below.
285
+ useEffect(() => {
286
+ if (!step) {
287
+ finish();
288
+ }
289
+ }, [step]);
290
+
291
+ const advance = () => {
292
+ if (index >= steps.length - 1) {
293
+ finish();
294
+ return;
295
+ }
296
+ setIndex(i => i + 1);
297
+ };
298
+
299
+ const back = () => {
300
+ if (index > 0) {
301
+ setIndex(i => i - 1);
302
+ }
303
+ };
304
+
305
+ const finish = () => {
306
+ markTourCompleted();
307
+ props.onClose();
308
+ };
309
+
310
+ // Refresh the callback refs every render so the keydown listener
311
+ // (bound once) always reads the latest closures.
312
+ advanceRef.current = advance;
313
+ backRef.current = back;
314
+ finishRef.current = finish;
315
+
316
+ if (!step) {
317
+ // No step to render right now. The cleanup effect above will fire
318
+ // `finish()` on the next tick; in the meantime return null so
319
+ // React doesn't see a setState inside this render path.
320
+ return null;
321
+ }
322
+
323
+ const isCenter = step.placement === 'center' || !step.anchorId;
324
+ // Anchored steps are "ready" once we have measured the anchor for
325
+ // *this* step. While unmeasured we keep the dialog mounted but
326
+ // visually hidden so the user doesn't see a flash at the centered
327
+ // fallback position before the real position lands.
328
+ const measured = isCenter || anchorRect !== null;
329
+ const tooltipStyle: React.CSSProperties = isCenter
330
+ ? {
331
+ top: '50%',
332
+ left: '50%',
333
+ transform: 'translate(-50%, -50%)',
334
+ width: TOOLTIP_WIDTH_PX
335
+ }
336
+ : (() => {
337
+ // Fall back to a zero-size rect at the viewport center when the
338
+ // anchor hasn't been measured yet. `DOMRect` is the standard
339
+ // constructor in the browser; jsdom-only test environments
340
+ // sometimes lack it, so build a plain object that satisfies
341
+ // the fields `computeTooltipPosition` reads.
342
+ const rect: DOMRect =
343
+ anchorRect ??
344
+ ({
345
+ top: window.innerHeight / 2,
346
+ left: window.innerWidth / 2,
347
+ right: window.innerWidth / 2,
348
+ bottom: window.innerHeight / 2,
349
+ width: 0,
350
+ height: 0,
351
+ x: window.innerWidth / 2,
352
+ y: window.innerHeight / 2,
353
+ toJSON: () => ({})
354
+ } as DOMRect);
355
+ // step.placement is guaranteed not to be 'center' here (isCenter
356
+ // would have been true otherwise); narrow for the type checker.
357
+ const placement = step.placement as Exclude<TourPlacement, 'center'>;
358
+ return {
359
+ ...computeTooltipPosition(rect, placement, tooltipHeight),
360
+ width: TOOLTIP_WIDTH_PX,
361
+ visibility: measured ? ('visible' as const) : ('hidden' as const)
362
+ };
363
+ })();
364
+
365
+ const spotlight = !isCenter && anchorRect && (
366
+ <div
367
+ className="nbi-tour-spotlight"
368
+ style={{
369
+ top: anchorRect.top - 4,
370
+ left: anchorRect.left - 4,
371
+ width: anchorRect.width + 8,
372
+ height: anchorRect.height + 8
373
+ }}
374
+ />
375
+ );
376
+
377
+ const isLast = index === steps.length - 1;
378
+ const progress = ((index + 1) / steps.length) * 100;
379
+
380
+ // Render into document.body via portal so the overlay can extend
381
+ // outside the sidebar's clip region and float over the rest of
382
+ // JupyterLab.
383
+ // ARIA: the dialog is modal-ish (covers the UI), so flag it as such.
384
+ // The label / description IDs change per step, but most screen
385
+ // readers don't re-announce when those attrs change without remount.
386
+ // The step body is wrapped in an aria-live="polite" region so each
387
+ // transition is announced as the content changes.
388
+ const titleId = `nbi-tour-title-${step.id}`;
389
+ const descId = `nbi-tour-desc-${step.id}`;
390
+ // Arrow direction is the inverse of placement (a tooltip placed
391
+ // ABOVE the anchor has its tail pointing DOWN). Center-placement
392
+ // tooltips have no anchor, so no arrow.
393
+ const arrowDirection = isCenter ? null : step.placement;
394
+ return createPortal(
395
+ <div
396
+ ref={rootRef}
397
+ className="nbi-tour-root"
398
+ role="dialog"
399
+ aria-modal="true"
400
+ aria-labelledby={titleId}
401
+ aria-describedby={descId}
402
+ >
403
+ {/* Scrim is non-dismissable on click: industry-standard tour UX.
404
+ Skip / Done / Esc are the explicit exits, so a misclick on
405
+ the dimmed area never abandons the tour. */}
406
+ <div className="nbi-tour-scrim" />
407
+ {spotlight}
408
+ <div
409
+ ref={tooltipRef}
410
+ className={`nbi-tour-tooltip${isCenter ? ' nbi-tour-tooltip-center' : ''}`}
411
+ style={tooltipStyle}
412
+ >
413
+ {arrowDirection && (
414
+ <div
415
+ className={`nbi-tour-arrow nbi-tour-arrow-${arrowDirection}`}
416
+ aria-hidden="true"
417
+ />
418
+ )}
419
+ <div
420
+ className="nbi-tour-progress"
421
+ role="progressbar"
422
+ aria-valuemin={1}
423
+ aria-valuemax={steps.length}
424
+ aria-valuenow={index + 1}
425
+ aria-label={`Tour progress: step ${index + 1} of ${steps.length}`}
426
+ >
427
+ <div
428
+ className="nbi-tour-progress-bar"
429
+ style={{ width: `${progress}%` }}
430
+ />
431
+ </div>
432
+ <div
433
+ className="nbi-tour-body"
434
+ aria-live="polite"
435
+ aria-atomic="true"
436
+ key={step.id}
437
+ >
438
+ <div className="nbi-tour-title" id={titleId}>
439
+ {step.title}
440
+ </div>
441
+ <div className="nbi-tour-description" id={descId}>
442
+ {step.description}
443
+ </div>
444
+ </div>
445
+ <div className="nbi-tour-actions">
446
+ <button type="button" className="nbi-tour-skip-link" onClick={finish}>
447
+ {labels.skip}
448
+ </button>
449
+ <div style={{ flexGrow: 1 }} />
450
+ {index > 0 && (
451
+ <button
452
+ type="button"
453
+ className="jp-Dialog-button jp-mod-reject jp-mod-styled"
454
+ onClick={back}
455
+ >
456
+ <div className="jp-Dialog-buttonLabel">{labels.back}</div>
457
+ </button>
458
+ )}
459
+ <button
460
+ type="button"
461
+ className="jp-Dialog-button jp-mod-accept jp-mod-styled"
462
+ onClick={advance}
463
+ autoFocus
464
+ >
465
+ <div className="jp-Dialog-buttonLabel">
466
+ {isLast ? labels.done : labels.next}
467
+ </div>
468
+ </button>
469
+ </div>
470
+ </div>
471
+ </div>,
472
+ document.body
473
+ );
474
+ }