{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "contract",
"title": "Module UI contract",
"description": "Screens, extension points and extensions every brick's UI exports; checkModules() refuses collisions.",
"files": [
{
"path": "src/contract.ts",
"content": "/**\n * The module UI contract — what every brick's UI exports, whichever way the product is assembled\n * (one bundle, federated at runtime, or each brick serving its own). One contract is what lets the\n * assembly mode be a setting rather than three systems.\n *\n * screens routes the brick owns, with the permission needed to see them\n * offers named extension points on its screens, where other bricks may add panels\n * extensions panels it adds to OTHER bricks' extension points\n *\n * A brick never imports another brick's UI. It links by URL and plugs into extension points — the UI's\n * version of a seam.\n */\nimport type { ComponentType } from \"react\";\n\nexport interface Screen {\n /** Path inside the brick, e.g. \"/partners/:bpId\". The shell mounts it under \"/<brick>\". */\n path: string;\n title: string;\n /** In the navigation when set — the section it sits in. Detail screens usually have none. */\n nav?: { section: string; label?: string; order?: number };\n /** Hidden from callers without it. Omitted = any signed-in user. */\n permission?: string;\n component: ComponentType<Record<string, never>>;\n}\n\nexport interface ExtensionPoint {\n /** \"<brick>.<screen>.<place>\", e.g. \"bp.partner.panels\" */\n id: string;\n /** What the host passes to every panel at this point, e.g. [\"bpId\"]. */\n context: string[];\n description: string;\n}\n\nexport interface Extension {\n /** The extension point it fills. */\n point: string;\n title: string;\n permission?: string;\n order?: number;\n component: ComponentType<{ context: Record<string, string> }>;\n}\n\nexport interface ModuleUI {\n /** The brick's name — the same as its backend's, e.g. \"bp\". */\n name: string;\n version: string;\n screens: Screen[];\n offers?: ExtensionPoint[];\n extensions?: Extension[];\n}\n\n/** Everything wrong with a set of module UIs, before anything is shown. Empty = they fit together. */\nexport function checkModules(modules: ModuleUI[]): string[] {\n const problems: string[] = [];\n const names = modules.map((m) => m.name);\n names.filter((n, i) => names.indexOf(n) !== i).forEach((n) => problems.push(`module ${n} is listed twice`));\n const points = new Map<string, string>();\n for (const m of modules) {\n const paths = m.screens.map((s) => s.path);\n paths.filter((p, i) => paths.indexOf(p) !== i).forEach((p) => problems.push(`${m.name}: two screens at ${p}`));\n for (const s of m.screens) {\n if (!s.path.startsWith(\"/\")) problems.push(`${m.name}: screen path ${s.path} must start with /`);\n }\n for (const p of m.offers ?? []) {\n if (!p.id.startsWith(`${m.name}.`)) problems.push(`${m.name}: extension point ${p.id} must start with \"${m.name}.\"`);\n if (points.has(p.id)) problems.push(`extension point ${p.id} is offered by both ${points.get(p.id)} and ${m.name}`);\n points.set(p.id, m.name);\n }\n }\n for (const m of modules) {\n for (const e of m.extensions ?? []) {\n if (!points.has(e.point)) problems.push(`${m.name}: \"${e.title}\" plugs into ${e.point}, which no module offers`);\n }\n }\n return problems;\n}\n\n/** The panels for one extension point, in order, that this caller may see. */\nexport function extensionsFor(modules: ModuleUI[], point: string, can: (permission?: string) => boolean): Extension[] {\n return modules\n .flatMap((m) => m.extensions ?? [])\n .filter((e) => e.point === point && can(e.permission))\n .sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title));\n}\n",
"type": "registry:lib",
"target": "erp-ui/contract.ts"
}
],
"type": "registry:lib"
}