{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "workflow-button",
  "title": "Workflow Button",
  "author": "Lloyd Humphreys",
  "description": "EXPERIMENTAL — the API is still settling and will change in breaking ways; pin what you install. A dependency-free split button that drives an entity through workflow stages: the primary button advances the happy path (re-labelling itself per stage), and an attached caret menu jumps anywhere the flow allows — hidden entirely when nothing is reachable. The flow is data: per-step `to` transition lists (to[0] = happy path, to: [] = terminal), an app `context` threaded into every resolver for role-aware disabling and prominence, per-step emphasis via `advanceVariant` (all shadcn Button variants, 'primary' aliased to 'default'), per-step icons/colors/attribution meta, and renderPrimary/renderItem slots. Styled on shadcn theme tokens with light-dark() fallbacks. Ships a framework-agnostic vanilla core plus a React wrapper.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/workflow-button/workflow-button.ts",
      "content": "// workflow-button — a zero-dependency split button that drives a flow through stages.\n//\n// EXPERIMENTAL: the API is still settling and will change in breaking ways.\n//\n// One control, two jobs. The primary button performs the *default advance* — one click\n// moves the entity to the next stage (Draft → In review → Approved → Published). The\n// attached caret opens a menu of *every* stage, so you can jump anywhere the flow allows,\n// not just forward. Which stages are reachable is a predicate you supply, so \"in DAG mode\n// you can't go back\" is one line (see `forwardOnly`).\n//\n// The primary re-labels itself as you move: at Draft it reads \"Submit for review\", at\n// Approved \"Publish\", and at the end it settles into a disabled readout of the final stage.\n//\n// Framework-agnostic vanilla DOM — no dependencies, no build step. A thin React wrapper\n// (<WorkflowButton> + useWorkflow) lives in workflow-button-react.tsx.\n//\n// ── Theming ────────────────────────────────────────────────────────────────────────────\n// Styles consume the shadcn theme tokens when present (`--primary`, `--popover`,\n// `--accent`, `--border`, `--input`, `--ring`, `--radius`, …) so inside a shadcn app the\n// control matches <Button>/<DropdownMenu> with zero configuration. Every token has a\n// `light-dark()` fallback mirroring shadcn's zinc defaults, so it also reads correctly\n// standalone. To override independently of the app theme, set the `--wf-*` variables on\n// the host (or an ancestor) — each wins over its shadcn counterpart:\n//   --wf-radius         corner radius                (--radius, 0.625rem)\n//   --wf-primary-bg     primary button background    (--primary)\n//   --wf-primary-fg     primary button text          (--primary-foreground)\n//   --wf-border         outline/menu border color    (--input / --border)\n//   --wf-hover          hover wash                   (--accent)\n//   --wf-menu-bg        menu background              (--popover)\n//   --wf-menu-fg        menu text                    (--popover-foreground)\n//   --wf-muted          secondary text / disabled    (--muted-foreground)\n//   --wf-ring           focus ring color             (--ring)\n//\n// React apps that want *literal* shadcn parts (real <Button> + <DropdownMenu>, ReactNode\n// icons/children) should install `workflow-button-shadcn` instead — same flow semantics,\n// composed from shadcn primitives.\n//\n// State ownership: self-managed by default (`manageState: true` — the control tracks its\n// own `current`), because vanilla usage often has no host state to defer to. Both React\n// wrappers flip this to host-owned (`current` + `onMove` as the whole contract) since a\n// workflow stage is domain data that belongs in your app state, not a widget.\n\n/**\n * How the split button presents — shadcn's Button variants, plus 'primary' as an alias\n * for 'default' (many design systems call the filled one \"primary\"; both work). 'link'\n * is deliberately unsupported: a link-styled split button with a caret trigger isn't a\n * coherent control.\n */\nexport type WorkflowVariant =\n  | 'default'\n  | 'primary'\n  | 'secondary'\n  | 'outline'\n  | 'ghost'\n  | 'destructive'\n\n/** Collapse the 'primary' alias so styling only deals in canonical names. */\nexport function normalizeVariant(\n  v: WorkflowVariant,\n): Exclude<WorkflowVariant, 'primary'> {\n  return v === 'primary' ? 'default' : v\n}\n\n/** One stage in the flow. */\nexport interface WorkflowStep {\n  /** Stable identity — what `current` points at and `onMove` reports. */\n  id: string\n  /** Menu label, and the primary's label when this step is the advance target\n   *  (unless `advanceLabel` overrides it). */\n  label: string\n  /** Primary-button label when advancing *to* this step, e.g. \"Submit for review\".\n   *  Use a verb here; `label` (the noun) is the fallback. */\n  advanceLabel?: string\n  /**\n   * How prominent the primary should be when this step is the advance target — the\n   * emphasis lives on the *destination*, like `advanceLabel`. The idiom: give the control\n   * a quiet base (`variant: 'outline'`) and mark the stages that demand action with\n   * `advanceVariant: 'default'` (or `'destructive'` for high-consequence moves). Falls\n   * back to the control's `variant`.\n   */\n  advanceVariant?: WorkflowVariant\n  /** Optional secondary line under the label in the menu. */\n  description?: string\n  /**\n   * Instance annotation — who did this step, when (\"Astrid · 2d ago\"). When present it\n   * REPLACES `description` as the line under the label: the definition's hint gives way\n   * to what actually happened. For richer content use the `renderItem` slot.\n   */\n  meta?: string\n  /** Optional status color (any CSS color) — a dot beside the label, in the menu and on the\n   *  primary when this step is the target. Ignored when `icon` is set. */\n  color?: string\n  /** Optional icon factory — returns a node (e.g. an SVG) shown instead of the color dot,\n   *  in the menu and on the primary. Called per render; return a fresh node each time. */\n  icon?: () => Node\n  /** Hard-disable jumping to this step, regardless of `canMoveTo`. */\n  disabled?: boolean\n  /**\n   * Explicit transitions out of this step — the workflow definition as data (the\n   * state-machine model, in contrast to code-first engines like Cloudflare Workflows\n   * whose graph only exists by running it; a UI has to *render* possibilities, so it\n   * wants the graph declared). When present:\n   *  - `to[0]` is the happy path — the primary's advance target,\n   *  - the menu enables exactly these ids (plus wherever `canMoveTo` further restricts),\n   *  - `to: []` marks an explicitly terminal stage.\n   * When absent, array order applies (advance = next in array, any step reachable).\n   */\n  to?: string[]\n}\n\n/**\n * Resolve the advance target from a step. Return null for a terminal stage. `context` is\n * whatever the app passed as `options.context` (viewer role, permissions, instance data —\n * the analogue of a workflow engine's event params).\n */\nexport type NextResolver<TCtx = unknown> = (\n  current: WorkflowStep,\n  steps: WorkflowStep[],\n  context: TCtx | undefined,\n) => string | null\n\n/** May we move `from` → `to`? Governs the primary's enablement and each menu item. */\nexport type MovePredicate<TCtx = unknown> = (\n  to: WorkflowStep,\n  from: WorkflowStep,\n  steps: WorkflowStep[],\n  context: TCtx | undefined,\n) => boolean\n\n/** Advance target by array order: the next step, or null at the end. */\nexport const nextInOrder: NextResolver = (current, steps) => {\n  const i = steps.findIndex((s) => s.id === current.id)\n  return i >= 0 && i < steps.length - 1 ? steps[i + 1].id : null\n}\n\n/** The built-in `next`: the step's `to[0]` (happy path) when declared, else array order. */\nexport const defaultNext: NextResolver = (current, steps, context) => {\n  if (current.to) return current.to[0] ?? null\n  return nextInOrder(current, steps, context)\n}\n\n/** The built-in `canMoveTo`: membership in the step's `to` list when declared, else any\n *  non-disabled step. Role/permission gating layers on top via your own predicate. */\nexport const defaultCanMoveTo: MovePredicate = (to, from) => {\n  if (to.disabled) return false\n  if (from.to) return from.to.includes(to.id)\n  return true\n}\n\n/**\n * A `canMoveTo` predicate for DAG / one-way flows over array order: only steps *after*\n * the current one are reachable — advance or skip ahead, never back. (Flows that declare\n * `to` lists encode their DAG directly and don't need this.)\n */\nexport const forwardOnly: MovePredicate = (to, from, steps) => {\n  if (to.disabled) return false\n  const fromI = steps.findIndex((s) => s.id === from.id)\n  const toI = steps.findIndex((s) => s.id === to.id)\n  return toI > fromI\n}\n\nexport interface WorkflowButtonOptions<TCtx = unknown> {\n  steps: WorkflowStep[]\n  /** Id of the current stage. */\n  current: string\n  /**\n   * App data threaded, verbatim, into every resolver (`next`, `canMoveTo`, `variantFor`,\n   * `advanceLabelFor`) — the viewer's role/permissions, the instance's assignee, anything.\n   * Update it via `setState({ context })` and the control re-renders reachability and\n   * emphasis. This is how role-aware prominence AND role-aware disabling stay pure data:\n   * `canMoveTo: (to, from, steps, ctx) => ctx.role === 'reviewer'` etc.\n   */\n  context?: TCtx\n  /** Advance target resolver. Default `defaultNext` (`to[0]` when declared, else array order). */\n  next?: NextResolver<TCtx>\n  /** Reachability predicate. Default `defaultCanMoveTo` (`to`-membership when declared,\n   *  else any non-disabled step). Pass `forwardOnly` for array-order DAG flows. */\n  canMoveTo?: MovePredicate<TCtx>\n  /**\n   * Fired on advance (primary) or a menu pick, with the target and previous ids. Return\n   * `false` to veto the built-in state update — do that when the parent owns `current` (async\n   * saves, React props); then reflect the new stage via `setState({ current })`.\n   */\n  onMove: (toId: string, fromId: string) => void | boolean\n  /** Advance the control's own `current` on a move (default true). Set false when a parent\n   *  owns the state and drives it through `setState`. */\n  manageState?: boolean\n  /** Override the primary label. Falls back to `target.advanceLabel ?? target.label`. */\n  advanceLabelFor?: (\n    target: WorkflowStep,\n    from: WorkflowStep,\n    context: TCtx | undefined,\n  ) => string\n  /**\n   * Fully own the primary button's content: return a node (or string) and it replaces the\n   * built-in icon/dot + label; return null to fall back to the default content for this\n   * render. `target` is null at a terminal stage.\n   */\n  renderPrimary?: (ctx: {\n    target: WorkflowStep | null\n    current: WorkflowStep\n  }) => Node | string | null\n  /** shadcn Button sizes: h-8 / h-9 / h-10. Default 'default'. */\n  size?: 'sm' | 'default' | 'lg'\n  /** The base presentation — what the control looks like when no step overrides it.\n   *  Default 'default' (filled). Use 'outline' as the quiet base for per-step emphasis. */\n  variant?: WorkflowVariant\n  /**\n   * Dynamic emphasis resolver — wins over `advanceVariant` and `variant`. Use it when\n   * prominence depends on more than the stage (e.g. only the assigned reviewer sees a\n   * loud \"Approve\"). Return null/undefined to fall through.\n   */\n  variantFor?: (\n    target: WorkflowStep,\n    from: WorkflowStep,\n    context: TCtx | undefined,\n  ) => WorkflowVariant | null | undefined\n  /**\n   * Own a menu item's content: return a node and it replaces the default\n   * icon/label/description/meta row (the current-step check stays); return null to keep\n   * the default for that step. Reachability/disabling still apply outside the slot.\n   */\n  renderItem?: (\n    step: WorkflowStep,\n    state: { isCurrent: boolean; reachable: boolean },\n  ) => Node | null\n  /** Accessible name for the menu trigger. Default \"Choose stage\". */\n  menuLabel?: string\n  /** Inject the stylesheet on first use (default true). */\n  injectStyles?: boolean\n  /** Extra class(es) on the root, for your own overrides. */\n  className?: string\n}\n\nexport interface WorkflowButton<TCtx = unknown> {\n  /** The control root (a `role=\"group\"`). Append it wherever you like. */\n  readonly element: HTMLElement\n  getCurrent(): string\n  /** The id the primary would advance to right now, or null if terminal/blocked. */\n  getAdvanceTarget(): string | null\n  /** Patch state in place (current, steps, context) and re-render. */\n  setState(\n    patch: Partial<Pick<WorkflowButtonOptions<TCtx>, 'steps' | 'current' | 'context'>>,\n  ): void\n  /** Programmatically advance (same as clicking the primary). No-op if terminal/blocked. */\n  advance(): void\n  /** Programmatically move to a step, if reachable. */\n  moveTo(id: string): void\n  /** Detach listeners. Call before dropping `element`; then `element.remove()`. */\n  destroy(): void\n}\n\n/** Build a workflow split button. */\nexport function createWorkflowButton<TCtx = unknown>(\n  opts: WorkflowButtonOptions<TCtx>,\n): WorkflowButton<TCtx> {\n  if (opts.injectStyles !== false) injectWorkflowStyles()\n\n  const next = (opts.next ?? defaultNext) as NextResolver<TCtx>\n  const canMoveTo = (opts.canMoveTo ?? defaultCanMoveTo) as MovePredicate<TCtx>\n  const manageState = opts.manageState ?? true\n  const size = opts.size ?? 'default'\n  const baseVariant = opts.variant ?? 'default'\n\n  let steps = opts.steps\n  let current = opts.current\n  let context = opts.context\n\n  const stepById = (id: string) => steps.find((s) => s.id === id)\n\n  /**\n   * The variant for this render: `variantFor` → the target's `advanceVariant` → the base.\n   * A terminal stage reads as a quiet 'secondary' readout (outline base stays outline).\n   */\n  const resolveVariant = (\n    target: WorkflowStep | null,\n    cur: WorkflowStep,\n  ): Exclude<WorkflowVariant, 'primary'> => {\n    if (target) {\n      return normalizeVariant(\n        opts.variantFor?.(target, cur, context) ??\n          target.advanceVariant ??\n          baseVariant,\n      )\n    }\n    const base = normalizeVariant(baseVariant)\n    return base === 'outline' || base === 'ghost' ? base : 'secondary'\n  }\n\n  const root = document.createElement('div')\n  const baseClass =\n    `workflow-button wf-size-${size}` +\n    (opts.className ? ` ${opts.className}` : '')\n  const applyRootState = (variant: WorkflowVariant, terminal: boolean) => {\n    root.className =\n      `${baseClass} wf-variant-${variant}` +\n      (terminal ? ' is-terminal' : '') +\n      (open ? ' is-open' : '')\n  }\n  root.setAttribute('role', 'group')\n\n  // ── Primary ────────────────────────────────────────────────────────────────\n  const primary = document.createElement('button')\n  primary.type = 'button'\n  primary.className = 'wf-primary'\n\n  // ── Caret trigger ──────────────────────────────────────────────────────────\n  const trigger = document.createElement('button')\n  trigger.type = 'button'\n  trigger.className = 'wf-trigger'\n  trigger.setAttribute('aria-haspopup', 'menu')\n  trigger.setAttribute('aria-expanded', 'false')\n  trigger.setAttribute('aria-label', opts.menuLabel ?? 'Choose stage')\n  trigger.innerHTML = caretSvg()\n\n  // ── Menu ───────────────────────────────────────────────────────────────────\n  const menu = document.createElement('div')\n  menu.className = 'wf-menu'\n  menu.setAttribute('role', 'menu')\n  menu.hidden = true\n\n  root.appendChild(primary)\n  root.appendChild(trigger)\n  root.appendChild(menu)\n\n  let items: HTMLButtonElement[] = []\n  let open = false\n\n  const advanceTargetId = (): string | null => {\n    const cur = stepById(current)\n    if (!cur) return null\n    const targetId = next(cur, steps, context)\n    if (!targetId) return null\n    const target = stepById(targetId)\n    if (!target || !canMoveTo(target, cur, steps, context)) return null\n    return targetId\n  }\n\n  /** Anywhere to go at all? When not, the picker is pointless and hides. */\n  const anyReachable = (): boolean => {\n    const cur = stepById(current)\n    if (!cur) return false\n    return steps.some((s) => s.id !== current && canMoveTo(s, cur, steps, context))\n  }\n\n  // ── Render ───────────────────────────────────────────────────────────────────\n  const renderPrimary = () => {\n    const cur = stepById(current)\n    if (!cur) return\n    const targetId = advanceTargetId()\n    const target = targetId ? stepById(targetId) : null\n\n    primary.disabled = !target\n    // Terminal (or blocked): the primary becomes a quiet readout of the current stage\n    // rather than a half-faded disabled button; otherwise the emphasis resolves per\n    // advance target (variantFor → advanceVariant → base variant).\n    applyRootState(resolveVariant(target ?? null, cur), !target)\n\n    primary.replaceChildren()\n    const custom = opts.renderPrimary?.({ target: target ?? null, current: cur }) ?? null\n    if (custom !== null) {\n      appendContent(primary, custom)\n      primary.removeAttribute('aria-label')\n      return\n    }\n    const shown = target ?? cur\n    const aff = affordanceFor(shown)\n    if (aff) primary.appendChild(aff)\n    const labelEl = document.createElement('span')\n    labelEl.className = 'wf-primary-label'\n    if (target) {\n      const label =\n        opts.advanceLabelFor?.(target, cur, context) ??\n        target.advanceLabel ??\n        target.label\n      labelEl.textContent = label\n      primary.setAttribute('aria-label', `${label} (advance from ${cur.label})`)\n    } else {\n      labelEl.textContent = cur.label\n      primary.removeAttribute('aria-label')\n    }\n    primary.appendChild(labelEl)\n  }\n\n  const renderMenu = () => {\n    // Nowhere to go (terminal + not restartable, or role-locked): the picker is\n    // pointless — hide the caret and let the control read as a plain status readout.\n    const solo = !anyReachable()\n    trigger.hidden = solo\n    root.classList.toggle('is-solo', solo)\n    if (solo && open) closeMenu(false)\n\n    menu.replaceChildren()\n    items = steps.map((step) => {\n      const item = document.createElement('button')\n      item.type = 'button'\n      item.className = 'wf-item'\n      item.setAttribute('role', 'menuitem')\n      item.dataset.id = step.id\n      const isCurrent = step.id === current\n      const reachable =\n        isCurrent || canMoveTo(step, stepById(current) ?? step, steps, context)\n      item.disabled = !reachable && !isCurrent\n      item.tabIndex = -1\n      if (isCurrent) item.setAttribute('aria-current', 'true')\n\n      const mark = document.createElement('span')\n      mark.className = 'wf-check'\n      mark.setAttribute('aria-hidden', 'true')\n      if (isCurrent) mark.innerHTML = checkSvg()\n\n      // The slot owns everything left of the current-step check when it returns a node.\n      const custom =\n        opts.renderItem?.(step, { isCurrent, reachable: reachable && !isCurrent }) ?? null\n      if (custom !== null) {\n        appendContent(item, custom)\n        item.appendChild(mark)\n      } else {\n        const aff = affordanceFor(step)\n        const text = document.createElement('span')\n        text.className = 'wf-item-text'\n        const lbl = document.createElement('span')\n        lbl.className = 'wf-item-label'\n        lbl.textContent = step.label\n        text.appendChild(lbl)\n        // One line under the label: attribution (what happened) beats the\n        // definition's static hint when both exist.\n        const sub = step.meta ?? step.description\n        if (sub) {\n          const desc = document.createElement('span')\n          desc.className = 'wf-item-desc'\n          desc.textContent = sub\n          text.appendChild(desc)\n        }\n        if (aff) item.appendChild(aff)\n        item.appendChild(text)\n        item.appendChild(mark)\n      }\n\n      item.addEventListener('click', () => {\n        if (step.id === current) {\n          closeMenu()\n          return\n        }\n        if (item.disabled) return\n        commitMove(step.id)\n        closeMenu()\n      })\n      menu.appendChild(item)\n      return item\n    })\n  }\n\n  const render = () => {\n    renderPrimary()\n    renderMenu()\n  }\n\n  // ── Moves ────────────────────────────────────────────────────────────────────\n  const commitMove = (toId: string) => {\n    const fromId = current\n    if (toId === fromId) return\n    const to = stepById(toId)\n    const from = stepById(fromId)\n    if (!to || !from || !canMoveTo(to, from, steps, context)) return\n    const veto = opts.onMove(toId, fromId)\n    if (manageState && veto !== false) {\n      current = toId\n      render()\n    }\n  }\n\n  const advance = () => {\n    const targetId = advanceTargetId()\n    if (targetId) commitMove(targetId)\n  }\n\n  // ── Menu open/close + keyboard ───────────────────────────────────────────────\n  const focusItem = (i: number) => {\n    const el = items[i]\n    if (el) el.focus()\n  }\n  const firstEnabled = (dir: 1 | -1, from: number): number => {\n    for (let i = from; i >= 0 && i < items.length; i += dir) {\n      if (!items[i].disabled) return i\n    }\n    return -1\n  }\n\n  const openMenu = () => {\n    if (open) return\n    open = true\n    menu.hidden = false\n    trigger.setAttribute('aria-expanded', 'true')\n    root.classList.add('is-open')\n    // Focus the current item if reachable to interact with, else the first enabled one.\n    const curIdx = steps.findIndex((s) => s.id === current)\n    const start = items[curIdx] && !items[curIdx].disabled ? curIdx : firstEnabled(1, 0)\n    if (start >= 0) focusItem(start)\n    document.addEventListener('pointerdown', onDocPointer, true)\n  }\n\n  const closeMenu = (refocus = true) => {\n    if (!open) return\n    open = false\n    menu.hidden = true\n    trigger.setAttribute('aria-expanded', 'false')\n    root.classList.remove('is-open')\n    document.removeEventListener('pointerdown', onDocPointer, true)\n    if (refocus) trigger.focus()\n  }\n\n  const onDocPointer = (e: PointerEvent) => {\n    if (!root.contains(e.target as Node)) closeMenu(false)\n  }\n\n  const onTriggerClick = () => (open ? closeMenu() : openMenu())\n\n  const onTriggerKey = (e: KeyboardEvent) => {\n    if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {\n      e.preventDefault()\n      openMenu()\n    } else if (e.key === 'ArrowUp') {\n      e.preventDefault()\n      openMenu()\n      const last = firstEnabled(-1, items.length - 1)\n      if (last >= 0) focusItem(last)\n    }\n  }\n\n  const onMenuKey = (e: KeyboardEvent) => {\n    const idx = items.indexOf(document.activeElement as HTMLButtonElement)\n    if (e.key === 'ArrowDown') {\n      e.preventDefault()\n      const n = firstEnabled(1, idx + 1)\n      if (n >= 0) focusItem(n)\n    } else if (e.key === 'ArrowUp') {\n      e.preventDefault()\n      const p = firstEnabled(-1, idx - 1)\n      if (p >= 0) focusItem(p)\n    } else if (e.key === 'Home') {\n      e.preventDefault()\n      const f = firstEnabled(1, 0)\n      if (f >= 0) focusItem(f)\n    } else if (e.key === 'End') {\n      e.preventDefault()\n      const l = firstEnabled(-1, items.length - 1)\n      if (l >= 0) focusItem(l)\n    } else if (e.key === 'Escape') {\n      e.preventDefault()\n      closeMenu()\n    } else if (e.key === 'Tab') {\n      // Tabbing out of the menu closes it (and lets focus move on naturally).\n      closeMenu(false)\n    }\n  }\n\n  primary.addEventListener('click', advance)\n  trigger.addEventListener('click', onTriggerClick)\n  trigger.addEventListener('keydown', onTriggerKey)\n  menu.addEventListener('keydown', onMenuKey)\n\n  render()\n\n  return {\n    element: root,\n    getCurrent: () => current,\n    getAdvanceTarget: advanceTargetId,\n    setState(patch) {\n      if (patch.steps) steps = patch.steps\n      if (patch.current != null) current = patch.current\n      if ('context' in patch) context = patch.context\n      render()\n    },\n    advance,\n    moveTo: (id) => commitMove(id),\n    destroy() {\n      closeMenu(false)\n      primary.removeEventListener('click', advance)\n      trigger.removeEventListener('click', onTriggerClick)\n      trigger.removeEventListener('keydown', onTriggerKey)\n      menu.removeEventListener('keydown', onMenuKey)\n      document.removeEventListener('pointerdown', onDocPointer, true)\n    },\n  }\n}\n\n// ── helpers ───────────────────────────────────────────────────────────────────\n\n/** The step's leading visual: its icon if provided, else a status-color dot, else nothing. */\nfunction affordanceFor(step: WorkflowStep): Node | null {\n  if (step.icon) {\n    const span = document.createElement('span')\n    span.className = 'wf-icon'\n    span.setAttribute('aria-hidden', 'true')\n    span.appendChild(step.icon())\n    return span\n  }\n  if (step.color) {\n    const dot = document.createElement('span')\n    dot.className = 'wf-dot'\n    dot.setAttribute('aria-hidden', 'true')\n    dot.style.setProperty('--dot', step.color)\n    return dot\n  }\n  return null\n}\n\nfunction appendContent(host: HTMLElement, content: Node | string) {\n  host.appendChild(\n    typeof content === 'string' ? document.createTextNode(content) : content,\n  )\n}\n\nfunction caretSvg(): string {\n  return `<svg class=\"wf-caret\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"/></svg>`\n}\n\nfunction checkSvg(): string {\n  return `<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20 6 9 17l-5-5\"/></svg>`\n}\n\n// ── Styles ───────────────────────────────────────────────────────────────────\n\nlet stylesInjected = false\n/** Inject the stylesheet once. Called automatically unless `injectStyles: false`. */\nexport function injectWorkflowStyles(): void {\n  if (stylesInjected || typeof document === 'undefined') return\n  if (document.getElementById('workflow-button-styles')) {\n    stylesInjected = true\n    return\n  }\n  const style = document.createElement('style')\n  style.id = 'workflow-button-styles'\n  style.textContent = workflowStyles()\n  document.head.appendChild(style)\n  stylesInjected = true\n}\n\n/** The component's CSS as a string (for callers who inject styles themselves / SSR). */\nexport function workflowStyles(): string {\n  return `\n.workflow-button {\n  /* shadcn theme tokens when present; zinc-flavored light-dark() fallbacks otherwise. */\n  --_primary: var(--wf-primary-bg, var(--primary, light-dark(#18181b, #e4e4e7)));\n  --_primary-fg: var(--wf-primary-fg, var(--primary-foreground, light-dark(#fafafa, #18181b)));\n  --_secondary: var(--secondary, light-dark(#f4f4f5, #27272a));\n  --_secondary-fg: var(--secondary-foreground, light-dark(#18181b, #fafafa));\n  --_background: var(--background, light-dark(#ffffff, #09090b));\n  --_accent: var(--wf-hover, var(--accent, light-dark(#f4f4f5, #27272a)));\n  --_accent-fg: var(--accent-foreground, light-dark(#18181b, #fafafa));\n  --_border: var(--wf-border, var(--input, var(--border, light-dark(#e4e4e7, #303036))));\n  --_popover: var(--wf-menu-bg, var(--popover, light-dark(#ffffff, #18181b)));\n  --_popover-fg: var(--wf-menu-fg, var(--popover-foreground, light-dark(#09090b, #fafafa)));\n  --_muted-fg: var(--wf-muted, var(--muted-foreground, light-dark(#71717a, #a1a1aa)));\n  --_destructive: var(--destructive, light-dark(#dc2626, #b91c1c));\n  --_ring: var(--wf-ring, var(--ring, light-dark(#a1a1aa, #71717a)));\n  --_radius: var(--wf-radius, var(--radius, 0.625rem));\n  /* shadcn's rounded-md — what <Button> actually uses. */\n  --_radius-md: calc(var(--_radius) - 2px);\n  position: relative;\n  display: inline-flex;\n  align-items: stretch;\n  isolation: isolate;\n  /* shadcn button typography: text-sm font-medium. */\n  font-family: inherit; font-size: 14px; line-height: 1.4; font-weight: 500;\n  color: inherit;\n}\n/* Base is layout-only — backgrounds/colors belong to the variant rules below, which are\n   lower-specificity than this compound selector and must not lose to it. */\n.workflow-button button {\n  appearance: none; -webkit-appearance: none;\n  font: inherit; margin: 0; border: 0; cursor: pointer;\n  display: inline-flex; align-items: center; justify-content: center; gap: 8px;\n  white-space: nowrap;\n  transition: background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;\n}\n/* shadcn focus: 3px ring at 50% + ring-colored edge. */\n.workflow-button button:focus-visible {\n  outline: none; z-index: 1;\n  box-shadow: 0 0 0 1px var(--_ring),\n              0 0 0 4px color-mix(in srgb, var(--_ring) 50%, transparent);\n}\n\n/* Shared split geometry. */\n.wf-primary { border-radius: var(--_radius-md) 0 0 var(--_radius-md); }\n.wf-trigger { border-radius: 0 var(--_radius-md) var(--_radius-md) 0; }\n.workflow-button.is-terminal .wf-primary { cursor: default; }\n/* Solo mode: nowhere to go at all — the caret hides and the primary owns both corners.\n   The display rule must be explicit: our button base sets display:inline-flex, and any\n   author display beats the UA's [hidden] → none, so the hidden attribute alone is not\n   enough to remove the trigger. */\n.workflow-button.is-solo .wf-primary { border-radius: var(--_radius-md); }\n.workflow-button.is-solo .wf-trigger,\n.workflow-button .wf-trigger[hidden] { display: none; }\n\n/* ── default: filled primary, hairline divider (no borders → no doubling). */\n.wf-variant-default .wf-primary,\n.wf-variant-default .wf-trigger { background: var(--_primary); color: var(--_primary-fg); }\n.wf-variant-default .wf-trigger {\n  border-left: 1px solid color-mix(in srgb, var(--_primary-fg) 20%, transparent);\n}\n/* shadcn hover:bg-primary/90. */\n.wf-variant-default .wf-primary:hover:not(:disabled),\n.wf-variant-default .wf-trigger:hover {\n  background: color-mix(in srgb, var(--_primary) 90%, transparent);\n}\n\n/* ── secondary: also the terminal readout (a state, not a broken button). */\n.wf-variant-secondary .wf-primary,\n.wf-variant-secondary .wf-trigger { background: var(--_secondary); color: var(--_secondary-fg); }\n.wf-variant-secondary .wf-trigger {\n  border-left: 1px solid color-mix(in srgb, var(--_secondary-fg) 15%, transparent);\n}\n.wf-variant-secondary .wf-primary:hover:not(:disabled),\n.wf-variant-secondary .wf-trigger:hover {\n  background: color-mix(in srgb, var(--_secondary) 80%, var(--_secondary-fg) 6%);\n}\n\n/* ── destructive: for high-consequence advances (irreversible publishes, rejections). */\n.wf-variant-destructive .wf-primary,\n.wf-variant-destructive .wf-trigger {\n  background: var(--_destructive); color: #fff;\n}\n.wf-variant-destructive .wf-trigger {\n  border-left: 1px solid color-mix(in srgb, #fff 25%, transparent);\n}\n.wf-variant-destructive .wf-primary:hover:not(:disabled),\n.wf-variant-destructive .wf-trigger:hover {\n  background: color-mix(in srgb, var(--_destructive) 90%, transparent);\n}\n\n/* ── ghost: no chrome at rest; hover reveals the accent wash (per shadcn). */\n.wf-variant-ghost .wf-primary,\n.wf-variant-ghost .wf-trigger { background: transparent; color: inherit; }\n.wf-variant-ghost .wf-primary:hover:not(:disabled),\n.wf-variant-ghost .wf-trigger:hover { background: var(--_accent); color: var(--_accent-fg); }\n.wf-variant-ghost.is-terminal .wf-primary { color: var(--_muted-fg); }\n\n/* ── outline: bordered like shadcn outline; edges overlap (-1px) → one border. */\n.wf-variant-outline .wf-primary,\n.wf-variant-outline .wf-trigger {\n  border: 1px solid var(--_border);\n  background: var(--_background); color: inherit;\n}\n.wf-variant-outline .wf-trigger { margin-left: -1px; border-radius: 0 var(--_radius-md) var(--_radius-md) 0; }\n.wf-variant-outline .wf-primary:hover:not(:disabled),\n.wf-variant-outline .wf-trigger:hover { background: var(--_accent); color: var(--_accent-fg); }\n.wf-variant-outline.is-terminal .wf-primary { color: var(--_muted-fg); }\n/* Keep the shared edge crisp when the overlapped buttons are hovered/focused. */\n.wf-variant-outline button:hover { z-index: 1; }\n\n/* ── sizes (shadcn h-8 / h-9 / h-10; trigger is the matching square icon button). */\n.wf-primary { height: 36px; padding: 0 16px; }\n.wf-trigger { height: 36px; width: 36px; padding: 0; flex: none; }\n.wf-size-sm .wf-primary { height: 32px; padding: 0 12px; font-size: 13px; }\n.wf-size-sm .wf-trigger { height: 32px; width: 32px; }\n.wf-size-lg .wf-primary { height: 40px; padding: 0 24px; }\n.wf-size-lg .wf-trigger { height: 40px; width: 40px; }\n\n.wf-caret { transition: transform 0.18s ease; }\n.workflow-button.is-open .wf-caret { transform: rotate(180deg); }\n\n/* Step affordances: icon slot (16px, like shadcn's size-4 svgs) or status dot. */\n.wf-icon { flex: none; display: inline-flex; pointer-events: none; }\n.wf-icon svg, .wf-icon img { width: 16px; height: 16px; display: block; }\n.wf-dot {\n  flex: none; width: 8px; height: 8px; border-radius: 50%;\n  background: var(--dot, currentColor);\n  box-shadow: 0 0 0 2px color-mix(in srgb, var(--dot, currentColor) 22%, transparent);\n}\n\n/* ── menu: shadcn DropdownMenuContent/Item. */\n.wf-menu {\n  position: absolute; top: calc(100% + 4px); right: 0; z-index: 50;\n  min-width: max(100%, 220px); max-height: 320px; overflow-y: auto;\n  padding: 4px; box-sizing: border-box;\n  background: var(--_popover); color: var(--_popover-fg);\n  border: 1px solid var(--_border);\n  border-radius: var(--_radius-md);\n  box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);\n}\n.wf-item {\n  width: 100%; text-align: left;\n  background: transparent; color: inherit;\n  border-radius: calc(var(--_radius) - 4px);\n  padding: 6px 8px; gap: 8px;\n  font-weight: 400;\n}\n.wf-item:hover:not(:disabled), .wf-item:focus-visible {\n  background: var(--_accent); color: var(--_accent-fg);\n  outline: none; box-shadow: none;\n}\n.wf-item:disabled { opacity: 0.5; cursor: default; }\n.wf-item[aria-current=\"true\"] .wf-item-label { font-weight: 500; }\n.wf-item-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }\n.wf-item-label { line-height: 1.4; }\n.wf-item-desc { font-size: 12px; line-height: 1.4; color: var(--_muted-fg); }\n.wf-item:hover:not(:disabled) .wf-item-desc,\n.wf-item:focus-visible .wf-item-desc { color: color-mix(in srgb, var(--_accent-fg) 70%, transparent); }\n.wf-check { flex: none; display: inline-flex; width: 16px; }\n`\n}\n",
      "type": "registry:lib",
      "target": "components/workflow-button/workflow-button.ts"
    },
    {
      "path": "registry/workflow-button/workflow-button-react.tsx",
      "content": "// workflow-button-react — a thin React wrapper over the framework-agnostic core.\n//\n// EXPERIMENTAL: the API is still settling and will change in breaking ways.\n//\n//   <WorkflowButton steps={steps} current={id} onMove={...} />   renders the split button\n//   useWorkflow({ steps, current, ... })                          headless: the flow math\n//\n// The component is controlled: you own `current`, handle `onMove`, and pass the new id back\n// as a prop. Internally it runs the vanilla core with `manageState: false` and syncs on\n// prop changes — so the DOM view and your React state never fight over who's current.\n//\n// State ownership: always controlled, on purpose — a workflow stage is host-app domain\n// data (it lives in your database, not in a widget), so `current` + `onMove` is the whole\n// contract. The vanilla core's `manageState` still exists for zero-framework, self-managed\n// usage; this wrapper just never turns it on.\n\n'use client'\n\nimport { useEffect, useMemo, useRef } from 'react'\nimport {\n  createWorkflowButton,\n  defaultCanMoveTo,\n  defaultNext,\n  forwardOnly,\n  nextInOrder,\n  normalizeVariant,\n  type MovePredicate,\n  type NextResolver,\n  type WorkflowButton as VanillaWorkflowButton,\n  type WorkflowStep,\n  type WorkflowVariant,\n} from './workflow-button'\n\ninterface WorkflowButtonProps<TCtx = unknown> {\n  steps: WorkflowStep[]\n  /** Id of the current stage (controlled). */\n  current: string\n  /** Fired on advance or a menu pick, with the target and previous ids. */\n  onMove: (toId: string, fromId: string) => void\n  /** App data (viewer role, permissions, assignee…) threaded into every resolver.\n   *  Changing it re-renders reachability and emphasis. */\n  context?: TCtx\n  next?: NextResolver<TCtx>\n  /** Reachability predicate — also your role-aware disabling hook.\n   *  Pass `forwardOnly` for array-order DAG flows; `to` lists need nothing. */\n  canMoveTo?: MovePredicate<TCtx>\n  advanceLabelFor?: (\n    target: WorkflowStep,\n    from: WorkflowStep,\n    context: TCtx | undefined,\n  ) => string\n  /** Fully own the primary's content (a DOM node or string — this wrapper drives the\n   *  vanilla core; return null to use the default content). For ReactNode children/icons,\n   *  use `workflow-button-shadcn` instead. */\n  renderPrimary?: (ctx: {\n    target: WorkflowStep | null\n    current: WorkflowStep\n  }) => Node | string | null\n  /** Own a menu item's row (DOM node; null = default). ReactNode → use the shadcn version. */\n  renderItem?: (\n    step: WorkflowStep,\n    state: { isCurrent: boolean; reachable: boolean },\n  ) => Node | null\n  size?: 'sm' | 'default' | 'lg'\n  /** Base presentation; per-step `advanceVariant` / `variantFor` win over it. */\n  variant?: WorkflowVariant\n  /** Dynamic emphasis resolver (role-aware prominence). Null falls through. */\n  variantFor?: (\n    target: WorkflowStep,\n    from: WorkflowStep,\n    context: TCtx | undefined,\n  ) => WorkflowVariant | null | undefined\n  menuLabel?: string\n  className?: string\n}\n\n/**\n * Renders the workflow split button. Fully controlled — `onMove` reports the intended move;\n * reflect it by updating the `current` you pass back in.\n *\n * The wrapper host is `display: contents`, so it adds no layout box; the button (an inline\n * group) lays out as if it were your own child.\n */\nfunction WorkflowButton<TCtx = unknown>({\n  steps,\n  current,\n  onMove,\n  context,\n  next,\n  canMoveTo,\n  advanceLabelFor,\n  renderPrimary,\n  renderItem,\n  size = 'default',\n  variant = 'default',\n  variantFor,\n  menuLabel,\n  className,\n}: WorkflowButtonProps<TCtx>) {\n  const hostRef = useRef<HTMLSpanElement>(null)\n  const btnRef = useRef<VanillaWorkflowButton<TCtx> | null>(null)\n\n  // Keep the callbacks/predicates fresh without re-creating the DOM control each render.\n  const cbs = useRef({\n    onMove,\n    next,\n    canMoveTo,\n    advanceLabelFor,\n    renderPrimary,\n    renderItem,\n    variantFor,\n  })\n  cbs.current = {\n    onMove,\n    next,\n    canMoveTo,\n    advanceLabelFor,\n    renderPrimary,\n    renderItem,\n    variantFor,\n  }\n\n  useEffect(() => {\n    const host = hostRef.current\n    if (!host) return\n    const btn = createWorkflowButton<TCtx>({\n      steps,\n      current,\n      context,\n      manageState: false, // React owns `current`; we sync via setState below.\n      size,\n      variant,\n      menuLabel,\n      className,\n      next: (c, s, x) => (cbs.current.next ?? defaultNext)(c, s, x),\n      canMoveTo: (t, f, s, x) => (cbs.current.canMoveTo ?? defaultCanMoveTo)(t, f, s, x),\n      advanceLabelFor: (t, f, x) =>\n        cbs.current.advanceLabelFor?.(t, f, x) ?? t.advanceLabel ?? t.label,\n      renderPrimary: (ctx) => cbs.current.renderPrimary?.(ctx) ?? null,\n      renderItem: (step, state) => cbs.current.renderItem?.(step, state) ?? null,\n      variantFor: (t, f, x) => cbs.current.variantFor?.(t, f, x) ?? null,\n      onMove: (toId, fromId) => cbs.current.onMove(toId, fromId),\n    })\n    host.appendChild(btn.element)\n    btnRef.current = btn\n    return () => {\n      btn.destroy()\n      btn.element.remove()\n      btnRef.current = null\n    }\n    // Re-create only on presentational changes; steps/current/context sync below.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [size, variant, menuLabel, className])\n\n  // Sync the controlled state into the live control.\n  useEffect(() => {\n    btnRef.current?.setState({ steps, current, context })\n  }, [steps, current, context])\n\n  return <span ref={hostRef} style={{ display: 'contents' }} />\n}\n\ninterface UseWorkflowOptions<TCtx = unknown> {\n  steps: WorkflowStep[]\n  current: string\n  context?: TCtx\n  next?: NextResolver<TCtx>\n  canMoveTo?: MovePredicate<TCtx>\n}\n\ninterface WorkflowState {\n  /** The current step object. */\n  currentStep: WorkflowStep | undefined\n  /** The id the primary would advance to, or null if terminal/blocked. */\n  advanceTarget: string | null\n  /** Whether the primary can advance right now. */\n  canAdvance: boolean\n  /** Is this step reachable from the current one? */\n  canMoveTo: (id: string) => boolean\n  /** Is there anywhere to go at all? (false → hide your picker UI) */\n  anyReachable: boolean\n}\n\n/**\n * Headless flow math — the same resolution the button uses, without any DOM. Build your own\n * control (a stepper, a command palette entry, a keyboard shortcut) on top.\n */\nfunction useWorkflow<TCtx = unknown>({\n  steps,\n  current,\n  context,\n  next,\n  canMoveTo,\n}: UseWorkflowOptions<TCtx>): WorkflowState {\n  return useMemo(() => {\n    const resolveNext = next ?? (defaultNext as NextResolver<TCtx>)\n    const reachable = canMoveTo ?? (defaultCanMoveTo as MovePredicate<TCtx>)\n    const cur = steps.find((s) => s.id === current)\n    const byId = (id: string) => steps.find((s) => s.id === id)\n\n    let advanceTarget: string | null = null\n    if (cur) {\n      const targetId = resolveNext(cur, steps, context)\n      const target = targetId ? byId(targetId) : null\n      if (target && reachable(target, cur, steps, context)) advanceTarget = targetId\n    }\n\n    return {\n      currentStep: cur,\n      advanceTarget,\n      canAdvance: advanceTarget !== null,\n      canMoveTo: (id) => {\n        const to = byId(id)\n        return !!(to && cur && id !== current && reachable(to, cur, steps, context))\n      },\n      anyReachable:\n        !!cur && steps.some((s) => s.id !== current && reachable(s, cur, steps, context)),\n    }\n  }, [steps, current, context, next, canMoveTo])\n}\n\nexport {\n  defaultCanMoveTo,\n  defaultNext,\n  forwardOnly,\n  nextInOrder,\n  normalizeVariant,\n  useWorkflow,\n  WorkflowButton,\n  type MovePredicate,\n  type NextResolver,\n  type UseWorkflowOptions,\n  type WorkflowButtonProps,\n  type WorkflowState,\n  type WorkflowStep,\n  type WorkflowVariant,\n}\n",
      "type": "registry:component",
      "target": "components/workflow-button/workflow-button-react.tsx"
    }
  ],
  "type": "registry:component"
}