@notis_ai/cli 0.2.0-beta.156.1 → 0.2.0-beta.157.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 (35) hide show
  1. package/README.md +11 -45
  2. package/config/notis_app_design_rules.json +135 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +5168 -7271
  4. package/dist/base-skills/notis-apps/SKILL.md +108 -194
  5. package/dist/base-skills/notis-cli/SKILL.md +64 -131
  6. package/package.json +1 -2
  7. package/skills/notis-apps/cli.md +34 -95
  8. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
  9. package/src/command-specs/apps.js +322 -1560
  10. package/src/runtime/agent-browser.js +169 -1
  11. package/src/runtime/app-boundary-validator.js +221 -0
  12. package/src/runtime/app-platform.js +359 -233
  13. package/src/runtime/app-test-server.js +292 -0
  14. package/template/app/page.tsx +45 -44
  15. package/template/components/page-heading.tsx +23 -0
  16. package/template/components/ui/badge.tsx +7 -4
  17. package/template/components/ui/card.tsx +24 -11
  18. package/template/components/ui/native-select.tsx +24 -0
  19. package/template/notis.config.ts +0 -1
  20. package/template/package.json +2 -2
  21. package/template/packages/sdk/package.json +1 -2
  22. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +20 -7
  23. package/template/packages/sdk/src/config.ts +0 -2
  24. package/template/packages/sdk/src/interactions.ts +2 -1
  25. package/template/packages/sdk/src/styles.css +28 -1
  26. package/src/runtime/app-dev-build-supervisor.js +0 -47
  27. package/src/runtime/app-dev-build.js +0 -41
  28. package/src/runtime/app-dev-consumers.js +0 -154
  29. package/src/runtime/app-dev-host-lock.js +0 -80
  30. package/src/runtime/app-dev-process-identity.js +0 -111
  31. package/src/runtime/app-dev-roots.js +0 -284
  32. package/src/runtime/app-dev-server.js +0 -1136
  33. package/src/runtime/app-dev-sessions.js +0 -185
  34. package/src/runtime/cli-mode.generated.js +0 -5
  35. package/src/runtime/cli-mode.js +0 -34
@@ -0,0 +1,292 @@
1
+ /** Temporary, explicit app verification/screenshot server. Never mounts a Workspace app. */
2
+ import { createServer } from 'node:http';
3
+ import { existsSync, readFileSync, statSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { exportNameFromPath, getBundleDir, loadAppConfig, readManifest } from './app-platform.js';
7
+
8
+ const CONTENT_TYPES = { '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.map': 'application/json; charset=utf-8' };
9
+ const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
10
+ const REPO_ROOT = resolve(CLI_ROOT, '../..');
11
+ const HARNESS_TEMPLATE_PATH = join(CLI_ROOT, 'template', '.harness', 'index.html.tmpl');
12
+ const FALLBACK_REACT_VERSION = '19.0.0';
13
+
14
+ function extFor(pathname) {
15
+ const idx = pathname.lastIndexOf('.');
16
+ return idx === -1 ? '' : pathname.slice(idx);
17
+ }
18
+
19
+ function isAllowedOrigin(origin) {
20
+ if (!origin) return true;
21
+ try {
22
+ const parsed = new URL(origin);
23
+ if (parsed.protocol === 'notis-app:') return true;
24
+ if (!['http:', 'https:'].includes(parsed.protocol)) return false;
25
+ return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname);
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ function corsHeaders(origin) {
32
+ const allowOrigin = origin && isAllowedOrigin(origin) ? origin : '*';
33
+ return {
34
+ 'Access-Control-Allow-Origin': allowOrigin,
35
+ 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
36
+ 'Access-Control-Allow-Headers': 'Content-Type, Cache-Control',
37
+ 'Cache-Control': 'no-store',
38
+ };
39
+ }
40
+
41
+ function safeJoin(baseDir, relPath) {
42
+ const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
43
+ if (normalized.includes('..')) return null;
44
+ return join(baseDir, normalized);
45
+ }
46
+
47
+ function scriptJson(value) {
48
+ return JSON.stringify(value)
49
+ .replace(/</g, '\\u003c')
50
+ .replace(/\u2028/g, '\\u2028')
51
+ .replace(/\u2029/g, '\\u2029');
52
+ }
53
+
54
+ function readJsonFile(path) {
55
+ if (!existsSync(path)) {
56
+ return null;
57
+ }
58
+ try {
59
+ return JSON.parse(readFileSync(path, 'utf-8'));
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function reactVersionFromPeer(peerRange) {
66
+ if (typeof peerRange !== 'string' || !peerRange) {
67
+ return FALLBACK_REACT_VERSION;
68
+ }
69
+ const exact = peerRange.match(/\d+\.\d+\.\d+/);
70
+ if (exact && !/[<>=~^*x]/i.test(peerRange.replace(exact[0], ''))) {
71
+ return exact[0];
72
+ }
73
+ if (peerRange.includes('19') || peerRange.includes('18')) {
74
+ return FALLBACK_REACT_VERSION;
75
+ }
76
+ return FALLBACK_REACT_VERSION;
77
+ }
78
+
79
+ function resolveHarnessReactVersion(projectDir) {
80
+ const candidates = [
81
+ join(projectDir, 'node_modules', '@notis', 'sdk', 'package.json'),
82
+ join(REPO_ROOT, 'packages', 'sdk', 'package.json'),
83
+ join(CLI_ROOT, 'template', 'packages', 'sdk', 'package.json'),
84
+ ];
85
+ for (const candidate of candidates) {
86
+ const pkg = readJsonFile(candidate);
87
+ const peer = pkg?.peerDependencies?.react;
88
+ if (peer) {
89
+ return reactVersionFromPeer(peer);
90
+ }
91
+ }
92
+ return FALLBACK_REACT_VERSION;
93
+ }
94
+
95
+ function titleFromSlug(slug) {
96
+ return String(slug || '')
97
+ .replace(/[-_]+/g, ' ')
98
+ .replace(/\b\w/g, (char) => char.toUpperCase());
99
+ }
100
+
101
+ function normalizeDatabaseDescriptors(databases) {
102
+ return (Array.isArray(databases) ? databases : [])
103
+ .map((entry) => {
104
+ if (typeof entry === 'string') {
105
+ return {
106
+ slug: entry,
107
+ title: titleFromSlug(entry),
108
+ description: null,
109
+ icon: null,
110
+ properties: [],
111
+ };
112
+ }
113
+ if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
114
+ return {
115
+ slug: entry.slug,
116
+ title: entry.title || titleFromSlug(entry.slug),
117
+ description: entry.description || null,
118
+ icon: entry.icon || null,
119
+ properties: Array.isArray(entry.properties) ? entry.properties : [],
120
+ };
121
+ }
122
+ return null;
123
+ })
124
+ .filter(Boolean);
125
+ }
126
+
127
+ function normalizeToolDescriptors(tools) {
128
+ return (Array.isArray(tools) ? tools : [])
129
+ .map((entry) => {
130
+ if (typeof entry === 'string') {
131
+ return { name: entry };
132
+ }
133
+ if (entry && typeof entry === 'object' && typeof entry.name === 'string') {
134
+ return entry;
135
+ }
136
+ return null;
137
+ })
138
+ .filter(Boolean);
139
+ }
140
+
141
+ function defaultRouteForManifest(manifest) {
142
+ const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
143
+ return routes.find((route) => route?.default) || routes[0] || null;
144
+ }
145
+
146
+ function findHarnessRoute(manifest, routeSlug) {
147
+ const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
148
+ if (!routeSlug) {
149
+ return defaultRouteForManifest(manifest);
150
+ }
151
+ return routes.find((route) => route?.slug === routeSlug) || null;
152
+ }
153
+
154
+ function buildHarnessDescriptor({ state, manifest, appConfig, route, scenario = null }) {
155
+ const databases = normalizeDatabaseDescriptors(
156
+ Array.isArray(appConfig?.databases) && appConfig.databases.length
157
+ ? appConfig.databases
158
+ : manifest.databases,
159
+ );
160
+ const tools = normalizeToolDescriptors(
161
+ Array.isArray(appConfig?.tools) && appConfig.tools.length
162
+ ? appConfig.tools
163
+ : manifest.tools,
164
+ );
165
+
166
+ return {
167
+ app: {
168
+ id: state.appId || 'harness-app',
169
+ slug: state.slug,
170
+ name: manifest.app?.name || appConfig?.name || state.slug,
171
+ icon: manifest.app?.icon || appConfig?.icon || null,
172
+ description: manifest.app?.description || appConfig?.description || null,
173
+ },
174
+ route: {
175
+ slug: route.slug,
176
+ path: route.path || '/',
177
+ name: route.name || titleFromSlug(route.slug),
178
+ icon: route.icon || null,
179
+ parentSlug: route.parentSlug || null,
180
+ default: Boolean(route.default),
181
+ resourceDeepLinks: route.resourceDeepLinks === true,
182
+ collection: route.collection || null,
183
+ },
184
+ databases,
185
+ context: { collectionItem: null, resourceId: null, screenshotScenario: scenario },
186
+ tools,
187
+ };
188
+ }
189
+
190
+ function plainObject(value) {
191
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
192
+ }
193
+
194
+ /**
195
+ * Resolve the fixture payload injected into one harness page load.
196
+ *
197
+ * A scenario may override individual `tools` / `requests` keys on top of the
198
+ * file-level defaults, which is how one route renders both its populated and
199
+ * its empty state. Each capture is its own page load, so a shallow per-key
200
+ * merge is all the isolation a scenario needs.
201
+ */
202
+ function harnessFixtures(projectDir, scenario) {
203
+ const fixtureConfig = readJsonFile(join(projectDir, 'metadata', 'screenshot-fixtures.json')) || {};
204
+ const scenarios = plainObject(fixtureConfig.scenarios);
205
+ const selected = scenario ? plainObject(scenarios[scenario]) : null;
206
+ return {
207
+ tools: { ...plainObject(fixtureConfig.tools), ...plainObject(selected?.tools) },
208
+ requests: { ...plainObject(fixtureConfig.requests), ...plainObject(selected?.requests) },
209
+ scenario: selected && Object.keys(selected).length > 0 ? selected : null,
210
+ };
211
+ }
212
+
213
+ function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions, scenario = null }) {
214
+ const template = readFileSync(HARNESS_TEMPLATE_PATH, 'utf-8');
215
+ const descriptor = buildHarnessDescriptor({ state, manifest, appConfig, route, scenario });
216
+ const routeExport = route.export_name || route.exportName || exportNameFromPath(route.path || '/');
217
+ const replacements = {
218
+ '{{REACT_VERSION}}': resolveHarnessReactVersion(state.projectDir),
219
+ '{{ROUTE_EXPORT}}': scriptJson(routeExport),
220
+ '{{RUNTIME_DESCRIPTOR}}': scriptJson(descriptor),
221
+ '{{MODE}}': scriptJson(harnessOptions.mode || 'stub'),
222
+ '{{API_BASE}}': scriptJson(harnessOptions.apiBase || null),
223
+ '{{JWT}}': scriptJson(harnessOptions.jwt || null),
224
+ '{{FIXTURES}}': scriptJson(harnessFixtures(state.projectDir, scenario)),
225
+ };
226
+ let html = template;
227
+ for (const [token, value] of Object.entries(replacements)) {
228
+ html = html.replaceAll(token, value);
229
+ }
230
+ return html;
231
+ }
232
+
233
+
234
+ /** Serve only explicitly supplied built projects, without discovery or filesystem watchers. */
235
+ export async function startAppTestServer({ apps, port, harness = {} }) {
236
+ if (!Array.isArray(apps) || !apps.length) throw new Error('At least one built app is required.');
237
+ const states = new Map();
238
+ for (const app of apps) {
239
+ states.set(app.slug, {
240
+ ...app,
241
+ manifest: readManifest(app.projectDir),
242
+ appConfig: await loadAppConfig(app.projectDir),
243
+ bundleDir: getBundleDir(app.projectDir),
244
+ });
245
+ }
246
+ const server = createServer((req, res) => {
247
+ const headers = corsHeaders(req.headers.origin || '');
248
+ const respond = (status, content, contentType = 'text/plain; charset=utf-8') => {
249
+ res.writeHead(status, { ...headers, 'Content-Type': contentType });
250
+ res.end(req.method === 'HEAD' ? undefined : content);
251
+ };
252
+ try {
253
+ if (req.headers.origin && !isAllowedOrigin(req.headers.origin)) return respond(403, 'origin not allowed');
254
+ if (req.method === 'OPTIONS') return respond(204, '');
255
+ if (!['GET', 'HEAD'].includes(req.method)) return respond(405, 'method not allowed');
256
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
257
+ if (url.pathname === '/healthz') return respond(200, JSON.stringify({ ok: true }), 'application/json');
258
+ const match = url.pathname.match(/^\/a\/([^/]+)\/(.*)$/);
259
+ const state = match && states.get(decodeURIComponent(match[1]));
260
+ if (!state) return respond(404, 'not found');
261
+ if (match[2] === 'harness') {
262
+ const route = findHarnessRoute(state.manifest, url.searchParams.get('route') || '');
263
+ if (!route) return respond(404, 'unknown route');
264
+ return respond(200, renderHarnessHtml({ state, manifest: state.manifest, appConfig: state.appConfig, route, harnessOptions: harness, scenario: url.searchParams.get('scenario') }), 'text/html; charset=utf-8');
265
+ }
266
+ if (match[2].startsWith('bundle/')) {
267
+ const relativePath = decodeURIComponent(match[2].slice('bundle/'.length));
268
+ const file = safeJoin(state.bundleDir, relativePath);
269
+ if (!file || !existsSync(file) || !statSync(file).isFile()) return respond(404, 'not found');
270
+ return respond(200, readFileSync(file), CONTENT_TYPES[extFor(file)] || 'application/octet-stream');
271
+ }
272
+ return respond(404, 'not found');
273
+ } catch (error) {
274
+ return respond(500, error instanceof Error ? error.message : String(error));
275
+ }
276
+ });
277
+ await new Promise((accept, reject) => {
278
+ server.once('error', reject);
279
+ server.listen(port, '127.0.0.1', () => { server.off('error', reject); accept(); });
280
+ });
281
+ let closing;
282
+ return {
283
+ port: server.address().port,
284
+ close() {
285
+ closing ||= new Promise((accept) => {
286
+ server.close(accept);
287
+ server.closeAllConnections?.();
288
+ });
289
+ return closing;
290
+ },
291
+ };
292
+ }
@@ -2,8 +2,11 @@
2
2
 
3
3
  import { getDocumentPreview, useDocuments, useNotis, Skeleton, ViewSkeleton } from '@notis/sdk';
4
4
  import { Badge } from '@/components/ui/badge';
5
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
5
+ import { PageHeading } from '@/components/page-heading';
6
6
 
7
+ // Reference page for the flat Notis design bar: a plain page header, bare
8
+ // figures, and a hairline-free list. No bordered boxes, no dividers, no
9
+ // palette colors, no loading text. `notis apps build` enforces these rules.
7
10
  export default function HomePage() {
8
11
  const { app, ready } = useNotis();
9
12
  // Documents come back normalized: plain property values, camelCase fields,
@@ -11,51 +14,49 @@ export default function HomePage() {
11
14
  const { documents, loading, hasData, error, refetch } = useDocuments('items', { pageSize: 25 });
12
15
 
13
16
  return (
14
- <main className="notis-app-shell space-y-6">
15
- <Card>
16
- <CardHeader className="space-y-3">
17
- <Badge variant="secondary" className="w-fit">Installed app</Badge>
18
- <div className="space-y-2">
19
- <CardTitle>{ready ? app?.name : <Skeleton style={{ width: 160 }} />}</CardTitle>
20
- <CardDescription>
21
- {ready ? app?.description : <Skeleton style={{ width: 240 }} />}
22
- </CardDescription>
23
- </div>
24
- </CardHeader>
25
- </Card>
17
+ <main className="notis-app-shell space-y-8">
18
+ <PageHeading
19
+ title={ready ? app?.name ?? 'Items' : <Skeleton style={{ width: 160, height: 28 }} />}
20
+ description={ready ? app?.description ?? undefined : <Skeleton style={{ width: 240 }} />}
21
+ />
22
+
23
+ <section className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
24
+ <div className="min-w-0">
25
+ <p className="text-xs font-medium text-muted-foreground">Items</p>
26
+ <p className="mt-1 text-2xl font-semibold tabular-nums">
27
+ {hasData ? documents.length : <Skeleton style={{ width: 48, height: 28 }} />}
28
+ </p>
29
+ </div>
30
+ </section>
26
31
 
27
- <Card>
28
- <CardHeader>
29
- <CardTitle>Items</CardTitle>
30
- <CardDescription>Use shadcn surfaces and portal tokens so the app feels native inside Notis.</CardDescription>
31
- </CardHeader>
32
- <CardContent>
33
- {error && <p role="alert" className="mb-3 text-sm text-destructive">{error.message} <button onClick={refetch}>Retry</button></p>}
34
- {loading ? (
35
- <ViewSkeleton variant="table" rows={4} />
36
- ) : !hasData ? null : documents.length === 0 ? (
37
- <div className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground">
38
- No items yet. Create your first item.
39
- </div>
40
- ) : (
41
- <div className="space-y-3">
42
- {documents.map((doc) => (
43
- <div key={doc.id} className="rounded-xl border border-border bg-background px-4 py-3">
44
- <div className="flex items-center justify-between gap-3">
45
- <p className="font-medium">{doc.title || 'Untitled'}</p>
46
- {typeof doc.properties.status === 'string' ? (
47
- <Badge variant="outline">{doc.properties.status}</Badge>
48
- ) : null}
49
- </div>
50
- <p className="mt-1 line-clamp-1 text-xs text-muted-foreground">
51
- {getDocumentPreview(doc)}
52
- </p>
32
+ <section className="space-y-3">
33
+ <h2 className="text-base font-semibold">Recent items</h2>
34
+ {error ? (
35
+ <p role="alert" className="rounded-xl bg-destructive/10 px-4 py-3 text-sm text-destructive">
36
+ {error.message}{' '}
37
+ <button type="button" className="font-medium underline" onClick={refetch}>Retry</button>
38
+ </p>
39
+ ) : null}
40
+ {loading ? (
41
+ <ViewSkeleton variant="table" rows={4} />
42
+ ) : !hasData ? null : documents.length === 0 ? (
43
+ <p className="px-4 py-10 text-center text-sm text-muted-foreground">No items yet. Create your first item.</p>
44
+ ) : (
45
+ <div className="space-y-2 lg:space-y-0">
46
+ {documents.map((doc) => (
47
+ <div key={doc.id} className="list-row flex items-center justify-between gap-3">
48
+ <div className="min-w-0">
49
+ <p className="truncate text-sm font-medium">{doc.title || 'Untitled'}</p>
50
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">{getDocumentPreview(doc)}</p>
53
51
  </div>
54
- ))}
55
- </div>
56
- )}
57
- </CardContent>
58
- </Card>
52
+ {typeof doc.properties.status === 'string' ? (
53
+ <Badge variant="secondary" className="shrink-0">{doc.properties.status}</Badge>
54
+ ) : null}
55
+ </div>
56
+ ))}
57
+ </div>
58
+ )}
59
+ </section>
59
60
  </main>
60
61
  );
61
62
  }
@@ -0,0 +1,23 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ // Mirrors the portal's PortalPageHeader: plain title, one-line description,
4
+ // actions on the right. No eyebrow, no rule underneath.
5
+ export function PageHeading({
6
+ title,
7
+ description,
8
+ actions,
9
+ }: {
10
+ title: ReactNode;
11
+ description?: ReactNode;
12
+ actions?: ReactNode;
13
+ }) {
14
+ return (
15
+ <header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
16
+ <div className="min-w-0 flex-1">
17
+ <h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
18
+ {description ? <p className="mt-1.5 text-sm text-muted-foreground">{description}</p> : null}
19
+ </div>
20
+ {actions ? <div className="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">{actions}</div> : null}
21
+ </header>
22
+ );
23
+ }
@@ -4,13 +4,16 @@ import { cva, type VariantProps } from 'class-variance-authority';
4
4
  import { cn } from '@/lib/utils';
5
5
 
6
6
  const badgeVariants = cva(
7
- 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
7
+ 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
8
8
  {
9
9
  variants: {
10
10
  variant: {
11
- default: 'border-transparent bg-primary/10 text-primary',
12
- secondary: 'border-transparent bg-secondary text-secondary-foreground',
13
- outline: 'text-foreground',
11
+ default: 'bg-primary/10 text-primary',
12
+ secondary: 'bg-foreground/[0.07] text-foreground',
13
+ destructive: 'bg-destructive/10 text-destructive',
14
+ // `outline` is kept for API compatibility and renders as plain text: the
15
+ // design bar has no outlined badges.
16
+ outline: 'bg-transparent text-foreground',
14
17
  },
15
18
  },
16
19
  defaultVariants: {
@@ -2,20 +2,33 @@ import * as React from 'react';
2
2
 
3
3
  import { cn } from '@/lib/utils';
4
4
 
5
+ // Surfaces are flat: a tinted panel on the page, and a plain background panel
6
+ // when nested inside another Card. No borders, no dividers, no shadows.
7
+ const CardDepthContext = React.createContext(0);
8
+
5
9
  const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
6
- ({ className, ...props }, ref) => (
7
- <div
8
- ref={ref}
9
- className={cn('rounded-2xl border bg-card text-card-foreground shadow-sm', className)}
10
- {...props}
11
- />
12
- ),
10
+ ({ className, ...props }, ref) => {
11
+ const depth = React.useContext(CardDepthContext);
12
+ return (
13
+ <CardDepthContext.Provider value={depth + 1}>
14
+ <div
15
+ ref={ref}
16
+ className={cn(
17
+ 'min-w-0 rounded-2xl text-card-foreground',
18
+ depth === 0 ? 'bg-muted' : 'bg-background',
19
+ className,
20
+ )}
21
+ {...props}
22
+ />
23
+ </CardDepthContext.Provider>
24
+ );
25
+ },
13
26
  );
14
27
  Card.displayName = 'Card';
15
28
 
16
29
  const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
17
30
  ({ className, ...props }, ref) => (
18
- <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
31
+ <div ref={ref} className={cn('flex flex-col space-y-1.5 p-5', className)} {...props} />
19
32
  ),
20
33
  );
21
34
  CardHeader.displayName = 'CardHeader';
@@ -24,7 +37,7 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
24
37
  ({ className, ...props }, ref) => (
25
38
  <h3
26
39
  ref={ref}
27
- className={cn('text-xl font-semibold leading-none tracking-tight', className)}
40
+ className={cn('text-base font-semibold leading-none tracking-tight', className)}
28
41
  {...props}
29
42
  />
30
43
  ),
@@ -41,14 +54,14 @@ CardDescription.displayName = 'CardDescription';
41
54
 
42
55
  const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
43
56
  ({ className, ...props }, ref) => (
44
- <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
57
+ <div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
45
58
  ),
46
59
  );
47
60
  CardContent.displayName = 'CardContent';
48
61
 
49
62
  const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
50
63
  ({ className, ...props }, ref) => (
51
- <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
64
+ <div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} />
52
65
  ),
53
66
  );
54
67
  CardFooter.displayName = 'CardFooter';
@@ -0,0 +1,24 @@
1
+ import * as React from 'react';
2
+ import { CaretDown } from '@phosphor-icons/react';
3
+
4
+ import { cn } from '@/lib/utils';
5
+
6
+ // Borderless native select: a tinted pill with a caret. Keeps the platform
7
+ // dropdown (no portal needed inside the app surface) and the portal focus ring.
8
+ const NativeSelect = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
9
+ ({ className, children, ...props }, ref) => (
10
+ <span className={cn('relative inline-flex', className)}>
11
+ <select
12
+ ref={ref}
13
+ className="h-9 w-full appearance-none rounded-md bg-muted pl-3 pr-8 text-sm text-foreground outline-none transition-colors hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
14
+ {...props}
15
+ >
16
+ {children}
17
+ </select>
18
+ <CaretDown className="pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
19
+ </span>
20
+ ),
21
+ );
22
+ NativeSelect.displayName = 'NativeSelect';
23
+
24
+ export { NativeSelect };
@@ -2,7 +2,6 @@ import { defineNotisApp } from '@notis/sdk/config';
2
2
 
3
3
  export default defineNotisApp({
4
4
  name: 'my-notis-app',
5
- devSlug: 'my-notis-app',
6
5
  title: 'My Notis App',
7
6
  description: 'A focused Notis app with one route and one database-backed workflow.',
8
7
  icon: 'phosphor:squares-four',
@@ -5,8 +5,8 @@
5
5
  "private": true,
6
6
  "type": "module",
7
7
  "scripts": {
8
- "dev": "vite",
9
- "build": "vite build"
8
+ "dev": "vite --configLoader runner",
9
+ "build": "vite build --configLoader runner"
10
10
  },
11
11
  "dependencies": {
12
12
  "@notis/sdk": "file:./packages/sdk",
@@ -12,8 +12,7 @@
12
12
  "./styles.css": "./src/styles.css"
13
13
  },
14
14
  "files": [
15
- "src",
16
- "template"
15
+ "src"
17
16
  ],
18
17
  "scripts": {
19
18
  "type-check": "tsc --noEmit"
@@ -14,6 +14,13 @@ import type { ShortcutScope } from '../interactions/shortcuts';
14
14
 
15
15
  export type MultiSelectAction = ResolvedCollectionAction;
16
16
 
17
+ /** The Portal owns global launcher positioning; app code only reports its own bar. */
18
+ export const BULK_ACTION_LAYOUT_EVENT = 'notis:bulk-actions-layout';
19
+ export interface BulkActionLayoutDetail {
20
+ bar: HTMLElement;
21
+ active: boolean;
22
+ }
23
+
17
24
  export interface MultiSelectActionBarProps {
18
25
  selectedCount: number;
19
26
  actions: MultiSelectAction[];
@@ -124,18 +131,24 @@ export function MultiSelectActionBar({
124
131
  useEffect(() => {
125
132
  const bar = barRef.current;
126
133
  if (!visible || !bar) return;
127
- // Also reaches a Portal launcher when an app renders inside a shadow root.
128
- const root = document.documentElement;
129
- const property = '--notis-bulk-actions-height';
130
- const previous = root.style.getPropertyValue(property);
131
- const update = () => root.style.setProperty(property, `${bar.getBoundingClientRect().height + 12}px`);
134
+ // Notify the owning document, including from a shadow root or after unmount.
135
+ // Only the Portal host may measure this bar and change global layout styles.
136
+ const report = (active: boolean) => {
137
+ const owner = bar.ownerDocument;
138
+ const LayoutEvent = owner.defaultView?.CustomEvent;
139
+ if (LayoutEvent) {
140
+ owner.dispatchEvent(new LayoutEvent<BulkActionLayoutDetail>(BULK_ACTION_LAYOUT_EVENT, {
141
+ detail: { bar, active },
142
+ }));
143
+ }
144
+ };
145
+ const update = () => report(true);
132
146
  update();
133
147
  const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
134
148
  observer?.observe(bar);
135
149
  return () => {
136
150
  observer?.disconnect();
137
- if (previous) root.style.setProperty(property, previous);
138
- else root.style.removeProperty(property);
151
+ report(false);
139
152
  };
140
153
  }, [visible]);
141
154
 
@@ -198,8 +198,6 @@ export interface NotisAppToolBinding {
198
198
  export interface NotisAppConfig {
199
199
  /** URL-safe app slug. Existing apps may still use a display name here. */
200
200
  name: string;
201
- /** Stable local-development slug. Defaults to a slug derived from `name`. */
202
- devSlug?: string;
203
201
  /** Human display title, Raycast-style. Falls back to `name`. */
204
202
  title?: string;
205
203
  description?: string;
@@ -29,7 +29,8 @@ export type {
29
29
  SelectionMarqueeRect,
30
30
  UseCollectionInteractionsOptions,
31
31
  } from './hooks/useCollectionInteractions';
32
- export { MultiSelectActionBar } from './components/MultiSelectActionBar';
32
+ export { MultiSelectActionBar, BULK_ACTION_LAYOUT_EVENT } from './components/MultiSelectActionBar';
33
+ export type { BulkActionLayoutDetail } from './components/MultiSelectActionBar';
33
34
  export { MultiSelectCheckbox, MultiSelectCheckbox as SelectionCheckbox } from './components/MultiSelectCheckbox';
34
35
  export { MultiSelectDragOverlay, MultiSelectDragOverlay as SelectionMarquee } from './components/MultiSelectDragOverlay';
35
36
  export { ShortcutHints } from './components/ShortcutHints';