{
  "name": "hold-to-strike",
  "type": "registry:ui",
  "title": "HoldToStrike",
  "description": "G4 as a behavior: hold ~600ms while the fill sweeps, release early cancels, completion strikes — and the CALLER announces with Undo. A hook for any control plus the ready-made StrikeButton.",
  "dependencies": [
    "@candlelight-ds/tokens",
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/instruments/HoldToStrike.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\n/**\n * HoldToStrike — the default destructive pattern (grammar G4): strike,\n * don't confirm. Press and hold ~600ms while the fill sweeps; release\n * early to cancel; completion strikes and the CALLER announces with\n * Undo (\"Struck *X*. Undo ⌘Z\") — undo is a deferred commit, not a\n * restore.\n *\n * Use when: deleting one thing (an object, a row, a pad).\n * Don't use when: cascade deletes — those earn a typed-confirm Overlay.\n */\n\nimport { forwardRef, useCallback, useEffect, useRef, useState } from 'react'\nimport { Trash2 } from 'lucide-react'\n// The law number lives in ONE home (spec §4) — import, never re-declare.\nimport { STRIKE_HOLD_MS } from '@candlelight-ds/tokens/metrics'\nimport { cn } from '@/lib/utils'\n\n/** The behavior alone — wrap any control (menu items, icon buttons). */\nexport function useHoldToStrike(onStrike: () => void, holdMs: number = STRIKE_HOLD_MS) {\n  const [holding, setHolding] = useState(false)\n  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const firedRef = useRef(false)\n\n  const cancel = useCallback(() => {\n    if (timerRef.current) clearTimeout(timerRef.current)\n    timerRef.current = null\n    setHolding(false)\n  }, [])\n\n  const start = useCallback(() => {\n    if (timerRef.current) return\n    firedRef.current = false\n    setHolding(true)\n    timerRef.current = setTimeout(() => {\n      timerRef.current = null\n      firedRef.current = true\n      setHolding(false)\n      onStrike()\n    }, holdMs)\n  }, [holdMs, onStrike])\n\n  useEffect(() => cancel, [cancel])\n\n  return {\n    holding,\n    holdMs,\n    props: {\n      onPointerDown: (event: React.PointerEvent) => {\n        if (event.button !== 0) return\n        event.preventDefault()\n        start()\n      },\n      onPointerUp: cancel,\n      onPointerLeave: cancel,\n      onPointerCancel: cancel,\n      onKeyDown: (event: React.KeyboardEvent) => {\n        if ((event.key === 'Enter' || event.key === ' ') && !event.repeat) {\n          event.preventDefault()\n          start()\n        }\n      },\n      onKeyUp: (event: React.KeyboardEvent) => {\n        if (event.key === 'Enter' || event.key === ' ') cancel()\n      },\n      // Suppress the click that follows a completed hold.\n      onClick: (event: React.MouseEvent) => {\n        if (firedRef.current) {\n          event.preventDefault()\n          event.stopPropagation()\n          firedRef.current = false\n        }\n      },\n    },\n  }\n}\n\ninterface StrikeButtonProps\n  extends Omit<\n    React.ButtonHTMLAttributes<HTMLButtonElement>,\n    'onClick' | 'onPointerDown' | 'onPointerUp' | 'onPointerLeave' | 'onPointerCancel' | 'onKeyDown' | 'onKeyUp'\n  > {\n  /** Fires after a completed hold. Announce it with Undo at the call site. */\n  onStrike: () => void\n  label?: string\n  holdMs?: number\n  size?: 'sm' | 'md' | 'icon'\n  /** Copy (§9) — the mid-hold word and the a11y suffix reach your copy module. */\n  holdingLabel?: string\n  confirmHint?: string\n  className?: string\n  disabled?: boolean\n}\n\nexport const StrikeButton = forwardRef<HTMLButtonElement, StrikeButtonProps>(function StrikeButton(\n  {\n    onStrike,\n    label = 'Strike',\n    holdMs = STRIKE_HOLD_MS,\n    size = 'md',\n    holdingLabel = 'Hold…',\n    confirmHint = 'hold to confirm',\n    className,\n    disabled,\n    ...rest\n  },\n  ref\n) {\n  const { holding, props } = useHoldToStrike(onStrike, holdMs)\n\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      disabled={disabled}\n      aria-label={`${label} — ${confirmHint}`}\n      {...rest}\n      {...props}\n      className={cn(\n        'relative inline-flex select-none items-center justify-center gap-2 overflow-hidden rounded-sm border transition-colors',\n        // The strike control's RESTING rim sits quieter than tint-rim by\n        // contract — destruction is cold, never inviting (§2.3); it warms\n        // to the rim only under the pointer.\n        // eslint-disable-next-line no-restricted-syntax\n        'border-danger/30 text-danger hover:border-danger-rim hover:bg-danger-fill',\n        'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-danger focus-visible:ring-offset-2 focus-visible:ring-offset-stage',\n        'disabled:pointer-events-none disabled:opacity-40',\n        size === 'md' && 'h-9 px-4 text-body',\n        size === 'sm' && 'h-8 px-3 text-meta',\n        size === 'icon' && 'h-8 w-8',\n        className\n      )}\n    >\n      {/* The fill sweep — scaleX rides the compositor (a width animation\n          would relayout); release early and it snaps back visibly. */}\n      {/* Bespoke by contract: the sweep reads as rising destruction —\n          hotter than tint-held, cooler than the rim — a one-of-a-kind\n          alpha for the one destruction instrument. */}\n      {/* eslint-disable no-restricted-syntax */}\n      <span\n        aria-hidden\n        className=\"absolute inset-0 origin-left bg-danger/25\"\n        style={{\n          transform: holding ? 'scaleX(1)' : 'scaleX(0)',\n          transition: holding\n            ? `transform ${holdMs}ms linear`\n            : 'transform 120ms ease-out',\n        }}\n      />\n      {/* eslint-enable no-restricted-syntax */}\n      <span className=\"relative inline-flex items-center gap-2\">\n        <Trash2 className={size === 'icon' ? 'h-4 w-4' : 'h-3.5 w-3.5'} />\n        {size !== 'icon' ? (holding ? holdingLabel : label) : null}\n      </span>\n    </button>\n  )\n})\n",
      "target": "components/instruments/HoldToStrike.tsx"
    }
  ],
  "meta": {
    "candlelight": {
      "category": "instrument",
      "gRules": [
        "G4",
        "G5"
      ],
      "useWhen": "Deleting one thing — a row, an object, a pad. Wrap menu items and icon buttons with the hook.",
      "dontUseWhen": "Cascade deletes — those earn a typed-confirm Overlay.",
      "states": [
        "resting (cold danger outline)",
        "holding (linear fill sweep)",
        "released early (sweep snaps back)",
        "struck"
      ],
      "keyboard": [
        {
          "keys": "Enter/Space held",
          "does": "same hold; keyup cancels — the keyboard path is first-class"
        }
      ],
      "acceptanceTests": [
        "Release before the threshold never strikes.",
        "Completion fires exactly once and suppresses the trailing click.",
        "Every strike is announced with Undo at the call site — undo is a deferred commit, not a restore."
      ]
    }
  },
  "registryDependencies": [
    "utils"
  ]
}