{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "punch-patch",
  "title": "Punch Patch",
  "description": "A role x permission matrix rendered as a Jacquard card where authority is asymmetric material: granting punches a clean hole through the card stock, revoking never un-punches. It pastes a visible patch over the hole instead, so a cell that was ever revoked stays permanently distinguishable from one that was never touched.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/punch-patch/component.tsx",
      "content": "\"use client\";\n\nimport { useId, useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent } from \"react\";\n\n// ---------------------------------------------------------------------------\n// PunchPatch — a role x permission matrix rendered as a Jacquard card where\n// authority is asymmetric material. Granting a permission punches a clean\n// circular hole through the card stock; revoking never un-punches — it pastes\n// a visible patch over the hole instead, leaving the hole's rim peeking 1px\n// around the patch and its torn corners permanent for the session. Re-granting\n// punches straight through the existing patch (a smaller hole cut through its\n// centre), so a cell that was ever revoked stays visibly distinct from one\n// that was never touched, forever, regardless of its current state. Inherited\n// grants render as sealed eyelets — solid rim + inner ring, non-interactive,\n// with a tooltip naming the parent role. Dependencies (grant X requires Y)\n// share one punch-bar motion across both cells instead of two animations run\n// back to back. Pure DOM + SVG, tokens only, reduced motion swaps instantly.\n// ---------------------------------------------------------------------------\n\nexport interface PunchPatchRole {\n  id: string;\n  name: string;\n}\n\nexport interface PunchPatchPermission {\n  id: string;\n  name: string;\n  /** id of another permission in the same set this one depends on; granting\n   *  this one cascades a grant of the dependency first, if it isn't held. */\n  requires?: string;\n}\n\nexport interface PunchPatchGrant {\n  roleId: string;\n  permissionId: string;\n}\n\nexport interface PunchPatchInherited extends PunchPatchGrant {\n  /** name of the parent role this grant is inherited from */\n  from: string;\n}\n\nexport interface PunchPatchProps {\n  /** accessible name for the whole matrix */\n  label?: string;\n  /** row headers */\n  roles: PunchPatchRole[];\n  /** column headers */\n  permissions: PunchPatchPermission[];\n  /** cells punched (granted) at mount */\n  defaultGranted?: PunchPatchGrant[];\n  /** cells with a punch-then-patch history at mount: currently off, previously granted */\n  defaultRevoked?: PunchPatchGrant[];\n  /** cells granted via role inheritance — rendered as sealed, non-interactive eyelets */\n  inherited?: PunchPatchInherited[];\n  /** fires with the current directly-held (non-inherited) grants after any toggle */\n  onChange?: (grants: PunchPatchGrant[]) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\ninterface CellState {\n  granted: boolean;\n  /** sticky for the session once true: this cell has been revoked at least once */\n  patched: boolean;\n}\n\nconst key = (roleId: string, permId: string) => `${roleId}::${permId}`;\n\nfunction useReducedMotion() {\n  return useRef(\n    typeof window !== \"undefined\" &&\n      window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n  ).current;\n}\n\nfunction buildInitialCells(\n  granted: PunchPatchGrant[],\n  revoked: PunchPatchGrant[],\n): Map<string, CellState> {\n  const m = new Map<string, CellState>();\n  for (const g of granted) m.set(key(g.roleId, g.permissionId), { granted: true, patched: false });\n  for (const r of revoked) m.set(key(r.roleId, r.permissionId), { granted: false, patched: true });\n  return m;\n}\n\ninterface PunchBar {\n  id: number;\n  left: number;\n  top: number;\n  width: number;\n}\n\nexport function PunchPatch({\n  label = \"Role permissions\",\n  roles,\n  permissions,\n  defaultGranted = [],\n  defaultRevoked = [],\n  inherited = [],\n  onChange,\n  className = \"\",\n}: PunchPatchProps) {\n  const reduced = useReducedMotion();\n  const baseId = useId();\n  const patchedDescId = `${baseId}-patched-desc`;\n\n  const [cells, setCells] = useState<Map<string, CellState>>(() =>\n    buildInitialCells(defaultGranted, defaultRevoked),\n  );\n  const [focusedR, setFocusedR] = useState(0);\n  const [focusedP, setFocusedP] = useState(0);\n  const [punchBar, setPunchBar] = useState<PunchBar | null>(null);\n\n  const chadEverRef = useRef<Set<string>>(new Set());\n  const gridWrapRef = useRef<HTMLDivElement>(null);\n  const cellRefs = useRef<Map<string, HTMLButtonElement>>(new Map());\n  const liveRef = useRef<HTMLDivElement>(null);\n  const barIdRef = useRef(0);\n  const barTimeoutRef = useRef<number | null>(null);\n\n  const inheritedMap = useMemo(() => {\n    const m = new Map<string, string>();\n    for (const i of inherited) m.set(key(i.roleId, i.permissionId), i.from);\n    return m;\n  }, [inherited]);\n\n  const permById = useMemo(() => {\n    const m = new Map<string, PunchPatchPermission>();\n    for (const p of permissions) m.set(p.id, p);\n    return m;\n  }, [permissions]);\n\n  const announce = (text: string) => {\n    if (liveRef.current) liveRef.current.textContent = text;\n  };\n\n  const emitChange = (next: Map<string, CellState>) => {\n    if (!onChange) return;\n    const out: PunchPatchGrant[] = [];\n    for (const role of roles) {\n      for (const perm of permissions) {\n        if (next.get(key(role.id, perm.id))?.granted) {\n          out.push({ roleId: role.id, permissionId: perm.id });\n        }\n      }\n    }\n    onChange(out);\n  };\n\n  const triggerPunchBar = (roleId: string, fromPermId: string, toPermId: string) => {\n    if (reduced) return;\n    const wrap = gridWrapRef.current;\n    const fromEl = cellRefs.current.get(key(roleId, fromPermId));\n    const toEl = cellRefs.current.get(key(roleId, toPermId));\n    if (!wrap || !fromEl || !toEl) return;\n    const wr = wrap.getBoundingClientRect();\n    const fr = fromEl.getBoundingClientRect();\n    const tr = toEl.getBoundingClientRect();\n    const left = Math.min(fr.left, tr.left) - wr.left;\n    const right = Math.max(fr.right, tr.right) - wr.left;\n    if (barTimeoutRef.current) window.clearTimeout(barTimeoutRef.current);\n    barIdRef.current += 1;\n    setPunchBar({\n      id: barIdRef.current,\n      left,\n      top: fr.top - wr.top + fr.height / 2 - 1.5,\n      width: right - left,\n    });\n    barTimeoutRef.current = window.setTimeout(() => setPunchBar(null), 340);\n  };\n\n  // Reads `cells` from the render closure rather than a functional setState\n  // updater on purpose: toggle only ever runs from a discrete click/keydown,\n  // one at a time, each committed before the next can fire — so there's no\n  // batching hazard here. That keeps onChange/announce/triggerPunchBar (each\n  // itself a state update) out of the updater function, where React would\n  // otherwise double-invoke them under StrictMode and warn about setState\n  // during another component's render.\n  const toggle = (roleId: string, roleName: string, permId: string) => {\n    const k = key(roleId, permId);\n    if (inheritedMap.has(k)) return;\n\n    const cur = cells.get(k) ?? { granted: false, patched: false };\n    const next = new Map(cells);\n    const perm = permById.get(permId);\n    const permName = perm?.name ?? permId;\n\n    if (cur.granted) {\n      next.set(k, { granted: false, patched: true });\n      setCells(next);\n      announce(`${permName} revoked for ${roleName}. Previously granted.`);\n      emitChange(next);\n      return;\n    }\n\n    next.set(k, { granted: true, patched: cur.patched });\n    chadEverRef.current.add(k);\n\n    let cascadeName: string | null = null;\n    let cascadeReqId: string | null = null;\n    const reqId = perm?.requires;\n    if (reqId) {\n      const reqKey = key(roleId, reqId);\n      const reqCur = cells.get(reqKey) ?? { granted: false, patched: false };\n      if (!reqCur.granted) {\n        next.set(reqKey, { granted: true, patched: reqCur.patched });\n        chadEverRef.current.add(reqKey);\n        cascadeName = permById.get(reqId)?.name ?? reqId;\n        cascadeReqId = reqId;\n      }\n    }\n\n    setCells(next);\n    if (cascadeReqId) triggerPunchBar(roleId, permId, cascadeReqId);\n    announce(\n      cascadeName\n        ? `${permName} granted for ${roleName}. Also granted: ${cascadeName}.`\n        : `${permName} granted for ${roleName}.`,\n    );\n    emitChange(next);\n  };\n\n  const colTemplate = `10rem repeat(${permissions.length}, minmax(3.25rem, 1fr))`;\n\n  const moveFocus = (nr: number, np: number) => {\n    const r = Math.min(Math.max(nr, 0), roles.length - 1);\n    const p = Math.min(Math.max(np, 0), permissions.length - 1);\n    setFocusedR(r);\n    setFocusedP(p);\n    cellRefs.current.get(key(roles[r].id, permissions[p].id))?.focus();\n  };\n\n  const onGridKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    switch (e.key) {\n      case \"ArrowRight\":\n        e.preventDefault();\n        moveFocus(focusedR, focusedP + 1);\n        return;\n      case \"ArrowLeft\":\n        e.preventDefault();\n        moveFocus(focusedR, focusedP - 1);\n        return;\n      case \"ArrowDown\":\n        e.preventDefault();\n        moveFocus(focusedR + 1, focusedP);\n        return;\n      case \"ArrowUp\":\n        e.preventDefault();\n        moveFocus(focusedR - 1, focusedP);\n        return;\n      case \"Home\":\n        e.preventDefault();\n        moveFocus(focusedR, 0);\n        return;\n      case \"End\":\n        e.preventDefault();\n        moveFocus(focusedR, permissions.length - 1);\n        return;\n      default:\n        return;\n    }\n  };\n\n  return (\n    <div className={`ns-pp-root w-full ${className}`}>\n      <style>{`\n        @keyframes ns-pp-patch-in {\n          from { transform: translateX(7px); opacity: 0; }\n          to { transform: translateX(0); opacity: 1; }\n        }\n        .ns-pp-patch-in { animation: ns-pp-patch-in 200ms cubic-bezier(0,0,0.2,1) both; }\n        @keyframes ns-pp-chad {\n          from { transform: translateY(0); opacity: 1; }\n          to { transform: translateY(10px); opacity: 0; }\n        }\n        .ns-pp-chad { animation: ns-pp-chad 200ms cubic-bezier(0.4,0,1,1) both; }\n        @keyframes ns-pp-bar {\n          from { transform: scaleX(0); opacity: 0.9; }\n          65% { opacity: 0.9; }\n          to { transform: scaleX(1); opacity: 0; }\n        }\n        .ns-pp-bar { animation: ns-pp-bar 320ms cubic-bezier(0.16,1,0.3,1) both; transform-origin: left center; }\n        @media (prefers-reduced-motion: reduce) {\n          .ns-pp-patch-in, .ns-pp-chad, .ns-pp-bar { animation: none !important; }\n        }\n      `}</style>\n\n      <p className=\"mb-2 font-mono text-xs tracking-widest text-ns-muted uppercase\">{label}</p>\n\n      <div className=\"w-full overflow-x-auto rounded-md border border-border\">\n        <div\n          ref={gridWrapRef}\n          role=\"grid\"\n          aria-label={label}\n          onKeyDown={onGridKeyDown}\n          className=\"relative min-w-max\"\n        >\n          {punchBar && (\n            <div\n              key={punchBar.id}\n              aria-hidden\n              className=\"ns-pp-bar pointer-events-none absolute z-10 h-[3px] rounded-full bg-ns-accent/50\"\n              style={{ left: punchBar.left, top: punchBar.top, width: punchBar.width }}\n            />\n          )}\n\n          <div role=\"row\" className=\"grid border-b border-border\" style={{ gridTemplateColumns: colTemplate }}>\n            <div role=\"columnheader\" className=\"border-r border-border\">\n              <span className=\"sr-only\">Role</span>\n            </div>\n            {permissions.map((perm) => (\n              <div\n                key={perm.id}\n                role=\"columnheader\"\n                className=\"border-r border-border px-2 py-2 text-center font-mono text-[10px] leading-tight tracking-wide text-ns-muted uppercase last:border-r-0\"\n              >\n                {perm.name}\n              </div>\n            ))}\n          </div>\n\n          {roles.map((role, r) => (\n            <div\n              key={role.id}\n              role=\"row\"\n              className=\"grid border-b border-border last:border-b-0\"\n              style={{ gridTemplateColumns: colTemplate }}\n            >\n              <div\n                role=\"rowheader\"\n                className=\"flex items-center border-r border-border px-3 py-2 text-sm text-foreground\"\n              >\n                {role.name}\n              </div>\n              {permissions.map((perm, p) => {\n                const k = key(role.id, perm.id);\n                const from = inheritedMap.get(k);\n                const isInherited = from !== undefined;\n                const cell = cells.get(k) ?? { granted: false, patched: false };\n                const isTabbable = r === focusedR && p === focusedP;\n                const tipId = `${baseId}-tip-${role.id}-${perm.id}`;\n                const showChad = !reduced && cell.granted && chadEverRef.current.has(k);\n\n                if (isInherited) {\n                  return (\n                    <div\n                      key={perm.id}\n                      role=\"gridcell\"\n                      className=\"border-r border-border last:border-r-0\"\n                    >\n                      <button\n                        ref={(el) => {\n                          if (el) cellRefs.current.set(k, el);\n                          else cellRefs.current.delete(k);\n                        }}\n                        type=\"button\"\n                        role=\"checkbox\"\n                        aria-checked=\"true\"\n                        aria-disabled=\"true\"\n                        aria-label={`${role.name}: ${perm.name}`}\n                        aria-describedby={tipId}\n                        className=\"group relative flex h-12 w-full cursor-default items-center justify-center focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ns-accent\"\n                        tabIndex={isTabbable ? 0 : -1}\n                        onFocus={() => {\n                          setFocusedR(r);\n                          setFocusedP(p);\n                        }}\n                      >\n                        <svg width={26} height={26} viewBox=\"0 0 40 40\" aria-hidden className=\"shrink-0\" style={{ pointerEvents: \"none\" }}>\n                          <circle cx={20} cy={20} r={9} fill=\"var(--background)\" stroke=\"var(--foreground)\" strokeWidth={1.5} opacity={0.85} />\n                          <circle cx={20} cy={20} r={3} fill=\"none\" stroke=\"var(--foreground)\" strokeWidth={1} opacity={0.5} />\n                        </svg>\n                        <span\n                          id={tipId}\n                          role=\"tooltip\"\n                          className=\"pointer-events-none absolute top-full left-1/2 z-20 mt-1 w-max max-w-[9rem] -translate-x-1/2 rounded-sm border border-border bg-background px-1.5 py-1 text-left text-[10px] leading-tight text-foreground opacity-0 shadow-sm transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100\"\n                        >\n                          Inherited from {from}\n                        </span>\n                      </button>\n                    </div>\n                  );\n                }\n\n                const describedBy = !cell.granted && cell.patched ? patchedDescId : undefined;\n\n                return (\n                  <div\n                    key={perm.id}\n                    role=\"gridcell\"\n                    className=\"border-r border-border last:border-r-0\"\n                  >\n                    <button\n                      ref={(el) => {\n                        if (el) cellRefs.current.set(k, el);\n                        else cellRefs.current.delete(k);\n                      }}\n                      type=\"button\"\n                      role=\"checkbox\"\n                      aria-checked={cell.granted}\n                      aria-label={`${role.name}: ${perm.name}`}\n                      aria-describedby={describedBy}\n                      data-pp-idx={r * permissions.length + p}\n                      tabIndex={isTabbable ? 0 : -1}\n                      onFocus={() => {\n                        setFocusedR(r);\n                        setFocusedP(p);\n                      }}\n                      onClick={() => toggle(role.id, role.name, perm.id)}\n                      className=\"flex h-12 w-full items-center justify-center transition-colors duration-150 hover:bg-border/30 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ns-accent\"\n                    >\n                      <svg width={26} height={26} viewBox=\"0 0 40 40\" aria-hidden className=\"shrink-0\" style={{ pointerEvents: \"none\" }}>\n                        <circle\n                          cx={20}\n                          cy={20}\n                          r={10}\n                          fill=\"none\"\n                          stroke=\"var(--foreground)\"\n                          strokeWidth={1}\n                          strokeDasharray=\"2.5 3\"\n                          opacity={0.28}\n                        />\n                        {cell.patched && (\n                          <g className={reduced ? undefined : \"ns-pp-patch-in\"}>\n                            <rect x={12} y={12} width={18} height={18} rx={3} fill=\"var(--foreground)\" opacity={0.22} />\n                            <rect x={11} y={11} width={18} height={18} rx={3} fill=\"var(--ns-muted)\" />\n                            <path d=\"M12.5 16 L12.5 12 L16.5 12\" fill=\"none\" stroke=\"var(--foreground)\" strokeWidth={1} strokeLinecap=\"round\" opacity={0.4} />\n                            <path d=\"M27.5 24 L27.5 28 L23.5 28\" fill=\"none\" stroke=\"var(--foreground)\" strokeWidth={1} strokeLinecap=\"round\" opacity={0.4} />\n                          </g>\n                        )}\n                        <circle\n                          data-pp-hole={cell.granted ? \"\" : undefined}\n                          cx={20}\n                          cy={20}\n                          r={cell.patched ? 6 : 8}\n                          fill=\"var(--background)\"\n                          // A hole cut in bare card stock is background-on-\n                          // background: without an edge, a granted cell renders\n                          // pixel-identical to an ungranted one at rest (the\n                          // patched case reads only because the patch behind it\n                          // is --ns-muted). The rim is what makes the punch a\n                          // punch on every cell, not just the patched ones.\n                          stroke=\"var(--foreground)\"\n                          strokeWidth={1.5}\n                          style={{\n                            transformBox: \"fill-box\",\n                            transformOrigin: \"center\",\n                            transform: cell.granted ? \"scale(1)\" : \"scale(0)\",\n                            opacity: cell.granted ? 1 : 0,\n                            transition: reduced\n                              ? \"none\"\n                              : \"transform 160ms cubic-bezier(0.3,0.9,0.4,1), opacity 130ms linear\",\n                          }}\n                        />\n                        {showChad && (\n                          <circle key=\"chad\" className=\"ns-pp-chad\" cx={20} cy={20} r={3} fill=\"var(--ns-muted)\" />\n                        )}\n                      </svg>\n                    </button>\n                  </div>\n                );\n              })}\n            </div>\n          ))}\n        </div>\n      </div>\n\n      <span id={patchedDescId} className=\"sr-only\">\n        Previously granted, later revoked.\n      </span>\n      <div ref={liveRef} role=\"status\" aria-live=\"polite\" className=\"sr-only\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/punch-patch.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "matrix",
      "permissions",
      "access-control",
      "grid",
      "checkbox",
      "admin",
      "svg",
      "aria-grid",
      "micro-interaction"
    ],
    "instruction": "Build a role x permission access matrix styled as a punch card whose material remembers its own history. DATA: roles (rows) x permissions (columns, some carrying a `requires` id naming another permission in the same set), plus defaultGranted, defaultRevoked (cells that start already patched — off, but previously held) and inherited (roleId+permissionId+from, rendered as sealed non-interactive eyelets) props. STRUCTURE: real ARIA grid — role=grid on the wrapper, role=row per row, role=columnheader for each permission, role=rowheader for each role name, role=gridcell wrapping each control. Every directly-editable cell is a real button role=checkbox aria-checked, accessible name '{role}: {permission}' (e.g. 'Editors: delete projects'). STATE PER CELL: two booleans, granted (current) and patched (sticky for the session — true forever once the cell has ever been revoked). RENDER LAYERS (all SVG, back to front): a faint dashed punch-site rim in --border; if patched, a patch group (a 1px-offset shadow rect in --foreground at low opacity, a solid --ns-muted patch rect 1px smaller than the rim on every side so the rim peeks through, and two small torn-corner tick marks in --foreground) that mounts once, ever, animating in with a 200ms ease-out translateX+opacity entrance the first time patched flips true, and stays mounted permanently after — this is what a re-grant punches through; a hole circle, always mounted, radius 8 on bare stock or 6 when patched (so it reads as a smaller void cut through the patch's centre), filled var(--background), scaled 0->1 and faded 0->1 on every grant/revoke via a 160ms cubic-bezier(0.3,0.9,0.4,1) transform transition with no overshoot (percussive, hard stop, not springy) — this single scale/opacity pair is what animates both a fresh punch and a punch-through-patch identically; a transient chad piece (small --ns-muted disc) that mounts only on a live user-triggered grant (never for seeded defaultGranted state) and plays a 200ms translateY(10px)+fade-out keyframe animation once via natural conditional mount/unmount, never replayed while the cell stays granted. TOGGLE: click or Space on a cell flips granted; going true->false sets patched=true (revoke, sticky) and announces '{permission} revoked for {role}. Previously granted.'; going false->true is a grant — if the permission has `requires` and the role doesn't yet hold that dependency, cascade-grant it in the SAME state update (both cells' hole transitions start together, not sequenced) and additionally fire one shared 'punch bar' — a thin --ns-accent bar measured from the two cells' real getBoundingClientRect()s via a ref map, positioned absolutely over the row, playing one 320ms scaleX(0->1)+fade keyframe connecting them — then announce '{permission} granted for {role}. Also granted: {dependency}.' via a single aria-live=polite status region; a plain grant with no cascade announces '{permission} granted for {role}.'. INHERITED CELLS: role=checkbox aria-checked=true aria-disabled=true aria-describedby pointing at a per-cell tooltip node reading 'Inherited from {parent role}' (kept in the accessibility tree at all times via opacity, not visibility/display, so assistive tech always gets it; shown visually on hover/focus via group-hover/group-focus-visible opacity), rendered as a sealed eyelet — solid rim circle stroke var(--foreground) plus a smaller inner ring, no punch/patch mechanics, not clickable (no onClick), but still reachable by grid navigation and Tab. PATCHED-CELL DESCRIPTION: every currently-off, ever-patched cell carries aria-describedby pointing at one shared sr-only node reading 'Previously granted, later revoked.' — the accessible description that keeps history honest even though aria-checked reads false. NAVIGATION: roving tabindex over the interactive+eyelet cells in a 2D roles x permissions array — only the focused cell has tabIndex 0, arrow keys move focus row/column-wise (clamped, no wrap), Home/End jump to the row's first/last column; real buttons already give Space/Enter the native click, no extra key handling needed for toggling. TOKENS: --background --foreground --ns-muted --border --ns-accent only, --ns-accent used exclusively for the transient cascade punch bar and focus rings (interaction-only). Reduced motion: every keyframe animation and the punch/patch CSS transition collapse to none via a top-level @media query plus a JS matchMedia read at mount, so grant/revoke/cascade apply their end states instantly with no chad and no bar. No canvas."
  },
  "type": "registry:ui"
}