{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"progressive-action","type":"registry:component","title":"Progressive Command Button","description":"One button that shows ready, running, review and done — without a modal.","dependencies":[],"categories":["systems","actions","state","progress","async"],"files":[{"path":"components/lab/progressive-action/progressive-action.tsx","target":"components/lab/progressive-action/progressive-action.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  useEffect,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport \"./progressive-action.css\";\n\nexport type ActionPhase = \"ready\" | \"running\" | \"review\" | \"done\" | \"error\";\n\nexport type ProgressiveActionLabels = Partial<Record<ActionPhase, string>>;\n\nexport type ProgressiveActionProps = {\n  /** Controlled phase — the host owns the workflow. */\n  phase: ActionPhase;\n  /** 0–1 while running; omit for an indeterminate bar. */\n  progress?: number;\n  onRun?: () => void;\n  onAccept?: () => void;\n  onRetry?: () => void;\n  onReset?: () => void;\n  /** Short summary shown in the review phase. */\n  reviewSummary?: ReactNode;\n  /** Override any phase's label. */\n  labels?: ProgressiveActionLabels;\n  /** Pins the control width so phase changes never resize it. */\n  fixedWidth?: number;\n  className?: string;\n};\n\nconst DEFAULT_LABELS: Record<ActionPhase, string> = {\n  ready: \"Run\",\n  running: \"Running…\",\n  review: \"Ready for review\",\n  done: \"Done\",\n  error: \"Something went wrong\",\n};\n\nconst ANNOUNCEMENTS: Record<ActionPhase, string> = {\n  ready: \"Ready\",\n  running: \"Running\",\n  review: \"Ready for review\",\n  done: \"Done\",\n  error: \"Failed\",\n};\n\nfunction CheckIcon() {\n  return (\n    <svg\n      className=\"pa-icon\"\n      viewBox=\"0 0 16 16\"\n      width=\"14\"\n      height=\"14\"\n      aria-hidden=\"true\"\n      focusable=\"false\"\n    >\n      <path\n        d=\"M3 8.5 6.2 11.6 13 4.7\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction AlertIcon() {\n  return (\n    <svg\n      className=\"pa-icon\"\n      viewBox=\"0 0 16 16\"\n      width=\"14\"\n      height=\"14\"\n      aria-hidden=\"true\"\n      focusable=\"false\"\n    >\n      <circle cx=\"8\" cy=\"8\" r=\"6.3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" />\n      <path\n        d=\"M8 4.7v4\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.6\"\n        strokeLinecap=\"round\"\n      />\n      <circle cx=\"8\" cy=\"11.3\" r=\"0.9\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n\n/**\n * PROGRESSIVE ACTION — one control that walks a small workflow in\n * place: ready, running with real progress, an optional review beat,\n * then done or error. No modal, no toast; the host owns the work and\n * this button owns the honesty. Phase changes are announced politely.\n */\nexport function ProgressiveAction({\n  phase,\n  progress,\n  onRun,\n  onAccept,\n  onRetry,\n  onReset,\n  reviewSummary,\n  labels,\n  fixedWidth,\n  className,\n}: ProgressiveActionProps) {\n  const [announcement, setAnnouncement] = useState(\"\");\n  const lastPhaseRef = useRef<ActionPhase | null>(null);\n\n  useEffect(() => {\n    if (lastPhaseRef.current === null) {\n      lastPhaseRef.current = phase;\n      return;\n    }\n    if (lastPhaseRef.current === phase) return;\n    lastPhaseRef.current = phase;\n    setAnnouncement(ANNOUNCEMENTS[phase]);\n  }, [phase]);\n\n  const labelFor = (key: ActionPhase) => labels?.[key] ?? DEFAULT_LABELS[key];\n\n  const raw =\n    typeof progress === \"number\" && Number.isFinite(progress) ? progress : undefined;\n  const clamped = raw === undefined ? undefined : Math.min(1, Math.max(0, raw));\n  const pct = clamped === undefined ? undefined : Math.round(clamped * 100);\n  const valueText = pct === undefined ? \"In progress\" : `${pct}%`;\n\n  return (\n    <div\n      className={`pa-root${className ? ` ${className}` : \"\"}`}\n      data-phase={phase}\n      style={fixedWidth ? { inlineSize: `${fixedWidth}px` } : undefined}\n    >\n      <div className=\"pa-row\">\n        <span className=\"pa-slot\">\n          <button\n            type=\"button\"\n            className=\"pa-main\"\n            data-state={phase}\n            aria-disabled={phase !== \"ready\"}\n            onClick={phase === \"ready\" ? onRun : undefined}\n          >\n            <span className=\"pa-face\">\n              {phase === \"done\" ? <CheckIcon /> : null}\n              {phase === \"error\" ? <AlertIcon /> : null}\n              <span className=\"pa-face-label\">{labelFor(phase)}</span>\n            </span>\n          </button>\n\n          {phase === \"running\" ? (\n            <span\n              className=\"pa-rail\"\n              role=\"progressbar\"\n              aria-label=\"Action progress\"\n              aria-valuemin={0}\n              aria-valuemax={100}\n              aria-valuenow={pct}\n              aria-valuetext={valueText}\n              data-indeterminate={clamped === undefined ? \"true\" : \"false\"}\n            >\n              <span\n                className=\"pa-rail-fill\"\n                style={\n                  clamped !== undefined\n                    ? { transform: `scaleX(${clamped})` }\n                    : undefined\n                }\n              />\n            </span>\n          ) : null}\n        </span>\n\n        {phase === \"running\" ? (\n          <button type=\"button\" className=\"pa-action\" onClick={onReset}>\n            Cancel\n          </button>\n        ) : null}\n        {phase === \"done\" ? (\n          <button type=\"button\" className=\"pa-action pa-action--go\" onClick={onReset}>\n            Run again\n          </button>\n        ) : null}\n        {phase === \"error\" ? (\n          <button type=\"button\" className=\"pa-action pa-action--go\" onClick={onRetry}>\n            Retry\n          </button>\n        ) : null}\n      </div>\n\n      {phase === \"review\" ? (\n        <div className=\"pa-review\">\n          <div className=\"pa-review-summary\">\n            {reviewSummary ?? (\n              <p className=\"pa-review-default\">\n                The step finished. Check the result before accepting.\n              </p>\n            )}\n          </div>\n          <div className=\"pa-review-actions\">\n            <button\n              type=\"button\"\n              className=\"pa-action pa-action--go\"\n              onClick={onAccept}\n            >\n              Accept\n            </button>\n            <button type=\"button\" className=\"pa-action\" onClick={onReset}>\n              Run again\n            </button>\n          </div>\n        </div>\n      ) : null}\n\n      <span className=\"pa-sr\" role=\"status\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n"},{"path":"components/lab/progressive-action/progressive-action.css","target":"components/lab/progressive-action/progressive-action.css","type":"registry:file","content":"/* ============================================================\n   PROGRESSIVE ACTION — self-contained styles.\n   Tokens are scoped on the root with fallbacks, so the control\n   carries its own theme into any codebase.\n   ============================================================ */\n\n.pa-root {\n  --pa-accent: #4f27e0;\n  --pa-accent-deep: #32119c;\n  --pa-accent-soft: rgba(79, 39, 224, 0.09);\n  --pa-ok: #137a4e;\n  --pa-ok-ink: #0d5c3b;\n  --pa-ok-soft: rgba(19, 122, 78, 0.1);\n  --pa-danger: #b3261e;\n  --pa-danger-ink: #8f1d17;\n  --pa-danger-soft: rgba(179, 38, 30, 0.09);\n  --pa-ink: #141418;\n  --pa-muted: #5f5f68;\n  --pa-line: rgba(20, 20, 24, 0.14);\n  --pa-surface: #ffffff;\n  --pa-surface-soft: #f8f8f6;\n  --pa-focus: #4f27e0;\n  --pa-radius: 10px;\n\n  position: relative;\n  display: inline-flex;\n  flex-direction: column;\n  gap: 8px;\n  max-inline-size: 100%;\n  font-size: 0.875rem;\n  line-height: 1.35;\n  color: var(--pa-ink);\n  transition: inline-size 220ms cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.pa-row {\n  display: flex;\n  align-items: stretch;\n  gap: 8px;\n  inline-size: 100%;\n}\n\n/* Keeps the progress rail pinned to the main button's bottom edge. */\n.pa-slot {\n  position: relative;\n  display: flex;\n  flex: 1 1 auto;\n  min-inline-size: 0;\n}\n\n.pa-main {\n  position: relative;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  inline-size: 100%;\n  min-height: 42px;\n  padding: 0 16px;\n  border: 1px solid transparent;\n  border-radius: var(--pa-radius);\n  font: inherit;\n  font-weight: 600;\n  cursor: pointer;\n  transition:\n    background-color 160ms ease,\n    border-color 160ms ease,\n    color 160ms ease;\n}\n\n.pa-main[data-state=\"ready\"] {\n  background: var(--pa-accent);\n  color: #ffffff;\n}\n\n.pa-main[data-state=\"ready\"]:hover {\n  background: var(--pa-accent-deep);\n}\n\n.pa-main[data-state=\"running\"],\n.pa-main[data-state=\"review\"] {\n  background: var(--pa-accent-soft);\n  border-color: rgba(79, 39, 224, 0.22);\n  color: var(--pa-accent-deep);\n  cursor: default;\n}\n\n.pa-main[data-state=\"running\"] {\n  padding-block-end: 4px;\n}\n\n.pa-main[data-state=\"done\"] {\n  background: var(--pa-ok-soft);\n  border-color: rgba(19, 122, 78, 0.26);\n  color: var(--pa-ok-ink);\n  cursor: default;\n}\n\n.pa-main[data-state=\"error\"] {\n  background: var(--pa-danger-soft);\n  border-color: rgba(179, 38, 30, 0.26);\n  color: var(--pa-danger-ink);\n  cursor: default;\n}\n\n.pa-main:focus-visible,\n.pa-action:focus-visible {\n  outline: 2px solid var(--pa-focus);\n  outline-offset: 2px;\n}\n\n.pa-main[data-state=\"ready\"]:active {\n  transform: translateY(0.5px);\n}\n\n.pa-face {\n  display: inline-flex;\n  align-items: center;\n  gap: 7px;\n  min-inline-size: 0;\n}\n\n.pa-face-label {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.pa-icon {\n  flex: none;\n}\n\n/* Thin determinate / indeterminate bar along the button's bottom edge. */\n.pa-rail {\n  position: absolute;\n  inset: auto 12px 6px 12px;\n  block-size: 3px;\n  border-radius: 999px;\n  background: rgba(79, 39, 224, 0.16);\n  overflow: hidden;\n  pointer-events: none;\n}\n\n.pa-rail-fill {\n  display: block;\n  block-size: 100%;\n  inline-size: 100%;\n  background: var(--pa-accent);\n  transform: scaleX(0);\n  transform-origin: left center;\n  transition: transform 120ms linear;\n}\n\n.pa-rail[data-indeterminate=\"true\"] .pa-rail-fill {\n  inline-size: 36%;\n  transform: none;\n  transition: none;\n  animation: pa-sweep 1.1s ease-in-out infinite;\n}\n\n@keyframes pa-sweep {\n  0% {\n    transform: translateX(-110%);\n  }\n  100% {\n    transform: translateX(320%);\n  }\n}\n\n/* Secondary actions: quiet by default, filled when they are the one. */\n.pa-action {\n  flex: none;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  min-height: 42px;\n  padding: 0 14px;\n  border: 1px solid var(--pa-line);\n  border-radius: var(--pa-radius);\n  background: var(--pa-surface);\n  color: var(--pa-ink);\n  font: inherit;\n  font-weight: 600;\n  white-space: nowrap;\n  cursor: pointer;\n  transition:\n    background-color 160ms ease,\n    border-color 160ms ease;\n}\n\n.pa-action:hover {\n  background: var(--pa-surface-soft);\n  border-color: rgba(20, 20, 24, 0.24);\n}\n\n.pa-action--go {\n  background: var(--pa-accent);\n  border-color: transparent;\n  color: #ffffff;\n}\n\n.pa-action--go:hover {\n  background: var(--pa-accent-deep);\n  border-color: transparent;\n}\n\n/* Inline review beat — attached to the control, never a modal. */\n.pa-review {\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n  padding: 12px;\n  border: 1px solid var(--pa-line);\n  border-radius: 12px;\n  background: var(--pa-surface-soft);\n}\n\n.pa-review-summary {\n  font-size: 0.8125rem;\n}\n\n.pa-review-default {\n  margin: 0;\n  color: var(--pa-muted);\n}\n\n.pa-review-actions {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n  justify-content: flex-end;\n}\n\n.pa-sr {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0 0 0 0);\n  clip-path: inset(50%);\n  white-space: nowrap;\n  border: 0;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .pa-root,\n  .pa-main,\n  .pa-action,\n  .pa-rail-fill {\n    transition: none;\n  }\n\n  .pa-main[data-state=\"ready\"]:active {\n    transform: none;\n  }\n\n  /* Indeterminate stays legible as a static mid-bar. */\n  .pa-rail[data-indeterminate=\"true\"] .pa-rail-fill {\n    animation: none;\n    inline-size: 40%;\n    transform: none;\n  }\n}\n"}]}