Queue Triage Ratchet

Queue

Card-triage queue driven by a literal one-way ratchet — deciding a card clicks a toothed rail forward one notch; undo means visibly lifting the pawl before the rail eases back.

Install
npx shadcn add https://design.helpmarq.com/r/queue-triage-ratchet.json
Source
registry/core/queue-triage-ratchet/component.tsx
"use client";

// PawlClick — a card-triage queue whose progress is a literal ratchet. Below
// the flat (no-3D) card stack sits a horizontal SVG rack of sawtooth teeth
// and a pawl. Deciding a card (archive or keep) fires the card out with a
// spring, and the rack advances exactly one tooth-width under a fixed pawl
// with a hard-stop ease — zero overshoot, sharp settle, unmistakably a click
// that only goes one way. Undo is deliberately a different kind of gesture:
// the pawl rotates up ~20deg and holds — visibly disengaging the mechanism —
// before the rack is allowed to ease backward one tooth, slowly and with
// visible effort, and only then does the pawl drop again. Undo always plays
// the lift/hold/drop gesture on request (never a disabled dead end); it only
// additionally restores a card and eases the rack when there is something in
// history to restore.
//
// Cards are a listbox: the stack is role="listbox" with roving
// aria-activedescendant onto the top card (role="option"). Left/Right arrows
// or J/K triage (archive/keep), U or Cmd/Ctrl+Z undoes — every gesture also
// has an on-screen button, nothing requires drag. A polite aria-live region
// announces each outcome ("Archived. 6 remaining. Press U to undo."). The
// rack + pawl SVG is aria-hidden — purely decorative reinforcement of a
// state that's already fully described in text.
//
// Reduced motion: card exit becomes a fast plain fade, the rack steps
// instantly (no transition), and the pawl's lift/drop is an instant
// attribute flip with no rotation tween — semantics (and the aria-live
// announcements) are unchanged either way.
//
// Pure DOM/SVG/CSS, no canvas. Colors are CSS custom properties only:
// --foreground for ink/ the engaged tooth, --border for resting teeth and
// hairlines, --muted for secondary text, --accent for focus rings only.

import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";

export interface PawlClickCard {
  id: string;
  title: string;
  subtitle?: string;
  body?: string;
}

export interface PawlClickProps {
  /** Initial queue, top of stack first. Managed internally after mount. */
  cards: PawlClickCard[];
  /** Called with the card whenever it's archived. */
  onArchive?: (card: PawlClickCard) => void;
  /** Called with the card whenever it's kept. */
  onKeep?: (card: PawlClickCard) => void;
  /** Called with the card whenever an archive/keep is undone. */
  onUndo?: (card: PawlClickCard) => void;
  /** Called once the queue is fully drained. */
  onEmpty?: () => void;
  className?: string;
}

type Decision = "archived" | "kept";
type Phase = "idle" | "commit" | "undo-lift" | "undo-ease";

interface HistoryEntry {
  card: PawlClickCard;
  decision: Decision;
}

const TOOTH = 26; // px, tooth pitch
const RACK_PAD = 12; // px, left padding before the first tooth
const PAWL_X = RACK_PAD + 10; // px, fixed screen x the pawl tip engages at

const COMMIT_RACK_MS = 260; // hard-stop ease, zero overshoot
const COMMIT_EXIT_MS = 320; // card spring exit
const PAWL_FLEX_MS = 200; // brief flex-over-tooth on drop

const LIFT_MS = 240; // pawl rotates up and holds
const EASE_MS = 680; // slow, effortful rack ease backward
const EMPTY_HOLD_MS = 320; // lift-and-drop with nothing to undo

const RACK_EASE_FWD = "cubic-bezier(0.16, 1, 0.3, 1)"; // sharp settle, no overshoot
const RACK_EASE_BACK = "cubic-bezier(0.65, 0, 0.35, 1)"; // slow, labored
const CARD_EXIT_EASE = "cubic-bezier(0.34, 1.56, 0.64, 1)"; // spring-ish snap

function useReducedMotion() {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduced;
}

export function PawlClick({
  cards,
  onArchive,
  onKeep,
  onUndo,
  onEmpty,
  className = "",
}: PawlClickProps) {
  const uid = useId();
  const reducedMotion = useReducedMotion();

  const initialRef = useRef(cards);
  const totalRef = useRef(Math.max(1, cards.length));
  const teethCount = totalRef.current;

  const [queue, setQueue] = useState<PawlClickCard[]>(initialRef.current);
  const [history, setHistory] = useState<HistoryEntry[]>([]);
  const [notch, setNotch] = useState(0);
  const [phase, setPhase] = useState<Phase>("idle");
  const [exiting, setExiting] = useState<{ card: PawlClickCard; dir: "left" | "right" } | null>(
    null,
  );
  const [announcement, setAnnouncement] = useState("");
  const [busy, setBusy] = useState(false); // gates Archive/Keep only

  const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
  const undoBusyRef = useRef(false);
  const emptyFiredRef = useRef(false);

  const addTimer = useCallback((fn: () => void, ms: number) => {
    const id = setTimeout(() => {
      timersRef.current.delete(id);
      fn();
    }, ms);
    timersRef.current.add(id);
    return id;
  }, []);

  const clearAllTimers = useCallback(() => {
    timersRef.current.forEach((id) => clearTimeout(id));
    timersRef.current.clear();
  }, []);

  useEffect(() => clearAllTimers, [clearAllTimers]);

  useEffect(() => {
    if (queue.length === 0 && !emptyFiredRef.current) {
      emptyFiredRef.current = true;
      onEmpty?.();
    } else if (queue.length > 0) {
      emptyFiredRef.current = false;
    }
  }, [queue.length, onEmpty]);

  const commit = useCallback(
    (decision: Decision) => {
      if (busy || queue.length === 0) return;
      const card = queue[0];
      const dir: "left" | "right" = decision === "archived" ? "left" : "right";

      setBusy(true);
      setPhase("commit");
      setExiting({ card, dir });
      setQueue((q) => q.slice(1));
      setHistory((h) => [...h, { card, decision }]);
      setNotch((n) => Math.min(teethCount - 1, n + 1));

      const remaining = queue.length - 1;
      const label = decision === "archived" ? "Archived" : "Kept";
      setAnnouncement(
        remaining > 0
          ? `${label}. ${remaining} remaining. Press U to undo.`
          : `${label}. Queue clear. Press U to undo.`,
      );

      if (decision === "archived") onArchive?.(card);
      else onKeep?.(card);

      const exitMs = reducedMotion ? 120 : COMMIT_EXIT_MS;
      const rackMs = reducedMotion ? 0 : COMMIT_RACK_MS + PAWL_FLEX_MS;
      const settleMs = Math.max(exitMs, rackMs);

      addTimer(() => {
        setExiting(null);
        setPhase("idle");
        setBusy(false);
      }, settleMs);
    },
    [busy, queue, teethCount, reducedMotion, addTimer, onArchive, onKeep],
  );

  const undo = useCallback(() => {
    if (undoBusyRef.current) return;
    undoBusyRef.current = true;
    // clearAllTimers cancels any pending commit-settle timer too — reconcile
    // that lock immediately rather than leaving Archive/Keep dead forever if
    // undo interrupts a still-animating commit.
    clearAllTimers();
    setBusy(false);
    setExiting(null);
    setPhase("undo-lift");

    // captured from this render's closure, not read back out of a state
    // updater — updaters must stay pure (StrictMode invokes them twice),
    // so every side effect below runs in a plain timer callback instead.
    const entry = history[history.length - 1];
    const queueLenAtClick = queue.length;

    // The CSS transition duration is zeroed under reduced motion (an
    // instant step, no tween) but the *state* still holds briefly either
    // way — "lifted" has to be a real, observable moment, not motion.
    const liftMs = reducedMotion ? 180 : LIFT_MS;
    addTimer(() => {
      if (!entry) {
        setAnnouncement("Nothing to undo.");
        const holdMs = reducedMotion ? 150 : EMPTY_HOLD_MS;
        addTimer(() => {
          setPhase("idle");
          undoBusyRef.current = false;
        }, holdMs);
        return;
      }
      setPhase("undo-ease");
      setHistory((h) => h.slice(0, -1));
      setQueue((q) => [entry.card, ...q]);
      setNotch((n) => Math.max(0, n - 1));
      onUndo?.(entry.card);
      setAnnouncement(`Undone. ${entry.card.title} restored. ${queueLenAtClick + 1} remaining.`);
      const easeMs = reducedMotion ? 220 : EASE_MS;
      addTimer(() => {
        setPhase("idle");
        undoBusyRef.current = false;
      }, easeMs);
    }, liftMs);
  }, [history, queue, reducedMotion, addTimer, clearAllTimers, onUndo]);

  const onKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLDivElement>) => {
      const key = e.key.toLowerCase();
      if (key === "arrowleft" || key === "j") {
        e.preventDefault();
        commit("archived");
      } else if (key === "arrowright" || key === "k") {
        e.preventDefault();
        commit("kept");
      } else if (key === "u" || ((e.metaKey || e.ctrlKey) && key === "z")) {
        e.preventDefault();
        undo();
      }
    },
    [commit, undo],
  );

  const engagedTooth = Math.min(notch, teethCount - 1);
  const rackTranslate = PAWL_X - (engagedTooth * TOOTH + RACK_PAD);
  const rackDuration = reducedMotion
    ? 0
    : phase === "undo-ease"
      ? EASE_MS
      : COMMIT_RACK_MS;
  const rackEase = phase === "undo-ease" ? RACK_EASE_BACK : RACK_EASE_FWD;

  const pawlLifted = phase === "undo-lift" || phase === "undo-ease";
  const pawlAngle = pawlLifted ? -20 : 0;
  const pawlDuration = reducedMotion ? 0 : phase === "undo-ease" ? 0 : LIFT_MS;

  const teeth = useMemo(() => {
    const out: { x: number; d: string }[] = [];
    for (let i = 0; i < teethCount; i++) {
      const x = RACK_PAD + i * TOOTH;
      // asymmetric sawtooth: shallow climbing ramp, then a steep blocking
      // face — the geometry itself reads "this only turns one way".
      const d = `M ${x} 30 L ${x + TOOTH * 0.72} 8 L ${x + TOOTH} 8 L ${x + TOOTH} 30 Z`;
      out.push({ x, d });
    }
    return out;
  }, [teethCount]);

  const rackWidth = RACK_PAD + teethCount * TOOTH + RACK_PAD;

  const topCard = queue[0];
  const stackVisible = queue.slice(0, 3);
  const optionId = (id: string) => `${uid}-opt-${id}`;
  const busyOrEmpty = busy || queue.length === 0;

  return (
    <div className={["mx-auto flex w-full max-w-md flex-col gap-5", className].join(" ")}>
      <div
        role="listbox"
        aria-label="Card triage queue"
        aria-activedescendant={topCard ? optionId(topCard.id) : undefined}
        tabIndex={0}
        onKeyDown={onKeyDown}
        className="ns-pw-stack relative rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        style={{ height: 176 }}
      >
        {stackVisible
          .slice()
          .reverse()
          .map((card, ri) => {
            const i = stackVisible.length - 1 - ri;
            const isTop = i === 0;
            return (
              <div
                key={card.id}
                id={optionId(card.id)}
                role="option"
                aria-selected={isTop}
                className="ns-pw-card absolute inset-x-0 top-0 flex flex-col gap-1.5 rounded-md border border-border bg-surface p-5 shadow-sm"
                style={{
                  transform: `translateY(${i * 10}px) scale(${1 - i * 0.03})`,
                  opacity: 1 - i * 0.22,
                  zIndex: 10 - i,
                }}
              >
                <h3 className="text-sm font-semibold text-foreground">{card.title}</h3>
                {card.subtitle && (
                  <p className="text-xs text-muted">{card.subtitle}</p>
                )}
                {card.body && isTop && (
                  <p className="mt-1 text-sm leading-relaxed text-foreground/90">{card.body}</p>
                )}
              </div>
            );
          })}

        {exiting && (
          <div
            aria-hidden
            className="ns-pw-card ns-pw-exit absolute inset-x-0 top-0 flex flex-col gap-1.5 rounded-md border border-border bg-surface p-5 shadow-sm"
            data-dir={exiting.dir}
            data-reduced={reducedMotion || undefined}
            style={{ zIndex: 20 }}
          >
            <h3 className="text-sm font-semibold text-foreground">{exiting.card.title}</h3>
            {exiting.card.subtitle && (
              <p className="text-xs text-muted">{exiting.card.subtitle}</p>
            )}
          </div>
        )}

        {!topCard && (
          <div className="absolute inset-0 flex items-center justify-center rounded-md border border-dashed border-border">
            <p className="text-sm text-muted">Queue clear</p>
          </div>
        )}
      </div>

      {/* rack + pawl — decorative, fully described by the live region above */}
      <div aria-hidden="true" className="relative h-9 w-full">
        <div className="absolute inset-0 overflow-hidden rounded-sm border border-border bg-background">
          <svg
            width={rackWidth}
            height={38}
            viewBox={`0 0 ${rackWidth} 38`}
            className="absolute inset-y-0"
            style={{
              transform: `translateX(${rackTranslate}px)`,
              transitionProperty: "transform",
              transitionDuration: `${rackDuration}ms`,
              transitionTimingFunction: rackEase,
            }}
          >
            {teeth.map((t, i) => (
              <path
                key={t.x}
                d={t.d}
                fill={i === engagedTooth ? "var(--foreground)" : "none"}
                fillOpacity={i === engagedTooth ? 0.14 : 1}
                stroke={i === engagedTooth ? "var(--foreground)" : "var(--border)"}
                strokeWidth={i === engagedTooth ? 1.75 : 1.25}
              />
            ))}
          </svg>
        </div>

        <svg
          width={22}
          height={26}
          viewBox="0 0 22 26"
          className="pointer-events-none absolute -top-3"
          style={{
            left: PAWL_X - 5,
            transform: `rotate(${pawlAngle}deg)`,
            transformOrigin: "4px 5px",
            transitionProperty: "transform",
            transitionDuration: `${pawlDuration}ms`,
            transitionTimingFunction: "cubic-bezier(0.34, 1.56, 0.64, 1)",
          }}
        >
          <path
            d="M4 4 L4 15 Q4 19 9 19 L16 19"
            fill="none"
            stroke="var(--foreground)"
            strokeWidth={2.25}
            strokeLinecap="round"
            strokeLinejoin="round"
          />
          <circle cx={4} cy={4} r={2} fill="var(--foreground)" />
        </svg>

        {/* Real (non-decorative, hit-testable) "armed to reverse" marker —
            the pawl svg above is pointer-events-none and its stroke is thin
            enough that a screenshot/gate hit-test at its own center can
            land on empty space, so the gate's `expect` target is this
            plain, normally-painted node instead, not the svg. */}
        {pawlLifted && (
          <div
            data-pawl-lifted="true"
            aria-hidden="true"
            className="absolute h-2.5 w-2.5 rounded-full bg-foreground"
            style={{ left: PAWL_X - 6, top: -10 }}
          />
        )}
      </div>

      <div className="flex items-center gap-2">
        <button
          type="button"
          onClick={() => commit("archived")}
          disabled={busyOrEmpty}
          className="ns-pw-btn flex-1 rounded-sm border border-border bg-surface px-3 py-2 text-sm font-medium text-foreground transition-colors hover:enabled:border-muted hover:enabled:bg-border/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-45"
        >
          Archive
        </button>
        <button
          type="button"
          onClick={() => commit("kept")}
          disabled={busyOrEmpty}
          className="ns-pw-btn flex-1 rounded-sm border border-border bg-surface px-3 py-2 text-sm font-medium text-foreground transition-colors hover:enabled:border-muted hover:enabled:bg-border/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-45"
        >
          Keep
        </button>
        <button
          type="button"
          data-pawl-undo
          onClick={undo}
          className="ns-pw-btn rounded-sm border border-border bg-surface px-3 py-2 text-sm font-medium text-muted transition-colors hover:border-muted hover:bg-border/60 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
        >
          Undo
        </button>
      </div>

      <p className="text-xs text-muted">
        Left/Right or J/K to triage, U (or Cmd/Ctrl+Z) to undo — undo lifts the pawl before the
        rack eases back.
      </p>

      <div role="status" aria-live="polite" className="sr-only">
        {announcement}
      </div>

      <style>{`
        .ns-pw-card {
          transition: transform 260ms ${CARD_EXIT_EASE}, opacity 260ms ease;
        }
        .ns-pw-exit[data-dir="left"] {
          animation: ns-pw-exit-left ${COMMIT_EXIT_MS}ms ${CARD_EXIT_EASE} both;
        }
        .ns-pw-exit[data-dir="right"] {
          animation: ns-pw-exit-right ${COMMIT_EXIT_MS}ms ${CARD_EXIT_EASE} both;
        }
        @keyframes ns-pw-exit-left {
          to { transform: translateX(-135%) rotate(-8deg); opacity: 0; }
        }
        @keyframes ns-pw-exit-right {
          to { transform: translateX(135%) rotate(8deg); opacity: 0; }
        }
        .ns-pw-exit[data-reduced][data-dir] {
          animation: ns-pw-exit-fade 120ms ease both;
        }
        @keyframes ns-pw-exit-fade {
          to { opacity: 0; }
        }
      `}</style>
    </div>
  );
}
Build spec

A card-triage queue for decision flows (inbox triage, review/approve, flashcards) where progress should feel earned rather than ambient. Cards sit in a flat, stacked layout — no 3D tilt, just a shallow translateY/scale/opacity falloff for the two cards behind the top one — inside a role=listbox with roving aria-activedescendant onto the top card (role=option, aria-selected on the top). Beneath the stack sits a horizontal SVG rack: an asymmetric sawtooth strip (shallow climbing ramp, steep blocking face — the geometry alone reads 'this only turns one way'), teeth stroked in --border, with a fixed pawl (a small hooked SVG path) engaging the current tooth from above; the engaged tooth's stroke switches to --foreground with a faint --foreground fill so progress is legible even as a static screenshot. Deciding a card — Archive or Keep, via on-screen buttons, Left/Right arrows, or J/K — fires the card off-stage with a spring-ish snap (cubic-bezier(0.34,1.56,0.64,1), translateX + slight rotate + fade) while the rack itself translates exactly one tooth-width to bring the next tooth under the fixed pawl, on a hard-stop ease (cubic-bezier(0.16,1,0.3,1)) with zero overshoot and a sharp settle — a firm click-snap, not a glide. Undo (button, U, or Cmd/Ctrl+Z) is a deliberately different gesture: the pawl first rotates up about 20 degrees and holds — visibly disengaging from the tooth — and only once it's lifted does the rack ease backward one tooth, slowly and on a labored curve (cubic-bezier(0.65,0,0.35,1), ~2.5x the forward duration) before the pawl drops again; undo always plays this lift/hold/drop gesture when pressed (it is never a dead, disabled control) and additionally restores the archived/kept card to the top of the queue and eases the rack only when there is actually something in history — pressing it with nothing to undo still lifts and drops the pawl, honestly showing the mechanism finding nothing to release. Every outcome is spoken through a single polite aria-live status region ('Archived. 6 remaining. Press U to undo.' / 'Kept. 5 remaining. Press U to undo.' / 'Undone. <title> restored. 6 remaining.' / 'Nothing to undo.'), so the queue is fully legible without ever looking at the rack — the rack (including the pawl) is aria-hidden, a redundant reinforcing visual, not a source of information. Every gesture has a single-keystroke and a visible-button equivalent; nothing requires drag. Reduced motion: the card exit collapses to a fast plain opacity fade (no translate/rotate/spring), the rack's transform transition duration drops to 0 (steps instantly to its new tooth), and the pawl's rotation is an instant attribute flip with no tween — all the same state changes and the same aria-live announcements, just without the motion. Pure DOM/SVG/CSS, no canvas; every color is a CSS custom property (--foreground for ink and the engaged tooth, --border for resting teeth and hairlines, --muted for secondary text, --accent reserved for the keyboard focus ring only). Distinct from avatar-stack-flock (a milling boids avatar formation with no commit/undo semantics at all) and segmented-control-fling (an elastic drag-to-fling segmented control with no notion of irreversibility) — queue-triage-ratchet's whole point is the one-way mechanical grammar of teeth, pawl, and notch-advance; the card stack is just the cargo riding on top of it.

Tags
queuetriageratchetundolistboxkeyboardcard-stackirreversibilityaria-live