every plugin route renders a workspace, and it is not a rule you can forget

an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.

    web/panels.ts   appRegistryMetas — at least one panel
    web/layout.ts   defaultLayout — how they are arranged

both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:

    probeplug: has a web/ directory but is missing web/layout.ts.
    Every plugin route renders a Workspace: contribute panels and a layout,
    not a screen.

there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.

the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.

the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 23:47:48 +00:00
co-authored by Claude Opus 5
parent 2e3c935da6
commit 543e88a9a6
38 changed files with 1026 additions and 440 deletions
+29 -8
View File
@@ -19,8 +19,8 @@ import { manifestProblems, type DiscoveredPlugin, type PluginManifest } from './
// api/router.ts a backend router
// db/schema.ts tables
// sidecar/index.ts a process (`.mjs` instead means node — see below)
// web/Router.tsx a frontend
// web/panels.ts panel apps
// web/panels.ts panel apps — REQUIRED with web/
// web/layout.ts how they are arranged — REQUIRED with web/
//
// Nothing here reads the database. This answers "what is on disk", which is a different question from
// "what is installed" — the install table answers that, and the two disagreeing is a state the app store
@@ -45,11 +45,29 @@ function findSidecar(dir: string): DiscoveredPlugin['sidecar'] {
return null;
}
function findWeb(dir: string): DiscoveredPlugin['web'] {
const router = join(dir, 'web', 'Router.tsx');
if (!existsSync(router)) return null;
const panels = join(dir, 'web', 'panels.ts');
return { router, panels: existsSync(panels) ? panels : null };
/**
* A plugin's frontend, or a reason it is refused.
*
* `web/` present means BOTH `panels.ts` and `layout.ts` must be. That is how "every route renders a
* workspace with at least one panel" is enforced: there is no way to ship a frontend that is not a
* workspace, because the plugin never renders the screen — it hands over panels and an arrangement.
*/
function findWeb(dir: string, appName: string): { web: DiscoveredPlugin['web']; error?: string } {
const webDir = join(dir, 'web');
if (!existsSync(webDir)) return { web: null };
const panels = join(webDir, 'panels.ts');
const layout = join(webDir, 'layout.ts');
const missing = [!existsSync(panels) && 'web/panels.ts', !existsSync(layout) && 'web/layout.ts'].filter(Boolean);
if (missing.length) {
return {
web: null,
error:
`${appName}: has a web/ directory but is missing ${missing.join(' and ')}. ` +
`Every plugin route renders a Workspace: contribute panels and a layout, not a screen.`,
};
}
return { web: { panels, layout } };
}
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
@@ -75,6 +93,9 @@ export async function loadPlugin(dir: string, appName: string): Promise<Discover
const problems = manifestProblems(appName, manifest);
if (problems.length) throw new Error(`${appName}: ${problems.join('; ')}`);
const web = findWeb(dir, appName);
if (web.error) throw new Error(web.error);
return {
appName,
dir,
@@ -82,7 +103,7 @@ export async function loadPlugin(dir: string, appName: string): Promise<Discover
api: fileOrNull(join(dir, 'api', 'router.ts')),
schema: fileOrNull(join(dir, 'db', 'schema.ts')),
sidecar: findSidecar(dir),
web: findWeb(dir),
web: web.web,
};
}