@notis_ai/cli 0.2.0-beta.155.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 (47) 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 +5180 -7281
  4. package/dist/base-skills/notis-apps/SKILL.md +141 -224
  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 +326 -1562
  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 +47 -45
  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 +62 -8
  23. package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
  24. package/template/packages/sdk/src/config.ts +0 -2
  25. package/template/packages/sdk/src/hooks/useCloudComputer.ts +15 -48
  26. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +17 -53
  27. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
  28. package/template/packages/sdk/src/hooks/useDocument.ts +12 -47
  29. package/template/packages/sdk/src/hooks/useDocuments.ts +18 -58
  30. package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
  31. package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
  32. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +15 -7
  33. package/template/packages/sdk/src/index.ts +8 -0
  34. package/template/packages/sdk/src/interactions.ts +2 -1
  35. package/template/packages/sdk/src/queryCache.ts +162 -0
  36. package/template/packages/sdk/src/runtime.ts +5 -0
  37. package/template/packages/sdk/src/styles.css +28 -1
  38. package/src/runtime/app-dev-build-supervisor.js +0 -47
  39. package/src/runtime/app-dev-build.js +0 -41
  40. package/src/runtime/app-dev-consumers.js +0 -154
  41. package/src/runtime/app-dev-host-lock.js +0 -80
  42. package/src/runtime/app-dev-process-identity.js +0 -111
  43. package/src/runtime/app-dev-roots.js +0 -284
  44. package/src/runtime/app-dev-server.js +0 -1136
  45. package/src/runtime/app-dev-sessions.js +0 -185
  46. package/src/runtime/cli-mode.generated.js +0 -5
  47. 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
+ }
@@ -1,60 +1,62 @@
1
1
  'use client';
2
2
 
3
- import { getDocumentPreview, useDocuments, useNotis } from '@notis/sdk';
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,
10
13
  // and typed content (contentMarkdown / contentBlocknote / plainText).
11
- const { documents, loading } = useDocuments('items', { pageSize: 25 });
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 : 'Loading...'}</CardTitle>
20
- <CardDescription>
21
- {ready ? app?.description : 'Loading app metadata...'}
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
- {loading ? (
34
- <p className="text-sm text-muted-foreground">Loading...</p>
35
- ) : documents.length === 0 ? (
36
- <div className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground">
37
- No items yet. Deploy the app and create some.
38
- </div>
39
- ) : (
40
- <div className="space-y-3">
41
- {documents.map((doc) => (
42
- <div key={doc.id} className="rounded-xl border border-border bg-background px-4 py-3">
43
- <div className="flex items-center justify-between gap-3">
44
- <p className="font-medium">{doc.title || 'Untitled'}</p>
45
- {typeof doc.properties.status === 'string' ? (
46
- <Badge variant="outline">{doc.properties.status}</Badge>
47
- ) : null}
48
- </div>
49
- <p className="mt-1 line-clamp-1 text-xs text-muted-foreground">
50
- {getDocumentPreview(doc)}
51
- </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>
52
51
  </div>
53
- ))}
54
- </div>
55
- )}
56
- </CardContent>
57
- </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>
58
60
  </main>
59
61
  );
60
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"