diff --git a/apps/dev-playground/client/src/lib/nav.ts b/apps/dev-playground/client/src/lib/nav.ts index 4b391cb71..00f70dfec 100644 --- a/apps/dev-playground/client/src/lib/nav.ts +++ b/apps/dev-playground/client/src/lib/nav.ts @@ -13,6 +13,7 @@ import { SearchIcon, ServerIcon, ShieldIcon, + Wand2Icon, ZapIcon, } from "lucide-react"; @@ -162,6 +163,13 @@ export const NAV_GROUPS: ReadonlyArray = [ "Resilient SSE streams: automatic Last-Event-ID tracking and reconnection.", icon: RadioIcon, }, + { + to: "/ui-variants", + label: "UI Variants", + description: + "Author UI in variants, pick one live in the browser, and let the agent finalize it into source.", + icon: Wand2Icon, + }, ], }, ]; diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index 8b953ed98..450287592 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as VectorSearchRouteRouteImport } from './routes/vector-search.route' +import { Route as UiVariantsRouteRouteImport } from './routes/ui-variants.route' import { Route as TypeSafetyRouteRouteImport } from './routes/type-safety.route' import { Route as TelemetryRouteRouteImport } from './routes/telemetry.route' import { Route as SqlHelpersRouteRouteImport } from './routes/sql-helpers.route' @@ -33,6 +34,11 @@ const VectorSearchRouteRoute = VectorSearchRouteRouteImport.update({ path: '/vector-search', getParentRoute: () => rootRouteImport, } as any) +const UiVariantsRouteRoute = UiVariantsRouteRouteImport.update({ + id: '/ui-variants', + path: '/ui-variants', + getParentRoute: () => rootRouteImport, +} as any) const TypeSafetyRouteRoute = TypeSafetyRouteRouteImport.update({ id: '/type-safety', path: '/type-safety', @@ -137,6 +143,7 @@ export interface FileRoutesByFullPath { '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute + '/ui-variants': typeof UiVariantsRouteRoute '/vector-search': typeof VectorSearchRouteRoute } export interface FileRoutesByTo { @@ -157,6 +164,7 @@ export interface FileRoutesByTo { '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute + '/ui-variants': typeof UiVariantsRouteRoute '/vector-search': typeof VectorSearchRouteRoute } export interface FileRoutesById { @@ -178,6 +186,7 @@ export interface FileRoutesById { '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute + '/ui-variants': typeof UiVariantsRouteRoute '/vector-search': typeof VectorSearchRouteRoute } export interface FileRouteTypes { @@ -200,6 +209,7 @@ export interface FileRouteTypes { | '/sql-helpers' | '/telemetry' | '/type-safety' + | '/ui-variants' | '/vector-search' fileRoutesByTo: FileRoutesByTo to: @@ -220,6 +230,7 @@ export interface FileRouteTypes { | '/sql-helpers' | '/telemetry' | '/type-safety' + | '/ui-variants' | '/vector-search' id: | '__root__' @@ -240,6 +251,7 @@ export interface FileRouteTypes { | '/sql-helpers' | '/telemetry' | '/type-safety' + | '/ui-variants' | '/vector-search' fileRoutesById: FileRoutesById } @@ -261,6 +273,7 @@ export interface RootRouteChildren { SqlHelpersRouteRoute: typeof SqlHelpersRouteRoute TelemetryRouteRoute: typeof TelemetryRouteRoute TypeSafetyRouteRoute: typeof TypeSafetyRouteRoute + UiVariantsRouteRoute: typeof UiVariantsRouteRoute VectorSearchRouteRoute: typeof VectorSearchRouteRoute } @@ -273,6 +286,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof VectorSearchRouteRouteImport parentRoute: typeof rootRouteImport } + '/ui-variants': { + id: '/ui-variants' + path: '/ui-variants' + fullPath: '/ui-variants' + preLoaderRoute: typeof UiVariantsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/type-safety': { id: '/type-safety' path: '/type-safety' @@ -413,6 +433,7 @@ const rootRouteChildren: RootRouteChildren = { SqlHelpersRouteRoute: SqlHelpersRouteRoute, TelemetryRouteRoute: TelemetryRouteRoute, TypeSafetyRouteRoute: TypeSafetyRouteRoute, + UiVariantsRouteRoute: UiVariantsRouteRoute, VectorSearchRouteRoute: VectorSearchRouteRoute, } export const routeTree = rootRouteImport diff --git a/apps/dev-playground/client/src/routes/ui-variants.route.tsx b/apps/dev-playground/client/src/routes/ui-variants.route.tsx new file mode 100644 index 000000000..5b8af0fdb --- /dev/null +++ b/apps/dev-playground/client/src/routes/ui-variants.route.tsx @@ -0,0 +1,260 @@ +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Separator, + Variant, + Variants, +} from "@databricks/appkit-ui/react"; +import { createFileRoute } from "@tanstack/react-router"; +import { + ArrowRight, + Boxes, + Gauge, + Github, + Radio, + Rocket, + ShieldCheck, + Sparkles, +} from "lucide-react"; + +export const Route = createFileRoute("/ui-variants")({ + component: UiVariantsRoute, +}); + +/** + * Demo of the `` picker: a page composed of two independent + * `` blocks: + * - `about-hero` — the top hero section (3 treatments) + * - `about-info` — the informational section underneath (3 treatments) + * + * Hover either framed block in the browser to reveal the switcher, flip + * between the variants, and Confirm the one you want. The `/ui` skill then + * finalizes each chosen variant into source, dropping the wrappers. + */ +function UiVariantsRoute() { + return ( +
+
+ + +
+ + About AppKit + +

+ Build Databricks apps, not plumbing +

+

+ AppKit is a modular TypeScript SDK with a plugin architecture, + first-class streaming, and built-in observability. +

+
+ + +
+
+
+ + +
+
+ + About AppKit + +

+ The SDK for Databricks applications +

+

+ A plugin-first toolkit that handles the hard parts — auth, + streaming, telemetry — so your team ships features faster. +

+
+ +
+
+
+ {[ + { value: "20+", label: "Built-in plugins" }, + { value: "100%", label: "TypeScript" }, + { value: "SSE", label: "Streaming-first" }, + { value: "OTEL", label: "Observability" }, + ].map((stat) => ( +
+
+ {stat.value} +
+
+ {stat.label} +
+
+ ))} +
+
+
+ + +
+

+ About AppKit +

+

+ A modular TypeScript SDK for building Databricks applications + with a plugin-based architecture, streaming, and observability + baked in. +

+
+
+
+ +
+ + + +
+ {[ + { + icon: Boxes, + title: "Plugin-first", + body: "Everything is a plugin — compose exactly the capabilities your app needs.", + }, + { + icon: Radio, + title: "Streaming-first", + body: "Built-in SSE with automatic reconnection and event replay.", + }, + { + icon: Gauge, + title: "Observable", + body: "OpenTelemetry traces, metrics, and logs wired in from day one.", + }, + { + icon: ShieldCheck, + title: "Type-safe", + body: "Heavy TypeScript with runtime validation via Zod.", + }, + { + icon: Sparkles, + title: "Great DX", + body: "HMR, hot-reload, source maps, and inspection tools out of the box.", + }, + { + icon: Rocket, + title: "Production-ready", + body: "Graceful shutdown, retries, timeouts, and caching interceptors.", + }, + ].map((feature) => ( + + + + {feature.title} + + +

+ {feature.body} +

+
+
+ ))} +
+
+ + +
+
+

+ Why AppKit? +

+

+ Designed for teams building data and AI apps on Databricks. +

+
+
+
+

+ Modular by design +

+

+ A plugin architecture lets you add analytics, agents, + Lakebase, and more without rewiring your app. +

+
+ +
+

+ Built for streaming +

+

+ Server-Sent Events with reconnection and per-stream + cancellation come standard. +

+
+ +
+

+ Observable and safe +

+

+ OpenTelemetry and Zod validation give you confidence in + production. +

+
+
+
+
+ + + + + What AppKit gives you + + The essentials for shipping Databricks apps quickly. + + + +
+ {[ + { + value: "Plugins", + body: "Compose analytics, agents, files, and more.", + }, + { + value: "Streaming", + body: "SSE with reconnection built in.", + }, + { + value: "Telemetry", + body: "OpenTelemetry traces and metrics.", + }, + ].map((item) => ( +
+
+ {item.value} +
+

+ {item.body} +

+
+ ))} +
+
+
+
+
+
+
+ ); +} diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index ecbd18e78..53d7ff9dd 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -9,6 +9,7 @@ import { PolicyDeniedError, server, serving, + uiVariants, WRITE_ACTIONS, } from "@databricks/appkit"; import { agents, createAgent, tool } from "@databricks/appkit/beta"; @@ -343,6 +344,7 @@ createApp({ reconnect(), telemetryExamples(), analytics({}), + uiVariants(), genie({ spaces: { demo: process.env.DATABRICKS_GENIE_SPACE_ID ?? "placeholder" }, }), diff --git a/docs/docs/api/appkit/Class.FileChoiceSink.md b/docs/docs/api/appkit/Class.FileChoiceSink.md new file mode 100644 index 000000000..995a3ec53 --- /dev/null +++ b/docs/docs/api/appkit/Class.FileChoiceSink.md @@ -0,0 +1,55 @@ +# Class: FileChoiceSink + +Default [ChoiceSink](Interface.ChoiceSink.md): upserts choices into UI\_CHOICES\_FILE, +one line per `` id. + +The file is resolved against `process.cwd()`, so it lands under whatever +directory the dev server runs from. Concurrent confirms are serialized behind +an internal queue so their read-modify-write can't interleave and lose an +update. + +## Implements + +- [`ChoiceSink`](Interface.ChoiceSink.md) + +## Constructors + +### Constructor + +```ts +new FileChoiceSink(relativePath: string): FileChoiceSink; +``` + +#### Parameters + +| Parameter | Type | Default value | +| ------ | ------ | ------ | +| `relativePath` | `string` | `UI_CHOICES_FILE` | + +#### Returns + +`FileChoiceSink` + +## Methods + +### record() + +```ts +record(record: UiChoiceRecord): Promise; +``` + +Record (upsert) a choice, keyed by `record.blockId`. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `record` | [`UiChoiceRecord`](Interface.UiChoiceRecord.md) | + +#### Returns + +`Promise`\<`void`\> + +#### Implementation of + +[`ChoiceSink`](Interface.ChoiceSink.md).[`record`](Interface.ChoiceSink.md#record) diff --git a/docs/docs/api/appkit/Interface.ChoiceSink.md b/docs/docs/api/appkit/Interface.ChoiceSink.md new file mode 100644 index 000000000..f5cd980b8 --- /dev/null +++ b/docs/docs/api/appkit/Interface.ChoiceSink.md @@ -0,0 +1,31 @@ +# Interface: ChoiceSink + +Storage backend for confirmed variant choices, decoupled from the recorder +plugin so the destination can vary by environment. The default +[FileChoiceSink](Class.FileChoiceSink.md) writes a local JSONL file, suitable wherever the +coding agent shares the app's filesystem; an environment without a shared +filesystem can supply its own implementation (e.g. a table-backed store). + +Implementations must be **keyed and latest-wins**: at most one record per +`blockId`, and recording an existing `blockId` replaces it rather than +appending, so the store always reflects the current choice for each block. + +## Methods + +### record() + +```ts +record(record: UiChoiceRecord): Promise; +``` + +Record (upsert) a choice, keyed by `record.blockId`. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `record` | [`UiChoiceRecord`](Interface.UiChoiceRecord.md) | + +#### Returns + +`Promise`\<`void`\> diff --git a/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md index 041dd8ca2..d0db2c533 100644 --- a/docs/docs/api/appkit/Interface.PluginManifest.md +++ b/docs/docs/api/appkit/Interface.PluginManifest.md @@ -72,6 +72,22 @@ Omit.description *** +### devOnly? + +```ts +optional devOnly: boolean; +``` + +When true, this plugin is only registered when NODE_ENV === "development". In any other environment createApp skips it entirely (not constructed, no routes, resources not validated). Use for dev-only tooling that must never run in a deployed app. + +#### Inherited from + +```ts +Omit.devOnly +``` + +*** + ### displayName ```ts diff --git a/docs/docs/api/appkit/Interface.UiChoiceRecord.md b/docs/docs/api/appkit/Interface.UiChoiceRecord.md new file mode 100644 index 000000000..c8b48bfa2 --- /dev/null +++ b/docs/docs/api/appkit/Interface.UiChoiceRecord.md @@ -0,0 +1,59 @@ +# Interface: UiChoiceRecord + +One recorded variant choice. + +CONTRACT: the `databricks-app-variants` agent skill parses these fields +(`blockId`, `chosenIndex`, `label`) to finalize the chosen variant. Renaming +or removing a field silently breaks finalization (the skill reads it from the +JSONL line, so there's no compile error) — update the skill in the same +change. + +## Properties + +### blockId + +```ts +blockId: string; +``` + +Stable id of the `` block the developer confirmed. + +*** + +### chosenIndex + +```ts +chosenIndex: number; +``` + +Zero-based index of the chosen `` child. + +*** + +### label? + +```ts +optional label: string; +``` + +Human-readable label of the chosen variant (for agent context). + +*** + +### note? + +```ts +optional note: string; +``` + +Optional free-form note from the developer. + +*** + +### ts + +```ts +ts: string; +``` + +ISO timestamp of when the choice was recorded. diff --git a/docs/docs/api/appkit/Variable.uiVariants.md b/docs/docs/api/appkit/Variable.uiVariants.md new file mode 100644 index 000000000..5f095dcde --- /dev/null +++ b/docs/docs/api/appkit/Variable.uiVariants.md @@ -0,0 +1,5 @@ +# Variable: uiVariants + +```ts +const uiVariants: ToPlugin; +``` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 24caa020e..a05af8ad5 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -21,6 +21,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [ConnectionError](Class.ConnectionError.md) | Error thrown when a connection or network operation fails. Use for database pool errors, API failures, timeouts, etc. | | [DatabricksAdapter](Class.DatabricksAdapter.md) | Adapter that talks directly to Databricks Model Serving `/invocations` endpoint. | | [ExecutionError](Class.ExecutionError.md) | Error thrown when an operation execution fails. Use for statement failures, canceled operations, or unexpected states. | +| [FileChoiceSink](Class.FileChoiceSink.md) | Default [ChoiceSink](Interface.ChoiceSink.md): upserts choices into UI\_CHOICES\_FILE, one line per `` id. | | [InitializationError](Class.InitializationError.md) | Error thrown when a service or component is not properly initialized. Use when accessing services before they are ready. | | [Plugin](Class.Plugin.md) | Base abstract class for creating AppKit plugins. | | [PolicyDeniedError](Class.PolicyDeniedError.md) | Thrown when a policy denies an action. | @@ -42,6 +43,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [AutoInheritToolsConfig](Interface.AutoInheritToolsConfig.md) | Auto-inherit configuration. When enabled for a given agent origin, agents with no explicit `tools:` declaration receive every registered ToolProvider plugin tool whose author marked `autoInheritable: true`. Tools without that flag — destructive, state-mutating, or privilege-sensitive — never spread automatically and must be wired via `tools:` (object or function form in code, `plugin:NAME` entries in markdown frontmatter). | | [BasePluginConfig](Interface.BasePluginConfig.md) | Base configuration interface for AppKit plugins | | [CacheConfig](Interface.CacheConfig.md) | Configuration for the CacheInterceptor. Controls TTL, size limits, storage backend, and probabilistic cleanup. | +| [ChoiceSink](Interface.ChoiceSink.md) | Storage backend for confirmed variant choices, decoupled from the recorder plugin so the destination can vary by environment. The default [FileChoiceSink](Class.FileChoiceSink.md) writes a local JSONL file, suitable wherever the coding agent shares the app's filesystem; an environment without a shared filesystem can supply its own implementation (e.g. a table-backed store). | | [DatabaseCredential](Interface.DatabaseCredential.md) | Database credentials with OAuth token for Postgres connection | | [EndpointConfig](Interface.EndpointConfig.md) | - | | [FilePolicyUser](Interface.FilePolicyUser.md) | Minimal user identity passed to the policy function. | @@ -81,6 +83,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [ToolkitEntry](Interface.ToolkitEntry.md) | A tool reference produced by a plugin's `.toolkit()` call. The agents plugin recognizes the `__toolkitRef` brand and dispatches tool invocations through `PluginContext.executeTool(req, pluginName, localName, ...)`, preserving OBO (asUser) and telemetry spans. | | [ToolkitOptions](Interface.ToolkitOptions.md) | - | | [ToolProvider](Interface.ToolProvider.md) | - | +| [UiChoiceRecord](Interface.UiChoiceRecord.md) | One recorded variant choice. | | [ValidationResult](Interface.ValidationResult.md) | Result of validating all registered resources against the environment. | ## Type Aliases @@ -116,6 +119,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [agents](Variable.agents.md) | Plugin factory for the agents plugin. Reads `config/agents/*.md` by default, resolves toolkits/tools from registered plugins, exposes `appkit.agents.*` runtime API and mounts `POST /invocations` and `POST /responses` (aliased non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). | | [READ\_ACTIONS](Variable.READ_ACTIONS.md) | Actions that only read data. | | [sql](Variable.sql.md) | SQL helper namespace | +| [uiVariants](Variable.uiVariants.md) | - | | [WRITE\_ACTIONS](Variable.WRITE_ACTIONS.md) | Actions that mutate data. | ## Functions diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 4bd5fa9fb..a81793310 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -56,6 +56,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Class.ExecutionError", label: "ExecutionError" }, + { + type: "doc", + id: "api/appkit/Class.FileChoiceSink", + label: "FileChoiceSink" + }, { type: "doc", id: "api/appkit/Class.InitializationError", @@ -142,6 +147,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.CacheConfig", label: "CacheConfig" }, + { + type: "doc", + id: "api/appkit/Interface.ChoiceSink", + label: "ChoiceSink" + }, { type: "doc", id: "api/appkit/Interface.DatabaseCredential", @@ -337,6 +347,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.ToolProvider", label: "ToolProvider" }, + { + type: "doc", + id: "api/appkit/Interface.UiChoiceRecord", + label: "UiChoiceRecord" + }, { type: "doc", id: "api/appkit/Interface.ValidationResult", @@ -474,6 +489,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Variable.sql", label: "sql" }, + { + type: "doc", + id: "api/appkit/Variable.uiVariants", + label: "uiVariants" + }, { type: "doc", id: "api/appkit/Variable.WRITE_ACTIONS", diff --git a/docs/docs/plugins/manifest.md b/docs/docs/plugins/manifest.md index 60f54b13c..33f941ff1 100644 --- a/docs/docs/plugins/manifest.md +++ b/docs/docs/plugins/manifest.md @@ -278,6 +278,7 @@ The rules block is propagated unchanged from the plugin manifest into the synced | `license` | SPDX identifier. | | `onSetupMessage` | One-shot message displayed after init. Use for short hints; prefer `scaffolding.rules` for actionable directives an agent must enforce. | | `hidden` | When `true`, the plugin is excluded from the synced template manifest. | +| `devOnly` | When `true`, `createApp` only registers the plugin when `NODE_ENV === "development"`; in any other environment it is skipped entirely (not constructed, no routes, resources not validated). Use for dev-only tooling that must never run in a deployed app. | | `stability` | `"beta"` or `"ga"`. Beta plugins may break across minor releases — see [Plugin stability tiers](./stability.md). | | `config.schema` | JSON Schema for the plugin's runtime config (used by the type generator and for validation). | diff --git a/docs/static/appkit-ui/styles.gen.css b/docs/static/appkit-ui/styles.gen.css index 0d24e8ea3..58ecf8142 100644 --- a/docs/static/appkit-ui/styles.gen.css +++ b/docs/static/appkit-ui/styles.gen.css @@ -49,6 +49,7 @@ --radius-xl: 0.75rem; --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --animate-spin: spin 1s linear infinite; + --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; --aspect-video: 16 / 9; --default-transition-duration: 150ms; @@ -259,6 +260,12 @@ .inset-y-0 { inset-block: calc(var(--spacing) * 0); } + .-top-1 { + top: calc(var(--spacing) * -1); + } + .-top-3 { + top: calc(var(--spacing) * -3); + } .-top-12 { top: calc(var(--spacing) * -12); } @@ -289,6 +296,9 @@ .top-full { top: 100%; } + .-right-1 { + right: calc(var(--spacing) * -1); + } .-right-12 { right: calc(var(--spacing) * -12); } @@ -499,6 +509,9 @@ .inline { display: inline; } + .inline-block { + display: inline-block; + } .inline-flex { display: inline-flex; } @@ -908,6 +921,9 @@ .origin-\(--radix-tooltip-content-transform-origin\) { transform-origin: var(--radix-tooltip-content-transform-origin); } + .origin-top-right { + transform-origin: 100% 0; + } .-translate-x-1\/2 { --tw-translate-x: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -944,6 +960,12 @@ --tw-translate-y: calc(-50% - 2px); translate: var(--tw-translate-x) var(--tw-translate-y); } + .scale-90 { + --tw-scale-x: 90%; + --tw-scale-y: 90%; + --tw-scale-z: 90%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } .rotate-45 { rotate: 45deg; } @@ -959,6 +981,9 @@ .animate-in { animation: enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none); } + .animate-ping { + animation: var(--animate-ping); + } .animate-pulse { animation: var(--animate-pulse); } @@ -1244,6 +1269,12 @@ --tw-border-style: dashed; border-style: dashed; } + .border-\[color-mix\(in_srgb\,var\(--variants-accent\)_40\%\,transparent\)\] { + border-color: var(--variants-accent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in srgb,var(--variants-accent) 40%,transparent); + } + } .border-border { border-color: var(--border); } @@ -1271,12 +1302,27 @@ .border-l-transparent { border-left-color: transparent; } + .bg-\[color-mix\(in_srgb\,var\(--variants-accent\)_15\%\,transparent\)\] { + background-color: var(--variants-accent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in srgb,var(--variants-accent) 15%,transparent); + } + } + .bg-\[color\:var\(--variants-accent\)\] { + background-color: var(--variants-accent); + } .bg-accent { background-color: var(--accent); } .bg-background { background-color: var(--background); } + .bg-background\/95 { + background-color: var(--background); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--background) 95%, transparent); + } + } .bg-black\/50 { background-color: color-mix(in srgb, #000 50%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -1307,6 +1353,12 @@ .bg-muted { background-color: var(--muted); } + .bg-muted-foreground\/40 { + background-color: var(--muted-foreground); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--muted-foreground) 40%, transparent); + } + } .bg-muted\/30 { background-color: var(--muted); @supports (color: color-mix(in lab, red, red)) { @@ -1567,6 +1619,9 @@ .text-\[0\.70rem\] { font-size: 0.70rem; } + .text-\[10px\] { + font-size: 10px; + } .text-\[11px\] { font-size: 11px; } @@ -1629,6 +1684,9 @@ .whitespace-pre-wrap { white-space: pre-wrap; } + .text-\[color\:var\(--variants-accent\)\] { + color: var(--variants-accent); + } .text-accent-foreground { color: var(--accent-foreground); } @@ -1753,6 +1811,16 @@ --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .ring-2 { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-\[color\:var\(--variants-accent\)\] { + --tw-ring-color: var(--variants-accent); + } + .ring-background { + --tw-ring-color: var(--background); + } .ring-ring\/50 { --tw-ring-color: var(--ring); @supports (color: color-mix(in lab, red, red)) { @@ -1784,11 +1852,21 @@ .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } + .backdrop-blur { + --tw-backdrop-blur: blur(8px); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } .transition { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .transition-\[box-shadow\] { + transition-property: box-shadow; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } .transition-\[color\,box-shadow\] { transition-property: color,box-shadow; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); @@ -1846,6 +1924,10 @@ --tw-duration: 100ms; transition-duration: 100ms; } + .duration-150 { + --tw-duration: 150ms; + transition-duration: 150ms; + } .duration-200 { --tw-duration: 200ms; transition-duration: 200ms; @@ -1885,14 +1967,83 @@ .\[--cell-size\:--spacing\(8\)\] { --cell-size: calc(var(--spacing) * 8); } + .\[animation-duration\:2\.5s\] { + animation-duration: 2.5s; + } .running { animation-play-state: running; } + .group-focus-within\:pointer-events-auto { + &:is(:where(.group):focus-within *) { + pointer-events: auto; + } + } + .group-focus-within\:pointer-events-none { + &:is(:where(.group):focus-within *) { + pointer-events: none; + } + } + .group-focus-within\:scale-100 { + &:is(:where(.group):focus-within *) { + --tw-scale-x: 100%; + --tw-scale-y: 100%; + --tw-scale-z: 100%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + } + .group-focus-within\:opacity-0 { + &:is(:where(.group):focus-within *) { + opacity: 0%; + } + } + .group-focus-within\:opacity-100 { + &:is(:where(.group):focus-within *) { + opacity: 100%; + } + } .group-focus-within\/menu-item\:opacity-100 { &:is(:where(.group\/menu-item):focus-within *) { opacity: 100%; } } + .group-hover\:pointer-events-auto { + &:is(:where(.group):hover *) { + @media (hover: hover) { + pointer-events: auto; + } + } + } + .group-hover\:pointer-events-none { + &:is(:where(.group):hover *) { + @media (hover: hover) { + pointer-events: none; + } + } + } + .group-hover\:scale-100 { + &:is(:where(.group):hover *) { + @media (hover: hover) { + --tw-scale-x: 100%; + --tw-scale-y: 100%; + --tw-scale-z: 100%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + } + } + .group-hover\:opacity-0 { + &:is(:where(.group):hover *) { + @media (hover: hover) { + opacity: 0%; + } + } + } + .group-hover\:opacity-100 { + &:is(:where(.group):hover *) { + @media (hover: hover) { + opacity: 100%; + } + } + } .group-hover\/menu-item\:opacity-100 { &:is(:where(.group\/menu-item):hover *) { @media (hover: hover) { @@ -2365,6 +2516,12 @@ padding-bottom: calc(var(--spacing) * 0); } } + .focus-within\:ring-1 { + &:focus-within { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } .hover\:cursor-pointer { &:hover { @media (hover: hover) { @@ -2372,6 +2529,16 @@ } } } + .hover\:bg-\[color-mix\(in_srgb\,var\(--variants-accent\)_12\%\,transparent\)\] { + &:hover { + @media (hover: hover) { + background-color: var(--variants-accent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in srgb,var(--variants-accent) 12%,transparent); + } + } + } + } .hover\:bg-accent { &:hover { @media (hover: hover) { @@ -2379,6 +2546,16 @@ } } } + .hover\:bg-destructive\/10 { + &:hover { + @media (hover: hover) { + background-color: var(--destructive); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--destructive) 10%, transparent); + } + } + } + } .hover\:bg-destructive\/90 { &:hover { @media (hover: hover) { @@ -2433,6 +2610,13 @@ } } } + .hover\:text-\[color\:var\(--variants-accent\)\] { + &:hover { + @media (hover: hover) { + color: var(--variants-accent); + } + } + } .hover\:text-accent-foreground { &:hover { @media (hover: hover) { @@ -2440,6 +2624,13 @@ } } } + .hover\:text-destructive { + &:hover { + @media (hover: hover) { + color: var(--destructive); + } + } + } .hover\:text-foreground { &:hover { @media (hover: hover) { @@ -2483,6 +2674,14 @@ } } } + .hover\:ring-1 { + &:hover { + @media (hover: hover) { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + } .hover\:ring-4 { &:hover { @media (hover: hover) { @@ -2678,6 +2877,11 @@ cursor: not-allowed; } } + .disabled\:opacity-30 { + &:disabled { + opacity: 30%; + } + } .disabled\:opacity-50 { &:disabled { opacity: 50%; @@ -5607,6 +5811,21 @@ inherits: false; initial-value: 0; } +@property --tw-scale-x { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-scale-y { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-scale-z { + syntax: "*"; + inherits: false; + initial-value: 1; +} @property --tw-rotate-x { syntax: "*"; inherits: false; @@ -5839,6 +6058,42 @@ syntax: "*"; inherits: false; } +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false; +} @property --tw-duration { syntax: "*"; inherits: false; @@ -5857,6 +6112,12 @@ transform: rotate(360deg); } } +@keyframes ping { + 75%, 100% { + transform: scale(2); + opacity: 0; + } +} @keyframes pulse { 50% { opacity: 0.5; @@ -5906,6 +6167,9 @@ --tw-translate-x: 0; --tw-translate-y: 0; --tw-translate-z: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-scale-z: 1; --tw-rotate-x: initial; --tw-rotate-y: initial; --tw-rotate-z: initial; @@ -5959,6 +6223,15 @@ --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; --tw-duration: initial; --tw-ease: initial; --tw-content: ""; diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index b948e5d1f..3e2279e73 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -4334,6 +4334,10 @@ "description": "When true, this plugin is excluded from the template plugins manifest (appkit.plugins.json) during sync.", "type": "boolean" }, + "devOnly": { + "description": "When true, this plugin is only registered when NODE_ENV === 'development'. In any other environment createApp skips it entirely (not constructed, no routes, resources not validated). Use for dev-only tooling that must never run in a deployed app.", + "type": "boolean" + }, "stability": { "description": "Plugin stability level. Beta plugins may have breaking API changes between minor releases but are on a path to GA. GA (general availability) plugins follow semver strictly.", "type": "string", diff --git a/packages/appkit-ui/src/react/index.ts b/packages/appkit-ui/src/react/index.ts index 233089b63..715f0c46b 100644 --- a/packages/appkit-ui/src/react/index.ts +++ b/packages/appkit-ui/src/react/index.ts @@ -7,3 +7,4 @@ export * from "./portal-container-context"; export * from "./resource-status-indicator"; export * from "./table"; export * from "./ui"; +export * from "./ui-variants"; diff --git a/packages/appkit-ui/src/react/ui-variants/index.ts b/packages/appkit-ui/src/react/ui-variants/index.ts new file mode 100644 index 000000000..18366a2b0 --- /dev/null +++ b/packages/appkit-ui/src/react/ui-variants/index.ts @@ -0,0 +1,2 @@ +export { Variant, type VariantProps } from "./variant"; +export { Variants, type VariantsProps } from "./variants"; diff --git a/packages/appkit-ui/src/react/ui-variants/variant.tsx b/packages/appkit-ui/src/react/ui-variants/variant.tsx new file mode 100644 index 000000000..e3592a3d3 --- /dev/null +++ b/packages/appkit-ui/src/react/ui-variants/variant.tsx @@ -0,0 +1,17 @@ +import type * as React from "react"; + +/** Props for a single {@link Variant} inside a {@link Variants} block. */ +export interface VariantProps { + /** Human-readable label shown in the switcher and recorded on confirm. */ + label?: string; + children: React.ReactNode; +} + +/** + * One candidate inside a {@link Variants} block. Purely a declarative marker: + * `Variants` reads its `label` and renders its `children` for the active + * index. It is not meant to be rendered on its own. + */ +export function Variant({ children }: VariantProps) { + return <>{children}; +} diff --git a/packages/appkit-ui/src/react/ui-variants/variants.tsx b/packages/appkit-ui/src/react/ui-variants/variants.tsx new file mode 100644 index 000000000..912dfe1f2 --- /dev/null +++ b/packages/appkit-ui/src/react/ui-variants/variants.tsx @@ -0,0 +1,354 @@ +import { + Check, + CheckCheck, + ChevronLeft, + ChevronRight, + Loader2, +} from "lucide-react"; +import { + Children, + type ComponentProps, + type CSSProperties, + isValidElement, + type ReactElement, + type ReactNode, + useMemo, + useState, +} from "react"; +import { cn } from "../lib/utils"; +import { Variant, type VariantProps } from "./variant"; + +/** Endpoint the recorder plugin (`uiVariants()`) mounts in development. */ +const CONFIRM_ENDPOINT = "/api/ui-variants/confirm"; + +/** Abort the confirm request after this long so the button can't hang. */ +const CONFIRM_TIMEOUT_MS = 5000; + +/** + * Default switcher accent — a violet chosen to stand apart from most apps' + * `primary`, so the dev-only picker chrome doesn't blend into the content it + * wraps. Override per-block via the `accent` prop. + */ +const DEFAULT_ACCENT = "#8b5cf6"; + +type ConfirmState = "idle" | "saving" | "recorded" | "unavailable" | "error"; + +/** Props for {@link Variants}. */ +export interface VariantsProps { + /** + * Stable, unique identifier for this block. The variant-picking agent matches + * on it to finalize the chosen variant into source, so it must be unique per + * file. (Named `blockId`, not `id`, because it is a semantic key — not a DOM + * element id.) + */ + blockId: string; + /** `` children — one candidate UI each. */ + children: ReactNode; + /** + * How the wrapper flows in the layout. Defaults to `"block"` (full width, + * variants stack vertically) — the right choice for page sections, heroes, + * and other block-level regions, which is the common case. Use `"inline"` + * when wrapping a small inline element such as a single button, so the + * wrapper hugs its content instead of spanning the row. + */ + layout?: "block" | "inline"; + /** Class name applied to the block wrapper (merged after `layout`). */ + className?: string; + /** + * Any CSS color for the switcher chrome (dot, ring, control cluster, count + * pill, confirm tick). Every accent element derives from this one value. + * Defaults to {@link DEFAULT_ACCENT}. The error state stays red regardless. + * + * @example accent="var(--primary)" // match the app theme + * @example accent="#f43f5e" // rose + */ + accent?: string; +} + +function isVariantElement(node: ReactNode): node is ReactElement { + return isValidElement(node) && node.type === Variant; +} + +/** + * Dev-time UI picker: renders several `` candidates one at a time. + * + * At rest the block shows a small, slowly-pulsing corner dot so it's clear + * it's "in variant state" without hovering — the dot pulses while a choice is + * still pending and goes calm once recorded. On hover (or focus) an accent + * ring outlines the block and the dot gives way to a compact control cluster + * (‹ index/total › ✓) that scales out of the corner. Confirming POSTs the + * choice to the `uiVariants()` recorder plugin; a coding agent then finalizes + * the chosen variant into source, removing this wrapper. + * + * All switcher chrome uses the `accent` color (default {@link DEFAULT_ACCENT}, + * a violet distinct from most apps' `primary`) so it doesn't blend into the + * content it wraps. + * + * Degrades gracefully: if the recorder endpoint is absent (feature off or a + * production build), the switcher still works as a viewer and Confirm reports + * that recording is unavailable. + * + * @example Page section (default layout — full-width, stacks vertically) + * ```tsx + * + *
+ *
+ *
+ * ``` + * + * @example Small inline element (hug the content) + * ```tsx + * + * + * + * + * ``` + * + * @example Custom accent + * ```tsx + * + * ``` + */ +export function Variants({ + blockId, + children, + layout = "block", + className, + accent = DEFAULT_ACCENT, +}: VariantsProps) { + const variants = useMemo( + () => Children.toArray(children).filter(isVariantElement), + [children], + ); + const [active, setActive] = useState(0); + const [confirmState, setConfirmState] = useState("idle"); + + if (variants.length === 0) return null; + + const clampedActive = Math.min(active, variants.length - 1); + const current = variants[clampedActive]; + const label = current.props.label ?? `Variant ${clampedActive + 1}`; + const atStart = clampedActive === 0; + const atEnd = clampedActive === variants.length - 1; + + const go = (delta: number) => { + // No wrap-around: clamp within bounds. + setActive((i) => Math.max(0, Math.min(variants.length - 1, i + delta))); + setConfirmState("idle"); + }; + + const confirm = async () => { + if (confirmState === "saving") return; + setConfirmState("saving"); + + // Bound the request so a hung recorder can't leave the button stuck in + // "saving" forever. + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CONFIRM_TIMEOUT_MS); + try { + const res = await fetch(CONFIRM_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blockId, chosenIndex: clampedActive, label }), + signal: controller.signal, + }); + // 404/403: the recorder isn't mounted (feature off or a production + // build). Degrade to a plain viewer. + if (res.status === 404 || res.status === 403) { + setConfirmState("unavailable"); + return; + } + // Any other non-OK status (e.g. a 500) is a failure the user can retry. + setConfirmState(res.ok ? "recorded" : "error"); + } catch { + // Network failure or timeout abort — the request never completed. + setConfirmState("error"); + } finally { + clearTimeout(timeout); + } + }; + + return ( +
+ {current} + + + +
+ go(-1)} + > + + + + + {clampedActive + 1}/{variants.length} + + + go(1)} + > + + + + +
+
+ ); +} + +function dotColorFor(state: ConfirmState): string { + switch (state) { + case "error": + return "bg-destructive"; + case "unavailable": + return "bg-muted-foreground/40"; + default: + return "bg-[color:var(--variants-accent)]"; + } +} + +/** + * Resting corner indicator: pulses while a choice is pending, calm once + * recorded — so a block's picked/unpicked state reads at a glance without + * hovering. + */ +function StateDot({ + state, + className, +}: { + state: ConfirmState; + className?: string; +}) { + const pulsing = state === "idle" || state === "saving"; + const color = dotColorFor(state); + + return ( + + {pulsing && ( + + )} + + + ); +} + +function RoundButton({ + children, + disabled, + className, + ...props +}: ComponentProps<"button">) { + return ( + + ); +} + +/** + * Round tick that confirms the active variant. State is conveyed by icon + + * color only (no text). The chosen `label` rides along in the title/aria for + * accessibility and hover context. + */ +function ConfirmButton({ + state, + label, + onClick, +}: { + state: ConfirmState; + label: string; + onClick: () => void; +}) { + const title = + state === "recorded" + ? `Recorded "${label}" — ask the agent to finalize` + : state === "unavailable" + ? "Recorder unavailable" + : state === "error" + ? "Failed — click to retry" + : `Confirm "${label}"`; + + return ( + + {state === "saving" ? ( + + ) : state === "recorded" ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/appkit/package.json b/packages/appkit/package.json index edd118908..4e681ef77 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -17,6 +17,7 @@ "dist", "bin", "scripts", + "skills", "docs", "docs.md", "CLAUDE.md", diff --git a/packages/appkit/skills/ui-variants.md b/packages/appkit/skills/ui-variants.md new file mode 100644 index 000000000..85c87d890 --- /dev/null +++ b/packages/appkit/skills/ui-variants.md @@ -0,0 +1,193 @@ +--- +description: Builds a piece of UI in multiple variants, lets the developer pick one live in the browser, then finalizes the chosen variant into source +argument-hint: +--- + +# UI — Build in Variants, Pick Live, Finalize + +User input: $ARGUMENTS + +Build the requested UI in several variants wrapped in the `` picker, +let the developer choose one **live in the browser**, and then finalize the +chosen variant into source (removing the wrapper). + +The picker lets a developer choose between several candidate UIs live in the +browser during local dev; you then finalize the chosen one into source. + +**These rules govern your behavior, not your narration.** Apply every rule +silently. To the developer, speak only about what you're building and what they +need to do — never about the mechanism (the choices file, how a choice is +stored, "I read it next turn", or whether you decided to ask). + +## The pieces + +- **`` / ``** — `@databricks/appkit-ui/react`. A dev-time + wrapper that renders one candidate at a time with a hover-revealed switcher + (prev/next, an index pill + label) and a **Confirm** tick. +- **`uiVariants()` plugin** — `@databricks/appkit`. A dev-only recorder. Confirm + POSTs `{ blockId, chosenIndex, label }` to `POST /api/ui-variants/confirm`; the + plugin **upserts** the choice into a JSONL file keyed by `blockId`. **Register it + conditionally** so it never mounts in production: + `...(process.env.NODE_ENV === "development" ? [uiVariants()] : [])`. +- **Choices file** — `node_modules/.databricks/appkit/.appkit-ui-choices.jsonl`, + gitignored. A **keyed store: one line per `` blockId** (not an append + log) — re-confirming a variant replaces that block's line, so the file always + reflects the current choice: + `{ "ts": "...", "blockId": "hero-cta", "chosenIndex": 1, "label": "Solid" }`. + Its path is relative to the dev server's cwd, not the repo root, so + **discover** it (finalize step 4) rather than assuming a fixed location. + +## 1. Understand the request + +Work out what to build (a component, a section, a page) and where it lives, then +pick the natural target file (e.g. a `*.route.tsx` or a component under +`src/components/…`), and decide the block breakdown before authoring. Two rules +shape this step: + +- **Ask for _what_ and _where_; build for _how it looks_.** Before generating, + ask at most one or two questions **only if the answer changes the build** — + ambiguous target/surface, an unclear axis of variation, or real-vs-placeholder + data on a data screen. Do **not** ask appearance/taste questions ("bold or + minimal?", "which color?"); make one bold and one minimal instead. If the + request is clear enough, skip questions. Never turn it into a checklist. +- **One `` block = one independent decision** — default to one block + per distinct section the user names (a hero + about page → two blocks), and + **default 3 meaningfully different variants per block**. + +## 2. Author the variants + +Wrap each section's candidates in its own `` block, following the +authoring rules below. Note the **file path + every `blockId`** — you need them +to finalize. + +- **MUST** treat one `` block as **one independent decision** and + default to **one block per distinct section/region** the user names. A page + with a hero and an about-us section is **two** blocks (`blockId="hero"`, + `blockId="about"`), so the developer chooses each section independently. Use a + single whole-page block **only** when the user asks for whole-page options or + the sections must move together as one unit. +- **MUST** give every `` block a **stable, unique `blockId`** within its + file. Duplicate ids are ambiguous — refuse and disambiguate. +- **MUST** wrap each candidate in `` with a short, distinct + label. The label is shown in the switcher and recorded on confirm. +- **SHOULD** default to **3 variants** unless the user asks for a specific + count. Make them meaningfully different (layout / emphasis / density). +- Keep each variant self-contained: imports it needs should already be present + so finalizing to any one of them leaves the file valid. +- **Layout:** `` defaults to block layout (full-width, stacking) — + correct for sections, heroes, and pages. Pass `layout="inline"` only when + wrapping a small inline element such as a single button. + +One block per section, each candidate a labelled ``: + +```tsx +import { Variants, Variant } from "@databricks/appkit-ui/react"; + +// "page with a hero and an about-us section" → one block per section + + …hero A… + …hero B… + …hero C… + + + + …about A… + …about B… + …about C… + +``` + +## 3. Ensure the recorder is running + +- Confirm the dev-only `uiVariants()` plugin is registered. **Register it + conditionally so it never mounts in production:** + ```ts + createApp({ + plugins: [ + server(), + ...(process.env.NODE_ENV === "development" ? [uiVariants()] : []), + // …other plugins + ], + }); + ``` + If it's missing, add it and tell the developer it's dev-only. +- Confirm the dev server is running so the browser can POST the choice. + +## 4. Hand off to the developer + +Tell the developer to make the choice in the browser: + +> Flip through the variants in the browser (hover the block to reveal the +> switcher) and click **Confirm** on the one you want. + +Then get the "I've chosen" signal — the developer's next message. + +**The signal is turn-based.** Do **not** start a background watcher. The confirm +is recorded to the file; read it on your next turn. How to prompt for it: + +- If your tool has an interactive question prompt **and** the developer is in an + active session, you MAY ask via that prompt — options like **"I've picked — + finalize" / "Still deciding" / "Cancel"** — to save them typing. +- Otherwise, ask in plain text and read the file on a later turn. + +Two hard rules: the question **must** carry a "still deciding / later" option so +it never blocks the developer; and only ask when someone is there to answer — if +unsure, use plain text. If the developer says "done" but no line exists for the +block yet, they haven't clicked Confirm — ask them to, don't finalize nothing. + +## 5. Finalize when the developer says they've chosen + +1. When the developer says they've chosen, **discover the choices file** (path + is relative to the dev server's cwd): + ```bash + f=$(find . -path '*/node_modules/.databricks/appkit/.appkit-ui-choices.jsonl' 2>/dev/null | head -1) + cat "$f" # find the line for your block's blockId + ``` +2. For the line matching your block's `blockId`, **reconcile `chosenIndex` against + `label`:** check the `` at `chosenIndex` (zero-based) still has the + recorded `label`. If they don't match, the file was edited after confirm — + prefer the `` whose `label` matches; if none matches, stop and ask + the developer to re-confirm. +3. Find the `` block and **replace the whole block with + the chosen ``'s inner JSX** — remove the ``/`` + wrapper, drop the now-unused import if nothing else uses it, reconcile + surrounding code, then format/lint the file + (`pnpm check:fix`, or `pnpm biome check --write `). +4. **Remove the consumed line** for that `blockId` from the choices file. Match the + `blockId` structurally (not a loose substring) so a label containing the text + can't delete the wrong line: + `tmp=$(mktemp); jq -Rc 'fromjson? | select(.blockId != "")' "$f" > "$tmp" && mv "$tmp" "$f"`. +5. Confirm the finalized UI back to the developer. + +## 6. Wrap up + +Offer to iterate (new variants, tweaks) if the developer wants another round. + +## Edge cases + +- **Developer confirms before you ask.** The choice waits in the file; read it + whenever you next act. +- **Developer changed their mind.** The store is keyed by `blockId`, so + re-confirming overwrites the previous line. Read whatever line is there now. +- **Endpoint absent (prod build / feature off).** The switcher still works as a + viewer; Confirm shows "Recorder unavailable". Nothing is recorded — nothing to + finalize. +- **Duplicate `blockId` in a file.** Ambiguous — refuse to finalize automatically; + ask which block, or re-author with unique ids. + +## Keeping it out of production + +`` is dev-time scaffolding. Always finalize (or remove) every block +before a production build — a leftover block ships the dev-only picker to +production. + +## Anti-patterns +- Wrapping several sections in one `` block (forces whole-page combos, + hides most combinations) — one block per section instead. +- Cosmetic-only variants (same layout, tweaked padding) — make them meaningfully + different or don't offer a choice. +- Starting a background watcher/monitor to catch the confirm — the flow is + turn-based on purpose; read the file when the developer says they're done. +- Leaving the `` wrapper in source after a choice — always finalize + (or remove) it; a leftover block ships the dev-only picker to production. +- Forgetting to clear the consumed choices line after finalizing. diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index 2973ee265..e735c200e 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -191,7 +191,7 @@ export class AppKit { TelemetryManager.initialize(config?.telemetry); await CacheManager.getInstance(config?.cache); - const rawPlugins = config.plugins as T; + const rawPlugins = AppKit.filterDevOnlyPlugins(config.plugins as T); // Collect manifest resources via registry const registry = new ResourceRegistry(); @@ -251,6 +251,35 @@ export class AppKit { reporter.sendStartup().catch(() => {}); } + /** + * Drops plugins whose manifest declares `devOnly: true` unless + * `NODE_ENV === "development"`. Runs before resource collection so a skipped + * plugin is never constructed, never injects routes, and has its resource + * requirements ignored — the framework, not the app author, enforces that + * dev-only tooling can never run in a deployed app. + * + * This is the primary guard; plugins that expose a mutating dev endpoint + * should still keep an in-handler `NODE_ENV` check as fail-safe defense + * against being mounted through a path that bypasses this filter. + */ + private static filterDevOnlyPlugins< + T extends PluginData[], + >(plugins: T): T { + if (!plugins || process.env.NODE_ENV === "development") return plugins; + + return plugins.filter((pluginData) => { + const isDevOnly = pluginData?.plugin?.manifest?.devOnly === true; + if (isDevOnly) { + logger.debug( + "Skipping dev-only plugin %s (NODE_ENV=%s)", + pluginData?.name ?? "unknown", + process.env.NODE_ENV ?? "", + ); + } + return !isDevOnly; + }) as T; + } + private static preparePlugins( plugins: PluginData[], ) { diff --git a/packages/appkit/src/core/tests/databricks.test.ts b/packages/appkit/src/core/tests/databricks.test.ts index 0083ec62e..cf7ff572a 100644 --- a/packages/appkit/src/core/tests/databricks.test.ts +++ b/packages/appkit/src/core/tests/databricks.test.ts @@ -391,6 +391,72 @@ describe("AppKit", () => { }); }); + describe("devOnly plugins", () => { + // A plugin whose manifest opts into dev-only registration. + class DevOnlyPlugin extends NormalTestPlugin { + static manifest = { + ...createTestManifest("devOnly"), + devOnly: true, + }; + name = "devOnly"; + } + + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + test("registers devOnly plugin when NODE_ENV is development", async () => { + process.env.NODE_ENV = "development"; + + const instance = (await createApp({ + plugins: [{ plugin: DevOnlyPlugin, config: {}, name: "devOnly" }], + })) as any; + + expect(instance.devOnly).toBeDefined(); + expect(instance.devOnly.setupCalled).toBe(true); + }); + + test("skips devOnly plugin when NODE_ENV is production", async () => { + process.env.NODE_ENV = "production"; + + const instance = (await createApp({ + plugins: [ + { plugin: DevOnlyPlugin, config: {}, name: "devOnly" }, + { plugin: NormalTestPlugin, config: {}, name: "normalTest" }, + ], + })) as any; + + // Dev-only plugin is absent; other plugins register normally. + expect(Object.keys(instance)).not.toContain("devOnly"); + expect(instance.normalTest).toBeDefined(); + expect(instance.normalTest.setupCalled).toBe(true); + }); + + test("skips devOnly plugin when NODE_ENV is unset", async () => { + process.env.NODE_ENV = undefined; + + const instance = (await createApp({ + plugins: [{ plugin: DevOnlyPlugin, config: {}, name: "devOnly" }], + })) as any; + + expect(Object.keys(instance)).not.toContain("devOnly"); + }); + + test("skips devOnly plugin when NODE_ENV is test", async () => { + process.env.NODE_ENV = "test"; + + const instance = (await createApp({ + plugins: [{ plugin: DevOnlyPlugin, config: {}, name: "devOnly" }], + })) as any; + + // Only NODE_ENV === "development" registers a devOnly plugin; anything + // else (including "test") fails closed. + expect(Object.keys(instance)).not.toContain("devOnly"); + }); + }); + describe("preparePlugins", () => { test("should transform plugin data array to plugin map", () => { const pluginData = [ diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index b25380737..53752801e 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -83,6 +83,13 @@ export type { ServingEndpointRegistry, ServingFactory, } from "./plugins/serving/types"; +// Hand-exported: the hyphenated `ui-variants` folder can't be an auto-export. +export { + type ChoiceSink, + FileChoiceSink, + type UiChoiceRecord, + uiVariants, +} from "./plugins/ui-variants"; // Registry types and utilities for plugin manifests export type { ConfigSchema, diff --git a/packages/appkit/src/plugins/ui-variants/choice-sink.ts b/packages/appkit/src/plugins/ui-variants/choice-sink.ts new file mode 100644 index 000000000..ce555e5c3 --- /dev/null +++ b/packages/appkit/src/plugins/ui-variants/choice-sink.ts @@ -0,0 +1,115 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +/** + * Path of the JSONL choices file, relative to the app's working directory. + * A coding agent reads it to pick up the developer's in-browser confirmation + * and finalize the chosen variant. + * + * Under `node_modules/`, so it's gitignored and cleared on a clean install. + * Each line is spent on read — the agent removes it once the choice is + * finalized. + * + * CONTRACT: the `databricks-app-variants` agent skill (in the + * databricks-agent-skills repo) discovers the choices file at this path. + * Changing this value silently breaks that skill's file discovery — update the + * skill's `find` path in the same change. + */ +const UI_CHOICES_FILE = + "node_modules/.databricks/appkit/.appkit-ui-choices.jsonl"; + +/** + * One recorded variant choice. + * + * CONTRACT: the `databricks-app-variants` agent skill parses these fields + * (`blockId`, `chosenIndex`, `label`) to finalize the chosen variant. Renaming + * or removing a field silently breaks finalization (the skill reads it from the + * JSONL line, so there's no compile error) — update the skill in the same + * change. + */ +export interface UiChoiceRecord { + /** ISO timestamp of when the choice was recorded. */ + ts: string; + /** Stable id of the `` block the developer confirmed. */ + blockId: string; + /** Zero-based index of the chosen `` child. */ + chosenIndex: number; + /** Human-readable label of the chosen variant (for agent context). */ + label?: string; + /** Optional free-form note from the developer. */ + note?: string; +} + +/** + * Storage backend for confirmed variant choices, decoupled from the recorder + * plugin so the destination can vary by environment. The default + * {@link FileChoiceSink} writes a local JSONL file, suitable wherever the + * coding agent shares the app's filesystem; an environment without a shared + * filesystem can supply its own implementation (e.g. a table-backed store). + * + * Implementations must be **keyed and latest-wins**: at most one record per + * `blockId`, and recording an existing `blockId` replaces it rather than + * appending, so the store always reflects the current choice for each block. + */ +export interface ChoiceSink { + /** Record (upsert) a choice, keyed by `record.blockId`. */ + record(record: UiChoiceRecord): Promise; +} + +/** + * Default {@link ChoiceSink}: upserts choices into {@link UI_CHOICES_FILE}, + * one line per `` id. + * + * The file is resolved against `process.cwd()`, so it lands under whatever + * directory the dev server runs from. Concurrent confirms are serialized behind + * an internal queue so their read-modify-write can't interleave and lose an + * update. + */ +export class FileChoiceSink implements ChoiceSink { + private readonly filePath: string; + private writeQueue: Promise = Promise.resolve(); + + constructor(relativePath: string = UI_CHOICES_FILE) { + this.filePath = path.resolve(process.cwd(), relativePath); + } + + record(record: UiChoiceRecord): Promise { + const run = this.writeQueue.then(() => this.upsert(record)); + // Keep the chain alive even if this write throws, but surface the error. + this.writeQueue = run.catch(() => {}); + return run; + } + + /** + * Reads the current file, replaces (or adds) the line for `record.blockId`, + * dropping any unparseable lines, and rewrites the whole file. + */ + private async upsert(record: UiChoiceRecord): Promise { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + + let existing = ""; + try { + existing = await fs.readFile(this.filePath, "utf-8"); + } catch { + // File doesn't exist yet — start empty. + } + + const kept = existing + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .filter((line) => { + try { + return ( + (JSON.parse(line) as UiChoiceRecord).blockId !== record.blockId + ); + } catch { + // Drop unparseable lines rather than let them accumulate. + return false; + } + }); + + kept.push(JSON.stringify(record)); + await fs.writeFile(this.filePath, `${kept.join("\n")}\n`, "utf-8"); + } +} diff --git a/packages/appkit/src/plugins/ui-variants/index.ts b/packages/appkit/src/plugins/ui-variants/index.ts new file mode 100644 index 000000000..db3d88ce3 --- /dev/null +++ b/packages/appkit/src/plugins/ui-variants/index.ts @@ -0,0 +1,127 @@ +import type { BasePluginConfig, IAppRouter } from "shared"; +import { createLogger } from "../../logging/logger"; +import { Plugin, toPlugin } from "../../plugin"; +import type { PluginManifest } from "../../registry"; +import { + type ChoiceSink, + FileChoiceSink, + type UiChoiceRecord, +} from "./choice-sink"; +import manifest from "./manifest.json"; + +export { + type ChoiceSink, + FileChoiceSink, + type UiChoiceRecord, +} from "./choice-sink"; + +const logger = createLogger("ui-variants"); + +interface UiVariantsConfig extends BasePluginConfig { + /** + * Storage backend for confirmed choices. Defaults to a {@link FileChoiceSink} + * writing a local JSONL file, which works wherever the coding agent shares + * the app's filesystem. Supply a custom sink for environments without a + * shared filesystem. + */ + sink?: ChoiceSink; +} + +/** Untrusted body of `POST /api/ui-variants/confirm`; validated in the handler. */ +interface ConfirmRequestBody { + blockId?: unknown; + chosenIndex?: unknown; + label?: unknown; + note?: unknown; +} + +/** + * Dev-only recorder backing the `` UI picker. + * + * When a developer confirms a variant in the browser, the `` + * component POSTs to `/api/ui-variants/confirm`. This plugin records the choice + * through a {@link ChoiceSink} (default: a keyed JSONL file), which a coding + * agent reads to finalize the chosen variant into the component source. + * Recording is a keyed upsert — one entry per `` id — and the plugin + * only records; it never edits source. + */ +class UiVariantsPlugin extends Plugin { + static manifest = manifest as PluginManifest<"ui-variants">; + + protected static description = + "Dev-only recorder for the UI picker"; + + private readonly sink: ChoiceSink; + + constructor(config: UiVariantsConfig = {}) { + super(config); + this.sink = config.sink ?? new FileChoiceSink(); + } + + injectRoutes(router: IAppRouter): void { + this.route(router, { + name: "confirm", + method: "post", + path: "/confirm", + handler: async (req, res) => { + // Dev-only: the recorder exists solely to drive the local edit loop, + // so it must never run in a deployed app. + if (process.env.NODE_ENV !== "development") { + res.status(403).json({ + ok: false, + message: "ui-variants confirm is only available in development", + }); + return; + } + + const body = (req.body ?? {}) as ConfirmRequestBody; + const { blockId, chosenIndex } = body; + + if (typeof blockId !== "string" || blockId.length === 0) { + res.status(400).json({ + ok: false, + message: "`blockId` (non-empty string) is required", + }); + return; + } + if (typeof chosenIndex !== "number" || !Number.isInteger(chosenIndex)) { + res.status(400).json({ + ok: false, + message: "`chosenIndex` (integer) is required", + }); + return; + } + + const record: UiChoiceRecord = { + ts: new Date().toISOString(), + blockId, + chosenIndex, + ...(typeof body.label === "string" ? { label: body.label } : {}), + ...(typeof body.note === "string" ? { note: body.note } : {}), + }; + + try { + await this.sink.record(record); + } catch (error) { + logger.error("Failed to record UI variant choice", { + error, + blockId, + }); + res + .status(500) + .json({ ok: false, message: "Failed to record variant choice" }); + return; + } + + logger.info("Recorded UI variant choice", { + blockId, + chosenIndex, + label: record.label, + }); + res.json({ ok: true, blockId, chosenIndex }); + }, + }); + } +} + +export const uiVariants = toPlugin(UiVariantsPlugin); diff --git a/packages/appkit/src/plugins/ui-variants/manifest.json b/packages/appkit/src/plugins/ui-variants/manifest.json new file mode 100644 index 000000000..2c9fbc27c --- /dev/null +++ b/packages/appkit/src/plugins/ui-variants/manifest.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "name": "ui-variants", + "displayName": "UI Variants Plugin", + "description": "Dev-only recorder for the UI picker: records the developer's chosen variant so a coding agent can finalize the component source", + "hidden": true, + "devOnly": true, + "resources": { + "required": [], + "optional": [] + } +} diff --git a/packages/shared/scripts/postinstall.js b/packages/shared/scripts/postinstall.js index 22f1c09dd..1fda2d4f7 100644 --- a/packages/shared/scripts/postinstall.js +++ b/packages/shared/scripts/postinstall.js @@ -4,3 +4,6 @@ console.log("[@databricks/appkit] To setup AI assistant instructions, run:"); console.log(""); console.log(" npx appkit setup --write"); console.log(""); +console.log(" This also installs AppKit skills (e.g. /ui) as slash commands"); +console.log(" in .claude/commands/. Skills alone: npx appkit skills install"); +console.log(""); diff --git a/packages/shared/src/cli/commands/lint.ts b/packages/shared/src/cli/commands/lint.ts index 00eb55ad8..cac5664eb 100644 --- a/packages/shared/src/cli/commands/lint.ts +++ b/packages/shared/src/cli/commands/lint.ts @@ -38,6 +38,16 @@ const rules: Rule[] = [ message: "parseFloat can return NaN. Validate input or use toNumber() helper from shared/types.ts.", }, + { + // is the dev-only variant picker from @databricks/appkit-ui, + // meant to drive the local edit loop. Finalize the chosen variant before + // deploying so the picker chrome never reaches production. + id: "no-variants-in-prod", + pattern: "$$$C", + message: + " is a development-only variant picker and must not be shipped. Finalize the chosen before deploying.", + includeTests: false, + }, ]; function isTestFile(filePath: string): boolean { diff --git a/packages/shared/src/cli/commands/setup.ts b/packages/shared/src/cli/commands/setup.ts index c72e661eb..c955e8bae 100644 --- a/packages/shared/src/cli/commands/setup.ts +++ b/packages/shared/src/cli/commands/setup.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { Command } from "commander"; +import { installSkills } from "./skills/install-skills"; const PACKAGES = [ { name: "@databricks/appkit", description: "Backend SDK" }, @@ -158,6 +159,28 @@ function runSetup(options: { write?: boolean }) { fs.writeFileSync(claudePath, finalContent); console.log(`\n✓ ${action} CLAUDE.md`); console.log(` Path: ${claudePath}`); + + // Install AppKit skills as slash commands in .claude/commands/. Best-effort: + // never fail `setup --write` just because skills couldn't be linked. + try { + const result = installSkills(); + const applied = result.installed.filter((s) => s.action !== "skipped"); + if (applied.length > 0) { + console.log( + `\n✓ Installed ${applied.length} skill(s) as slash commands`, + ); + for (const s of applied) { + console.log(` /${s.name}`); + } + console.log(` Path: ${result.commandsDir}`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.log(`\n(Skipped installing skills: ${msg})`); + console.log( + " Run `npx appkit skills install` once packages are present.", + ); + } } else { console.log("\nTo create/update CLAUDE.md, run:"); console.log(" npx appkit setup --write\n"); diff --git a/packages/shared/src/cli/commands/skills/index.ts b/packages/shared/src/cli/commands/skills/index.ts new file mode 100644 index 000000000..e6ffa4afb --- /dev/null +++ b/packages/shared/src/cli/commands/skills/index.ts @@ -0,0 +1,75 @@ +import { Command } from "commander"; +import { type InstalledSkill, installSkills } from "./install-skills"; + +function describe(skill: InstalledSkill): string { + switch (skill.action) { + case "linked": + return ` ✓ /${skill.name} → linked`; + case "copied": + return ` ✓ /${skill.name} → copied`; + case "replaced": + return ` ✓ /${skill.name} → updated`; + case "skipped": + return ` – /${skill.name} → skipped (${skill.reason})`; + } +} + +function runInstall(options: { + dir?: string; + copy?: boolean; + force?: boolean; +}) { + let result: ReturnType; + try { + result = installSkills(options); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + + if (result.installed.length === 0) { + console.log("No skills found to install."); + return; + } + + console.log(`Installing AppKit skills into ${result.commandsDir}:`); + for (const skill of result.installed) { + console.log(describe(skill)); + } + + const count = result.installed.filter((s) => s.action !== "skipped").length; + if (count > 0) { + console.log( + `\n✓ ${count} skill${count === 1 ? "" : "s"} available as slash commands.`, + ); + } +} + +const installSkillsCommand = new Command("install") + .description("Install AppKit skills as slash commands in .claude/commands/") + .option("--dir ", "Target project directory (default: cwd)") + .option( + "--copy", + "Copy files instead of symlinking (portable for git/deploy)", + ) + .option("--force", "Overwrite existing files that aren't AppKit-managed") + .addHelpText( + "after", + ` +Examples: + $ appkit skills install + $ appkit skills install --copy + $ appkit skills install --dir ./my-app`, + ) + .action(runInstall); + +export const skillsCommand = new Command("skills") + .description("Manage AppKit skills (slash commands for AI assistants)") + .addCommand(installSkillsCommand) + .addHelpText( + "after", + ` +Examples: + $ appkit skills install + $ appkit skills install --copy`, + ); diff --git a/packages/shared/src/cli/commands/skills/install-skills.ts b/packages/shared/src/cli/commands/skills/install-skills.ts new file mode 100644 index 000000000..04fa7f64c --- /dev/null +++ b/packages/shared/src/cli/commands/skills/install-skills.ts @@ -0,0 +1,157 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * The installed AppKit package that ships the `skills/` directory. Skills are + * shipped inside `@databricks/appkit` (see `files` in its package.json and + * `tools/dist-appkit.ts`), so we resolve them from the consumer's node_modules. + */ +const SKILLS_PACKAGE = "@databricks/appkit"; + +/** Where slash-command markdown lives in the consumer project. */ +const COMMANDS_SUBDIR = path.join(".claude", "commands"); + +export interface InstallSkillsOptions { + /** Target project directory. Defaults to process.cwd(). */ + dir?: string; + /** Copy the files instead of symlinking (portable across git/deploy). */ + copy?: boolean; + /** Overwrite an existing entry even if it isn't ours. */ + force?: boolean; +} + +export interface InstalledSkill { + name: string; + source: string; + target: string; + action: "linked" | "copied" | "skipped" | "replaced"; + reason?: string; +} + +export interface InstallSkillsResult { + skillsDir: string; + commandsDir: string; + installed: InstalledSkill[]; +} + +/** + * Locate the shipped `skills/` directory inside the installed AppKit package. + * In a consuming project this is `node_modules/@databricks/appkit/skills`; in + * the monorepo that path is a workspace symlink to `packages/appkit`, which now + * carries `skills/`, so the same resolution works in both places. + */ +export function findSkillsDir(targetDir: string): string | null { + const skillsDir = path.join( + targetDir, + "node_modules", + SKILLS_PACKAGE, + "skills", + ); + return fs.existsSync(skillsDir) ? skillsDir : null; +} + +/** + * Whether an existing target path was created by us (a symlink into the + * package's skills dir, or a copy we can safely replace). We only auto-replace + * symlinks pointing at the package; regular files are left alone unless forced. + */ +function existingIsOurs(target: string, source: string): boolean { + try { + const stat = fs.lstatSync(target); + if (stat.isSymbolicLink()) { + const resolved = path.resolve( + path.dirname(target), + fs.readlinkSync(target), + ); + return resolved === path.resolve(source); + } + } catch { + // no existing target + } + return false; +} + +/** + * Install AppKit's shipped skills as slash commands in the consumer project's + * `.claude/commands/`. Symlinks by default (so upgrades to the package flow + * through automatically); pass `copy` for environments where symlinks don't + * survive (git checkouts, Databricks Apps deploy). + */ +export function installSkills( + options: InstallSkillsOptions = {}, +): InstallSkillsResult { + const targetDir = options.dir ? path.resolve(options.dir) : process.cwd(); + + const skillsDir = findSkillsDir(targetDir); + if (!skillsDir) { + throw new Error( + `Could not find ${SKILLS_PACKAGE}/skills in ${targetDir}. ` + + `Install ${SKILLS_PACKAGE} first (e.g. npm install ${SKILLS_PACKAGE}).`, + ); + } + + const commandsDir = path.join(targetDir, COMMANDS_SUBDIR); + fs.mkdirSync(commandsDir, { recursive: true }); + + const files = fs + .readdirSync(skillsDir) + .filter((f) => f.endsWith(".md")) + .sort(); + + const installed: InstalledSkill[] = []; + + for (const file of files) { + const source = path.join(skillsDir, file); + const target = path.join(commandsDir, file); + const name = path.basename(file, ".md"); + + // lstat (not existsSync) so a dangling symlink still counts as present. + let hasEntry: boolean; + try { + fs.lstatSync(target); + hasEntry = true; + } catch { + hasEntry = false; + } + + const ours = existingIsOurs(target, source); + + if (hasEntry && !ours && !options.force) { + installed.push({ + name, + source, + target, + action: "skipped", + reason: "a different file already exists (use --force to overwrite)", + }); + continue; + } + + const replacing = hasEntry; + if (replacing) { + fs.rmSync(target, { force: true }); + } + + if (options.copy) { + fs.copyFileSync(source, target); + installed.push({ + name, + source, + target, + action: replacing ? "replaced" : "copied", + }); + } else { + // Relative link so the .claude dir stays portable if the project moves. + const linkTarget = path.relative(commandsDir, source); + fs.symlinkSync(linkTarget, target); + installed.push({ + name, + source, + target, + action: replacing ? "replaced" : "linked", + }); + } + } + + return { skillsDir, commandsDir, installed }; +} diff --git a/packages/shared/src/cli/index.ts b/packages/shared/src/cli/index.ts index aa60157c8..6ffe64a12 100644 --- a/packages/shared/src/cli/index.ts +++ b/packages/shared/src/cli/index.ts @@ -10,6 +10,7 @@ import { generateTypesCommand } from "./commands/generate-types.js"; import { lintCommand } from "./commands/lint.js"; import { pluginCommand } from "./commands/plugin/index.js"; import { setupCommand } from "./commands/setup.js"; +import { skillsCommand } from "./commands/skills/index.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const pkgPath = join(__dirname, "../../package.json"); @@ -28,5 +29,6 @@ cmd.addCommand(lintCommand); cmd.addCommand(docsCommand); cmd.addCommand(pluginCommand); cmd.addCommand(codemodCommand); +cmd.addCommand(skillsCommand); await cmd.parseAsync(); diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index da4800548..2b823d0b6 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -118,6 +118,7 @@ export interface PluginManifest | "license" | "onSetupMessage" | "hidden" + | "devOnly" | "stability" > { name: TName; @@ -146,6 +147,8 @@ export interface PluginManifest onSetupMessage?: string; /** When true, this plugin is excluded from the template plugins manifest (appkit.plugins.json) during sync. */ hidden?: boolean; + /** When true, this plugin is only registered when NODE_ENV === "development". In any other environment createApp skips it entirely (not constructed, no routes, resources not validated). Use for dev-only tooling that must never run in a deployed app. */ + devOnly?: boolean; /** Plugin stability level. Beta plugins may have breaking API changes between minor releases but are on a path to GA. GA (general availability) plugins follow semver strictly. */ stability?: "beta" | "ga"; } diff --git a/packages/shared/src/schemas/manifest.ts b/packages/shared/src/schemas/manifest.ts index 58a48c276..ceb0412ab 100644 --- a/packages/shared/src/schemas/manifest.ts +++ b/packages/shared/src/schemas/manifest.ts @@ -733,6 +733,12 @@ export const pluginManifestSchema = z .describe( "When true, this plugin is excluded from the template plugins manifest (appkit.plugins.json) during sync.", ), + devOnly: z + .boolean() + .optional() + .describe( + "When true, this plugin is only registered when NODE_ENV === 'development'. In any other environment createApp skips it entirely (not constructed, no routes, resources not validated). Use for dev-only tooling that must never run in a deployed app.", + ), stability: z .enum(["beta", "ga"]) .optional() diff --git a/tools/dist-appkit.ts b/tools/dist-appkit.ts index 810d55d55..bfc361489 100644 --- a/tools/dist-appkit.ts +++ b/tools/dist-appkit.ts @@ -72,6 +72,11 @@ if (fs.existsSync("bin")) { fs.cpSync("bin", "tmp/bin", { recursive: true }); } +// Copy shipped skills (slash commands installed via `appkit skills install`) +if (fs.existsSync("skills")) { + fs.cpSync("skills", "tmp/skills", { recursive: true }); +} + // Copy bin and scripts from shared package if (fs.existsSync(sharedBin)) { fs.mkdirSync("tmp/bin", { recursive: true });