@notis_ai/cli 0.2.0-beta.19.1 → 0.2.0-beta.191.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 (164) hide show
  1. package/README.md +421 -133
  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/dist/agent-hooks/notis-agent-hook.mjs +19299 -0
  7. package/dist/base-skills/notis-apps/SKILL.md +72 -0
  8. package/dist/base-skills/notis-apps/references/architecture.md +167 -0
  9. package/dist/base-skills/notis-apps/references/context.md +81 -0
  10. package/dist/base-skills/notis-apps/references/design.md +178 -0
  11. package/dist/base-skills/notis-apps/references/reading.md +89 -0
  12. package/dist/base-skills/notis-apps/references/release.md +145 -0
  13. package/dist/base-skills/notis-apps/references/sdk.md +63 -0
  14. package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
  15. package/dist/base-skills/notis-cli/SKILL.md +154 -0
  16. package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
  17. package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
  18. package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
  19. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  20. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  21. package/dist/base-skills/notis-query/SKILL.md +67 -0
  22. package/dist/base-skills/notis-query/references/database-discovery.md +70 -0
  23. package/dist/base-skills/notis-query/references/documents.md +50 -0
  24. package/dist/base-skills/notis-query/references/query.md +543 -0
  25. package/{template/packages/notis-sdk → dist/sdk}/package.json +14 -4
  26. package/dist/sdk/src/agentContext.ts +36 -0
  27. package/dist/sdk/src/components/Dialog.tsx +46 -0
  28. package/dist/sdk/src/components/DocumentEditor.tsx +103 -0
  29. package/dist/sdk/src/components/Markdown.tsx +60 -0
  30. package/dist/sdk/src/components/MarkdownEditor.tsx +121 -0
  31. package/dist/sdk/src/components/MultiSelectActionBar.tsx +285 -0
  32. package/dist/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
  33. package/dist/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  34. package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  35. package/dist/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
  36. package/dist/sdk/src/components/ShortcutHints.tsx +56 -0
  37. package/dist/sdk/src/components/Skeleton.tsx +24 -0
  38. package/dist/sdk/src/config.ts +261 -0
  39. package/dist/sdk/src/documents.ts +256 -0
  40. package/dist/sdk/src/hooks/useActiveResource.ts +19 -0
  41. package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
  42. package/dist/sdk/src/hooks/useCloudComputer.ts +64 -0
  43. package/dist/sdk/src/hooks/useCollectionInteractions.ts +838 -0
  44. package/dist/sdk/src/hooks/useDatabaseSchema.ts +49 -0
  45. package/dist/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  46. package/dist/sdk/src/hooks/useDocument.ts +43 -0
  47. package/dist/sdk/src/hooks/useDocuments.ts +84 -0
  48. package/dist/sdk/src/hooks/useHandover.ts +77 -0
  49. package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
  50. package/dist/sdk/src/hooks/useMultiSelect.ts +95 -0
  51. package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useNotis.ts +12 -4
  52. package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useNotisNavigation.ts +11 -8
  53. package/dist/sdk/src/hooks/useQuery.ts +71 -0
  54. package/dist/sdk/src/hooks/useTool.ts +65 -0
  55. package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
  56. package/dist/sdk/src/hooks/useTopBarSearch.ts +81 -0
  57. package/dist/sdk/src/hooks/useUpsertDocument.ts +95 -0
  58. package/dist/sdk/src/index.ts +164 -0
  59. package/dist/sdk/src/interactions/actions.ts +59 -0
  60. package/dist/sdk/src/interactions/shortcuts.tsx +726 -0
  61. package/dist/sdk/src/interactions/visibility.ts +13 -0
  62. package/dist/sdk/src/interactions.ts +45 -0
  63. package/dist/sdk/src/provider.tsx +44 -0
  64. package/dist/sdk/src/queryCache.ts +170 -0
  65. package/dist/sdk/src/runtime.ts +465 -0
  66. package/dist/sdk/src/styles.css +266 -0
  67. package/dist/sdk/src/tailwind.ts +66 -0
  68. package/dist/sdk/src/vite.ts +73 -0
  69. package/dist/skill-sync/index.js +1753 -0
  70. package/dist/skill-sync/index.js.map +7 -0
  71. package/dist/skill-sync-worker.mjs +3113 -0
  72. package/package.json +18 -7
  73. package/skills/notis-apps/cli.md +299 -0
  74. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  75. package/skills/notis-onboarding/BRIEF.md +129 -0
  76. package/skills/notis-query/cli.md +39 -0
  77. package/src/agent-hook-entry.js +5 -0
  78. package/src/cli.js +294 -25
  79. package/src/command-specs/agents.js +392 -0
  80. package/src/command-specs/apps.js +1483 -203
  81. package/src/command-specs/auth.js +114 -137
  82. package/src/command-specs/diagnostics.js +730 -0
  83. package/src/command-specs/handover.js +374 -0
  84. package/src/command-specs/helpers.js +85 -82
  85. package/src/command-specs/index.js +25 -6
  86. package/src/command-specs/meta.js +150 -18
  87. package/src/command-specs/onboarding.js +290 -0
  88. package/src/command-specs/profile.js +358 -0
  89. package/src/command-specs/reports.js +97 -0
  90. package/src/command-specs/skills.js +75 -0
  91. package/src/command-specs/smoke.js +386 -0
  92. package/src/command-specs/tools.js +455 -139
  93. package/src/runtime/agent-browser.js +677 -0
  94. package/src/runtime/agent-memory-state.js +126 -0
  95. package/src/runtime/agent-setup.js +383 -0
  96. package/src/runtime/app-boundary-validator.js +404 -0
  97. package/src/runtime/app-changelog.js +79 -0
  98. package/src/runtime/app-platform.js +2652 -205
  99. package/src/runtime/app-registry-scaffolds.js +367 -0
  100. package/src/runtime/app-test-server.js +293 -0
  101. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  102. package/src/runtime/auth-recovery.js +110 -0
  103. package/src/runtime/base-skills.d.ts +20 -0
  104. package/src/runtime/base-skills.js +167 -0
  105. package/src/runtime/channel.js +133 -0
  106. package/src/runtime/delegated-context.js +68 -0
  107. package/src/runtime/errors.js +1 -0
  108. package/src/runtime/git.js +233 -0
  109. package/src/runtime/login-listener.js +15 -0
  110. package/src/runtime/oauth.js +2622 -0
  111. package/src/runtime/output.js +37 -5
  112. package/src/runtime/ports.js +31 -0
  113. package/src/runtime/profiles.js +906 -55
  114. package/src/runtime/screenshot-framing.js +85 -0
  115. package/src/runtime/skill-sync/cloud-client.ts +99 -0
  116. package/src/runtime/skill-sync/index.ts +786 -0
  117. package/src/runtime/skill-sync/local-scanner.ts +1151 -0
  118. package/src/runtime/skill-sync/symlink-manager.ts +433 -0
  119. package/src/runtime/skill-sync/sync-plan.ts +47 -0
  120. package/src/runtime/skill-sync/types.ts +131 -0
  121. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  122. package/src/runtime/skill-sync-service.js +109 -0
  123. package/src/runtime/store-screenshot.js +146 -0
  124. package/src/runtime/sync-skills.d.ts +37 -0
  125. package/src/runtime/sync-skills.js +231 -0
  126. package/src/runtime/telemetry.js +92 -0
  127. package/src/runtime/transport.js +324 -45
  128. package/src/skill-sync-worker-entry.js +2 -0
  129. package/src/skill-sync-worker.js +50 -0
  130. package/template/.harness/index.html.tmpl +435 -0
  131. package/template/CHANGELOG.md +5 -0
  132. package/template/app/globals.css +28 -3
  133. package/template/app/layout.tsx +6 -3
  134. package/template/app/page.tsx +49 -42
  135. package/template/components/page-heading.tsx +23 -0
  136. package/template/components/ui/badge.tsx +7 -4
  137. package/template/components/ui/button.tsx +1 -1
  138. package/template/components/ui/card.tsx +24 -11
  139. package/template/components/ui/native-select.tsx +24 -0
  140. package/template/notis.config.ts +24 -6
  141. package/template/package-lock.json +3642 -0
  142. package/template/package.json +19 -16
  143. package/template/postcss.config.mjs +1 -1
  144. package/template/tailwind.config.ts +1 -6
  145. package/template/tsconfig.json +1 -0
  146. package/src/command-specs/db.js +0 -163
  147. package/src/runtime/app-preview-server.js +0 -312
  148. package/template/packages/notis-sdk/src/config.ts +0 -48
  149. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  150. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  151. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  152. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  153. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  154. package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
  155. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
  156. package/template/packages/notis-sdk/src/index.ts +0 -47
  157. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  158. package/template/packages/notis-sdk/src/runtime.ts +0 -159
  159. package/template/packages/notis-sdk/src/styles.css +0 -123
  160. package/template/packages/notis-sdk/src/vite.ts +0 -54
  161. package/template/packages/notis-sdk/tsconfig.json +0 -15
  162. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useBackend.ts +0 -0
  163. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useTools.ts +0 -0
  164. /package/{template/packages/notis-sdk → dist/sdk}/src/ui.ts +0 -0
@@ -0,0 +1,103 @@
1
+ 'use client';
2
+
3
+ import type { CSSProperties, ReactElement } from 'react';
4
+ import { useNotisRuntime } from '../provider';
5
+ import { useDocument } from '../hooks/useDocument';
6
+ import { Markdown } from './Markdown';
7
+ import type { NotisDocumentEditorProps } from '../runtime';
8
+
9
+ const fallbackNoticeStyle: CSSProperties = {
10
+ marginTop: '0.75rem',
11
+ fontSize: '12px',
12
+ color: 'hsl(var(--muted-foreground))',
13
+ };
14
+
15
+ const fallbackFrameStyle: CSSProperties = {
16
+ borderRadius: '0.5rem',
17
+ border: '1px dashed hsl(var(--border))',
18
+ padding: '1rem',
19
+ fontSize: '13px',
20
+ color: 'hsl(var(--muted-foreground))',
21
+ };
22
+
23
+ const fallbackTitleStyle: CSSProperties = {
24
+ margin: '0 0 0.75rem',
25
+ fontSize: '1.5rem',
26
+ fontWeight: 700,
27
+ letterSpacing: '-0.01em',
28
+ color: 'hsl(var(--foreground))',
29
+ };
30
+
31
+ /**
32
+ * Read-only stand-in used when no host editor is available (standalone dev
33
+ * harness, snapshots). Markdown content renders via `Markdown`; other content
34
+ * types get a typed placeholder.
35
+ */
36
+ function DocumentEditorFallback({ documentId, variant = 'full', className }: NotisDocumentEditorProps) {
37
+ const runtime = useNotisRuntime();
38
+ const { document, loading, error } = useDocument(documentId);
39
+ // Listing screenshots should show the app as it looks in the portal, so the
40
+ // harness-only editing notice stays out of scenario captures.
41
+ const isScreenshot = Boolean(runtime?.context?.screenshotScenario);
42
+
43
+ if (loading) {
44
+ return <div className={className} style={fallbackFrameStyle}>Loading document…</div>;
45
+ }
46
+ if (error || !document) {
47
+ return (
48
+ <div className={className} style={fallbackFrameStyle}>
49
+ {error?.message ?? 'Document not found.'}
50
+ </div>
51
+ );
52
+ }
53
+
54
+ const isMarkdown = document.contentType === 'markdown' || !document.contentType;
55
+ return (
56
+ <div className={className}>
57
+ {variant === 'full' ? <h1 style={fallbackTitleStyle}>{document.title}</h1> : null}
58
+ {isMarkdown ? (
59
+ <>
60
+ <Markdown value={document.contentMarkdown ?? ''} />
61
+ {isScreenshot ? null : (
62
+ <p style={fallbackNoticeStyle}>Read-only preview — editing is available inside the Notis portal.</p>
63
+ )}
64
+ </>
65
+ ) : document.contentType === 'view' ? (
66
+ <div style={fallbackFrameStyle}>
67
+ {document.viewType === 'report'
68
+ ? 'Interactive report'
69
+ : document.viewType === 'html'
70
+ ? 'Interactive HTML document'
71
+ : 'View document'} — open it in Notis Documents for
72
+ the full native experience.
73
+ </div>
74
+ ) : (
75
+ <div style={fallbackFrameStyle}>
76
+ {document.fileType ? `${document.fileType.toUpperCase()} document` : 'File document'} — open it in
77
+ the Notis portal to view.
78
+ </div>
79
+ )}
80
+ </div>
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Embeds the document editor for a document anywhere in an app view. The host
86
+ * (portal) provides the implementation through `runtime.ui.DocumentEditor` and
87
+ * dispatches on the document's content type (markdown -> rich text editor,
88
+ * files -> matching viewer, views -> their trusted native Documents surface).
89
+ * Outside the portal this falls back to a read-only preview or an explicit
90
+ * open-in-Documents notice for native views.
91
+ *
92
+ * ```tsx
93
+ * <DocumentEditor documentId={entry.id} variant="body" className="min-h-[320px]" />
94
+ * ```
95
+ */
96
+ export function DocumentEditor(props: NotisDocumentEditorProps): ReactElement {
97
+ const runtime = useNotisRuntime();
98
+ const HostEditor = runtime?.ui?.DocumentEditor;
99
+ if (HostEditor) {
100
+ return <HostEditor {...props} />;
101
+ }
102
+ return <DocumentEditorFallback {...props} />;
103
+ }
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+
3
+ import type { AnchorHTMLAttributes, ReactElement } from 'react';
4
+ import ReactMarkdown, { type Components } from 'react-markdown';
5
+ import remarkGfm from 'remark-gfm';
6
+
7
+ export interface MarkdownProps {
8
+ /** Markdown source to render. */
9
+ value: string;
10
+ /** Typography scale. 'sm' suits dense surfaces (cards, list rows). */
11
+ size?: 'sm' | 'base';
12
+ className?: string;
13
+ /** Per-element overrides, passed through to react-markdown. */
14
+ components?: Components;
15
+ }
16
+
17
+ function MarkdownLink({
18
+ href,
19
+ children,
20
+ node: _node,
21
+ ...rest
22
+ }: AnchorHTMLAttributes<HTMLAnchorElement> & { node?: unknown }) {
23
+ const isExternal = typeof href === 'string' && /^https?:\/\//i.test(href);
24
+ return (
25
+ <a
26
+ href={href}
27
+ {...(isExternal ? { target: '_blank', rel: 'noreferrer noopener' } : {})}
28
+ {...rest}
29
+ >
30
+ {children}
31
+ </a>
32
+ );
33
+ }
34
+
35
+ const defaultComponents: Components = {
36
+ a: MarkdownLink,
37
+ };
38
+
39
+ /**
40
+ * Renders a markdown value with Notis typography. Markdown is a value type:
41
+ * use this anywhere markdown appears — document content, database fields,
42
+ * generated text. Styling comes from `.notis-markdown` rules in the SDK
43
+ * stylesheet and follows the portal theme variables.
44
+ */
45
+ export function Markdown({ value, size = 'base', className, components }: MarkdownProps): ReactElement {
46
+ const classes = ['notis-markdown', size === 'sm' ? 'notis-markdown--sm' : null, className]
47
+ .filter(Boolean)
48
+ .join(' ');
49
+
50
+ return (
51
+ <div className={classes}>
52
+ <ReactMarkdown
53
+ remarkPlugins={[remarkGfm]}
54
+ components={components ? { ...defaultComponents, ...components } : defaultComponents}
55
+ >
56
+ {value}
57
+ </ReactMarkdown>
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,121 @@
1
+ import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { useNotisRuntime } from '../provider';
4
+ import type { NotisMarkdownEditorProps } from '../runtime';
5
+
6
+ function MarkdownEditorFallback({
7
+ value,
8
+ revision,
9
+ readOnly,
10
+ autosaveMs = 1500,
11
+ placeholder = 'Write in Markdown…',
12
+ className,
13
+ onChange,
14
+ onSave,
15
+ onDirtyChange,
16
+ onSavingChange,
17
+ }: NotisMarkdownEditorProps) {
18
+ const [draft, setDraft] = useState(value);
19
+ const [saveAttempt, setSaveAttempt] = useState(0);
20
+ const draftRef = useRef(value);
21
+ const dirtyRef = useRef(false);
22
+ const savingRef = useRef(false);
23
+ const revisionRef = useRef(revision);
24
+ const onSaveRef = useRef(onSave);
25
+ const readOnlyRef = useRef(readOnly);
26
+ const unmountedRef = useRef(false);
27
+ const saveRef = useRef<() => Promise<void>>(async () => undefined);
28
+
29
+ useEffect(() => {
30
+ onSaveRef.current = onSave;
31
+ readOnlyRef.current = readOnly;
32
+ }, [onSave, readOnly]);
33
+
34
+ useEffect(() => {
35
+ if (dirtyRef.current) return;
36
+ draftRef.current = value;
37
+ revisionRef.current = revision;
38
+ setDraft(value);
39
+ }, [revision, value]);
40
+
41
+ const save = useCallback(async () => {
42
+ if (!onSave || !dirtyRef.current || readOnly || savingRef.current) return;
43
+ const markdown = draftRef.current;
44
+ savingRef.current = true;
45
+ if (!unmountedRef.current) onSavingChange?.(true);
46
+ try {
47
+ const result = await onSave({ markdown, expectedRevision: revisionRef.current });
48
+ if (result?.revision) revisionRef.current = result.revision;
49
+ if (draftRef.current === markdown) {
50
+ dirtyRef.current = false;
51
+ if (!unmountedRef.current) onDirtyChange?.(false);
52
+ }
53
+ } finally {
54
+ savingRef.current = false;
55
+ if (!unmountedRef.current) onSavingChange?.(false);
56
+ if (dirtyRef.current && draftRef.current !== markdown) {
57
+ if (unmountedRef.current) {
58
+ void saveRef.current().catch((error) => console.error('Could not save Markdown on unmount', error));
59
+ } else {
60
+ setSaveAttempt((attempt) => attempt + 1);
61
+ }
62
+ }
63
+ }
64
+ }, [onDirtyChange, onSave, onSavingChange, readOnly]);
65
+ saveRef.current = save;
66
+
67
+ useEffect(() => {
68
+ unmountedRef.current = false;
69
+ return () => {
70
+ unmountedRef.current = true;
71
+ if (!savingRef.current) {
72
+ void saveRef.current().catch((error) => console.error('Could not save Markdown on unmount', error));
73
+ }
74
+ };
75
+ }, []);
76
+
77
+ useEffect(() => {
78
+ if (!dirtyRef.current || !onSave || readOnly) return;
79
+ const timer = window.setTimeout(() => void save(), Math.max(250, autosaveMs));
80
+ return () => window.clearTimeout(timer);
81
+ }, [autosaveMs, draft, onSave, readOnly, save, saveAttempt]);
82
+
83
+ const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
84
+ const next = event.currentTarget.value;
85
+ draftRef.current = next;
86
+ setDraft(next);
87
+ if (!dirtyRef.current) {
88
+ dirtyRef.current = true;
89
+ onDirtyChange?.(true);
90
+ }
91
+ onChange?.(next);
92
+ };
93
+
94
+ const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
95
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
96
+ event.preventDefault();
97
+ void save();
98
+ }
99
+ };
100
+
101
+ return (
102
+ <textarea
103
+ value={draft}
104
+ readOnly={readOnly}
105
+ placeholder={placeholder}
106
+ className={className}
107
+ onChange={handleChange}
108
+ onKeyDown={handleKeyDown}
109
+ style={{ minHeight: 360, width: '100%', resize: 'vertical' }}
110
+ />
111
+ );
112
+ }
113
+
114
+ /** Use Notis' editor while leaving persistence entirely under app control. */
115
+ export function MarkdownEditor(props: NotisMarkdownEditorProps): ReactElement {
116
+ const runtime = useNotisRuntime();
117
+ const HostEditor = runtime?.ui?.MarkdownEditor;
118
+ return HostEditor
119
+ ? <HostEditor key={props.resourceKey} {...props} />
120
+ : <MarkdownEditorFallback key={props.resourceKey} {...props} />;
121
+ }
@@ -0,0 +1,285 @@
1
+ 'use client';
2
+
3
+ import React, {
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ type ReactElement,
10
+ } from 'react';
11
+ import type { ResolvedCollectionAction } from '../interactions/actions';
12
+ import { activateShortcutCollection, shortcutDisplay, useShortcuts, type ShortcutDefinition } from '../interactions/shortcuts';
13
+ import { isInteractionElementVisible } from '../interactions/visibility';
14
+ import type { ShortcutScope } from '../interactions/shortcuts';
15
+
16
+ export type MultiSelectAction = ResolvedCollectionAction;
17
+
18
+ /** The Portal owns global launcher positioning; app code only reports its own bar. */
19
+ export const BULK_ACTION_LAYOUT_EVENT = 'notis:bulk-actions-layout';
20
+ export interface BulkActionLayoutDetail {
21
+ bar: HTMLElement;
22
+ active: boolean;
23
+ }
24
+
25
+ export interface MultiSelectActionBarProps {
26
+ selectedCount: number;
27
+ actions: MultiSelectAction[];
28
+ /** Override the "{count} selected" label units. Default: "selected" with no item word. */
29
+ itemLabel?: { singular: string; plural: string };
30
+ /** Optional extra class on the outer container (composed alongside the inline styles). */
31
+ className?: string;
32
+ /** Optional override for the bar's positioning style. Defaults to bottom-center floating. */
33
+ style?: CSSProperties;
34
+ /** Disable action key bindings while leaving the visible toolbar mounted. */
35
+ shortcutsEnabled?: boolean;
36
+ /** Override the default collection shortcut scope. */
37
+ shortcutScope?: ShortcutScope;
38
+ collectionOwnerId?: string;
39
+ isAvailable?: () => boolean;
40
+ onClearSelection?: () => void;
41
+ }
42
+
43
+ const containerBaseStyle: CSSProperties = {
44
+ position: 'fixed',
45
+ bottom: 'calc(var(--notis-viewport-bottom, 0px) + max(1rem, var(--notis-safe-area-bottom, env(safe-area-inset-bottom, 0px))))',
46
+ left: '50%',
47
+ transform: 'translateX(-50%)',
48
+ zIndex: 60,
49
+ display: 'flex',
50
+ alignItems: 'center',
51
+ gap: '0.25rem',
52
+ padding: '0.375rem 0.5rem',
53
+ borderRadius: '0.5rem',
54
+ background: 'color-mix(in srgb, hsl(var(--foreground)) 95%, transparent)',
55
+ color: 'hsl(var(--background))',
56
+ boxShadow: '0 10px 25px -10px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.08)',
57
+ backdropFilter: 'blur(6px)',
58
+ WebkitBackdropFilter: 'blur(6px)',
59
+ pointerEvents: 'auto',
60
+ fontSize: '13px',
61
+ lineHeight: 1.2,
62
+ width: 'max-content',
63
+ maxWidth: 'calc(100% - 2rem)',
64
+ };
65
+
66
+ const countStyle: CSSProperties = {
67
+ flexShrink: 0,
68
+ padding: '0 0.5rem',
69
+ fontSize: '12px',
70
+ fontWeight: 500,
71
+ fontVariantNumeric: 'tabular-nums',
72
+ whiteSpace: 'nowrap',
73
+ color: 'color-mix(in srgb, hsl(var(--background)) 70%, transparent)',
74
+ };
75
+
76
+ const dividerStyle: CSSProperties = {
77
+ width: '1px',
78
+ height: '1rem',
79
+ margin: '0 0.125rem',
80
+ background: 'color-mix(in srgb, hsl(var(--background)) 20%, transparent)',
81
+ };
82
+
83
+ const baseButtonStyle: CSSProperties = {
84
+ flexShrink: 0,
85
+ display: 'inline-flex',
86
+ alignItems: 'center',
87
+ gap: '0.375rem',
88
+ padding: '0.25rem 0.5rem',
89
+ border: 0,
90
+ background: 'transparent',
91
+ color: 'color-mix(in srgb, hsl(var(--background)) 90%, transparent)',
92
+ borderRadius: '0.375rem',
93
+ cursor: 'pointer',
94
+ fontSize: '13px',
95
+ fontFamily: 'inherit',
96
+ whiteSpace: 'nowrap',
97
+ transition: 'background-color 120ms ease, color 120ms ease',
98
+ };
99
+
100
+ const keycapStyle: CSSProperties = {
101
+ display: 'inline-flex',
102
+ alignItems: 'center',
103
+ justifyContent: 'center',
104
+ minWidth: '20px',
105
+ height: '20px',
106
+ padding: '0 4px',
107
+ borderRadius: '4px',
108
+ border: '1px solid color-mix(in srgb, hsl(var(--background)) 20%, transparent)',
109
+ background: 'color-mix(in srgb, hsl(var(--background)) 15%, transparent)',
110
+ color: 'inherit',
111
+ fontSize: '11px',
112
+ fontWeight: 500,
113
+ lineHeight: 1,
114
+ fontFamily: 'inherit',
115
+ };
116
+
117
+ const iconSlotStyle: CSSProperties = {
118
+ display: 'inline-flex',
119
+ alignItems: 'center',
120
+ justifyContent: 'center',
121
+ color: 'inherit',
122
+ };
123
+
124
+ export function MultiSelectActionBar({
125
+ selectedCount,
126
+ actions,
127
+ itemLabel,
128
+ className,
129
+ style,
130
+ shortcutsEnabled = true,
131
+ shortcutScope = 'collection',
132
+ collectionOwnerId,
133
+ isAvailable,
134
+ onClearSelection,
135
+ }: MultiSelectActionBarProps): ReactElement | null {
136
+ const barRef = useRef<HTMLDivElement>(null);
137
+ const visible = selectedCount > 0;
138
+ useEffect(() => {
139
+ const bar = barRef.current;
140
+ if (!visible || !bar) return;
141
+ // Notify the owning document, including from a shadow root or after unmount.
142
+ // Only the Portal host may measure this bar and change global layout styles.
143
+ const report = (active: boolean) => {
144
+ const owner = bar.ownerDocument;
145
+ const LayoutEvent = owner.defaultView?.CustomEvent;
146
+ if (LayoutEvent) {
147
+ owner.dispatchEvent(new LayoutEvent<BulkActionLayoutDetail>(BULK_ACTION_LAYOUT_EVENT, {
148
+ detail: { bar, active },
149
+ }));
150
+ }
151
+ };
152
+ const update = () => report(isAvailable?.() !== false && isInteractionElementVisible(bar));
153
+ update();
154
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
155
+ observer?.observe(bar);
156
+ const VisibilityObserver = bar.ownerDocument.defaultView?.MutationObserver;
157
+ const visibilityObserver = VisibilityObserver ? new VisibilityObserver(update) : null;
158
+ let element: Element | null = bar;
159
+ while (element) {
160
+ visibilityObserver?.observe(element, { attributes: true, attributeFilter: element === bar.ownerDocument.documentElement ? ['hidden', 'inert', 'aria-hidden', 'class'] : ['hidden', 'inert', 'aria-hidden', 'style', 'class'] });
161
+ const root: Node = element.getRootNode();
162
+ element = element.parentElement ?? ('host' in root ? (root as ShadowRoot).host : null);
163
+ }
164
+ return () => {
165
+ observer?.disconnect();
166
+ visibilityObserver?.disconnect();
167
+ report(false);
168
+ };
169
+ }, [isAvailable, visible]);
170
+
171
+ const actionShortcuts = useMemo<ShortcutDefinition[]>(() => {
172
+ return actions.flatMap((action): ShortcutDefinition[] => {
173
+ if (!action.shortcut) return [];
174
+ return [{
175
+ id: `collection.action.${action.id}`,
176
+ keys: action.shortcut,
177
+ label: action.label,
178
+ allowRepeat: Boolean(action.disabled || action.pending),
179
+ // Keep advertised keys owned while disabled/pending; never fall through to another action.
180
+ onTrigger: () => { if (!action.disabled && !action.pending) action.onRun(); },
181
+ }];
182
+ });
183
+ }, [actions]);
184
+ useShortcuts(actionShortcuts, {
185
+ enabled: shortcutsEnabled && selectedCount > 0,
186
+ scope: shortcutScope,
187
+ priority: 25,
188
+ collectionOwnerId,
189
+ isAvailable: () => isAvailable?.() !== false && isInteractionElementVisible(barRef.current),
190
+ });
191
+
192
+ if (selectedCount === 0) return null;
193
+
194
+ const countWord = itemLabel
195
+ ? selectedCount === 1
196
+ ? itemLabel.singular
197
+ : itemLabel.plural
198
+ : 'selected';
199
+ const countLabel = itemLabel
200
+ ? `${selectedCount} ${countWord} selected`
201
+ : `${selectedCount} selected`;
202
+
203
+ const composedStyle: CSSProperties = { ...containerBaseStyle, ...(style || {}) };
204
+
205
+ return (
206
+ <div
207
+ ref={barRef}
208
+ data-notis-bulk-actions
209
+ onFocusCapture={() => { if (collectionOwnerId) activateShortcutCollection(collectionOwnerId); }}
210
+ onMouseDownCapture={() => { if (collectionOwnerId) activateShortcutCollection(collectionOwnerId); }}
211
+ onKeyDown={(event) => {
212
+ if (event.key !== 'Escape' || !shortcutsEnabled || !onClearSelection || isAvailable?.() === false) return;
213
+ event.preventDefault();
214
+ event.stopPropagation();
215
+ onClearSelection();
216
+ }}
217
+ role="toolbar"
218
+ aria-label={`Bulk actions for ${selectedCount} selected ${countWord}`}
219
+ className={className}
220
+ style={composedStyle}
221
+ >
222
+ <style>{`
223
+ [data-notis-bulk-action-icon][data-has-shortcut] { display: none !important; }
224
+ @media (max-width: 639px) {
225
+ [data-notis-bulk-actions] { flex-wrap: wrap; width: calc(100% - 2rem) !important; }
226
+ [data-notis-bulk-count] { flex-basis: 100%; padding: 0.375rem 0.5rem !important; font-size: 14px !important; }
227
+ [data-notis-bulk-divider] { display: none; }
228
+ [data-notis-bulk-action-list] { width: 100%; }
229
+ [data-notis-bulk-actions] button { min-height: 48px; font-size: 16px !important; }
230
+ }
231
+ @media (hover: none) and (pointer: coarse) {
232
+ [data-notis-bulk-actions] kbd { display: none !important; }
233
+ [data-notis-bulk-action-icon][data-has-shortcut] { display: inline-flex !important; }
234
+ }
235
+ `}</style>
236
+ <span data-notis-bulk-count style={countStyle}>{countLabel}</span>
237
+ {actions.length > 0 ? <span data-notis-bulk-divider aria-hidden style={dividerStyle} /> : null}
238
+ <div data-notis-bulk-action-list style={{ display: 'flex', minWidth: 0, overflowX: 'auto', overscrollBehaviorX: 'contain', gap: '0.25rem' }}>
239
+ {actions.map((action) => (
240
+ <ActionButton key={action.id} action={action} />
241
+ ))}
242
+ </div>
243
+ </div>
244
+ );
245
+ }
246
+
247
+ function ActionButton({ action }: { action: MultiSelectAction }) {
248
+ const [hover, setHover] = useState(false);
249
+ const display = action.shortcut ? shortcutDisplay(action.shortcut) : null;
250
+ const buttonStyle: CSSProperties = {
251
+ ...baseButtonStyle,
252
+ background: hover
253
+ ? 'color-mix(in srgb, hsl(var(--background)) 12%, transparent)'
254
+ : 'transparent',
255
+ color: hover
256
+ ? 'hsl(var(--background))'
257
+ : baseButtonStyle.color,
258
+ opacity: action.disabled ? 0.45 : 1,
259
+ cursor: action.disabled || action.pending ? 'not-allowed' : 'pointer',
260
+ };
261
+
262
+ return (
263
+ <button
264
+ type="button"
265
+ onClick={() => {
266
+ if (!action.disabled && !action.pending) void action.onRun();
267
+ }}
268
+ disabled={action.disabled || action.pending}
269
+ aria-busy={action.pending || undefined}
270
+ aria-keyshortcuts={action.shortcut || undefined}
271
+ data-destructive={action.destructive ? 'true' : 'false'}
272
+ onMouseEnter={() => setHover(true)}
273
+ onMouseLeave={() => setHover(false)}
274
+ onFocus={() => setHover(true)}
275
+ onBlur={() => setHover(false)}
276
+ style={buttonStyle}
277
+ >
278
+ {action.icon ? (
279
+ <span data-notis-bulk-action-icon data-has-shortcut={action.shortcut ? '' : undefined} aria-hidden style={iconSlotStyle}>{action.icon}</span>
280
+ ) : null}
281
+ {display ? <kbd aria-hidden style={keycapStyle}>{display}</kbd> : null}
282
+ <span>{action.pending ? `${action.label}…` : action.label}</span>
283
+ </button>
284
+ );
285
+ }
@@ -0,0 +1,97 @@
1
+ 'use client';
2
+
3
+ import React, { type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactElement } from 'react';
4
+
5
+ export interface MultiSelectCheckboxProps {
6
+ isSelected: boolean;
7
+ onClick: (event: ReactMouseEvent) => void;
8
+ disabled?: boolean;
9
+ /** When true, the checkbox is always visible. Default false (hover/focus reveal via the parent's :hover state). */
10
+ alwaysVisible?: boolean;
11
+ /** Optional aria-label override. Defaults to "Select item" / "Deselect item". */
12
+ ariaLabel?: string;
13
+ className?: string;
14
+ /** Optional inline-style override merged with the defaults. */
15
+ style?: CSSProperties;
16
+ }
17
+
18
+ const baseStyle: CSSProperties = {
19
+ flexShrink: 0,
20
+ display: 'inline-flex',
21
+ alignItems: 'center',
22
+ justifyContent: 'center',
23
+ width: '16px',
24
+ height: '16px',
25
+ borderRadius: '4px',
26
+ borderWidth: '1px',
27
+ borderStyle: 'solid',
28
+ borderColor: 'hsl(var(--border))',
29
+ background: 'transparent',
30
+ color: 'hsl(var(--primary-foreground))',
31
+ cursor: 'pointer',
32
+ padding: 0,
33
+ transition: 'background-color 120ms ease, border-color 120ms ease, opacity 150ms ease',
34
+ };
35
+
36
+ const selectedStyle: CSSProperties = {
37
+ background: 'hsl(var(--primary))',
38
+ borderColor: 'hsl(var(--primary))',
39
+ };
40
+
41
+ const checkPath = 'M3.5 7.5l2.5 2.5 6.5-6.5';
42
+
43
+ export function MultiSelectCheckbox({
44
+ isSelected,
45
+ onClick,
46
+ disabled = false,
47
+ alwaysVisible = false,
48
+ ariaLabel,
49
+ className,
50
+ style,
51
+ }: MultiSelectCheckboxProps): ReactElement {
52
+ const merged: CSSProperties = {
53
+ ...baseStyle,
54
+ ...(isSelected ? selectedStyle : null),
55
+ ...(alwaysVisible || isSelected ? { opacity: 1 } : null),
56
+ ...(disabled ? { opacity: 0.45, cursor: 'not-allowed' } : null),
57
+ ...(style || null),
58
+ };
59
+
60
+ // When alwaysVisible is false and the row isn't selected, defer the
61
+ // hover-reveal to the parent — consumers wrap the checkbox in a `.group`
62
+ // hover scope and pass `data-notis-hover-reveal` styles via className.
63
+ // Out of the box, we render the checkbox at full opacity if selected,
64
+ // otherwise inherit the parent's reveal state.
65
+ return (
66
+ <button
67
+ type="button"
68
+ role="checkbox"
69
+ aria-checked={isSelected}
70
+ disabled={disabled}
71
+ aria-label={ariaLabel ?? (isSelected ? 'Deselect item' : 'Select item')}
72
+ onClick={onClick}
73
+ onMouseDown={(e) => e.stopPropagation()}
74
+ data-notis-multiselect-checkbox=""
75
+ data-selected={isSelected ? 'true' : 'false'}
76
+ className={className}
77
+ style={merged}
78
+ >
79
+ {isSelected ? (
80
+ <svg
81
+ width="10"
82
+ height="10"
83
+ viewBox="0 0 16 16"
84
+ fill="none"
85
+ stroke="currentColor"
86
+ strokeWidth={3}
87
+ strokeLinecap="round"
88
+ strokeLinejoin="round"
89
+ aria-hidden
90
+ focusable="false"
91
+ >
92
+ <path d={checkPath} />
93
+ </svg>
94
+ ) : null}
95
+ </button>
96
+ );
97
+ }