From 844886ded00db95dc46344210fc094f8a37118fb Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Wed, 1 Jul 2026 22:13:52 -0700 Subject: [PATCH] refactor: extract ui-extensions-sdk --- apps/server/package.json | 1 + apps/server/src/store/fileAgentBundleStore.ts | 33 +- apps/web/package.json | 1 + apps/web/public/bundle-ui-harness/sample.js | 8 +- .../features/bundle-ui/bundle-ui.worker.ts | 10 +- .../components/_shared/author-component.ts | 5 +- .../_shared/define-remote-element.ts | 2 +- .../bundle-ui/components/badge/badge.host.tsx | 2 +- .../components/badge/badge.remote.tsx | 7 +- .../block-stack/block-stack.host.tsx | 2 +- .../block-stack/block-stack.remote.tsx | 7 +- .../components/button/button.host.tsx | 2 +- .../components/button/button.remote.tsx | 7 +- .../components/card/card-content.host.tsx | 3 +- .../components/card/card-content.remote.tsx | 7 +- .../components/card/card-description.host.tsx | 3 +- .../card/card-description.remote.tsx | 7 +- .../components/card/card-footer.host.tsx | 3 +- .../components/card/card-footer.remote.tsx | 7 +- .../components/card/card-header.host.tsx | 3 +- .../components/card/card-header.remote.tsx | 7 +- .../components/card/card-title.host.tsx | 3 +- .../components/card/card-title.remote.tsx | 7 +- .../bundle-ui/components/card/card.host.tsx | 3 +- .../bundle-ui/components/card/card.remote.tsx | 7 +- .../components/checkbox/checkbox.host.tsx | 2 +- .../components/checkbox/checkbox.remote.tsx | 7 +- .../components/heading/heading.host.tsx | 2 +- .../components/heading/heading.remote.tsx | 7 +- .../bundle-ui/components/icon/icon.host.tsx | 2 +- .../bundle-ui/components/icon/icon.remote.tsx | 7 +- .../inline-stack/inline-stack.host.tsx | 2 +- .../inline-stack/inline-stack.remote.tsx | 7 +- .../bundle-ui/components/pill/pill.host.tsx | 3 +- .../bundle-ui/components/pill/pill.remote.tsx | 7 +- .../components/progress/progress.host.tsx | 2 +- .../components/progress/progress.remote.tsx | 7 +- .../components/score-ring/score-ring.host.tsx | 2 +- .../score-ring/score-ring.remote.tsx | 7 +- .../components/spinner/spinner.host.tsx | 2 +- .../components/spinner/spinner.remote.tsx | 7 +- .../status-bar/status-bar.remote.tsx | 7 +- .../bundle-ui/components/text/text.host.tsx | 2 +- .../bundle-ui/components/text/text.remote.tsx | 7 +- .../components/textarea/textarea.host.tsx | 2 +- .../components/textarea/textarea.remote.tsx | 7 +- .../src/features/bundle-ui/runtime/bridge.tsx | 7 +- .../bundle-ui/runtime/workerModuleLoader.ts | 3 +- apps/web/src/features/bundle-ui/types.ts | 90 +---- apps/web/tsconfig.json | 4 +- apps/web/vite.config.ts | 25 +- docs/bundle-ui/authoring-guide.md | 56 +++- docs/server/configuration-bundles.md | 8 +- examples/tangle-oss/tsconfig.json | 15 + examples/tangle-oss/ui/pipeline-progress.tsx | 2 +- packages/ui-extensions-sdk/README.md | 308 ++++++++++++++++++ packages/ui-extensions-sdk/eslint.config.js | 3 + packages/ui-extensions-sdk/package.json | 45 +++ packages/ui-extensions-sdk/src/build.ts | 71 ++++ packages/ui-extensions-sdk/src/cli.ts | 115 +++++++ packages/ui-extensions-sdk/src/components.ts | 123 +++++++ .../ui-extensions-sdk/src/contracts/badge.ts | 2 +- .../src/contracts/block-stack.ts | 4 +- .../ui-extensions-sdk/src/contracts/button.ts | 2 +- .../src/contracts/card-content.ts | 2 +- .../src/contracts/card-description.ts | 2 +- .../src/contracts/card-footer.ts | 2 +- .../src/contracts/card-header.ts | 2 +- .../src/contracts/card-title.ts | 2 +- .../ui-extensions-sdk/src/contracts/card.ts | 2 +- .../src/contracts/checkbox.ts | 2 +- .../ui-extensions-sdk/src/contracts}/enums.ts | 6 +- .../src/contracts}/events.ts | 4 +- .../src/contracts/heading.ts | 4 +- .../ui-extensions-sdk/src/contracts/icon.ts | 2 +- .../src/contracts/inline-stack.ts | 4 +- .../ui-extensions-sdk/src/contracts/pill.ts | 2 +- .../src/contracts/progress.ts | 2 +- .../src/contracts/score-ring.ts | 2 +- .../src/contracts/spinner.ts | 2 +- .../src/contracts/status-bar.ts | 2 +- .../ui-extensions-sdk/src/contracts/text.ts | 4 +- .../src/contracts/textarea.ts | 2 +- packages/ui-extensions-sdk/src/host.ts | 39 +++ packages/ui-extensions-sdk/src/index.ts | 20 ++ packages/ui-extensions-sdk/src/types.ts | 80 +++++ .../ui-extensions-sdk/tsconfig.author.json | 16 + packages/ui-extensions-sdk/tsconfig.json | 8 + pnpm-lock.yaml | 49 +++ 89 files changed, 1198 insertions(+), 192 deletions(-) create mode 100644 examples/tangle-oss/tsconfig.json create mode 100644 packages/ui-extensions-sdk/README.md create mode 100644 packages/ui-extensions-sdk/eslint.config.js create mode 100644 packages/ui-extensions-sdk/package.json create mode 100644 packages/ui-extensions-sdk/src/build.ts create mode 100755 packages/ui-extensions-sdk/src/cli.ts create mode 100644 packages/ui-extensions-sdk/src/components.ts rename apps/web/src/features/bundle-ui/components/badge/badge.contract.ts => packages/ui-extensions-sdk/src/contracts/badge.ts (93%) rename apps/web/src/features/bundle-ui/components/block-stack/block-stack.contract.ts => packages/ui-extensions-sdk/src/contracts/block-stack.ts (84%) rename apps/web/src/features/bundle-ui/components/button/button.contract.ts => packages/ui-extensions-sdk/src/contracts/button.ts (92%) rename apps/web/src/features/bundle-ui/components/card/card-content.contract.ts => packages/ui-extensions-sdk/src/contracts/card-content.ts (83%) rename apps/web/src/features/bundle-ui/components/card/card-description.contract.ts => packages/ui-extensions-sdk/src/contracts/card-description.ts (75%) rename apps/web/src/features/bundle-ui/components/card/card-footer.contract.ts => packages/ui-extensions-sdk/src/contracts/card-footer.ts (83%) rename apps/web/src/features/bundle-ui/components/card/card-header.contract.ts => packages/ui-extensions-sdk/src/contracts/card-header.ts (85%) rename apps/web/src/features/bundle-ui/components/card/card-title.contract.ts => packages/ui-extensions-sdk/src/contracts/card-title.ts (74%) rename apps/web/src/features/bundle-ui/components/card/card.contract.ts => packages/ui-extensions-sdk/src/contracts/card.ts (83%) rename apps/web/src/features/bundle-ui/components/checkbox/checkbox.contract.ts => packages/ui-extensions-sdk/src/contracts/checkbox.ts (87%) rename {apps/web/src/features/bundle-ui/components/_shared => packages/ui-extensions-sdk/src/contracts}/enums.ts (77%) rename {apps/web/src/features/bundle-ui/components/_shared => packages/ui-extensions-sdk/src/contracts}/events.ts (84%) rename apps/web/src/features/bundle-ui/components/heading/heading.contract.ts => packages/ui-extensions-sdk/src/contracts/heading.ts (86%) rename apps/web/src/features/bundle-ui/components/icon/icon.contract.ts => packages/ui-extensions-sdk/src/contracts/icon.ts (92%) rename apps/web/src/features/bundle-ui/components/inline-stack/inline-stack.contract.ts => packages/ui-extensions-sdk/src/contracts/inline-stack.ts (86%) rename apps/web/src/features/bundle-ui/components/pill/pill.contract.ts => packages/ui-extensions-sdk/src/contracts/pill.ts (88%) rename apps/web/src/features/bundle-ui/components/progress/progress.contract.ts => packages/ui-extensions-sdk/src/contracts/progress.ts (85%) rename apps/web/src/features/bundle-ui/components/score-ring/score-ring.contract.ts => packages/ui-extensions-sdk/src/contracts/score-ring.ts (85%) rename apps/web/src/features/bundle-ui/components/spinner/spinner.contract.ts => packages/ui-extensions-sdk/src/contracts/spinner.ts (78%) rename apps/web/src/features/bundle-ui/components/status-bar/status-bar.contract.ts => packages/ui-extensions-sdk/src/contracts/status-bar.ts (88%) rename apps/web/src/features/bundle-ui/components/text/text.contract.ts => packages/ui-extensions-sdk/src/contracts/text.ts (72%) rename apps/web/src/features/bundle-ui/components/textarea/textarea.contract.ts => packages/ui-extensions-sdk/src/contracts/textarea.ts (87%) create mode 100644 packages/ui-extensions-sdk/src/host.ts create mode 100644 packages/ui-extensions-sdk/src/index.ts create mode 100644 packages/ui-extensions-sdk/src/types.ts create mode 100644 packages/ui-extensions-sdk/tsconfig.author.json create mode 100644 packages/ui-extensions-sdk/tsconfig.json diff --git a/apps/server/package.json b/apps/server/package.json index 53c1677..104ea97 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@tangent/shared": "workspace:*", + "@tangent/ui-extensions-sdk": "workspace:*", "better-sqlite3": "^12.10.0", "croner": "^10.0.1", "drizzle-orm": "^0.45.2", diff --git a/apps/server/src/store/fileAgentBundleStore.ts b/apps/server/src/store/fileAgentBundleStore.ts index 5284dd3..f9bbf8b 100644 --- a/apps/server/src/store/fileAgentBundleStore.ts +++ b/apps/server/src/store/fileAgentBundleStore.ts @@ -15,7 +15,10 @@ import { MANIFEST_FILENAME, } from "@tangent/shared/configBundle.ts"; import type { AgentBundleMeta } from "@tangent/shared/contracts.ts"; -import { build } from "esbuild"; +import { + buildUiComponent, + UiComponentBuildError, +} from "@tangent/ui-extensions-sdk/build"; import { unzipSync } from "fflate"; import { AGENT_BUNDLES_ROOT } from "../config.ts"; @@ -129,21 +132,16 @@ async function compileComponent( ): Promise { let output: string; try { - const result = await build({ - entryPoints: [path.join(work, component.entry)], - bundle: true, - format: "esm", - platform: "browser", - jsx: "automatic", - packages: "external", - write: false, - logLevel: "silent", - }); - output = result.outputFiles[0].text; + output = await buildUiComponent(path.join(work, component.entry)); } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const detail = + err instanceof UiComponentBuildError && err.cause instanceof Error + ? err.cause.message + : err instanceof Error + ? err.message + : String(err); throw new AgentBundleValidationError( - `agent bundle: failed to compile ui entry "${component.entry}"\n${message}`, + `agent bundle: failed to compile ui entry "${component.entry}"\n${detail}`, ); } await writeFile(path.join(outDir, `${component.name}.js`), output); @@ -153,9 +151,10 @@ async function compileComponent( * Transpiles each declared UI component to ESM under `/ui/.js`. * * The component sources are extracted to a temp working directory so esbuild - * can resolve relative sibling imports, then bundled transpile-only: bare - * (`packages: "external"`) imports such as `react`, `@tangent/bundle-ui`, and - * `@remote-dom/*` are left untouched for the Phase-5 worker import map to + * can resolve relative sibling imports, then bundled transpile-only via the + * shared {@link buildUiComponent} helper (also used by the `ui-extensions` CLI, + * so local builds match): bare imports such as `react` and + * `@tangent/ui-extensions-sdk` are left untouched for the worker import map to * resolve, while relative imports within the bundle are inlined. The component * never executes here. Throws {@link AgentBundleValidationError} when a declared * entry is missing from the zip or fails to compile, so the upload route can diff --git a/apps/web/package.json b/apps/web/package.json index aca7bbf..7e98840 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@remote-dom/polyfill": "^1.5.1", "@remote-dom/react": "^1.2.2", "@tangent/shared": "workspace:*", + "@tangent/ui-extensions-sdk": "workspace:*", "@tangent/ui-primitives": "workspace:*", "@tangent/windows": "workspace:*", "@tanstack/history": "1.162.0", diff --git a/apps/web/public/bundle-ui-harness/sample.js b/apps/web/public/bundle-ui-harness/sample.js index 78d70d4..825d3e5 100644 --- a/apps/web/public/bundle-ui-harness/sample.js +++ b/apps/web/public/bundle-ui-harness/sample.js @@ -1,15 +1,15 @@ // Dev harness fixture: a compiled bundle-UI "message" component. // -// This mirrors what the Phase-3 server compiler emits for an author's +// This mirrors what the server compiler emits for an author's // `ui/pipeline-progress.tsx`: plain ESM with *bare* imports (`react`, -// `@tangent/bundle-ui`) that the Phase-5 worker module loader resolves against +// `@tangent/ui-extensions-sdk`) that the worker module loader resolves against // the worker's own module copies. It exercises the full loop: getProps -> // host.fetch polling a real allowlisted endpoint -> render via the vocabulary -> // host.sendPrompt on press. // // Equivalent source: // import { useEffect, useState } from "react"; -// import { host, Card, BlockStack, Heading, Text, Progress, Button } from "@tangent/bundle-ui"; +// import { host, Card, BlockStack, Heading, Text, Progress, Button } from "@tangent/ui-extensions-sdk"; // export default function PipelineProgress() { ... } import { createElement as h, useEffect, useState } from "react"; @@ -21,7 +21,7 @@ import { host, Progress, Text, -} from "@tangent/bundle-ui"; +} from "@tangent/ui-extensions-sdk"; export default function PipelineProgress() { const [executionId, setExecutionId] = useState(null); diff --git a/apps/web/src/features/bundle-ui/bundle-ui.worker.ts b/apps/web/src/features/bundle-ui/bundle-ui.worker.ts index da752ac..5fff0fb 100644 --- a/apps/web/src/features/bundle-ui/bundle-ui.worker.ts +++ b/apps/web/src/features/bundle-ui/bundle-ui.worker.ts @@ -4,7 +4,7 @@ * Runs a compiled bundle component against a remote-dom DOM polyfill and streams * the resulting tree of vocabulary elements to the host over `@quilted/threads`. * The component reaches the outside world only through the host bridge exposed - * as `@tangent/bundle-ui`. + * as `@tangent/ui-extensions-sdk`. * * The polyfill imports MUST stay first: importing `./runtime/bridge` registers * custom elements, which requires `customElements` to already exist. @@ -33,6 +33,12 @@ import type { HostBridge, RenderOptions, WorkerApi } from "./types"; registerWorkerModules({ react: React as unknown as Record, "react/jsx-runtime": ReactJsxRuntime as unknown as Record, + "@tangent/ui-extensions-sdk": BundleUiRuntime as unknown as Record< + string, + unknown + >, + // Legacy alias: bundles compiled before the SDK rename still import + // `@tangent/bundle-ui`. Resolve it to the same runtime so they keep working. "@tangent/bundle-ui": BundleUiRuntime as unknown as Record, }); @@ -41,7 +47,7 @@ async function render( options: RenderOptions, ): Promise { // The host functions become this worker's bridge; the component reaches them - // via `@tangent/bundle-ui`'s `host`. + // via `@tangent/ui-extensions-sdk`'s `host`. globalThis.__TANGENT_BUNDLE_UI_HOST__ = thread.imports as HostBridge; const Component = await loadComponent(options.moduleUrl); diff --git a/apps/web/src/features/bundle-ui/components/_shared/author-component.ts b/apps/web/src/features/bundle-ui/components/_shared/author-component.ts index 8e1bdbf..529bbd0 100644 --- a/apps/web/src/features/bundle-ui/components/_shared/author-component.ts +++ b/apps/web/src/features/bundle-ui/components/_shared/author-component.ts @@ -7,10 +7,13 @@ */ import { createRemoteComponent } from "@remote-dom/react"; +import type { + EventPayload, + RemoteEvents, +} from "@tangent/ui-extensions-sdk/contracts/events"; import { type ComponentType, createElement, type ReactNode } from "react"; import type { AnyRemoteElementConstructor } from "./define-remote-element"; -import type { EventPayload, RemoteEvents } from "./events"; type AnyProps = Record; type Handler = (...args: unknown[]) => void; diff --git a/apps/web/src/features/bundle-ui/components/_shared/define-remote-element.ts b/apps/web/src/features/bundle-ui/components/_shared/define-remote-element.ts index ad769b2..1ac69dc 100644 --- a/apps/web/src/features/bundle-ui/components/_shared/define-remote-element.ts +++ b/apps/web/src/features/bundle-ui/components/_shared/define-remote-element.ts @@ -11,9 +11,9 @@ import { createRemoteElement, type RemoteElementConstructor, } from "@remote-dom/core/elements"; +import type { RemoteEvents } from "@tangent/ui-extensions-sdk/contracts/events"; import type { z } from "zod"; -import type { RemoteEvents } from "./events"; import { wireProperties } from "./wire-properties"; export type AnyRemoteElementConstructor = RemoteElementConstructor< diff --git a/apps/web/src/features/bundle-ui/components/badge/badge.host.tsx b/apps/web/src/features/bundle-ui/components/badge/badge.host.tsx index 2c6a9aa..e38b260 100644 --- a/apps/web/src/features/bundle-ui/components/badge/badge.host.tsx +++ b/apps/web/src/features/bundle-ui/components/badge/badge.host.tsx @@ -1,6 +1,6 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/badge"; import { Badge } from "@tangent/ui-primitives/badge"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./badge.contract"; export const BadgeHost = makeHostComponent(Badge, attributes); diff --git a/apps/web/src/features/bundle-ui/components/badge/badge.remote.tsx b/apps/web/src/features/bundle-ui/components/badge/badge.remote.tsx index 4308997..c4f1fd8 100644 --- a/apps/web/src/features/bundle-ui/components/badge/badge.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/badge/badge.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/badge"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./badge.contract"; const BadgeElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/block-stack/block-stack.host.tsx b/apps/web/src/features/bundle-ui/components/block-stack/block-stack.host.tsx index 8d4daf1..9dc8c29 100644 --- a/apps/web/src/features/bundle-ui/components/block-stack/block-stack.host.tsx +++ b/apps/web/src/features/bundle-ui/components/block-stack/block-stack.host.tsx @@ -1,6 +1,6 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/block-stack"; import { BlockStack } from "@tangent/ui-primitives/layout"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./block-stack.contract"; export const BlockStackHost = makeHostComponent(BlockStack, attributes); diff --git a/apps/web/src/features/bundle-ui/components/block-stack/block-stack.remote.tsx b/apps/web/src/features/bundle-ui/components/block-stack/block-stack.remote.tsx index 63d6341..36c111c 100644 --- a/apps/web/src/features/bundle-ui/components/block-stack/block-stack.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/block-stack/block-stack.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/block-stack"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./block-stack.contract"; const BlockStackElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/button/button.host.tsx b/apps/web/src/features/bundle-ui/components/button/button.host.tsx index 5c36afd..1de3f0f 100644 --- a/apps/web/src/features/bundle-ui/components/button/button.host.tsx +++ b/apps/web/src/features/bundle-ui/components/button/button.host.tsx @@ -1,7 +1,7 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/button"; import { Button } from "@tangent/ui-primitives/button"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./button.contract"; export const ButtonHost = makeHostComponent(Button, attributes, (props) => { const onPress = props.onPress; diff --git a/apps/web/src/features/bundle-ui/components/button/button.remote.tsx b/apps/web/src/features/bundle-ui/components/button/button.remote.tsx index 23600c0..31e9050 100644 --- a/apps/web/src/features/bundle-ui/components/button/button.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/button/button.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/button"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./button.contract"; const ButtonElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/card/card-content.host.tsx b/apps/web/src/features/bundle-ui/components/card/card-content.host.tsx index 8cc744b..a75b010 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-content.host.tsx +++ b/apps/web/src/features/bundle-ui/components/card/card-content.host.tsx @@ -1,6 +1,7 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/card-content"; + import { CardContent } from "@/shared/ui/card"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./card-content.contract"; export const CardContentHost = makeHostComponent(CardContent, attributes); diff --git a/apps/web/src/features/bundle-ui/components/card/card-content.remote.tsx b/apps/web/src/features/bundle-ui/components/card/card-content.remote.tsx index 734d8f2..f6ec40f 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-content.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/card/card-content.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/card-content"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./card-content.contract"; const CardContentElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/card/card-description.host.tsx b/apps/web/src/features/bundle-ui/components/card/card-description.host.tsx index 58f9384..1a91221 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-description.host.tsx +++ b/apps/web/src/features/bundle-ui/components/card/card-description.host.tsx @@ -1,7 +1,8 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/card-description"; + import { CardDescription } from "@/shared/ui/card"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./card-description.contract"; export const CardDescriptionHost = makeHostComponent( CardDescription, diff --git a/apps/web/src/features/bundle-ui/components/card/card-description.remote.tsx b/apps/web/src/features/bundle-ui/components/card/card-description.remote.tsx index 4b1d734..5b6dd2d 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-description.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/card/card-description.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/card-description"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./card-description.contract"; const CardDescriptionElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/card/card-footer.host.tsx b/apps/web/src/features/bundle-ui/components/card/card-footer.host.tsx index e6c07ab..a7b0fda 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-footer.host.tsx +++ b/apps/web/src/features/bundle-ui/components/card/card-footer.host.tsx @@ -1,6 +1,7 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/card-footer"; + import { CardFooter } from "@/shared/ui/card"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./card-footer.contract"; export const CardFooterHost = makeHostComponent(CardFooter, attributes); diff --git a/apps/web/src/features/bundle-ui/components/card/card-footer.remote.tsx b/apps/web/src/features/bundle-ui/components/card/card-footer.remote.tsx index 6774b23..eb121d3 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-footer.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/card/card-footer.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/card-footer"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./card-footer.contract"; const CardFooterElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/card/card-header.host.tsx b/apps/web/src/features/bundle-ui/components/card/card-header.host.tsx index d88c845..b59c159 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-header.host.tsx +++ b/apps/web/src/features/bundle-ui/components/card/card-header.host.tsx @@ -1,6 +1,7 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/card-header"; + import { CardHeader } from "@/shared/ui/card"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./card-header.contract"; export const CardHeaderHost = makeHostComponent(CardHeader, attributes); diff --git a/apps/web/src/features/bundle-ui/components/card/card-header.remote.tsx b/apps/web/src/features/bundle-ui/components/card/card-header.remote.tsx index 9c5789e..d4010ee 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-header.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/card/card-header.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/card-header"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./card-header.contract"; const CardHeaderElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/card/card-title.host.tsx b/apps/web/src/features/bundle-ui/components/card/card-title.host.tsx index 48142f7..44b310f 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-title.host.tsx +++ b/apps/web/src/features/bundle-ui/components/card/card-title.host.tsx @@ -1,6 +1,7 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/card-title"; + import { CardTitle } from "@/shared/ui/card"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./card-title.contract"; export const CardTitleHost = makeHostComponent(CardTitle, attributes); diff --git a/apps/web/src/features/bundle-ui/components/card/card-title.remote.tsx b/apps/web/src/features/bundle-ui/components/card/card-title.remote.tsx index 955dfbe..eb10979 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-title.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/card/card-title.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/card-title"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./card-title.contract"; const CardTitleElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/card/card.host.tsx b/apps/web/src/features/bundle-ui/components/card/card.host.tsx index 475fc7f..048dbca 100644 --- a/apps/web/src/features/bundle-ui/components/card/card.host.tsx +++ b/apps/web/src/features/bundle-ui/components/card/card.host.tsx @@ -1,6 +1,7 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/card"; + import { Card } from "@/shared/ui/card"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./card.contract"; export const CardHost = makeHostComponent(Card, attributes); diff --git a/apps/web/src/features/bundle-ui/components/card/card.remote.tsx b/apps/web/src/features/bundle-ui/components/card/card.remote.tsx index b2840ad..40e76b6 100644 --- a/apps/web/src/features/bundle-ui/components/card/card.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/card/card.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/card"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./card.contract"; const CardElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/checkbox/checkbox.host.tsx b/apps/web/src/features/bundle-ui/components/checkbox/checkbox.host.tsx index 2305b83..1386045 100644 --- a/apps/web/src/features/bundle-ui/components/checkbox/checkbox.host.tsx +++ b/apps/web/src/features/bundle-ui/components/checkbox/checkbox.host.tsx @@ -1,7 +1,7 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/checkbox"; import { Checkbox } from "@tangent/ui-primitives/checkbox"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./checkbox.contract"; export const CheckboxHost = makeHostComponent(Checkbox, attributes, (props) => { const onChange = props.onChange; diff --git a/apps/web/src/features/bundle-ui/components/checkbox/checkbox.remote.tsx b/apps/web/src/features/bundle-ui/components/checkbox/checkbox.remote.tsx index 9c2e2f3..494480e 100644 --- a/apps/web/src/features/bundle-ui/components/checkbox/checkbox.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/checkbox/checkbox.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/checkbox"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./checkbox.contract"; const CheckboxElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/heading/heading.host.tsx b/apps/web/src/features/bundle-ui/components/heading/heading.host.tsx index fcb7832..043c217 100644 --- a/apps/web/src/features/bundle-ui/components/heading/heading.host.tsx +++ b/apps/web/src/features/bundle-ui/components/heading/heading.host.tsx @@ -1,6 +1,6 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/heading"; import { Heading } from "@tangent/ui-primitives/typography"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./heading.contract"; export const HeadingHost = makeHostComponent(Heading, attributes); diff --git a/apps/web/src/features/bundle-ui/components/heading/heading.remote.tsx b/apps/web/src/features/bundle-ui/components/heading/heading.remote.tsx index dab7cfd..d401d12 100644 --- a/apps/web/src/features/bundle-ui/components/heading/heading.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/heading/heading.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/heading"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./heading.contract"; const HeadingElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/icon/icon.host.tsx b/apps/web/src/features/bundle-ui/components/icon/icon.host.tsx index 72c3075..355b7e0 100644 --- a/apps/web/src/features/bundle-ui/components/icon/icon.host.tsx +++ b/apps/web/src/features/bundle-ui/components/icon/icon.host.tsx @@ -1,7 +1,7 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/icon"; import { Icon } from "@tangent/ui-primitives/icon"; import type { RemoteProps } from "../_shared/remote-props"; -import { attributes } from "./icon.contract"; // Explicit (not via `makeHostComponent`) because an invalid icon name must render // nothing rather than spread an undefined `name` into the required `Icon` prop. diff --git a/apps/web/src/features/bundle-ui/components/icon/icon.remote.tsx b/apps/web/src/features/bundle-ui/components/icon/icon.remote.tsx index f9d3025..2513591 100644 --- a/apps/web/src/features/bundle-ui/components/icon/icon.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/icon/icon.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/icon"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./icon.contract"; const IconElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/inline-stack/inline-stack.host.tsx b/apps/web/src/features/bundle-ui/components/inline-stack/inline-stack.host.tsx index a436167..dc7c392 100644 --- a/apps/web/src/features/bundle-ui/components/inline-stack/inline-stack.host.tsx +++ b/apps/web/src/features/bundle-ui/components/inline-stack/inline-stack.host.tsx @@ -1,6 +1,6 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/inline-stack"; import { InlineStack } from "@tangent/ui-primitives/layout"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./inline-stack.contract"; export const InlineStackHost = makeHostComponent(InlineStack, attributes); diff --git a/apps/web/src/features/bundle-ui/components/inline-stack/inline-stack.remote.tsx b/apps/web/src/features/bundle-ui/components/inline-stack/inline-stack.remote.tsx index f11f898..b80b2d5 100644 --- a/apps/web/src/features/bundle-ui/components/inline-stack/inline-stack.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/inline-stack/inline-stack.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/inline-stack"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./inline-stack.contract"; const InlineStackElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/pill/pill.host.tsx b/apps/web/src/features/bundle-ui/components/pill/pill.host.tsx index b6debac..3aae038 100644 --- a/apps/web/src/features/bundle-ui/components/pill/pill.host.tsx +++ b/apps/web/src/features/bundle-ui/components/pill/pill.host.tsx @@ -1,6 +1,7 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/pill"; + import { Pill } from "@/shared/ui/patterns/pill"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./pill.contract"; export const PillHost = makeHostComponent(Pill, attributes); diff --git a/apps/web/src/features/bundle-ui/components/pill/pill.remote.tsx b/apps/web/src/features/bundle-ui/components/pill/pill.remote.tsx index 7ff103c..242d3f6 100644 --- a/apps/web/src/features/bundle-ui/components/pill/pill.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/pill/pill.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/pill"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./pill.contract"; const PillElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/progress/progress.host.tsx b/apps/web/src/features/bundle-ui/components/progress/progress.host.tsx index b715798..7740ad1 100644 --- a/apps/web/src/features/bundle-ui/components/progress/progress.host.tsx +++ b/apps/web/src/features/bundle-ui/components/progress/progress.host.tsx @@ -1,6 +1,6 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/progress"; import { Progress } from "@tangent/ui-primitives/progress"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./progress.contract"; export const ProgressHost = makeHostComponent(Progress, attributes); diff --git a/apps/web/src/features/bundle-ui/components/progress/progress.remote.tsx b/apps/web/src/features/bundle-ui/components/progress/progress.remote.tsx index 426a11b..b38ba36 100644 --- a/apps/web/src/features/bundle-ui/components/progress/progress.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/progress/progress.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/progress"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./progress.contract"; const ProgressElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/score-ring/score-ring.host.tsx b/apps/web/src/features/bundle-ui/components/score-ring/score-ring.host.tsx index 4b662cf..362fadd 100644 --- a/apps/web/src/features/bundle-ui/components/score-ring/score-ring.host.tsx +++ b/apps/web/src/features/bundle-ui/components/score-ring/score-ring.host.tsx @@ -1,6 +1,6 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/score-ring"; import { ScoreRing } from "@tangent/ui-primitives/score-ring"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./score-ring.contract"; export const ScoreRingHost = makeHostComponent(ScoreRing, attributes); diff --git a/apps/web/src/features/bundle-ui/components/score-ring/score-ring.remote.tsx b/apps/web/src/features/bundle-ui/components/score-ring/score-ring.remote.tsx index 90aa52a..56a9312 100644 --- a/apps/web/src/features/bundle-ui/components/score-ring/score-ring.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/score-ring/score-ring.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/score-ring"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./score-ring.contract"; const ScoreRingElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/spinner/spinner.host.tsx b/apps/web/src/features/bundle-ui/components/spinner/spinner.host.tsx index 97795a6..0457f97 100644 --- a/apps/web/src/features/bundle-ui/components/spinner/spinner.host.tsx +++ b/apps/web/src/features/bundle-ui/components/spinner/spinner.host.tsx @@ -1,6 +1,6 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/spinner"; import { Spinner } from "@tangent/ui-primitives/spinner"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./spinner.contract"; export const SpinnerHost = makeHostComponent(Spinner, attributes); diff --git a/apps/web/src/features/bundle-ui/components/spinner/spinner.remote.tsx b/apps/web/src/features/bundle-ui/components/spinner/spinner.remote.tsx index 33de91f..40800e7 100644 --- a/apps/web/src/features/bundle-ui/components/spinner/spinner.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/spinner/spinner.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/spinner"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./spinner.contract"; const SpinnerElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/status-bar/status-bar.remote.tsx b/apps/web/src/features/bundle-ui/components/status-bar/status-bar.remote.tsx index c52af3b..38f371a 100644 --- a/apps/web/src/features/bundle-ui/components/status-bar/status-bar.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/status-bar/status-bar.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/status-bar"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./status-bar.contract"; const StatusBarElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/text/text.host.tsx b/apps/web/src/features/bundle-ui/components/text/text.host.tsx index d45f4a8..2787c8f 100644 --- a/apps/web/src/features/bundle-ui/components/text/text.host.tsx +++ b/apps/web/src/features/bundle-ui/components/text/text.host.tsx @@ -1,6 +1,6 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/text"; import { Text } from "@tangent/ui-primitives/typography"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./text.contract"; export const TextHost = makeHostComponent(Text, attributes); diff --git a/apps/web/src/features/bundle-ui/components/text/text.remote.tsx b/apps/web/src/features/bundle-ui/components/text/text.remote.tsx index cf8e1f1..148c7d8 100644 --- a/apps/web/src/features/bundle-ui/components/text/text.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/text/text.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/text"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./text.contract"; const TextElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/components/textarea/textarea.host.tsx b/apps/web/src/features/bundle-ui/components/textarea/textarea.host.tsx index efcf603..dacb4cc 100644 --- a/apps/web/src/features/bundle-ui/components/textarea/textarea.host.tsx +++ b/apps/web/src/features/bundle-ui/components/textarea/textarea.host.tsx @@ -1,8 +1,8 @@ +import { attributes } from "@tangent/ui-extensions-sdk/contracts/textarea"; import { Textarea } from "@tangent/ui-primitives/textarea"; import type { ChangeEvent } from "react"; import { makeHostComponent } from "../_shared/make-host-component"; -import { attributes } from "./textarea.contract"; export const TextareaHost = makeHostComponent(Textarea, attributes, (props) => { const onInput = props.onInput; diff --git a/apps/web/src/features/bundle-ui/components/textarea/textarea.remote.tsx b/apps/web/src/features/bundle-ui/components/textarea/textarea.remote.tsx index 0fd2784..f9af4da 100644 --- a/apps/web/src/features/bundle-ui/components/textarea/textarea.remote.tsx +++ b/apps/web/src/features/bundle-ui/components/textarea/textarea.remote.tsx @@ -1,6 +1,11 @@ +import { + attributes, + events, + TAG, +} from "@tangent/ui-extensions-sdk/contracts/textarea"; + import { makeAuthorComponent } from "../_shared/author-component"; import { defineRemoteElement } from "../_shared/define-remote-element"; -import { attributes, events, TAG } from "./textarea.contract"; const TextareaElement = defineRemoteElement(TAG, attributes, events); diff --git a/apps/web/src/features/bundle-ui/runtime/bridge.tsx b/apps/web/src/features/bundle-ui/runtime/bridge.tsx index e2a58b0..16af366 100644 --- a/apps/web/src/features/bundle-ui/runtime/bridge.tsx +++ b/apps/web/src/features/bundle-ui/runtime/bridge.tsx @@ -1,7 +1,8 @@ /* eslint-disable react-refresh/only-export-components -- worker-only runtime barrel that intentionally exports the `host` bridge alongside the element wrappers; never hot-reloaded on the host. */ /** - * `@tangent/bundle-ui` — the module a sandboxed bundle component imports. + * `@tangent/ui-extensions-sdk` — the runtime the worker injects for the module a + * sandboxed UI extension imports. * * It is a barrel that exposes: * - `host`: the allowlisted bridge (`getProps` / `sendPrompt` / `fetch`), which @@ -28,7 +29,9 @@ declare global { function bridge(): HostBridge { const current = globalThis.__TANGENT_BUNDLE_UI_HOST__; if (!current) { - throw new Error("@tangent/bundle-ui: host bridge is not available yet"); + throw new Error( + "@tangent/ui-extensions-sdk: host bridge is not available yet", + ); } return current; } diff --git a/apps/web/src/features/bundle-ui/runtime/workerModuleLoader.ts b/apps/web/src/features/bundle-ui/runtime/workerModuleLoader.ts index 18c2e04..4886f86 100644 --- a/apps/web/src/features/bundle-ui/runtime/workerModuleLoader.ts +++ b/apps/web/src/features/bundle-ui/runtime/workerModuleLoader.ts @@ -2,7 +2,8 @@ * Worker-side loader for compiled bundle components (Phase 3 output). * * The server transpiles author `.tsx` with `packages: "external"`, so the served - * JS keeps bare imports (`react`, `react/jsx-runtime`, `@tangent/bundle-ui`). + * JS keeps bare imports (`react`, `react/jsx-runtime`, + * `@tangent/ui-extensions-sdk`). * Dedicated module workers cannot use import maps, so we resolve those bare * specifiers ourselves: the worker bundles its own copies of these modules, and * this loader exposes them to the dynamically-imported component via tiny diff --git a/apps/web/src/features/bundle-ui/types.ts b/apps/web/src/features/bundle-ui/types.ts index 9f2fc69..2c876ef 100644 --- a/apps/web/src/features/bundle-ui/types.ts +++ b/apps/web/src/features/bundle-ui/types.ts @@ -1,81 +1,23 @@ /** - * Shared contracts for the bundle-UI sandbox runtime (Phase 5). + * Bundle-UI host/worker types. * - * These types describe the bridge that crosses the host <-> worker boundary and - * are imported by both sides, so the two halves cannot drift. They mirror the - * documented contract in `docs/bundle-ui/host-bridge.md`. Everything here must - * be JSON-serializable since it travels over `@quilted/threads`. + * The bridge contract (`HostBridge` and friends) is owned by + * `@tangent/ui-extensions-sdk` so authors and the runtime share one source of + * truth; it is re-exported here for the host/worker code. The worker plumbing + * types (`RenderOptions`, `WorkerApi`) are internal to the web app and stay here. */ -/** Which surface a component is rendered on. */ -export type BundleUiKind = "message" | "panel"; - -/** Options for a host-mediated `fetch`, mirroring a tiny subset of `RequestInit`. */ -export interface HostRequestInit { - /** HTTP method; defaults to `GET`. */ - method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - /** Allowlisted request headers. */ - headers?: Record; - /** JSON-serializable body; sent as JSON. */ - body?: unknown; - /** Query parameters appended to the destination. */ - query?: Record; -} - -export interface HostTargetRequest { - target: "tangle"; - path: string; -} - -export type HostFetchInput = string | HostTargetRequest; - -/** JSON-safe stand-in for a `Response` (a live `Response` can't cross a thread). */ -export interface HostResponse { - ok: boolean; - status: number; - headers: Record; - /** Parsed body when the response is JSON. */ - json?: unknown; - /** Raw text body otherwise. */ - text?: string; -} - -/** - * A host UI action a component can request through {@link HostBridge.execUICommand}. - * - * Modeled as a discriminated union (rather than bespoke methods) so new actions - * can be added — each carrying its own payload — without growing the bridge - * surface. `collapse` collapses the chat message the component is rendered in, - * and URL commands open either a supplied `https:` URL or a server-configured - * target URL in a new browser tab. - */ -export type UICommand = - | { type: "collapse" } - | { type: "openUrl"; url: string } - | { type: "openTargetUrl"; target: "tangle"; path: string }; - -/** - * The only channel a sandboxed component has to the host. Exposed to the worker - * over `@quilted/threads`; every call is asynchronous across the boundary. - */ -export interface HostBridge { - /** JSON props for a `message` component; an empty object for `panel`. */ - getProps(): Promise>; - /** Composes and sends a chat message to Prime. */ - sendPrompt(text: string): Promise; - /** Host-mediated, allowlist-proxied network egress. */ - fetch(input: HostFetchInput, init?: HostRequestInit): Promise; - /** - * Reads a previously persisted value for `key` from this instance's - * key-value store, or `null` if absent. State survives page reloads and is - * scoped to the message instance; `panel` components have no store. - */ - getState(key: string): Promise; - /** Persists a JSON-serializable `value` under `key` for this instance. */ - setState(key: string, value: unknown): Promise; - /** Requests a host UI action (e.g. collapsing the component's message). */ - execUICommand(command: UICommand): Promise; -} +import type { BundleUiKind } from "@tangent/ui-extensions-sdk/types"; + +export type { + BundleUiKind, + HostBridge, + HostFetchInput, + HostRequestInit, + HostResponse, + HostTargetRequest, + UICommand, +} from "@tangent/ui-extensions-sdk/types"; /** Arguments the host passes to the worker's `render` export. */ export interface RenderOptions { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index e44bcc6..757798f 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -4,7 +4,9 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", "paths": { "@/*": ["./src/*"], - "@tangent/bundle-ui": ["./src/features/bundle-ui/runtime/bridge.tsx"] + "@tangent/ui-extensions-sdk": [ + "./src/features/bundle-ui/runtime/bridge.tsx" + ] } }, "include": ["src"] diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 98d830b..bed7700 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -52,16 +52,21 @@ export default defineConfig({ tailwindcss(), ], resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - // The bundle-UI bridge author components import. In-repo (harness, type - // checks) it resolves to the runtime module; sandboxed bundle components - // get it injected by the worker module loader at runtime instead. - "@tangent/bundle-ui": path.resolve( - __dirname, - "./src/features/bundle-ui/runtime/bridge.tsx", - ), - }, + alias: [ + { find: "@", replacement: path.resolve(__dirname, "./src") }, + // The SDK barrel UI extensions import. In-repo (harness, type checks) the + // bare specifier resolves to the runtime module; sandboxed components get + // it injected by the worker module loader at runtime instead. Matched + // exactly so subpaths (e.g. `@tangent/ui-extensions-sdk/contracts/*`) + // still resolve to the real package. + { + find: /^@tangent\/ui-extensions-sdk$/, + replacement: path.resolve( + __dirname, + "./src/features/bundle-ui/runtime/bridge.tsx", + ), + }, + ], }, server: { allowedHosts: [".tunnel.shopifycloud.tech"], diff --git a/docs/bundle-ui/authoring-guide.md b/docs/bundle-ui/authoring-guide.md index e91eb0d..088dccc 100644 --- a/docs/bundle-ui/authoring-guide.md +++ b/docs/bundle-ui/authoring-guide.md @@ -3,10 +3,45 @@ This guide shows how to write a bundle UI component. Before you start, read [`rules.md`](rules.md) — the constraints there are not optional. -A component is a `.tsx` file under your bundle's `ui/` directory, written with -remote-dom's React renderer. It imports the host bridge and renders **only** -elements from the [vocabulary](element-vocabulary.md). The server transpiles it on -upload (Phase 3); you ship plain `.tsx`. +A component is a `.tsx` file under your bundle's `ui/` directory. It imports the +host bridge and the typed element wrappers from the `@tangent/ui-extensions-sdk` +package and renders **only** elements from the [vocabulary](element-vocabulary.md). +The server transpiles it on upload; you ship plain `.tsx`. + +## Local development (typing + build) + +The `@tangent/ui-extensions-sdk` package gives you full editor typing for the +component vocabulary and the `host` bridge, plus a CLI that builds and +type-checks your bundle exactly the way the server does on upload. + +1. Add the SDK and React to your bundle's dev dependencies, then scaffold a + `tsconfig.json` that picks up the SDK's types: + + ```bash + pnpm add -D @tangent/ui-extensions-sdk react @types/react + pnpm exec ui-extensions init # writes tsconfig.json next to ui/ + ``` + +2. Type-check and build against the same esbuild config the server uses: + + ```bash + pnpm exec ui-extensions typecheck # tsc --noEmit over ui/**/*.tsx + pnpm exec ui-extensions build # compiles each entry to ui-dist/.js + ``` + +`build` transpile-bundles every entry declared in `tangent.yaml` under +`ui.components`. Relative imports are inlined, so you can split logic into +sibling files — API helpers, shared types, pure functions — and import them with +normal relative paths. Only the worker-provided bare imports (`react` and +`@tangent/ui-extensions-sdk`) stay external; other npm packages are not available +in the sandbox. + +``` +ui/ + pipeline-progress.tsx # entry (declared in tangent.yaml) + lib/tangle-api.ts # helper, imported as "./lib/tangle-api" + lib/format.ts # helper +``` ## The two kinds @@ -53,7 +88,13 @@ placeholder rather than erroring. renders a progress chip: ```tsx -import { BlockStack, Card, host, Progress, Text } from "@tangent/bundle-ui"; +import { + BlockStack, + Card, + host, + Progress, + Text, +} from "@tangent/ui-extensions-sdk"; import { useEffect, useState } from "react"; export default function PipelineProgress() { @@ -146,7 +187,7 @@ import { Heading, host, Textarea, -} from "@tangent/bundle-ui"; +} from "@tangent/ui-extensions-sdk"; import { useState } from "react"; export default function LaunchExperiment() { @@ -175,7 +216,8 @@ export default function LaunchExperiment() { } ``` -> Import the bridge and the typed element wrappers from `@tangent/bundle-ui`. The +> Import the bridge and the typed element wrappers from +> `@tangent/ui-extensions-sdk`. The > wrappers normalize remote events into plain serializable callbacks: `onPress` > (no payload) for `Button`, `onInput` (the new string) for `Textarea`. Props go > in, serializable events come out, and all data/prompts flow through the bridge. diff --git a/docs/server/configuration-bundles.md b/docs/server/configuration-bundles.md index 8c92089..4928981 100644 --- a/docs/server/configuration-bundles.md +++ b/docs/server/configuration-bundles.md @@ -164,9 +164,11 @@ listing), an optional `icon.svg`, and compiled UI component JS under `ui/`. `compileUiComponents` extracts the bundle to a temp dir so esbuild can resolve relative sibling imports, then transpiles each declared `ui.components[].entry` -to ESM with `packages: "external"` (bare imports like `react`, -`@tangent/bundle-ui`, `@remote-dom/*` are left for the worker import map; the -component never executes at upload). Output lands at `ui/.js`. +to ESM via the shared `buildUiComponent` helper from `@tangent/ui-extensions-sdk` +(the same helper the `ui-extensions` authoring CLI uses, so local builds match). +Bare imports like `react` and `@tangent/ui-extensions-sdk` are left for the +worker import map; the component never executes at upload. Output lands at +`ui/.js`. The marketplace routes ([routes/agentBundles.ts](../../server/src/routes/agentBundles.ts)) expose list / get / icon / download / `ui/:file` / upload / delete, plus the diff --git a/examples/tangle-oss/tsconfig.json b/examples/tangle-oss/tsconfig.json new file mode 100644 index 0000000..05635de --- /dev/null +++ b/examples/tangle-oss/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../packages/ui-extensions-sdk/tsconfig.author.json", + "compilerOptions": { + "paths": { + "@tangent/ui-extensions-sdk": [ + "../../packages/ui-extensions-sdk/src/index.ts" + ], + "react": ["../../apps/web/node_modules/@types/react/index.d.ts"], + "react/jsx-runtime": [ + "../../apps/web/node_modules/@types/react/jsx-runtime.d.ts" + ] + } + }, + "include": ["ui/**/*.tsx"] +} diff --git a/examples/tangle-oss/ui/pipeline-progress.tsx b/examples/tangle-oss/ui/pipeline-progress.tsx index 5e4d972..f6dba5b 100644 --- a/examples/tangle-oss/ui/pipeline-progress.tsx +++ b/examples/tangle-oss/ui/pipeline-progress.tsx @@ -26,7 +26,7 @@ import { Progress, StatusBar, Text, -} from "@tangent/bundle-ui"; +} from "@tangent/ui-extensions-sdk"; import { useEffect, useState } from "react"; const POLL_INTERVAL_MS = 4000; diff --git a/packages/ui-extensions-sdk/README.md b/packages/ui-extensions-sdk/README.md new file mode 100644 index 0000000..108e939 --- /dev/null +++ b/packages/ui-extensions-sdk/README.md @@ -0,0 +1,308 @@ +# @tangent/ui-extensions-sdk + +The authoring SDK for **Tangle UI extensions** — the small React components a +bundle renders inside the chat (a `message` chip/card) or the composer (a +`panel` form). It is the single source of truth for the extension contract: + +- the **typed component vocabulary** (`Button`, `Card`, `Text`, …), +- the **`host` bridge** (the only channel a sandboxed component has to the app), +- the **wire contracts** (zod schemas) shared by the host and worker runtimes, +- the **`ui-extensions` CLI** for building and type-checking a bundle locally, +- the **esbuild build helper** the server also uses on upload (no drift). + +If you are writing an extension, you only need [Authoring](#authoring-an-extension) +and [The CLI](#the-cli). The rest documents how the package is wired for +maintainers. + +--- + +## How an extension runs (the mental model) + +1. You write a `.tsx` component under your bundle's `ui/` directory and import + from `@tangent/ui-extensions-sdk`. +2. On upload, the server transpile-bundles each entry to ESM (via the shared + [`buildUiComponent`](#build-helper-tangentui-extensions-sdkbuild) helper), + leaving bare imports (`react`, `@tangent/ui-extensions-sdk`) external. +3. In the browser, the component runs inside a **Web Worker** sandbox. The worker + injects its own React and a live implementation of the SDK, then streams the + rendered tree to the host via [remote-dom](https://github.com/Shopify/remote-dom). +4. The component never touches the DOM, `window`, or the network directly — all + side effects go through the `host` bridge, which the host mediates (props, + prompts, allowlisted `fetch`, persisted state, UI commands). + +Because the worker swaps the SDK module at runtime, the component +_implementations_ exported from this package are thin stubs: **only the types +matter to authors.** This is what makes local type-checking match production. + +--- + +## Authoring an extension + +A component is a default-exported React component. Import the vocabulary and the +`host` bridge from the package: + +```tsx +import { + Card, + BlockStack, + Text, + Progress, + host, +} from "@tangent/ui-extensions-sdk"; +import { useEffect, useState } from "react"; + +export default function PipelineProgress() { + const [pct, setPct] = useState(0); + + useEffect(() => { + host.getProps().then((props) => { + if (typeof props.percent === "number") setPct(props.percent); + }); + }, []); + + return ( + + + Pipeline progress + = 1 ? "success" : "info"} /> + + + ); +} +``` + +### The two kinds + +Declare each component in your bundle's `tangent.yaml`: + +```yaml +ui: + components: + - name: pipeline-progress + kind: message # or "panel" + entry: ui/pipeline-progress.tsx +``` + +| Kind | Rendered when | Gets props from | +| --------- | ------------------------------------------- | ---------------------------- | +| `message` | the agent emits a `tangent-ui:` block | `host.getProps()` (its JSON) | +| `panel` | listed in the composer for the bundle | none (`getProps()` is empty) | + +### The component vocabulary + +Every component is fully typed — props, enums, and event handlers autocomplete. + +`Badge`, `BlockStack`, `Button`, `Card`, `CardHeader`, `CardTitle`, +`CardDescription`, `CardContent`, `CardFooter`, `Checkbox`, `Heading`, `Icon`, +`InlineStack`, `Pill`, `Progress`, `ScoreRing`, `Spinner`, `StatusBar`, `Text`, +`Textarea`. + +Events are surfaced as plain, serializable callbacks: + +- `Button` → `onPress?: () => void` +- `Checkbox` → `onCheckedChange?: (checked: boolean) => void` +- `Textarea` → `onInput?: (value: string) => void` + +`Icon` `name` is typed to the Lucide icon set. Container components accept +`children`. + +### The `host` bridge + +`host` is the only way out of the sandbox. Every method is async. + +```ts +await host.getProps(); // JSON props for a `message` component ({} for panels) +await host.sendPrompt("Launch experiment: …"); // send a chat message to Prime +await host.fetch({ target: "tangle", path: "/api/…" }); // allowlisted egress +await host.fetch("https://api.example.com/…", { method: "POST", body: {…} }); +await host.getState("key"); // per-instance persisted value (message only) +await host.setState("key", value); +await host.execUICommand({ type: "collapse" }); // collapse the host message +``` + +`host.fetch` is proxied through the server's egress allowlist; direct/global +`fetch` is not available. See `docs/bundle-ui/` for the full host-bridge and +security reference. + +### Multi-file extensions (helpers) + +Relative imports are bundled into the entry, so you can split logic into sibling +files and import them with normal relative paths: + +``` +ui/ + pipeline-progress.tsx # the entry declared in tangent.yaml + lib/tangle-api.ts # import { getRun } from "./lib/tangle-api" + lib/format.ts +``` + +Only the worker-provided bare imports (`react`, `react/jsx-runtime`, +`@tangent/ui-extensions-sdk`) stay external. **Other npm packages are not +available in the sandbox** — anything bare other than those will fail to resolve +at runtime. + +--- + +## The CLI + +The package ships a `ui-extensions` binary. `` defaults to the current +directory and must contain a `tangent.yaml`. + +```bash +ui-extensions init [dir] # scaffold a tsconfig.json for authoring +ui-extensions typecheck [dir] # tsc --noEmit over the bundle's ui/ sources +ui-extensions build [dir] # compile every ui.components entry to ui-dist/ +``` + +### Typical workflow (external bundle repo) + +```bash +# 1. Install the SDK + React types as dev dependencies +pnpm add -D @tangent/ui-extensions-sdk react @types/react + +# 2. Scaffold tsconfig.json (extends the SDK's author preset) +pnpm exec ui-extensions init + +# 3. Author under ui/, then type-check and build +pnpm exec ui-extensions typecheck +pnpm exec ui-extensions build +``` + +`build` writes compiled ESM to `ui-dist/.js`, mirroring what the server +produces on upload (bare imports left external). Add `ui-dist/` to your +`.gitignore` — it is generated output. + +### Running the CLI in this monorepo + +The in-repo example lives at `examples/tangle-oss` (it has a checked-in +`tsconfig.json` with in-repo path aliases). Run the CLI against it with: + +```bash +pnpm --filter @tangent/ui-extensions-sdk cli build ../../examples/tangle-oss +pnpm --filter @tangent/ui-extensions-sdk cli typecheck ../../examples/tangle-oss +# or invoke the linked bin from a consumer package: +pnpm --filter @tangent/web exec ui-extensions build ../../examples/tangle-oss +``` + +--- + +## Package exports + +| Subpath | Purpose | +| ------------------------------------------------- | ------------------------------------------------------------------- | +| `@tangent/ui-extensions-sdk` | Author entry: typed components + `host` + bridge types. | +| `@tangent/ui-extensions-sdk/types` | The host/worker bridge types on their own. | +| `@tangent/ui-extensions-sdk/contracts/` | A single component's wire contract (`attributes`, `events`, `TAG`). | +| `@tangent/ui-extensions-sdk/build` | The shared esbuild helper (`buildUiComponent`). | +| `@tangent/ui-extensions-sdk/tsconfig.author.json` | The tsconfig preset authors extend. | + +### Build helper (`@tangent/ui-extensions-sdk/build`) + +The server and the CLI import the **same** function so a component that builds +locally builds identically on upload: + +```ts +import { + buildUiComponent, + uiComponentBuildOptions, +} from "@tangent/ui-extensions-sdk/build"; + +const esmSource = await buildUiComponent("/abs/path/to/ui/entry.tsx"); +``` + +The esbuild config is transpile-bundle only (`bundle: true`, `format: "esm"`, +`platform: "browser"`, `jsx: "automatic"`, `packages: "external"`) with an +explicit `external` list for the worker-provided specifiers. That explicit list +matters: without it, esbuild would inline a workspace-symlinked +`@tangent/ui-extensions-sdk` (its symlink resolves outside `node_modules`), +diverging from the server build and shadowing the real runtime. + +### The tsconfig preset + +`tsconfig.author.json` is self-contained (`jsx: react-jsx`, +`moduleResolution: bundler`, `lib: DOM/ES2022`, `strict`) so external authors +extend it without needing any other `@tangent/*` package: + +```json +{ + "extends": "@tangent/ui-extensions-sdk/tsconfig.author.json", + "include": ["ui/**/*.tsx"] +} +``` + +`ui-extensions init` writes exactly this. + +--- + +## Package layout + +``` +src/ + index.ts # author entry — re-exports components, host, types + components.ts # typed component decls + prop types (derived from contracts) + host.ts # the typed `host` bridge object + types.ts # host/worker bridge contract types + build.ts # shared esbuild options + buildUiComponent() + cli.ts # the `ui-extensions` binary + contracts/ + .ts # per-component zod attributes + events + TAG (pure zod) + events.ts # RemoteEvents / EventPayload types + enums.ts # cross-component enum tuples (GAP, TEXT_SIZE, …) +tsconfig.author.json # the preset authors extend +``` + +### How the pieces connect + +- **`contracts/*`** are pure zod schemas — the wire truth. They are consumed by + the web app's worker (to define remote elements) and host (to validate + attributes) via the `contracts/` subpath. +- **`components.ts`** derives each `…Props` type from `z.infer` + and adds event handlers + `children`, then exports thin stub components. The + web runtime (`apps/web/.../runtime/bridge.tsx`) provides the real + implementations at worker time; production never runs these stubs. +- **`types.ts`** is the bridge contract shared by the SDK, the web host, and the + worker so the halves cannot drift. + +--- + +## Adding a new component (maintainers) + +1. Add `src/contracts/.ts` — export `TAG`, a zod `attributes` object, and + `events` (`RemoteEvents`). Reuse enum tuples from `contracts/enums.ts` where + they apply. +2. Add the prop type + stub export in `src/components.ts` (derive from + `z.infer`; add event handlers / `children` as needed). +3. In `apps/web/src/features/bundle-ui/components//`: + - `.remote.tsx` — `makeAuthorComponent` over `defineRemoteElement`, + re-exported from `runtime/bridge.tsx`. + - `.host.tsx` — the host adapter mapping to the real + `@tangent/ui-primitives` component; register it in + `components/host-registry.ts`. +4. Run the checks below and update `docs/bundle-ui/element-vocabulary.md`. + +--- + +## Development & validation + +This package has no build step — it is consumed as TypeScript source through its +`exports` map (like the other `@tangent/*` workspace packages). + +```bash +pnpm --filter @tangent/ui-extensions-sdk typecheck +pnpm --filter @tangent/ui-extensions-sdk lint +pnpm --filter @tangent/ui-extensions-sdk format + +# repo-wide (typecheck + lint + build across all packages) +pnpm validate + +# the server tests exercise the upload → compile path end-to-end +pnpm --filter @tangent/server test +``` + +## Notes & non-goals + +- The sandbox model is fixed: npm packages other than the worker-provided ones + are unsupported; all egress goes through the server allowlist. +- Bundles compiled before the SDK rename import the legacy `@tangent/bundle-ui` + specifier; the worker registers it as an alias to the same runtime, so old and + new artifacts both resolve. New builds emit `@tangent/ui-extensions-sdk`. diff --git a/packages/ui-extensions-sdk/eslint.config.js b/packages/ui-extensions-sdk/eslint.config.js new file mode 100644 index 0000000..eb8297b --- /dev/null +++ b/packages/ui-extensions-sdk/eslint.config.js @@ -0,0 +1,3 @@ +import base from "@tangent/build/eslint/base"; + +export default [{ ignores: ["node_modules"] }, ...base]; diff --git a/packages/ui-extensions-sdk/package.json b/packages/ui-extensions-sdk/package.json new file mode 100644 index 0000000..c18b7b0 --- /dev/null +++ b/packages/ui-extensions-sdk/package.json @@ -0,0 +1,45 @@ +{ + "name": "@tangent/ui-extensions-sdk", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "ui-extensions": "./src/cli.ts" + }, + "exports": { + ".": "./src/index.ts", + "./build": "./src/build.ts", + "./types": "./src/types.ts", + "./contracts/*.ts": "./src/contracts/*.ts", + "./contracts/*": "./src/contracts/*.ts", + "./tsconfig.author.json": "./tsconfig.author.json" + }, + "prettier": "@tangent/build/prettier", + "scripts": { + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "format": "prettier --write .", + "cli": "tsx src/cli.ts" + }, + "dependencies": { + "esbuild": "0.28.0", + "lucide-react": "^1.17.0", + "tsx": "^4.22.3", + "yaml": "^2.9.0", + "zod": "^4.4.3" + }, + "peerDependencies": { + "react": "^19", + "typescript": ">=5" + }, + "devDependencies": { + "@tangent/build": "workspace:*", + "@tangent/ui-primitives": "workspace:*", + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "eslint": "^10.4.0", + "prettier": "^3.8.3", + "react": "^19.2.6", + "typescript": "^6.0.3" + } +} diff --git a/packages/ui-extensions-sdk/src/build.ts b/packages/ui-extensions-sdk/src/build.ts new file mode 100644 index 0000000..81d7279 --- /dev/null +++ b/packages/ui-extensions-sdk/src/build.ts @@ -0,0 +1,71 @@ +/** + * The single esbuild configuration for compiling a UI extension entry to the + * ESM asset the sandbox worker loads. Shared by the server (on bundle upload) + * and the `ui-extensions` CLI (local build), so a component that builds locally + * builds identically on the server. + * + * Transpile-bundle only: relative sibling imports (helpers, shared logic) are + * inlined, while bare imports (`react`, `react/jsx-runtime`, + * `@tangent/ui-extensions-sdk`) are left external for the worker's import map to + * resolve at runtime. The component never executes here. + */ + +import { build, type BuildOptions } from "esbuild"; + +/** + * Bare specifiers the sandbox worker injects at runtime. They must stay external + * even when resolvable at build time — otherwise a locally installed (workspace- + * symlinked) `@tangent/ui-extensions-sdk` would get inlined, diverging from the + * server build (whose temp dir can't resolve it) and shadowing the real runtime. + */ +const WORKER_PROVIDED = [ + "react", + "react/*", + "react-dom", + "react-dom/*", + "@tangent/ui-extensions-sdk", + "@tangent/ui-extensions-sdk/*", +]; + +/** esbuild options for compiling a single UI extension `entry` to ESM. */ +export function uiComponentBuildOptions(entryPath: string): BuildOptions { + return { + entryPoints: [entryPath], + bundle: true, + format: "esm", + platform: "browser", + jsx: "automatic", + // Everything bare stays external for the worker's import map; the explicit + // list also covers workspace-symlinked packages `packages: "external"` alone + // would otherwise resolve outside node_modules and bundle. + packages: "external", + external: WORKER_PROVIDED, + write: false, + logLevel: "silent", + }; +} + +/** Thrown when a UI extension entry fails to compile. */ +export class UiComponentBuildError extends Error { + readonly entry: string; + + constructor(entry: string, cause: unknown) { + const detail = cause instanceof Error ? cause.message : String(cause); + super(`failed to compile ui entry "${entry}"\n${detail}`, { cause }); + this.name = "UiComponentBuildError"; + this.entry = entry; + } +} + +/** + * Transpile-bundles a single UI extension entry and returns the ESM source. + * Throws {@link UiComponentBuildError} when the entry fails to compile. + */ +export async function buildUiComponent(entryPath: string): Promise { + try { + const result = await build(uiComponentBuildOptions(entryPath)); + return result.outputFiles![0].text; + } catch (err) { + throw new UiComponentBuildError(entryPath, err); + } +} diff --git a/packages/ui-extensions-sdk/src/cli.ts b/packages/ui-extensions-sdk/src/cli.ts new file mode 100755 index 0000000..e1e1a0e --- /dev/null +++ b/packages/ui-extensions-sdk/src/cli.ts @@ -0,0 +1,115 @@ +#!/usr/bin/env tsx +/** + * `ui-extensions` — local authoring CLI for Tangle UI extensions. + * + * ui-extensions build [dir] compile every ui.components entry to ui-dist/ + * ui-extensions typecheck [dir] type-check the bundle's ui/ sources + * ui-extensions init [dir] scaffold a tsconfig.json for authoring + * + * `dir` defaults to the current working directory and must contain a + * `tangent.yaml` manifest. `build` shares its esbuild config with the server, so + * a component that builds here builds identically on upload. + */ + +import { spawn } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import process from "node:process"; + +import { parse } from "yaml"; + +import { buildUiComponent } from "./build"; + +interface UiComponent { + name: string; + entry: string; +} + +interface Manifest { + ui?: { components?: UiComponent[] }; +} + +const OUT_DIR = "ui-dist"; + +function fail(message: string): never { + console.error(`ui-extensions: ${message}`); + process.exit(1); +} + +async function readComponents(dir: string): Promise { + const manifestPath = path.join(dir, "tangent.yaml"); + let raw: string; + try { + raw = await readFile(manifestPath, "utf8"); + } catch { + return fail(`no tangent.yaml found in ${dir}`); + } + const manifest = parse(raw) as Manifest | null; + const components = manifest?.ui?.components ?? []; + if (components.length === 0) { + return fail(`no ui.components declared in ${manifestPath}`); + } + return components; +} + +async function runBuild(dir: string): Promise { + const components = await readComponents(dir); + const outDir = path.join(dir, OUT_DIR); + await mkdir(outDir, { recursive: true }); + + for (const component of components) { + const entryPath = path.join(dir, component.entry); + const output = await buildUiComponent(entryPath); + const outPath = path.join(outDir, `${component.name}.js`); + await writeFile(outPath, output); + console.log(`ui-extensions: built ${component.name} -> ${outPath}`); + } +} + +function runTypecheck(dir: string): Promise { + const require = createRequire(import.meta.url); + const tsc = require.resolve("typescript/bin/tsc"); + const tsconfig = path.join(dir, "tsconfig.json"); + return new Promise((resolve) => { + const child = spawn(process.execPath, [tsc, "--noEmit", "-p", tsconfig], { + stdio: "inherit", + }); + child.on("exit", (code) => { + if (code && code !== 0) process.exit(code); + resolve(); + }); + }); +} + +const TSCONFIG_TEMPLATE = `${JSON.stringify( + { + extends: "@tangent/ui-extensions-sdk/tsconfig.author.json", + include: ["ui/**/*.tsx"], + }, + null, + 2, +)}\n`; + +async function runInit(dir: string): Promise { + const tsconfigPath = path.join(dir, "tsconfig.json"); + await writeFile(tsconfigPath, TSCONFIG_TEMPLATE); + console.log(`ui-extensions: wrote ${tsconfigPath}`); +} + +async function main(): Promise { + const [command, dirArg] = process.argv.slice(2); + const dir = path.resolve(dirArg ?? "."); + + if (command === "build") return runBuild(dir); + if (command === "typecheck") return runTypecheck(dir); + if (command === "init") return runInit(dir); + + return fail( + `unknown command "${command ?? ""}". Use build, typecheck, or init.`, + ); +} + +main().catch((err: unknown) => { + fail(err instanceof Error ? err.message : String(err)); +}); diff --git a/packages/ui-extensions-sdk/src/components.ts b/packages/ui-extensions-sdk/src/components.ts new file mode 100644 index 0000000..246a0f1 --- /dev/null +++ b/packages/ui-extensions-sdk/src/components.ts @@ -0,0 +1,123 @@ +/** + * The typed component surface a UI extension imports from + * `@tangent/ui-extensions-sdk`. + * + * Each component's props are derived from its wire contract (`z.infer` over the + * same zod `attributes` the host validates), plus the author-facing event + * handlers and `children`. This keeps the author types in lockstep with the + * runtime contract — change a contract and the author types move with it. + * + * The implementations are intentionally thin: at runtime the worker swaps this + * module for its own remote-dom runtime, and the local build externalizes the + * import, so these bodies never execute in production. They render children so a + * component still degrades gracefully if imported directly (e.g. in a test). + */ + +import { + createElement, + Fragment, + type FunctionComponent, + type ReactNode, +} from "react"; +import type { z } from "zod"; + +import { attributes as badgeAttributes } from "./contracts/badge"; +import { attributes as blockStackAttributes } from "./contracts/block-stack"; +import { attributes as buttonAttributes } from "./contracts/button"; +import { attributes as cardAttributes } from "./contracts/card"; +import { attributes as cardContentAttributes } from "./contracts/card-content"; +import { attributes as cardDescriptionAttributes } from "./contracts/card-description"; +import { attributes as cardFooterAttributes } from "./contracts/card-footer"; +import { attributes as cardHeaderAttributes } from "./contracts/card-header"; +import { attributes as cardTitleAttributes } from "./contracts/card-title"; +import { attributes as checkboxAttributes } from "./contracts/checkbox"; +import { attributes as headingAttributes } from "./contracts/heading"; +import { attributes as iconAttributes } from "./contracts/icon"; +import { attributes as inlineStackAttributes } from "./contracts/inline-stack"; +import { attributes as pillAttributes } from "./contracts/pill"; +import { attributes as progressAttributes } from "./contracts/progress"; +import { attributes as scoreRingAttributes } from "./contracts/score-ring"; +import { attributes as spinnerAttributes } from "./contracts/spinner"; +import { attributes as statusBarAttributes } from "./contracts/status-bar"; +import { attributes as textAttributes } from "./contracts/text"; +import { attributes as textareaAttributes } from "./contracts/textarea"; + +type Attrs = z.infer; +type WithChildren = { children?: ReactNode }; + +export type BadgeProps = Attrs & WithChildren; +export type BlockStackProps = Attrs & WithChildren; +export type ButtonProps = Attrs & + WithChildren & { + /** Fired when the button is activated. */ + onPress?: () => void; + }; +export type CardProps = Attrs & WithChildren; +export type CardContentProps = Attrs & + WithChildren; +export type CardDescriptionProps = Attrs & + WithChildren; +export type CardFooterProps = Attrs & WithChildren; +export type CardHeaderProps = Attrs & WithChildren; +export type CardTitleProps = Attrs & WithChildren; +export type CheckboxProps = Omit< + Attrs, + "checked" +> & { + /** Whether the box is checked; defaults to `false`. */ + checked?: boolean; + /** Fired with the next checked state when toggled. */ + onCheckedChange?: (checked: boolean) => void; +}; +export type HeadingProps = Omit, "level"> & + WithChildren & { + /** Heading level 1–6; defaults to `1`. */ + level?: Attrs["level"]; + }; +export type IconProps = Attrs; +export type InlineStackProps = Attrs & + WithChildren; +export type PillProps = Attrs & WithChildren; +export type ProgressProps = Attrs; +export type ScoreRingProps = Omit< + Attrs, + "score" +> & { + /** Score 0–100; defaults to `0`. */ + score?: number; +}; +export type SpinnerProps = Attrs; +export type StatusBarProps = Attrs; +export type TextProps = Attrs & WithChildren; +export type TextareaProps = Attrs & { + /** Fired with the textarea's value on every input. */ + onInput?: (value: string) => void; +}; + +function stub

(displayName: string): FunctionComponent

{ + const Component = (props: P): ReactNode => + createElement(Fragment, null, (props as WithChildren).children); + Component.displayName = displayName; + return Component; +} + +export const Badge = stub("Badge"); +export const BlockStack = stub("BlockStack"); +export const Button = stub("Button"); +export const Card = stub("Card"); +export const CardContent = stub("CardContent"); +export const CardDescription = stub("CardDescription"); +export const CardFooter = stub("CardFooter"); +export const CardHeader = stub("CardHeader"); +export const CardTitle = stub("CardTitle"); +export const Checkbox = stub("Checkbox"); +export const Heading = stub("Heading"); +export const Icon = stub("Icon"); +export const InlineStack = stub("InlineStack"); +export const Pill = stub("Pill"); +export const Progress = stub("Progress"); +export const ScoreRing = stub("ScoreRing"); +export const Spinner = stub("Spinner"); +export const StatusBar = stub("StatusBar"); +export const Text = stub("Text"); +export const Textarea = stub("Textarea"); diff --git a/apps/web/src/features/bundle-ui/components/badge/badge.contract.ts b/packages/ui-extensions-sdk/src/contracts/badge.ts similarity index 93% rename from apps/web/src/features/bundle-ui/components/badge/badge.contract.ts rename to packages/ui-extensions-sdk/src/contracts/badge.ts index 70c93eb..df5a0cb 100644 --- a/apps/web/src/features/bundle-ui/components/badge/badge.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/badge.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-badge" as const; diff --git a/apps/web/src/features/bundle-ui/components/block-stack/block-stack.contract.ts b/packages/ui-extensions-sdk/src/contracts/block-stack.ts similarity index 84% rename from apps/web/src/features/bundle-ui/components/block-stack/block-stack.contract.ts rename to packages/ui-extensions-sdk/src/contracts/block-stack.ts index 14c5a3d..ac3776a 100644 --- a/apps/web/src/features/bundle-ui/components/block-stack/block-stack.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/block-stack.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import { GAP } from "../_shared/enums"; -import type { RemoteEvents } from "../_shared/events"; +import { GAP } from "./enums"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-block-stack" as const; diff --git a/apps/web/src/features/bundle-ui/components/button/button.contract.ts b/packages/ui-extensions-sdk/src/contracts/button.ts similarity index 92% rename from apps/web/src/features/bundle-ui/components/button/button.contract.ts rename to packages/ui-extensions-sdk/src/contracts/button.ts index 4082a9a..fe23f84 100644 --- a/apps/web/src/features/bundle-ui/components/button/button.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/button.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-button" as const; diff --git a/apps/web/src/features/bundle-ui/components/card/card-content.contract.ts b/packages/ui-extensions-sdk/src/contracts/card-content.ts similarity index 83% rename from apps/web/src/features/bundle-ui/components/card/card-content.contract.ts rename to packages/ui-extensions-sdk/src/contracts/card-content.ts index 0d714d7..0cbc4d4 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-content.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/card-content.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-card-content" as const; diff --git a/apps/web/src/features/bundle-ui/components/card/card-description.contract.ts b/packages/ui-extensions-sdk/src/contracts/card-description.ts similarity index 75% rename from apps/web/src/features/bundle-ui/components/card/card-description.contract.ts rename to packages/ui-extensions-sdk/src/contracts/card-description.ts index 4b3fca1..954826a 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-description.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/card-description.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-card-description" as const; diff --git a/apps/web/src/features/bundle-ui/components/card/card-footer.contract.ts b/packages/ui-extensions-sdk/src/contracts/card-footer.ts similarity index 83% rename from apps/web/src/features/bundle-ui/components/card/card-footer.contract.ts rename to packages/ui-extensions-sdk/src/contracts/card-footer.ts index 1f1d725..6fb80e7 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-footer.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/card-footer.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-card-footer" as const; diff --git a/apps/web/src/features/bundle-ui/components/card/card-header.contract.ts b/packages/ui-extensions-sdk/src/contracts/card-header.ts similarity index 85% rename from apps/web/src/features/bundle-ui/components/card/card-header.contract.ts rename to packages/ui-extensions-sdk/src/contracts/card-header.ts index afc7d13..a797b38 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-header.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/card-header.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-card-header" as const; diff --git a/apps/web/src/features/bundle-ui/components/card/card-title.contract.ts b/packages/ui-extensions-sdk/src/contracts/card-title.ts similarity index 74% rename from apps/web/src/features/bundle-ui/components/card/card-title.contract.ts rename to packages/ui-extensions-sdk/src/contracts/card-title.ts index 9610685..d041e74 100644 --- a/apps/web/src/features/bundle-ui/components/card/card-title.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/card-title.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-card-title" as const; diff --git a/apps/web/src/features/bundle-ui/components/card/card.contract.ts b/packages/ui-extensions-sdk/src/contracts/card.ts similarity index 83% rename from apps/web/src/features/bundle-ui/components/card/card.contract.ts rename to packages/ui-extensions-sdk/src/contracts/card.ts index 5fd875a..b6bae5b 100644 --- a/apps/web/src/features/bundle-ui/components/card/card.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/card.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-card" as const; diff --git a/apps/web/src/features/bundle-ui/components/checkbox/checkbox.contract.ts b/packages/ui-extensions-sdk/src/contracts/checkbox.ts similarity index 87% rename from apps/web/src/features/bundle-ui/components/checkbox/checkbox.contract.ts rename to packages/ui-extensions-sdk/src/contracts/checkbox.ts index 4820589..7cb964c 100644 --- a/apps/web/src/features/bundle-ui/components/checkbox/checkbox.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/checkbox.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-checkbox" as const; diff --git a/apps/web/src/features/bundle-ui/components/_shared/enums.ts b/packages/ui-extensions-sdk/src/contracts/enums.ts similarity index 77% rename from apps/web/src/features/bundle-ui/components/_shared/enums.ts rename to packages/ui-extensions-sdk/src/contracts/enums.ts index d6e3ad4..45d47ac 100644 --- a/apps/web/src/features/bundle-ui/components/_shared/enums.ts +++ b/packages/ui-extensions-sdk/src/contracts/enums.ts @@ -1,8 +1,8 @@ /** * Cross-component enum tuples shared by more than one component contract. These - * mirror the corresponding `cva` variant keys in `src/shared/ui` and are a - * curated subset (display + simple input) safe for third-party bundle - * components. Component-specific enums live inline in each `*.contract.ts`. + * mirror the corresponding `cva` variant keys in `@tangent/ui-primitives` and are + * a curated subset (display + simple input) safe for third-party UI extensions. + * Component-specific enums live inline in each contract. */ /** Spacing scale shared by `BlockStack` / `InlineStack` `gap`. */ diff --git a/apps/web/src/features/bundle-ui/components/_shared/events.ts b/packages/ui-extensions-sdk/src/contracts/events.ts similarity index 84% rename from apps/web/src/features/bundle-ui/components/_shared/events.ts rename to packages/ui-extensions-sdk/src/contracts/events.ts index b6cddce..cf7b7ed 100644 --- a/apps/web/src/features/bundle-ui/components/_shared/events.ts +++ b/packages/ui-extensions-sdk/src/contracts/events.ts @@ -1,7 +1,7 @@ /** * Neutral (React-free, remote-dom-free) event contract types, shared by a - * component's `*.contract.ts` and the worker/host helpers. Keeping them here lets - * the contract stay importable from both sides without pulling in either runtime. + * component's contract and the worker/host helpers. Keeping them here lets the + * contract stay importable from both sides without pulling in either runtime. */ /** diff --git a/apps/web/src/features/bundle-ui/components/heading/heading.contract.ts b/packages/ui-extensions-sdk/src/contracts/heading.ts similarity index 86% rename from apps/web/src/features/bundle-ui/components/heading/heading.contract.ts rename to packages/ui-extensions-sdk/src/contracts/heading.ts index b41f35e..e34a007 100644 --- a/apps/web/src/features/bundle-ui/components/heading/heading.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/heading.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import { TEXT_SIZE, TEXT_TONE, TEXT_WEIGHT } from "../_shared/enums"; -import type { RemoteEvents } from "../_shared/events"; +import { TEXT_SIZE, TEXT_TONE, TEXT_WEIGHT } from "./enums"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-heading" as const; diff --git a/apps/web/src/features/bundle-ui/components/icon/icon.contract.ts b/packages/ui-extensions-sdk/src/contracts/icon.ts similarity index 92% rename from apps/web/src/features/bundle-ui/components/icon/icon.contract.ts rename to packages/ui-extensions-sdk/src/contracts/icon.ts index 25b21a9..c95ffe4 100644 --- a/apps/web/src/features/bundle-ui/components/icon/icon.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/icon.ts @@ -2,7 +2,7 @@ import type { IconName } from "@tangent/ui-primitives/icon"; import { icons } from "lucide-react"; import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-icon" as const; diff --git a/apps/web/src/features/bundle-ui/components/inline-stack/inline-stack.contract.ts b/packages/ui-extensions-sdk/src/contracts/inline-stack.ts similarity index 86% rename from apps/web/src/features/bundle-ui/components/inline-stack/inline-stack.contract.ts rename to packages/ui-extensions-sdk/src/contracts/inline-stack.ts index 4bd9d09..d0d9cab 100644 --- a/apps/web/src/features/bundle-ui/components/inline-stack/inline-stack.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/inline-stack.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import { GAP } from "../_shared/enums"; -import type { RemoteEvents } from "../_shared/events"; +import { GAP } from "./enums"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-inline-stack" as const; diff --git a/apps/web/src/features/bundle-ui/components/pill/pill.contract.ts b/packages/ui-extensions-sdk/src/contracts/pill.ts similarity index 88% rename from apps/web/src/features/bundle-ui/components/pill/pill.contract.ts rename to packages/ui-extensions-sdk/src/contracts/pill.ts index 5cb4ba6..03b5d26 100644 --- a/apps/web/src/features/bundle-ui/components/pill/pill.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/pill.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-pill" as const; diff --git a/apps/web/src/features/bundle-ui/components/progress/progress.contract.ts b/packages/ui-extensions-sdk/src/contracts/progress.ts similarity index 85% rename from apps/web/src/features/bundle-ui/components/progress/progress.contract.ts rename to packages/ui-extensions-sdk/src/contracts/progress.ts index a90c5c3..c7f667f 100644 --- a/apps/web/src/features/bundle-ui/components/progress/progress.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/progress.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-progress" as const; diff --git a/apps/web/src/features/bundle-ui/components/score-ring/score-ring.contract.ts b/packages/ui-extensions-sdk/src/contracts/score-ring.ts similarity index 85% rename from apps/web/src/features/bundle-ui/components/score-ring/score-ring.contract.ts rename to packages/ui-extensions-sdk/src/contracts/score-ring.ts index 13e25a0..7acc071 100644 --- a/apps/web/src/features/bundle-ui/components/score-ring/score-ring.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/score-ring.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-score-ring" as const; diff --git a/apps/web/src/features/bundle-ui/components/spinner/spinner.contract.ts b/packages/ui-extensions-sdk/src/contracts/spinner.ts similarity index 78% rename from apps/web/src/features/bundle-ui/components/spinner/spinner.contract.ts rename to packages/ui-extensions-sdk/src/contracts/spinner.ts index b4e8949..59c9456 100644 --- a/apps/web/src/features/bundle-ui/components/spinner/spinner.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/spinner.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-spinner" as const; diff --git a/apps/web/src/features/bundle-ui/components/status-bar/status-bar.contract.ts b/packages/ui-extensions-sdk/src/contracts/status-bar.ts similarity index 88% rename from apps/web/src/features/bundle-ui/components/status-bar/status-bar.contract.ts rename to packages/ui-extensions-sdk/src/contracts/status-bar.ts index 4d57e99..9b08ea1 100644 --- a/apps/web/src/features/bundle-ui/components/status-bar/status-bar.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/status-bar.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-status-bar" as const; diff --git a/apps/web/src/features/bundle-ui/components/text/text.contract.ts b/packages/ui-extensions-sdk/src/contracts/text.ts similarity index 72% rename from apps/web/src/features/bundle-ui/components/text/text.contract.ts rename to packages/ui-extensions-sdk/src/contracts/text.ts index 6c3554e..14f4198 100644 --- a/apps/web/src/features/bundle-ui/components/text/text.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/text.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import { TEXT_SIZE, TEXT_TONE, TEXT_WEIGHT } from "../_shared/enums"; -import type { RemoteEvents } from "../_shared/events"; +import { TEXT_SIZE, TEXT_TONE, TEXT_WEIGHT } from "./enums"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-text" as const; diff --git a/apps/web/src/features/bundle-ui/components/textarea/textarea.contract.ts b/packages/ui-extensions-sdk/src/contracts/textarea.ts similarity index 87% rename from apps/web/src/features/bundle-ui/components/textarea/textarea.contract.ts rename to packages/ui-extensions-sdk/src/contracts/textarea.ts index a96f7f4..01e7b4a 100644 --- a/apps/web/src/features/bundle-ui/components/textarea/textarea.contract.ts +++ b/packages/ui-extensions-sdk/src/contracts/textarea.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { RemoteEvents } from "../_shared/events"; +import type { RemoteEvents } from "./events"; export const TAG = "tangent-textarea" as const; diff --git a/packages/ui-extensions-sdk/src/host.ts b/packages/ui-extensions-sdk/src/host.ts new file mode 100644 index 0000000..75f585b --- /dev/null +++ b/packages/ui-extensions-sdk/src/host.ts @@ -0,0 +1,39 @@ +/** + * The allowlisted host bridge a UI extension talks to. + * + * At runtime the worker injects the real implementation onto `globalThis` and + * swaps this module for its own runtime copy, so the body here exists mainly to + * give authors a typed, importable `host`. See `docs/bundle-ui/host-bridge.md`. + */ + +import type { + HostBridge, + HostFetchInput, + HostRequestInit, + UICommand, +} from "./types"; + +declare global { + var __TANGENT_BUNDLE_UI_HOST__: HostBridge | undefined; +} + +function bridge(): HostBridge { + const current = globalThis.__TANGENT_BUNDLE_UI_HOST__; + if (!current) { + throw new Error( + "@tangent/ui-extensions-sdk: host bridge is not available yet", + ); + } + return current; +} + +/** The allowlisted host bridge. See `docs/bundle-ui/host-bridge.md`. */ +export const host: HostBridge = { + getProps: () => bridge().getProps(), + sendPrompt: (text: string) => bridge().sendPrompt(text), + fetch: (input: HostFetchInput, init?: HostRequestInit) => + bridge().fetch(input, init), + getState: (key: string) => bridge().getState(key), + setState: (key: string, value: unknown) => bridge().setState(key, value), + execUICommand: (command: UICommand) => bridge().execUICommand(command), +}; diff --git a/packages/ui-extensions-sdk/src/index.ts b/packages/ui-extensions-sdk/src/index.ts new file mode 100644 index 0000000..7869861 --- /dev/null +++ b/packages/ui-extensions-sdk/src/index.ts @@ -0,0 +1,20 @@ +/** + * `@tangent/ui-extensions-sdk` — the SDK a Tangle UI extension imports. + * + * Exposes the typed component vocabulary, the allowlisted `host` bridge, and the + * bridge contract types. Authoring against this module gives full editor typing; + * at runtime the worker injects the real implementations. See the authoring + * guide in `docs/bundle-ui/`. + */ + +export * from "./components"; +export { host } from "./host"; +export type { + BundleUiKind, + HostBridge, + HostFetchInput, + HostRequestInit, + HostResponse, + HostTargetRequest, + UICommand, +} from "./types"; diff --git a/packages/ui-extensions-sdk/src/types.ts b/packages/ui-extensions-sdk/src/types.ts new file mode 100644 index 0000000..534d74c --- /dev/null +++ b/packages/ui-extensions-sdk/src/types.ts @@ -0,0 +1,80 @@ +/** + * The contract that crosses the host <-> worker boundary for a UI extension. + * + * These types describe the `host` bridge a sandboxed component talks to. They + * are the single source of truth shared by the SDK (author side), the web host, + * and the worker runtime, so the halves cannot drift. Everything here must be + * JSON-serializable since it travels over `@quilted/threads`. + */ + +/** Which surface a component is rendered on. */ +export type BundleUiKind = "message" | "panel"; + +/** Options for a host-mediated `fetch`, mirroring a tiny subset of `RequestInit`. */ +export interface HostRequestInit { + /** HTTP method; defaults to `GET`. */ + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Allowlisted request headers. */ + headers?: Record; + /** JSON-serializable body; sent as JSON. */ + body?: unknown; + /** Query parameters appended to the destination. */ + query?: Record; +} + +/** A server-resolved egress target, addressed by name + path rather than URL. */ +export interface HostTargetRequest { + target: "tangle"; + path: string; +} + +/** What `host.fetch` accepts: an absolute URL or a server-resolved target. */ +export type HostFetchInput = string | HostTargetRequest; + +/** JSON-safe stand-in for a `Response` (a live `Response` can't cross a thread). */ +export interface HostResponse { + ok: boolean; + status: number; + headers: Record; + /** Parsed body when the response is JSON. */ + json?: unknown; + /** Raw text body otherwise. */ + text?: string; +} + +/** + * A host UI action a component can request through {@link HostBridge.execUICommand}. + * + * Modeled as a discriminated union (rather than bespoke methods) so new actions + * can be added — each carrying its own payload — without growing the bridge + * surface. `collapse` collapses the chat message the component is rendered in, + * and URL commands open either a supplied `https:` URL or a server-configured + * target URL in a new browser tab. + */ +export type UICommand = + | { type: "collapse" } + | { type: "openUrl"; url: string } + | { type: "openTargetUrl"; target: "tangle"; path: string }; + +/** + * The only channel a sandboxed component has to the host. Exposed to the worker + * over `@quilted/threads`; every call is asynchronous across the boundary. + */ +export interface HostBridge { + /** JSON props for a `message` component; an empty object for `panel`. */ + getProps(): Promise>; + /** Composes and sends a chat message to Prime. */ + sendPrompt(text: string): Promise; + /** Host-mediated, allowlist-proxied network egress. */ + fetch(input: HostFetchInput, init?: HostRequestInit): Promise; + /** + * Reads a previously persisted value for `key` from this instance's + * key-value store, or `null` if absent. State survives page reloads and is + * scoped to the message instance; `panel` components have no store. + */ + getState(key: string): Promise; + /** Persists a JSON-serializable `value` under `key` for this instance. */ + setState(key: string, value: unknown): Promise; + /** Requests a host UI action (e.g. collapsing the component's message). */ + execUICommand(command: UICommand): Promise; +} diff --git a/packages/ui-extensions-sdk/tsconfig.author.json b/packages/ui-extensions-sdk/tsconfig.author.json new file mode 100644 index 0000000..eff89a3 --- /dev/null +++ b/packages/ui-extensions-sdk/tsconfig.author.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "@tangent/ui-extensions-sdk author", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true + } +} diff --git a/packages/ui-extensions-sdk/tsconfig.json b/packages/ui-extensions-sdk/tsconfig.json new file mode 100644 index 0000000..c466487 --- /dev/null +++ b/packages/ui-extensions-sdk/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@tangent/build/tsconfig/react.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", + "types": ["node", "react"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5c07b9..27d2329 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@tangent/shared': specifier: workspace:* version: link:../../packages/shared + '@tangent/ui-extensions-sdk': + specifier: workspace:* + version: link:../../packages/ui-extensions-sdk better-sqlite3: specifier: ^12.10.0 version: 12.10.0 @@ -136,6 +139,9 @@ importers: '@tangent/shared': specifier: workspace:* version: link:../../packages/shared + '@tangent/ui-extensions-sdk': + specifier: workspace:* + version: link:../../packages/ui-extensions-sdk '@tangent/ui-primitives': specifier: workspace:* version: link:../../packages/ui-primitives @@ -323,6 +329,49 @@ importers: specifier: ^6.0.3 version: 6.0.3 + packages/ui-extensions-sdk: + dependencies: + esbuild: + specifier: 0.28.0 + version: 0.28.0 + lucide-react: + specifier: ^1.17.0 + version: 1.17.0(react@19.2.6) + tsx: + specifier: ^4.22.3 + version: 4.22.3 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@tangent/build': + specifier: workspace:* + version: link:../build + '@tangent/ui-primitives': + specifier: workspace:* + version: link:../ui-primitives + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + '@types/react': + specifier: ^19.2.15 + version: 19.2.15 + eslint: + specifier: ^10.4.0 + version: 10.4.0(jiti@2.7.0) + prettier: + specifier: ^3.8.3 + version: 3.8.4 + react: + specifier: ^19.2.6 + version: 19.2.6 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/ui-primitives: dependencies: '@radix-ui/react-collapsible':