@keemakr/ui-sdk 0.1.1 → 1.0.0-dev-20260807074219

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.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # Keemakr UI SDK
2
+
3
+ Build server-driven module screens that Keemakr renders in Dashboard and Kee clients. Your module
4
+ returns structured UI data. It does not send HTML or browser code.
5
+
6
+ ## 1. Organize UI by surface
7
+
8
+ Keep `ui/` at the module root, separate from agents:
9
+
10
+ ```text
11
+ ui/
12
+ campaigns/
13
+ surface.ts
14
+ screen.ts
15
+ campaign/
16
+ screen.ts
17
+ campaign-settings/
18
+ surface.ts
19
+ screen.ts
20
+ ```
21
+
22
+ A surface is one complete navigable flow. Every nested `screen.ts` inherits its surface's
23
+ availability. Put shared helpers under `ui/_shared/`; the generator ignores underscore-prefixed
24
+ directories.
25
+
26
+ ## 2. Declare where a surface is available
27
+
28
+ ```ts
29
+ import { defineSurface } from '@keemakr/ui-sdk';
30
+
31
+ export default defineSurface({
32
+ label: 'Campaigns',
33
+ availableOn: ['dashboard', 'client'],
34
+ });
35
+ ```
36
+
37
+ Use `['dashboard']` for tenant administration screens that should not appear in Kee clients. The
38
+ array is required and may contain `dashboard`, `client`, or both.
39
+
40
+ ## 3. Define screens
41
+
42
+ ```ts
43
+ import { defineScreen } from '@keemakr/ui-sdk';
44
+
45
+ export default defineScreen({
46
+ load: async () => ({
47
+ version: 1,
48
+ title: 'Campaigns',
49
+ children: [{ type: 'text', value: 'Your campaigns appear here.' }],
50
+ }),
51
+ });
52
+ ```
53
+
54
+ Folder names are screen identities. For example, `ui/campaigns/campaign/screen.ts` is the
55
+ `campaign` screen. Screen folder names must be unique across the module.
56
+
57
+ ## 4. Generate before Eve runs
58
+
59
+ Use the SDK wrapper in the module scripts:
60
+
61
+ ```json
62
+ {
63
+ "scripts": {
64
+ "dev": "keemakr-ui dev -- eve dev",
65
+ "build": "keemakr-ui build && eve build"
66
+ }
67
+ }
68
+ ```
69
+
70
+ The generator validates `ui/`, writes `ui/screen-registry.ts`, and synchronizes
71
+ `manifest.surfaces` plus `manifest.screenRegistry` in `entry.json`. Do not edit those generated
72
+ fields or the registry by hand. A module with no surfaces omits both fields. Removing a screen
73
+ records its identity so later builds keep warning about the breaking change.
74
+
75
+ Core uses the installed manifest to filter navigation and to gate every load and action before it
76
+ calls the module runtime. Availability controls UI placement. Existing authentication and roles
77
+ still control who may enter Dashboard or a Kee client.
78
+
79
+ See [`examples/campaigns`](./examples/campaigns) for one complete in-memory example.
@@ -0,0 +1,9 @@
1
+ import { type UiManifest } from './schema.js';
2
+ export interface BuildModuleUiResult {
3
+ manifest: UiManifest;
4
+ registryPath: string;
5
+ entryPath: string;
6
+ removedScreens: string[];
7
+ }
8
+ /** Validate root ui/, generate its runtime registry, and synchronize entry.json. */
9
+ export declare function buildModuleUi(projectRoot?: string): Promise<BuildModuleUiResult>;
package/dist/build.js ADDED
@@ -0,0 +1,264 @@
1
+ import { readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { basename, dirname, join, relative, sep } from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+ import ts from 'typescript';
5
+ import { surfaceDefinitionSchema, uiManifestSchema, } from './schema.js';
6
+ const SLUG = /^[a-z][a-z0-9_-]*$/;
7
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
8
+ const REMOVED_SCREENS_META_KEY = 'uiSdkRemovedScreens';
9
+ function unwrap(expression) {
10
+ if (ts.isParenthesizedExpression(expression) ||
11
+ ts.isAsExpression(expression) ||
12
+ ts.isSatisfiesExpression(expression)) {
13
+ return unwrap(expression.expression);
14
+ }
15
+ return expression;
16
+ }
17
+ function literalValue(expression) {
18
+ const value = unwrap(expression);
19
+ if (ts.isStringLiteralLike(value))
20
+ return value.text;
21
+ if (value.kind === ts.SyntaxKind.TrueKeyword)
22
+ return true;
23
+ if (value.kind === ts.SyntaxKind.FalseKeyword)
24
+ return false;
25
+ if (ts.isArrayLiteralExpression(value))
26
+ return value.elements.map(literalValue);
27
+ throw new Error('surface.ts values must be string or array literals');
28
+ }
29
+ async function readSurfaceDefinition(path) {
30
+ const source = ts.createSourceFile(path, await readFile(path, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
31
+ let object;
32
+ for (const statement of source.statements) {
33
+ if (!ts.isExportAssignment(statement))
34
+ continue;
35
+ const exported = unwrap(statement.expression);
36
+ if (!ts.isCallExpression(exported) || exported.arguments.length !== 1)
37
+ continue;
38
+ if (!ts.isIdentifier(exported.expression) || exported.expression.text !== 'defineSurface')
39
+ continue;
40
+ const argument = unwrap(exported.arguments[0]);
41
+ if (ts.isObjectLiteralExpression(argument))
42
+ object = argument;
43
+ }
44
+ if (!object) {
45
+ throw new Error(`${path}: default export must be defineSurface({ ... })`);
46
+ }
47
+ const raw = {};
48
+ for (const property of object.properties) {
49
+ if (!ts.isPropertyAssignment(property)) {
50
+ throw new Error(`${path}: surface properties must use key: value syntax`);
51
+ }
52
+ const name = property.name;
53
+ if (!ts.isIdentifier(name) && !ts.isStringLiteral(name)) {
54
+ throw new Error(`${path}: surface property names must be static`);
55
+ }
56
+ raw[name.text] = literalValue(property.initializer);
57
+ }
58
+ const allowed = new Set(['label', 'icon', 'description', 'availableOn']);
59
+ for (const key of Object.keys(raw)) {
60
+ if (!allowed.has(key))
61
+ throw new Error(`${path}: unknown surface property "${key}"`);
62
+ }
63
+ const parsed = surfaceDefinitionSchema.safeParse(raw);
64
+ if (!parsed.success) {
65
+ const issue = parsed.error.issues[0];
66
+ throw new Error(`${path}: ${issue?.path.join('.') || 'surface'} ${issue?.message ?? 'is invalid'}`);
67
+ }
68
+ return parsed.data;
69
+ }
70
+ async function isFile(path) {
71
+ return stat(path)
72
+ .then((value) => value.isFile())
73
+ .catch(() => false);
74
+ }
75
+ async function collectScreenFiles(directory) {
76
+ const out = [];
77
+ if (await isFile(join(directory, 'screen.ts')))
78
+ out.push(join(directory, 'screen.ts'));
79
+ const entries = await readdir(directory, { withFileTypes: true });
80
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
81
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_'))
82
+ continue;
83
+ const child = join(directory, entry.name);
84
+ if (await isFile(join(child, 'surface.ts'))) {
85
+ throw new Error(`${join(child, 'surface.ts')}: nested surfaces are not allowed`);
86
+ }
87
+ out.push(...(await collectScreenFiles(child)));
88
+ }
89
+ return out;
90
+ }
91
+ function moduleSpecifier(fromDirectory, file) {
92
+ const value = relative(fromDirectory, file).split(sep).join('/').replace(/\.ts$/, '.js');
93
+ return value.startsWith('.') ? value : `./${value}`;
94
+ }
95
+ function propertyKey(key) {
96
+ return IDENTIFIER.test(key) ? key : `'${key}'`;
97
+ }
98
+ async function readIfPresent(path) {
99
+ return readFile(path, 'utf8').catch((error) => {
100
+ if (error.code === 'ENOENT')
101
+ return undefined;
102
+ throw error;
103
+ });
104
+ }
105
+ function temporaryPath(path, index) {
106
+ return `${path}.keemakr-ui-${process.pid}-${Date.now()}-${index}.tmp`;
107
+ }
108
+ async function restoreOutput(output) {
109
+ if (output.previous === undefined) {
110
+ await rm(output.path, { force: true });
111
+ return;
112
+ }
113
+ const restorePath = temporaryPath(output.path, -1);
114
+ await writeFile(restorePath, output.previous);
115
+ await rename(restorePath, output.path);
116
+ }
117
+ /**
118
+ * Prepare every generated file before replacing either live output. A process
119
+ * can still die between two renames, but ordinary write/rename failures restore
120
+ * the previous pair rather than leaving one generated contract half-updated.
121
+ */
122
+ async function replaceGeneratedOutputs(outputs) {
123
+ const changed = [];
124
+ for (const output of outputs) {
125
+ const previous = await readIfPresent(output.path);
126
+ if (previous !== output.contents)
127
+ changed.push({ ...output, previous });
128
+ }
129
+ try {
130
+ for (const [index, output] of changed.entries()) {
131
+ if (output.contents === undefined)
132
+ continue;
133
+ output.temporaryPath = temporaryPath(output.path, index);
134
+ await writeFile(output.temporaryPath, output.contents);
135
+ }
136
+ const committed = [];
137
+ try {
138
+ for (const output of changed) {
139
+ if (output.temporaryPath)
140
+ await rename(output.temporaryPath, output.path);
141
+ else
142
+ await rm(output.path, { force: true });
143
+ committed.push(output);
144
+ }
145
+ }
146
+ catch (error) {
147
+ const restores = await Promise.allSettled(committed.reverse().map(restoreOutput));
148
+ const restoreFailure = restores.find((result) => result.status === 'rejected');
149
+ if (restoreFailure?.status === 'rejected') {
150
+ throw new AggregateError([error, restoreFailure.reason], 'generated UI outputs could not be committed or restored');
151
+ }
152
+ throw error;
153
+ }
154
+ }
155
+ finally {
156
+ await Promise.all(changed.flatMap((output) => output.temporaryPath ? [rm(output.temporaryPath, { force: true })] : []));
157
+ }
158
+ }
159
+ function stringArray(value) {
160
+ return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : [];
161
+ }
162
+ /** Validate root ui/, generate its runtime registry, and synchronize entry.json. */
163
+ export async function buildModuleUi(projectRoot = process.cwd()) {
164
+ const uiRoot = join(projectRoot, 'ui');
165
+ const entryPath = join(projectRoot, 'entry.json');
166
+ const entries = await readdir(uiRoot, { withFileTypes: true }).catch((error) => {
167
+ if (error.code === 'ENOENT')
168
+ return [];
169
+ throw error;
170
+ });
171
+ const surfaces = [];
172
+ const screenRegistry = [];
173
+ const registryImports = [];
174
+ const seenScreens = new Map();
175
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
176
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_'))
177
+ continue;
178
+ if (!SLUG.test(entry.name))
179
+ throw new Error(`surface directory "${entry.name}" is not a slug`);
180
+ const surfaceDirectory = join(uiRoot, entry.name);
181
+ const definitionPath = join(surfaceDirectory, 'surface.ts');
182
+ const rootScreenPath = join(surfaceDirectory, 'screen.ts');
183
+ if (!(await isFile(definitionPath)))
184
+ throw new Error(`${definitionPath}: file is required`);
185
+ if (!(await isFile(rootScreenPath)))
186
+ throw new Error(`${rootScreenPath}: file is required`);
187
+ const definition = await readSurfaceDefinition(definitionPath);
188
+ surfaces.push({ key: entry.name, screen: entry.name, ...definition });
189
+ for (const file of await collectScreenFiles(surfaceDirectory)) {
190
+ const screen = basename(dirname(file));
191
+ if (!SLUG.test(screen))
192
+ throw new Error(`${file}: screen directory "${screen}" is not a slug`);
193
+ const previous = seenScreens.get(screen);
194
+ if (previous) {
195
+ throw new Error(`duplicate screen identity "${screen}" in ${previous} and ${file}`);
196
+ }
197
+ seenScreens.set(screen, file);
198
+ screenRegistry.push({ screen, surface: entry.name });
199
+ registryImports.push({ screen, file });
200
+ }
201
+ }
202
+ screenRegistry.sort((a, b) => a.screen.localeCompare(b.screen));
203
+ registryImports.sort((a, b) => a.screen.localeCompare(b.screen));
204
+ const manifest = surfaces.length === 0
205
+ ? { surfaces, screenRegistry }
206
+ : uiManifestSchema.parse({ surfaces, screenRegistry });
207
+ const entrySource = await readFile(entryPath, 'utf8');
208
+ const originalEntryManifest = JSON.parse(entrySource);
209
+ const entryManifest = JSON.parse(entrySource);
210
+ if (!entryManifest.manifest || typeof entryManifest.manifest !== 'object') {
211
+ throw new Error(`${entryPath}: manifest object is required`);
212
+ }
213
+ const registryPath = join(uiRoot, 'screen-registry.ts');
214
+ const imports = registryImports
215
+ .map((item, index) => `import screen${index} from '${moduleSpecifier(uiRoot, item.file)}';`)
216
+ .join('\n');
217
+ const properties = registryImports
218
+ .map((item, index) => ` ${propertyKey(item.screen)}: screen${index},`)
219
+ .join('\n');
220
+ const registryContents = surfaces.length === 0
221
+ ? undefined
222
+ : `// Generated by @keemakr/ui-sdk. Do not edit.\n${imports}\n\nexport const SCREENS = {\n${properties}\n} as const;\n`;
223
+ const previousScreens = Array.isArray(entryManifest.manifest.screenRegistry)
224
+ ? entryManifest.manifest.screenRegistry.flatMap((item) => item && typeof item === 'object' && 'screen' in item && typeof item.screen === 'string'
225
+ ? [item.screen]
226
+ : [])
227
+ : [];
228
+ const currentScreens = new Set(manifest.screenRegistry.map((item) => item.screen));
229
+ const meta = entryManifest.manifest.meta && typeof entryManifest.manifest.meta === 'object'
230
+ ? entryManifest.manifest.meta
231
+ : {};
232
+ const removedScreens = [
233
+ ...new Set([
234
+ ...stringArray(meta[REMOVED_SCREENS_META_KEY]),
235
+ ...previousScreens.filter((screen) => !currentScreens.has(screen)),
236
+ ]),
237
+ ]
238
+ .filter((screen) => !currentScreens.has(screen))
239
+ .sort();
240
+ if (surfaces.length === 0) {
241
+ delete entryManifest.manifest.surfaces;
242
+ delete entryManifest.manifest.screenRegistry;
243
+ }
244
+ else {
245
+ entryManifest.manifest.surfaces = manifest.surfaces;
246
+ entryManifest.manifest.screenRegistry = manifest.screenRegistry;
247
+ }
248
+ if (removedScreens.length > 0) {
249
+ entryManifest.manifest.meta = { ...meta, [REMOVED_SCREENS_META_KEY]: removedScreens };
250
+ }
251
+ else if (REMOVED_SCREENS_META_KEY in meta) {
252
+ const remainingMeta = { ...meta };
253
+ delete remainingMeta[REMOVED_SCREENS_META_KEY];
254
+ entryManifest.manifest.meta = remainingMeta;
255
+ }
256
+ const entryContents = isDeepStrictEqual(entryManifest, originalEntryManifest)
257
+ ? entrySource
258
+ : `${JSON.stringify(entryManifest, null, 2)}\n`;
259
+ await replaceGeneratedOutputs([
260
+ { path: registryPath, contents: registryContents },
261
+ { path: entryPath, contents: entryContents },
262
+ ]);
263
+ return { manifest, registryPath, entryPath, removedScreens };
264
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ import { watch } from 'node:fs';
3
+ import { spawn } from 'node:child_process';
4
+ import { join } from 'node:path';
5
+ import { buildModuleUi } from './build.js';
6
+ async function build() {
7
+ const result = await buildModuleUi();
8
+ if (result.removedScreens.length > 0) {
9
+ console.warn(`Warning: removed screen identities: ${result.removedScreens.join(', ')}. Treat path renames as breaking changes.`);
10
+ }
11
+ console.log(`Built ${result.manifest.surfaces.length} UI surface(s) and ${result.manifest.screenRegistry.length} screen(s).`);
12
+ }
13
+ function createRebuildScheduler() {
14
+ let buildInFlight;
15
+ let dirty = false;
16
+ return () => {
17
+ if (buildInFlight) {
18
+ dirty = true;
19
+ return;
20
+ }
21
+ buildInFlight = (async () => {
22
+ do {
23
+ dirty = false;
24
+ try {
25
+ await build();
26
+ }
27
+ catch (error) {
28
+ console.error(error instanceof Error ? error.message : error);
29
+ }
30
+ } while (dirty);
31
+ })();
32
+ void buildInFlight.finally(() => {
33
+ buildInFlight = undefined;
34
+ });
35
+ };
36
+ }
37
+ function createUiWatcher(onChange) {
38
+ const directory = join(process.cwd(), 'ui');
39
+ try {
40
+ return watch(directory, { recursive: true }, (_event, filename) => {
41
+ if (!filename || filename.endsWith('screen-registry.ts'))
42
+ return;
43
+ onChange();
44
+ });
45
+ }
46
+ catch (error) {
47
+ const detail = error instanceof Error ? error.message : String(error);
48
+ throw new Error(`Unable to recursively watch ${directory}. keemakr-ui dev requires Node >=20.9 with recursive fs.watch support on this platform. Upgrade Node or run \"keemakr-ui build\" after UI changes. (${detail})`);
49
+ }
50
+ }
51
+ async function dev(command) {
52
+ await build();
53
+ let timer;
54
+ const requestRebuild = createRebuildScheduler();
55
+ const watcher = createUiWatcher(() => {
56
+ if (timer)
57
+ clearTimeout(timer);
58
+ timer = setTimeout(() => {
59
+ timer = undefined;
60
+ requestRebuild();
61
+ }, 50);
62
+ });
63
+ let watcherClosed = false;
64
+ const closeWatcher = () => {
65
+ if (timer) {
66
+ clearTimeout(timer);
67
+ timer = undefined;
68
+ }
69
+ if (watcherClosed)
70
+ return;
71
+ watcherClosed = true;
72
+ watcher.close();
73
+ };
74
+ let child;
75
+ watcher.once('error', (error) => {
76
+ closeWatcher();
77
+ console.error(`UI watcher stopped: ${error.message}`);
78
+ child?.kill('SIGTERM');
79
+ process.exitCode = 1;
80
+ });
81
+ const childCommand = command[0] ?? 'eve';
82
+ const childArgs = command.length > 0 ? command.slice(1) : ['dev'];
83
+ child = spawn(childCommand, childArgs, { stdio: 'inherit' });
84
+ const stop = (signal) => {
85
+ closeWatcher();
86
+ child?.kill(signal);
87
+ };
88
+ process.once('SIGINT', () => stop('SIGINT'));
89
+ process.once('SIGTERM', () => stop('SIGTERM'));
90
+ child.once('error', (error) => {
91
+ closeWatcher();
92
+ console.error(`Unable to start ${childCommand}: ${error.message}`);
93
+ process.exitCode = 1;
94
+ });
95
+ child.once('exit', (code, signal) => {
96
+ closeWatcher();
97
+ process.exitCode = signal ? 1 : (code ?? 1);
98
+ });
99
+ }
100
+ async function main() {
101
+ const [command = 'build', ...args] = process.argv.slice(2);
102
+ if (command === 'build') {
103
+ await build();
104
+ }
105
+ else if (command === 'dev') {
106
+ const separator = args.indexOf('--');
107
+ await dev(separator === -1 ? args : args.slice(separator + 1));
108
+ }
109
+ else {
110
+ throw new Error('Usage: keemakr-ui build | keemakr-ui dev -- eve dev');
111
+ }
112
+ }
113
+ main().catch((error) => {
114
+ console.error(error instanceof Error ? error.message : error);
115
+ process.exitCode = 1;
116
+ });
package/dist/define.d.ts CHANGED
@@ -1,19 +1,4 @@
1
- /**
2
- * defineScreen — the module-dev half of the contract.
3
- *
4
- * A screen is two things: a `load` that returns a ScreenTree, and named
5
- * `actions` the tree's buttons/forms reference by key. Handlers run
6
- * server-side in the module's own eve deployment; keemakr-core calls them
7
- * through the grant-authed screens channel and validates every response
8
- * against the schemas in ./schema before rendering.
9
- *
10
- * `Ctx` is generic on purpose: this package depends on nothing. The eve
11
- * channel adapter (shipped alongside the reference module) binds Ctx to the
12
- * eve tool ctx, so inside handlers `useKee(ctx)` works exactly as it does in
13
- * agent tools — tenant and scopes come from the verified grant, never from
14
- * params.
15
- */
16
- import type { ActionResultInput, ScreenTreeInput } from './schema.js';
1
+ import type { ActionResultInput, ScreenTreeInput, SurfaceDefinition } from './schema.js';
17
2
  export type ScreenParams = Record<string, string>;
18
3
  export type LoadHandler<Ctx> = (ctx: Ctx, params: ScreenParams) => ScreenTreeInput | Promise<ScreenTreeInput>;
19
4
  export type ActionHandler<Ctx> = (ctx: Ctx, input: Record<string, unknown>, params: ScreenParams) => ActionResultInput | Promise<ActionResultInput>;
@@ -26,3 +11,5 @@ export interface ScreenDefinition<Ctx = unknown> {
26
11
  * cheap authoring-time validation, not runtime behavior.
27
12
  */
28
13
  export declare function defineScreen<Ctx = unknown>(screen: ScreenDefinition<Ctx>): ScreenDefinition<Ctx>;
14
+ /** Define surface metadata. The filesystem supplies its key, root screen, and child ownership. */
15
+ export declare function defineSurface(surface: SurfaceDefinition): SurfaceDefinition;
package/dist/define.js CHANGED
@@ -1,3 +1,19 @@
1
+ /**
2
+ * defineScreen — the module-dev half of the contract.
3
+ *
4
+ * A screen is two things: a `load` that returns a ScreenTree, and named
5
+ * `actions` the tree's buttons/forms reference by key. Handlers run
6
+ * server-side in the module's own eve deployment; keemakr-core calls them
7
+ * through the grant-authed screens channel and validates every response
8
+ * against the schemas in ./schema before rendering.
9
+ *
10
+ * `Ctx` is generic on purpose: this package depends on nothing. The eve
11
+ * channel adapter (shipped alongside the reference module) binds Ctx to the
12
+ * eve tool ctx, so inside handlers `useKee(ctx)` works exactly as it does in
13
+ * agent tools — tenant and scopes come from the verified grant, never from
14
+ * params.
15
+ */
16
+ import { surfaceDefinitionSchema } from './schema.js';
1
17
  const ACTION_KEY = /^[a-z][a-z0-9_-]*$/;
2
18
  /**
3
19
  * Identity function (the defineProvider pattern): exists for inference and
@@ -11,3 +27,8 @@ export function defineScreen(screen) {
11
27
  }
12
28
  return screen;
13
29
  }
30
+ /** Define surface metadata. The filesystem supplies its key, root screen, and child ownership. */
31
+ export function defineSurface(surface) {
32
+ surfaceDefinitionSchema.parse(surface);
33
+ return surface;
34
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { UI_SCHEMA_VERSION, pressSchema, buttonSchema, cellSchema, nodeSchema, screenTreeSchema, actionResultSchema, screenLoadRequestSchema, screenLoadResponseSchema, screenActionRequestSchema, surfaceSchema, } from './schema.js';
2
- export type { Press, ScreenNode, ScreenTree, ActionResult, Surface, ScreenTreeInput, ActionResultInput, } from './schema.js';
3
- export { defineScreen } from './define.js';
1
+ export { UI_SCHEMA_VERSION, pressSchema, buttonSchema, cellSchema, nodeSchema, screenTreeSchema, actionResultSchema, screenLoadRequestSchema, screenLoadResponseSchema, screenActionRequestSchema, interfaceFamilySchema, surfaceDefinitionSchema, surfaceSchema, screenRegistrationSchema, uiManifestSchema, } from './schema.js';
2
+ export type { Press, ScreenNode, ScreenTree, ActionResult, InterfaceFamily, SurfaceDefinition, Surface, ScreenRegistration, UiManifest, ScreenTreeInput, ActionResultInput, } from './schema.js';
3
+ export { defineScreen, defineSurface } from './define.js';
4
4
  export type { ScreenDefinition, ScreenParams, LoadHandler, ActionHandler } from './define.js';
package/dist/index.js CHANGED
@@ -6,5 +6,5 @@
6
6
  // platform components, proxies load/action with a grant
7
7
  //
8
8
  // Module code never ships to the browser. UI is data, not code.
9
- export { UI_SCHEMA_VERSION, pressSchema, buttonSchema, cellSchema, nodeSchema, screenTreeSchema, actionResultSchema, screenLoadRequestSchema, screenLoadResponseSchema, screenActionRequestSchema, surfaceSchema, } from './schema.js';
10
- export { defineScreen } from './define.js';
9
+ export { UI_SCHEMA_VERSION, pressSchema, buttonSchema, cellSchema, nodeSchema, screenTreeSchema, actionResultSchema, screenLoadRequestSchema, screenLoadResponseSchema, screenActionRequestSchema, interfaceFamilySchema, surfaceDefinitionSchema, surfaceSchema, screenRegistrationSchema, uiManifestSchema, } from './schema.js';
10
+ export { defineScreen, defineSurface } from './define.js';
package/dist/schema.d.ts CHANGED
@@ -302,22 +302,65 @@ export declare const screenActionRequestSchema: z.ZodObject<{
302
302
  input: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
303
303
  idempotencyKey: z.ZodOptional<z.ZodString>;
304
304
  }, z.core.$strip>;
305
+ export declare const interfaceFamilySchema: z.ZodEnum<{
306
+ dashboard: "dashboard";
307
+ client: "client";
308
+ }>;
309
+ export declare const surfaceDefinitionSchema: z.ZodObject<{
310
+ label: z.ZodString;
311
+ icon: z.ZodOptional<z.ZodString>;
312
+ description: z.ZodOptional<z.ZodString>;
313
+ availableOn: z.ZodArray<z.ZodEnum<{
314
+ dashboard: "dashboard";
315
+ client: "client";
316
+ }>>;
317
+ }, z.core.$strip>;
305
318
  /**
306
319
  * Manifest addition: `manifest.surfaces[]` in entry.json. Each surface is a
307
320
  * side-menu entry pointing at an entry screen. Core's marketplace
308
321
  * validateSubmission composes this in (wired in the proxy phase).
309
322
  */
310
323
  export declare const surfaceSchema: z.ZodObject<{
311
- key: z.ZodString;
312
324
  label: z.ZodString;
313
325
  icon: z.ZodOptional<z.ZodString>;
314
- screen: z.ZodString;
315
326
  description: z.ZodOptional<z.ZodString>;
327
+ availableOn: z.ZodArray<z.ZodEnum<{
328
+ dashboard: "dashboard";
329
+ client: "client";
330
+ }>>;
331
+ key: z.ZodString;
332
+ screen: z.ZodString;
333
+ }, z.core.$strip>;
334
+ export declare const screenRegistrationSchema: z.ZodObject<{
335
+ screen: z.ZodString;
336
+ surface: z.ZodString;
337
+ }, z.core.$strip>;
338
+ /** The complete generated UI portion of a module manifest. */
339
+ export declare const uiManifestSchema: z.ZodObject<{
340
+ surfaces: z.ZodArray<z.ZodObject<{
341
+ label: z.ZodString;
342
+ icon: z.ZodOptional<z.ZodString>;
343
+ description: z.ZodOptional<z.ZodString>;
344
+ availableOn: z.ZodArray<z.ZodEnum<{
345
+ dashboard: "dashboard";
346
+ client: "client";
347
+ }>>;
348
+ key: z.ZodString;
349
+ screen: z.ZodString;
350
+ }, z.core.$strip>>;
351
+ screenRegistry: z.ZodArray<z.ZodObject<{
352
+ screen: z.ZodString;
353
+ surface: z.ZodString;
354
+ }, z.core.$strip>>;
316
355
  }, z.core.$strip>;
317
356
  export type Press = z.infer<typeof pressSchema>;
318
357
  export type ScreenTree = z.infer<typeof screenTreeSchema>;
319
358
  export type ActionResult = z.infer<typeof actionResultSchema>;
359
+ export type InterfaceFamily = z.infer<typeof interfaceFamilySchema>;
360
+ export type SurfaceDefinition = z.infer<typeof surfaceDefinitionSchema>;
320
361
  export type Surface = z.infer<typeof surfaceSchema>;
362
+ export type ScreenRegistration = z.infer<typeof screenRegistrationSchema>;
363
+ export type UiManifest = z.infer<typeof uiManifestSchema>;
321
364
  export type ScreenTreeInput = z.input<typeof screenTreeSchema>;
322
365
  export type ActionResultInput = z.input<typeof actionResultSchema>;
323
366
  export {};
package/dist/schema.js CHANGED
@@ -235,16 +235,76 @@ export const screenActionRequestSchema = z.object({
235
235
  * half of at-least-once). Optional for runtime version-skew tolerance. */
236
236
  idempotencyKey: z.string().max(200).optional(),
237
237
  });
238
+ export const interfaceFamilySchema = z.enum(['dashboard', 'client']);
239
+ export const surfaceDefinitionSchema = z.object({
240
+ label: z.string().min(1).max(60),
241
+ /** Lucide icon name, same convention as entry.json `icon`. */
242
+ icon: z.string().max(60).optional(),
243
+ description: z.string().max(200).optional(),
244
+ availableOn: z
245
+ .array(interfaceFamilySchema)
246
+ .min(1)
247
+ .max(2)
248
+ .refine((families) => new Set(families).size === families.length, {
249
+ message: 'interface families must be unique',
250
+ }),
251
+ });
238
252
  /**
239
253
  * Manifest addition: `manifest.surfaces[]` in entry.json. Each surface is a
240
254
  * side-menu entry pointing at an entry screen. Core's marketplace
241
255
  * validateSubmission composes this in (wired in the proxy phase).
242
256
  */
243
- export const surfaceSchema = z.object({
257
+ export const surfaceSchema = surfaceDefinitionSchema.extend({
244
258
  key: slug,
245
- label: z.string().max(60),
246
- /** Lucide icon name, same convention as entry.json `icon`. */
247
- icon: z.string().max(60).optional(),
248
259
  screen: slug,
249
- description: z.string().max(200).optional(),
260
+ });
261
+ export const screenRegistrationSchema = z.object({
262
+ screen: slug,
263
+ surface: slug,
264
+ });
265
+ /** The complete generated UI portion of a module manifest. */
266
+ export const uiManifestSchema = z
267
+ .object({
268
+ surfaces: z.array(surfaceSchema).min(1),
269
+ screenRegistry: z.array(screenRegistrationSchema).min(1).max(256),
270
+ })
271
+ .superRefine((manifest, ctx) => {
272
+ const surfaceKeys = new Set();
273
+ for (const [index, surface] of manifest.surfaces.entries()) {
274
+ if (surfaceKeys.has(surface.key)) {
275
+ ctx.addIssue({
276
+ code: 'custom',
277
+ path: ['surfaces', index, 'key'],
278
+ message: `duplicate surface key "${surface.key}"`,
279
+ });
280
+ }
281
+ surfaceKeys.add(surface.key);
282
+ }
283
+ const screens = new Map();
284
+ for (const [index, registration] of manifest.screenRegistry.entries()) {
285
+ if (!surfaceKeys.has(registration.surface)) {
286
+ ctx.addIssue({
287
+ code: 'custom',
288
+ path: ['screenRegistry', index, 'surface'],
289
+ message: `unknown surface "${registration.surface}"`,
290
+ });
291
+ }
292
+ if (screens.has(registration.screen)) {
293
+ ctx.addIssue({
294
+ code: 'custom',
295
+ path: ['screenRegistry', index, 'screen'],
296
+ message: `duplicate screen identity "${registration.screen}"`,
297
+ });
298
+ }
299
+ screens.set(registration.screen, registration.surface);
300
+ }
301
+ for (const [index, surface] of manifest.surfaces.entries()) {
302
+ if (screens.get(surface.screen) !== surface.key) {
303
+ ctx.addIssue({
304
+ code: 'custom',
305
+ path: ['surfaces', index, 'screen'],
306
+ message: `root screen "${surface.screen}" must be registered to surface "${surface.key}"`,
307
+ });
308
+ }
309
+ }
250
310
  });
@@ -0,0 +1,17 @@
1
+ # Campaigns example
2
+
3
+ This example has:
4
+
5
+ - a Campaigns surface available in Dashboard and Kee clients;
6
+ - a nested campaign detail screen that inherits that availability;
7
+ - a separate Campaign settings surface available only in Dashboard;
8
+ - one in-memory pause action.
9
+
10
+ From this directory, generate the manifest and registry, then run the demo:
11
+
12
+ ```bash
13
+ npx keemakr-ui build
14
+ npx tsx demo.ts
15
+ ```
16
+
17
+ No database or external service is required.
@@ -0,0 +1,13 @@
1
+ import { SCREENS } from './ui/screen-registry.js';
2
+
3
+ async function main() {
4
+ const params = { id: 'summer-launch' };
5
+ console.log(await SCREENS.campaign.load({}, params));
6
+ console.log(await SCREENS.campaign.actions?.pause?.({}, {}, params));
7
+ console.log(await SCREENS.campaign.load({}, params));
8
+ }
9
+
10
+ void main().catch((error: unknown) => {
11
+ console.error(error);
12
+ process.exitCode = 1;
13
+ });
@@ -0,0 +1,53 @@
1
+ {
2
+ "slug": "campaigns-example",
3
+ "display_name": "Campaigns example",
4
+ "visibility": "public",
5
+ "runtime": {
6
+ "kind": "eve",
7
+ "url": "http://localhost:3210",
8
+ "status": "dev"
9
+ },
10
+ "manifest": {
11
+ "department": {
12
+ "key": "sales",
13
+ "label": "Sales"
14
+ },
15
+ "agents": [
16
+ {
17
+ "key": "campaign-manager",
18
+ "display_name": "Campaign manager"
19
+ }
20
+ ],
21
+ "surfaces": [
22
+ {
23
+ "label": "Campaign settings",
24
+ "description": "Configure tenant campaign defaults.",
25
+ "availableOn": ["dashboard"],
26
+ "key": "campaign-settings",
27
+ "screen": "campaign-settings"
28
+ },
29
+ {
30
+ "label": "Campaigns",
31
+ "description": "Create and monitor campaigns.",
32
+ "availableOn": ["dashboard", "client"],
33
+ "key": "campaigns",
34
+ "screen": "campaigns"
35
+ }
36
+ ],
37
+ "screenRegistry": [
38
+ {
39
+ "screen": "campaign",
40
+ "surface": "campaigns"
41
+ },
42
+ {
43
+ "screen": "campaign-settings",
44
+ "surface": "campaign-settings"
45
+ },
46
+ {
47
+ "screen": "campaigns",
48
+ "surface": "campaigns"
49
+ }
50
+ ]
51
+ },
52
+ "dependencies": []
53
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "types": ["node"]
9
+ },
10
+ "include": ["demo.ts", "ui/**/*.ts"]
11
+ }
@@ -0,0 +1,9 @@
1
+ import { defineScreen } from '@keemakr/ui-sdk';
2
+
3
+ export default defineScreen({
4
+ load: async () => ({
5
+ version: 1,
6
+ title: 'Campaign settings',
7
+ children: [{ type: 'text', value: 'Default daily send limit: 100' }],
8
+ }),
9
+ });
@@ -0,0 +1,7 @@
1
+ import { defineSurface } from '@keemakr/ui-sdk';
2
+
3
+ export default defineSurface({
4
+ label: 'Campaign settings',
5
+ description: 'Configure tenant campaign defaults.',
6
+ availableOn: ['dashboard'],
7
+ });
@@ -0,0 +1,29 @@
1
+ import { defineScreen } from '@keemakr/ui-sdk';
2
+ import { campaigns } from '../../data.js';
3
+
4
+ export default defineScreen({
5
+ load: async (_ctx, params) => {
6
+ const campaign = campaigns.find((item) => item.id === params.id);
7
+ return {
8
+ version: 1,
9
+ title: campaign?.name ?? 'Campaign not found',
10
+ children: campaign
11
+ ? [
12
+ { type: 'text' as const, value: `Status: ${campaign.status}` },
13
+ {
14
+ type: 'button' as const,
15
+ label: 'Pause campaign',
16
+ onPress: { kind: 'action' as const, action: 'pause' },
17
+ },
18
+ ]
19
+ : [{ type: 'text' as const, value: 'This campaign does not exist.' }],
20
+ };
21
+ },
22
+ actions: {
23
+ pause: async (_ctx, _input, params) => {
24
+ const campaign = campaigns.find((item) => item.id === params.id);
25
+ if (campaign) campaign.status = 'paused';
26
+ return { toast: campaign ? 'Campaign paused.' : 'Campaign not found.', refresh: true };
27
+ },
28
+ },
29
+ });
@@ -0,0 +1,27 @@
1
+ import { defineScreen } from '@keemakr/ui-sdk';
2
+ import { campaigns } from '../data.js';
3
+
4
+ export default defineScreen({
5
+ load: async () => ({
6
+ version: 1,
7
+ title: 'Campaigns',
8
+ children: [
9
+ {
10
+ type: 'table',
11
+ columns: [
12
+ { key: 'name', label: 'Campaign' },
13
+ { key: 'status', label: 'Status' },
14
+ ],
15
+ rows: campaigns.map((campaign) => ({
16
+ key: campaign.id,
17
+ cells: {
18
+ name: { kind: 'text', value: campaign.name },
19
+ status: { kind: 'badge', value: campaign.status },
20
+ },
21
+ onPress: { kind: 'navigate', screen: 'campaign', params: { id: campaign.id } },
22
+ })),
23
+ empty: 'No campaigns yet.',
24
+ },
25
+ ],
26
+ }),
27
+ });
@@ -0,0 +1,7 @@
1
+ import { defineSurface } from '@keemakr/ui-sdk';
2
+
3
+ export default defineSurface({
4
+ label: 'Campaigns',
5
+ description: 'Create and monitor campaigns.',
6
+ availableOn: ['dashboard', 'client'],
7
+ });
@@ -0,0 +1,9 @@
1
+ export interface Campaign {
2
+ id: string;
3
+ name: string;
4
+ status: 'active' | 'paused';
5
+ }
6
+
7
+ export const campaigns: Campaign[] = [
8
+ { id: 'summer-launch', name: 'Summer launch', status: 'active' },
9
+ ];
@@ -0,0 +1,10 @@
1
+ // Generated by @keemakr/ui-sdk. Do not edit.
2
+ import screen0 from './campaigns/campaign/screen.js';
3
+ import screen1 from './campaign-settings/screen.js';
4
+ import screen2 from './campaigns/screen.js';
5
+
6
+ export const SCREENS = {
7
+ campaign: screen0,
8
+ 'campaign-settings': screen1,
9
+ campaigns: screen2,
10
+ } as const;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keemakr/ui-sdk",
3
- "version": "0.1.1",
3
+ "version": "1.0.0-dev-20260807074219",
4
4
  "description": "Product screens for keemakr marketplace modules: a versioned, server-driven UI tree schema plus defineScreen() handlers. Module code never ships to the browser — keemakr-core validates the tree and renders it with platform components.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,11 +11,20 @@
11
11
  "types": "./dist/index.d.ts",
12
12
  "import": "./dist/index.js",
13
13
  "default": "./dist/index.js"
14
+ },
15
+ "./build": {
16
+ "types": "./dist/build.d.ts",
17
+ "import": "./dist/build.js",
18
+ "default": "./dist/build.js"
14
19
  }
15
20
  },
21
+ "bin": {
22
+ "keemakr-ui": "./dist/cli.js"
23
+ },
16
24
  "files": [
17
25
  "dist",
18
- "README.md"
26
+ "README.md",
27
+ "examples"
19
28
  ],
20
29
  "publishConfig": {
21
30
  "access": "public"
@@ -33,9 +42,11 @@
33
42
  "peerDependencies": {
34
43
  "zod": "^4.4.3"
35
44
  },
45
+ "dependencies": {
46
+ "typescript": "^5"
47
+ },
36
48
  "devDependencies": {
37
49
  "@types/node": "^20.19.43",
38
- "typescript": "^5",
39
50
  "zod": "^4.4.3"
40
51
  },
41
52
  "engines": {