@svadmin/create 0.40.3 → 0.41.0
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 +13 -0
- package/blueprints/customer-workspace/vibe-skill.md +8 -1
- package/blueprints/product-workspace/ARCHITECTURE.md +18 -0
- package/blueprints/product-workspace/README.md +14 -0
- package/blueprints/product-workspace/src/App.svelte +23 -0
- package/blueprints/product-workspace/src/ProductSurfacePage.svelte +89 -0
- package/blueprints/product-workspace/src/app.css +6 -0
- package/blueprints/product-workspace/src/design.svelte.ts +7 -0
- package/blueprints/product-workspace/src/product-provider.ts +81 -0
- package/blueprints/product-workspace/src/svadmin.config.ts +29 -0
- package/blueprints/product-workspace/svadmin.vibe.json +10 -0
- package/blueprints/product-workspace/vibe-skill.md +14 -0
- package/dist/index.js +614 -32
- package/guidance/AGENTS.md +11 -0
- package/guidance/PRODUCT.md +29 -0
- package/package.json +2 -2
- package/scaffold-manifest.json +21 -21
package/README.md
CHANGED
|
@@ -119,6 +119,19 @@ bun install
|
|
|
119
119
|
bun run dev
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
+
## Product-first Vibe Loop
|
|
123
|
+
|
|
124
|
+
Use a product plan when a feature request is larger than one CRUD resource. The plan keeps business workflows, resources, pages, actions, and navigation separate so generated projects do not expose every backend table as a page.
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# Read product issues before generating UI
|
|
128
|
+
npx @svadmin/create vibe review ./svadmin.product.json
|
|
129
|
+
|
|
130
|
+
# Preview the experience for one role; omit --role to inspect all roles
|
|
131
|
+
npx @svadmin/create vibe plan ./svadmin.product.json --role operator
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The plan uses schema version `product/v1`. Menu entries are driven by actual business needs and role/task context; svadmin does not apply a global menu-count cap. Generated starters include `PRODUCT.md` alongside `DESIGN.md` and `AGENTS.md`.
|
|
122
135
|
## Automated Inference CLI / 接口代码推断器
|
|
123
136
|
|
|
124
137
|
Automatically infer ResourceDefinitions, TypeBox schemas, and Svelte 5 CRUD components from REST endpoints, OpenAPI / Swagger specs, GraphQL endpoints (introspection), or local schema/sample files:
|
|
@@ -5,7 +5,7 @@ description: Build and refine Svelte admin pages from the installed svadmin page
|
|
|
5
5
|
|
|
6
6
|
# svadmin Vibe
|
|
7
7
|
|
|
8
|
-
Read `DESIGN.md`, `svadmin.vibe.json`, and `svadmin.ai.json`. Select the smallest
|
|
8
|
+
Read `PRODUCT.md`, `DESIGN.md`, `svadmin.vibe.json`, and `svadmin.ai.json`. Run `svadmin vibe review ./svadmin.product.json` before generating a multi-resource feature. Select the smallest
|
|
9
9
|
matching page family from `pages`; inspect its source and the relevant resource
|
|
10
10
|
contract before editing. The catalog is a starting point, not permission to
|
|
11
11
|
replace the customer's workflow with this demo's business model.
|
|
@@ -22,6 +22,13 @@ Pass an empty object to search all pages. These tools read shipped references
|
|
|
22
22
|
only; inspect the current customer's sources separately before making changes.
|
|
23
23
|
The CLI commands remain the fallback when no MCP client is configured.
|
|
24
24
|
|
|
25
|
+
## Product boundary
|
|
26
|
+
|
|
27
|
+
- Start from the user's outcome and workflows, not from the complete resource registry.
|
|
28
|
+
- Treat resources as data contracts; only create a surface or menu node when a workflow, role, or business context justifies it.
|
|
29
|
+
- Keep menu count proportional to the actual domain. Never remove required business entries to satisfy a global limit.
|
|
30
|
+
- Keep one dominant action per surface and use the review command to catch dense action/section layouts before markup.
|
|
31
|
+
|
|
25
32
|
## Implement
|
|
26
33
|
|
|
27
34
|
- Read installed `@svadmin/ui/component-registry` metadata and component type
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Product workspace boundaries
|
|
2
|
+
|
|
3
|
+
Product mode separates product decisions from runtime resources:
|
|
4
|
+
|
|
5
|
+
- `svadmin.product.json` is the source of truth for roles, workflows, resources,
|
|
6
|
+
surfaces and menu entries.
|
|
7
|
+
- `src/generated/product-plan.ts` parses the plan at the application boundary.
|
|
8
|
+
- `src/generated/resources.ts` exposes read-only resource metadata to svadmin.
|
|
9
|
+
- `src/generated/navigation.ts` filters product menu entries by role and feature
|
|
10
|
+
flags. There is no fixed menu count.
|
|
11
|
+
- `src/generated/pages.ts` contains the renderer-neutral page plan.
|
|
12
|
+
- `src/generated/page-routes.ts` registers custom surface routes.
|
|
13
|
+
- `src/product-provider.ts` is an empty in-memory adapter for visual review.
|
|
14
|
+
|
|
15
|
+
Product actions are metadata until a typed command or provider contract is
|
|
16
|
+
connected. The generated scaffold never claims that a button performed a real
|
|
17
|
+
mutation. Server authorization, tenancy, audit, idempotency and persistence
|
|
18
|
+
remain backend responsibilities.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Product workspace
|
|
2
|
+
|
|
3
|
+
This blueprint is generated from a validated `svadmin.product.json`. It keeps
|
|
4
|
+
product decisions, navigation and page metadata in generated files so the
|
|
5
|
+
application starts with a coherent information architecture instead of a
|
|
6
|
+
database-shaped menu.
|
|
7
|
+
|
|
8
|
+
The default provider is an empty in-memory provider. It is useful for visual
|
|
9
|
+
review and route acceptance, but it is not a backend, an authorization boundary,
|
|
10
|
+
or a source of business data. Wire an explicit provider and command contract
|
|
11
|
+
before enabling production mutations.
|
|
12
|
+
|
|
13
|
+
Generated files under `src/generated` are derived artifacts. Edit the product
|
|
14
|
+
plan and regenerate the project rather than editing those files by hand.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { AdminApp } from '@svadmin/ui';
|
|
3
|
+
import { resolveAdminConfig } from '@svadmin/app';
|
|
4
|
+
import config from './svadmin.config';
|
|
5
|
+
import { brand, getDesign } from './design.svelte';
|
|
6
|
+
import { productMenu } from './generated/navigation';
|
|
7
|
+
import { productResources } from './generated/resources';
|
|
8
|
+
import { productPageRoutes } from './generated/page-routes';
|
|
9
|
+
|
|
10
|
+
const resolved = resolveAdminConfig(config);
|
|
11
|
+
const design = $derived(getDesign());
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<AdminApp
|
|
15
|
+
providerBundle={resolved.providers}
|
|
16
|
+
resources={[...productResources]}
|
|
17
|
+
menu={productMenu}
|
|
18
|
+
pageRoutes={productPageRoutes}
|
|
19
|
+
title={brand.name}
|
|
20
|
+
locale="zh-CN"
|
|
21
|
+
themeConfig={design.theme}
|
|
22
|
+
queryClientDefaultOptions={{ queries: { retry: false } }}
|
|
23
|
+
/>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { productPagePlans, productSurfaceRoutes } from './generated/pages';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
route: string;
|
|
6
|
+
params: Record<string, string | undefined>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let { route }: Props = $props();
|
|
10
|
+
const page = $derived(productPagePlans.find(candidate => productSurfaceRoutes[route] === candidate.id));
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<svelte:head>
|
|
14
|
+
<title>{page?.title ?? 'Product surface'}</title>
|
|
15
|
+
</svelte:head>
|
|
16
|
+
|
|
17
|
+
<div class="surface-page">
|
|
18
|
+
{#if page}
|
|
19
|
+
<header class="surface-hero">
|
|
20
|
+
<div>
|
|
21
|
+
<p class="eyebrow">{page.kind}</p>
|
|
22
|
+
<h1>{page.title}</h1>
|
|
23
|
+
<p class="purpose">{page.purpose}</p>
|
|
24
|
+
</div>
|
|
25
|
+
{#if page.primaryAction}
|
|
26
|
+
<span class="action-badge">主操作:{page.primaryAction.label}</span>
|
|
27
|
+
{/if}
|
|
28
|
+
</header>
|
|
29
|
+
|
|
30
|
+
<div class="state-strip">
|
|
31
|
+
{#each page.states as state (state)}
|
|
32
|
+
<span class="state">{state}</span>
|
|
33
|
+
{/each}
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div class="surface-grid">
|
|
37
|
+
{#each page.sections as section (section.id)}
|
|
38
|
+
<section class="surface-card">
|
|
39
|
+
<div class="card-heading">
|
|
40
|
+
<h2>{section.title}</h2>
|
|
41
|
+
<span>区块</span>
|
|
42
|
+
</div>
|
|
43
|
+
<p>此区块服务于当前工作流,并展示产品计划声明的资源。</p>
|
|
44
|
+
{#if section.resourceIds.length}
|
|
45
|
+
<div class="resource-list">
|
|
46
|
+
{#each section.resourceIds as resourceId (resourceId)}
|
|
47
|
+
<span>{resourceId}</span>
|
|
48
|
+
{/each}
|
|
49
|
+
</div>
|
|
50
|
+
{:else}
|
|
51
|
+
<p class="muted">此区块暂无数据资源,等待接入真实 provider。</p>
|
|
52
|
+
{/if}
|
|
53
|
+
</section>
|
|
54
|
+
{/each}
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<aside class="contract-note">
|
|
58
|
+
<strong>动作仍是产品元数据</strong>
|
|
59
|
+
<span>接入 defineCommand 或资源契约后,再启用真实提交、审批和写入。</span>
|
|
60
|
+
</aside>
|
|
61
|
+
{:else}
|
|
62
|
+
<section class="surface-card">
|
|
63
|
+
<h1>未找到页面计划</h1>
|
|
64
|
+
<p>路径 {route} 尚未在产品计划中注册。</p>
|
|
65
|
+
</section>
|
|
66
|
+
{/if}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<style>
|
|
70
|
+
.surface-page { max-width: 1180px; margin: 0 auto; padding: 32px; color: #172033; }
|
|
71
|
+
.surface-hero { display: flex; justify-content: space-between; gap: 24px; align-items: flex-end; margin-bottom: 18px; }
|
|
72
|
+
.eyebrow { margin: 0 0 8px; text-transform: uppercase; letter-spacing: .12em; color: #65708a; font-size: 12px; font-weight: 700; }
|
|
73
|
+
h1, h2, p { margin-top: 0; }
|
|
74
|
+
h1 { margin-bottom: 8px; font-size: clamp(28px, 4vw, 46px); letter-spacing: -.03em; }
|
|
75
|
+
h2 { font-size: 18px; }
|
|
76
|
+
.purpose { max-width: 700px; color: #65708a; font-size: 16px; line-height: 1.6; }
|
|
77
|
+
.action-badge, .state, .resource-list span { border-radius: 999px; background: #e8eefc; color: #3558a8; padding: 7px 11px; font-size: 12px; font-weight: 650; }
|
|
78
|
+
.state-strip { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
|
79
|
+
.state { background: #eef0f3; color: #596273; }
|
|
80
|
+
.surface-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
|
|
81
|
+
.surface-card { min-height: 150px; padding: 22px; border: 1px solid #e4e7ed; border-radius: 18px; background: #fff; box-shadow: 0 10px 30px rgb(32 45 72 / 5%); }
|
|
82
|
+
.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
83
|
+
.card-heading span { color: #8a94a7; font-size: 12px; }
|
|
84
|
+
.surface-card p { color: #65708a; line-height: 1.6; }
|
|
85
|
+
.resource-list { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 18px; }
|
|
86
|
+
.muted { color: #98a1b2 !important; font-size: 14px; }
|
|
87
|
+
.contract-note { display: flex; flex-direction: column; gap: 5px; margin-top: 18px; padding: 16px 18px; border-left: 3px solid #6f8fe8; background: #f0f4ff; color: #33425f; line-height: 1.5; }
|
|
88
|
+
@media (max-width: 640px) { .surface-page { padding: 20px 16px; } .surface-hero { align-items: flex-start; flex-direction: column; } }
|
|
89
|
+
</style>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { HttpError, type BaseRecord, type DataProvider, type Filter } from '@svadmin/core';
|
|
2
|
+
import { productResources } from './generated/resources';
|
|
3
|
+
|
|
4
|
+
export type ProductScenario = 'normal' | 'empty' | 'error' | 'loading' | 'denied' | 'readonly';
|
|
5
|
+
|
|
6
|
+
function matches(row: BaseRecord, filter: Filter): boolean {
|
|
7
|
+
if (!('field' in filter)) {
|
|
8
|
+
return filter.operator === 'or'
|
|
9
|
+
? filter.value.some(candidate => matches(row, candidate))
|
|
10
|
+
: filter.value.every(candidate => matches(row, candidate));
|
|
11
|
+
}
|
|
12
|
+
const value = row[filter.field];
|
|
13
|
+
switch (filter.operator) {
|
|
14
|
+
case 'eq': return value === filter.value;
|
|
15
|
+
case 'ne': return value !== filter.value;
|
|
16
|
+
case 'contains': return String(value ?? '').toLowerCase().includes(String(filter.value).toLowerCase());
|
|
17
|
+
case 'in': return Array.isArray(filter.value) && filter.value.includes(value);
|
|
18
|
+
case 'null': return value === null || value === undefined;
|
|
19
|
+
case 'nnull': return value !== null && value !== undefined;
|
|
20
|
+
default: throw new HttpError('当前脚手架不支持该筛选条件', 400);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createProductProvider(scenario: ProductScenario = 'normal'): DataProvider {
|
|
25
|
+
const rows: Record<string, BaseRecord[]> = Object.fromEntries(
|
|
26
|
+
productResources.map(resource => [resource.name, [] as BaseRecord[]]),
|
|
27
|
+
);
|
|
28
|
+
async function table(resource: string): Promise<BaseRecord[]> {
|
|
29
|
+
if (scenario === 'denied') throw new HttpError('无权访问', 403);
|
|
30
|
+
if (scenario === 'error') throw new HttpError('演示 provider 暂不可用', 503);
|
|
31
|
+
if (scenario === 'loading') await new Promise(resolve => setTimeout(resolve, 1200));
|
|
32
|
+
const data = rows[resource];
|
|
33
|
+
if (!data) throw new HttpError('资源不存在', 404);
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
function find(data: BaseRecord[], id: string | number): BaseRecord {
|
|
37
|
+
const row = data.find(record => String(record['id']) === String(id));
|
|
38
|
+
if (!row) throw new HttpError('记录不存在', 404);
|
|
39
|
+
return row;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
getApiUrl: () => 'memory://svadmin-product',
|
|
43
|
+
async getList({ resource, pagination, sorters = [], filters = [] }) {
|
|
44
|
+
const data = await table(resource);
|
|
45
|
+
let result = (scenario === 'empty' ? [] : data).filter(row => filters.every(filter => matches(row, filter)));
|
|
46
|
+
result = [...result].sort((a, b) => {
|
|
47
|
+
for (const sorter of sorters) {
|
|
48
|
+
const order = String(a[sorter.field] ?? '').localeCompare(String(b[sorter.field] ?? ''), 'zh-CN', { numeric: true });
|
|
49
|
+
if (order) return sorter.order === 'desc' ? -order : order;
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
});
|
|
53
|
+
const total = result.length;
|
|
54
|
+
if (pagination?.mode !== 'off') {
|
|
55
|
+
const size = pagination?.pageSize ?? 10;
|
|
56
|
+
const start = ((pagination?.current ?? 1) - 1) * size;
|
|
57
|
+
result = result.slice(start, start + size);
|
|
58
|
+
}
|
|
59
|
+
return { data: structuredClone(result), total };
|
|
60
|
+
},
|
|
61
|
+
async getOne({ resource, id }) { return { data: structuredClone(find(await table(resource), id)) }; },
|
|
62
|
+
async getMany({ resource, ids }) {
|
|
63
|
+
return { data: structuredClone((await table(resource)).filter(row => ids.some(id => String(id) === String(row['id'])))) };
|
|
64
|
+
},
|
|
65
|
+
async create({ resource, variables }) {
|
|
66
|
+
if (scenario === 'readonly') throw new HttpError('仅允许查看', 403);
|
|
67
|
+
const data = await table(resource);
|
|
68
|
+
const record = { ...(variables as BaseRecord), id: crypto.randomUUID() };
|
|
69
|
+
data.push(record);
|
|
70
|
+
return { data: structuredClone(record) };
|
|
71
|
+
},
|
|
72
|
+
async update({ resource, id, variables }) {
|
|
73
|
+
if (scenario === 'readonly') throw new HttpError('仅允许查看', 403);
|
|
74
|
+
const data = await table(resource);
|
|
75
|
+
const record = find(data, id);
|
|
76
|
+
Object.assign(record, variables as BaseRecord);
|
|
77
|
+
return { data: structuredClone(record) };
|
|
78
|
+
},
|
|
79
|
+
async deleteOne() { throw new HttpError('产品脚手架未启用删除', 403); },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createProviderBundle, defineAdminConfig } from '@svadmin/app';
|
|
2
|
+
import { productResources } from './generated/resources';
|
|
3
|
+
import { createProductProvider, type ProductScenario } from './product-provider';
|
|
4
|
+
|
|
5
|
+
const requested = typeof location !== 'undefined' && import.meta.env.DEV
|
|
6
|
+
? new URLSearchParams(location.search).get('scenario')
|
|
7
|
+
: null;
|
|
8
|
+
const scenarios: readonly string[] = ['normal', 'empty', 'error', 'loading', 'denied', 'readonly'];
|
|
9
|
+
const scenario: ProductScenario = requested !== null && scenarios.includes(requested)
|
|
10
|
+
? scenarioFrom(requested)
|
|
11
|
+
: 'normal';
|
|
12
|
+
|
|
13
|
+
function scenarioFrom(value: string): ProductScenario {
|
|
14
|
+
return value as ProductScenario;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default defineAdminConfig({
|
|
18
|
+
name: 'product-workspace',
|
|
19
|
+
providers: createProviderBundle({
|
|
20
|
+
dataProvider: createProductProvider(scenario),
|
|
21
|
+
accessControlProvider: {
|
|
22
|
+
can: async ({ action }) => ({
|
|
23
|
+
can: scenario !== 'denied' && (scenario !== 'readonly' || ['list', 'show', 'field'].includes(action)),
|
|
24
|
+
}),
|
|
25
|
+
options: { buttons: { enableAccessControl: true, hideIfUnauthorized: true } },
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
resources: productResources,
|
|
29
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"id": "product-workspace",
|
|
4
|
+
"status": "generated",
|
|
5
|
+
"framework": "svelte5-vite",
|
|
6
|
+
"source": "svadmin.product.json",
|
|
7
|
+
"generated": ["src/generated/product-plan.ts", "src/generated/navigation.ts", "src/generated/pages.ts", "src/generated/resources.ts", "src/generated/page-routes.ts"],
|
|
8
|
+
"dataMode": "empty-in-memory",
|
|
9
|
+
"boundaries": ["No production writes", "No fake authorization", "Actions require explicit command contracts", "Menu count follows business needs"]
|
|
10
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: svadmin-vibe
|
|
3
|
+
description: Build product-first Svelte admin surfaces from a validated svadmin.product.json and generated page plans.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# svadmin Vibe
|
|
7
|
+
|
|
8
|
+
Read PRODUCT.md, DESIGN.md, ARCHITECTURE.md, svadmin.product.json and svadmin.ai.json before changing a generated project. Keep the product plan as the source of truth for roles, workflows, resources, surfaces and menu entries.
|
|
9
|
+
|
|
10
|
+
Start with the user outcome and workflow. Resources are data contracts and do not automatically become pages or sidebar entries. Menu count follows the business domain; do not remove required entries to satisfy a global limit. Keep one dominant action per surface and use progressive disclosure for secondary actions.
|
|
11
|
+
|
|
12
|
+
Generated files under src/generated are derived artifacts. Change svadmin.product.json and regenerate instead of editing them by hand. Product actions are metadata until a typed command or provider contract is connected. Never claim that an empty in-memory provider provides persistence, authorization, audit or production data.
|
|
13
|
+
|
|
14
|
+
Use the installed @svadmin/ui component registry and public type declarations. Keep the Svelte/SvelteKit stack, semantic tokens, responsive layout and state coverage. Verify with bun run check, bun run build and bun run test:ui, then inspect 1440x900 and 1920x1080 captures. Backend authorization, tenancy, audit and durable writes remain server responsibilities.
|
package/dist/index.js
CHANGED
|
@@ -17670,7 +17670,7 @@ function buildAdminAiManifest(options) {
|
|
|
17670
17670
|
notes: SCAFFOLD_MIGRATION_NOTES
|
|
17671
17671
|
},
|
|
17672
17672
|
plugins: [],
|
|
17673
|
-
guidance: ["AGENTS.md", "DESIGN.md"]
|
|
17673
|
+
guidance: ["AGENTS.md", "DESIGN.md", "PRODUCT.md"]
|
|
17674
17674
|
};
|
|
17675
17675
|
}
|
|
17676
17676
|
function buildScaffoldPlatformFiles(options) {
|
|
@@ -18685,6 +18685,406 @@ var resources = [
|
|
|
18685
18685
|
|
|
18686
18686
|
// src/vibe-command.ts
|
|
18687
18687
|
init_vibe_catalog();
|
|
18688
|
+
|
|
18689
|
+
// ../core/src/product-plan.ts
|
|
18690
|
+
var PRODUCT_PLAN_SCHEMA_VERSION = "product/v1";
|
|
18691
|
+
function isRecord2(value) {
|
|
18692
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18693
|
+
}
|
|
18694
|
+
function hasStringId(value) {
|
|
18695
|
+
return isRecord2(value) && typeof value["id"] === "string" && value["id"].trim().length > 0;
|
|
18696
|
+
}
|
|
18697
|
+
var PRODUCT_DISCLOSURES = ["visible", "collapsed", "more", "context", "hidden"];
|
|
18698
|
+
var PRODUCT_PRIORITIES = ["core", "secondary", "future"];
|
|
18699
|
+
var PRODUCT_FREQUENCIES = ["high", "medium", "low", "unknown"];
|
|
18700
|
+
var PRODUCT_PLACEMENTS = ["primary", "secondary", "settings", "context", "command", "hidden"];
|
|
18701
|
+
var PRODUCT_PAGE_KINDS = ["workbench", "list", "detail", "form", "approval", "import", "settings", "custom"];
|
|
18702
|
+
var PRODUCT_STATES = ["empty", "loading", "error", "forbidden", "success"];
|
|
18703
|
+
function pushEnumIssue(issues, value, path, allowed) {
|
|
18704
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
18705
|
+
issues.push({ code: "invalid-enum", path, message: path + " must be one of " + allowed.join(", ") + "." });
|
|
18706
|
+
}
|
|
18707
|
+
}
|
|
18708
|
+
function pushProductFieldIssues(issues, value, path) {
|
|
18709
|
+
if (!Array.isArray(value)) {
|
|
18710
|
+
issues.push({ code: "missing-array", path, message: path + " must be an array." });
|
|
18711
|
+
return;
|
|
18712
|
+
}
|
|
18713
|
+
const seen = new Set;
|
|
18714
|
+
value.forEach((item, index) => {
|
|
18715
|
+
const itemPath = path + "." + index;
|
|
18716
|
+
if (!isRecord2(item)) {
|
|
18717
|
+
issues.push({ code: "invalid-field", path: itemPath, message: itemPath + " must be an object." });
|
|
18718
|
+
return;
|
|
18719
|
+
}
|
|
18720
|
+
const fieldId = item["id"];
|
|
18721
|
+
if (typeof fieldId !== "string" || fieldId.trim().length === 0) {
|
|
18722
|
+
issues.push({ code: "invalid-id", path: itemPath + ".id", message: "Every product field needs a non-empty string id." });
|
|
18723
|
+
return;
|
|
18724
|
+
}
|
|
18725
|
+
if (seen.has(fieldId)) {
|
|
18726
|
+
issues.push({ code: "duplicate-id", path: itemPath + ".id", message: "Product field id " + fieldId + " is duplicated within its resource." });
|
|
18727
|
+
}
|
|
18728
|
+
seen.add(fieldId);
|
|
18729
|
+
for (const key of ["label", "type"]) {
|
|
18730
|
+
if (typeof item[key] !== "string")
|
|
18731
|
+
issues.push({ code: "invalid-field", path: itemPath + "." + key, message: itemPath + "." + key + " must be a string." });
|
|
18732
|
+
}
|
|
18733
|
+
for (const key of ["readableBy", "writableBy", "usedByWorkflowIds"]) {
|
|
18734
|
+
const candidate = item[key];
|
|
18735
|
+
if (!Array.isArray(candidate) || !candidate.every((entry) => typeof entry === "string")) {
|
|
18736
|
+
issues.push({ code: "invalid-field", path: itemPath + "." + key, message: itemPath + "." + key + " must be an array of strings." });
|
|
18737
|
+
}
|
|
18738
|
+
}
|
|
18739
|
+
for (const key of ["required", "sensitive"]) {
|
|
18740
|
+
if (item[key] !== undefined && typeof item[key] !== "boolean") {
|
|
18741
|
+
issues.push({ code: "invalid-field", path: itemPath + "." + key, message: itemPath + "." + key + " must be a boolean when provided." });
|
|
18742
|
+
}
|
|
18743
|
+
}
|
|
18744
|
+
if (item["disclosure"] !== undefined)
|
|
18745
|
+
pushEnumIssue(issues, item["disclosure"], itemPath + ".disclosure", PRODUCT_DISCLOSURES);
|
|
18746
|
+
});
|
|
18747
|
+
}
|
|
18748
|
+
function pushEntityIssues(issues, value, path, seen, shape = {}) {
|
|
18749
|
+
if (!Array.isArray(value)) {
|
|
18750
|
+
issues.push({ code: "missing-array", path, message: `${path} must be an array.` });
|
|
18751
|
+
return;
|
|
18752
|
+
}
|
|
18753
|
+
value.forEach((item, index) => {
|
|
18754
|
+
const itemPath = `${path}.${index}`;
|
|
18755
|
+
if (!hasStringId(item)) {
|
|
18756
|
+
issues.push({ code: "invalid-id", path: `${itemPath}.id`, message: "Every product entity needs a non-empty string id." });
|
|
18757
|
+
return;
|
|
18758
|
+
}
|
|
18759
|
+
if (seen.has(item.id)) {
|
|
18760
|
+
issues.push({ code: "duplicate-id", path: `${itemPath}.id`, message: `Product id "${item.id}" is duplicated.` });
|
|
18761
|
+
}
|
|
18762
|
+
seen.add(item.id);
|
|
18763
|
+
const record = isRecord2(item) ? item : undefined;
|
|
18764
|
+
if (!record)
|
|
18765
|
+
return;
|
|
18766
|
+
for (const key of shape.strings ?? []) {
|
|
18767
|
+
if (typeof record[key] !== "string")
|
|
18768
|
+
issues.push({ code: "invalid-field", path: `${itemPath}.${key}`, message: `${itemPath}.${key} must be a string.` });
|
|
18769
|
+
}
|
|
18770
|
+
for (const key of shape.arrays ?? []) {
|
|
18771
|
+
const candidate = record[key];
|
|
18772
|
+
if (!Array.isArray(candidate) || !candidate.every((value) => typeof value === "string")) {
|
|
18773
|
+
issues.push({ code: "invalid-field", path: `${itemPath}.${key}`, message: `${itemPath}.${key} must be an array of strings.` });
|
|
18774
|
+
}
|
|
18775
|
+
}
|
|
18776
|
+
});
|
|
18777
|
+
}
|
|
18778
|
+
function validateProductPlan(input) {
|
|
18779
|
+
const issues = [];
|
|
18780
|
+
if (!isRecord2(input))
|
|
18781
|
+
return { ok: false, issues: [{ code: "invalid-root", path: "", message: "Product plan must be a JSON object." }] };
|
|
18782
|
+
if (input["schemaVersion"] !== PRODUCT_PLAN_SCHEMA_VERSION) {
|
|
18783
|
+
issues.push({ code: "invalid-version", path: "schemaVersion", message: `Expected ${PRODUCT_PLAN_SCHEMA_VERSION}.` });
|
|
18784
|
+
}
|
|
18785
|
+
if (!isRecord2(input["brief"])) {
|
|
18786
|
+
issues.push({ code: "invalid-root", path: "brief", message: "Product plan brief must be an object." });
|
|
18787
|
+
} else {
|
|
18788
|
+
const brief = input["brief"];
|
|
18789
|
+
for (const key of ["id", "name", "description", "primaryOutcome", "primaryAudience"]) {
|
|
18790
|
+
if (typeof brief[key] !== "string")
|
|
18791
|
+
issues.push({ code: "invalid-field", path: `brief.${key}`, message: `brief.${key} must be a string.` });
|
|
18792
|
+
}
|
|
18793
|
+
for (const key of ["constraints", "nonGoals", "assumptions"]) {
|
|
18794
|
+
const candidate = brief[key];
|
|
18795
|
+
if (!Array.isArray(candidate) || !candidate.every((value) => typeof value === "string"))
|
|
18796
|
+
issues.push({ code: "invalid-field", path: `brief.${key}`, message: `brief.${key} must be an array of strings.` });
|
|
18797
|
+
}
|
|
18798
|
+
}
|
|
18799
|
+
const shapes = {
|
|
18800
|
+
roles: { strings: ["name"], arrays: ["permissions"] },
|
|
18801
|
+
workflows: { strings: ["name", "trigger", "goal", "priority", "frequency"], arrays: ["actorRoleIds", "steps", "successCriteria", "exceptions"] },
|
|
18802
|
+
resources: { strings: ["name", "label"], arrays: ["roleIds", "workflowIds"] },
|
|
18803
|
+
actions: { strings: ["label", "workflowId"], arrays: ["roleIds"] },
|
|
18804
|
+
surfaces: { strings: ["name", "kind", "purpose"], arrays: ["workflowIds", "resourceIds", "roleIds", "actionIds", "sections", "states"] },
|
|
18805
|
+
menu: { strings: ["label", "placement"], arrays: ["workflowIds", "roleIds"] }
|
|
18806
|
+
};
|
|
18807
|
+
for (const key of ["roles", "workflows", "resources", "actions", "surfaces", "menu"]) {
|
|
18808
|
+
pushEntityIssues(issues, input[key], key, new Set, shapes[key]);
|
|
18809
|
+
}
|
|
18810
|
+
const workflows = input["workflows"];
|
|
18811
|
+
if (Array.isArray(workflows))
|
|
18812
|
+
workflows.forEach((workflow, index) => {
|
|
18813
|
+
if (!isRecord2(workflow))
|
|
18814
|
+
return;
|
|
18815
|
+
if (typeof workflow["priority"] === "string")
|
|
18816
|
+
pushEnumIssue(issues, workflow["priority"], "workflows." + index + ".priority", PRODUCT_PRIORITIES);
|
|
18817
|
+
if (typeof workflow["frequency"] === "string")
|
|
18818
|
+
pushEnumIssue(issues, workflow["frequency"], "workflows." + index + ".frequency", PRODUCT_FREQUENCIES);
|
|
18819
|
+
});
|
|
18820
|
+
const resources = input["resources"];
|
|
18821
|
+
if (Array.isArray(resources))
|
|
18822
|
+
resources.forEach((resource, index) => {
|
|
18823
|
+
if (!isRecord2(resource))
|
|
18824
|
+
return;
|
|
18825
|
+
pushProductFieldIssues(issues, resource["fields"], "resources." + index + ".fields");
|
|
18826
|
+
if (resource["menuPlacement"] !== undefined)
|
|
18827
|
+
pushEnumIssue(issues, resource["menuPlacement"], "resources." + index + ".menuPlacement", PRODUCT_PLACEMENTS);
|
|
18828
|
+
if (resource["internal"] !== undefined && typeof resource["internal"] !== "boolean") {
|
|
18829
|
+
issues.push({ code: "invalid-field", path: "resources." + index + ".internal", message: "resources." + index + ".internal must be a boolean when provided." });
|
|
18830
|
+
}
|
|
18831
|
+
});
|
|
18832
|
+
const actions = input["actions"];
|
|
18833
|
+
if (Array.isArray(actions))
|
|
18834
|
+
actions.forEach((action, index) => {
|
|
18835
|
+
if (!isRecord2(action))
|
|
18836
|
+
return;
|
|
18837
|
+
if (action["disclosure"] !== undefined)
|
|
18838
|
+
pushEnumIssue(issues, action["disclosure"], "actions." + index + ".disclosure", PRODUCT_DISCLOSURES);
|
|
18839
|
+
if (action["destructive"] !== undefined && typeof action["destructive"] !== "boolean") {
|
|
18840
|
+
issues.push({ code: "invalid-field", path: "actions." + index + ".destructive", message: "actions." + index + ".destructive must be a boolean when provided." });
|
|
18841
|
+
}
|
|
18842
|
+
});
|
|
18843
|
+
const surfaces = input["surfaces"];
|
|
18844
|
+
if (Array.isArray(surfaces))
|
|
18845
|
+
surfaces.forEach((surface, index) => {
|
|
18846
|
+
if (!isRecord2(surface))
|
|
18847
|
+
return;
|
|
18848
|
+
if (typeof surface["kind"] === "string")
|
|
18849
|
+
pushEnumIssue(issues, surface["kind"], "surfaces." + index + ".kind", PRODUCT_PAGE_KINDS);
|
|
18850
|
+
if (surface["menuPlacement"] !== undefined)
|
|
18851
|
+
pushEnumIssue(issues, surface["menuPlacement"], "surfaces." + index + ".menuPlacement", PRODUCT_PLACEMENTS);
|
|
18852
|
+
if (surface["route"] !== undefined && (typeof surface["route"] !== "string" || surface["route"].trim().length === 0)) {
|
|
18853
|
+
issues.push({ code: "invalid-field", path: "surfaces." + index + ".route", message: "surfaces." + index + ".route must be a non-empty string when provided." });
|
|
18854
|
+
}
|
|
18855
|
+
const states = surface["states"];
|
|
18856
|
+
if (Array.isArray(states))
|
|
18857
|
+
states.forEach((state, stateIndex) => {
|
|
18858
|
+
if (typeof state === "string")
|
|
18859
|
+
pushEnumIssue(issues, state, "surfaces." + index + ".states." + stateIndex, PRODUCT_STATES);
|
|
18860
|
+
});
|
|
18861
|
+
});
|
|
18862
|
+
const menu = input["menu"];
|
|
18863
|
+
if (Array.isArray(menu))
|
|
18864
|
+
menu.forEach((entry, index) => {
|
|
18865
|
+
if (!isRecord2(entry))
|
|
18866
|
+
return;
|
|
18867
|
+
if (typeof entry["placement"] === "string")
|
|
18868
|
+
pushEnumIssue(issues, entry["placement"], "menu." + index + ".placement", PRODUCT_PLACEMENTS);
|
|
18869
|
+
for (const key of ["href", "parentId", "surfaceId", "resourceId", "condition"]) {
|
|
18870
|
+
if (entry[key] !== undefined && typeof entry[key] !== "string") {
|
|
18871
|
+
issues.push({ code: "invalid-field", path: "menu." + index + "." + key, message: "menu." + index + "." + key + " must be a string when provided." });
|
|
18872
|
+
}
|
|
18873
|
+
}
|
|
18874
|
+
if (entry["order"] !== undefined && (typeof entry["order"] !== "number" || !Number.isFinite(entry["order"]))) {
|
|
18875
|
+
issues.push({ code: "invalid-field", path: "menu." + index + ".order", message: "menu." + index + ".order must be a finite number when provided." });
|
|
18876
|
+
}
|
|
18877
|
+
});
|
|
18878
|
+
return issues.length === 0 ? { ok: true, value: input } : { ok: false, issues };
|
|
18879
|
+
}
|
|
18880
|
+
|
|
18881
|
+
class ProductPlanValidationError extends Error {
|
|
18882
|
+
issues;
|
|
18883
|
+
constructor(issues) {
|
|
18884
|
+
super(`Invalid product plan (${issues.length} issue${issues.length === 1 ? "" : "s"})`);
|
|
18885
|
+
this.name = "ProductPlanValidationError";
|
|
18886
|
+
this.issues = issues;
|
|
18887
|
+
}
|
|
18888
|
+
}
|
|
18889
|
+
function parseProductPlan(input) {
|
|
18890
|
+
const result = validateProductPlan(input);
|
|
18891
|
+
if (!result.ok)
|
|
18892
|
+
throw new ProductPlanValidationError(result.issues);
|
|
18893
|
+
return result.value;
|
|
18894
|
+
}
|
|
18895
|
+
|
|
18896
|
+
// ../core/src/product-navigation.ts
|
|
18897
|
+
function canSeeByRole(item, roleIds) {
|
|
18898
|
+
return item.roleIds.length === 0 || item.roleIds.some((roleId) => roleIds.includes(roleId));
|
|
18899
|
+
}
|
|
18900
|
+
function conditionEnabled(condition, flags) {
|
|
18901
|
+
return condition === undefined ? true : flags?.[condition] === true;
|
|
18902
|
+
}
|
|
18903
|
+
function createNode(item, allItems, context, ancestry = new Set) {
|
|
18904
|
+
const visibleByRole = canSeeByRole(item, context.roleIds);
|
|
18905
|
+
const visibleByCondition = conditionEnabled(item.condition, context.featureFlags);
|
|
18906
|
+
const visible = visibleByRole && visibleByCondition && item.placement !== "hidden";
|
|
18907
|
+
const nextAncestry = new Set(ancestry);
|
|
18908
|
+
nextAncestry.add(item.id);
|
|
18909
|
+
const children = nextAncestry.has(item.id) ? allItems.filter((candidate) => candidate.parentId === item.id && !nextAncestry.has(candidate.id)).sort((left, right) => (left.order ?? 0) - (right.order ?? 0)).map((candidate) => createNode(candidate, allItems, context, nextAncestry)) : [];
|
|
18910
|
+
return {
|
|
18911
|
+
...item,
|
|
18912
|
+
children,
|
|
18913
|
+
visible,
|
|
18914
|
+
...visible ? {} : { reason: visibleByRole ? "hidden by business condition or placement" : "current role is not allowed to see this entry" }
|
|
18915
|
+
};
|
|
18916
|
+
}
|
|
18917
|
+
function buildProductNavigation(plan, context) {
|
|
18918
|
+
return plan.menu.filter((item) => item.parentId === undefined).sort((left, right) => (left.order ?? 0) - (right.order ?? 0)).map((item) => createNode(item, plan.menu, context));
|
|
18919
|
+
}
|
|
18920
|
+
|
|
18921
|
+
// ../core/src/product-pages.ts
|
|
18922
|
+
function buildProductPagePlan(surface, plan) {
|
|
18923
|
+
const actionById = new Map(plan.actions.map((action) => [action.id, action]));
|
|
18924
|
+
const primaryAction = surface.primaryActionId === undefined ? undefined : actionById.get(surface.primaryActionId);
|
|
18925
|
+
const secondaryActions = surface.actionIds.filter((id) => id !== surface.primaryActionId).map((id) => actionById.get(id)).filter((action) => action !== undefined);
|
|
18926
|
+
const sections = surface.sections.length ? surface.sections.map((title, index) => ({ id: `${surface.id}.section.${index + 1}`, title, resourceIds: surface.resourceIds })) : [{ id: `${surface.id}.content`, title: surface.name, resourceIds: surface.resourceIds }];
|
|
18927
|
+
return {
|
|
18928
|
+
id: surface.id,
|
|
18929
|
+
kind: surface.kind,
|
|
18930
|
+
title: surface.name,
|
|
18931
|
+
purpose: surface.purpose,
|
|
18932
|
+
workflowIds: surface.workflowIds,
|
|
18933
|
+
roleIds: surface.roleIds,
|
|
18934
|
+
...primaryAction === undefined ? {} : { primaryAction },
|
|
18935
|
+
secondaryActions,
|
|
18936
|
+
sections,
|
|
18937
|
+
states: surface.states
|
|
18938
|
+
};
|
|
18939
|
+
}
|
|
18940
|
+
|
|
18941
|
+
// ../core/src/product-review.ts
|
|
18942
|
+
function has2(set, value) {
|
|
18943
|
+
return set.has(value);
|
|
18944
|
+
}
|
|
18945
|
+
function addIssue(issues, issue) {
|
|
18946
|
+
issues.push(issue);
|
|
18947
|
+
}
|
|
18948
|
+
function reviewProductPlan(plan) {
|
|
18949
|
+
const issues = [];
|
|
18950
|
+
const roleIds = new Set(plan.roles.map((role) => role.id));
|
|
18951
|
+
const workflowIds = new Set(plan.workflows.map((workflow) => workflow.id));
|
|
18952
|
+
const resourceIds = new Set(plan.resources.map((resource) => resource.id));
|
|
18953
|
+
const surfaceIds = new Set(plan.surfaces.map((surface) => surface.id));
|
|
18954
|
+
const actionIds = new Set(plan.actions.map((action) => action.id));
|
|
18955
|
+
for (const workflow of plan.workflows) {
|
|
18956
|
+
if (!workflow.actorRoleIds.length)
|
|
18957
|
+
addIssue(issues, { code: "workflow.missing-actor", severity: "error", message: `Workflow "${workflow.name}" has no actor role.`, path: `workflows.${workflow.id}.actorRoleIds` });
|
|
18958
|
+
for (const roleId of workflow.actorRoleIds)
|
|
18959
|
+
if (!has2(roleIds, roleId))
|
|
18960
|
+
addIssue(issues, { code: "workflow.unknown-role", severity: "error", message: `Workflow "${workflow.name}" references unknown role "${roleId}".`, path: `workflows.${workflow.id}.actorRoleIds` });
|
|
18961
|
+
if (!workflow.goal.trim())
|
|
18962
|
+
addIssue(issues, { code: "workflow.missing-goal", severity: "error", message: `Workflow "${workflow.name}" has no user goal.`, path: `workflows.${workflow.id}.goal` });
|
|
18963
|
+
}
|
|
18964
|
+
for (const resource of plan.resources) {
|
|
18965
|
+
if (!resource.workflowIds.length && !resource.internal)
|
|
18966
|
+
addIssue(issues, { code: "resource.no-workflow", severity: "warning", message: `Resource "${resource.label}" is not attached to a workflow.`, path: `resources.${resource.id}.workflowIds`, suggestion: "Make it contextual/internal or attach it to a real task." });
|
|
18967
|
+
for (const workflowId of resource.workflowIds)
|
|
18968
|
+
if (!has2(workflowIds, workflowId))
|
|
18969
|
+
addIssue(issues, { code: "resource.unknown-workflow", severity: "error", message: `Resource "${resource.label}" references unknown workflow "${workflowId}".`, path: `resources.${resource.id}.workflowIds` });
|
|
18970
|
+
}
|
|
18971
|
+
for (const surface of plan.surfaces) {
|
|
18972
|
+
reviewSurface(surface, { workflowIds, actionIds, resourceIds }, issues);
|
|
18973
|
+
}
|
|
18974
|
+
const menuById = new Map(plan.menu.map((item) => [item.id, item]));
|
|
18975
|
+
for (const item of plan.menu) {
|
|
18976
|
+
if (item.parentId !== undefined && !menuById.has(item.parentId)) {
|
|
18977
|
+
addIssue(issues, { code: "menu.unknown-parent", severity: "error", message: `Menu item "${item.label}" references unknown parent "${item.parentId}".`, path: `menu.${item.id}.parentId` });
|
|
18978
|
+
}
|
|
18979
|
+
}
|
|
18980
|
+
const visiting = new Set;
|
|
18981
|
+
const visited = new Set;
|
|
18982
|
+
const visitMenu = (id) => {
|
|
18983
|
+
if (visiting.has(id)) {
|
|
18984
|
+
addIssue(issues, { code: "menu.cycle", severity: "error", message: `Menu hierarchy contains a cycle at "${id}".`, path: `menu.${id}.parentId` });
|
|
18985
|
+
return;
|
|
18986
|
+
}
|
|
18987
|
+
if (visited.has(id))
|
|
18988
|
+
return;
|
|
18989
|
+
visiting.add(id);
|
|
18990
|
+
const parentId = menuById.get(id)?.parentId;
|
|
18991
|
+
if (parentId !== undefined && menuById.has(parentId))
|
|
18992
|
+
visitMenu(parentId);
|
|
18993
|
+
visiting.delete(id);
|
|
18994
|
+
visited.add(id);
|
|
18995
|
+
};
|
|
18996
|
+
for (const item of plan.menu)
|
|
18997
|
+
visitMenu(item.id);
|
|
18998
|
+
for (const item of plan.menu) {
|
|
18999
|
+
if (!item.workflowIds.length && !item.resourceId && !item.surfaceId)
|
|
19000
|
+
addIssue(issues, { code: "menu.no-business-target", severity: "warning", message: `Menu item "${item.label}" has no workflow, resource or surface target.`, path: `menu.${item.id}`, suggestion: "Keep it only for an intentional external/system entry." });
|
|
19001
|
+
if (item.surfaceId && !has2(surfaceIds, item.surfaceId))
|
|
19002
|
+
addIssue(issues, { code: "menu.unknown-surface", severity: "error", message: `Menu item "${item.label}" references unknown surface.`, path: `menu.${item.id}.surfaceId` });
|
|
19003
|
+
if (item.resourceId && !has2(resourceIds, item.resourceId))
|
|
19004
|
+
addIssue(issues, { code: "menu.unknown-resource", severity: "error", message: `Menu item "${item.label}" references unknown resource.`, path: `menu.${item.id}.resourceId` });
|
|
19005
|
+
if (!item.roleIds.length && item.placement !== "hidden")
|
|
19006
|
+
addIssue(issues, { code: "menu.public-entry", severity: "info", message: `Menu item "${item.label}" is public at the UI hint layer.`, path: `menu.${item.id}.roleIds`, suggestion: "Keep server authorization independent from menu visibility." });
|
|
19007
|
+
}
|
|
19008
|
+
for (const action of plan.actions) {
|
|
19009
|
+
if (!has2(workflowIds, action.workflowId))
|
|
19010
|
+
addIssue(issues, { code: "action.unknown-workflow", severity: "error", message: `Action "${action.label}" references unknown workflow.`, path: `actions.${action.id}.workflowId` });
|
|
19011
|
+
if (!action.roleIds.length)
|
|
19012
|
+
addIssue(issues, { code: "action.missing-roles", severity: "warning", message: `Action "${action.label}" has no role visibility contract.`, path: `actions.${action.id}.roleIds` });
|
|
19013
|
+
}
|
|
19014
|
+
const served = new Set;
|
|
19015
|
+
plan.surfaces.forEach((surface) => surface.workflowIds.forEach((id) => served.add(id)));
|
|
19016
|
+
plan.menu.forEach((item) => item.workflowIds.forEach((id) => served.add(id)));
|
|
19017
|
+
const unserved = plan.workflows.filter((workflow) => !served.has(workflow.id));
|
|
19018
|
+
unserved.forEach((workflow) => addIssue(issues, { code: "workflow.unserved", severity: workflow.priority === "core" ? "error" : "warning", message: `Workflow "${workflow.name}" has no page or menu entry.`, path: `workflows.${workflow.id}`, suggestion: "Add a surface or contextual entry, or mark the workflow as deferred." }));
|
|
19019
|
+
const roleVisibilityGaps = plan.workflows.filter((workflow) => workflow.actorRoleIds.length > 0 && !plan.surfaces.some((surface) => surface.workflowIds.includes(workflow.id) && (surface.roleIds.length === 0 || workflow.actorRoleIds.some((roleId) => surface.roleIds.includes(roleId))))).length;
|
|
19020
|
+
const orphanResourceCount = plan.resources.filter((resource) => resource.workflowIds.length === 0 && !resource.internal).length;
|
|
19021
|
+
const errors = issues.filter((issue) => issue.severity === "error");
|
|
19022
|
+
return {
|
|
19023
|
+
ok: errors.length === 0,
|
|
19024
|
+
issues,
|
|
19025
|
+
metrics: {
|
|
19026
|
+
resourceCount: plan.resources.length,
|
|
19027
|
+
workflowCount: plan.workflows.length,
|
|
19028
|
+
surfaceCount: plan.surfaces.length,
|
|
19029
|
+
menuNodeCount: plan.menu.length,
|
|
19030
|
+
orphanResourceCount,
|
|
19031
|
+
unservedWorkflowCount: unserved.length,
|
|
19032
|
+
roleVisibilityGaps
|
|
19033
|
+
}
|
|
19034
|
+
};
|
|
19035
|
+
}
|
|
19036
|
+
function reviewSurface(surface, refs, issues) {
|
|
19037
|
+
if (!surface.purpose.trim())
|
|
19038
|
+
addIssue(issues, { code: "surface.missing-purpose", severity: "error", message: `Surface "${surface.name}" has no purpose.`, path: `surfaces.${surface.id}.purpose` });
|
|
19039
|
+
if (!surface.workflowIds.length)
|
|
19040
|
+
addIssue(issues, { code: "surface.no-workflow", severity: "warning", message: `Surface "${surface.name}" is not attached to a workflow.`, path: `surfaces.${surface.id}.workflowIds` });
|
|
19041
|
+
surface.workflowIds.forEach((id) => {
|
|
19042
|
+
if (!refs.workflowIds.has(id))
|
|
19043
|
+
addIssue(issues, { code: "surface.unknown-workflow", severity: "error", message: `Surface "${surface.name}" references unknown workflow "${id}".`, path: `surfaces.${surface.id}.workflowIds` });
|
|
19044
|
+
});
|
|
19045
|
+
surface.resourceIds.forEach((id) => {
|
|
19046
|
+
if (!refs.resourceIds.has(id))
|
|
19047
|
+
addIssue(issues, { code: "surface.unknown-resource", severity: "error", message: `Surface "${surface.name}" references unknown resource "${id}".`, path: `surfaces.${surface.id}.resourceIds` });
|
|
19048
|
+
});
|
|
19049
|
+
surface.actionIds.forEach((id) => {
|
|
19050
|
+
if (!refs.actionIds.has(id))
|
|
19051
|
+
addIssue(issues, { code: "surface.unknown-action", severity: "error", message: `Surface "${surface.name}" references unknown action "${id}".`, path: `surfaces.${surface.id}.actionIds` });
|
|
19052
|
+
});
|
|
19053
|
+
if (surface.primaryActionId && !refs.actionIds.has(surface.primaryActionId))
|
|
19054
|
+
addIssue(issues, { code: "surface.unknown-primary-action", severity: "error", message: `Surface "${surface.name}" references unknown primary action.`, path: `surfaces.${surface.id}.primaryActionId` });
|
|
19055
|
+
if (!surface.states.length)
|
|
19056
|
+
addIssue(issues, { code: "surface.missing-states", severity: "warning", message: `Surface "${surface.name}" does not declare empty/loading/error/permission states.`, path: `surfaces.${surface.id}.states`, suggestion: "Declare the states the Svelte view must implement." });
|
|
19057
|
+
if (surface.sections.length > 6)
|
|
19058
|
+
addIssue(issues, { code: "surface.section-density", severity: "warning", message: `Surface "${surface.name}" contains ${surface.sections.length} sections.`, path: `surfaces.${surface.id}.sections`, suggestion: "Group related information or use progressive disclosure." });
|
|
19059
|
+
if (surface.actionIds.length > 4)
|
|
19060
|
+
addIssue(issues, { code: "surface.action-density", severity: "warning", message: `Surface "${surface.name}" exposes ${surface.actionIds.length} actions.`, path: `surfaces.${surface.id}.actionIds`, suggestion: "Keep one primary action and move secondary actions into context or More." });
|
|
19061
|
+
}
|
|
19062
|
+
|
|
19063
|
+
// ../core/src/product-pipeline.ts
|
|
19064
|
+
function runProductPipeline(input) {
|
|
19065
|
+
const navigation = buildProductNavigation(input.plan, input.navigation);
|
|
19066
|
+
const visibleSurfaceIds = input.surfaceIds === undefined ? undefined : new Set(input.surfaceIds);
|
|
19067
|
+
const pages = input.plan.surfaces.filter((surface) => visibleSurfaceIds === undefined || visibleSurfaceIds.has(surface.id)).filter((surface) => surface.roleIds.length === 0 || surface.roleIds.some((roleId) => input.navigation.roleIds.includes(roleId))).map((surface) => buildProductPagePlan(surface, input.plan));
|
|
19068
|
+
return { navigation, pages, productReview: reviewProductPlan(input.plan) };
|
|
19069
|
+
}
|
|
19070
|
+
function formatProductPreview(output) {
|
|
19071
|
+
const lines = ["Navigation:"];
|
|
19072
|
+
for (const node of output.navigation) {
|
|
19073
|
+
if (!node.visible)
|
|
19074
|
+
continue;
|
|
19075
|
+
lines.push(`- ${node.label}`);
|
|
19076
|
+
for (const child of node.children)
|
|
19077
|
+
if (child.visible)
|
|
19078
|
+
lines.push(` - ${child.label}`);
|
|
19079
|
+
}
|
|
19080
|
+
lines.push("", "Pages:");
|
|
19081
|
+
output.pages.forEach((page) => lines.push(`- ${page.title}: ${page.purpose}`));
|
|
19082
|
+
lines.push("", `Product review: ${output.productReview.ok ? "pass" : "fail"}`);
|
|
19083
|
+
return lines.join(`
|
|
19084
|
+
`);
|
|
19085
|
+
}
|
|
19086
|
+
|
|
19087
|
+
// src/vibe-command.ts
|
|
18688
19088
|
var PRESETS = ["operations", "enterprise", "collaboration"];
|
|
18689
19089
|
function parseVibeArguments(args) {
|
|
18690
19090
|
const [command, ...rest] = args;
|
|
@@ -18692,6 +19092,31 @@ function parseVibeArguments(args) {
|
|
|
18692
19092
|
return { command };
|
|
18693
19093
|
if (command === "catalog" && rest.length === 0)
|
|
18694
19094
|
return { command };
|
|
19095
|
+
if (command === "plan" || command === "review") {
|
|
19096
|
+
const positional = [];
|
|
19097
|
+
const roleIds = [];
|
|
19098
|
+
for (let i = 0;i < rest.length; i++) {
|
|
19099
|
+
const arg = rest[i];
|
|
19100
|
+
if (arg === undefined)
|
|
19101
|
+
continue;
|
|
19102
|
+
if (arg === "--role") {
|
|
19103
|
+
const roleId = rest[++i];
|
|
19104
|
+
if (!roleId?.trim())
|
|
19105
|
+
throw new Error(`Missing value for --role`);
|
|
19106
|
+
roleIds.push(roleId);
|
|
19107
|
+
} else if (arg.startsWith("-")) {
|
|
19108
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
19109
|
+
} else {
|
|
19110
|
+
positional.push(arg);
|
|
19111
|
+
}
|
|
19112
|
+
}
|
|
19113
|
+
if (positional.length !== 1)
|
|
19114
|
+
throw new Error(`vibe ${command} requires exactly one product plan JSON file`);
|
|
19115
|
+
const planFile = positional[0];
|
|
19116
|
+
if (planFile === undefined)
|
|
19117
|
+
throw new Error("Missing product plan JSON file");
|
|
19118
|
+
return { command, planFile: path6.resolve(planFile), roleIds };
|
|
19119
|
+
}
|
|
18695
19120
|
if (command === "catalog" && rest.length === 2 && rest[0] === "--query" && rest[1]?.trim()) {
|
|
18696
19121
|
return { command, query: rest[1] };
|
|
18697
19122
|
}
|
|
@@ -18699,9 +19124,11 @@ function parseVibeArguments(args) {
|
|
|
18699
19124
|
return { command, page: rest[0] };
|
|
18700
19125
|
}
|
|
18701
19126
|
if (command !== "init")
|
|
18702
|
-
throw new Error("Usage: vibe mcp | vibe catalog [--query <keywords>] | vibe inspect <page> | vibe init <directory> [--preset operations|enterprise|collaboration] [--write]");
|
|
19127
|
+
throw new Error("Usage: vibe mcp | vibe catalog [--query <keywords>] | vibe inspect <page> | vibe plan <product-plan.json> [--role <role-id>] | vibe review <product-plan.json> | vibe init <directory> [--preset operations|enterprise|collaboration] [--product-plan <file>] [--role <role-id>] [--write]");
|
|
18703
19128
|
let preset = "enterprise";
|
|
18704
19129
|
let write = false;
|
|
19130
|
+
let productPlanFile;
|
|
19131
|
+
const roleIds = [];
|
|
18705
19132
|
const positional = [];
|
|
18706
19133
|
for (let i = 0;i < rest.length; i++) {
|
|
18707
19134
|
const arg = rest[i];
|
|
@@ -18714,6 +19141,16 @@ function parseVibeArguments(args) {
|
|
|
18714
19141
|
if (!PRESETS.some((p) => p === value))
|
|
18715
19142
|
throw new Error(`Unknown design preset: ${value ?? "(missing)"}`);
|
|
18716
19143
|
preset = value;
|
|
19144
|
+
} else if (arg === "--product-plan" || arg === "--plan") {
|
|
19145
|
+
const value = rest[++i];
|
|
19146
|
+
if (!value?.trim())
|
|
19147
|
+
throw new Error(`Missing value for ${arg}`);
|
|
19148
|
+
productPlanFile = path6.resolve(value);
|
|
19149
|
+
} else if (arg === "--role") {
|
|
19150
|
+
const value = rest[++i];
|
|
19151
|
+
if (!value?.trim())
|
|
19152
|
+
throw new Error("Missing value for --role");
|
|
19153
|
+
roleIds.push(value);
|
|
18717
19154
|
} else if (arg.startsWith("-"))
|
|
18718
19155
|
throw new Error(`Unknown option: ${arg}`);
|
|
18719
19156
|
else
|
|
@@ -18724,7 +19161,7 @@ function parseVibeArguments(args) {
|
|
|
18724
19161
|
const directory = positional[0];
|
|
18725
19162
|
if (directory === undefined)
|
|
18726
19163
|
throw new Error("Missing project directory");
|
|
18727
|
-
return { command, directory: path6.resolve(directory), preset, write };
|
|
19164
|
+
return { command, directory: path6.resolve(directory), preset, write, productPlanFile, roleIds };
|
|
18728
19165
|
}
|
|
18729
19166
|
function readTree(directory, prefix = "") {
|
|
18730
19167
|
const files = {};
|
|
@@ -18740,8 +19177,119 @@ function readTree(directory, prefix = "") {
|
|
|
18740
19177
|
}
|
|
18741
19178
|
return files;
|
|
18742
19179
|
}
|
|
18743
|
-
function
|
|
18744
|
-
|
|
19180
|
+
function productRoute(surface) {
|
|
19181
|
+
return surface.route ?? "/workflows/" + surface.id;
|
|
19182
|
+
}
|
|
19183
|
+
function buildProductGeneratedFiles(plan, selectedRoleIds) {
|
|
19184
|
+
const allRoleIds = plan.roles.map((role) => role.id);
|
|
19185
|
+
const roleIds = selectedRoleIds.length ? selectedRoleIds : allRoleIds;
|
|
19186
|
+
const planJson = JSON.stringify(plan, null, 2);
|
|
19187
|
+
const summary = [
|
|
19188
|
+
"# " + plan.brief.name,
|
|
19189
|
+
"",
|
|
19190
|
+
plan.brief.description,
|
|
19191
|
+
"",
|
|
19192
|
+
`## Primary outcome
|
|
19193
|
+
` + plan.brief.primaryOutcome,
|
|
19194
|
+
`## Audience
|
|
19195
|
+
` + plan.brief.primaryAudience,
|
|
19196
|
+
`## Roles
|
|
19197
|
+
` + (plan.roles.map((role) => "- **" + role.name + "** (" + role.id + "):" + (role.description ?? "未提供描述")).join(`
|
|
19198
|
+
`) || "- 未定义角色"),
|
|
19199
|
+
`## Workflows
|
|
19200
|
+
` + (plan.workflows.map((workflow) => "- **" + workflow.name + "**:" + workflow.goal + "(" + workflow.priority + " / " + workflow.frequency + ")").join(`
|
|
19201
|
+
`) || "- 未定义工作流"),
|
|
19202
|
+
`## Surfaces
|
|
19203
|
+
` + (plan.surfaces.map((surface) => "- **" + surface.name + "**:" + productRoute(surface) + "," + surface.purpose).join(`
|
|
19204
|
+
`) || "- 未定义页面"),
|
|
19205
|
+
`## Menu
|
|
19206
|
+
` + (plan.menu.map((item) => "- " + item.label + "(" + item.placement + ")").join(`
|
|
19207
|
+
`) || "- 未定义菜单入口"),
|
|
19208
|
+
"## Generation notes",
|
|
19209
|
+
"- Generated resources are read-only metadata until an explicit contract is added.",
|
|
19210
|
+
"- Product actions are metadata and do not imply backend mutations.",
|
|
19211
|
+
"- Initial visible roles: " + (roleIds.join(", ") || "all") + "."
|
|
19212
|
+
].join(`
|
|
19213
|
+
|
|
19214
|
+
`) + `
|
|
19215
|
+
`;
|
|
19216
|
+
const resourcesSource = [
|
|
19217
|
+
"import type { FieldDefinition, ResourceDefinition } from '@svadmin/core';",
|
|
19218
|
+
"import { productPlan } from './product-plan';",
|
|
19219
|
+
"",
|
|
19220
|
+
"const supportedFieldTypes = new Set<string>(['text','number','boolean','date','time','datetime','daterange','select','multiselect','tags','textarea','richtext','image','images','json','relation','color','url','email','phone','currency','percent','file','markdown','password','array','tree-select','treeselect','cascader','transfer','rate','rating','avatar','copy','code']);",
|
|
19221
|
+
"function toFieldType(type: string): FieldDefinition['type'] {",
|
|
19222
|
+
" return (supportedFieldTypes.has(type) ? type : 'text') as FieldDefinition['type'];",
|
|
19223
|
+
"}",
|
|
19224
|
+
"function toField(field: (typeof productPlan.resources)[number]['fields'][number]): FieldDefinition {",
|
|
19225
|
+
" const hidden = field.sensitive === true || field.disclosure === 'hidden';",
|
|
19226
|
+
" return { key: field.id, label: field.label, type: toFieldType(field.type), required: field.required, showInList: !hidden, showInShow: !hidden, showInForm: !hidden, showInCreate: !hidden, showInEdit: !hidden };",
|
|
19227
|
+
"}",
|
|
19228
|
+
"export const productResources: ResourceDefinition[] = productPlan.resources.map(resource => ({",
|
|
19229
|
+
" name: resource.runtimeName ?? resource.name, label: resource.label,",
|
|
19230
|
+
" icon: resource.menuPlacement === 'settings' ? 'settings' : undefined,",
|
|
19231
|
+
" fields: resource.fields.map(toField), canCreate: false, canEdit: false, canDelete: false, canShow: true,",
|
|
19232
|
+
" showInMenu: resource.internal !== true && resource.menuPlacement !== 'hidden' && resource.menuPlacement !== 'context' && resource.menuPlacement !== 'command',",
|
|
19233
|
+
"}));",
|
|
19234
|
+
""
|
|
19235
|
+
].join(`
|
|
19236
|
+
`);
|
|
19237
|
+
const generatedPlan = [
|
|
19238
|
+
"import { parseProductPlan, type ProductPlan } from '@svadmin/core/product-plan';",
|
|
19239
|
+
"",
|
|
19240
|
+
"export const productPlan: ProductPlan = parseProductPlan(" + planJson + ");",
|
|
19241
|
+
""
|
|
19242
|
+
].join(`
|
|
19243
|
+
`);
|
|
19244
|
+
const generatedNavigation = [
|
|
19245
|
+
"import type { MenuItem } from '@svadmin/core';",
|
|
19246
|
+
"import { buildProductMenuItems } from '@svadmin/core/product-navigation';",
|
|
19247
|
+
"import { productPlan } from './product-plan';",
|
|
19248
|
+
"",
|
|
19249
|
+
"export const ALL_PRODUCT_ROLE_IDS = " + JSON.stringify(allRoleIds) + " as const;",
|
|
19250
|
+
"export const INITIAL_PRODUCT_ROLE_IDS = " + JSON.stringify(roleIds) + " as const;",
|
|
19251
|
+
"export function createProductMenu(roleIds: readonly string[] = INITIAL_PRODUCT_ROLE_IDS, featureFlags: Readonly<Record<string, boolean>> = {}): MenuItem[] {",
|
|
19252
|
+
" return buildProductMenuItems(productPlan, { roleIds, featureFlags });",
|
|
19253
|
+
"}",
|
|
19254
|
+
"export const productMenu = createProductMenu();",
|
|
19255
|
+
""
|
|
19256
|
+
].join(`
|
|
19257
|
+
`);
|
|
19258
|
+
const generatedPages = [
|
|
19259
|
+
"import { runProductPipeline } from '@svadmin/core/product-pipeline';",
|
|
19260
|
+
"import { productPlan } from './product-plan';",
|
|
19261
|
+
"import { INITIAL_PRODUCT_ROLE_IDS } from './navigation';",
|
|
19262
|
+
"",
|
|
19263
|
+
"export const productPipeline = runProductPipeline({ plan: productPlan, navigation: { roleIds: [...INITIAL_PRODUCT_ROLE_IDS] } });",
|
|
19264
|
+
"export const productPagePlans = productPipeline.pages;",
|
|
19265
|
+
"export const productSurfaceRoutes = Object.fromEntries(productPlan.surfaces.map(surface => [surface.route ?? '/workflows/' + surface.id, surface.id])) as Record<string, string>;",
|
|
19266
|
+
""
|
|
19267
|
+
].join(`
|
|
19268
|
+
`);
|
|
19269
|
+
const generatedRoutes = [
|
|
19270
|
+
"import type { AdminPageRoute } from '@svadmin/ui';",
|
|
19271
|
+
"import ProductSurfacePage from '../ProductSurfacePage.svelte';",
|
|
19272
|
+
"import { productSurfaceRoutes } from './pages';",
|
|
19273
|
+
"",
|
|
19274
|
+
"export const productPageRoutes: readonly AdminPageRoute[] = Object.keys(productSurfaceRoutes).map(path => ({ path, component: ProductSurfacePage }));",
|
|
19275
|
+
""
|
|
19276
|
+
].join(`
|
|
19277
|
+
`);
|
|
19278
|
+
return {
|
|
19279
|
+
"svadmin.product.json": planJson + `
|
|
19280
|
+
`,
|
|
19281
|
+
"PRODUCT.md": summary,
|
|
19282
|
+
"src/generated/product-plan.ts": generatedPlan,
|
|
19283
|
+
"src/generated/resources.ts": resourcesSource,
|
|
19284
|
+
"src/generated/navigation.ts": generatedNavigation,
|
|
19285
|
+
"src/generated/pages.ts": generatedPages,
|
|
19286
|
+
"src/generated/page-routes.ts": generatedRoutes,
|
|
19287
|
+
"src/generated/README.md": `Generated product files. Derived from svadmin.product.json; regenerate instead of editing by hand.
|
|
19288
|
+
`
|
|
19289
|
+
};
|
|
19290
|
+
}
|
|
19291
|
+
function planVibeProject(packageRoot, manifest, preset, options = {}) {
|
|
19292
|
+
const blueprint = path6.join(packageRoot, options.productPlan ? "blueprints/product-workspace" : "blueprints/customer-workspace");
|
|
18745
19293
|
const files = readTree(blueprint);
|
|
18746
19294
|
const template = path6.join(packageRoot, "template");
|
|
18747
19295
|
for (const name of ["index.html", "svelte.config.js", "tsconfig.json", "vite.config.ts", "src/main.ts", "src/vite-env.d.ts"]) {
|
|
@@ -18752,15 +19300,19 @@ function planVibeProject(packageRoot, manifest, preset) {
|
|
|
18752
19300
|
}
|
|
18753
19301
|
files["DESIGN.md"] = fs6.readFileSync(path6.join(packageRoot, "guidance/DESIGN.md"), "utf8");
|
|
18754
19302
|
files["AGENTS.md"] = fs6.readFileSync(path6.join(packageRoot, "guidance/AGENTS.md"), "utf8");
|
|
19303
|
+
files["PRODUCT.md"] = fs6.readFileSync(path6.join(packageRoot, "guidance/PRODUCT.md"), "utf8");
|
|
19304
|
+
if (options.productPlan)
|
|
19305
|
+
Object.assign(files, buildProductGeneratedFiles(options.productPlan, options.roleIds ?? []));
|
|
18755
19306
|
const skill = files["vibe-skill.md"];
|
|
18756
19307
|
if (typeof skill !== "string")
|
|
18757
19308
|
throw new Error("The shipped vibe skill is missing");
|
|
18758
19309
|
files[".agents/skills/svadmin-vibe/SKILL.md"] = skill;
|
|
18759
19310
|
delete files["vibe-skill.md"];
|
|
18760
|
-
|
|
19311
|
+
if (!options.productPlan)
|
|
19312
|
+
files["src/design-selection.ts"] = `export const initialPreset = '${preset}' as const;
|
|
18761
19313
|
`;
|
|
18762
19314
|
const pkg = createProjectPackageJson(manifest, {
|
|
18763
|
-
projectName: "svadmin-customer-workspace",
|
|
19315
|
+
projectName: options.productPlan ? "svadmin-product-workspace" : "svadmin-customer-workspace",
|
|
18764
19316
|
dataProvider: "none",
|
|
18765
19317
|
authProvider: "none"
|
|
18766
19318
|
});
|
|
@@ -18777,31 +19329,36 @@ function planVibeProject(packageRoot, manifest, preset) {
|
|
|
18777
19329
|
authProvider: "none",
|
|
18778
19330
|
...manifest.dependencies["@svadmin/core"] === undefined ? {} : { coreVersionRange: manifest.dependencies["@svadmin/core"] }
|
|
18779
19331
|
});
|
|
18780
|
-
|
|
18781
|
-
name: resource.name,
|
|
18782
|
-
|
|
18783
|
-
|
|
18784
|
-
|
|
18785
|
-
|
|
18786
|
-
|
|
18787
|
-
|
|
18788
|
-
|
|
18789
|
-
|
|
18790
|
-
|
|
18791
|
-
|
|
18792
|
-
|
|
18793
|
-
|
|
18794
|
-
|
|
18795
|
-
|
|
18796
|
-
|
|
18797
|
-
|
|
18798
|
-
|
|
18799
|
-
|
|
18800
|
-
|
|
19332
|
+
if (options.productPlan) {
|
|
19333
|
+
ai.resources = options.productPlan.resources.map((resource) => ({ name: resource.runtimeName ?? resource.name, label: resource.label, fields: resource.fields.map((field) => ({ key: field.id, label: field.label, type: field.type, required: field.required ?? false })), operations: ["list", "show"] }));
|
|
19334
|
+
ai.routes = options.productPlan.surfaces.map((surface) => ({ resource: surface.resourceIds[0] ?? "", path: productRoute(surface), operations: ["list", "show"] }));
|
|
19335
|
+
} else {
|
|
19336
|
+
ai.resources = resources.map((resource) => ({
|
|
19337
|
+
name: resource.name,
|
|
19338
|
+
label: resource.label ?? resource.name,
|
|
19339
|
+
fields: (resource.fields ?? []).map((field) => ({
|
|
19340
|
+
key: field.key,
|
|
19341
|
+
label: field.label,
|
|
19342
|
+
type: field.type,
|
|
19343
|
+
required: field.required ?? false
|
|
19344
|
+
})),
|
|
19345
|
+
operations: uiOnlyResources.includes(resource.name) ? [] : [
|
|
19346
|
+
"list",
|
|
19347
|
+
...resource.canShow === false ? [] : ["show"],
|
|
19348
|
+
...resource.canCreate === false ? [] : ["create"],
|
|
19349
|
+
...resource.canEdit === false ? [] : ["edit"]
|
|
19350
|
+
]
|
|
19351
|
+
}));
|
|
19352
|
+
ai.routes = ai.resources.map((resource) => ({
|
|
19353
|
+
resource: resource.name,
|
|
19354
|
+
path: `/${resource.name}`,
|
|
19355
|
+
operations: uiOnlyResources.includes(resource.name) ? ["list"] : resource.operations
|
|
19356
|
+
}));
|
|
19357
|
+
}
|
|
18801
19358
|
ai.providers.data = { choice: "none", package: null, capabilities: ["in-memory-demo", "data", "filter", "sort", "pagination"] };
|
|
18802
19359
|
ai.project.commands = pkg.scripts;
|
|
18803
19360
|
ai.project.testCommand = "bun run test:ui";
|
|
18804
|
-
ai.guidance = [...ai.guidance, "svadmin.vibe.json", "ARCHITECTURE.md", ".agents/skills/svadmin-vibe/SKILL.md"];
|
|
19361
|
+
ai.guidance = [...ai.guidance, "PRODUCT.md", "svadmin.vibe.json", "ARCHITECTURE.md", ".agents/skills/svadmin-vibe/SKILL.md"];
|
|
18805
19362
|
files["svadmin.ai.json"] = `${JSON.stringify(ai, null, 2)}
|
|
18806
19363
|
`;
|
|
18807
19364
|
files["svadmin.schema.json"] = `${JSON.stringify(buildAdminSchemaJson(), null, 2)}
|
|
@@ -18850,10 +19407,35 @@ async function vibeCommand(args, packageRoot, manifest) {
|
|
|
18850
19407
|
console.log(JSON.stringify(inspectVibePage(packageRoot, options.page), null, 2));
|
|
18851
19408
|
return;
|
|
18852
19409
|
}
|
|
19410
|
+
if (options.command === "review" || options.command === "plan") {
|
|
19411
|
+
const input = JSON.parse(fs6.readFileSync(options.planFile, "utf8"));
|
|
19412
|
+
const plan = parseProductPlan(input);
|
|
19413
|
+
if (options.command === "review") {
|
|
19414
|
+
console.log(JSON.stringify(reviewProductPlan(plan), null, 2));
|
|
19415
|
+
return;
|
|
19416
|
+
}
|
|
19417
|
+
const roleIds = options.roleIds.length ? options.roleIds : plan.roles.map((role) => role.id);
|
|
19418
|
+
console.log(formatProductPreview(runProductPipeline({ plan, navigation: { roleIds } })));
|
|
19419
|
+
return;
|
|
19420
|
+
}
|
|
19421
|
+
if (options.command !== "init")
|
|
19422
|
+
throw new Error("Unsupported vibe command");
|
|
18853
19423
|
if (fs6.existsSync(options.directory))
|
|
18854
19424
|
throw new Error("The target already exists. Choose a new directory; existing projects are never overwritten.");
|
|
18855
|
-
|
|
18856
|
-
|
|
19425
|
+
let productPlan;
|
|
19426
|
+
if (options.productPlanFile) {
|
|
19427
|
+
const input = JSON.parse(fs6.readFileSync(options.productPlanFile, "utf8"));
|
|
19428
|
+
productPlan = parseProductPlan(input);
|
|
19429
|
+
const report = reviewProductPlan(productPlan);
|
|
19430
|
+
if (!report.ok)
|
|
19431
|
+
throw new Error(`Product plan review failed:
|
|
19432
|
+
` + JSON.stringify(report, null, 2));
|
|
19433
|
+
if (report.issues.length)
|
|
19434
|
+
console.warn("Product plan review warnings: " + report.issues.length);
|
|
19435
|
+
}
|
|
19436
|
+
const productOptions = { roleIds: options.roleIds, ...productPlan === undefined ? {} : { productPlan } };
|
|
19437
|
+
const files = planVibeProject(packageRoot, manifest, options.preset, productOptions);
|
|
19438
|
+
console.log(`svadmin vibe: ${productPlan ? "product-workspace" : "customer-workspace"} / ${options.preset}
|
|
18857
19439
|
${options.directory}`);
|
|
18858
19440
|
for (const file of Object.keys(files).sort())
|
|
18859
19441
|
console.log(` add ${file}`);
|
|
@@ -18991,7 +19573,7 @@ function upgrade(args) {
|
|
|
18991
19573
|
const upgradeExecution = commandArguments.write ? writeProjectPackageJsonUpgrade(packagePath, scaffoldManifest, new Date) : planProjectPackageFileUpgrade(packagePath, scaffoldManifest);
|
|
18992
19574
|
printUpgradeExecution(upgradeExecution, commandArguments.projectDirectory, packagePath);
|
|
18993
19575
|
}
|
|
18994
|
-
var GUIDANCE_FILES = ["DESIGN.md", "AGENTS.md"];
|
|
19576
|
+
var GUIDANCE_FILES = ["DESIGN.md", "AGENTS.md", "PRODUCT.md"];
|
|
18995
19577
|
function missingGuidanceFiles(projectDirectory) {
|
|
18996
19578
|
return GUIDANCE_FILES.filter((fileName) => !fs7.existsSync(path7.join(projectDirectory, fileName)));
|
|
18997
19579
|
}
|
package/guidance/AGENTS.md
CHANGED
|
@@ -4,6 +4,17 @@ Read `DESIGN.md` before creating, revising, or reviewing any UI. It is the
|
|
|
4
4
|
authority for visual language, page hierarchy, feedback ownership, and
|
|
5
5
|
acceptance checks in this project.
|
|
6
6
|
|
|
7
|
+
## Product-first generation
|
|
8
|
+
|
|
9
|
+
Read `PRODUCT.md` before converting a schema, API, or feature list into pages. Build the product brief, workflows, resources, actions, surfaces, and menu in that order. Resources are data contracts; they are not automatic pages or menu entries.
|
|
10
|
+
|
|
11
|
+
- Keep one primary purpose and one dominant next action per surface.
|
|
12
|
+
- Give every core workflow a serving surface or contextual entry.
|
|
13
|
+
- Declare loading, empty, error, forbidden, and success states before writing markup.
|
|
14
|
+
- Menu count is determined by actual business needs. Never apply a fixed global cap; use roles, task frequency, placement, grouping, and context to keep it legible.
|
|
15
|
+
- Run `svadmin vibe review ./svadmin.product.json` before generating UI and `svadmin vibe plan ./svadmin.product.json --role <role-id>` to inspect role-specific navigation.
|
|
16
|
+
- Treat review warnings as prompts for product judgment. Do not silently delete business-required workflows or menu entries to make a count pass.
|
|
17
|
+
|
|
7
18
|
## Platform entrypoints
|
|
8
19
|
|
|
9
20
|
Before generating or modifying application code, read these stable files
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Product-first generation rules
|
|
2
|
+
|
|
3
|
+
svadmin generates an admin application from business decisions, not from a raw resource list. Read this file before turning an API, database schema, or feature request into UI.
|
|
4
|
+
|
|
5
|
+
## Decision order
|
|
6
|
+
|
|
7
|
+
1. Write the product brief: primary outcome, audience, constraints, non-goals, and assumptions.
|
|
8
|
+
2. List the real workflows in actor language. Each core workflow needs a trigger, goal, steps, success criteria, exceptions, priority, and frequency.
|
|
9
|
+
3. Register resources as data contracts. A resource can be contextual or internal; it does not automatically become a page or menu item.
|
|
10
|
+
4. Define actions with an owning workflow and role visibility.
|
|
11
|
+
5. Define surfaces by purpose. Keep one primary action per surface, declare empty/loading/error/forbidden/success states, and move secondary actions into context or More.
|
|
12
|
+
6. Define menu nodes last. Every node should point to a workflow, resource, or surface and declare the roles that need it.
|
|
13
|
+
|
|
14
|
+
## Menu policy
|
|
15
|
+
|
|
16
|
+
There is no fixed menu-count limit. A small product may need one entry; a large operational product may need many. Do not delete business-required entries to satisfy an arbitrary count. Use role visibility, workflow frequency, placement, grouping, and contextual entries to keep the navigation legible. Backend authorization remains authoritative; menu visibility is only a UI hint.
|
|
17
|
+
|
|
18
|
+
## CLI review loop
|
|
19
|
+
|
|
20
|
+
Store the plan as JSON using schema version `product/v1`, then run:
|
|
21
|
+
|
|
22
|
+
- `svadmin vibe review ./svadmin.product.json` to report orphan resources, unserved workflows, unknown references, missing states, and dense surfaces.
|
|
23
|
+
- `svadmin vibe plan ./svadmin.product.json --role operator` to inspect the role-specific menu and generated page plans.
|
|
24
|
+
|
|
25
|
+
Fix product review errors before generating Svelte markup. Warnings are review prompts, not automatic page deletion rules.
|
|
26
|
+
|
|
27
|
+
## Visual acceptance
|
|
28
|
+
|
|
29
|
+
Generated pages must have one clear purpose, one dominant next action, and an explicit state contract. Prefer the existing `@svadmin/ui` page families and semantic tokens. Inspect long labels, dense tables, empty results, denied permissions, failures, and mobile layouts before accepting the result.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/create",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"description": "Scaffolding tool for svadmin projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"@types/node": "^26.4.0",
|
|
31
31
|
"@types/prompts": "^2.4.9",
|
|
32
32
|
"typescript": "^7.0.2",
|
|
33
|
-
"@svadmin/core": "^0.
|
|
33
|
+
"@svadmin/core": "^0.61.0"
|
|
34
34
|
},
|
|
35
35
|
"keywords": [
|
|
36
36
|
"svadmin",
|
package/scaffold-manifest.json
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
"check": "svelte-check --tsconfig ./tsconfig.json"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@svadmin/core": "^0.
|
|
14
|
-
"@svadmin/app": "^0.2.
|
|
15
|
-
"@svadmin/ai-elements": "^0.11.
|
|
16
|
-
"@svadmin/ui": "^0.
|
|
13
|
+
"@svadmin/core": "^0.61.0",
|
|
14
|
+
"@svadmin/app": "^0.2.4",
|
|
15
|
+
"@svadmin/ai-elements": "^0.11.2",
|
|
16
|
+
"@svadmin/ui": "^0.84.0",
|
|
17
17
|
"typebox": "^1.3.34",
|
|
18
18
|
"@tanstack/svelte-query": "^6.1.48",
|
|
19
19
|
"@lucide/svelte": "^1.35.0"
|
|
@@ -42,76 +42,76 @@
|
|
|
42
42
|
"graphql": "^16.8.0"
|
|
43
43
|
},
|
|
44
44
|
"simple-rest": {
|
|
45
|
-
"@svadmin/simple-rest": "^0.12.
|
|
45
|
+
"@svadmin/simple-rest": "^0.12.5",
|
|
46
46
|
"@refinedev/simple-rest": "^6.0.1"
|
|
47
47
|
},
|
|
48
48
|
"supabase": {
|
|
49
|
-
"@svadmin/supabase": "^0.19.
|
|
49
|
+
"@svadmin/supabase": "^0.19.2",
|
|
50
50
|
"@supabase/supabase-js": "^2.112.4",
|
|
51
51
|
"@refinedev/supabase": "^6.0.2"
|
|
52
52
|
},
|
|
53
53
|
"graphql": {
|
|
54
|
-
"@svadmin/graphql": "^0.12.
|
|
54
|
+
"@svadmin/graphql": "^0.12.5",
|
|
55
55
|
"@refinedev/graphql": "^8.0.1",
|
|
56
56
|
"graphql-request": "^7.4.0",
|
|
57
57
|
"graphql": "^16.8.0"
|
|
58
58
|
},
|
|
59
59
|
"rest": {
|
|
60
|
-
"@svadmin/rest": "^0.2.
|
|
60
|
+
"@svadmin/rest": "^0.2.4",
|
|
61
61
|
"@refinedev/rest": "^2.1.1",
|
|
62
62
|
"@nestjsx/crud-request": "^5.0.0-alpha.3"
|
|
63
63
|
},
|
|
64
64
|
"airtable": {
|
|
65
|
-
"@svadmin/airtable": "^0.12.
|
|
65
|
+
"@svadmin/airtable": "^0.12.5",
|
|
66
66
|
"@refinedev/airtable": "^5.0.1"
|
|
67
67
|
},
|
|
68
68
|
"appwrite": {
|
|
69
|
-
"@svadmin/appwrite": "^0.11.
|
|
69
|
+
"@svadmin/appwrite": "^0.11.5",
|
|
70
70
|
"appwrite": ">=26.2.0",
|
|
71
71
|
"@refinedev/appwrite": "^8.0.1"
|
|
72
72
|
},
|
|
73
73
|
"directus": {
|
|
74
|
-
"@svadmin/directus": "^0.11.
|
|
74
|
+
"@svadmin/directus": "^0.11.5"
|
|
75
75
|
},
|
|
76
76
|
"drizzle": {
|
|
77
|
-
"@svadmin/drizzle": "^0.14.
|
|
77
|
+
"@svadmin/drizzle": "^0.14.2",
|
|
78
78
|
"drizzle-orm": "^1.0.0-rc.1",
|
|
79
79
|
"refine-sqlx": "^0.10.3"
|
|
80
80
|
},
|
|
81
81
|
"elysia": {
|
|
82
|
-
"@svadmin/elysia": "^0.14.
|
|
82
|
+
"@svadmin/elysia": "^0.14.5",
|
|
83
83
|
"elysia": ">=1.4.30",
|
|
84
84
|
"@elysiajs/eden": ">=1.4.9"
|
|
85
85
|
},
|
|
86
86
|
"firebase": {
|
|
87
|
-
"@svadmin/firebase": "^0.12.
|
|
87
|
+
"@svadmin/firebase": "^0.12.5"
|
|
88
88
|
},
|
|
89
89
|
"hasura": {
|
|
90
|
-
"@svadmin/hasura": "^0.12.
|
|
90
|
+
"@svadmin/hasura": "^0.12.5",
|
|
91
91
|
"@refinedev/hasura": "^7.0.1",
|
|
92
92
|
"graphql-request": "^7.4.0"
|
|
93
93
|
},
|
|
94
94
|
"medusa": {
|
|
95
|
-
"@svadmin/medusa": "^0.12.
|
|
95
|
+
"@svadmin/medusa": "^0.12.5",
|
|
96
96
|
"@refinedev/medusa": "^5.0.0"
|
|
97
97
|
},
|
|
98
98
|
"nestjs-query": {
|
|
99
|
-
"@svadmin/nestjs-query": "^0.12.
|
|
99
|
+
"@svadmin/nestjs-query": "^0.12.5",
|
|
100
100
|
"@refinedev/nestjs-query": "^5.0.0"
|
|
101
101
|
},
|
|
102
102
|
"nestjsx-crud": {
|
|
103
|
-
"@svadmin/nestjsx-crud": "^0.12.
|
|
103
|
+
"@svadmin/nestjsx-crud": "^0.12.5",
|
|
104
104
|
"@refinedev/nestjsx-crud": "^6.0.1"
|
|
105
105
|
},
|
|
106
106
|
"pocketbase": {
|
|
107
|
-
"@svadmin/pocketbase": "^0.13.
|
|
107
|
+
"@svadmin/pocketbase": "^0.13.2",
|
|
108
108
|
"pocketbase": ">=0.28.0"
|
|
109
109
|
},
|
|
110
110
|
"sanity": {
|
|
111
|
-
"@svadmin/sanity": "^0.11.
|
|
111
|
+
"@svadmin/sanity": "^0.11.5"
|
|
112
112
|
},
|
|
113
113
|
"strapi": {
|
|
114
|
-
"@svadmin/strapi": "^0.12.
|
|
114
|
+
"@svadmin/strapi": "^0.12.5",
|
|
115
115
|
"@refinedev/strapi-v4": "^7.0.1"
|
|
116
116
|
}
|
|
117
117
|
},
|