uidex
v0.5.2
Published
Convention-driven UI element registry and devtools surface for React apps.
Maintainers
Readme
uidex
uidex surfaces structured context about a running UI — pages, features, widgets, regions, elements, primitives, flows — to humans (devtools overlay, command palette, detail panels), agents (typed uidex.gen.ts registry), and test runners (Playwright fixture + reporter).
This is uidex 0.1 — a greenfield rewrite. The published surface is a single npm package with framework-neutral core, an optional React adapter, and a built-in cloud client, each reachable through its own subpath export.
Install
pnpm add uidexQuick start (React)
npx uidex init # write .uidex.json and .gitignore entry
npx uidex scan # generate src/uidex.gen.ts// any React root
import { UidexProvider, UidexMount } from "uidex/react"
export default function Root({ children }: { children: React.ReactNode }) {
return (
<UidexProvider projectKey="pk_your_project">
{children}
{process.env.NODE_ENV !== "production" && <UidexMount />}
</UidexProvider>
)
}That is the full integration. Shadow DOM mounts under the <UidexMount> node, the Inspector is always on while the surface is mounted (hover highlights any uidex-annotated element; plain click opens the element menu anchored to its bounds), and Cmd+K opens the palette. Passing projectKey auto-wires the built-in cloud client; pass cloud={null} to opt out, or cloud={customAdapter} to supply your own.
Dev-only mount
Because the Inspector intercepts clicks on every uidex-annotated element while mounted, uidex is a dev-only surface. Consumers MUST gate the mount on an environment check — the SDK does not enforce this itself.
// React — gate <UidexMount> on NODE_ENV
{
process.env.NODE_ENV !== "production" && <UidexMount />
}// Vanilla — gate createUidex().mount() on NODE_ENV
if (process.env.NODE_ENV !== "production") {
createUidex({ cloud: cloud({ projectKey: "pk_..." }) }).mount()
}Quick start (vanilla)
import { createUidex } from "uidex"
import { cloud } from "uidex/cloud"
const uidex = createUidex({
cloud: cloud({ projectKey: "pk_your_project" }),
})
uidex.mount() // defaults to document.bodyPackage layout
| Subpath | Purpose |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| uidex | Vanilla core. createUidex, session store (XState v5 surface + highlight + form machines), entity registry, injected surface, panel registry, Zag machines. No React, no network. |
| uidex/react | React boot wrapper. UidexProvider, useUidex, UidexMount, createReactPanel. No network. |
| uidex/cloud | Fetch client for the cloud ingest API. cloud({ projectKey, endpoint }), FeedbackPayload, etc. No React. |
| uidex/headless | Narrower vanilla entry — Registry + Session + Shadow DOM + Overlay + Inspector + MenuBar. No panels. |
| uidex/scan | Scanner pipeline (discover → walk → extract → resolve → audit → emit) and CLI. |
| uidex/playwright | Test fixture (uidex(id)). Optional peer: @playwright/test. |
| uidex/playwright/reporter | Coverage reporter. |
The build enforces subpath isolation: uidex contains no React or network code, uidex/react contains no network code, uidex/cloud contains no React.
Entity model
Eight kinds, one registry, one ref shape:
type EntityKind =
| "route"
| "page"
| "feature"
| "widget"
| "region"
| "element"
| "primitive"
| "flow"
interface EntityRef {
kind: EntityKind
id: string
}- route — a URL pattern. Auto-detected from the framework router.
- page — the module that renders a route.
export const uidex = { page: "id", ... } as const satisfies Uidex.Pageoverrides the derived id and declares acceptance. - feature — cross-cutting behaviour under
src/features/*.export const uidex = { feature: "id", ... }overrides. - widget — composite interactive unit (video player, date picker). Declared with
data-uidex-widget="id"on the DOM root ANDexport const uidex = { widget: "id", acceptance: [...] }on the component module. The scanner cross-validates the two. - region — HTML5 landmark or
role="region".data-uidex-regionoverrides id. - element — explicit interactive element. Always annotated via
data-uidex. - primitive — reusable presentational component (button, input) under
src/ui/**. - flow — top-level
test.describeine2e/**;@uidex:flowtag adds richer metadata. Opt out withexport const uidex = { notFlow: true }.
Runtime API
import { createUidex } from "uidex"
const uidex = createUidex({
cloud: null, // or a CloudAdapter from uidex/cloud
theme: "auto", // "light" | "dark" | "auto"
defaultPanels: true, // register bundled detail + palette panels
panels: [myCustomPanel], // additional panels (last registered wins on conflict)
})
uidex.mount() // defaults to document.body
uidex.unmount()createUidex returns:
| Field | Shape |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| registry | Registry — .add, .get(kind, id), .list(kind), .query(fn). |
| session | Zustand store projecting the XState surface machine. hover, selection, stack, inspectorActive, theme, ingestActive. session.send(event) for direct machine events. |
| panels | { register, unregister, list } — panel registrar. |
| cloud | The configured CloudAdapter or null. |
| ingest | Console + network capture (auto-enabled when cloud is configured). |
| shadowRoot | The Shadow DOM root after mount(), else null. |
React wiring
import { UidexProvider, UidexMount, useUidex } from "uidex/react"<UidexProvider>acceptsprojectKey,cloud, andconfigprops. It creates the core instance on mount and disposes on unmount. IfprojectKeyis provided andcloudis not explicitly set, it auto-wirescloud({ projectKey })fromuidex/cloud.useUidex()returns the core instance and throwsUidexContextErroroutside a provider.<UidexMount>renders the host DOM node the surface attaches to; the surface detaches on unmount.
Headless
import { createHeadless } from "uidex/headless"
const h = createHeadless({ theme: "auto" })
h.mount()
h.overlay.show(el, { label: "target" })
h.inspector.start()No panels, no ViewStack. Same Shadow DOM, Overlay, MenuBar, CursorTooltip, CommandPalette (empty). Intended for agents, screencasts, and non-panel consumers.
Panel system
Panels are the pluggable UI surface. Both detail views and palette commands are Panels. Panels are framework-agnostic — render is imperative:
interface Panel {
id: string
matches?: (ref: EntityRef) => boolean // detail panel
command?: { label: string; group?: string; shortcut?: string } // palette entry
render: (ctx: PanelContext, root: HTMLElement) => () => void
}renderreceives a mount root and returns a cleanup function.- Last registered wins on
matches()overlap.idcollision replaces the prior registration. ctx.navigate(ref)pushes the matching panel forrefonto the view stack; the current view stays mounted underneath and is revealed on pop.ctx.push({ id, ref? })andctx.pop()drive the stack directly;ctx.close()clears every entry at once.ctx.cloudis the configuredCloudAdapterornull; cloud-backed affordances MUST degrade gracefully when it is null.
Authoring a panel with React:
import { createReactPanel } from "uidex/react"
export const myPanel = createReactPanel({
id: "my-panel",
matches: (ref) => ref.kind === "widget",
component: ({ ctx }) => <div>Widget: {ctx.ref?.id}</div>,
})createReactPanel wraps createRoot / unmount and produces a conforming Panel.
Element menu
Clicking any uidex-annotated element opens a popover menu anchored to the element's bounds. The menu is chrome owned by the Surface (not a Panel), composed from the shared menu + popover Zag machines. All rows close the menu on activation; Escape and outside-click dismiss and return focus to the prior focus holder.
| Row | When shown | Action |
| ---------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Copy path | Always | navigator.clipboard.writeText("<kind>:<id>"). |
| Open details | A registered panel matches the entity | session.actions.select(ref) + session.actions.openPanel(panel.id, ref) where panel = panels.findMatch(ref). |
| Submit feedback | A registered panel matches the entity | Routes to the matched detail panel (feedback currently lives as a sub-view). Suppressed if there is no detail panel for the entity. |
| View acceptance criteria | ref.kind === "widget" and a detail panel matches | Opens the widget detail panel. |
| Find usages | ref.kind === "primitive" | Queries the registry for entities composing the primitive and opens the primitive detail panel. |
The ⌘K palette provides the keyboard-driven counterpart: its "On this page" section enumerates every uidex entity present in the current DOM, and selecting a row calls ctx.navigate(ref).
Bundled panels
createUidex({ defaultPanels: true }) (the default) registers:
commandPalettePanel, componentDetailPanel (element), pageDetailPanel, featureDetailPanel, widgetDetailPanel, flowDetailPanel, primitiveDetailPanel, regionDetailPanel.
All eight are vanilla TypeScript built on Zag machines and Tailwind classes.
Report uidex issue
Every view's footer Actions popup (⌘K from depth 1, or the footer affordance) includes a Report uidex issue entry. It opens the SDK's built-in feedback form and submits to a uidex-maintained project — separate from your host cloud adapter, so SDK bugs surface to the uidex team without contaminating your own ticketing. The report is stamped with the SDK version and the originating view id (e.g. uidex-sdk:command-palette); when the SDK cannot reach its endpoint the form falls back to copying a Markdown report to the clipboard.
Scanner
One pipeline, six stages, each stage in its own file and independently callable:
discover(cwd) // locate .uidex.json files across the monorepo
-> walk(sources) // enumerate files per include/exclude
-> extract(files) // data-uidex* attrs + export const uidex AST literals
-> resolve(ann) // apply conventions, compute scopes, back-references
-> audit(registry) // diagnostics: missing, scope-leak, acceptance coverage
-> emit(registry) // deterministic uidex.gen.tsCLI:
npx uidex init # scaffold .uidex.json
npx uidex scan # run full pipeline
npx uidex scan --check # fail on registry drift (CI gate)
npx uidex scan --lint # annotation lint diagnostics (includes legacy-jsdoc)
npx uidex scan --audit # --check + --lint + coverage
npx uidex scan --json # machine-readable output
npx uidex scaffold widget <id> # emit Playwright spec from declared acceptance
npx uidex claude install # Claude Code rules + skill + hooksSee the docs site for the convention table, annotation surfaces, and the acceptance workflow.
Bundler plugins
Optional opt-in watch integrations regenerate uidex.gen.ts on file save:
@uidex/vite-plugin—plugins: [uidex()]invite.config.ts.@uidex/webpack-plugin—new UidexPlugin()inwebpack.config.js.@uidex/next-plugin—withUidex(config)wrapper fornext.config.*. Webpack transport today; Turbopack support tracked as a follow-up.
All three wrap @uidex/plugin-core so behaviour and diagnostic shapes are identical across bundlers.
Config (.uidex.json)
Flat schema. Legacy nested scanner.*, defaults, colors are rejected with descriptive errors.
{
"$schema": "./node_modules/uidex/uidex.schema.json",
"sources": [
{
"rootDir": "src",
"include": ["**/*.{ts,tsx}"],
"exclude": ["**/*.test.*"]
}
],
"output": "src/uidex.gen.ts",
"flows": ["e2e/**/*.spec.ts"],
"typeMode": "strict",
"audit": { "scopeLeak": true, "coverage": true, "acceptance": true },
"conventions": {
"primitives": ["src/ui/**", "src/components/ui/**"],
"features": "src/features/*",
"pages": "auto",
"flows": ["e2e/**/*.spec.ts"],
"regions": "landmarks"
}
}Set any conventions.* entry to false to disable that auto-discovery stage.
typeMode controls whether emitted id types are literal unions ("strict", default) or string ("loose", a temporary migration knob).
Acceptance workflow
Declare criteria in the module-scoped export const uidex:
import type { Uidex } from "@/uidex.gen"
export const uidex = {
widget: "video-player",
acceptance: [
"Plays on space",
"Mutes with m",
"Scrubs with arrow keys",
],
} as const satisfies Uidex.Widget
export function VideoPlayer() {
return <div data-uidex-widget="video-player">...</div>
}- Scanner extracts
acceptance→entity.meta.acceptance(in source order). - Flows touching the widget (directly or via descendants) are collected into
meta.flows. scan --auditflags any criterion not covered by a flow; the hint suggestsuidex scaffold widget <id>.uidex scaffold widget video-playeremits a Playwright spec with onetest()per criterion, tagged@uidex:flow. Re-running is idempotent unless--forceis passed.
Cloud + ingest
Configure cloud by passing a CloudAdapter into createUidex({ cloud }) or, from React, by passing projectKey / cloud to <UidexProvider>. The built-in client lives at uidex/cloud:
import { cloud } from "uidex/cloud"
const adapter = cloud({ projectKey: "pk_..." })
await adapter.feedback.submit(payload)
await adapter.integrations.getConfig()cloud() takes { projectKey, endpoint?, fetch? }. endpoint defaults to the production cloud URL; fetch allows tests or self-hosted deployments to inject a custom transport. Errors surface as CloudError with status, retryAfter, and details.
When cloud is configured, uidex/ingest auto-enables:
- Console capture:
warn/error, ring buffer ≤ 50. Originals always invoked. - Network capture: failed fetches only, ring buffer ≤ 20. Native
fetchreference is captured at module load, socloud.feedback.submitalways uses an un-intercepted fetch.
Override per-channel via createUidex({ ingest: { console: {...}, network: null } }), or pass ingest: null to disable entirely.
Playwright
import { test, expect } from "uidex/playwright"
test.describe("Add and complete a todo", { tag: "@uidex:flow" }, () => {
test("adds a todo", async ({ uidex }) => {
await uidex("todo-text-field").fill("Buy milk")
await uidex("todo-add-button").click()
await expect(uidex("todo-item")).toHaveCount(1)
})
})Resolves any data-uidex, data-uidex-region, data-uidex-widget, or data-uidex-primitive. Register the coverage reporter to emit flow → entity coverage data — it is additive, compatible with any other reporter.
// playwright.config.ts
reporter: [["list"], ["uidex/playwright/reporter"]]Scripts
pnpm build # build:css + tsup + check:bundles
pnpm test # vitest (watch)
pnpm test:run # vitest (run)
pnpm typecheck # tsc --noEmit
pnpm lint # eslint src/