diff --git a/.claude/commands/ui.md b/.claude/commands/ui.md new file mode 100644 index 00000000..d4710a96 --- /dev/null +++ b/.claude/commands/ui.md @@ -0,0 +1,106 @@ +--- +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). + +## 0. Load the reference — it is the source of truth + +Before doing anything, read `.claude/references/ui-variants-guidance.md` and +follow it throughout. It owns the mechanics — the ``/`` +contract, stable-id rules, the confirm → finalize loop, edge cases, and safety. +This command is the overview; **when the two overlap, the reference wins.** + +Two rules from it apply to everything below, so keep them in mind here: + +- **These instructions 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). +- One `` block = one independent decision (see §1). + +## 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. Follow the +reference's **"Authoring variants" rules** for the details; the two that shape +this step: + +- **Ask for _what_ and _where_; build for _how it looks_.** Ask only when the + answer changes the build (ambiguous target/surface, unclear axis of variation, + real-vs-placeholder data) — never taste questions; the variants *are* the + question. Otherwise skip questions and build. +- **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 variants" rules and the tsx example in the reference** — one block +per section, a stable unique `id` per block, a short distinct `label` per +``, `layout="inline"` only for a small inline element, and every +import each candidate needs already present so any one finalizes to a valid +file. Note the **file path + every `id`** — you need them to finalize. + +## 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 + ], + }); + ``` + In the dev-playground it's already wired this way. 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. **Prompt for +it per the reference's "The signal is turn-based" rules** (no watcher; read the +file next turn; any interactive prompt must carry a "still deciding / later" +option, otherwise plain text). + +## 5. Finalize when the developer says they've chosen + +**Run the reference's "confirm → finalize loop" exactly** — discover the choices +file, reconcile `chosenIndex` against `label`, replace the whole block with the +chosen ``'s inner JSX, drop the now-unused import, lint, remove the +consumed line, and confirm back which variant was applied. If no line exists for +your `id` yet, the developer hasn't clicked Confirm — ask them to, don't +finalize. Handle the reference's edge cases (early confirm, changed mind, +endpoint absent, duplicate id) as they come up. + +## 6. Wrap up + +Offer to iterate (new variants, tweaks) if the developer wants another round. + +## 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/.claude/references/ui-variants-guidance.md b/.claude/references/ui-variants-guidance.md new file mode 100644 index 00000000..da7ad4c1 --- /dev/null +++ b/.claude/references/ui-variants-guidance.md @@ -0,0 +1,155 @@ +# App UI Variants — Reference + +Rules for the `/ui` command and anyone maintaining the `` picker. The +picker lets a developer choose between several candidate UIs live in the browser +during local dev; the agent then finalizes the chosen one into source. + +**These rules govern the agent's behavior, not its narration.** Apply them +silently. To the developer, speak only about what's being built and what they +need to do — never about the mechanism (the choices file, how the choice is +stored, "read on the next turn", or whether questions were considered). + +## Contents + +- **The pieces** — ``/``, the `uiVariants()` recorder, the choices file. +- **The signal is turn-based** — no watcher; how to prompt for the confirm. +- **Authoring variants (agent rules)** — when to ask, block granularity, ids, count, example. +- **The confirm → finalize loop** — discover the file, reconcile, replace, clean up. +- **Edge cases** — early confirm, mind changed, endpoint absent, duplicate id. +- **Keeping it out of production** — always finalize or remove every block. +- **Contract source** — where the file path and record shape are owned. + +## 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 `{ id, chosenIndex, label }` to `POST /api/ui-variants/confirm`; the + plugin **upserts** the choice into a JSONL file keyed by `id`. **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 `` id** (not an append + log) — re-confirming a variant replaces that block's line, so the file always + reflects the current choice: + `{ "ts": "...", "id": "hero-cta", "chosenIndex": 1, "label": "Solid" }`. + Its path is relative to the dev server's cwd, not the repo root, so + **discover** it (step 4) rather than assuming a fixed location. + +## The signal is turn-based + +Do **not** start a background watcher. The confirm is recorded to the file; read +it on your next turn. The developer's "I've chosen" message is the signal to +finalize. + +**How to prompt for that signal:** + +- 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. + +## Authoring variants (agent rules) + +- **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. +- **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 (`id="hero"`, + `id="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 `id`** 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. +- Record the **file path + `id`** you used — you need both to finalize. + +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… + +``` + +## The confirm → finalize loop + +1. Author the `` block(s) in the target file. +2. Ensure `uiVariants()` is registered and the dev server is running. +3. Tell the developer to flip through variants in the browser, click **Confirm** + on the one they want, and tell you when done. Do not start a watcher. +4. 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 id + ``` +5. For the line matching your block's `id`, **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. +6. 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 `). +7. **Remove the consumed line** for that `id` from the choices file. Match the + `id` structurally (not a loose substring) so a label containing the text + can't delete the wrong line: + `tmp=$(mktemp); jq -Rc 'fromjson? | select(.id != "")' "$f" > "$tmp" && mv "$tmp" "$f"`. +8. Confirm the finalized UI back to the developer. + +## Edge cases + +- **Developer confirms before the agent asks.** The choice waits in the file; + read it whenever you next act. +- **Developer changed their mind.** The store is keyed by `id`, 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 `id` 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. + +## Contract source + +The choices-file path and record shape (`id`, `chosenIndex`, `label`) are owned +by the `uiVariants()` recorder in the installed `@databricks/appkit` package. If +finalization finds no file or a missing field, that package changed — reconcile +this skill against the version in use. diff --git a/apps/dev-playground/client/src/lib/nav.ts b/apps/dev-playground/client/src/lib/nav.ts index 4b391cb7..00f70dfe 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 8b953ed9..45028759 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 00000000..e55c8da5 --- /dev/null +++ b/apps/dev-playground/client/src/routes/ui-variants.route.tsx @@ -0,0 +1,262 @@ +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 ( +
+
+ {/* biome-ignore lint/correctness/useUniqueElementIds: the Variants `id` is a stable identifier the /ui skill matches on to finalize the chosen variant into source — not a DOM element id, so useId() would break it */} + + +
+ + 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. +

+
+
+
+ +
+ + {/* biome-ignore lint/correctness/useUniqueElementIds: the Variants `id` is a stable identifier the /ui skill matches on to finalize the chosen variant into source — not a DOM element id, so useId() would break it */} + + +
+ {[ + { + 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 ecbd18e7..0d3b0a66 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,8 @@ createApp({ reconnect(), telemetryExamples(), analytics({}), + // Dev-only recorder for the UI picker. + ...(process.env.NODE_ENV === "development" ? [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 00000000..f7f6492c --- /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.id`. + +#### 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 00000000..ace9d7ac --- /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 +`id`, and recording an existing `id` 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.id`. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `record` | [`UiChoiceRecord`](Interface.UiChoiceRecord.md) | + +#### Returns + +`Promise`\<`void`\> diff --git a/docs/docs/api/appkit/Interface.UiChoiceRecord.md b/docs/docs/api/appkit/Interface.UiChoiceRecord.md new file mode 100644 index 00000000..ac3637bb --- /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 +(`id`, `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 + +### chosenIndex + +```ts +chosenIndex: number; +``` + +Zero-based index of the chosen `` child. + +*** + +### id + +```ts +id: string; +``` + +Stable id of the `` block the developer confirmed. + +*** + +### 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 00000000..5f095dcd --- /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 4d995051..d4956e51 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. | @@ -80,6 +82,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 @@ -115,6 +118,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 c74d6232..7176d413 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", @@ -332,6 +342,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", @@ -469,6 +484,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/static/appkit-ui/styles.gen.css b/docs/static/appkit-ui/styles.gen.css index 0d24e8ea..58ecf814 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/packages/appkit-ui/src/react/index.ts b/packages/appkit-ui/src/react/index.ts index 233089b6..40e32eb3 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 "./variants"; diff --git a/packages/appkit-ui/src/react/variants/index.ts b/packages/appkit-ui/src/react/variants/index.ts new file mode 100644 index 00000000..18366a2b --- /dev/null +++ b/packages/appkit-ui/src/react/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/variants/variant.tsx b/packages/appkit-ui/src/react/variants/variant.tsx new file mode 100644 index 00000000..e3592a3d --- /dev/null +++ b/packages/appkit-ui/src/react/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/variants/variants.tsx b/packages/appkit-ui/src/react/variants/variants.tsx new file mode 100644 index 00000000..841c5ce6 --- /dev/null +++ b/packages/appkit-ui/src/react/variants/variants.tsx @@ -0,0 +1,352 @@ +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 id for this block. The variant-picking agent matches on this + * id to finalize the chosen variant into source, so it must be unique per file. + */ + id: 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({ + id, + 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({ id, 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/src/index.ts b/packages/appkit/src/index.ts index b2538073..85a04e97 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -83,6 +83,17 @@ export type { ServingEndpointRegistry, ServingFactory, } from "./plugins/serving/types"; +// UI Variants (dev-only recorder for the picker). Hidden from the +// generated barrels because the hyphenated folder name can't be an auto-export; +// re-exported here so consumers can add `uiVariants()` to their plugin list +// (and supply a custom ChoiceSink for hosted environments). The file path is an +// internal detail of FileChoiceSink, not part of the public API. +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 00000000..0bbd5ca1 --- /dev/null +++ b/packages/appkit/src/plugins/ui-variants/choice-sink.ts @@ -0,0 +1,114 @@ +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. + * + * Lives under `node_modules/.databricks/appkit/` — AppKit's own scratch + * directory (the type generator uses the sibling `.appkit-types-cache.json` + * there) — so it's gitignored by default 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 + * (`id`, `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. */ + id: 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 + * `id`, and recording an existing `id` 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.id`. */ + 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.id`, + * 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).id !== record.id; + } 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 00000000..12677c3d --- /dev/null +++ b/packages/appkit/src/plugins/ui-variants/index.ts @@ -0,0 +1,124 @@ +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 { + id?: 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 { id, chosenIndex } = body; + + if (typeof id !== "string" || id.length === 0) { + res.status(400).json({ + ok: false, + message: "`id` (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(), + id, + 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, id }); + res + .status(500) + .json({ ok: false, message: "Failed to record variant choice" }); + return; + } + + logger.info("Recorded UI variant choice", { + id, + chosenIndex, + label: record.label, + }); + res.json({ ok: true, id, 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 00000000..af08c87b --- /dev/null +++ b/packages/appkit/src/plugins/ui-variants/manifest.json @@ -0,0 +1,11 @@ +{ + "$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, + "resources": { + "required": [], + "optional": [] + } +} diff --git a/packages/shared/src/cli/commands/lint.ts b/packages/shared/src/cli/commands/lint.ts index 00eb55ad..cac5664e 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 {