From 75772101770fdab7562efeadf399518eec0dc1c9 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 20 Oct 2026 14:26:08 +0200 Subject: [PATCH 1/6] feat(appkit): add dev-only design plugin and ui-only template - design() plugin (beta): in development serves a /design canvas that renders every PascalCase export under client/src/design/, and injects a click-to-select picker into the canvas and every app page. Picks are traced via the existing data-source transform plus the React fiber (component, owner stack, props), screenshotted, and persisted with an optional note to .appkit/design-selection.json (GET/POST/DELETE /api/design/selection). Outside development it mounts nothing and /design returns 404. - Plugins can contribute dev-server Vite plugins via an optional vitePlugins() hook. - appkit-ui/react/beta: DesignCanvas, DesignPicker, mountDesignPicker. - Template: gated client/src/design example, .appkit/ gitignored, and a ui-only app template (server + design). Co-authored-by: Isaac Signed-off-by: MarioCadenas --- docs/docs/api/appkit/Variable.design.md | 5 + docs/docs/api/appkit/index.md | 1 + docs/docs/api/appkit/typedoc-sidebar.ts | 5 + docs/docs/plugins/design.md | 84 ++++++ docs/static/appkit-ui/styles.gen.css | 18 ++ packages/appkit-ui/src/react/beta.ts | 6 + .../src/react/design/design-canvas.tsx | 74 ++++++ .../src/react/design/design-picker.tsx | 240 ++++++++++++++++++ .../appkit-ui/src/react/design/inspect.ts | 140 ++++++++++ .../src/plugins/beta-exports.generated.ts | 1 + packages/appkit/src/plugins/design/index.ts | 97 +++++++ .../appkit/src/plugins/design/manifest.json | 11 + packages/appkit/src/plugins/design/store.ts | 141 ++++++++++ .../src/plugins/design/tests/design.test.ts | 93 +++++++ .../appkit/src/plugins/design/vite-plugin.ts | 84 ++++++ packages/appkit/src/plugins/server/index.ts | 5 + .../src/plugins/server/vite-dev-server.ts | 4 +- packages/shared/src/plugin.ts | 3 + template/README.md.tmpl | 3 + template/_gitignore | 1 + template/appkit.plugins.json | 11 + template/client/src/design/Example.tsx | 22 ++ tools/generate-app-templates.ts | 6 + 23 files changed, 1054 insertions(+), 1 deletion(-) create mode 100644 docs/docs/api/appkit/Variable.design.md create mode 100644 docs/docs/plugins/design.md create mode 100644 packages/appkit-ui/src/react/design/design-canvas.tsx create mode 100644 packages/appkit-ui/src/react/design/design-picker.tsx create mode 100644 packages/appkit-ui/src/react/design/inspect.ts create mode 100644 packages/appkit/src/plugins/design/index.ts create mode 100644 packages/appkit/src/plugins/design/manifest.json create mode 100644 packages/appkit/src/plugins/design/store.ts create mode 100644 packages/appkit/src/plugins/design/tests/design.test.ts create mode 100644 packages/appkit/src/plugins/design/vite-plugin.ts create mode 100644 template/client/src/design/Example.tsx diff --git a/docs/docs/api/appkit/Variable.design.md b/docs/docs/api/appkit/Variable.design.md new file mode 100644 index 000000000..eed131b5d --- /dev/null +++ b/docs/docs/api/appkit/Variable.design.md @@ -0,0 +1,5 @@ +# Variable: design + +```ts +const design: ToPlugin; +``` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index ee6db8b67..fab63f199 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -190,6 +190,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | ------ | ------ | | [agents](Variable.agents.md) | Plugin factory for the agents plugin. Discovers agents from `server/agents//agent.{ts,md}` by default (markdown still in `config/agents/` is read as a deprecated fallback), resolves toolkits/tools from registered plugins, exposes the `appkit.agents.*` runtime API and mounts `POST /invocations` and `POST /responses` (aliased non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). | | [aiSearch](Variable.aiSearch.md) | - | +| [design](Variable.design.md) | - | | [READ\_ACTIONS](Variable.READ_ACTIONS.md) | Actions that only read data. | | [sql](Variable.sql.md) | SQL helper namespace | | [SUPERVISOR\_EXTENSION\_KEY](Variable.SUPERVISOR_EXTENSION_KEY.md) | Namespace key under which the adapter reads its hosted-tool payload from [AgentInput.extensions](Interface.AgentInput.md#extensions). Exported so the agents plugin and standalone `runAgent` (the producers) can write under the same key the adapter reads. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index dca9af23b..8f7b03f36 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -847,6 +847,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Variable.aiSearch", label: "aiSearch" }, + { + type: "doc", + id: "api/appkit/Variable.design", + label: "design" + }, { type: "doc", id: "api/appkit/Variable.READ_ACTIONS", diff --git a/docs/docs/plugins/design.md b/docs/docs/plugins/design.md new file mode 100644 index 000000000..0fabca338 --- /dev/null +++ b/docs/docs/plugins/design.md @@ -0,0 +1,84 @@ +--- +sidebar_position: 20 +--- + +# Design plugin + + +:::warning Beta plugin +This plugin is currently **beta**. APIs may change between minor releases. Import from `@databricks/appkit/beta`. See [Plugin Stability Tiers](./stability.md). +::: + + +A dev-only design canvas and element picker for working with a coding agent. It only runs with `NODE_ENV=development`. In any other environment it does nothing, and `/design` returns 404. + +## Basic usage + +```ts +import { createApp, server } from "@databricks/appkit"; +import { design } from "@databricks/appkit/beta"; + +await createApp({ + plugins: [server(), design()], +}); +``` + +## The `/design` canvas + +`/design` renders every PascalCase export of every file under `client/src/design/`, with no props, one frame each. Put two kinds of components there: + +- **Drafts**: design-only UI that may later be moved into the real app. +- **Previews**: wrappers that render the app's real components with mock data. + +```tsx +// client/src/design/OrderCard.preview.tsx +import { OrderCard } from "../components/OrderCard"; + +export function OrderCardShipped() { + return ; +} +``` + +A frame that throws shows its error in place. It doesn't break the rest of the canvas. + +## Selecting elements + +In development, a **Select** button appears in the bottom-right corner of the canvas and of every app page. Click it, then click elements to add or remove them (Escape stops selecting). Add an optional note and click **Send to agent**. + +Each element is traced back to its source through the `data-source="file:line:col"` attribute that the dev server adds to native JSX elements. The nearest React component and its props come from React's fiber tree. + +## Handing selections to the agent + +Each send is appended to `.appkit/design-selection.json`, so selections survive restarts. Add `.appkit/` to `.gitignore`. + +```json +{ + "selections": [ + { + "id": "80b453fd-…", + "ts": "2026-10-20T12:22:45.026Z", + "note": "make the button primary", + "url": "/design", + "elements": [ + { + "component": "Button", + "stack": ["Button", "ProbeCard"], + "source": "client/src/design/Probe.tsx:5:4", + "props": { "variant": "secondary" }, + "tag": "button", + "text": "Probe button", + "screenshot": ".appkit/design-screenshots/80b453fd-…-0.jpeg" + } + ] + } + ] +} +``` + +Screenshots are saved as image files next to it, so an agent can open them directly. The agent can read the file itself, or use these routes: + +| Method | Path | Description | +| ------ | ---- | ----------- | +| `GET` | `/api/design/selection` | Read all pending selections | +| `POST` | `/api/design/selection` | Add a selection (used by the picker) | +| `DELETE` | `/api/design/selection` | Clear selections and screenshots once applied | diff --git a/docs/static/appkit-ui/styles.gen.css b/docs/static/appkit-ui/styles.gen.css index 0cc301c72..145f8db68 100644 --- a/docs/static/appkit-ui/styles.gen.css +++ b/docs/static/appkit-ui/styles.gen.css @@ -467,6 +467,9 @@ .mb-4 { margin-bottom: calc(var(--spacing) * 4); } + .mb-6 { + margin-bottom: calc(var(--spacing) * 6); + } .-ml-1 { margin-left: calc(var(--spacing) * -1); } @@ -711,6 +714,9 @@ .min-h-\[100vh\] { min-height: 100vh; } + .min-h-screen { + min-height: 100vh; + } .min-h-svh { min-height: 100svh; } @@ -1011,6 +1017,9 @@ .touch-none { touch-action: none; } + .resize { + resize: both; + } .resize-none { resize: none; } @@ -1119,6 +1128,9 @@ .gap-7 { gap: calc(var(--spacing) * 7); } + .gap-8 { + gap: calc(var(--spacing) * 8); + } .gap-\[--spacing\(var\(--gap\)\)\] { gap: calc(var(--spacing) * var(--gap)); } @@ -1374,6 +1386,12 @@ background-color: color-mix(in oklab, var(--muted) 30%, transparent); } } + .bg-muted\/40 { + background-color: var(--muted); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--muted) 40%, transparent); + } + } .bg-muted\/50 { background-color: var(--muted); @supports (color: color-mix(in lab, red, red)) { diff --git a/packages/appkit-ui/src/react/beta.ts b/packages/appkit-ui/src/react/beta.ts index 5b1053b14..762a0c7b1 100644 --- a/packages/appkit-ui/src/react/beta.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -75,3 +75,9 @@ export type { UseDatabaseWriteOptions, UseDatabaseWriteState, } from "./hooks/use-database-write"; + +// Design canvas + element picker. Track the dev-only `design` plugin, which +// ships at beta from '@databricks/appkit/beta' and mounts both for you. +export { DesignCanvas, type DesignCanvasProps } from "./design/design-canvas"; +export { DesignPicker, mountDesignPicker } from "./design/design-picker"; +export type { DesignElementInfo } from "./design/inspect"; diff --git a/packages/appkit-ui/src/react/design/design-canvas.tsx b/packages/appkit-ui/src/react/design/design-canvas.tsx new file mode 100644 index 000000000..1b89a49f5 --- /dev/null +++ b/packages/appkit-ui/src/react/design/design-canvas.tsx @@ -0,0 +1,74 @@ +import { Component, type ComponentType, type ReactNode } from "react"; + +/** Props for {@link DesignCanvas}. */ +export interface DesignCanvasProps { + /** `import.meta.glob` result: module path → module exports. */ + modules: Record>; +} + +class Boundary extends Component<{ children: ReactNode }, { error?: Error }> { + state: { error?: Error } = {}; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + if (this.state.error) { + return ( +
+          {this.state.error.message}
+        
+ ); + } + return this.props.children; + } +} + +/** + * Renders every PascalCase export of every module under `client/src/design/` + * with no props, one frame each. Drafts and previews (real components wrapped + * with mock data) are both just zero-prop components in that folder. + * + * Served by the `design()` plugin at `/design` in development. + */ +export function DesignCanvas({ modules }: DesignCanvasProps) { + const frames = Object.entries(modules) + .sort(([a], [b]) => a.localeCompare(b)) + .flatMap(([file, mod]) => + Object.entries(mod) + .filter( + ([name, value]) => /^[A-Z]/.test(name) && typeof value === "function", + ) + .map(([name, value]) => ({ + id: `${file}#${name}`, + label: `${file.replace(/^\/src\/design\//, "")} · ${name}`, + Frame: value as ComponentType, + })), + ); + + return ( +
+

Design canvas

+ {frames.length === 0 ? ( +

+ No designs yet. Export components from files in{" "} + client/src/design/ and they show up here. +

+ ) : ( +
+ {frames.map(({ id, label, Frame }) => ( +
+

+ {label} +

+
+ + + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/packages/appkit-ui/src/react/design/design-picker.tsx b/packages/appkit-ui/src/react/design/design-picker.tsx new file mode 100644 index 000000000..027f3ae27 --- /dev/null +++ b/packages/appkit-ui/src/react/design/design-picker.tsx @@ -0,0 +1,240 @@ +import { type CSSProperties, useEffect, useReducer, useState } from "react"; +import { createRoot } from "react-dom/client"; + +import { + captureElement, + type DesignElementInfo, + inspectElement, +} from "./inspect"; + +/** Endpoint the `design()` plugin mounts in development. */ +const SELECTION_ENDPOINT = "/api/design/selection"; +const ROOT_ID = "appkit-design-picker"; +/** Same violet as the `` picker, so dev chrome reads as dev chrome. */ +const ACCENT = "#8b5cf6"; + +type Status = "idle" | "sending" | "sent" | "error"; + +interface Picked { + el: Element; + info: DesignElementInfo; +} + +function box(el: Element, style: CSSProperties, key?: number) { + const r = el.getBoundingClientRect(); + return ( +
+ ); +} + +const panel: CSSProperties = { + position: "fixed", + right: 16, + bottom: 64, + width: 320, + maxHeight: "60vh", + overflow: "auto", + background: "#fff", + color: "#111", + border: `1px solid ${ACCENT}`, + borderRadius: 8, + padding: 12, + font: "12px/1.4 system-ui, sans-serif", + boxShadow: "0 8px 24px rgba(0,0,0,.18)", +}; + +const button: CSSProperties = { + background: ACCENT, + color: "#fff", + border: 0, + borderRadius: 6, + padding: "6px 12px", + font: "600 12px system-ui, sans-serif", + cursor: "pointer", +}; + +/** + * Dev-only click-to-select overlay. Toggle it with the floating button (or + * Escape to stop), click elements to add/remove them, add a note, and send the + * selection to the `design()` plugin for the coding agent to pick up. + * + * Injected into every page by the design plugin's dev server; you don't + * render it yourself. + */ +export function DesignPicker() { + const [active, setActive] = useState(false); + const [hovered, setHovered] = useState(null); + const [picked, setPicked] = useState([]); + const [note, setNote] = useState(""); + const [status, setStatus] = useState("idle"); + // Outlines are positioned from live rects; re-render when the page moves. + const [, rerender] = useReducer((n: number) => n + 1, 0); + + useEffect(() => { + window.addEventListener("scroll", rerender, true); + window.addEventListener("resize", rerender); + return () => { + window.removeEventListener("scroll", rerender, true); + window.removeEventListener("resize", rerender); + }; + }, []); + + useEffect(() => { + if (!active) return; + const own = (t: EventTarget | null) => + !(t instanceof Element) || !!t.closest(`#${ROOT_ID}`); + const onMove = (e: MouseEvent) => + setHovered(own(e.target) ? null : (e.target as Element)); + const onClick = (e: MouseEvent) => { + if (own(e.target)) return; + e.preventDefault(); + e.stopPropagation(); + const el = e.target as Element; + setStatus("idle"); + setPicked((prev) => + prev.some((p) => p.el === el) + ? prev.filter((p) => p.el !== el) + : [...prev, { el, info: inspectElement(el) }], + ); + }; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setActive(false); + document.addEventListener("mousemove", onMove, true); + document.addEventListener("click", onClick, true); + document.addEventListener("keydown", onKey, true); + return () => { + document.removeEventListener("mousemove", onMove, true); + document.removeEventListener("click", onClick, true); + document.removeEventListener("keydown", onKey, true); + setHovered(null); + }; + }, [active]); + + async function send() { + setStatus("sending"); + try { + const elements = await Promise.all( + picked.map(async ({ el, info }) => ({ + ...info, + screenshot: await captureElement(el), + })), + ); + const res = await fetch(SELECTION_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + elements, + note: note.trim() || undefined, + url: location.pathname, + }), + }); + if (!res.ok) throw new Error(String(res.status)); + setPicked([]); + setNote(""); + setActive(false); + setStatus("sent"); + } catch { + setStatus("error"); + } + } + + return ( + <> + {active && hovered && box(hovered, { outline: `2px dashed ${ACCENT}` })} + {picked.map((p, i) => + box( + p.el, + { outline: `2px solid ${ACCENT}`, background: `${ACCENT}22` }, + i, + ), + )} + {(picked.length > 0 || status !== "idle") && ( +
+ {picked.map((p, i) => ( +
+
+ {p.info.component ?? `<${p.info.tag}>`} +
+ {p.info.source ?? "no source location"} +
+
+ +
+ ))} + {picked.length > 0 && ( + <> +