@flanksource/clicky-ui
Flanksource's React component library built on shadcn/ui with light/dark theming and density presets.
Install
pnpm add @flanksource/clicky-ui @tanstack/react-query react react-dom tailwindcss
npm install @flanksource/clicky-ui @tanstack/react-query react react-dom tailwindcss
yarn add @flanksource/clicky-ui @tanstack/react-query react react-dom tailwindcss
bun add @flanksource/clicky-ui @tanstack/react-query react react-dom tailwindcss
Usage
import { Button } from "@flanksource/clicky-ui/components";
import { ThemeProvider, DensityProvider } from "@flanksource/clicky-ui/hooks";
import "@flanksource/clicky-ui/styles.css";
export function App() {
return (
<ThemeProvider>
<DensityProvider>
<Button variant="default">Click me</Button>
</DensityProvider>
</ThemeProvider>
);
}
Runtime settings
Compose SpecRuntimeBar with the detailed SpecRuntimeEditor from @flanksource/clicky-ui/ai. The bar owns mode, model, effort, permission posture, cost, timeout, source/worktree, and commit timing. The detailed editor provides prompt, fallback, tool-policy, environment, checkout, and workflow details. Token and turn limit inputs are omitted; existing values survive edits and serialization.
Session ID is editable only with variant="preset". Environment variables always use SecretKeySelector for source, resource name, and key. Supply secretSelector catalog loaders to offer resource and key suggestions; without a catalog, users enter reference names and keys directly. Literal values remain a separate source. Shared comboboxes and dropdown menus expand to the available viewport height and scroll when their options exceed it.
import { useState } from "react";
import {
SpecRuntimeBar,
SpecRuntimeEditor,
type AISpecRuntimeValue,
} from "@flanksource/clicky-ui/ai";
export function RuntimeSettings() {
const [value, setValue] = useState<AISpecRuntimeValue>({ mode: "agent" });
return (
<SpecRuntimeEditor
value={value}
onChange={setValue}
beforeSections={<SpecRuntimeBar value={value} onChange={setValue} />}
/>
);
}
Pass the host's models and families catalogs to both components. effectiveMode and effectiveModel describe inherited identity without copying it into the draft. Set readOnly on both components for protected layers. inheritedCommits supplies the lower layer's commit policy; an explicit empty commit list selects Never. variant="preset" omits task-owned worktree paths, and sections restricts bar actions for hosts such as verification-only editors. Use PromptRunEditor for a run composer with single/multi-model runtime bars and shared limits.
Verification details
VerificationResults renders typed fixture steps through verificationAdapters(). Detail explains execution: commands, assertions, a compact wait summary, events and exported variables. Outputs contains the selected attempt's final structured result, including null and empty objects. The header's attempt selector appears for multiple attempts and defaults to the latest; selecting an attempt switches its execution, wait, Outputs and Copy details together, while the tree and run totals retain the step's overall verdict. Reports without stored historical evidence say so when that attempt is selected.
Waits retain the effective policy, outcome, actual elapsed durationMs, final outputs, and optional terminal error and trace. Intermediate poll values are not persisted. The summary shows the condition, elapsed badge, outcome and truncated output preview on one line; the full condition is available on hover, full outputs in the Outputs tab, and schedule/error diagnostics in the Wait details disclosure. TestRunner and VerificationResults accept activeAttempt and onAttemptChange for hosts that store the selection in the URL. Domain adapters supply { id, status, durationMs, node } views through detailAttempts.
Hosts can replace just the wait section without replacing the rest of the fixture renderer:
import {
VerificationResults,
verificationAdapters,
type VerifyReport,
} from "@flanksource/clicky-ui/data";
const adapters = verificationAdapters({
renderWait: (wait) => (
<p>
{wait.outcome}: {wait.durationMs}ms
</p>
),
});
export function VerificationPanel({ report }: { report: VerifyReport }) {
return <VerificationResults report={report} adapters={adapters} />;
}
The optional renderWait callback receives the parsed final WaitDetail. Omitting it uses the compact row; returning null deliberately hides the wait section. StepWait exposes the standard rendering for custom compositions, and StepOutputs renders final outputs independently of the execution sections.
Typed properties
Properties displays metadata as a two-column list. Each item has a stable key, a raw value, and an optional type and unit. Objects and arrays use the expandable JSON viewer; strings remain literal unless type: "json" is specified. Numeric types require finite numbers, and formatted values retain the original value in a tooltip.
import { Properties } from "@flanksource/clicky-ui/data";
export function RuntimeProperties() {
return (
<Properties
keyRef="runtime"
items={[
{ key: "memory", value: 1048576, type: "bytes" },
{ key: "uptime", value: 90000, type: "duration", unit: "ms" },
{ key: "load", value: 0.25, type: "number", unit: "percentunit" },
{ key: "enabled", value: false, type: "boolean" },
{ key: "started", value: 1704067200000, type: "date" },
{ key: "config", value: '{"enabled":true}', type: "json" },
]}
/>
);
}
type accepts text, json, bytes, duration, number, boolean, or date. Bytes use binary scaling. Durations default to milliseconds; set unit: "s" for seconds. Numeric units also accept the shared unit registry, including percent, percentunit, and Bps. Dates accept a Date, an ISO string, or a timestamp in milliseconds. keyRef persists the density selected in the display menu. Custom renderValue renderers override typed formatting, and prefixActions or suffixActions add per-row actions without changing the raw value.
Clicky AST Renderer
import { Clicky, type ClickyDocument } from "@flanksource/clicky-ui/clicky";
const document: ClickyDocument = {
version: 1,
node: {
kind: "text",
text: "hello from clicky",
plain: "hello from clicky",
},
};
export function ClickyPanel() {
return <Clicky data={document} />;
}
Clicky also accepts a JSON string payload. The intended producer is the sibling clicky repo's tagged html-react AST, which preserves structural types such as trees, tables, code blocks, collapsed sections, buttons, and nested text content.
Operation explorer
OperationCatalog and EntityExplorerApp (both exported from @flanksource/clicky-ui/rpc) render an OpenAPI spec — fetched via an OperationsApiClient — as a navigable list of operations grouped by entity surface. They expect the spec to declare x-clicky surface metadata for the surfaces they should expose. See apps/kitchen-sink/src/demos/OperationExplorerDemo.tsx for a fake-client example.
Timeseries widgets and WorkloadCard
TimeseriesPanel, TimeseriesGauge, TimeseriesCoreBars and WorkloadCard (from @flanksource/clicky-ui/data) poll their series with @tanstack/react-query, so they must render under the host application's QueryClientProvider. React Query v5 is a required peer dependency, ensuring clicky-ui and its host use the same Query Client context rather than installing isolated copies.
Each series is either URL-backed (baseUrl + id, loaded through fetcher(url)) or function-backed via load:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WorkloadCard } from "@flanksource/clicky-ui/data";
const queryClient = new QueryClient();
<QueryClientProvider client={queryClient}>
<WorkloadCard
workload={{
type: "EC2 instance",
name: "build-runner-1",
status: { label: "running", health: "healthy" },
metadata: [{ label: "region", value: "us-east-1" }],
}}
metrics={{
cpu: {
value: {
id: "i-0a1b2c3d.cpu", // cache identity: unique per data source
load: ({ range, signal }) =>
api.instanceCpu("i-0a1b2c3d", { range, signal }),
},
max: 4000,
},
}}
/>
</QueryClientProvider>;
A load-backed series is cached under ["timeseries", "load", id, range]: the id is the cache identity, so two series that share an id share one cached response. The loader receives the widget's range and react-query's AbortSignal, which aborts when the query is cancelled (for example on unmount). URL-backed series keep their ["timeseries", requestUrl] key. See the timeseries guide for details.
Change listeners (x-on-change)
A property's x-on-change entries act on its sibling fields while when holds (and else while it does not). hide/show, enable/disable, require/optional and patch are derived state, recomputed from the current value on every render. set and reset fire only when the field is edited. patch merges standard keywords over a sibling, so a listener can make it readOnly or writeOnly, or change its title, enum or bounds:
"Status": {
"type": "string",
"enum": ["draft", "signed"],
"x-on-change": [{
"when": { "const": "signed" },
"patch": {
"Amount": { "readOnly": true },
"Rate": { "title": "Agreed rate", "minimum": 0, "maximum": 25 }
}
}]
}
A patch may not change a field's shape (type, properties, items, allOf, …), its required-ness or its value (default, const); the form throws, pointing at the action that does. The Form listeners kitchen-sink demo has runnable examples of every action.
A hide/show/enable/disable target or a patch key may be a /-separated path into a sibling's subtree: groups/Rates hides a group, and groups/Rates/Fee hides one column of it, because an array is crossed into its items. An object's x-on-load entries have the same shape. Their when reads the object itself, they apply before any x-on-change, and they allow only the derived actions and patch. See the JSON Schema extensions reference in the docs app for the full grammar and ordering.
A writeOnly: true field is an input that is never read back: an editable form renders it normally, while a view renders nothing for it. A view is a readOnly form, anything under a readOnly object, array or map, or a field that is itself readOnly. x-disabled is not a view, so a disabled subtree keeps its controls. Use format: "password" to mask the value while it is edited.
Markdown editor field
JsonSchemaForm fields with format: md — and the standalone MdxEditorField exported from @flanksource/clicky-ui/mdx-editor — render an MDXEditor-backed rich-text field. Its base styles ship as a separate stylesheet so apps that don't use the field don't pay its weight (the editor's JavaScript is also loaded lazily, on first render). Import it once at the app root, in addition to styles.css:
import "@flanksource/clicky-ui/styles.css";
import "@flanksource/clicky-ui/mdx-editor.css"; // only if you render the markdown field
Without this import the field renders, but its toolbar and content are unstyled.
If Markdown cannot be imported, the field displays the parsing error and preserves the document in a source editor. Correct the source and choose Retry rich text to reopen the rich-text editor. Source edits use the same onChange callback; read-only and disabled fields remain protected. Loading another document through value retries its import automatically.
Bundle size guidance
Prefer subpath imports in production apps:
import { Button } from "@flanksource/clicky-ui/components";
import { cn } from "@flanksource/clicky-ui/utils";
import { DataTable } from "@flanksource/clicky-ui/data";
The root @flanksource/clicky-ui barrel remains supported for compatibility and convenience, but subpaths give bundlers a smaller entry surface. Import @flanksource/clicky-ui/styles.css once at the app root. Markdown parsing and code highlighting are loaded asynchronously by their components.
The published package uses plain semver dependency ranges and does not require pnpm workspace or catalog protocols. The package ships ESM and CommonJS entrypoints for the public subpaths above.
Expression language support
Import gomplate-compatible Monaco language support from @flanksource/clicky-ui/expressions, or the optional React playground from @flanksource/clicky-ui/expressions/playground. The language entry registers CEL, Go templates, JSONPath, and templates embedded in YAML, JSON, or text without importing the playground UI. See the expressions guide.
UI subpaths that render icons are intended for browser bundlers such as Vite, Rollup, Webpack, Rspack, and Bun's bundler. The Iconify React packages expose .jsx modules and CSS imports, so plain Node import/require of icon-heavy subpaths is not a supported verification target.
IntelliJ icon catalog
Import the offline components from @flanksource/clicky-ui/icons:
import {
UiBreakpoint,
UiMessageQueue,
UiSqlPrimaryKey,
} from "@flanksource/clicky-ui/icons";
The jb-site: entries in icons/icon-selections.json resolve against the IntelliJ icon catalog. Run pnpm --filter @flanksource/clicky-ui download:icons after changing selections, then pnpm --filter @flanksource/clicky-ui build:icons:force. The downloader checks each catalog path and requires an Apache 2.0 header in every downloaded SVG. It stores URL-fetched sources from JetBrains, Iconify, and other providers under icons/svg/remote; generated React components are built from those local files. The per-component source list is in NOTICE.md.
Downloaded IntelliJ icon ZIPs can be imported with pnpm --filter @flanksource/clicky-ui exec tsx scripts/import-jetbrains-zips.ts <download-dir> <since-ISO> --licensed-only. The command extracts each light/dark pair into icons/svg/downloaded, adds programming selections, and writes icons/jetbrains-download-audit.json for the processed archives. Use --include-without-header instead to explicitly include pairs without an embedded Apache header. Those pairs use jb-download-unverified: source IDs and appear in the audit's withoutHeader list; their license is not verified by the import. Both SVG directories are offline build inputs; remote distinguishes URL-fetched artwork from ZIP-extracted artwork. Rebuild with build:icons:force after importing. Each concept has a preferred Ui<Concept> icon and numbered alternatives such as UiFunction1 and UiFunction2. Imported icons have matching Dark variants, while existing preferred components keep their artwork. programmingIconCatalog supplies typed concept, variation, color-role, and header-status metadata for palette pages.
Markdown and code highlighting use optional peer dependencies (marked, shiki, and @shikijs/*). Install them in applications that render those components.
Tailwind preset
Tailwind CSS v4 consumers can load the preset through its compatibility directive:
/* src/styles.css */
@config "../tailwind.config.ts";
@import "tailwindcss";
// tailwind.config.ts
import preset from "@flanksource/clicky-ui/tailwind-preset";
export default {
presets: [preset],
};
The preset wires up:
- Theme tokens via
[data-theme="light" | "dark"]attributes on<html>. - Density variants via
[data-density="compact" | "comfortable" | "spacious"]. - Spacing utilities (
gap-density-2,p-density-4, etc.) scaled by the active density.
Theming
const { theme, resolvedTheme, setTheme } = useTheme();
const { density, setDensity } = useDensity();
Both hooks persist their choice to localStorage under clicky-ui-theme / clicky-ui-density. Add this inline script to <head> to avoid FOUC:
<script>
(function () {
try {
var t = localStorage.getItem("clicky-ui-theme") || "system";
var d = localStorage.getItem("clicky-ui-density") || "comfortable";
var resolved =
t === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: t;
document.documentElement.setAttribute("data-theme", resolved);
document.documentElement.setAttribute("data-density", d);
} catch (_) {}
})();
</script>
Multiplexing SSE streams
Browsers cap HTTP/1.1 at six connections per host, shared across tabs, so an app that opens one native EventSource per topic can starve its own fetches. createEventHub returns an EventSourceFactory that multiplexes every stream the hooks open (useTaskRun, usePrompts, useLogTail, ...) over one real connection to a server events endpoint (such as clicky's sse.Hub). Wrap the tree in EventSourceProvider once:
import {
EventSourceProvider,
createEventHub,
} from "@flanksource/clicky-ui/hooks";
// Create the hub once at module scope so every render shares one connection.
// `buildId` is the build the page was served with (e.g. from a <meta> tag);
// when the server's hello reports a different build the page reloads once.
// Omit it (or pass null) to never reload.
const eventHub = createEventHub({
buildId: document.querySelector<HTMLMetaElement>('meta[name="app-build"]')
?.content,
});
root.render(
<EventSourceProvider value={eventHub}>
<App />
</EventSourceProvider>,
);
Options: url (default /api/events), buildId (string | null), and onBuildMismatch (default window.location.reload()). Each hub keeps its own connection, so two hubs never share state.
Lint rules
The package ships opt-in oxlint rules that keep consuming projects on clicky-ui's components, overlays, theming and icons instead of rebuilding them by hand.
Install oxlint in the consuming project. If the project does not already depend
on @flanksource/clicky-ui, add that as the normal runtime dependency too:
pnpm add @flanksource/clicky-ui
pnpm add -D oxlint
Then enable the JS plugin in the consumer's .oxlintrc.json:
{
"jsPlugins": [
{
"name": "clicky-ui",
"specifier": "@flanksource/clicky-ui/oxlint-plugins"
}
],
"rules": {
"clicky-ui/prefer-clicky-components": "warn",
"clicky-ui/no-adhoc-overlay": "error",
"clicky-ui/prefer-tailwind-classes": "warn",
"clicky-ui/prefer-theme-tokens": "error",
"clicky-ui/prefer-clicky-icons": "warn"
}
}
Add a package script that runs oxlint over the consumer source:
{
"scripts": {
"lint:clicky-ui": "oxlint -c .oxlintrc.json src --deny-warnings"
}
}
The specifier form resolves through the package export. In a local sibling
checkout, or if the package export is not available to your toolchain, point the
plugin directly at the file instead:
{
"jsPlugins": [
"./node_modules/@flanksource/clicky-ui/oxlint-plugins/clicky-ui.js"
]
}
The rules flag rebuilt primitives (<button>, <table>, …), ad-hoc dialogs and
arbitrary z-index, hand-rolled inline styles, hardcoded colors / raw theme
storage access, and icons imported from unapproved libraries or faked with
emoji. See oxlint-plugins/README.md for the full
guide.
Comments in an external document
CommentProvider accepts onScrollToAnchor(anchor, options) and getAnchorTop(anchor) for hosts whose annotated document lives outside the rail's frame. onScrollToAnchor must return whether the anchor is available and navigation was dispatched; getAnchorTop returns its current viewport offset, or null when unavailable. Supplying a navigation delegate replaces local DOM scrolling. Without these callbacks the provider retains its registered-element behavior. Use CommentSidePanel with focusedAlignment="flow" for a detached rail, and synchronize selection and highlighting through useCommentContext.
<CommentProvider
comments={comments}
config={config}
initialRailMode="all"
getAnchorTop={(anchor) => documentAnchors.get(anchor)?.top ?? null}
onScrollToAnchor={(anchor, options) => {
if (!documentAnchors.has(anchor)) return false;
annotatedWindow.postMessage(
{ type: "scroll", anchor, options },
annotatedOrigin,
);
return true;
}}
>
<CommentSidePanel focusedAlignment="flow" />
</CommentProvider>
The host owns message validation, comment persistence and mutation callbacks. See the playground bookmarklet for an origin-checked bridge and an external-page review surface.
License
Apache-2.0