@notis_ai/cli 0.2.0-beta.19.1 → 0.2.0-beta.196.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.
Files changed (216) hide show
  1. package/README.md +822 -135
  2. package/bin/check-runtime.js +15 -0
  3. package/bin/notis.js +2 -0
  4. package/config/notis_app_boundary_rules.json +50 -0
  5. package/config/notis_app_design_rules.json +135 -0
  6. package/config/portal-route-roots.json +48 -0
  7. package/dist/agent-hooks/notis-agent-hook.mjs +45587 -0
  8. package/dist/base-skills/notis-apps/SKILL.md +93 -0
  9. package/dist/base-skills/notis-apps/references/architecture.md +114 -0
  10. package/dist/base-skills/notis-apps/references/context.md +81 -0
  11. package/dist/base-skills/notis-apps/references/design.md +97 -0
  12. package/dist/base-skills/notis-apps/references/memory.md +58 -0
  13. package/dist/base-skills/notis-apps/references/reading.md +67 -0
  14. package/dist/base-skills/notis-apps/references/release.md +107 -0
  15. package/dist/base-skills/notis-apps/references/sdk.md +116 -0
  16. package/dist/base-skills/notis-apps/references/troubleshooting.md +24 -0
  17. package/dist/base-skills/notis-apps/references/views.md +142 -0
  18. package/dist/base-skills/notis-cli/SKILL.md +154 -0
  19. package/dist/base-skills/notis-cli/references/app-delivery.md +19 -0
  20. package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
  21. package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
  22. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  23. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  24. package/dist/base-skills/notis-query/SKILL.md +41 -0
  25. package/dist/base-skills/notis-query/references/database-discovery.md +60 -0
  26. package/dist/base-skills/notis-query/references/documents.md +63 -0
  27. package/dist/base-skills/notis-query/references/query.md +76 -0
  28. package/{template/packages/notis-sdk → dist/sdk}/package.json +14 -4
  29. package/dist/sdk/src/agentContext.ts +36 -0
  30. package/dist/sdk/src/bundleModule.ts +79 -0
  31. package/dist/sdk/src/components/Dialog.tsx +46 -0
  32. package/dist/sdk/src/components/DocumentEditor.tsx +106 -0
  33. package/dist/sdk/src/components/DocumentPage.tsx +141 -0
  34. package/dist/sdk/src/components/Markdown.tsx +60 -0
  35. package/dist/sdk/src/components/MarkdownEditor.tsx +121 -0
  36. package/dist/sdk/src/components/MultiSelectActionBar.tsx +285 -0
  37. package/dist/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
  38. package/dist/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  39. package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  40. package/dist/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
  41. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  42. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  43. package/dist/sdk/src/components/ShortcutHints.tsx +56 -0
  44. package/dist/sdk/src/components/Skeleton.tsx +24 -0
  45. package/dist/sdk/src/config.ts +239 -0
  46. package/dist/sdk/src/documents.ts +258 -0
  47. package/dist/sdk/src/hooks/useAction.ts +28 -0
  48. package/dist/sdk/src/hooks/useActiveResource.ts +19 -0
  49. package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
  50. package/dist/sdk/src/hooks/useCloudComputer.ts +95 -0
  51. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  52. package/dist/sdk/src/hooks/useCollectionInteractions.ts +838 -0
  53. package/dist/sdk/src/hooks/useDatabaseSchema.ts +49 -0
  54. package/dist/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  55. package/dist/sdk/src/hooks/useDocument.ts +43 -0
  56. package/dist/sdk/src/hooks/useDocuments.ts +84 -0
  57. package/dist/sdk/src/hooks/useHandover.ts +78 -0
  58. package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
  59. package/dist/sdk/src/hooks/useMultiSelect.ts +95 -0
  60. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  61. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  62. package/dist/sdk/src/hooks/useNotis.ts +42 -0
  63. package/dist/sdk/src/hooks/useNotisNavigation.ts +67 -0
  64. package/dist/sdk/src/hooks/useQuery.ts +74 -0
  65. package/dist/sdk/src/hooks/useTool.ts +65 -0
  66. package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
  67. package/dist/sdk/src/hooks/useTopBarSearch.ts +81 -0
  68. package/dist/sdk/src/hooks/useUpsertDocument.ts +95 -0
  69. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  70. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  71. package/dist/sdk/src/index.ts +200 -0
  72. package/dist/sdk/src/interactions/actions.ts +59 -0
  73. package/dist/sdk/src/interactions/shortcuts.tsx +740 -0
  74. package/dist/sdk/src/interactions/visibility.ts +13 -0
  75. package/dist/sdk/src/interactions.ts +45 -0
  76. package/dist/sdk/src/nativeData.ts +39 -0
  77. package/dist/sdk/src/presentation.tsx +82 -0
  78. package/dist/sdk/src/provider.tsx +49 -0
  79. package/dist/sdk/src/queryCache.ts +172 -0
  80. package/dist/sdk/src/queryErrors.ts +51 -0
  81. package/dist/sdk/src/runtime.ts +603 -0
  82. package/dist/sdk/src/space.ts +425 -0
  83. package/dist/sdk/src/styles.css +290 -0
  84. package/dist/sdk/src/tailwind.ts +66 -0
  85. package/dist/sdk/src/vite.ts +83 -0
  86. package/dist/skill-sync/index.js +2143 -0
  87. package/dist/skill-sync/index.js.map +7 -0
  88. package/dist/skill-sync-worker.mjs +3487 -0
  89. package/dist/space-harness/host.js +66 -0
  90. package/dist/view-renderer/dist/host.css +1 -0
  91. package/dist/view-renderer/dist/host.js +1042 -0
  92. package/dist/view-renderer/dist/theme.css +73 -0
  93. package/dist/view-renderer/src/dom.js +180 -0
  94. package/dist/view-renderer/src/errors.js +8 -0
  95. package/dist/view-renderer/src/host.tsx +173 -0
  96. package/dist/view-renderer/src/index.js +4 -0
  97. package/dist/view-renderer/src/pool.js +76 -0
  98. package/dist/view-renderer/src/remote-broker.js +59 -0
  99. package/dist/view-renderer/src/renderer.js +201 -0
  100. package/package.json +25 -8
  101. package/skills/notis-apps/cli.md +406 -0
  102. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  103. package/skills/notis-onboarding/BRIEF.md +133 -0
  104. package/skills/notis-query/cli.md +39 -0
  105. package/src/agent-hook-entry.js +5 -0
  106. package/src/cli.js +294 -25
  107. package/src/command-specs/agents.js +392 -0
  108. package/src/command-specs/apps.js +1476 -203
  109. package/src/command-specs/auth.js +114 -137
  110. package/src/command-specs/diagnostics.js +730 -0
  111. package/src/command-specs/files.js +29 -0
  112. package/src/command-specs/handover.js +374 -0
  113. package/src/command-specs/helpers.js +86 -82
  114. package/src/command-specs/index.js +31 -6
  115. package/src/command-specs/meta.js +150 -18
  116. package/src/command-specs/onboarding.js +290 -0
  117. package/src/command-specs/profile.js +358 -0
  118. package/src/command-specs/skills.js +304 -0
  119. package/src/command-specs/smoke.js +386 -0
  120. package/src/command-specs/spaces.js +572 -0
  121. package/src/command-specs/tools.js +455 -139
  122. package/src/command-specs/views.js +69 -0
  123. package/src/runtime/agent-browser.js +700 -0
  124. package/src/runtime/agent-memory-state.js +126 -0
  125. package/src/runtime/agent-setup.js +383 -0
  126. package/src/runtime/app-boundary-validator.js +407 -0
  127. package/src/runtime/app-changelog.js +79 -0
  128. package/src/runtime/app-data-literals.js +82 -0
  129. package/src/runtime/app-platform.js +2752 -237
  130. package/src/runtime/app-registry-scaffolds.js +367 -0
  131. package/src/runtime/app-test-server.js +293 -0
  132. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  133. package/src/runtime/auth-recovery.js +110 -0
  134. package/src/runtime/base-skills.d.ts +20 -0
  135. package/src/runtime/base-skills.js +167 -0
  136. package/src/runtime/channel.js +133 -0
  137. package/src/runtime/confined-space-build.js +74 -0
  138. package/src/runtime/confined-space-verify.js +49 -0
  139. package/src/runtime/delegated-context.js +68 -0
  140. package/src/runtime/errors.js +1 -0
  141. package/src/runtime/git.js +233 -0
  142. package/src/runtime/login-listener.js +15 -0
  143. package/src/runtime/native-record-files.js +47 -0
  144. package/src/runtime/oauth.js +2622 -0
  145. package/src/runtime/output.js +37 -5
  146. package/src/runtime/ports.js +31 -0
  147. package/src/runtime/profiles.js +906 -55
  148. package/src/runtime/retired-sdk-files.js +20 -0
  149. package/src/runtime/screenshot-framing.js +85 -0
  150. package/src/runtime/skill-sync/cloud-client.ts +121 -0
  151. package/src/runtime/skill-sync/index.ts +905 -0
  152. package/src/runtime/skill-sync/local-scanner.ts +1259 -0
  153. package/src/runtime/skill-sync/native-identity.ts +102 -0
  154. package/src/runtime/skill-sync/native-plan.ts +59 -0
  155. package/src/runtime/skill-sync/symlink-manager.ts +433 -0
  156. package/src/runtime/skill-sync/sync-plan.ts +54 -0
  157. package/src/runtime/skill-sync/types.ts +157 -0
  158. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  159. package/src/runtime/skill-sync-service.js +109 -0
  160. package/src/runtime/source-root.js +32 -0
  161. package/src/runtime/space-collection-scaffold.js +66 -0
  162. package/src/runtime/space-database-structures.js +229 -0
  163. package/src/runtime/space-fixture-broker.js +50 -0
  164. package/src/runtime/space-lock.js +337 -0
  165. package/src/runtime/space-platform.js +105 -0
  166. package/src/runtime/space-source.js +281 -0
  167. package/src/runtime/space-test-server.js +327 -0
  168. package/src/runtime/space-verification.js +89 -0
  169. package/src/runtime/space-verify-browser-worker.js +51 -0
  170. package/src/runtime/space-view-manifest.js +254 -0
  171. package/src/runtime/store-screenshot.js +146 -0
  172. package/src/runtime/sync-skills.d.ts +37 -0
  173. package/src/runtime/sync-skills.js +231 -0
  174. package/src/runtime/telemetry.js +92 -0
  175. package/src/runtime/transport.js +331 -45
  176. package/src/runtime/view-render.js +49 -0
  177. package/src/skill-sync-worker-entry.js +2 -0
  178. package/src/skill-sync-worker.js +50 -0
  179. package/src/space-harness-host.ts +103 -0
  180. package/template/.harness/index.html.tmpl +435 -0
  181. package/template/CHANGELOG.md +5 -0
  182. package/template/app/globals.css +29 -3
  183. package/template/app/layout.tsx +6 -3
  184. package/template/app/page.tsx +49 -42
  185. package/template/components/page-heading.tsx +23 -0
  186. package/template/components/ui/badge.tsx +7 -4
  187. package/template/components/ui/button.tsx +1 -1
  188. package/template/components/ui/card.tsx +24 -11
  189. package/template/components/ui/native-select.tsx +24 -0
  190. package/template/notis.config.ts +24 -6
  191. package/template/package-lock.json +3642 -0
  192. package/template/package.json +19 -16
  193. package/template/postcss.config.mjs +1 -1
  194. package/template/tailwind.config.ts +1 -6
  195. package/template/tsconfig.json +1 -0
  196. package/src/command-specs/db.js +0 -163
  197. package/src/runtime/app-preview-server.js +0 -312
  198. package/template/packages/notis-sdk/src/config.ts +0 -48
  199. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  200. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  201. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  202. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  203. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  204. package/template/packages/notis-sdk/src/hooks/useNotis.ts +0 -31
  205. package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +0 -49
  206. package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
  207. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
  208. package/template/packages/notis-sdk/src/index.ts +0 -47
  209. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  210. package/template/packages/notis-sdk/src/runtime.ts +0 -159
  211. package/template/packages/notis-sdk/src/styles.css +0 -123
  212. package/template/packages/notis-sdk/src/vite.ts +0 -54
  213. package/template/packages/notis-sdk/tsconfig.json +0 -15
  214. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useBackend.ts +0 -0
  215. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useTools.ts +0 -0
  216. /package/{template/packages/notis-sdk → dist/sdk}/src/ui.ts +0 -0
@@ -0,0 +1,740 @@
1
+ 'use client';
2
+
3
+ import React, {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ type ReactNode,
13
+ } from 'react';
14
+ import { isInteractionElementVisible } from './visibility';
15
+
16
+ export type ShortcutScope = 'app' | 'route' | 'collection' | 'detail' | 'modal';
17
+
18
+ export const SHORTCUT_SCOPE_PRIORITY: Record<ShortcutScope, number> = {
19
+ app: 0,
20
+ route: 100,
21
+ collection: 200,
22
+ detail: 300,
23
+ modal: 400,
24
+ };
25
+
26
+ export interface ShortcutDefinition {
27
+ id: string;
28
+ /** A chord (`Mod+A`) or timed sequence (`G I`). */
29
+ keys: string | string[];
30
+ label?: string;
31
+ enabled?: boolean;
32
+ allowInEditable?: boolean;
33
+ allowRepeat?: boolean;
34
+ preventDefault?: boolean;
35
+ stopPropagation?: boolean;
36
+ onTrigger: (event: KeyboardEvent) => void | Promise<void>;
37
+ }
38
+
39
+ export interface UseShortcutsOptions {
40
+ enabled?: boolean;
41
+ scope?: ShortcutScope;
42
+ /** Added to the scope priority for local precedence within a scope. */
43
+ priority?: number;
44
+ sequenceTimeoutMs?: number;
45
+ /** Internal owner used to route collection shortcuts to the last interacted collection. */
46
+ collectionOwnerId?: string;
47
+ /** Evaluated at dispatch time, including for retained hidden app views. */
48
+ isAvailable?: () => boolean;
49
+ }
50
+
51
+ interface ShortcutRegistration {
52
+ id: number;
53
+ order: number;
54
+ scope: ShortcutScope;
55
+ priority: number;
56
+ enabled: boolean;
57
+ sequenceTimeoutMs: number;
58
+ collectionOwnerId?: string;
59
+ /** Evaluated at dispatch time, including for retained hidden app views. */
60
+ isAvailable?: () => boolean;
61
+ getShortcuts: () => ShortcutDefinition[];
62
+ }
63
+
64
+ interface ShortcutRegistry {
65
+ register: (registration: Omit<ShortcutRegistration, 'id' | 'order'>) => () => void;
66
+ }
67
+
68
+ const SHORTCUT_CONTEXT_SYMBOL = Symbol.for('notis.sdk.shortcut_context');
69
+ type ShortcutContextGlobal = typeof globalThis & {
70
+ [SHORTCUT_CONTEXT_SYMBOL]?: React.Context<ShortcutRegistry | null>;
71
+ };
72
+
73
+ function getShortcutContext(): React.Context<ShortcutRegistry | null> {
74
+ const scope = globalThis as ShortcutContextGlobal;
75
+ if (!scope[SHORTCUT_CONTEXT_SYMBOL]) {
76
+ scope[SHORTCUT_CONTEXT_SYMBOL] = createContext<ShortcutRegistry | null>(null);
77
+ }
78
+ return scope[SHORTCUT_CONTEXT_SYMBOL]!;
79
+ }
80
+
81
+ const ShortcutContext = getShortcutContext();
82
+
83
+ const SHORTCUT_COLLECTION_STATE_SYMBOL = Symbol.for('notis.sdk.shortcut_collection_state');
84
+ type ShortcutCollectionGlobal = typeof globalThis & {
85
+ [SHORTCUT_COLLECTION_STATE_SYMBOL]?: { activeOwnerId: string | null; nextOwnerId: number };
86
+ };
87
+
88
+ function getShortcutCollectionState() {
89
+ const scope = globalThis as ShortcutCollectionGlobal;
90
+ if (!scope[SHORTCUT_COLLECTION_STATE_SYMBOL]) {
91
+ scope[SHORTCUT_COLLECTION_STATE_SYMBOL] = { activeOwnerId: null, nextOwnerId: 1 };
92
+ }
93
+ return scope[SHORTCUT_COLLECTION_STATE_SYMBOL]!;
94
+ }
95
+
96
+ export function createShortcutCollectionOwner(): string {
97
+ const state = getShortcutCollectionState();
98
+ return `collection-${state.nextOwnerId++}`;
99
+ }
100
+
101
+ export function releaseShortcutCollection(ownerId: string): void {
102
+ const state = getShortcutCollectionState();
103
+ if (state.activeOwnerId === ownerId) state.activeOwnerId = null;
104
+ }
105
+
106
+ export function activateShortcutCollection(ownerId: string): void {
107
+ getShortcutCollectionState().activeOwnerId = ownerId;
108
+ }
109
+
110
+ interface ParsedChord {
111
+ key: string;
112
+ mod: boolean;
113
+ shift: boolean;
114
+ alt: boolean;
115
+ }
116
+
117
+ interface ParsedShortcut {
118
+ definition: ShortcutDefinition;
119
+ sequence: ParsedChord[];
120
+ }
121
+
122
+ interface SequenceState {
123
+ chords: string[];
124
+ at: number;
125
+ }
126
+
127
+ function normalizeKey(key: string): string {
128
+ const lower = key.trim().toLowerCase();
129
+ if (lower === 'esc') return 'escape';
130
+ if (lower === 'return') return 'enter';
131
+ if (lower === 'space') return ' ';
132
+ if (lower === 'arrowup' || lower === 'up') return 'arrowup';
133
+ if (lower === 'arrowdown' || lower === 'down') return 'arrowdown';
134
+ if (lower === 'arrowleft' || lower === 'left') return 'arrowleft';
135
+ if (lower === 'arrowright' || lower === 'right') return 'arrowright';
136
+ return lower;
137
+ }
138
+
139
+ function parseChord(raw: string): ParsedChord | null {
140
+ const parts = raw.split('+').map((part) => part.trim()).filter(Boolean);
141
+ if (parts.length === 0) return null;
142
+ let key = '';
143
+ let mod = false;
144
+ let shift = false;
145
+ let alt = false;
146
+ for (const part of parts) {
147
+ const lower = part.toLowerCase();
148
+ if (lower === 'mod' || lower === 'cmd' || lower === 'ctrl' || lower === 'meta') {
149
+ mod = true;
150
+ } else if (lower === 'shift') {
151
+ shift = true;
152
+ } else if (lower === 'alt' || lower === 'option') {
153
+ alt = true;
154
+ } else {
155
+ key = normalizeKey(part);
156
+ }
157
+ }
158
+ return key ? { key, mod, shift, alt } : null;
159
+ }
160
+
161
+ function parseShortcut(definition: ShortcutDefinition): ParsedShortcut[] {
162
+ const values = Array.isArray(definition.keys) ? definition.keys : [definition.keys];
163
+ return values.flatMap((value) => {
164
+ const sequence = value
165
+ .trim()
166
+ .split(/\s+/)
167
+ .map(parseChord)
168
+ .filter((chord): chord is ParsedChord => chord !== null);
169
+ return sequence.length > 0 ? [{ definition, sequence }] : [];
170
+ });
171
+ }
172
+
173
+ function chordToken(chord: ParsedChord): string {
174
+ return `${chord.mod ? 'm' : '-'}${chord.shift ? 's' : '-'}${chord.alt ? 'a' : '-'}:${chord.key}`;
175
+ }
176
+
177
+ function eventChord(event: KeyboardEvent): ParsedChord {
178
+ let key = normalizeKey(event.key);
179
+ let shift = event.shiftKey;
180
+ if (key === '3' && shift) key = '#';
181
+ if (key === '#' || key === '?') shift = false;
182
+ return {
183
+ key,
184
+ mod: Boolean(event.metaKey || event.ctrlKey),
185
+ shift,
186
+ alt: event.altKey,
187
+ };
188
+ }
189
+
190
+ export function isEditableShortcutEvent(event: Event): boolean {
191
+ const path = event.composedPath?.() ?? [event.target];
192
+ return path.some((target) => {
193
+ if (!(target instanceof HTMLElement)) return false;
194
+ if (target.isContentEditable || target.getAttribute('contenteditable') === 'true') return true;
195
+ const tag = target.tagName;
196
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON' || tag === 'SUMMARY') {
197
+ return true;
198
+ }
199
+ if (tag === 'A' && target.hasAttribute('href')) return true;
200
+ // A focused separator (a resizable edge) owns its arrow keys.
201
+ return ['button', 'link', 'menuitem', 'separator', 'switch', 'tab'].includes(
202
+ target.getAttribute('role') || '',
203
+ );
204
+ });
205
+ }
206
+
207
+ function matchPrefix(tokens: string[], candidate: ParsedShortcut): boolean {
208
+ if (tokens.length > candidate.sequence.length) return false;
209
+ return tokens.every((token, index) => token === chordToken(candidate.sequence[index]!));
210
+ }
211
+
212
+ function consumeShortcut(event: KeyboardEvent, definition: ShortcutDefinition): void {
213
+ if (definition.preventDefault !== false) event.preventDefault();
214
+ if (definition.stopPropagation !== false) {
215
+ event.stopPropagation();
216
+ event.stopImmediatePropagation?.();
217
+ }
218
+ void definition.onTrigger(event);
219
+ }
220
+
221
+ // Apps built before collections stopped binding Escape to an empty selection
222
+ // still register it, which swallows Escape for nothing. With collection items
223
+ // rendered and none selected, leave Escape to the enclosing surface (the Portal
224
+ // closes its frontmost layer, then returns to Inbox).
225
+ function isEmptyCollectionClear(shortcut: ShortcutDefinition, ownerDocument: Document, event: KeyboardEvent): boolean {
226
+ if (event.key !== 'Escape' || shortcut.id !== 'collection.clear') return false;
227
+ const items = collectionItems(ownerDocument).filter((item) => isInteractionElementVisible(item as HTMLElement));
228
+ return items.length > 0 && !items.some((item) => item.getAttribute('aria-selected') === 'true');
229
+ }
230
+
231
+ /** App views render in open shadow roots, which document queries do not enter. */
232
+ function collectionItems(root: Document | ShadowRoot): Element[] {
233
+ const items = Array.from(root.querySelectorAll('[data-notis-collection-item-id], [data-notis-row-id]'));
234
+ for (const element of Array.from(root.querySelectorAll('*'))) {
235
+ if (element.shadowRoot) items.push(...collectionItems(element.shadowRoot));
236
+ }
237
+ return items;
238
+ }
239
+
240
+ // Native dialogs make the background inert, but document capture listeners still
241
+ // receive their keyboard events. Leave those events to the dialog and its controls.
242
+ function isDialogEvent(event: KeyboardEvent): boolean {
243
+ return event.composedPath().some((target) =>
244
+ target instanceof HTMLElement && target.matches('dialog.notis-dialog[open]'),
245
+ );
246
+ }
247
+
248
+ export function ShortcutProvider({ children, isAvailable }: { children: ReactNode; isAvailable?: () => boolean }) {
249
+ const parentRegistry = useContext(ShortcutContext);
250
+ if (parentRegistry) return <>{children}</>;
251
+ return <ShortcutProviderRoot isAvailable={isAvailable}>{children}</ShortcutProviderRoot>;
252
+ }
253
+
254
+ export interface ShortcutHelpEntry {
255
+ id: string;
256
+ keys: string[];
257
+ label: string;
258
+ scope: ShortcutScope;
259
+ }
260
+
261
+ const HELP_SCOPE_LABELS: Record<ShortcutScope, string> = {
262
+ modal: 'Dialog',
263
+ detail: 'Detail',
264
+ collection: 'Collection',
265
+ route: 'This view',
266
+ app: 'App',
267
+ };
268
+
269
+ const HELP_SCOPE_ORDER: ShortcutScope[] = ['modal', 'detail', 'collection', 'route', 'app'];
270
+
271
+ function applicableRegistrations(
272
+ registrations: Iterable<ShortcutRegistration>,
273
+ ): ShortcutRegistration[] {
274
+ const ordered = Array.from(registrations)
275
+ .filter((registration) => registration.enabled && registration.isAvailable?.() !== false)
276
+ .sort((a, b) => b.priority - a.priority || b.order - a.order);
277
+ const activeCollectionOwner = getShortcutCollectionState().activeOwnerId;
278
+ const hasActiveCollection = Boolean(
279
+ activeCollectionOwner
280
+ && ordered.some((registration) => registration.collectionOwnerId === activeCollectionOwner),
281
+ );
282
+ return hasActiveCollection
283
+ ? ordered.filter(
284
+ (registration) => !registration.collectionOwnerId
285
+ || registration.collectionOwnerId === activeCollectionOwner,
286
+ )
287
+ : ordered;
288
+ }
289
+
290
+ // Standalone app verification and legacy hosts may not install a provider.
291
+ // They must still arbitrate shortcuts together, not by effect/listener order.
292
+ const FALLBACK_REGISTRIES_SYMBOL = Symbol.for('notis.sdk.fallback_shortcut_registries');
293
+ interface FallbackShortcutRegistry {
294
+ registrations: Map<number, ShortcutRegistration>;
295
+ nextId: number;
296
+ dispatch: (event: KeyboardEvent) => void;
297
+ }
298
+ type FallbackShortcutGlobal = typeof globalThis & {
299
+ [FALLBACK_REGISTRIES_SYMBOL]?: WeakMap<Document, FallbackShortcutRegistry>;
300
+ };
301
+
302
+ function registerFallbackShortcut(input: Omit<ShortcutRegistration, 'id' | 'order'>): () => void {
303
+ const scope = globalThis as FallbackShortcutGlobal;
304
+ const registries = scope[FALLBACK_REGISTRIES_SYMBOL] ??= new WeakMap();
305
+ const ownerDocument = document;
306
+ let registry = registries.get(ownerDocument);
307
+ if (!registry) {
308
+ const registrations = new Map<number, ShortcutRegistration>();
309
+ registry = {
310
+ registrations,
311
+ nextId: 1,
312
+ dispatch: (event) => {
313
+ if (event.defaultPrevented || isDialogEvent(event)) return;
314
+ const editable = isEditableShortcutEvent(event);
315
+ const token = chordToken(eventChord(event));
316
+ for (const registration of applicableRegistrations(registrations.values())) {
317
+ for (const shortcut of registration.getShortcuts()) {
318
+ if (shortcut.enabled === false || (editable && !shortcut.allowInEditable) || (event.repeat && !shortcut.allowRepeat)
319
+ || isEmptyCollectionClear(shortcut, ownerDocument, event)) continue;
320
+ const match = parseShortcut(shortcut).some(
321
+ (candidate) => candidate.sequence.length === 1 && chordToken(candidate.sequence[0]!) === token,
322
+ );
323
+ if (match) {
324
+ consumeShortcut(event, shortcut);
325
+ return;
326
+ }
327
+ }
328
+ }
329
+ },
330
+ };
331
+ registries.set(ownerDocument, registry);
332
+ ownerDocument.addEventListener('keydown', registry.dispatch, true);
333
+ }
334
+ const id = registry.nextId++;
335
+ registry.registrations.set(id, { ...input, id, order: id });
336
+ return () => {
337
+ registry.registrations.delete(id);
338
+ if (registry.registrations.size === 0) {
339
+ ownerDocument.removeEventListener('keydown', registry.dispatch, true);
340
+ registries.delete(ownerDocument);
341
+ }
342
+ };
343
+ }
344
+
345
+ function shortcutToken(raw: string): string | null {
346
+ const definition: ShortcutDefinition = { id: '', keys: raw, onTrigger: () => undefined };
347
+ const parsed = parseShortcut(definition)[0];
348
+ return parsed ? parsed.sequence.map(chordToken).join(' ') : null;
349
+ }
350
+
351
+ function collectShortcutHelpEntries(
352
+ registrations: Iterable<ShortcutRegistration>,
353
+ ): ShortcutHelpEntry[] {
354
+ const claimed = new Set<string>();
355
+ const entries: ShortcutHelpEntry[] = [];
356
+ for (const registration of applicableRegistrations(registrations)) {
357
+ for (const shortcut of registration.getShortcuts()) {
358
+ if (shortcut.enabled === false || !shortcut.label) continue;
359
+ const values = Array.isArray(shortcut.keys) ? shortcut.keys : [shortcut.keys];
360
+ const keys = values.filter((value) => {
361
+ const token = shortcutToken(value);
362
+ if (!token || claimed.has(token)) return false;
363
+ claimed.add(token);
364
+ return true;
365
+ });
366
+ if (keys.length > 0) {
367
+ entries.push({ id: shortcut.id, keys, label: shortcut.label, scope: registration.scope });
368
+ }
369
+ }
370
+ }
371
+ return entries;
372
+ }
373
+
374
+ function ShortcutProviderRoot({ children, isAvailable }: { children: ReactNode; isAvailable?: () => boolean }) {
375
+ const registrationsRef = useRef(new Map<number, ShortcutRegistration>());
376
+ const nextIdRef = useRef(1);
377
+ const nextOrderRef = useRef(1);
378
+ const sequenceRef = useRef<SequenceState>({ chords: [], at: 0 });
379
+ const [helpOpen, setHelpOpen] = useState(false);
380
+ const helpOpenRef = useRef(false);
381
+ const [, setRegistryVersion] = useState(0);
382
+ const availabilityRef = useRef(isAvailable);
383
+
384
+ const updateHelpOpen = useCallback((open: boolean) => {
385
+ helpOpenRef.current = open;
386
+ setHelpOpen(open);
387
+ }, []);
388
+
389
+ useLayoutEffect(() => {
390
+ availabilityRef.current = isAvailable;
391
+ if (isAvailable?.() === false) {
392
+ sequenceRef.current = { chords: [], at: 0 };
393
+ updateHelpOpen(false);
394
+ }
395
+ }, [isAvailable, updateHelpOpen]);
396
+
397
+ const register = useCallback((input: Omit<ShortcutRegistration, 'id' | 'order'>) => {
398
+ const id = nextIdRef.current++;
399
+ registrationsRef.current.set(id, {
400
+ ...input,
401
+ id,
402
+ order: nextOrderRef.current++,
403
+ });
404
+ if (helpOpenRef.current) setRegistryVersion((version) => version + 1);
405
+ return () => {
406
+ registrationsRef.current.delete(id);
407
+ if (helpOpenRef.current) setRegistryVersion((version) => version + 1);
408
+ };
409
+ }, []);
410
+
411
+ useEffect(() => {
412
+ const handleKeyDown = (event: KeyboardEvent) => {
413
+ if (availabilityRef.current?.() === false) {
414
+ sequenceRef.current = { chords: [], at: 0 };
415
+ return;
416
+ }
417
+ if (event.defaultPrevented || isDialogEvent(event)) return;
418
+ const editable = isEditableShortcutEvent(event);
419
+ if (!editable && !event.repeat && event.key === '?' && !event.metaKey && !event.ctrlKey && !event.altKey) {
420
+ event.preventDefault();
421
+ event.stopPropagation();
422
+ event.stopImmediatePropagation?.();
423
+ updateHelpOpen(!helpOpenRef.current);
424
+ return;
425
+ }
426
+ if (helpOpenRef.current) {
427
+ if (event.key === 'Escape') {
428
+ event.preventDefault();
429
+ event.stopPropagation();
430
+ event.stopImmediatePropagation?.();
431
+ updateHelpOpen(false);
432
+ }
433
+ return;
434
+ }
435
+
436
+ const registrations = applicableRegistrations(registrationsRef.current.values());
437
+ if (registrations.length === 0) return;
438
+ const token = chordToken(eventChord(event));
439
+ const now = Date.now();
440
+
441
+ for (const registration of registrations) {
442
+ const candidates = registration.getShortcuts()
443
+ .filter((shortcut) => shortcut.enabled !== false)
444
+ .filter((shortcut) => !editable || shortcut.allowInEditable)
445
+ .filter((shortcut) => !event.repeat || shortcut.allowRepeat)
446
+ .filter((shortcut) => !isEmptyCollectionClear(shortcut, document, event))
447
+ .flatMap(parseShortcut);
448
+ if (candidates.length === 0) continue;
449
+
450
+ const previous = now - sequenceRef.current.at <= registration.sequenceTimeoutMs
451
+ ? sequenceRef.current.chords
452
+ : [];
453
+ const attempts = previous.length > 0 ? [[...previous, token], [token]] : [[token]];
454
+
455
+ for (const attempt of attempts) {
456
+ const matches = candidates.filter((candidate) => matchPrefix(attempt, candidate));
457
+ if (matches.length === 0) continue;
458
+ const exact = matches.find((candidate) => candidate.sequence.length === attempt.length);
459
+ const hasLonger = matches.some((candidate) => candidate.sequence.length > attempt.length);
460
+ if (exact && !hasLonger) {
461
+ sequenceRef.current = { chords: [], at: 0 };
462
+ consumeShortcut(event, exact.definition);
463
+ return;
464
+ }
465
+ sequenceRef.current = { chords: attempt, at: now };
466
+ event.preventDefault();
467
+ return;
468
+ }
469
+ }
470
+
471
+ sequenceRef.current = { chords: [], at: 0 };
472
+ };
473
+
474
+ document.addEventListener('keydown', handleKeyDown, true);
475
+ return () => document.removeEventListener('keydown', handleKeyDown, true);
476
+ }, [updateHelpOpen]);
477
+
478
+ const registry = useMemo<ShortcutRegistry>(() => ({ register }), [register]);
479
+ const helpEntries = helpOpen ? collectShortcutHelpEntries(registrationsRef.current.values()) : [];
480
+ return (
481
+ <ShortcutContext.Provider value={registry}>
482
+ {children}
483
+ {helpOpen ? <ShortcutHelpDialog entries={helpEntries} onClose={() => updateHelpOpen(false)} /> : null}
484
+ </ShortcutContext.Provider>
485
+ );
486
+ }
487
+
488
+ function ShortcutHelpDialog({
489
+ entries,
490
+ onClose,
491
+ }: {
492
+ entries: ShortcutHelpEntry[];
493
+ onClose: () => void;
494
+ }) {
495
+ const closeRef = useRef<HTMLButtonElement>(null);
496
+ const previousFocusRef = useRef<HTMLElement | null>(
497
+ typeof document !== 'undefined' && document.activeElement instanceof HTMLElement
498
+ ? document.activeElement
499
+ : null,
500
+ );
501
+ useEffect(() => {
502
+ closeRef.current?.focus({ preventScroll: true });
503
+ return () => previousFocusRef.current?.focus({ preventScroll: true });
504
+ }, []);
505
+ const grouped = HELP_SCOPE_ORDER.map((scope) => ({
506
+ scope,
507
+ entries: entries.filter((entry) => entry.scope === scope),
508
+ })).filter((group) => group.entries.length > 0);
509
+
510
+ return (
511
+ <div
512
+ role="presentation"
513
+ data-notis-shortcut-help-backdrop=""
514
+ onMouseDown={(event) => {
515
+ if (event.target === event.currentTarget) onClose();
516
+ }}
517
+ style={helpBackdropStyle}
518
+ >
519
+ <section
520
+ role="dialog"
521
+ aria-modal="true"
522
+ aria-labelledby="notis-shortcut-help-title"
523
+ data-notis-shortcut-help=""
524
+ onKeyDown={(event) => {
525
+ // The help surface has one interactive control. Keep keyboard focus
526
+ // in the modal instead of allowing Tab to escape into app chrome.
527
+ if (event.key === 'Tab') {
528
+ event.preventDefault();
529
+ closeRef.current?.focus({ preventScroll: true });
530
+ }
531
+ }}
532
+ style={helpDialogStyle}
533
+ >
534
+ <header style={helpHeaderStyle}>
535
+ <div>
536
+ <h2 id="notis-shortcut-help-title" style={helpTitleStyle}>Keyboard shortcuts</h2>
537
+ <p style={helpSubtitleStyle}>Available in this view</p>
538
+ </div>
539
+ <button
540
+ ref={closeRef}
541
+ type="button"
542
+ aria-label="Close keyboard shortcuts"
543
+ onClick={onClose}
544
+ style={helpCloseStyle}
545
+ >
546
+ Esc
547
+ </button>
548
+ </header>
549
+ <div style={helpBodyStyle}>
550
+ {grouped.map((group) => (
551
+ <section key={group.scope} aria-label={HELP_SCOPE_LABELS[group.scope]}>
552
+ <h3 style={helpGroupTitleStyle}>{HELP_SCOPE_LABELS[group.scope]}</h3>
553
+ <div style={helpListStyle}>
554
+ {group.entries.map((entry) => (
555
+ <div key={`${group.scope}:${entry.id}`} style={helpRowStyle}>
556
+ <span>{entry.label}</span>
557
+ <span style={helpKeysStyle}>
558
+ {entry.keys.map((keys) => (
559
+ <kbd key={keys} style={helpKeyStyle}>{shortcutDisplay(keys)}</kbd>
560
+ ))}
561
+ </span>
562
+ </div>
563
+ ))}
564
+ </div>
565
+ </section>
566
+ ))}
567
+ <section aria-label="Help">
568
+ <h3 style={helpGroupTitleStyle}>Help</h3>
569
+ <div style={helpListStyle}>
570
+ <div style={helpRowStyle}>
571
+ <span>Show keyboard shortcuts</span>
572
+ <kbd style={helpKeyStyle}>?</kbd>
573
+ </div>
574
+ </div>
575
+ </section>
576
+ </div>
577
+ </section>
578
+ </div>
579
+ );
580
+ }
581
+
582
+ const helpBackdropStyle: React.CSSProperties = {
583
+ position: 'fixed',
584
+ inset: 0,
585
+ zIndex: 1000,
586
+ display: 'grid',
587
+ placeItems: 'center',
588
+ padding: '1.5rem',
589
+ background: 'rgba(0, 0, 0, 0.42)',
590
+ backdropFilter: 'blur(3px)',
591
+ };
592
+
593
+ const helpDialogStyle: React.CSSProperties = {
594
+ width: 'min(34rem, 100%)',
595
+ maxHeight: 'min(42rem, calc(100dvh - 3rem))',
596
+ overflow: 'hidden',
597
+ border: '1px solid hsl(var(--border))',
598
+ borderRadius: '0.875rem',
599
+ background: 'hsl(var(--background))',
600
+ color: 'hsl(var(--foreground))',
601
+ boxShadow: '0 24px 70px rgba(0, 0, 0, 0.28)',
602
+ fontFamily: 'inherit',
603
+ };
604
+
605
+ const helpHeaderStyle: React.CSSProperties = {
606
+ display: 'flex',
607
+ alignItems: 'flex-start',
608
+ justifyContent: 'space-between',
609
+ gap: '1rem',
610
+ padding: '1.25rem 1.25rem 1rem',
611
+ borderBottom: '1px solid hsl(var(--border))',
612
+ };
613
+
614
+ const helpTitleStyle: React.CSSProperties = { margin: 0, fontSize: '1rem', fontWeight: 650 };
615
+ const helpSubtitleStyle: React.CSSProperties = {
616
+ margin: '0.2rem 0 0',
617
+ color: 'hsl(var(--muted-foreground))',
618
+ fontSize: '0.75rem',
619
+ };
620
+ const helpCloseStyle: React.CSSProperties = {
621
+ border: '1px solid hsl(var(--border))',
622
+ borderRadius: '0.35rem',
623
+ background: 'hsl(var(--muted))',
624
+ color: 'hsl(var(--muted-foreground))',
625
+ padding: '0.2rem 0.45rem',
626
+ cursor: 'pointer',
627
+ font: 'inherit',
628
+ fontSize: '0.7rem',
629
+ };
630
+ const helpBodyStyle: React.CSSProperties = {
631
+ display: 'grid',
632
+ gap: '1.1rem',
633
+ maxHeight: 'calc(min(42rem, 100dvh - 3rem) - 5rem)',
634
+ overflowY: 'auto',
635
+ padding: '1rem 1.25rem 1.25rem',
636
+ };
637
+ const helpGroupTitleStyle: React.CSSProperties = {
638
+ margin: '0 0 0.35rem',
639
+ color: 'hsl(var(--muted-foreground))',
640
+ fontSize: '0.68rem',
641
+ fontWeight: 650,
642
+ letterSpacing: '0.06em',
643
+ textTransform: 'uppercase',
644
+ };
645
+ const helpListStyle: React.CSSProperties = { display: 'grid' };
646
+ const helpRowStyle: React.CSSProperties = {
647
+ display: 'flex',
648
+ minHeight: '2rem',
649
+ alignItems: 'center',
650
+ justifyContent: 'space-between',
651
+ gap: '1rem',
652
+ borderBottom: '1px solid color-mix(in srgb, hsl(var(--border)) 65%, transparent)',
653
+ fontSize: '0.8rem',
654
+ };
655
+ const helpKeysStyle: React.CSSProperties = {
656
+ display: 'inline-flex',
657
+ flexWrap: 'wrap',
658
+ justifyContent: 'flex-end',
659
+ gap: '0.3rem',
660
+ };
661
+ const helpKeyStyle: React.CSSProperties = {
662
+ minWidth: '1.6rem',
663
+ border: '1px solid hsl(var(--border))',
664
+ borderBottomWidth: '2px',
665
+ borderRadius: '0.35rem',
666
+ background: 'hsl(var(--muted))',
667
+ color: 'hsl(var(--foreground))',
668
+ padding: '0.15rem 0.4rem',
669
+ textAlign: 'center',
670
+ font: 'inherit',
671
+ fontSize: '0.72rem',
672
+ };
673
+
674
+ export function useShortcuts(
675
+ shortcuts: ShortcutDefinition[],
676
+ options: UseShortcutsOptions = {},
677
+ ): void {
678
+ const registry = useContext(ShortcutContext);
679
+ const shortcutsRef = useRef(shortcuts);
680
+ shortcutsRef.current = shortcuts;
681
+ const {
682
+ enabled = true,
683
+ scope = 'route',
684
+ priority = 0,
685
+ sequenceTimeoutMs = 1500,
686
+ collectionOwnerId,
687
+ isAvailable,
688
+ } = options;
689
+ const availableRef = useRef(isAvailable);
690
+ availableRef.current = isAvailable;
691
+
692
+ useEffect(() => {
693
+ if (!registry || !enabled) return;
694
+ return registry.register({
695
+ enabled,
696
+ scope,
697
+ priority: SHORTCUT_SCOPE_PRIORITY[scope] + priority,
698
+ sequenceTimeoutMs,
699
+ collectionOwnerId,
700
+ isAvailable: () => availableRef.current?.() !== false,
701
+ getShortcuts: () => availableRef.current?.() === false ? [] : shortcutsRef.current,
702
+ });
703
+ }, [collectionOwnerId, enabled, priority, registry, scope, sequenceTimeoutMs]);
704
+
705
+ // Compatibility fallback for SDK components rendered outside a provider.
706
+ useEffect(() => {
707
+ if (registry || !enabled) return;
708
+ return registerFallbackShortcut({
709
+ enabled,
710
+ scope,
711
+ priority: SHORTCUT_SCOPE_PRIORITY[scope] + priority,
712
+ sequenceTimeoutMs,
713
+ collectionOwnerId,
714
+ isAvailable: () => availableRef.current?.() !== false,
715
+ getShortcuts: () => shortcutsRef.current,
716
+ });
717
+ }, [collectionOwnerId, enabled, priority, registry, scope, sequenceTimeoutMs]);
718
+ }
719
+
720
+ export function shortcutDisplay(raw: string): string {
721
+ return raw
722
+ .trim()
723
+ .split(/\s+/)
724
+ .map((chord) => chord
725
+ .split('+')
726
+ .map((part) => {
727
+ const lower = part.toLowerCase();
728
+ if (lower === 'mod' || lower === 'cmd' || lower === 'meta') return '⌘';
729
+ if (lower === 'ctrl') return '⌃';
730
+ if (lower === 'shift') return '⇧';
731
+ if (lower === 'alt' || lower === 'option') return '⌥';
732
+ if (lower === 'arrowup' || lower === 'up') return '↑';
733
+ if (lower === 'arrowdown' || lower === 'down') return '↓';
734
+ if (lower === 'arrowleft' || lower === 'left') return '←';
735
+ if (lower === 'arrowright' || lower === 'right') return '→';
736
+ return part.length === 1 ? part.toUpperCase() : part;
737
+ })
738
+ .join(''))
739
+ .join(' ');
740
+ }