From 5eaca21f2997e85688e5fd46efe23088bcdc35ae Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 9 Oct 2026 13:40:55 -0700 Subject: [PATCH 01/16] Add PR jail for people with too many open PRs A playful nudge to close out PRs: when your own open PRs pass a limit (more than 7 open, or any open longer than 14 days; drafts, bots and deploy-held PRs don't count), a jail cell drops over the board and lists them. Dragging the key into the padlock lets you out. It shows again only when things get worse: at least 4 hours after it last showed, with more open PRs or another one past the age line. A refresh never brings it back, and open tabs stay in sync. A lock badge beside the bell shows while you're over a limit and reopens the list. Settings hold both limits, whether drafts count, and a preview button (or #jail=1 in the URL). The art follows the light and dark themes; the robot stands behind the door with his tally wall beside him; on a phone the list comes first and the key is one drag straight down into the lock. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/app.tsx | 2 + frontend-v2/src/assets/jail/cell-r10.svg | 111 +++++ frontend-v2/src/assets/jail/inmate-r03.svg | 81 ++++ frontend-v2/src/assets/jail/inmate-r05.svg | 83 ++++ frontend-v2/src/assets/jail/key-g01.svg | 41 ++ frontend-v2/src/assets/jail/lock-g01.svg | 37 ++ frontend-v2/src/components/JailMode.tsx | 499 +++++++++++++++++++++ frontend-v2/src/components/Settings.tsx | 53 +++ frontend-v2/src/model/jail.test.ts | 140 ++++++ frontend-v2/src/model/jail.ts | 122 +++++ frontend-v2/src/settings.ts | 10 + frontend-v2/src/styles.css | 447 ++++++++++++++++++ 12 files changed, 1626 insertions(+) create mode 100644 frontend-v2/src/assets/jail/cell-r10.svg create mode 100644 frontend-v2/src/assets/jail/inmate-r03.svg create mode 100644 frontend-v2/src/assets/jail/inmate-r05.svg create mode 100644 frontend-v2/src/assets/jail/key-g01.svg create mode 100644 frontend-v2/src/assets/jail/lock-g01.svg create mode 100644 frontend-v2/src/components/JailMode.tsx create mode 100644 frontend-v2/src/model/jail.test.ts create mode 100644 frontend-v2/src/model/jail.ts diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 286530ae..0fff1684 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -59,6 +59,7 @@ import { Stats } from './views/Stats'; import type { ProjectsNav } from './views/Projects'; import { Search } from './views/Search'; import { Settings } from './components/Settings'; +import { JailMode } from './components/JailMode'; export type { Lens }; @@ -1172,6 +1173,7 @@ export function App() { > v1 board + + + Cell r10: neon sign cell + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PR JAIL + PR JAIL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LOCKED + LOCKED + + + + + + diff --git a/frontend-v2/src/assets/jail/inmate-r03.svg b/frontend-v2/src/assets/jail/inmate-r03.svg new file mode 100644 index 00000000..6b63299c --- /dev/null +++ b/frontend-v2/src/assets/jail/inmate-r03.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #0010 + + + + + + + + + + + + + + + + day 17 + + + + + + + + + + + + + + + diff --git a/frontend-v2/src/assets/jail/inmate-r05.svg b/frontend-v2/src/assets/jail/inmate-r05.svg new file mode 100644 index 00000000..8cd29c19 --- /dev/null +++ b/frontend-v2/src/assets/jail/inmate-r05.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #0010 + + + + + + + + + + + + + + + + + + + + + + + MERGED! + + + + + + + + + + + + + + + diff --git a/frontend-v2/src/assets/jail/key-g01.svg b/frontend-v2/src/assets/jail/key-g01.svg new file mode 100644 index 00000000..4bde54e1 --- /dev/null +++ b/frontend-v2/src/assets/jail/key-g01.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend-v2/src/assets/jail/lock-g01.svg b/frontend-v2/src/assets/jail/lock-g01.svg new file mode 100644 index 00000000..73eeda59 --- /dev/null +++ b/frontend-v2/src/assets/jail/lock-g01.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx new file mode 100644 index 00000000..246ac4b8 --- /dev/null +++ b/frontend-v2/src/components/JailMode.tsx @@ -0,0 +1,499 @@ +import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from 'react'; +import { createPortal } from 'react-dom'; +import { Lock } from 'lucide-react'; +import type { DerivedPull } from '../../../shared/model/status'; +import { githubUrl } from '../../../shared/format'; +import { + jailCase, + jailDue, + jailRecord, + parseJailRecord, + JAIL_KEY, + type JailCase, +} from '../model/jail'; +import { useSettings } from '../settings'; +import { readStorage, writeStorage } from '../storage'; +import { HeaderIconButton, RepoRef } from './bits'; + +/** + * PR jail: when your own open PRs pass a limit (model/jail.ts), a cell + * drops over the board and lists them, and comes back only when that gets + * worse (jailDue). Dragging the key onto the lock (or pressing it) lets you + * out. While you're over, a header badge reopens it. `#jail=1` in the URL + * forces it, any time, for a preview. + */ + +// the art as raw SVG strings, inlined so CSS can animate their parts +const svgs = import.meta.glob('../assets/jail/*.svg', { + query: '?raw', + import: 'default', + eager: true, +}); +const svg = (name: string) => svgs[`../assets/jail/${name}.svg`]; + +/** Several SVGs share one page, so every id (and every #ref to one) gets a + * per-copy prefix. */ +function prefixIds(raw: string, prefix: string): string { + return raw + .replace(/<\?xml[^>]*>/, '') + .replace(/[^<]*<\/title>/, '') + .replace(/id="/g, `id="${prefix}`) + .replace(/(href="#|url\(#)/g, `$1${prefix}`); +} + +/** One cell's art and where things sit on it, in its 1200x800 viewBox. + * `front` names the bars drawn again in front of the robot, so it reads as + * inside the cell; swapping the cell art means a new one of these. */ +const CELL = { + svg: svg('cell-r10'), + // the door's outer bars cross the robot's sides; the middle one stays + // behind him, since in front it would hide his face + front: [725, 875], + barTop: 92, + barBottom: 708, + // the robot's face sits on the door's middle line (x 800); inmate-r05 + // draws him further left in its art, so its box sits further right + inmate: { x: 590, y: 275, w: 330, h: 440 }, + merged: { x: 635, y: 275, w: 330, h: 440 }, + // inmate-r03's tally wall, on the back wall in the bay left of the door + tally: { x: 528, y: 271, w: 216, h: 288 }, + // hung on the door's latch edge, clear of the robot's face + lock: { x: 608, y: 330, w: 120, h: 160 }, + // right above the lock, so getting out is one drag straight down + key: { x: 631, y: 96, w: 74, h: 148 }, + // the dashed arrow from the key's bit down to the lock + guide: { x: 653, y: 248, w: 30, h: 78 }, + // the drop-target ring, centered on the padlock's keyhole + ring: { x: 598, y: 372, w: 140, h: 140 }, +}; + +const ART = { + back: prefixIds(CELL.svg, 'jb-'), + front: prefixIds(CELL.svg, 'jf-'), + inmate: prefixIds(svg('inmate-r03'), 'ji-'), + tally: prefixIds(svg('inmate-r03'), 'jt-'), + merged: prefixIds(svg('inmate-r05'), 'jm-'), + key: prefixIds(svg('key-g01'), 'jk-'), + lock: prefixIds(svg('lock-g01'), 'jl-'), +}; + +const box = (b: { x: number; y: number; w: number; h: number }): CSSProperties => ({ + left: `${b.x / 12}%`, + top: `${b.y / 8}%`, + width: `${b.w / 12}%`, + height: `${b.h / 8}%`, +}); + +/** A clip-path showing only narrow strips around the front bars. The strips + * join along the top edge with zero-width seams. */ +function frontClip(): string { + const top = `${CELL.barTop / 8}%`; + const bottom = `${CELL.barBottom / 8}%`; + const pts = CELL.front.flatMap(x => { + const l = `${(x - 18) / 12}%`; + const r = `${(x + 18) / 12}%`; + return [`${l} ${top}`, `${r} ${top}`, `${r} ${bottom}`, `${l} ${bottom}`, `${l} ${top}`]; + }); + return `polygon(${pts.join(', ')})`; +} + +const hasPreviewFlag = () => new URLSearchParams(location.hash.slice(1)).get('jail') === '1'; + +/** Renders the header badge in place, and the cell over the page. */ +export function JailMode({ + pulls, + me, + extraBots, + initialized, +}: { + pulls: DerivedPull[]; + me: string; + extraBots: ReadonlySet<string>; + initialized: boolean; +}) { + const settings = useSettings(); + // read on the first render: the app rewrites the hash right after and + // drops the unknown param + const [preview, setPreview] = useState(hasPreviewFlag); + const [shown, setShown] = useState<JailCase<DerivedPull> | null>(null); + // the last showing, kept here too so blocked storage still holds it for + // this page load; another tab's showing arrives by the storage event + const record = useRef(parseJailRecord(readStorage(JAIL_KEY))); + + const found = useMemo( + () => + initialized + ? jailCase(pulls, me, extraBots, { + maxOpen: settings.jailMaxOpen, + maxDays: settings.jailMaxDays, + countDrafts: settings.jailCountDrafts, + }) + : null, + [initialized, pulls, me, extraBots, settings] + ); + + const show = (jail: JailCase<DerivedPull>) => { + record.current = jailRecord(jail, Date.now()); + writeStorage(JAIL_KEY, JSON.stringify(record.current)); + setShown(jail); + }; + + useEffect(() => { + const onHash = () => { + if (hasPreviewFlag()) setPreview(true); + }; + const onStorage = (e: StorageEvent) => { + if (e.key === JAIL_KEY) record.current = parseJailRecord(e.newValue); + }; + window.addEventListener('hashchange', onHash); + window.addEventListener('storage', onStorage); + return () => { + window.removeEventListener('hashchange', onHash); + window.removeEventListener('storage', onStorage); + }; + }, []); + + // the preview never writes the record, so it can't stand in for a real one + useEffect(() => { + if (!initialized || !preview) return; + setPreview(false); + setShown(found ?? sampleCase(pulls)); + }, [initialized, preview, found, pulls]); + + // checked on load, on every data change, and when the tab comes back; a + // hidden tab or a focused field waits for the next one, so it never lands + // mid-typing or out of sight + useEffect(() => { + if (!found || shown) return; + const check = () => { + if (document.visibilityState === 'hidden') return; + if (document.activeElement?.matches('input, textarea, select')) return; + if (!jailDue(found, record.current, Date.now())) return; + show(found); + }; + check(); + document.addEventListener('visibilitychange', check); + window.addEventListener('focus', check); + return () => { + document.removeEventListener('visibilitychange', check); + window.removeEventListener('focus', check); + }; + }, [found, shown]); + + return ( + <> + {found && ( + <HeaderIconButton + icon={Lock} + label={`PR jail: ${found.why}`} + onClick={() => show(found)} + className="relative" + > + <span className="absolute -top-1 -right-1 grid h-4 min-w-[16px] place-items-center rounded-full bg-warn px-1 text-[10px] leading-none font-semibold text-surface tabular-nums"> + {found.pulls.length > 9 ? '9+' : found.pulls.length} + </span> + </HeaderIconButton> + )} + {shown && + createPortal(<JailCell jail={shown} onDone={() => setShown(null)} />, document.body)} + </> + ); +} + +/** The preview's fallback when you're not in jail: the board's oldest + * PRs, so there's something to look at. */ +function sampleCase(pulls: DerivedPull[]): JailCase<DerivedPull> { + const sample = pulls + .filter(p => p.data.state === 'open') + .sort((a, b) => b.ageDays - a.ageDays) + .slice(0, 6); + return { + pulls: sample, + why: 'Preview: these are sample PRs from the board', + count: 0, + over: [], + }; +} + +type Phase = 'locked' | 'turning' | 'open' | 'leaving'; + +// ms from a hit: key snaps and turns, shackle opens and the robot cheers, +// then the cell lifts away +const TURN_MS = 600; +const CHEER_MS = 1400; +const LIFT_MS = 650; + +function JailCell({ jail, onDone }: { jail: JailCase<DerivedPull>; onDone: () => void }) { + const rootRef = useRef<HTMLDivElement>(null); + const keyRef = useRef<HTMLButtonElement>(null); + const lockRef = useRef<HTMLDivElement>(null); + const drag = useRef<{ id: number; x: number; y: number } | null>(null); + const [phase, setPhase] = useState<Phase>('locked'); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const [dragging, setDragging] = useState(false); + // the key is over the lock: the ring says "let go here" + const [near, setNear] = useState(false); + // the "Drag me" hint goes once the key's been touched, by pointer or keys + const [touched, setTouched] = useState(false); + // a missed drop: the lock shakes its head before the key springs back + const [nope, setNope] = useState(false); + const timers = useRef<number[]>([]); + + useEffect(() => { + const root = rootRef.current; + // the dialog takes focus, not the key, so the key only shows its focus + // ring when someone tabs to it; no scroll, as the key sits below the + // list on a phone + root?.focus({ preventScroll: true }); + // Esc doesn't let you out (the key does); Tab stays inside + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') e.preventDefault(); + if (e.key !== 'Tab' || !root) return; + const focusables = root.querySelectorAll<HTMLElement>('a[href], button'); + if (!focusables.length) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } else if (!root.contains(document.activeElement)) { + e.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', onKey); + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const pending = timers.current; + return () => { + document.removeEventListener('keydown', onKey); + document.body.style.overflow = prevOverflow; + pending.forEach(clearTimeout); + }; + }, []); + + const unlock = () => { + if (phase !== 'locked') return; + // snap the key's bit onto the keyhole + const d = bitToHole(); + if (d) setOffset(o => ({ x: o.x + d.x, y: o.y + d.y })); + setPhase('turning'); + const at = (ms: number, fn: () => void) => timers.current.push(window.setTimeout(fn, ms)); + at(TURN_MS, () => setPhase('open')); + at(TURN_MS + CHEER_MS, () => setPhase('leaving')); + at(TURN_MS + CHEER_MS + LIFT_MS, onDone); + }; + + /** How far the key's bit is from the keyhole, in px. */ + const bitToHole = () => { + const key = keyRef.current?.getBoundingClientRect(); + const lock = lockRef.current?.getBoundingClientRect(); + if (!key || !lock?.width) return null; + return { + x: lock.left + lock.width / 2 - (key.left + key.width / 2), + y: lock.top + lock.height * 0.72 - (key.top + key.height * 0.85), + }; + }; + + const keyOverLock = () => { + const key = keyRef.current?.getBoundingClientRect(); + const lock = lockRef.current?.getBoundingClientRect(); + const slop = 24; + return Boolean( + key && + lock && + key.right > lock.left - slop && + key.left < lock.right + slop && + key.bottom > lock.top - slop && + key.top < lock.bottom + slop + ); + }; + + const onPointerDown = (e: PointerEvent<HTMLButtonElement>) => { + if (phase !== 'locked') return; + drag.current = { id: e.pointerId, x: e.clientX - offset.x, y: e.clientY - offset.y }; + setDragging(true); + setTouched(true); + // keeps the moves coming when the pointer outruns the key + e.currentTarget.setPointerCapture?.(e.pointerId); + }; + const onPointerMove = (e: PointerEvent<HTMLButtonElement>) => { + const d = drag.current; + if (d?.id !== e.pointerId) return; + const next = { x: e.clientX - d.x, y: e.clientY - d.y }; + const isNear = keyOverLock(); + setNear(isNear); + // in range, the keyhole pulls the key's bit a third of the way in + const pull = isNear ? bitToHole() : null; + setOffset( + pull + ? { + x: next.x + (pull.x - (next.x - offset.x)) / 3, + y: next.y + (pull.y - (next.y - offset.y)) / 3, + } + : next + ); + }; + const onPointerUp = (e: PointerEvent<HTMLButtonElement>) => { + if (drag.current?.id !== e.pointerId) return; + drag.current = null; + setDragging(false); + setNear(false); + if (keyOverLock()) unlock(); + else { + // a miss springs back home + setOffset({ x: 0, y: 0 }); + setNope(true); + timers.current.push(window.setTimeout(() => setNope(false), 400)); + } + }; + + const free = phase === 'open' || phase === 'leaving'; + return ( + <div + ref={rootRef} + tabIndex={-1} + role="dialog" + aria-modal="true" + aria-labelledby="jail-title" + aria-describedby="jail-why" + className="jail fixed inset-0 z-[200] overflow-hidden outline-none" + > + {/* the cell's back wall, so the board doesn't show between the bars */} + <div className="jail-scrim absolute inset-0" aria-hidden /> + <div + className={`jail-stage absolute inset-0 flex flex-col overflow-y-auto jail-wide:block jail-wide:overflow-visible ${ + phase === 'leaving' ? 'is-leaving' : '' + }`} + > + {/* the cell: the whole screen when it's wide (jail-wide in styles.css); on a phone or tablet, the part with + the robot and the door, below the list */} + <div className="jail-frame order-2"> + <div className="jail-scene" aria-hidden> + <div + className="jail-art jail-tally absolute" + style={box(CELL.tally)} + dangerouslySetInnerHTML={{ __html: ART.tally }} + /> + <div + className="jail-art absolute inset-0" + dangerouslySetInnerHTML={{ __html: ART.back }} + /> + <div + key={free ? 'merged' : 'jailed'} + className={`jail-art jail-inmate absolute ${free ? 'jail-cheer' : ''}`} + style={box(free ? CELL.merged : CELL.inmate)} + dangerouslySetInnerHTML={{ __html: free ? ART.merged : ART.inmate }} + /> + <div + className="jail-art absolute inset-0" + style={{ clipPath: frontClip() }} + dangerouslySetInnerHTML={{ __html: ART.front }} + /> + <div + className={`jail-ring absolute rounded-full ${dragging ? 'is-dragging' : ''} ${ + near ? 'is-near' : '' + } ${phase !== 'locked' ? 'is-done' : ''}`} + style={box(CELL.ring)} + /> + {phase === 'locked' && !dragging && ( + <svg + className="jail-guide absolute" + style={box(CELL.guide)} + viewBox="0 0 30 78" + fill="none" + > + {/* a casing under the arrow, so it reads over the door's bar */} + <path d="M15 2V66M6 60L15 72L24 60" className="jail-guide-case" /> + <path d="M15 2V66" className="jail-guide-line" /> + <path d="M6 60L15 72L24 60" /> + </svg> + )} + <div + ref={lockRef} + className={`jail-art jail-lock absolute ${free ? 'is-open' : ''} ${ + near ? 'is-near' : '' + } ${nope ? 'is-nope' : ''} ${phase === 'locked' ? 'is-live' : ''}`} + style={box(CELL.lock)} + dangerouslySetInnerHTML={{ __html: ART.lock }} + /> + </div> + {/* the key rides the same geometry as the art, outside its aria-hidden */} + <div className="jail-scene pointer-events-none"> + <button + ref={keyRef} + type="button" + aria-label="Use the key to unlock PR jail" + className={`jail-key ${ + phase === 'locked' ? 'is-live' : '' + } pointer-events-auto absolute z-10 cursor-grab touch-none rounded-lg ${ + dragging ? 'is-dragging cursor-grabbing' : '' + } ${phase !== 'locked' ? 'is-turning' : ''} ${free ? 'is-in' : ''}`} + style={{ ...box(CELL.key), translate: `${offset.x}px ${offset.y}px` }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} + // keyboard and screen readers click with detail 0; a + // mouse has to drag the key + onClick={e => e.detail === 0 && unlock()} + onKeyDown={() => setTouched(true)} + > + {!touched && phase === 'locked' && ( + <span + aria-hidden + className="jail-hint pointer-events-none absolute top-[18%] left-full ml-1.5 rounded-full border border-line bg-surface px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap text-ink-2 shadow-sm" + > + Drag me + </span> + )} + <span + className="jail-art jail-key-art block h-full w-full" + dangerouslySetInnerHTML={{ __html: ART.key }} + /> + </button> + </div> + </div> + <section className="relative order-1 m-4 flex shrink-0 flex-col gap-3 rounded-xl border border-line bg-surface p-4 text-ink shadow-xl jail-wide:absolute jail-wide:top-24 jail-wide:left-[4vw] jail-wide:m-0 jail-wide:w-[26rem]"> + <div> + <h2 id="jail-title" className="m-0 text-lg font-semibold"> + {free ? 'Out of PR jail' : 'PR jail'} + </h2> + <p id="jail-why" className="m-0 text-[13px] text-ink-2"> + {jail.why} + </p> + </div> + <ul className="m-0 flex list-none flex-col gap-2 p-0 jail-wide:max-h-[45vh] jail-wide:overflow-y-auto"> + {jail.pulls.map(p => ( + <li + key={`${p.data.repo}#${p.data.number}`} + className="flex flex-col text-[13px]" + > + <a + href={githubUrl(p.data.repo, p.data.number)} + target="_blank" + rel="noopener noreferrer" + className="font-medium text-ink hover:underline" + > + {p.data.title} <span aria-hidden>↗</span> + </a> + <span className="text-xs"> + <RepoRef repo={p.data.repo} number={p.data.number} />{' '} + <span className="text-ink-3 tabular-nums">· {p.ageDays}d open</span> + </span> + </li> + ))} + </ul> + <p className="m-0 border-t border-line pt-3 text-xs text-ink-3" aria-live="polite"> + {free + ? 'Merged! You’re free.' + : 'Drag the key down into the lock to get out (or press Enter on it).'} + </p> + </section> + </div> + </div> + ); +} diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 72b31dd0..9286b9af 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -328,6 +328,59 @@ export function Settings({ onChange={ageDisplay => set({ ageDisplay })} /> </Field> + <Field + label="PR jail: open PRs" + hint="PR jail drops over the board when you have more than this many open PRs of your own (bots and deploy holds never count). It comes back only when that gets worse, at most every 4 hours." + > + <NumberField + value={s.jailMaxOpen} + min={1} + max={50} + suffix="PRs" + onChange={jailMaxOpen => set({ jailMaxOpen })} + /> + </Field> + <Field + label="PR jail: days open" + hint="It also drops when any of them has been open longer than this." + > + <NumberField + value={s.jailMaxDays} + min={1} + max={365} + suffix="days" + onChange={jailMaxDays => set({ jailMaxDays })} + /> + </Field> + <Field + label="PR jail: count drafts" + hint="Off leaves your draft PRs out of both limits." + > + <Segmented + ariaLabel="count drafts toward PR jail" + value={s.jailCountDrafts ? 'on' : 'off'} + options={[ + ['off', 'Off'], + ['on', 'On'], + ]} + onChange={v => set({ jailCountDrafts: v === 'on' })} + /> + </Field> + <Field + label="See PR jail" + hint="Drops it now with your open PRs, whatever the limits say." + > + <QuietButton + onClick={() => { + // JailMode listens for jail=1 in the hash + const hash = new URLSearchParams(location.hash.slice(1)); + hash.set('jail', '1'); + location.hash = hash.toString(); + }} + > + Preview PR jail + </QuietButton> + </Field> <Field label="Getting QA is a to-do" hint="For teams that self-review, no separate CR gate means lining up QA is the real stall, so “Find a QA-er” on your own PRs shows in Waiting on you. Off keeps it in My work only." diff --git a/frontend-v2/src/model/jail.test.ts b/frontend-v2/src/model/jail.test.ts new file mode 100644 index 00000000..b3f7eb92 --- /dev/null +++ b/frontend-v2/src/model/jail.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { + JAIL_COOLDOWN_MS, + jailCase, + jailDue, + jailRecord, + parseJailRecord, + type JailPull, +} from './jail'; + +const LIMITS = { maxOpen: 7, maxDays: 14 }; +const NO_BOTS: ReadonlySet<string> = new Set(); + +let n = 0; +function pull( + login: string, + ageDays: number, + o: { draft?: boolean; deployBlockedBy?: string[] } = {} +): JailPull { + n += 1; + return { + data: { + repo: 'iFixit/ifixit', + number: n, + title: `PR ${n}`, + draft: o.draft ?? false, + state: 'open', + user: { login }, + }, + ageDays, + deployBlockedBy: o.deployBlockedBy ?? [], + }; +} + +const many = (count: number, login = 'me', ageDays = 1) => + Array.from({ length: count }, () => pull(login, ageDays)); + +describe('jailCase', () => { + it('stays free at the open-PR limit', () => { + expect(jailCase(many(7), 'me', NO_BOTS, LIMITS)).toBeNull(); + }); + + it('jails past the open-PR limit, listing them oldest first', () => { + const pulls = [...many(7), pull('me', 9)]; + const c = jailCase(pulls, 'me', NO_BOTS, LIMITS); + expect(c?.pulls).toHaveLength(8); + expect(c?.pulls[0].ageDays).toBe(9); + expect(c?.why).toBe('8 open PRs. Parole at 7. Close 1 to get out.'); + expect(c?.count).toBe(8); + }); + + it('jails one PR past the age limit, and lists only the old ones', () => { + const old = pull('me', 21); + const c = jailCase([pull('me', 2), old, pull('me', 14)], 'me', NO_BOTS, LIMITS); + expect(c?.pulls).toEqual([old]); + expect(c?.why).toBe(`#${old.data.number} has been open 21 days. Parole at 14 days.`); + expect(c?.count).toBe(3); + expect(c?.over).toEqual([`iFixit/ifixit#${old.data.number}`]); + }); + + it('ignores other people’s PRs', () => { + expect( + jailCase([...many(9, 'someone'), pull('someone', 90)], 'me', NO_BOTS, LIMITS) + ).toBeNull(); + }); + + it('ignores drafts unless they count', () => { + const drafts = Array.from({ length: 9 }, () => pull('me', 90, { draft: true })); + expect(jailCase(drafts, 'me', NO_BOTS, LIMITS)).toBeNull(); + const counted = jailCase(drafts, 'me', NO_BOTS, { ...LIMITS, countDrafts: true }); + expect(counted?.pulls).toHaveLength(9); + }); + + it('ignores bots, by suffix and by the config list', () => { + expect(jailCase(many(9, 'dependabot[bot]'), 'dependabot[bot]', NO_BOTS, LIMITS)).toBeNull(); + expect(jailCase(many(9, 'renovate'), 'renovate', new Set(['renovate']), LIMITS)).toBeNull(); + }); + + it('ignores PRs held for a deploy', () => { + const held = Array.from({ length: 9 }, () => pull('me', 90, { deployBlockedBy: ['qa'] })); + expect(jailCase(held, 'me', NO_BOTS, LIMITS)).toBeNull(); + }); +}); + +describe('jailDue', () => { + const HOUR = 60 * 60 * 1000; + const T = 1_000_000_000_000; + const over = (pulls: JailPull[]) => jailCase(pulls, 'me', NO_BOTS, LIMITS); + const recordAt = (pulls: JailPull[], at: number) => { + const c = over(pulls); + if (!c) throw new Error('expected to be over a limit'); + return jailRecord(c, at); + }; + const eight = many(8); + const shownAt = recordAt(eight, T); + + it('never shows while you are under the limits', () => { + expect(jailDue(over(many(7)), null, T)).toBe(false); + }); + + it('shows the first time', () => { + expect(jailDue(over(eight), null, T)).toBe(true); + }); + + it('does not re-show on a refresh', () => { + expect(jailDue(over(eight), shownAt, T + 1000)).toBe(false); + }); + + it('does not re-show within 4 hours, even when it got worse', () => { + expect(jailDue(over([...eight, pull('me', 1)]), shownAt, T + 3 * HOUR)).toBe(false); + }); + + it('re-shows after 4 hours when you have more open PRs', () => { + expect(jailDue(over([...eight, pull('me', 1)]), shownAt, T + JAIL_COOLDOWN_MS)).toBe(true); + }); + + it('does not re-show after 4 hours when it is the same or better', () => { + expect(jailDue(over(eight), shownAt, T + 24 * HOUR)).toBe(false); + // closing two then opening one is still a net gain of room + const fewer = recordAt([...eight, ...many(2)], T); + expect(jailDue(over([...eight, pull('me', 1)]), fewer, T + 24 * HOUR)).toBe(false); + }); + + it('re-shows after 4 hours when a PR newly crosses the age limit', () => { + const old = pull('me', 20); + const pulls = [pull('me', 1), old]; + const last = recordAt(pulls, T); + expect(jailDue(over(pulls), last, T + 24 * HOUR)).toBe(false); + // same count: the young one aged past 14 days + const aged = { ...pulls[0], ageDays: 15 }; + expect(jailDue(over([aged, old]), last, T + 24 * HOUR)).toBe(true); + }); + + it('ignores a missing or unreadable record (and the old daily key)', () => { + expect(parseJailRecord(null)).toBeNull(); + expect(parseJailRecord('2026-10-09')).toBeNull(); + expect(parseJailRecord('{"at":1}')).toBeNull(); + expect(parseJailRecord(JSON.stringify(shownAt))).toEqual(shownAt); + }); +}); diff --git a/frontend-v2/src/model/jail.ts b/frontend-v2/src/model/jail.ts new file mode 100644 index 00000000..a5eed39b --- /dev/null +++ b/frontend-v2/src/model/jail.ts @@ -0,0 +1,122 @@ +import { isBotLogin } from '../../../shared/model/visibility'; +import type { PullData } from '../../../shared/types'; + +/** Jail when you have more than this many open PRs of your own. */ +export const JAIL_MAX_OPEN = 7; +/** Jail when any of your open PRs is older than this many days. */ +export const JAIL_MAX_DAYS = 14; + +/** localStorage key holding the JailRecord of the last time jail showed. */ +export const JAIL_KEY = 'pd2.jail'; +/** After jail shows, it stays away at least this long. */ +export const JAIL_COOLDOWN_MS = 4 * 60 * 60 * 1000; + +/** What jail showed you last time, so it only comes back when that got worse. */ +export interface JailRecord { + /** ms when it showed */ + at: number; + /** how many counted open PRs you had then */ + count: number; + /** repo#number of each PR past the age limit then */ + over: string[]; +} + +/** The fields the rule reads off a derived pull. */ +export interface JailPull { + data: Pick<PullData, 'repo' | 'number' | 'title' | 'draft' | 'state' | 'user'>; + ageDays: number; + /** who holds a deploy block on it; a held PR is done, so it doesn't count */ + deployBlockedBy: string[]; +} + +export interface JailCase<P extends JailPull> { + /** the PRs that put you there, oldest first */ + pulls: P[]; + /** one line saying why, and how to get out */ + why: string; + /** how many open PRs counted */ + count: number; + /** repo#number of each counted PR past the age limit */ + over: string[]; +} + +const idOf = (p: JailPull) => `${p.data.repo}#${p.data.number}`; + +const oldestFirst = <P extends JailPull>(a: P, b: P) => b.ageDays - a.ageDays; + +/** + * Your own open, human PRs that aren't held for a deploy, checked against the + * two limits; drafts count only when `countDrafts` is on. Null when you're + * under both. + */ +export function jailCase<P extends JailPull>( + pulls: P[], + me: string, + extraBots: ReadonlySet<string>, + limits: { maxOpen: number; maxDays: number; countDrafts?: boolean } +): JailCase<P> | null { + const mine = pulls + .filter( + p => + p.data.user.login === me && + p.data.state === 'open' && + (limits.countDrafts || !p.data.draft) && + !p.deployBlockedBy.length && + !isBotLogin(p.data.user.login, extraBots) + ) + .sort(oldestFirst); + const old = mine.filter(p => p.ageDays > limits.maxDays); + const counts = { count: mine.length, over: old.map(idOf) }; + if (mine.length > limits.maxOpen) { + const n = mine.length; + return { + pulls: mine, + why: `${n} open PRs. Parole at ${limits.maxOpen}. Close ${n - limits.maxOpen} to get out.`, + ...counts, + }; + } + if (old.length) { + return { + pulls: old, + why: `#${old[0].data.number} has been open ${old[0].ageDays} days. Parole at ${limits.maxDays} days.`, + ...counts, + }; + } + return null; +} + +/** The record to store when `jail` shows at `now`. */ +export const jailRecord = (jail: JailCase<JailPull>, now: number): JailRecord => ({ + at: now, + count: jail.count, + over: jail.over, +}); + +/** A stored record, or null when it's missing or unreadable. */ +export function parseJailRecord(raw: string | null): JailRecord | null { + try { + const r = JSON.parse(raw ?? 'null') as Partial<JailRecord> | null; + if (typeof r?.at !== 'number' || typeof r.count !== 'number' || !Array.isArray(r.over)) { + return null; + } + return { at: r.at, count: r.count, over: r.over }; + } catch { + return null; + } +} + +/** + * Whether jail should drop now: you're over a limit, and either it never + * showed, or it's been at least JAIL_COOLDOWN_MS and things got worse since + * (more open PRs, net, or a PR newly past the age limit). + */ +export function jailDue( + jail: JailCase<JailPull> | null, + last: JailRecord | null, + now: number +): boolean { + if (!jail) return false; + if (!last) return true; + if (now - last.at < JAIL_COOLDOWN_MS) return false; + return jail.count > last.count || jail.over.some(id => !last.over.includes(id)); +} diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index 6e0366a0..2a7f14e2 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -1,4 +1,5 @@ import { STARVE_DAYS } from '../../shared/model/status'; +import { JAIL_MAX_DAYS, JAIL_MAX_OPEN } from './model/jail'; import { createPersistentStore } from './storage'; /** one named review circle; DEFAULT_TEAM_NAME is what quick add-to-team @@ -114,6 +115,12 @@ export interface Settings { * or age tooltip) before it opens, so brushing the pointer across the board * doesn't flash panels open. 0 = open instantly; a click always bypasses it. */ hoverDelayMs: number; + /** PR jail drops when you have more than this many open PRs of your own */ + jailMaxOpen: number; + /** ...or when any of them has been open longer than this many days */ + jailMaxDays: number; + /** whether your draft PRs count toward PR jail */ + jailCountDrafts: boolean; } /** The heaviest age text tier (red), derived from ageWarnDays rather than @@ -167,6 +174,9 @@ export const DEFAULT_SETTINGS: Settings = { repoQueueCap: 15, openPrsNewTab: true, hoverDelayMs: 250, + jailMaxOpen: JAIL_MAX_OPEN, + jailMaxDays: JAIL_MAX_DAYS, + jailCountDrafts: false, }; const store = createPersistentStore('pd2.settings', DEFAULT_SETTINGS); diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index e6bb49a5..a019fa83 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -1,4 +1,8 @@ @import "tailwindcss"; +/* PR jail's side-by-side layout (list left, cell right) needs a wide, + landscape screen: narrower or portrait, the cell's cover-scaling pushes the + robot under the list or off the right edge, so it stacks like a phone */ +@custom-variant jail-wide (@media (min-width: 900px) and (min-aspect-ratio: 11/10)); /* --sweep must be a registered <angle> for the CI ring's share to GLIDE between values (an unregistered custom property snaps). Top level on @@ -1006,3 +1010,446 @@ input[type="checkbox"] { font-size: 13px; font-weight: 600; } + +/* ── PR jail (components/JailMode.tsx): the cell drops from above, lands + with a heavy bounce and a small screen shake, and lifts away once the + key turns. Transform only. ── */ +/* The art's colors (assets/jail/*.svg use these through style="") and the + back wall. Light: steel bars on the board's own surface, a yellow door, + no glow. Dark: the neon sign, toned down to sit with the dark tokens. */ +.jail { + --jail-wall-lit: var(--surface); + --jail-wall-dim: var(--secondary); + --jail-mortar: rgb(15 23 42 / 0.045); + --jail-edge: #64748b; + --jail-rail: #94a3b8; + --jail-rail-hi: #cbd5e1; + --jail-board: #e2e8f0; + --jail-hw: #cbd5e1; + --jail-bar: #cbd5e1; + --jail-bar-core: #f8fafc; + --jail-cross: var(--brand); + --jail-cross-core: var(--brand-100); + --jail-door: #eab308; + --jail-door-core: #fef9c3; + --jail-sign: #854d0e; + --jail-hole: var(--ink); + --jail-glow: 0; + /* the key's halo, the guide arrow and the lock's ring: what you can touch */ + --jail-accent: var(--brand); + --jail-halo: 0.3; +} +.dark .jail { + --jail-wall-lit: var(--muted); + --jail-wall-dim: var(--canvas); + --jail-mortar: rgb(255 255 255 / 0.025); + --jail-edge: var(--canvas); + --jail-rail: var(--surface); + --jail-rail-hi: var(--muted); + --jail-board: var(--surface); + --jail-hw: var(--secondary); + --jail-bar: #4cc3dc; + --jail-bar-core: #c9f3fb; + --jail-cross: #df7fb0; + --jail-cross-core: #f8dcea; + --jail-door: #e6cb45; + --jail-door-core: #fbf6d6; + --jail-sign: #fbf6d6; + --jail-hole: #020617; + --jail-glow: 0.6; + --jail-accent: var(--jail-cross); + --jail-halo: 0.35; +} +.jail-scrim { + background: + repeating-linear-gradient(to bottom, transparent 0 46px, var(--jail-mortar) 46px 48px), + radial-gradient(ellipse at 60% 45%, var(--jail-wall-lit) 0%, var(--jail-wall-dim) 75%); +} +.jail-glow { + opacity: var(--jail-glow); +} +/* inmate-r03 is drawn twice: its tally wall behind the bars, the robot + behind the door */ +.jail-tally [id$='body'], +.jail-tally [id$='head'], +.jail-tally ellipse, +.jail-inmate [id$='tally'] { + display: none; +} +.jail-art svg { + display: block; + width: 100%; + height: 100%; +} +/* the cell art covers the screen like background-size: cover */ +.jail-scene { + position: absolute; + left: 50%; + top: 50%; + width: max(100vw, 150vh); + aspect-ratio: 3 / 2; + transform: translate(-50%, -50%); +} +/* on a wide screen (jail-wide) the cell fills it, moved right so the robot + stands in the clear bay beside the PR list */ +.jail-frame { + position: absolute; + inset: 0; +} +@media (min-width: 900px) and (min-aspect-ratio: 11/10) { + .jail-scene { + left: calc(50% + 12vw); + } +} +/* on a phone or tablet the list comes first and the cell scrolls in below it, + cropped to the tally wall, the robot and the door (x 470 to 980 of the + 1200-wide art) */ +@media not all and (min-width: 900px) and (min-aspect-ratio: 11/10) { + .jail-frame { + position: relative; + flex-shrink: 0; + width: 100%; + aspect-ratio: 510 / 800; + overflow: hidden; + } + .jail-scene { + left: calc(-470 / 510 * 100%); + top: 0; + width: calc(1200 / 510 * 100%); + transform: none; + } +} +/* the padlock is the lock; the cell's own plate would peek out under it */ +.jail-scene [id$='lockplate'] { + display: none; +} +@keyframes jail-drop { + 0% { + transform: translateY(-110%); + animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45); + } + 55% { + transform: translateY(0); + animation-timing-function: ease-out; + } + 70% { + transform: translateY(-5%); + animation-timing-function: ease-in; + } + 83% { + transform: translateY(0); + animation-timing-function: ease-out; + } + 91% { + transform: translateY(-1.2%); + animation-timing-function: ease-in; + } + 100% { + transform: translateY(0); + } +} +@keyframes jail-shake { + 20% { + translate: -7px 4px; + } + 40% { + translate: 6px -3px; + } + 60% { + translate: -4px 2px; + } + 80% { + translate: 2px -1px; + } +} +@keyframes jail-lift { + to { + transform: translateY(-110%); + } +} +@keyframes jail-fade-in { + from { + opacity: 0; + } +} +@keyframes jail-fade-out { + to { + opacity: 0; + } +} +.jail { + /* lands at 55% of the drop */ + animation: jail-shake 320ms ease-out 500ms; +} +.jail-scrim { + animation: jail-fade-in 300ms ease-out; +} +.jail-stage { + animation: jail-drop 900ms both; +} +.jail-stage.is-leaving { + animation: jail-lift 650ms cubic-bezier(0.5, 0, 0.75, 0) both; +} +.jail:has(.is-leaving) .jail-scrim { + animation: jail-fade-out 650ms ease-in both; +} +/* the key springs home after a miss, and snaps then turns on a hit */ +.jail-key { + transform-origin: 50% 85%; + transition: translate 450ms cubic-bezier(0.34, 1.56, 0.64, 1); +} +.jail-key.is-dragging { + transition: none; + scale: 1.08; +} +.jail-key.is-turning { + rotate: 90deg; + transition: + translate 220ms ease-out, + rotate 300ms ease-in-out 240ms; +} +/* what says "drag the key to the lock": a halo behind the key (it travels + with it), a slow sway like it hangs on a hook, a dashed arrow down, and a + ring on the lock that brightens while you drag and goes solid when near */ +.jail-key::before { + content: ''; + position: absolute; + inset: -12% -55%; + z-index: -1; + border-radius: 50%; + background: radial-gradient( + closest-side, + color-mix(in srgb, var(--jail-accent) calc(var(--jail-halo) * 100%), transparent), + transparent + ); + pointer-events: none; +} +.jail-key.is-turning::before { + opacity: 0; + transition: opacity 200ms; +} +@keyframes jail-sway { + 0%, + 100% { + rotate: -3deg; + } + 50% { + rotate: 3deg; + } +} +.jail-key-art { + transform-origin: 50% 4%; + animation: jail-sway 3s ease-in-out infinite; + transition: + translate 150ms ease-out, + filter 150ms ease-out; +} +.jail-key.is-dragging .jail-key-art, +.jail-key.is-turning .jail-key-art { + animation: none; +} +.jail-key:is(:hover, :focus-visible):not(.is-dragging, .is-turning) .jail-key-art { + translate: 0 -2px; + filter: drop-shadow(0 4px 6px rgb(15 23 42 / 0.3)); +} +.jail-key:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 3px; +} +.jail-guide { + overflow: visible; + stroke: var(--jail-accent); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.8; +} +.jail-guide-case { + stroke: var(--jail-wall-lit); + stroke-width: 7; +} +.jail-guide-line { + stroke-dasharray: 6 7; + animation: jail-march 1.2s linear infinite; +} +@keyframes jail-march { + to { + stroke-dashoffset: -13; + } +} +.jail-ring { + border: 2px dashed color-mix(in srgb, var(--jail-accent) 45%, transparent); + background: radial-gradient( + closest-side, + color-mix(in srgb, var(--jail-accent) calc(var(--jail-halo) * 100%), transparent), + transparent + ); + animation: jail-pulse 2.4s ease-in-out infinite; + transition: + scale 150ms ease-out, + border-color 150ms, + opacity 200ms; +} +@keyframes jail-pulse { + 50% { + scale: 1.06; + opacity: 0.6; + } +} +.jail-ring.is-dragging { + border-color: color-mix(in srgb, var(--jail-accent) 80%, transparent); +} +.jail-ring.is-near { + animation: none; + scale: 1.15; + border-style: solid; + border-color: var(--jail-accent); +} +.jail-ring.is-done { + animation: none; + opacity: 0; +} +/* the pipeline runs: the commit dots down the key's shaft light in turn, + toward the bit and the lock, and the commits around the keyhole chase + round while the keyhole beats. Near the lock it rattles; a miss gets a + head shake. */ +@keyframes jail-run { + 0%, + 60%, + 100% { + opacity: 0.35; + scale: 1; + } + 20% { + opacity: 1; + scale: 1.6; + } +} +.jail-key.is-live .jail-key-art circle { + transform-box: fill-box; + transform-origin: center; + animation: jail-run 1.5s ease-in-out infinite; +} +.jail-key.is-live .jail-key-art circle:nth-of-type(2) { + animation-delay: 0.25s; +} +.jail-key.is-live .jail-key-art circle:nth-of-type(3) { + animation-delay: 0.5s; +} +@keyframes jail-chase { + 0%, + 30%, + 100% { + fill: var(--jail-rail); + } + 8% { + fill: var(--jail-cross); + } +} +.jail-lock.is-live #jl-body > g:first-of-type circle { + animation: jail-chase 1.8s linear infinite; +} +.jail-lock.is-live #jl-body > g:first-of-type circle:nth-of-type(2) { + animation-delay: 0.3s; +} +.jail-lock.is-live #jl-body > g:first-of-type circle:nth-of-type(3) { + animation-delay: 0.6s; +} +.jail-lock.is-live #jl-body > g:first-of-type circle:nth-of-type(4) { + animation-delay: 0.9s; +} +.jail-lock.is-live #jl-body > g:first-of-type circle:nth-of-type(5) { + animation-delay: 1.2s; +} +.jail-lock.is-live #jl-body > g:first-of-type circle:nth-of-type(6) { + animation-delay: 1.5s; +} +@keyframes jail-beat { + 0%, + 40%, + 100% { + filter: drop-shadow(0 0 0 transparent); + } + 12%, + 28% { + filter: drop-shadow(0 0 6px var(--jail-accent)); + } +} +.jail-lock.is-live #jl-keyhole { + animation: jail-beat 1.6s ease-out infinite; +} +@keyframes jail-rattle { + 25% { + rotate: -4deg; + } + 75% { + rotate: 4deg; + } +} +.jail-lock { + transform-origin: 50% 20%; +} +.jail-lock.is-near { + animation: jail-rattle 180ms linear infinite; +} +@keyframes jail-nope { + 20%, + 60% { + translate: -6px 0; + } + 40%, + 80% { + translate: 6px 0; + } +} +.jail-lock.is-nope { + animation: jail-nope 400ms ease-in-out; +} +.jail-key.is-live:is(:hover, :focus-visible):not(.is-dragging) .jail-key-art { + filter: drop-shadow(0 4px 6px rgb(15 23 42 / 0.3)) brightness(1.08); +} +/* once turned, the key sinks into the keyhole so it clears the cheering robot */ +.jail-key.is-in { + opacity: 0; + scale: 0.4; + transition: + opacity 250ms ease-in, + scale 250ms ease-in; +} +.jail-lock #jl-shackle { + transform-box: fill-box; + transform-origin: 0% 100%; + transition: transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1); +} +.jail-lock.is-open #jl-shackle { + transform: translateY(-14%) rotate(-28deg); +} +@keyframes jail-cheer { + from { + transform: translateY(6%) scale(0.92); + } +} +.jail-cheer { + animation: jail-cheer 420ms cubic-bezier(0.34, 1.56, 0.64, 1); +} +/* reduced motion: no drop or lift, just a fade. !important and a class + selector beat the global reduced-motion rule above. */ +@media (prefers-reduced-motion: reduce) { + .jail, + .jail-cheer, + .jail-key-art, + .jail-guide-line, + .jail-ring, + .jail-key-art circle, + .jail-lock, + .jail-lock circle, + .jail-lock #jl-keyhole { + animation: none !important; + } + .jail-stage { + animation: jail-fade-in 250ms ease-out both !important; + } + .jail-stage.is-leaving { + animation: jail-fade-out 400ms ease-in both !important; + } +} From 8a608d00cf59c094716cc98c7103e2350b96cde4 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 13:59:17 -0700 Subject: [PATCH 02/16] Keep PR jail's list live while the cell is down The open jail showed a copy of your PRs from the moment it dropped, so a merge or a new PR arriving over the socket never reached it: it stayed on "8 open PRs" after a ninth came in. The cell now reads the live list; merging under the limits says "You're under the limits now. Use the key to walk out." Leaving saves what you last saw, so PRs opened while it was up don't drop it again right away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.test.tsx | 63 ++++++++++++++++++++ frontend-v2/src/components/JailMode.tsx | 36 +++++++++-- 2 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 frontend-v2/src/components/JailMode.test.tsx diff --git a/frontend-v2/src/components/JailMode.test.tsx b/frontend-v2/src/components/JailMode.test.tsx new file mode 100644 index 00000000..11ac6f89 --- /dev/null +++ b/frontend-v2/src/components/JailMode.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { DerivedPull } from '../../../shared/model/status'; +import { JailMode } from './JailMode'; + +// React's act() warns unless the environment says it's a test +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let n = 0; +const pull = (ageDays = 1) => { + n += 1; + return { + data: { + repo: 'iFixit/ifixit', + number: n, + title: `PR ${n}`, + draft: false, + state: 'open', + user: { login: 'me' }, + }, + ageDays, + deployBlockedBy: [], + } as unknown as DerivedPull; +}; +const many = (count: number) => Array.from({ length: count }, () => pull()); + +let root: Root | null = null; +const host = document.createElement('div'); +document.body.append(host); +afterEach(() => { + act(() => root?.unmount()); + root = null; + localStorage.clear(); +}); + +function render(pulls: DerivedPull[]) { + const r = (root ??= createRoot(host)); + act(() => r.render(<JailMode pulls={pulls} me="me" extraBots={new Set()} initialized />)); +} + +const why = () => document.getElementById('jail-why')?.textContent; +const listed = () => document.querySelectorAll('[role="dialog"] li').length; + +describe('JailMode while the cell is down', () => { + it('follows new PRs and merges live', () => { + const first = many(8); + render(first); + expect(why()).toMatch(/^8 open PRs/); + expect(listed()).toBe(8); + + // a new PR arrives over the socket + render([...first, pull()]); + expect(why()).toMatch(/^9 open PRs/); + expect(listed()).toBe(9); + + // merges bring you under the limits: the cell says so + render(first.slice(0, 3)); + expect(why()).toMatch(/under the limits/); + expect(listed()).toBe(0); + }); +}); diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index 246ac4b8..d60d97c0 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -115,7 +115,9 @@ export function JailMode({ // read on the first render: the app rewrites the hash right after and // drops the unknown param const [preview, setPreview] = useState(hasPreviewFlag); - const [shown, setShown] = useState<JailCase<DerivedPull> | null>(null); + // which jail is open, not a copy of it: the list below is read live, so a + // merge or a new PR shows up while it's open + const [shown, setShown] = useState<'real' | 'preview' | null>(null); // the last showing, kept here too so blocked storage still holds it for // this page load; another tab's showing arrives by the storage event const record = useRef(parseJailRecord(readStorage(JAIL_KEY))); @@ -132,10 +134,13 @@ export function JailMode({ [initialized, pulls, me, extraBots, settings] ); - const show = (jail: JailCase<DerivedPull>) => { + const remember = (jail: JailCase<DerivedPull>) => { record.current = jailRecord(jail, Date.now()); writeStorage(JAIL_KEY, JSON.stringify(record.current)); - setShown(jail); + }; + const show = (jail: JailCase<DerivedPull>) => { + remember(jail); + setShown('real'); }; useEffect(() => { @@ -157,8 +162,8 @@ export function JailMode({ useEffect(() => { if (!initialized || !preview) return; setPreview(false); - setShown(found ?? sampleCase(pulls)); - }, [initialized, preview, found, pulls]); + setShown('preview'); + }, [initialized, preview]); // checked on load, on every data change, and when the tab comes back; a // hidden tab or a focused field waits for the next one, so it never lands @@ -195,11 +200,30 @@ export function JailMode({ </HeaderIconButton> )} {shown && - createPortal(<JailCell jail={shown} onDone={() => setShown(null)} />, document.body)} + createPortal( + <JailCell + jail={found ?? (shown === 'preview' ? sampleCase(pulls) : FREE)} + onDone={() => { + // what you saw last is what the next showing compares + // against, so PRs opened while it was up don't re-drop it + if (shown === 'real' && found) remember(found); + setShown(null); + }} + />, + document.body + )} </> ); } +/** Merged or closed your way under the limits while the cell was down. */ +const FREE: JailCase<DerivedPull> = { + pulls: [], + why: 'You’re under the limits now. Use the key to walk out.', + count: 0, + over: [], +}; + /** The preview's fallback when you're not in jail: the board's oldest * PRs, so there's something to look at. */ function sampleCase(pulls: DerivedPull[]): JailCase<DerivedPull> { From 3da4549fc3f08d7f1b98515e81e8143bb6f94949 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 14:22:53 -0700 Subject: [PATCH 03/16] Group PR jail's list so the way out is on top The jail listed your PRs as one flat, oldest-first list, so the ones you could merge in seconds sat between ones waiting on someone else. Now it splits them the way My work does (same rowWord): Ready to merge on top, then Your move, then Waiting on others, each oldest first. Each row shows the age, the move, a CI mark, and who it waits on. The why line counts against the groups ("Merge your 3 ready ones and close 10 more"), never counts a parked PR as ready, and doesn't promise "you're out" while an old PR would still hold you. The preview now shows your own PRs as jail counts them. CiGlyph comes out of CiStatus so the board and the jail draw the same CI mark. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.test.tsx | 62 +++++-- frontend-v2/src/components/JailMode.tsx | 167 +++++++++++++++---- frontend-v2/src/components/pips.tsx | 49 +++--- frontend-v2/src/model/jail.test.ts | 54 +++++- frontend-v2/src/model/jail.ts | 86 ++++++++-- 5 files changed, 339 insertions(+), 79 deletions(-) diff --git a/frontend-v2/src/components/JailMode.test.tsx b/frontend-v2/src/components/JailMode.test.tsx index 11ac6f89..c17e4c35 100644 --- a/frontend-v2/src/components/JailMode.test.tsx +++ b/frontend-v2/src/components/JailMode.test.tsx @@ -2,27 +2,52 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it } from 'vitest'; -import type { DerivedPull } from '../../../shared/model/status'; +import { derive, type DerivedPull } from '../../../shared/model/status'; +import type { PullData } from '../../../shared/types'; import { JailMode } from './JailMode'; // React's act() warns unless the environment says it's a test (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; let n = 0; -const pull = (ageDays = 1) => { +/** One of my open PRs, run through the real derive; `ready` waives CR and + * QA so it can merge now, otherwise it waits on CR. */ +const pull = (ageDays = 1, { ready = false } = {}): DerivedPull => { n += 1; - return { - data: { - repo: 'iFixit/ifixit', - number: n, - title: `PR ${n}`, - draft: false, - state: 'open', - user: { login: 'me' }, + const at = new Date(Date.now() - ageDays * 86400_000 - 60_000).toISOString(); + const data: PullData = { + repo: 'iFixit/ifixit', + number: n, + state: 'open', + title: `PR ${n}`, + body: '', + draft: false, + created_at: at, + updated_at: at, + closed_at: null, + merged_at: null, + mergeable: true, + difficulty: null, + additions: 10, + deletions: 5, + changed_files: 2, + milestone: { title: null, due_on: null }, + head: { ref: 'branch', sha: 'abc', repo: { owner: { login: 'iFixit' } } }, + base: { ref: 'master' }, + user: { login: 'me' }, + status: { + cr_req: ready ? 0 : 1, + qa_req: ready ? 0 : 1, + allCR: [], + allQA: [], + dev_block: [], + deploy_block: [], + commit_statuses: [], }, - ageDays, - deployBlockedBy: [], - } as unknown as DerivedPull; + labels: [], + participants: [], + }; + return derive(data, undefined); }; const many = (count: number) => Array.from({ length: count }, () => pull()); @@ -42,6 +67,7 @@ function render(pulls: DerivedPull[]) { const why = () => document.getElementById('jail-why')?.textContent; const listed = () => document.querySelectorAll('[role="dialog"] li').length; +const headers = () => [...document.querySelectorAll('[role="dialog"] h3')].map(h => h.textContent); describe('JailMode while the cell is down', () => { it('follows new PRs and merges live', () => { @@ -60,4 +86,14 @@ describe('JailMode while the cell is down', () => { expect(why()).toMatch(/under the limits/); expect(listed()).toBe(0); }); + + it('lifts ready PRs to the top and counts them in the why line', () => { + const ready = pull(2, { ready: true }); + render([...many(7), ready]); + expect(headers()).toEqual(['Ready to merge · 1', 'Waiting on others · 7']); + expect(document.querySelector('[role="dialog"] li a')?.textContent).toContain( + ready.data.title + ); + expect(why()).toBe('8 open PRs. Parole at 7. Merge your ready one and you’re out.'); + }); }); diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index d60d97c0..4971b540 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -3,9 +3,12 @@ import { createPortal } from 'react-dom'; import { Lock } from 'lucide-react'; import type { DerivedPull } from '../../../shared/model/status'; import { githubUrl } from '../../../shared/format'; +import { rowNote, rowWord } from '../model/actions'; +import { STAGE_WORDS } from '../model/stage'; import { jailCase, jailDue, + jailGroups, jailRecord, parseJailRecord, JAIL_KEY, @@ -14,6 +17,8 @@ import { import { useSettings } from '../settings'; import { readStorage, writeStorage } from '../storage'; import { HeaderIconButton, RepoRef } from './bits'; +import { eyebrowText } from './Lane'; +import { CiGlyph } from './pips'; /** * PR jail: when your own open PRs pass a limit (model/jail.ts), a cell @@ -202,7 +207,12 @@ export function JailMode({ {shown && createPortal( <JailCell - jail={found ?? (shown === 'preview' ? sampleCase(pulls) : FREE)} + jail={ + found ?? + (shown === 'preview' ? sampleCase(pulls, me, extraBots, settings) : FREE) + } + me={me} + maxDays={settings.jailMaxDays} onDone={() => { // what you saw last is what the next showing compares // against, so PRs opened while it was up don't re-drop it @@ -224,15 +234,26 @@ const FREE: JailCase<DerivedPull> = { over: [], }; -/** The preview's fallback when you're not in jail: the board's oldest - * PRs, so there's something to look at. */ -function sampleCase(pulls: DerivedPull[]): JailCase<DerivedPull> { - const sample = pulls - .filter(p => p.data.state === 'open') - .sort((a, b) => b.ageDays - a.ageDays) - .slice(0, 6); +/** The preview when you're not in jail: your PRs as jail counts them (any + * open one trips a limit of 0), or the board's oldest when you have none, + * so there's something to look at. */ +function sampleCase( + pulls: DerivedPull[], + me: string, + extraBots: ReadonlySet<string>, + settings: { jailMaxDays: number; jailCountDrafts: boolean } +): JailCase<DerivedPull> { + const mine = jailCase(pulls, me, extraBots, { + maxOpen: 0, + maxDays: settings.jailMaxDays, + countDrafts: settings.jailCountDrafts, + }); + if (mine) return { ...mine, why: 'Preview: your open PRs, as jail would list them' }; return { - pulls: sample, + pulls: pulls + .filter(p => p.data.state === 'open') + .sort((a, b) => b.ageDays - a.ageDays) + .slice(0, 6), why: 'Preview: these are sample PRs from the board', count: 0, over: [], @@ -247,7 +268,17 @@ const TURN_MS = 600; const CHEER_MS = 1400; const LIFT_MS = 650; -function JailCell({ jail, onDone }: { jail: JailCase<DerivedPull>; onDone: () => void }) { +function JailCell({ + jail, + me, + maxDays, + onDone, +}: { + jail: JailCase<DerivedPull>; + me: string; + maxDays: number; + onDone: () => void; +}) { const rootRef = useRef<HTMLDivElement>(null); const keyRef = useRef<HTMLButtonElement>(null); const lockRef = useRef<HTMLDivElement>(null); @@ -490,27 +521,7 @@ function JailCell({ jail, onDone }: { jail: JailCase<DerivedPull>; onDone: () => {jail.why} </p> </div> - <ul className="m-0 flex list-none flex-col gap-2 p-0 jail-wide:max-h-[45vh] jail-wide:overflow-y-auto"> - {jail.pulls.map(p => ( - <li - key={`${p.data.repo}#${p.data.number}`} - className="flex flex-col text-[13px]" - > - <a - href={githubUrl(p.data.repo, p.data.number)} - target="_blank" - rel="noopener noreferrer" - className="font-medium text-ink hover:underline" - > - {p.data.title} <span aria-hidden>↗</span> - </a> - <span className="text-xs"> - <RepoRef repo={p.data.repo} number={p.data.number} />{' '} - <span className="text-ink-3 tabular-nums">· {p.ageDays}d open</span> - </span> - </li> - ))} - </ul> + <JailList pulls={jail.pulls} me={me} maxDays={maxDays} /> <p className="m-0 border-t border-line pt-3 text-xs text-ink-3" aria-live="polite"> {free ? 'Merged! You’re free.' @@ -521,3 +532,97 @@ function JailCell({ jail, onDone }: { jail: JailCase<DerivedPull>; onDone: () => </div> ); } + +/** Your PRs, the way My work groups them, with the ready ones on top: the + * quickest way out first. Someone else's PRs (the preview's fallback) list + * flat, since their groups would be a reviewer's. */ +function JailList({ pulls, me, maxDays }: { pulls: DerivedPull[]; me: string; maxDays: number }) { + const mine = pulls.every(p => p.data.user.login === me); + const groups = mine + ? jailGroups(pulls, me) + : { ready: [], move: [], waiting: pulls.map(pull => ({ pull })) }; + const sections = [ + { title: STAGE_WORDS.ready, tone: 'text-brand-700', rows: groups.ready, ready: true }, + { title: 'Your move', tone: 'text-brand-700', rows: groups.move }, + { title: 'Waiting on others', tone: 'text-ink-3', rows: groups.waiting }, + ].filter(g => g.rows.length); + return ( + <div className="flex flex-col gap-3 jail-wide:max-h-[45vh] jail-wide:overflow-y-auto"> + {sections.map(g => ( + <section key={g.title} aria-label={mine ? g.title : undefined}> + {mine && ( + <h3 className={`m-0 ${eyebrowText} ${g.tone}`}> + {g.title} <span className="text-ink-3 tabular-nums">· {g.rows.length}</span> + </h3> + )} + {g.ready && ( + <p className="m-0 text-xs text-ink-3">Quickest way out. Merge these first.</p> + )} + <ul className="m-0 mt-1.5 flex list-none flex-col gap-2 p-0"> + {g.rows.map(({ pull }) => ( + <JailRow + key={`${pull.data.repo}#${pull.data.number}`} + pull={pull} + me={me} + maxDays={maxDays} + ready={Boolean(g.ready)} + mine={mine} + /> + ))} + </ul> + </section> + ))} + </div> + ); +} + +/** One PR: the title to GitHub and its age, then what it's waiting on. */ +function JailRow({ + pull: p, + me, + maxDays, + ready, + mine, +}: { + pull: DerivedPull; + me: string; + maxDays: number; + ready: boolean; + mine: boolean; +}) { + const word = mine && !ready ? rowWord(p, me) : null; + const context = mine ? rowNote(p, me).context : null; + // a do row names the move, then the detail; a wait row's detail already + // says who it waits on ("waiting on bob to re-stamp"), so the word only + // stands in when there's no detail + const move = word?.kind === 'do' ? word.word : null; + const detail = context ?? (word?.kind === 'wait' ? word.word : null); + return ( + <li className="flex flex-col text-[13px]"> + <span className="flex items-baseline gap-3"> + <a + href={githubUrl(p.data.repo, p.data.number)} + target="_blank" + rel="noopener noreferrer" + className="min-w-0 flex-1 font-medium text-ink hover:underline" + > + {p.data.title} <span aria-hidden>↗</span> + </a> + <span + className={`shrink-0 text-xs tabular-nums ${ + p.ageDays > maxDays ? 'font-semibold text-warn' : 'text-ink-3' + }`} + title={`Open ${p.ageDays} days`} + > + {p.ageDays ? `${p.ageDays}d` : '<1d'} + </span> + </span> + <span className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-xs text-ink-3 jail-wide:flex-nowrap"> + <RepoRef repo={p.data.repo} number={p.data.number} /> + {move && <span className="font-medium whitespace-nowrap text-brand-700">· {move}</span>} + {!ready && <CiGlyph pull={p} />} + {detail && <span className="min-w-0 truncate">· {detail}</span>} + </span> + </li> + ); +} diff --git a/frontend-v2/src/components/pips.tsx b/frontend-v2/src/components/pips.tsx index 18648090..e961dc35 100644 --- a/frontend-v2/src/components/pips.tsx +++ b/frontend-v2/src/components/pips.tsx @@ -34,6 +34,32 @@ function ciDuration(s: CommitStatus): string | null { return secs < 60 ? `${Math.round(secs)}s` : `${Math.round(secs / 60)}m`; } +/** CI's one circle (styles.css .ci-ring/.ci-fail): a red ring with an X = + * failing (a 1-of-25 failure reads as loudly as 25-of-25), the slate ring + * sweeping closed = running (the sweep is the share done), a closed green + * ring = passed. Draws nothing for passed unless `passed`, or with no CI. */ +export function CiGlyph({ pull, passed = false }: { pull: DerivedPull; passed?: boolean }) { + const checks = pull.ci === 'none' ? [] : [...headStatuses(pull.data)]; + if (!checks.length) return null; + if (checks.some(isRedCheck)) return <span aria-hidden className="ci-fail" />; + const pendingCount = checks.filter(c => c.data.state === 'pending').length; + if (pendingCount) + return ( + <span + aria-hidden + className="ci-ring ci-ring-run" + style={ + { + '--sweep': `${Math.round( + ((checks.length - pendingCount) / checks.length) * 360 + )}deg`, + } as CSSProperties + } + /> + ); + return passed ? <span aria-hidden className="ci-ring ci-ring-pass" /> : null; +} + /** * CI in the sign-off family: the machine is a reviewer, so it wears the same * label + circle-mark anatomy as CR and QA. Only a failure earns ink at rest @@ -91,28 +117,7 @@ export function CiStatus({ pull }: { pull: DerivedPull }) { <span aria-hidden className="w-[18px] text-left text-[11px] font-medium text-ink-3"> CI </span> - {/* one circle (styles.css .ci-ring/.ci-fail): a red ring with - an X = failing (the fraction lives in the popover; a 1-of-25 - failure must read as loudly as 25-of-25), the slate ring - sweeping closed = running (the sweep is the completed share, - v1's grey section reborn), and a closed green ring revealed - on row hover = passed (no draw-on-reveal: animation means a - state CHANGED, and hovering isn't a change). */} - {failing > 0 ? ( - <span aria-hidden className="ci-fail" /> - ) : pending ? ( - <span - aria-hidden - className="ci-ring ci-ring-run" - style={ - { - '--sweep': `${Math.round(((checks.length - pendingCount) / checks.length) * 360)}deg`, - } as CSSProperties - } - /> - ) : ( - <span aria-hidden className="ci-ring ci-ring-pass" /> - )} + <CiGlyph pull={pull} passed /> </button> )} > diff --git a/frontend-v2/src/model/jail.test.ts b/frontend-v2/src/model/jail.test.ts index b3f7eb92..2ab3ffd1 100644 --- a/frontend-v2/src/model/jail.test.ts +++ b/frontend-v2/src/model/jail.test.ts @@ -15,7 +15,7 @@ let n = 0; function pull( login: string, ageDays: number, - o: { draft?: boolean; deployBlockedBy?: string[] } = {} + o: { draft?: boolean; deployBlockedBy?: string[]; ready?: boolean; cryo?: boolean } = {} ): JailPull { n += 1; return { @@ -29,6 +29,8 @@ function pull( }, ageDays, deployBlockedBy: o.deployBlockedBy ?? [], + status: o.ready ? 'ready' : 'needs_cr', + cryo: o.cryo ?? false, }; } @@ -36,6 +38,52 @@ const many = (count: number, login = 'me', ageDays = 1) => Array.from({ length: count }, () => pull(login, ageDays)); describe('jailCase', () => { + // [open, ready] -> the why line; limit 7 + it.each([ + [20, 0, '20 open PRs. Parole at 7. Close 13 to get out.'], + [20, 3, '20 open PRs. Parole at 7. Merge your 3 ready ones and close 10 more.'], + [10, 3, '10 open PRs. Parole at 7. Merge your 3 ready ones and you’re out.'], + [12, 6, '12 open PRs. Parole at 7. Merge 5 of your 6 ready ones and you’re out.'], + [8, 1, '8 open PRs. Parole at 7. Merge your ready one and you’re out.'], + [9, 1, '9 open PRs. Parole at 7. Merge your ready one and close 1 more.'], + ])('with %i open and %i ready, points at the ready ones first', (open, ready, why) => { + const pulls = [ + ...many(open - ready), + ...Array.from({ length: ready }, () => pull('me', 1, { ready: true })), + ]; + expect(jailCase(pulls, 'me', NO_BOTS, LIMITS)?.why).toBe(why); + }); + + it('never counts a parked PR as ready', () => { + const parked = pull('me', 1, { ready: true, cryo: true }); + expect(jailCase([...many(7), parked], 'me', NO_BOTS, LIMITS)?.why).toBe( + '8 open PRs. Parole at 7. Close 1 to get out.' + ); + }); + + it('won’t promise you’re out while an old PR would still hold you', () => { + const old = pull('me', 40); + const pulls = [...many(6), old, pull('me', 1, { ready: true })]; + expect(jailCase(pulls, 'me', NO_BOTS, LIMITS)?.why).toBe( + `8 open PRs. Parole at 7. Merge your ready one to get under 7. #${old.data.number} is still past 14 days.` + ); + // an old PR that's ready leaves with the merge, so you are out + const oldReady = [...many(7), pull('me', 40, { ready: true })]; + expect(jailCase(oldReady, 'me', NO_BOTS, LIMITS)?.why).toBe( + '8 open PRs. Parole at 7. Merge your ready one and you’re out.' + ); + }); + + it('says merge when the one old PR is ready, and counts several', () => { + const old = pull('me', 30, { ready: true }); + expect(jailCase([old], 'me', NO_BOTS, LIMITS)?.why).toBe( + `#${old.data.number} has been open 30 days and it’s ready. Merge it to get out.` + ); + expect(jailCase([pull('me', 20), pull('me', 15)], 'me', NO_BOTS, LIMITS)?.why).toBe( + '2 PRs open past 14 days. Merge or close them to get out.' + ); + }); + it('stays free at the open-PR limit', () => { expect(jailCase(many(7), 'me', NO_BOTS, LIMITS)).toBeNull(); }); @@ -53,7 +101,9 @@ describe('jailCase', () => { const old = pull('me', 21); const c = jailCase([pull('me', 2), old, pull('me', 14)], 'me', NO_BOTS, LIMITS); expect(c?.pulls).toEqual([old]); - expect(c?.why).toBe(`#${old.data.number} has been open 21 days. Parole at 14 days.`); + expect(c?.why).toBe( + `#${old.data.number} has been open 21 days. Parole at 14 days. Merge or close it to get out.` + ); expect(c?.count).toBe(3); expect(c?.over).toEqual([`iFixit/ifixit#${old.data.number}`]); }); diff --git a/frontend-v2/src/model/jail.ts b/frontend-v2/src/model/jail.ts index a5eed39b..f6d5507a 100644 --- a/frontend-v2/src/model/jail.ts +++ b/frontend-v2/src/model/jail.ts @@ -1,5 +1,7 @@ +import type { DerivedPull } from '../../../shared/model/status'; import { isBotLogin } from '../../../shared/model/visibility'; import type { PullData } from '../../../shared/types'; +import { rowWord } from './actions'; /** Jail when you have more than this many open PRs of your own. */ export const JAIL_MAX_OPEN = 7; @@ -27,8 +29,15 @@ export interface JailPull { ageDays: number; /** who holds a deploy block on it; a held PR is done, so it doesn't count */ deployBlockedBy: string[]; + status: DerivedPull['status']; + /** parked (Cryogenic Storage): kept open on purpose, never "ready" */ + cryo: boolean; } +/** Can merge now, the quickest way out. A parked PR can be signed off and + * green, but My work files it under waiting, so jail does too. */ +export const isJailReady = (p: JailPull) => p.status === 'ready' && !p.cryo; + export interface JailCase<P extends JailPull> { /** the PRs that put you there, oldest first */ pulls: P[]; @@ -68,23 +77,78 @@ export function jailCase<P extends JailPull>( const old = mine.filter(p => p.ageDays > limits.maxDays); const counts = { count: mine.length, over: old.map(idOf) }; if (mine.length > limits.maxOpen) { - const n = mine.length; - return { - pulls: mine, - why: `${n} open PRs. Parole at ${limits.maxOpen}. Close ${n - limits.maxOpen} to get out.`, - ...counts, - }; + const why = countWhy( + mine.length, + limits.maxOpen, + mine.filter(isJailReady).length, + old.filter(p => !isJailReady(p)), + limits.maxDays + ); + return { pulls: mine, why, ...counts }; } if (old.length) { - return { - pulls: old, - why: `#${old[0].data.number} has been open ${old[0].ageDays} days. Parole at ${limits.maxDays} days.`, - ...counts, - }; + return { pulls: old, why: ageWhy(old, limits.maxDays), ...counts }; } return null; } +/** The count line, pointing at the ready PRs first since merging one is + * the quickest way to lower the count. `stillOld` are the PRs past the age + * limit that merging the ready ones won't clear, so it never promises + * "you're out" while one is left. */ +// ponytail: when more are ready than needed, an old ready PR left unmerged +// still holds you; the line doesn't say which ready ones to pick +function countWhy( + n: number, + max: number, + ready: number, + stillOld: JailPull[], + maxDays: number +): string { + const need = n - max; + const head = `${n} open PRs. Parole at ${max}.`; + const yours = ready === 1 ? 'your ready one' : `your ${ready} ready ones`; + const out = stillOld.length ? `to get under ${max}` : 'and you’re out'; + const tail = !stillOld.length + ? '' + : stillOld.length === 1 + ? ` #${stillOld[0].data.number} is still past ${maxDays} days.` + : ` ${stillOld.length} PRs are still past ${maxDays} days.`; + if (!ready) + return `${head} Close ${need} to get ${stillOld.length ? `under ${max}` : 'out'}.${tail}`; + if (ready < need) return `${head} Merge ${yours} and close ${need - ready} more.${tail}`; + if (ready === need) return `${head} Merge ${yours} ${out}.${tail}`; + return `${head} Merge ${need} of ${yours} ${out}.${tail}`; +} + +function ageWhy(old: JailPull[], maxDays: number): string { + if (old.length > 1) { + return `${old.length} PRs open past ${maxDays} days. Merge or close them to get out.`; + } + const [p] = old; + const head = `#${p.data.number} has been open ${p.ageDays} days`; + return isJailReady(p) + ? `${head} and it’s ready. Merge it to get out.` + : `${head}. Parole at ${maxDays} days. Merge or close it to get out.`; +} + +/** + * A jail's PRs split the way My work splits them, by the same rowWord, with + * the ready ones lifted out on top: ready to merge, your move, then waiting + * on others. Each group runs oldest first; the row's word says the rest. + */ +export function jailGroups(pulls: DerivedPull[], me: string) { + const worded = pulls + .map(pull => ({ pull, word: rowWord(pull, me) })) + .sort((a, b) => b.pull.ageDays - a.pull.ageDays); + const rest = worded.filter(w => !isJailReady(w.pull)); + return { + ready: worded.filter(w => isJailReady(w.pull)), + move: rest.filter(w => w.word.kind === 'do'), + waiting: rest.filter(w => w.word.kind === 'wait'), + }; +} + /** The record to store when `jail` shows at `now`. */ export const jailRecord = (jail: JailCase<JailPull>, now: number): JailRecord => ({ at: now, From 8f71dd2878c381d0ae6c29961e64581f2702a9d9 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 15:58:49 -0700 Subject: [PATCH 04/16] Make PR jail a plain modal you can turn off People complained about the jail. The full-screen animated cell with the key and the lock was a lot to sit through, and some people don't want the feature at all. Now it's a modal with a small sad robot behind bars, drawn as a few lines in the board's own color tokens so it matches the UI and themes with dark mode, above the same grouped list. It still has no close button: you drag the card onto a trash can at the bottom of the screen (on a phone that's a swipe down), and a keyboard or screen reader presses the trash. A new PR jail setting turns it off, badge and all; it defaults to on. The cell, key, lock and inmate art and their CSS go away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/assets/jail/cell-r10.svg | 111 ----- frontend-v2/src/assets/jail/inmate-r03.svg | 81 ---- frontend-v2/src/assets/jail/inmate-r05.svg | 83 ---- frontend-v2/src/assets/jail/key-g01.svg | 41 -- frontend-v2/src/assets/jail/lock-g01.svg | 37 -- frontend-v2/src/components/JailMode.test.tsx | 21 + frontend-v2/src/components/JailMode.tsx | 405 ++++++----------- frontend-v2/src/components/Settings.tsx | 20 +- frontend-v2/src/settings.ts | 5 +- frontend-v2/src/styles.css | 447 ------------------- 10 files changed, 170 insertions(+), 1081 deletions(-) delete mode 100644 frontend-v2/src/assets/jail/cell-r10.svg delete mode 100644 frontend-v2/src/assets/jail/inmate-r03.svg delete mode 100644 frontend-v2/src/assets/jail/inmate-r05.svg delete mode 100644 frontend-v2/src/assets/jail/key-g01.svg delete mode 100644 frontend-v2/src/assets/jail/lock-g01.svg diff --git a/frontend-v2/src/assets/jail/cell-r10.svg b/frontend-v2/src/assets/jail/cell-r10.svg deleted file mode 100644 index e96e674c..00000000 --- a/frontend-v2/src/assets/jail/cell-r10.svg +++ /dev/null @@ -1,111 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800"> - <title>Cell r10: neon sign cell - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - PR JAIL - PR JAIL - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - LOCKED - LOCKED - - - - - - diff --git a/frontend-v2/src/assets/jail/inmate-r03.svg b/frontend-v2/src/assets/jail/inmate-r03.svg deleted file mode 100644 index 6b63299c..00000000 --- a/frontend-v2/src/assets/jail/inmate-r03.svg +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - #0010 - - - - - - - - - - - - - - - - day 17 - - - - - - - - - - - - - - - diff --git a/frontend-v2/src/assets/jail/inmate-r05.svg b/frontend-v2/src/assets/jail/inmate-r05.svg deleted file mode 100644 index 8cd29c19..00000000 --- a/frontend-v2/src/assets/jail/inmate-r05.svg +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - #0010 - - - - - - - - - - - - - - - - - - - - - - - MERGED! - - - - - - - - - - - - - - - diff --git a/frontend-v2/src/assets/jail/key-g01.svg b/frontend-v2/src/assets/jail/key-g01.svg deleted file mode 100644 index 4bde54e1..00000000 --- a/frontend-v2/src/assets/jail/key-g01.svg +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend-v2/src/assets/jail/lock-g01.svg b/frontend-v2/src/assets/jail/lock-g01.svg deleted file mode 100644 index 73eeda59..00000000 --- a/frontend-v2/src/assets/jail/lock-g01.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend-v2/src/components/JailMode.test.tsx b/frontend-v2/src/components/JailMode.test.tsx index c17e4c35..e67b534f 100644 --- a/frontend-v2/src/components/JailMode.test.tsx +++ b/frontend-v2/src/components/JailMode.test.tsx @@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it } from 'vitest'; import { derive, type DerivedPull } from '../../../shared/model/status'; import type { PullData } from '../../../shared/types'; +import { setSettings } from '../settings'; import { JailMode } from './JailMode'; // React's act() warns unless the environment says it's a test @@ -58,6 +59,7 @@ afterEach(() => { act(() => root?.unmount()); root = null; localStorage.clear(); + setSettings({ jailOn: true }); }); function render(pulls: DerivedPull[]) { @@ -96,4 +98,23 @@ describe('JailMode while the cell is down', () => { ); expect(why()).toBe('8 open PRs. Parole at 7. Merge your ready one and you’re out.'); }); + + it('stays away, badge and all, when PR jail is off', () => { + setSettings({ jailOn: false }); + render(many(9)); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(document.querySelector('[aria-label^="PR jail"]')).toBeNull(); + }); + + it('goes away when the trash is pressed from the keyboard', () => { + render(many(8)); + const trash = document.querySelector( + '[aria-label="Throw PR jail in the trash"]' + ); + // a keyboard press clicks with detail 0; a mouse click does nothing + act(() => trash?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }))); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + act(() => trash?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 }))); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); }); diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index 4971b540..b0bdec78 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from 'react'; +import { useEffect, useMemo, useRef, useState, type PointerEvent } from 'react'; import { createPortal } from 'react-dom'; -import { Lock } from 'lucide-react'; +import { Lock, Trash2 } from 'lucide-react'; import type { DerivedPull } from '../../../shared/model/status'; import { githubUrl } from '../../../shared/format'; import { rowNote, rowWord } from '../model/actions'; @@ -21,87 +21,14 @@ import { eyebrowText } from './Lane'; import { CiGlyph } from './pips'; /** - * PR jail: when your own open PRs pass a limit (model/jail.ts), a cell - * drops over the board and lists them, and comes back only when that gets - * worse (jailDue). Dragging the key onto the lock (or pressing it) lets you - * out. While you're over, a header badge reopens it. `#jail=1` in the URL - * forces it, any time, for a preview. + * PR jail: when your own open PRs pass a limit (model/jail.ts), a modal pops + * up listing them, and comes back only when that gets worse (jailDue). It + * has no close button: you drag it to the trash at the bottom of the screen + * (or press the trash from the keyboard). While you're over, a header badge + * reopens it. `#jail=1` in the URL forces it, any time, for a preview; the + * jailOn setting turns the whole thing off. */ -// the art as raw SVG strings, inlined so CSS can animate their parts -const svgs = import.meta.glob('../assets/jail/*.svg', { - query: '?raw', - import: 'default', - eager: true, -}); -const svg = (name: string) => svgs[`../assets/jail/${name}.svg`]; - -/** Several SVGs share one page, so every id (and every #ref to one) gets a - * per-copy prefix. */ -function prefixIds(raw: string, prefix: string): string { - return raw - .replace(/<\?xml[^>]*>/, '') - .replace(/[^<]*<\/title>/, '') - .replace(/id="/g, `id="${prefix}`) - .replace(/(href="#|url\(#)/g, `$1${prefix}`); -} - -/** One cell's art and where things sit on it, in its 1200x800 viewBox. - * `front` names the bars drawn again in front of the robot, so it reads as - * inside the cell; swapping the cell art means a new one of these. */ -const CELL = { - svg: svg('cell-r10'), - // the door's outer bars cross the robot's sides; the middle one stays - // behind him, since in front it would hide his face - front: [725, 875], - barTop: 92, - barBottom: 708, - // the robot's face sits on the door's middle line (x 800); inmate-r05 - // draws him further left in its art, so its box sits further right - inmate: { x: 590, y: 275, w: 330, h: 440 }, - merged: { x: 635, y: 275, w: 330, h: 440 }, - // inmate-r03's tally wall, on the back wall in the bay left of the door - tally: { x: 528, y: 271, w: 216, h: 288 }, - // hung on the door's latch edge, clear of the robot's face - lock: { x: 608, y: 330, w: 120, h: 160 }, - // right above the lock, so getting out is one drag straight down - key: { x: 631, y: 96, w: 74, h: 148 }, - // the dashed arrow from the key's bit down to the lock - guide: { x: 653, y: 248, w: 30, h: 78 }, - // the drop-target ring, centered on the padlock's keyhole - ring: { x: 598, y: 372, w: 140, h: 140 }, -}; - -const ART = { - back: prefixIds(CELL.svg, 'jb-'), - front: prefixIds(CELL.svg, 'jf-'), - inmate: prefixIds(svg('inmate-r03'), 'ji-'), - tally: prefixIds(svg('inmate-r03'), 'jt-'), - merged: prefixIds(svg('inmate-r05'), 'jm-'), - key: prefixIds(svg('key-g01'), 'jk-'), - lock: prefixIds(svg('lock-g01'), 'jl-'), -}; - -const box = (b: { x: number; y: number; w: number; h: number }): CSSProperties => ({ - left: `${b.x / 12}%`, - top: `${b.y / 8}%`, - width: `${b.w / 12}%`, - height: `${b.h / 8}%`, -}); - -/** A clip-path showing only narrow strips around the front bars. The strips - * join along the top edge with zero-width seams. */ -function frontClip(): string { - const top = `${CELL.barTop / 8}%`; - const bottom = `${CELL.barBottom / 8}%`; - const pts = CELL.front.flatMap(x => { - const l = `${(x - 18) / 12}%`; - const r = `${(x + 18) / 12}%`; - return [`${l} ${top}`, `${r} ${top}`, `${r} ${bottom}`, `${l} ${bottom}`, `${l} ${top}`]; - }); - return `polygon(${pts.join(', ')})`; -} - const hasPreviewFlag = () => new URLSearchParams(location.hash.slice(1)).get('jail') === '1'; /** Renders the header badge in place, and the cell over the page. */ @@ -129,7 +56,7 @@ export function JailMode({ const found = useMemo( () => - initialized + initialized && settings.jailOn ? jailCase(pulls, me, extraBots, { maxOpen: settings.jailMaxOpen, maxDays: settings.jailMaxDays, @@ -206,7 +133,7 @@ export function JailMode({ )} {shown && createPortal( - <JailCell + <JailModal jail={ found ?? (shown === 'preview' ? sampleCase(pulls, me, extraBots, settings) : FREE) @@ -229,7 +156,7 @@ export function JailMode({ /** Merged or closed your way under the limits while the cell was down. */ const FREE: JailCase<DerivedPull> = { pulls: [], - why: 'You’re under the limits now. Use the key to walk out.', + why: 'You’re under the limits now. Drag this to the trash and get back to work.', count: 0, over: [], }; @@ -260,15 +187,9 @@ function sampleCase( }; } -type Phase = 'locked' | 'turning' | 'open' | 'leaving'; - -// ms from a hit: key snaps and turns, shackle opens and the robot cheers, -// then the cell lifts away -const TURN_MS = 600; -const CHEER_MS = 1400; -const LIFT_MS = 650; - -function JailCell({ +/** The modal over the board. The only way out is dragging it onto the + * trash; a keyboard or screen reader presses the trash instead. */ +function JailModal({ jail, me, maxDays, @@ -280,27 +201,17 @@ function JailCell({ onDone: () => void; }) { const rootRef = useRef<HTMLDivElement>(null); - const keyRef = useRef<HTMLButtonElement>(null); - const lockRef = useRef<HTMLDivElement>(null); + const trashRef = useRef<HTMLButtonElement>(null); const drag = useRef<{ id: number; x: number; y: number } | null>(null); - const [phase, setPhase] = useState<Phase>('locked'); const [offset, setOffset] = useState({ x: 0, y: 0 }); const [dragging, setDragging] = useState(false); - // the key is over the lock: the ring says "let go here" - const [near, setNear] = useState(false); - // the "Drag me" hint goes once the key's been touched, by pointer or keys - const [touched, setTouched] = useState(false); - // a missed drop: the lock shakes its head before the key springs back - const [nope, setNope] = useState(false); - const timers = useRef<number[]>([]); + // the card is over the trash: the trash says "let go here" + const [over, setOver] = useState(false); useEffect(() => { const root = rootRef.current; - // the dialog takes focus, not the key, so the key only shows its focus - // ring when someone tabs to it; no scroll, as the key sits below the - // list on a phone root?.focus({ preventScroll: true }); - // Esc doesn't let you out (the key does); Tab stays inside + // Esc doesn't close it (the trash does); Tab stays inside const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') e.preventDefault(); if (e.key !== 'Tab' || !root) return; @@ -322,91 +233,43 @@ function JailCell({ document.addEventListener('keydown', onKey); const prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; - const pending = timers.current; return () => { document.removeEventListener('keydown', onKey); document.body.style.overflow = prevOverflow; - pending.forEach(clearTimeout); }; }, []); - const unlock = () => { - if (phase !== 'locked') return; - // snap the key's bit onto the keyhole - const d = bitToHole(); - if (d) setOffset(o => ({ x: o.x + d.x, y: o.y + d.y })); - setPhase('turning'); - const at = (ms: number, fn: () => void) => timers.current.push(window.setTimeout(fn, ms)); - at(TURN_MS, () => setPhase('open')); - at(TURN_MS + CHEER_MS, () => setPhase('leaving')); - at(TURN_MS + CHEER_MS + LIFT_MS, onDone); - }; - - /** How far the key's bit is from the keyhole, in px. */ - const bitToHole = () => { - const key = keyRef.current?.getBoundingClientRect(); - const lock = lockRef.current?.getBoundingClientRect(); - if (!key || !lock?.width) return null; - return { - x: lock.left + lock.width / 2 - (key.left + key.width / 2), - y: lock.top + lock.height * 0.72 - (key.top + key.height * 0.85), - }; - }; - - const keyOverLock = () => { - const key = keyRef.current?.getBoundingClientRect(); - const lock = lockRef.current?.getBoundingClientRect(); - const slop = 24; + /** Whether the pointer is over the trash, with some slop. */ + const onTrash = (x: number, y: number) => { + const t = trashRef.current?.getBoundingClientRect(); + const slop = 32; return Boolean( - key && - lock && - key.right > lock.left - slop && - key.left < lock.right + slop && - key.bottom > lock.top - slop && - key.top < lock.bottom + slop + t && x > t.left - slop && x < t.right + slop && y > t.top - slop && y < t.bottom + slop ); }; - const onPointerDown = (e: PointerEvent<HTMLButtonElement>) => { - if (phase !== 'locked') return; + const onPointerDown = (e: PointerEvent<HTMLElement>) => { + // links in the header stay clickable + if ((e.target as HTMLElement).closest('a')) return; drag.current = { id: e.pointerId, x: e.clientX - offset.x, y: e.clientY - offset.y }; setDragging(true); - setTouched(true); - // keeps the moves coming when the pointer outruns the key e.currentTarget.setPointerCapture?.(e.pointerId); }; - const onPointerMove = (e: PointerEvent<HTMLButtonElement>) => { + const onPointerMove = (e: PointerEvent<HTMLElement>) => { const d = drag.current; if (d?.id !== e.pointerId) return; - const next = { x: e.clientX - d.x, y: e.clientY - d.y }; - const isNear = keyOverLock(); - setNear(isNear); - // in range, the keyhole pulls the key's bit a third of the way in - const pull = isNear ? bitToHole() : null; - setOffset( - pull - ? { - x: next.x + (pull.x - (next.x - offset.x)) / 3, - y: next.y + (pull.y - (next.y - offset.y)) / 3, - } - : next - ); + setOffset({ x: e.clientX - d.x, y: e.clientY - d.y }); + setOver(onTrash(e.clientX, e.clientY)); }; - const onPointerUp = (e: PointerEvent<HTMLButtonElement>) => { + const onPointerUp = (e: PointerEvent<HTMLElement>) => { if (drag.current?.id !== e.pointerId) return; drag.current = null; setDragging(false); - setNear(false); - if (keyOverLock()) unlock(); - else { - // a miss springs back home - setOffset({ x: 0, y: 0 }); - setNope(true); - timers.current.push(window.setTimeout(() => setNope(false), 400)); - } + setOver(false); + if (onTrash(e.clientX, e.clientY)) onDone(); + else setOffset({ x: 0, y: 0 }); }; - const free = phase === 'open' || phase === 'leaving'; return ( <div ref={rootRef} @@ -415,120 +278,58 @@ function JailCell({ aria-modal="true" aria-labelledby="jail-title" aria-describedby="jail-why" - className="jail fixed inset-0 z-[200] overflow-hidden outline-none" + className="fixed inset-0 z-[200] flex flex-col items-center overflow-y-auto bg-ink/40 px-4 pt-[8vh] pb-32 outline-none" > - {/* the cell's back wall, so the board doesn't show between the bars */} - <div className="jail-scrim absolute inset-0" aria-hidden /> - <div - className={`jail-stage absolute inset-0 flex flex-col overflow-y-auto jail-wide:block jail-wide:overflow-visible ${ - phase === 'leaving' ? 'is-leaving' : '' - }`} + <section + className={`flex max-h-[calc(100dvh-8vh-9rem)] w-full max-w-[30rem] flex-col gap-3 rounded-xl border border-line bg-surface p-4 text-ink shadow-2xl ${ + dragging ? '' : 'transition-transform duration-200 motion-reduce:transition-none' + } ${over ? 'scale-90 opacity-70' : ''}`} + style={{ translate: `${offset.x}px ${offset.y}px` }} > - {/* the cell: the whole screen when it's wide (jail-wide in styles.css); on a phone or tablet, the part with - the robot and the door, below the list */} - <div className="jail-frame order-2"> - <div className="jail-scene" aria-hidden> - <div - className="jail-art jail-tally absolute" - style={box(CELL.tally)} - dangerouslySetInnerHTML={{ __html: ART.tally }} - /> - <div - className="jail-art absolute inset-0" - dangerouslySetInnerHTML={{ __html: ART.back }} - /> - <div - key={free ? 'merged' : 'jailed'} - className={`jail-art jail-inmate absolute ${free ? 'jail-cheer' : ''}`} - style={box(free ? CELL.merged : CELL.inmate)} - dangerouslySetInnerHTML={{ __html: free ? ART.merged : ART.inmate }} - /> - <div - className="jail-art absolute inset-0" - style={{ clipPath: frontClip() }} - dangerouslySetInnerHTML={{ __html: ART.front }} - /> - <div - className={`jail-ring absolute rounded-full ${dragging ? 'is-dragging' : ''} ${ - near ? 'is-near' : '' - } ${phase !== 'locked' ? 'is-done' : ''}`} - style={box(CELL.ring)} - /> - {phase === 'locked' && !dragging && ( - <svg - className="jail-guide absolute" - style={box(CELL.guide)} - viewBox="0 0 30 78" - fill="none" - > - {/* a casing under the arrow, so it reads over the door's bar */} - <path d="M15 2V66M6 60L15 72L24 60" className="jail-guide-case" /> - <path d="M15 2V66" className="jail-guide-line" /> - <path d="M6 60L15 72L24 60" /> - </svg> - )} - <div - ref={lockRef} - className={`jail-art jail-lock absolute ${free ? 'is-open' : ''} ${ - near ? 'is-near' : '' - } ${nope ? 'is-nope' : ''} ${phase === 'locked' ? 'is-live' : ''}`} - style={box(CELL.lock)} - dangerouslySetInnerHTML={{ __html: ART.lock }} - /> - </div> - {/* the key rides the same geometry as the art, outside its aria-hidden */} - <div className="jail-scene pointer-events-none"> - <button - ref={keyRef} - type="button" - aria-label="Use the key to unlock PR jail" - className={`jail-key ${ - phase === 'locked' ? 'is-live' : '' - } pointer-events-auto absolute z-10 cursor-grab touch-none rounded-lg ${ - dragging ? 'is-dragging cursor-grabbing' : '' - } ${phase !== 'locked' ? 'is-turning' : ''} ${free ? 'is-in' : ''}`} - style={{ ...box(CELL.key), translate: `${offset.x}px ${offset.y}px` }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerCancel={onPointerUp} - // keyboard and screen readers click with detail 0; a - // mouse has to drag the key - onClick={e => e.detail === 0 && unlock()} - onKeyDown={() => setTouched(true)} - > - {!touched && phase === 'locked' && ( - <span - aria-hidden - className="jail-hint pointer-events-none absolute top-[18%] left-full ml-1.5 rounded-full border border-line bg-surface px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap text-ink-2 shadow-sm" - > - Drag me - </span> - )} - <span - className="jail-art jail-key-art block h-full w-full" - dangerouslySetInnerHTML={{ __html: ART.key }} - /> - </button> - </div> - </div> - <section className="relative order-1 m-4 flex shrink-0 flex-col gap-3 rounded-xl border border-line bg-surface p-4 text-ink shadow-xl jail-wide:absolute jail-wide:top-24 jail-wide:left-[4vw] jail-wide:m-0 jail-wide:w-[26rem]"> - <div> + {/* the handle: drag the card from its top, down to the trash */} + <div + className={`flex touch-none items-center gap-3 select-none ${ + dragging ? 'cursor-grabbing' : 'cursor-grab' + }`} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} + > + <SadRobot /> + <div className="min-w-0"> <h2 id="jail-title" className="m-0 text-lg font-semibold"> - {free ? 'Out of PR jail' : 'PR jail'} + PR jail </h2> <p id="jail-why" className="m-0 text-[13px] text-ink-2"> {jail.why} </p> </div> + </div> + <div className="min-h-0 overflow-y-auto"> <JailList pulls={jail.pulls} me={me} maxDays={maxDays} /> - <p className="m-0 border-t border-line pt-3 text-xs text-ink-3" aria-live="polite"> - {free - ? 'Merged! You’re free.' - : 'Drag the key down into the lock to get out (or press Enter on it).'} - </p> - </section> - </div> + </div> + <p className="m-0 border-t border-line pt-3 text-xs text-ink-3"> + Drag this card into the trash below to put it away. + </p> + </section> + <button + ref={trashRef} + type="button" + aria-label="Throw PR jail in the trash" + // keyboard and screen readers click with detail 0; a mouse has to + // drag the card here + onClick={e => e.detail === 0 && onDone()} + className={`fixed bottom-6 left-1/2 grid size-16 -translate-x-1/2 cursor-default place-items-center rounded-full border-2 border-dashed shadow-lg transition-colors duration-150 motion-reduce:transition-none ${ + over + ? 'scale-110 border-bad bg-bad text-surface' + : dragging + ? 'border-bad bg-surface text-bad' + : 'border-line bg-surface text-ink-3' + }`} + > + <Trash2 size={26} aria-hidden /> + </button> </div> ); } @@ -547,7 +348,7 @@ function JailList({ pulls, me, maxDays }: { pulls: DerivedPull[]; me: string; ma { title: 'Waiting on others', tone: 'text-ink-3', rows: groups.waiting }, ].filter(g => g.rows.length); return ( - <div className="flex flex-col gap-3 jail-wide:max-h-[45vh] jail-wide:overflow-y-auto"> + <div className="flex flex-col gap-3"> {sections.map(g => ( <section key={g.title} aria-label={mine ? g.title : undefined}> {mine && ( @@ -617,7 +418,7 @@ function JailRow({ {p.ageDays ? `${p.ageDays}d` : '<1d'} </span> </span> - <span className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-xs text-ink-3 jail-wide:flex-nowrap"> + <span className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-xs text-ink-3"> <RepoRef repo={p.data.repo} number={p.data.number} /> {move && <span className="font-medium whitespace-nowrap text-brand-700">· {move}</span>} {!ready && <CiGlyph pull={p} />} @@ -626,3 +427,53 @@ function JailRow({ </li> ); } + +/** The inmate: a sad robot behind bars, drawn in the board's own line + * weights and color tokens so it themes with everything else. */ +function SadRobot() { + return ( + <svg + aria-hidden + viewBox="0 0 96 96" + className="size-20 shrink-0 rounded-xl bg-secondary" + fill="none" + strokeLinecap="round" + strokeLinejoin="round" + > + {/* a drooping antenna */} + <path d="M48 24 C48 18 52 15 57 15" className="stroke-ink-3" strokeWidth={2} /> + <circle cx="58" cy="15" r="2.5" className="fill-warn" /> + {/* head, and the face on its screen */} + <rect + x="26" + y="24" + width="44" + height="34" + rx="10" + className="fill-surface stroke-ink-2" + strokeWidth={2} + /> + <path d="M38 35 L43 33 M58 35 L53 33" className="stroke-ink-2" strokeWidth={2} /> + <circle cx="41" cy="40" r="2.2" className="fill-ink-2" /> + <circle cx="55" cy="40" r="2.2" className="fill-ink-2" /> + <path d="M42 50 Q48 45 54 50" className="stroke-ink-2" strokeWidth={2} /> + {/* striped prison body */} + <rect + x="34" + y="62" + width="28" + height="22" + rx="6" + className="fill-surface stroke-ink-2" + strokeWidth={2} + /> + <path d="M34 69 H62 M34 76 H62" className="stroke-ink-3" strokeWidth={2} /> + {/* the bars, in front of him */} + <path + d="M14 8 V88 M31 8 V88 M65 8 V88 M82 8 V88 M8 8 H88 M8 88 H88" + className="stroke-ink-3" + strokeWidth={2.5} + /> + </svg> + ); +} diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 9286b9af..3ece53dc 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -328,9 +328,23 @@ export function Settings({ onChange={ageDisplay => set({ ageDisplay })} /> </Field> + <Field + label="PR jail" + hint="Pops up a list of your open PRs when you have too many, or one has been open too long. Off hides it and its header badge." + > + <Segmented + ariaLabel="PR jail" + value={s.jailOn ? 'on' : 'off'} + options={[ + ['off', 'Off'], + ['on', 'On'], + ]} + onChange={v => set({ jailOn: v === 'on' })} + /> + </Field> <Field label="PR jail: open PRs" - hint="PR jail drops over the board when you have more than this many open PRs of your own (bots and deploy holds never count). It comes back only when that gets worse, at most every 4 hours." + hint="PR jail pops up when you have more than this many open PRs of your own (bots and deploy holds never count). It comes back only when that gets worse, at most every 4 hours." > <NumberField value={s.jailMaxOpen} @@ -342,7 +356,7 @@ export function Settings({ </Field> <Field label="PR jail: days open" - hint="It also drops when any of them has been open longer than this." + hint="It also pops up when any of them has been open longer than this." > <NumberField value={s.jailMaxDays} @@ -368,7 +382,7 @@ export function Settings({ </Field> <Field label="See PR jail" - hint="Drops it now with your open PRs, whatever the limits say." + hint="Shows it now with your open PRs, whatever the limits say." > <QuietButton onClick={() => { diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index 2a7f14e2..b7ac6704 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -115,7 +115,9 @@ export interface Settings { * or age tooltip) before it opens, so brushing the pointer across the board * doesn't flash panels open. 0 = open instantly; a click always bypasses it. */ hoverDelayMs: number; - /** PR jail drops when you have more than this many open PRs of your own */ + /** PR jail pops up at all; off hides it and its header badge */ + jailOn: boolean; + /** PR jail pops up when you have more than this many open PRs of your own */ jailMaxOpen: number; /** ...or when any of them has been open longer than this many days */ jailMaxDays: number; @@ -174,6 +176,7 @@ export const DEFAULT_SETTINGS: Settings = { repoQueueCap: 15, openPrsNewTab: true, hoverDelayMs: 250, + jailOn: true, jailMaxOpen: JAIL_MAX_OPEN, jailMaxDays: JAIL_MAX_DAYS, jailCountDrafts: false, diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index a019fa83..e6bb49a5 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -1,8 +1,4 @@ @import "tailwindcss"; -/* PR jail's side-by-side layout (list left, cell right) needs a wide, - landscape screen: narrower or portrait, the cell's cover-scaling pushes the - robot under the list or off the right edge, so it stacks like a phone */ -@custom-variant jail-wide (@media (min-width: 900px) and (min-aspect-ratio: 11/10)); /* --sweep must be a registered <angle> for the CI ring's share to GLIDE between values (an unregistered custom property snaps). Top level on @@ -1010,446 +1006,3 @@ input[type="checkbox"] { font-size: 13px; font-weight: 600; } - -/* ── PR jail (components/JailMode.tsx): the cell drops from above, lands - with a heavy bounce and a small screen shake, and lifts away once the - key turns. Transform only. ── */ -/* The art's colors (assets/jail/*.svg use these through style="") and the - back wall. Light: steel bars on the board's own surface, a yellow door, - no glow. Dark: the neon sign, toned down to sit with the dark tokens. */ -.jail { - --jail-wall-lit: var(--surface); - --jail-wall-dim: var(--secondary); - --jail-mortar: rgb(15 23 42 / 0.045); - --jail-edge: #64748b; - --jail-rail: #94a3b8; - --jail-rail-hi: #cbd5e1; - --jail-board: #e2e8f0; - --jail-hw: #cbd5e1; - --jail-bar: #cbd5e1; - --jail-bar-core: #f8fafc; - --jail-cross: var(--brand); - --jail-cross-core: var(--brand-100); - --jail-door: #eab308; - --jail-door-core: #fef9c3; - --jail-sign: #854d0e; - --jail-hole: var(--ink); - --jail-glow: 0; - /* the key's halo, the guide arrow and the lock's ring: what you can touch */ - --jail-accent: var(--brand); - --jail-halo: 0.3; -} -.dark .jail { - --jail-wall-lit: var(--muted); - --jail-wall-dim: var(--canvas); - --jail-mortar: rgb(255 255 255 / 0.025); - --jail-edge: var(--canvas); - --jail-rail: var(--surface); - --jail-rail-hi: var(--muted); - --jail-board: var(--surface); - --jail-hw: var(--secondary); - --jail-bar: #4cc3dc; - --jail-bar-core: #c9f3fb; - --jail-cross: #df7fb0; - --jail-cross-core: #f8dcea; - --jail-door: #e6cb45; - --jail-door-core: #fbf6d6; - --jail-sign: #fbf6d6; - --jail-hole: #020617; - --jail-glow: 0.6; - --jail-accent: var(--jail-cross); - --jail-halo: 0.35; -} -.jail-scrim { - background: - repeating-linear-gradient(to bottom, transparent 0 46px, var(--jail-mortar) 46px 48px), - radial-gradient(ellipse at 60% 45%, var(--jail-wall-lit) 0%, var(--jail-wall-dim) 75%); -} -.jail-glow { - opacity: var(--jail-glow); -} -/* inmate-r03 is drawn twice: its tally wall behind the bars, the robot - behind the door */ -.jail-tally [id$='body'], -.jail-tally [id$='head'], -.jail-tally ellipse, -.jail-inmate [id$='tally'] { - display: none; -} -.jail-art svg { - display: block; - width: 100%; - height: 100%; -} -/* the cell art covers the screen like background-size: cover */ -.jail-scene { - position: absolute; - left: 50%; - top: 50%; - width: max(100vw, 150vh); - aspect-ratio: 3 / 2; - transform: translate(-50%, -50%); -} -/* on a wide screen (jail-wide) the cell fills it, moved right so the robot - stands in the clear bay beside the PR list */ -.jail-frame { - position: absolute; - inset: 0; -} -@media (min-width: 900px) and (min-aspect-ratio: 11/10) { - .jail-scene { - left: calc(50% + 12vw); - } -} -/* on a phone or tablet the list comes first and the cell scrolls in below it, - cropped to the tally wall, the robot and the door (x 470 to 980 of the - 1200-wide art) */ -@media not all and (min-width: 900px) and (min-aspect-ratio: 11/10) { - .jail-frame { - position: relative; - flex-shrink: 0; - width: 100%; - aspect-ratio: 510 / 800; - overflow: hidden; - } - .jail-scene { - left: calc(-470 / 510 * 100%); - top: 0; - width: calc(1200 / 510 * 100%); - transform: none; - } -} -/* the padlock is the lock; the cell's own plate would peek out under it */ -.jail-scene [id$='lockplate'] { - display: none; -} -@keyframes jail-drop { - 0% { - transform: translateY(-110%); - animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45); - } - 55% { - transform: translateY(0); - animation-timing-function: ease-out; - } - 70% { - transform: translateY(-5%); - animation-timing-function: ease-in; - } - 83% { - transform: translateY(0); - animation-timing-function: ease-out; - } - 91% { - transform: translateY(-1.2%); - animation-timing-function: ease-in; - } - 100% { - transform: translateY(0); - } -} -@keyframes jail-shake { - 20% { - translate: -7px 4px; - } - 40% { - translate: 6px -3px; - } - 60% { - translate: -4px 2px; - } - 80% { - translate: 2px -1px; - } -} -@keyframes jail-lift { - to { - transform: translateY(-110%); - } -} -@keyframes jail-fade-in { - from { - opacity: 0; - } -} -@keyframes jail-fade-out { - to { - opacity: 0; - } -} -.jail { - /* lands at 55% of the drop */ - animation: jail-shake 320ms ease-out 500ms; -} -.jail-scrim { - animation: jail-fade-in 300ms ease-out; -} -.jail-stage { - animation: jail-drop 900ms both; -} -.jail-stage.is-leaving { - animation: jail-lift 650ms cubic-bezier(0.5, 0, 0.75, 0) both; -} -.jail:has(.is-leaving) .jail-scrim { - animation: jail-fade-out 650ms ease-in both; -} -/* the key springs home after a miss, and snaps then turns on a hit */ -.jail-key { - transform-origin: 50% 85%; - transition: translate 450ms cubic-bezier(0.34, 1.56, 0.64, 1); -} -.jail-key.is-dragging { - transition: none; - scale: 1.08; -} -.jail-key.is-turning { - rotate: 90deg; - transition: - translate 220ms ease-out, - rotate 300ms ease-in-out 240ms; -} -/* what says "drag the key to the lock": a halo behind the key (it travels - with it), a slow sway like it hangs on a hook, a dashed arrow down, and a - ring on the lock that brightens while you drag and goes solid when near */ -.jail-key::before { - content: ''; - position: absolute; - inset: -12% -55%; - z-index: -1; - border-radius: 50%; - background: radial-gradient( - closest-side, - color-mix(in srgb, var(--jail-accent) calc(var(--jail-halo) * 100%), transparent), - transparent - ); - pointer-events: none; -} -.jail-key.is-turning::before { - opacity: 0; - transition: opacity 200ms; -} -@keyframes jail-sway { - 0%, - 100% { - rotate: -3deg; - } - 50% { - rotate: 3deg; - } -} -.jail-key-art { - transform-origin: 50% 4%; - animation: jail-sway 3s ease-in-out infinite; - transition: - translate 150ms ease-out, - filter 150ms ease-out; -} -.jail-key.is-dragging .jail-key-art, -.jail-key.is-turning .jail-key-art { - animation: none; -} -.jail-key:is(:hover, :focus-visible):not(.is-dragging, .is-turning) .jail-key-art { - translate: 0 -2px; - filter: drop-shadow(0 4px 6px rgb(15 23 42 / 0.3)); -} -.jail-key:focus-visible { - outline: 2px solid var(--brand); - outline-offset: 3px; -} -.jail-guide { - overflow: visible; - stroke: var(--jail-accent); - stroke-width: 3; - stroke-linecap: round; - stroke-linejoin: round; - opacity: 0.8; -} -.jail-guide-case { - stroke: var(--jail-wall-lit); - stroke-width: 7; -} -.jail-guide-line { - stroke-dasharray: 6 7; - animation: jail-march 1.2s linear infinite; -} -@keyframes jail-march { - to { - stroke-dashoffset: -13; - } -} -.jail-ring { - border: 2px dashed color-mix(in srgb, var(--jail-accent) 45%, transparent); - background: radial-gradient( - closest-side, - color-mix(in srgb, var(--jail-accent) calc(var(--jail-halo) * 100%), transparent), - transparent - ); - animation: jail-pulse 2.4s ease-in-out infinite; - transition: - scale 150ms ease-out, - border-color 150ms, - opacity 200ms; -} -@keyframes jail-pulse { - 50% { - scale: 1.06; - opacity: 0.6; - } -} -.jail-ring.is-dragging { - border-color: color-mix(in srgb, var(--jail-accent) 80%, transparent); -} -.jail-ring.is-near { - animation: none; - scale: 1.15; - border-style: solid; - border-color: var(--jail-accent); -} -.jail-ring.is-done { - animation: none; - opacity: 0; -} -/* the pipeline runs: the commit dots down the key's shaft light in turn, - toward the bit and the lock, and the commits around the keyhole chase - round while the keyhole beats. Near the lock it rattles; a miss gets a - head shake. */ -@keyframes jail-run { - 0%, - 60%, - 100% { - opacity: 0.35; - scale: 1; - } - 20% { - opacity: 1; - scale: 1.6; - } -} -.jail-key.is-live .jail-key-art circle { - transform-box: fill-box; - transform-origin: center; - animation: jail-run 1.5s ease-in-out infinite; -} -.jail-key.is-live .jail-key-art circle:nth-of-type(2) { - animation-delay: 0.25s; -} -.jail-key.is-live .jail-key-art circle:nth-of-type(3) { - animation-delay: 0.5s; -} -@keyframes jail-chase { - 0%, - 30%, - 100% { - fill: var(--jail-rail); - } - 8% { - fill: var(--jail-cross); - } -} -.jail-lock.is-live #jl-body > g:first-of-type circle { - animation: jail-chase 1.8s linear infinite; -} -.jail-lock.is-live #jl-body > g:first-of-type circle:nth-of-type(2) { - animation-delay: 0.3s; -} -.jail-lock.is-live #jl-body > g:first-of-type circle:nth-of-type(3) { - animation-delay: 0.6s; -} -.jail-lock.is-live #jl-body > g:first-of-type circle:nth-of-type(4) { - animation-delay: 0.9s; -} -.jail-lock.is-live #jl-body > g:first-of-type circle:nth-of-type(5) { - animation-delay: 1.2s; -} -.jail-lock.is-live #jl-body > g:first-of-type circle:nth-of-type(6) { - animation-delay: 1.5s; -} -@keyframes jail-beat { - 0%, - 40%, - 100% { - filter: drop-shadow(0 0 0 transparent); - } - 12%, - 28% { - filter: drop-shadow(0 0 6px var(--jail-accent)); - } -} -.jail-lock.is-live #jl-keyhole { - animation: jail-beat 1.6s ease-out infinite; -} -@keyframes jail-rattle { - 25% { - rotate: -4deg; - } - 75% { - rotate: 4deg; - } -} -.jail-lock { - transform-origin: 50% 20%; -} -.jail-lock.is-near { - animation: jail-rattle 180ms linear infinite; -} -@keyframes jail-nope { - 20%, - 60% { - translate: -6px 0; - } - 40%, - 80% { - translate: 6px 0; - } -} -.jail-lock.is-nope { - animation: jail-nope 400ms ease-in-out; -} -.jail-key.is-live:is(:hover, :focus-visible):not(.is-dragging) .jail-key-art { - filter: drop-shadow(0 4px 6px rgb(15 23 42 / 0.3)) brightness(1.08); -} -/* once turned, the key sinks into the keyhole so it clears the cheering robot */ -.jail-key.is-in { - opacity: 0; - scale: 0.4; - transition: - opacity 250ms ease-in, - scale 250ms ease-in; -} -.jail-lock #jl-shackle { - transform-box: fill-box; - transform-origin: 0% 100%; - transition: transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1); -} -.jail-lock.is-open #jl-shackle { - transform: translateY(-14%) rotate(-28deg); -} -@keyframes jail-cheer { - from { - transform: translateY(6%) scale(0.92); - } -} -.jail-cheer { - animation: jail-cheer 420ms cubic-bezier(0.34, 1.56, 0.64, 1); -} -/* reduced motion: no drop or lift, just a fade. !important and a class - selector beat the global reduced-motion rule above. */ -@media (prefers-reduced-motion: reduce) { - .jail, - .jail-cheer, - .jail-key-art, - .jail-guide-line, - .jail-ring, - .jail-key-art circle, - .jail-lock, - .jail-lock circle, - .jail-lock #jl-keyhole { - animation: none !important; - } - .jail-stage { - animation: jail-fade-in 250ms ease-out both !important; - } - .jail-stage.is-leaving { - animation: jail-fade-out 400ms ease-in both !important; - } -} From b8604163d30b523a3bc192012508d3bc03e200da Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 16:16:43 -0700 Subject: [PATCH 05/16] Split PR jail's switch into Automatic, Manual and Off Some people want the header lock without the pop-up: they'd rather check their PRs when they choose to. The single on/off switch couldn't say that, since Off hid the lock too. Now the PR jail setting has three choices: Automatic pops it up when you're over the limits, Manual only shows the lock to open when you like, and Off hides both. Anyone who already picked Off keeps it. The trash can at the bottom becomes a snooze clock labeled "Snooze". Dragging your PR list into a trash can read like throwing the PRs away; snooze is what it really does, since jail comes back when things get worse. The hint under the list now says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.test.tsx | 24 +++++--- frontend-v2/src/components/JailMode.tsx | 58 +++++++++++--------- frontend-v2/src/components/Settings.tsx | 9 +-- frontend-v2/src/settings.ts | 15 ++++- 4 files changed, 66 insertions(+), 40 deletions(-) diff --git a/frontend-v2/src/components/JailMode.test.tsx b/frontend-v2/src/components/JailMode.test.tsx index e67b534f..9d08166a 100644 --- a/frontend-v2/src/components/JailMode.test.tsx +++ b/frontend-v2/src/components/JailMode.test.tsx @@ -59,7 +59,7 @@ afterEach(() => { act(() => root?.unmount()); root = null; localStorage.clear(); - setSettings({ jailOn: true }); + setSettings({ jailMode: 'auto' }); }); function render(pulls: DerivedPull[]) { @@ -99,22 +99,30 @@ describe('JailMode while the cell is down', () => { expect(why()).toBe('8 open PRs. Parole at 7. Merge your ready one and you’re out.'); }); + it('only shows the badge when PR jail is manual', () => { + setSettings({ jailMode: 'manual' }); + render(many(9)); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + const badge = document.querySelector<HTMLButtonElement>('[aria-label^="PR jail"]'); + expect(badge).not.toBeNull(); + act(() => badge?.click()); + expect(why()).toMatch(/^9 open PRs/); + }); + it('stays away, badge and all, when PR jail is off', () => { - setSettings({ jailOn: false }); + setSettings({ jailMode: 'off' }); render(many(9)); expect(document.querySelector('[role="dialog"]')).toBeNull(); expect(document.querySelector('[aria-label^="PR jail"]')).toBeNull(); }); - it('goes away when the trash is pressed from the keyboard', () => { + it('goes away when the clock is pressed from the keyboard', () => { render(many(8)); - const trash = document.querySelector<HTMLButtonElement>( - '[aria-label="Throw PR jail in the trash"]' - ); + const clock = document.querySelector<HTMLButtonElement>('[aria-label="Snooze PR jail"]'); // a keyboard press clicks with detail 0; a mouse click does nothing - act(() => trash?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }))); + act(() => clock?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }))); expect(document.querySelector('[role="dialog"]')).not.toBeNull(); - act(() => trash?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 }))); + act(() => clock?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 }))); expect(document.querySelector('[role="dialog"]')).toBeNull(); }); }); diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index b0bdec78..253a9d6c 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type PointerEvent } from 'react'; import { createPortal } from 'react-dom'; -import { Lock, Trash2 } from 'lucide-react'; +import { AlarmClock, Lock } from 'lucide-react'; import type { DerivedPull } from '../../../shared/model/status'; import { githubUrl } from '../../../shared/format'; import { rowNote, rowWord } from '../model/actions'; @@ -23,10 +23,11 @@ import { CiGlyph } from './pips'; /** * PR jail: when your own open PRs pass a limit (model/jail.ts), a modal pops * up listing them, and comes back only when that gets worse (jailDue). It - * has no close button: you drag it to the trash at the bottom of the screen - * (or press the trash from the keyboard). While you're over, a header badge - * reopens it. `#jail=1` in the URL forces it, any time, for a preview; the - * jailOn setting turns the whole thing off. + * has no close button: you drag it onto the snooze clock at the bottom of + * the screen (or press the clock from the keyboard). It takes a little work + * on purpose, so it's never an Esc away. While you're over, a header badge + * reopens it. `#jail=1` in the URL forces it, any time, for a preview. The + * jailMode setting picks: pop up (auto), the badge alone (manual), or off. */ const hasPreviewFlag = () => new URLSearchParams(location.hash.slice(1)).get('jail') === '1'; @@ -56,7 +57,7 @@ export function JailMode({ const found = useMemo( () => - initialized && settings.jailOn + initialized && settings.jailMode !== 'off' ? jailCase(pulls, me, extraBots, { maxOpen: settings.jailMaxOpen, maxDays: settings.jailMaxDays, @@ -101,7 +102,7 @@ export function JailMode({ // hidden tab or a focused field waits for the next one, so it never lands // mid-typing or out of sight useEffect(() => { - if (!found || shown) return; + if (!found || shown || settings.jailMode !== 'auto') return; const check = () => { if (document.visibilityState === 'hidden') return; if (document.activeElement?.matches('input, textarea, select')) return; @@ -115,7 +116,7 @@ export function JailMode({ document.removeEventListener('visibilitychange', check); window.removeEventListener('focus', check); }; - }, [found, shown]); + }, [found, shown, settings.jailMode]); return ( <> @@ -156,7 +157,7 @@ export function JailMode({ /** Merged or closed your way under the limits while the cell was down. */ const FREE: JailCase<DerivedPull> = { pulls: [], - why: 'You’re under the limits now. Drag this to the trash and get back to work.', + why: 'You’re under the limits now. Drag this onto the clock and get back to work.', count: 0, over: [], }; @@ -188,7 +189,7 @@ function sampleCase( } /** The modal over the board. The only way out is dragging it onto the - * trash; a keyboard or screen reader presses the trash instead. */ + * snooze clock; a keyboard or screen reader presses the clock instead. */ function JailModal({ jail, me, @@ -201,17 +202,17 @@ function JailModal({ onDone: () => void; }) { const rootRef = useRef<HTMLDivElement>(null); - const trashRef = useRef<HTMLButtonElement>(null); + const clockRef = useRef<HTMLButtonElement>(null); const drag = useRef<{ id: number; x: number; y: number } | null>(null); const [offset, setOffset] = useState({ x: 0, y: 0 }); const [dragging, setDragging] = useState(false); - // the card is over the trash: the trash says "let go here" + // the card is over the clock: the clock says "let go here" const [over, setOver] = useState(false); useEffect(() => { const root = rootRef.current; root?.focus({ preventScroll: true }); - // Esc doesn't close it (the trash does); Tab stays inside + // Esc doesn't close it (the clock does); Tab stays inside const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') e.preventDefault(); if (e.key !== 'Tab' || !root) return; @@ -239,9 +240,9 @@ function JailModal({ }; }, []); - /** Whether the pointer is over the trash, with some slop. */ - const onTrash = (x: number, y: number) => { - const t = trashRef.current?.getBoundingClientRect(); + /** Whether the pointer is over the clock, with some slop. */ + const onClock = (x: number, y: number) => { + const t = clockRef.current?.getBoundingClientRect(); const slop = 32; return Boolean( t && x > t.left - slop && x < t.right + slop && y > t.top - slop && y < t.bottom + slop @@ -259,14 +260,14 @@ function JailModal({ const d = drag.current; if (d?.id !== e.pointerId) return; setOffset({ x: e.clientX - d.x, y: e.clientY - d.y }); - setOver(onTrash(e.clientX, e.clientY)); + setOver(onClock(e.clientX, e.clientY)); }; const onPointerUp = (e: PointerEvent<HTMLElement>) => { if (drag.current?.id !== e.pointerId) return; drag.current = null; setDragging(false); setOver(false); - if (onTrash(e.clientX, e.clientY)) onDone(); + if (onClock(e.clientX, e.clientY)) onDone(); else setOffset({ x: 0, y: 0 }); }; @@ -286,7 +287,7 @@ function JailModal({ } ${over ? 'scale-90 opacity-70' : ''}`} style={{ translate: `${offset.x}px ${offset.y}px` }} > - {/* the handle: drag the card from its top, down to the trash */} + {/* the handle: drag the card from its top, down to the clock */} <div className={`flex touch-none items-center gap-3 select-none ${ dragging ? 'cursor-grabbing' : 'cursor-grab' @@ -310,25 +311,32 @@ function JailModal({ <JailList pulls={jail.pulls} me={me} maxDays={maxDays} /> </div> <p className="m-0 border-t border-line pt-3 text-xs text-ink-3"> - Drag this card into the trash below to put it away. + Drag this card onto the clock below to snooze it. It comes back if things get worse, + at most every 4 hours. </p> </section> <button - ref={trashRef} + ref={clockRef} type="button" - aria-label="Throw PR jail in the trash" + aria-label="Snooze PR jail" // keyboard and screen readers click with detail 0; a mouse has to // drag the card here onClick={e => e.detail === 0 && onDone()} className={`fixed bottom-6 left-1/2 grid size-16 -translate-x-1/2 cursor-default place-items-center rounded-full border-2 border-dashed shadow-lg transition-colors duration-150 motion-reduce:transition-none ${ over - ? 'scale-110 border-bad bg-bad text-surface' + ? 'scale-110 border-brand bg-brand text-surface' : dragging - ? 'border-bad bg-surface text-bad' + ? 'border-brand bg-surface text-brand' : 'border-line bg-surface text-ink-3' }`} > - <Trash2 size={26} aria-hidden /> + <AlarmClock size={26} aria-hidden /> + <span + aria-hidden + className="absolute top-full mt-1 rounded-full bg-surface px-1.5 text-[11px] font-medium text-ink-2" + > + Snooze + </span> </button> </div> ); diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 3ece53dc..e117aa53 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -330,16 +330,17 @@ export function Settings({ </Field> <Field label="PR jail" - hint="Pops up a list of your open PRs when you have too many, or one has been open too long. Off hides it and its header badge." + hint="Automatic pops up your list of open PRs when you have too many, or one has been open too long. Manual only shows the lock in the header, to open when you like. Off hides both." > <Segmented ariaLabel="PR jail" - value={s.jailOn ? 'on' : 'off'} + value={s.jailMode} options={[ + ['auto', 'Automatic'], + ['manual', 'Manual'], ['off', 'Off'], - ['on', 'On'], ]} - onChange={v => set({ jailOn: v === 'on' })} + onChange={jailMode => set({ jailMode })} /> </Field> <Field diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index b7ac6704..b8686fbe 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -115,8 +115,9 @@ export interface Settings { * or age tooltip) before it opens, so brushing the pointer across the board * doesn't flash panels open. 0 = open instantly; a click always bypasses it. */ hoverDelayMs: number; - /** PR jail pops up at all; off hides it and its header badge */ - jailOn: boolean; + /** PR jail: 'auto' pops it up when you're over the limits, 'manual' only + * shows the header lock (click to open), 'off' shows neither */ + jailMode: 'auto' | 'manual' | 'off'; /** PR jail pops up when you have more than this many open PRs of your own */ jailMaxOpen: number; /** ...or when any of them has been open longer than this many days */ @@ -176,13 +177,21 @@ export const DEFAULT_SETTINGS: Settings = { repoQueueCap: 15, openPrsNewTab: true, hoverDelayMs: 250, - jailOn: true, + jailMode: 'auto', jailMaxOpen: JAIL_MAX_OPEN, jailMaxDays: JAIL_MAX_DAYS, jailCountDrafts: false, }; const store = createPersistentStore('pd2.settings', DEFAULT_SETTINGS); +// jailOn (an on/off switch, live for a day) became jailMode; an Off carries over +{ + const saved = store.get() as Settings & { jailOn?: boolean }; + if (saved.jailOn !== undefined) { + const { jailOn, ...rest } = saved; + store.set({ ...rest, jailMode: jailOn ? rest.jailMode : 'off' }); + } +} /** Union of every personal roster, sorted — "your people": the set that * leads the review queues and wears the teammate corner heart. */ From 250442cdbeacc075f97198e8335fc78bbac7c158 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 16:25:29 -0700 Subject: [PATCH 06/16] Snooze PR jail by holding a key, and count down to free Dragging the card onto a clock was the only way past the jail without fixing anything, and it didn't work the same on a phone or a keyboard. The point is a little friction, not a puzzle. 1) Snooze is now a hold: press and hold the row at the bottom for 2 seconds, with a pointer, a finger, Enter or Space. A small line-drawn padlock shows it: the key in the keyhole wiggles while it waits, turns a quarter turn as you hold while a ring around it fills, and the shackle pops open at the end. Letting go early springs it back. A tap, a click or Esc does nothing. 2) The jail lets you go by itself once you're under the limits: it says "You're out. Nice work." and closes after a couple of seconds. 3) A countdown to freedom: "6 to go" next to the title and on the header badge, enough PRs to get under the count limit plus every one past the age limit, ticking down live as you merge or close. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.test.tsx | 41 ++- frontend-v2/src/components/JailMode.tsx | 278 ++++++++++++------- frontend-v2/src/model/jail.test.ts | 10 + frontend-v2/src/model/jail.ts | 10 +- frontend-v2/src/styles.css | 28 ++ 5 files changed, 257 insertions(+), 110 deletions(-) diff --git a/frontend-v2/src/components/JailMode.test.tsx b/frontend-v2/src/components/JailMode.test.tsx index 9d08166a..d2dcddbd 100644 --- a/frontend-v2/src/components/JailMode.test.tsx +++ b/frontend-v2/src/components/JailMode.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { derive, type DerivedPull } from '../../../shared/model/status'; import type { PullData } from '../../../shared/types'; import { setSettings } from '../settings'; @@ -60,6 +60,7 @@ afterEach(() => { root = null; localStorage.clear(); setSettings({ jailMode: 'auto' }); + vi.useRealTimers(); }); function render(pulls: DerivedPull[]) { @@ -69,6 +70,8 @@ function render(pulls: DerivedPull[]) { const why = () => document.getElementById('jail-why')?.textContent; const listed = () => document.querySelectorAll('[role="dialog"] li').length; +const dialog = () => document.querySelector('[role="dialog"]'); +const countdown = () => document.querySelector('#jail-title span')?.textContent; const headers = () => [...document.querySelectorAll('[role="dialog"] h3')].map(h => h.textContent); describe('JailMode while the cell is down', () => { @@ -83,10 +86,16 @@ describe('JailMode while the cell is down', () => { expect(why()).toMatch(/^9 open PRs/); expect(listed()).toBe(9); - // merges bring you under the limits: the cell says so + expect(countdown()).toBe('2 to go'); + + // merges bring you under the limits: it says so, then lets you go + vi.useFakeTimers(); render(first.slice(0, 3)); - expect(why()).toMatch(/under the limits/); + expect(why()).toBe('You’re out. Nice work.'); + expect(countdown()).toBe('Free'); expect(listed()).toBe(0); + act(() => vi.advanceTimersByTime(2500)); + expect(dialog()).toBeNull(); }); it('lifts ready PRs to the top and counts them in the why line', () => { @@ -116,13 +125,25 @@ describe('JailMode while the cell is down', () => { expect(document.querySelector('[aria-label^="PR jail"]')).toBeNull(); }); - it('goes away when the clock is pressed from the keyboard', () => { + it('snoozes only when Snooze is held down, by key or pointer', () => { + vi.useFakeTimers(); render(many(8)); - const clock = document.querySelector<HTMLButtonElement>('[aria-label="Snooze PR jail"]'); - // a keyboard press clicks with detail 0; a mouse click does nothing - act(() => clock?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }))); - expect(document.querySelector('[role="dialog"]')).not.toBeNull(); - act(() => clock?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 }))); - expect(document.querySelector('[role="dialog"]')).toBeNull(); + const snooze = [ + ...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button'), + ].find(b => b.textContent?.includes('Hold to snooze')); + const key = (type: string, k = 'Enter') => + act(() => snooze?.dispatchEvent(new KeyboardEvent(type, { key: k, bubbles: true }))); + // a tap of Enter, or Esc, does nothing + key('keydown'); + act(() => vi.advanceTimersByTime(500)); + key('keyup'); + act(() => vi.advanceTimersByTime(3000)); + act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))); + expect(dialog()).not.toBeNull(); + // holding Space the full two seconds turns the key, then it opens + key('keydown', ' '); + act(() => vi.advanceTimersByTime(2000)); + act(() => vi.advanceTimersByTime(400)); + expect(dialog()).toBeNull(); }); }); diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index 253a9d6c..1924b024 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useRef, useState, type PointerEvent } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { AlarmClock, Lock } from 'lucide-react'; +import { Lock } from 'lucide-react'; import type { DerivedPull } from '../../../shared/model/status'; import { githubUrl } from '../../../shared/format'; import { rowNote, rowWord } from '../model/actions'; @@ -23,11 +23,11 @@ import { CiGlyph } from './pips'; /** * PR jail: when your own open PRs pass a limit (model/jail.ts), a modal pops * up listing them, and comes back only when that gets worse (jailDue). It - * has no close button: you drag it onto the snooze clock at the bottom of - * the screen (or press the clock from the keyboard). It takes a little work - * on purpose, so it's never an Esc away. While you're over, a header badge - * reopens it. `#jail=1` in the URL forces it, any time, for a preview. The - * jailMode setting picks: pop up (auto), the badge alone (manual), or off. + * has no close button and Esc does nothing: you hold Snooze down for a + * couple of seconds (a little work on purpose), or get under the limits and + * it lets you go. While you're over, a header badge counts down what's left + * and reopens it. `#jail=1` in the URL forces it, any time, for a preview. + * The jailMode setting picks: pop up (auto), the badge alone (manual), or off. */ const hasPreviewFlag = () => new URLSearchParams(location.hash.slice(1)).get('jail') === '1'; @@ -118,17 +118,25 @@ export function JailMode({ }; }, [found, shown, settings.jailMode]); + // you got under the limits while it was up: it says so, then lets you go + const freed = shown === 'real' && !found; + useEffect(() => { + if (!freed) return; + const t = window.setTimeout(() => setShown(null), FREED_MS); + return () => clearTimeout(t); + }, [freed]); + return ( <> {found && ( <HeaderIconButton icon={Lock} - label={`PR jail: ${found.why}`} + label={`PR jail, ${found.toGo} to go: ${found.why}`} onClick={() => show(found)} className="relative" > <span className="absolute -top-1 -right-1 grid h-4 min-w-[16px] place-items-center rounded-full bg-warn px-1 text-[10px] leading-none font-semibold text-surface tabular-nums"> - {found.pulls.length > 9 ? '9+' : found.pulls.length} + {found.toGo > 9 ? '9+' : found.toGo} </span> </HeaderIconButton> )} @@ -154,12 +162,16 @@ export function JailMode({ ); } -/** Merged or closed your way under the limits while the cell was down. */ +/** How long "You're out" shows before the jail closes on its own. */ +const FREED_MS = 2500; + +/** Merged or closed your way under the limits while it was up. */ const FREE: JailCase<DerivedPull> = { pulls: [], - why: 'You’re under the limits now. Drag this onto the clock and get back to work.', + why: 'You’re out. Nice work.', count: 0, over: [], + toGo: 0, }; /** The preview when you're not in jail: your PRs as jail counts them (any @@ -185,11 +197,18 @@ function sampleCase( why: 'Preview: these are sample PRs from the board', count: 0, over: [], + toGo: 0, }; } -/** The modal over the board. The only way out is dragging it onto the - * snooze clock; a keyboard or screen reader presses the clock instead. */ +/** How long the snooze button has to be held down. */ +const HOLD_MS = 2000; +/** How long the open lock shows before the jail closes. */ +const OPEN_MS = 400; + +/** The modal over the board. No close button and no Esc: the only way out + * besides getting under the limits is holding Snooze down for a couple of + * seconds, by pointer, finger, Enter or Space. */ function JailModal({ jail, me, @@ -202,17 +221,12 @@ function JailModal({ onDone: () => void; }) { const rootRef = useRef<HTMLDivElement>(null); - const clockRef = useRef<HTMLButtonElement>(null); - const drag = useRef<{ id: number; x: number; y: number } | null>(null); - const [offset, setOffset] = useState({ x: 0, y: 0 }); - const [dragging, setDragging] = useState(false); - // the card is over the clock: the clock says "let go here" - const [over, setOver] = useState(false); + const free = jail === FREE; useEffect(() => { const root = rootRef.current; root?.focus({ preventScroll: true }); - // Esc doesn't close it (the clock does); Tab stays inside + // Esc doesn't close it (holding Snooze does); Tab stays inside const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') e.preventDefault(); if (e.key !== 'Tab' || !root) return; @@ -240,37 +254,6 @@ function JailModal({ }; }, []); - /** Whether the pointer is over the clock, with some slop. */ - const onClock = (x: number, y: number) => { - const t = clockRef.current?.getBoundingClientRect(); - const slop = 32; - return Boolean( - t && x > t.left - slop && x < t.right + slop && y > t.top - slop && y < t.bottom + slop - ); - }; - - const onPointerDown = (e: PointerEvent<HTMLElement>) => { - // links in the header stay clickable - if ((e.target as HTMLElement).closest('a')) return; - drag.current = { id: e.pointerId, x: e.clientX - offset.x, y: e.clientY - offset.y }; - setDragging(true); - e.currentTarget.setPointerCapture?.(e.pointerId); - }; - const onPointerMove = (e: PointerEvent<HTMLElement>) => { - const d = drag.current; - if (d?.id !== e.pointerId) return; - setOffset({ x: e.clientX - d.x, y: e.clientY - d.y }); - setOver(onClock(e.clientX, e.clientY)); - }; - const onPointerUp = (e: PointerEvent<HTMLElement>) => { - if (drag.current?.id !== e.pointerId) return; - drag.current = null; - setDragging(false); - setOver(false); - if (onClock(e.clientX, e.clientY)) onDone(); - else setOffset({ x: 0, y: 0 }); - }; - return ( <div ref={rootRef} @@ -279,66 +262,163 @@ function JailModal({ aria-modal="true" aria-labelledby="jail-title" aria-describedby="jail-why" - className="fixed inset-0 z-[200] flex flex-col items-center overflow-y-auto bg-ink/40 px-4 pt-[8vh] pb-32 outline-none" + className="fixed inset-0 z-[200] flex flex-col items-center overflow-y-auto bg-ink/40 px-4 pt-[8vh] pb-8 outline-none" > - <section - className={`flex max-h-[calc(100dvh-8vh-9rem)] w-full max-w-[30rem] flex-col gap-3 rounded-xl border border-line bg-surface p-4 text-ink shadow-2xl ${ - dragging ? '' : 'transition-transform duration-200 motion-reduce:transition-none' - } ${over ? 'scale-90 opacity-70' : ''}`} - style={{ translate: `${offset.x}px ${offset.y}px` }} - > - {/* the handle: drag the card from its top, down to the clock */} - <div - className={`flex touch-none items-center gap-3 select-none ${ - dragging ? 'cursor-grabbing' : 'cursor-grab' - }`} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerCancel={onPointerUp} - > + <section className="flex max-h-[calc(100dvh-8vh-2rem)] w-full max-w-[30rem] flex-col gap-3 rounded-xl border border-line bg-surface p-4 text-ink shadow-2xl"> + <div className="flex items-center gap-3"> <SadRobot /> - <div className="min-w-0"> - <h2 id="jail-title" className="m-0 text-lg font-semibold"> + <div className="min-w-0 flex-1"> + <h2 id="jail-title" className="m-0 flex items-center gap-2 text-lg font-semibold"> PR jail + {/* the countdown to freedom: ticks down live as you merge + or close */} + <span + className={`rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ${ + free ? 'bg-ok/15 text-ok' : 'bg-warn/15 text-warn' + }`} + > + {free ? 'Free' : `${jail.toGo} to go`} + </span> </h2> - <p id="jail-why" className="m-0 text-[13px] text-ink-2"> + <p id="jail-why" className="m-0 text-[13px] text-ink-2" aria-live="polite"> {jail.why} </p> </div> </div> - <div className="min-h-0 overflow-y-auto"> - <JailList pulls={jail.pulls} me={me} maxDays={maxDays} /> - </div> - <p className="m-0 border-t border-line pt-3 text-xs text-ink-3"> - Drag this card onto the clock below to snooze it. It comes back if things get worse, - at most every 4 hours. - </p> + {!free && ( + <> + <div className="min-h-0 overflow-y-auto"> + <JailList pulls={jail.pulls} me={me} maxDays={maxDays} /> + </div> + <HoldToSnooze onDone={onDone} /> + </> + )} </section> - <button - ref={clockRef} - type="button" - aria-label="Snooze PR jail" - // keyboard and screen readers click with detail 0; a mouse has to - // drag the card here - onClick={e => e.detail === 0 && onDone()} - className={`fixed bottom-6 left-1/2 grid size-16 -translate-x-1/2 cursor-default place-items-center rounded-full border-2 border-dashed shadow-lg transition-colors duration-150 motion-reduce:transition-none ${ - over - ? 'scale-110 border-brand bg-brand text-surface' - : dragging - ? 'border-brand bg-surface text-brand' - : 'border-line bg-surface text-ink-3' - }`} + </div> + ); +} + +/** Snooze, but only for someone who means it: hold the row down for + * HOLD_MS, by pointer, finger, Enter or Space. The key in the lock turns a + * quarter turn as you hold and the ring around it fills, both off the same + * clock; letting go early springs them back. At the end the shackle pops + * open and the jail closes. */ +function HoldToSnooze({ onDone }: { onDone: () => void }) { + const [holding, setHolding] = useState(false); + const [opened, setOpened] = useState(false); + const timer = useRef<number | undefined>(undefined); + const start = () => { + if (timer.current !== undefined || opened) return; + setHolding(true); + timer.current = window.setTimeout(() => { + setOpened(true); + timer.current = window.setTimeout(onDone, OPEN_MS); + }, HOLD_MS); + }; + const stop = () => { + if (opened) return; + clearTimeout(timer.current); + timer.current = undefined; + setHolding(false); + }; + useEffect(() => () => clearTimeout(timer.current), []); + const isPress = (key: string) => key === 'Enter' || key === ' '; + // the ring and the key share one timing: fill and turn while held, snap + // back when let go + const sweep = holding ? `${HOLD_MS}ms linear` : '150ms ease-out'; + return ( + <button + type="button" + aria-describedby="jail-snooze-hint" + onPointerDown={e => { + e.currentTarget.setPointerCapture?.(e.pointerId); + start(); + }} + onPointerUp={stop} + onPointerCancel={stop} + onKeyDown={e => { + if (!isPress(e.key)) return; + e.preventDefault(); + if (!e.repeat) start(); + }} + onKeyUp={e => isPress(e.key) && stop()} + onBlur={stop} + onContextMenu={e => e.preventDefault()} + className="pressable group flex w-full touch-none items-center gap-3 rounded-lg border-t border-line pt-3 text-left select-none [-webkit-touch-callout:none]" + > + <svg + aria-hidden + viewBox="0 0 48 48" + className="size-12 shrink-0" + fill="none" + strokeLinecap="round" + strokeLinejoin="round" > - <AlarmClock size={26} aria-hidden /> - <span - aria-hidden - className="absolute top-full mt-1 rounded-full bg-surface px-1.5 text-[11px] font-medium text-ink-2" + {/* the ring: a quiet track, filling with brand as you hold */} + <circle cx="24" cy="24" r="22" className="stroke-line" strokeWidth={2} /> + <circle + cx="24" + cy="24" + r="22" + pathLength={1} + strokeDasharray="1" + className="stroke-brand" + strokeWidth={2} + transform="rotate(-90 24 24)" + style={{ + strokeDashoffset: holding ? 0 : 1, + transition: `stroke-dashoffset ${sweep}`, + }} + /> + {/* the shackle, which pops up once the key has turned */} + <path + d="M18 22 V18 a6 6 0 0 1 12 0 V22" + className="stroke-ink-2" + strokeWidth={2.5} + style={{ + translate: opened ? '0 -4px' : '0 0', + transition: 'translate 200ms ease-out', + }} + /> + <rect + x="14" + y="22" + width="20" + height="15" + rx="4" + className="fill-surface stroke-ink-2" + strokeWidth={2} + /> + {/* the key's bow in the keyhole: it wiggles while it waits for + you, and turns a quarter turn as you hold */} + <g + className={holding || opened ? '' : 'jail-key-wiggle'} + style={{ transformOrigin: '24px 29.5px' }} > - Snooze + <rect + x="22.5" + y="25" + width="3" + height="9" + rx="1.5" + className="fill-brand" + style={{ + transformOrigin: '24px 29.5px', + rotate: holding ? '90deg' : '0deg', + transition: `rotate ${sweep}`, + }} + /> + </g> + </svg> + <span className="min-w-0"> + <span className="block text-[13px] font-semibold text-ink group-hover:text-brand"> + {opened ? 'Snoozed' : holding ? 'Keep holding…' : 'Hold to snooze'} </span> - </button> - </div> + <span id="jail-snooze-hint" className="block text-xs text-ink-3"> + It comes back if things get worse, at most every 4 hours. + </span> + </span> + </button> ); } diff --git a/frontend-v2/src/model/jail.test.ts b/frontend-v2/src/model/jail.test.ts index 2ab3ffd1..0069a243 100644 --- a/frontend-v2/src/model/jail.test.ts +++ b/frontend-v2/src/model/jail.test.ts @@ -74,6 +74,14 @@ describe('jailCase', () => { ); }); + it('counts what’s left to close, the old ones included', () => { + // 9 open (2 over the limit) and 3 old: closing the 3 old ones frees you + const pulls = [...many(6), pull('me', 20), pull('me', 30), pull('me', 40)]; + expect(jailCase(pulls, 'me', NO_BOTS, LIMITS)?.toGo).toBe(3); + // 12 open (5 over) and 1 old: 5 to go, the old one among them + expect(jailCase([...many(11), pull('me', 20)], 'me', NO_BOTS, LIMITS)?.toGo).toBe(5); + }); + it('says merge when the one old PR is ready, and counts several', () => { const old = pull('me', 30, { ready: true }); expect(jailCase([old], 'me', NO_BOTS, LIMITS)?.why).toBe( @@ -95,6 +103,7 @@ describe('jailCase', () => { expect(c?.pulls[0].ageDays).toBe(9); expect(c?.why).toBe('8 open PRs. Parole at 7. Close 1 to get out.'); expect(c?.count).toBe(8); + expect(c?.toGo).toBe(1); }); it('jails one PR past the age limit, and lists only the old ones', () => { @@ -106,6 +115,7 @@ describe('jailCase', () => { ); expect(c?.count).toBe(3); expect(c?.over).toEqual([`iFixit/ifixit#${old.data.number}`]); + expect(c?.toGo).toBe(1); }); it('ignores other people’s PRs', () => { diff --git a/frontend-v2/src/model/jail.ts b/frontend-v2/src/model/jail.ts index f6d5507a..65f5504b 100644 --- a/frontend-v2/src/model/jail.ts +++ b/frontend-v2/src/model/jail.ts @@ -47,6 +47,10 @@ export interface JailCase<P extends JailPull> { count: number; /** repo#number of each counted PR past the age limit */ over: string[]; + /** how many PRs you still have to merge or close to be free: enough to + * get under the count limit, and every one past the age limit (closing an + * old one counts toward both) */ + toGo: number; } const idOf = (p: JailPull) => `${p.data.repo}#${p.data.number}`; @@ -75,7 +79,11 @@ export function jailCase<P extends JailPull>( ) .sort(oldestFirst); const old = mine.filter(p => p.ageDays > limits.maxDays); - const counts = { count: mine.length, over: old.map(idOf) }; + const counts = { + count: mine.length, + over: old.map(idOf), + toGo: Math.max(mine.length - limits.maxOpen, old.length), + }; if (mine.length > limits.maxOpen) { const why = countWhy( mine.length, diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index e6bb49a5..371b8db2 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -1006,3 +1006,31 @@ input[type="checkbox"] { font-size: 13px; font-weight: 600; } + +/* PR jail's snooze key (components/JailMode.tsx): a slow wiggle in the + keyhole while it waits to be held. Transform only, and still when motion + is reduced. */ +@keyframes jail-key-wiggle { + 0%, + 60%, + 100% { + rotate: 0deg; + } + 70% { + rotate: -14deg; + } + 80% { + rotate: 12deg; + } + 90% { + rotate: -6deg; + } +} +.jail-key-wiggle { + animation: jail-key-wiggle 2.8s ease-in-out infinite; +} +@media (prefers-reduced-motion: reduce) { + .jail-key-wiggle { + animation: none; + } +} From 35c48d8c1a4d570a3f6ec08ea1d6ba037227d7c6 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 16:33:38 -0700 Subject: [PATCH 07/16] Open PR jail's lock, and give its list room to read The jail's list was tight: 13px rows two pixels apart, the move and the detail run together with dots, and the snooze lock just closed the card when you finished holding it. 1) Finishing the hold opens the lock: the shackle lifts out of the body and swings over its right leg to the other side, and the ring and the key turn green, before the jail closes. 2) Focus starts on "Hold to snooze", so holding Enter or Space works the moment it opens. 3) More room: a wider card with more padding, rows split by hairlines with real space between, 14px titles, ages in days, and the next step as a small pill instead of a dot-separated word. Each group says what to do with it ("Each of these is waiting on you"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.test.tsx | 4 +- frontend-v2/src/components/JailMode.tsx | 117 ++++++++++++------- frontend-v2/src/styles.css | 22 +++- 3 files changed, 99 insertions(+), 44 deletions(-) diff --git a/frontend-v2/src/components/JailMode.test.tsx b/frontend-v2/src/components/JailMode.test.tsx index d2dcddbd..7679b52e 100644 --- a/frontend-v2/src/components/JailMode.test.tsx +++ b/frontend-v2/src/components/JailMode.test.tsx @@ -131,6 +131,8 @@ describe('JailMode while the cell is down', () => { const snooze = [ ...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button'), ].find(b => b.textContent?.includes('Hold to snooze')); + // it has focus from the start, so the keyboard can hold it right away + expect(document.activeElement).toBe(snooze); const key = (type: string, k = 'Enter') => act(() => snooze?.dispatchEvent(new KeyboardEvent(type, { key: k, bubbles: true }))); // a tap of Enter, or Esc, does nothing @@ -143,7 +145,7 @@ describe('JailMode while the cell is down', () => { // holding Space the full two seconds turns the key, then it opens key('keydown', ' '); act(() => vi.advanceTimersByTime(2000)); - act(() => vi.advanceTimersByTime(400)); + act(() => vi.advanceTimersByTime(700)); expect(dialog()).toBeNull(); }); }); diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index 1924b024..1c811886 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -204,7 +204,7 @@ function sampleCase( /** How long the snooze button has to be held down. */ const HOLD_MS = 2000; /** How long the open lock shows before the jail closes. */ -const OPEN_MS = 400; +const OPEN_MS = 700; /** The modal over the board. No close button and no Esc: the only way out * besides getting under the limits is holding Snooze down for a couple of @@ -225,7 +225,10 @@ function JailModal({ useEffect(() => { const root = rootRef.current; - root?.focus({ preventScroll: true }); + // focus starts on Snooze, so holding Enter or Space works right away; + // the dialog itself when it's showing "You're out" + const snooze = root?.querySelector<HTMLElement>('[data-snooze]'); + (snooze ?? root)?.focus({ preventScroll: true }); // Esc doesn't close it (holding Snooze does); Tab stays inside const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') e.preventDefault(); @@ -264,11 +267,14 @@ function JailModal({ aria-describedby="jail-why" className="fixed inset-0 z-[200] flex flex-col items-center overflow-y-auto bg-ink/40 px-4 pt-[8vh] pb-8 outline-none" > - <section className="flex max-h-[calc(100dvh-8vh-2rem)] w-full max-w-[30rem] flex-col gap-3 rounded-xl border border-line bg-surface p-4 text-ink shadow-2xl"> - <div className="flex items-center gap-3"> + <section className="flex max-h-[calc(100dvh-8vh-2rem)] w-full max-w-[34rem] flex-col gap-5 rounded-2xl border border-line bg-surface p-5 text-ink shadow-2xl sm:p-6"> + <div className="flex items-center gap-4"> <SadRobot /> <div className="min-w-0 flex-1"> - <h2 id="jail-title" className="m-0 flex items-center gap-2 text-lg font-semibold"> + <h2 + id="jail-title" + className="m-0 flex items-center gap-2.5 text-xl font-semibold" + > PR jail {/* the countdown to freedom: ticks down live as you merge or close */} @@ -280,17 +286,23 @@ function JailModal({ {free ? 'Free' : `${jail.toGo} to go`} </span> </h2> - <p id="jail-why" className="m-0 text-[13px] text-ink-2" aria-live="polite"> + <p + id="jail-why" + className="m-0 mt-1 text-sm leading-snug text-ink-2" + aria-live="polite" + > {jail.why} </p> </div> </div> {!free && ( <> - <div className="min-h-0 overflow-y-auto"> + <div className="-mx-1 min-h-0 overflow-y-auto px-1"> <JailList pulls={jail.pulls} me={me} maxDays={maxDays} /> </div> - <HoldToSnooze onDone={onDone} /> + <div className="border-t border-line pt-3"> + <HoldToSnooze onDone={onDone} /> + </div> </> )} </section> @@ -344,7 +356,8 @@ function HoldToSnooze({ onDone }: { onDone: () => void }) { onKeyUp={e => isPress(e.key) && stop()} onBlur={stop} onContextMenu={e => e.preventDefault()} - className="pressable group flex w-full touch-none items-center gap-3 rounded-lg border-t border-line pt-3 text-left select-none [-webkit-touch-callout:none]" + data-snooze + className="pressable group -mx-2 flex touch-none items-center gap-4 rounded-lg px-2 py-2 text-left select-none [-webkit-touch-callout:none] hover:bg-secondary" > <svg aria-hidden @@ -362,7 +375,7 @@ function HoldToSnooze({ onDone }: { onDone: () => void }) { r="22" pathLength={1} strokeDasharray="1" - className="stroke-brand" + className={opened ? 'stroke-ok' : 'stroke-brand'} strokeWidth={2} transform="rotate(-90 24 24)" style={{ @@ -370,15 +383,12 @@ function HoldToSnooze({ onDone }: { onDone: () => void }) { transition: `stroke-dashoffset ${sweep}`, }} /> - {/* the shackle, which pops up once the key has turned */} + {/* the shackle: once the key has turned it lifts out of the + body and swings over its right leg to the other side */} <path d="M18 22 V18 a6 6 0 0 1 12 0 V22" - className="stroke-ink-2" + className={`stroke-ink-2 ${opened ? 'jail-shackle-open' : ''}`} strokeWidth={2.5} - style={{ - translate: opened ? '0 -4px' : '0 0', - transition: 'translate 200ms ease-out', - }} /> <rect x="14" @@ -401,7 +411,7 @@ function HoldToSnooze({ onDone }: { onDone: () => void }) { width="3" height="9" rx="1.5" - className="fill-brand" + className={opened ? 'fill-ok' : 'fill-brand'} style={{ transformOrigin: '24px 29.5px', rotate: holding ? '90deg' : '0deg', @@ -411,10 +421,10 @@ function HoldToSnooze({ onDone }: { onDone: () => void }) { </g> </svg> <span className="min-w-0"> - <span className="block text-[13px] font-semibold text-ink group-hover:text-brand"> + <span className="block text-sm font-semibold text-ink"> {opened ? 'Snoozed' : holding ? 'Keep holding…' : 'Hold to snooze'} </span> - <span id="jail-snooze-hint" className="block text-xs text-ink-3"> + <span id="jail-snooze-hint" className="mt-0.5 block text-xs leading-relaxed text-ink-3"> It comes back if things get worse, at most every 4 hours. </span> </span> @@ -423,31 +433,48 @@ function HoldToSnooze({ onDone }: { onDone: () => void }) { } /** Your PRs, the way My work groups them, with the ready ones on top: the - * quickest way out first. Someone else's PRs (the preview's fallback) list - * flat, since their groups would be a reviewer's. */ + * quickest way out first. Each group says what to do with it, then lists + * oldest first. Someone else's PRs (the preview's fallback) list flat, since + * their groups would be a reviewer's. */ function JailList({ pulls, me, maxDays }: { pulls: DerivedPull[]; me: string; maxDays: number }) { const mine = pulls.every(p => p.data.user.login === me); const groups = mine ? jailGroups(pulls, me) : { ready: [], move: [], waiting: pulls.map(pull => ({ pull })) }; const sections = [ - { title: STAGE_WORDS.ready, tone: 'text-brand-700', rows: groups.ready, ready: true }, - { title: 'Your move', tone: 'text-brand-700', rows: groups.move }, - { title: 'Waiting on others', tone: 'text-ink-3', rows: groups.waiting }, + { + title: STAGE_WORDS.ready, + tone: 'text-brand-700', + hint: 'Quickest way out. Merge these first.', + rows: groups.ready, + ready: true, + }, + { + title: 'Your move', + tone: 'text-brand-700', + hint: 'Each of these is waiting on you.', + rows: groups.move, + }, + { + title: 'Waiting on others', + tone: 'text-ink-3', + hint: 'Nudge whoever has it, or close the ones you no longer need.', + rows: groups.waiting, + }, ].filter(g => g.rows.length); return ( - <div className="flex flex-col gap-3"> + <div className="flex flex-col gap-6"> {sections.map(g => ( <section key={g.title} aria-label={mine ? g.title : undefined}> {mine && ( - <h3 className={`m-0 ${eyebrowText} ${g.tone}`}> - {g.title} <span className="text-ink-3 tabular-nums">· {g.rows.length}</span> - </h3> - )} - {g.ready && ( - <p className="m-0 text-xs text-ink-3">Quickest way out. Merge these first.</p> + <div className="mb-1"> + <h3 className={`m-0 ${eyebrowText} ${g.tone}`}> + {g.title} <span className="text-ink-3 tabular-nums">· {g.rows.length}</span> + </h3> + <p className="m-0 mt-0.5 text-xs text-ink-3">{g.hint}</p> + </div> )} - <ul className="m-0 mt-1.5 flex list-none flex-col gap-2 p-0"> + <ul className="m-0 flex list-none flex-col divide-y divide-line p-0"> {g.rows.map(({ pull }) => ( <JailRow key={`${pull.data.repo}#${pull.data.number}`} @@ -465,7 +492,8 @@ function JailList({ pulls, me, maxDays }: { pulls: DerivedPull[]; me: string; ma ); } -/** One PR: the title to GitHub and its age, then what it's waiting on. */ +/** One PR: the title to GitHub and its age, then the move and what it's + * waiting on. */ function JailRow({ pull: p, me, @@ -486,31 +514,36 @@ function JailRow({ // stands in when there's no detail const move = word?.kind === 'do' ? word.word : null; const detail = context ?? (word?.kind === 'wait' ? word.word : null); + const old = p.ageDays > maxDays; return ( - <li className="flex flex-col text-[13px]"> - <span className="flex items-baseline gap-3"> + <li className="flex flex-col gap-1.5 py-3"> + <span className="flex items-baseline gap-4"> <a href={githubUrl(p.data.repo, p.data.number)} target="_blank" rel="noopener noreferrer" - className="min-w-0 flex-1 font-medium text-ink hover:underline" + className="min-w-0 flex-1 text-sm leading-snug font-medium text-ink hover:underline" > {p.data.title} <span aria-hidden>↗</span> </a> <span className={`shrink-0 text-xs tabular-nums ${ - p.ageDays > maxDays ? 'font-semibold text-warn' : 'text-ink-3' + old ? 'font-semibold text-warn' : 'text-ink-3' }`} - title={`Open ${p.ageDays} days`} + title={`Open ${p.ageDays} days${old ? `, past the ${maxDays}-day limit` : ''}`} > - {p.ageDays ? `${p.ageDays}d` : '<1d'} + {p.ageDays ? `${p.ageDays} days` : 'today'} </span> </span> - <span className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-xs text-ink-3"> + <span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-3"> <RepoRef repo={p.data.repo} number={p.data.number} /> - {move && <span className="font-medium whitespace-nowrap text-brand-700">· {move}</span>} + {move && ( + <span className="rounded-md bg-brand/10 px-1.5 py-0.5 font-medium whitespace-nowrap text-brand-700"> + {move} + </span> + )} {!ready && <CiGlyph pull={p} />} - {detail && <span className="min-w-0 truncate">· {detail}</span>} + {detail && <span className="min-w-0">{detail}</span>} </span> </li> ); diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 371b8db2..6a0f6650 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -1029,8 +1029,28 @@ input[type="checkbox"] { .jail-key-wiggle { animation: jail-key-wiggle 2.8s ease-in-out infinite; } +/* ...and the shackle opening once it has turned: up out of the body, then + over its right leg (x 30) to the other side, like a padlock swinging + open. Reduced motion lands on the open lock with no swing. */ +@keyframes jail-shackle-open { + from { + transform: translateY(0) scaleX(1); + } + 35% { + transform: translateY(-5px) scaleX(1); + } + to { + transform: translateY(-5px) scaleX(-1); + } +} +.jail-shackle-open { + transform-origin: 30px 18px; + transform: translateY(-5px) scaleX(-1); + animation: jail-shackle-open 600ms cubic-bezier(0.22, 1, 0.36, 1); +} @media (prefers-reduced-motion: reduce) { - .jail-key-wiggle { + .jail-key-wiggle, + .jail-shackle-open { animation: none; } } From 38d1651bedfbd92bd4b8f4226bc45ccab1bc965b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 16:42:21 -0700 Subject: [PATCH 08/16] Rework PR jail into one clear button and the board's own look "Hold to snooze" was our word, not the user's, and the jail had grown its own styles (uppercase eyebrows, hint lines, a subtext under the button) when the board already has a header system people know. 1) The button says what happens: "Hold to get back to work", full width with the lock at its side. A quick click says "Hold it down", so the gesture teaches itself; the subtext is gone (Settings already says when it comes back). Done, it reads "Unlocked". 2) Groups use the board's GroupHeader (title, a short note, the count on the right), pinned to the top of the list as you scroll. 3) Size: 40rem on a desktop so titles fit one line, at most 80% of the screen tall; a sheet up from the bottom on a phone, with the button at thumb height. The header and the button stay put and only the list scrolls. 4) "Copy a nudge" on Waiting on others copies a Slack-ready list of those PRs, their links, and who each one waits on. 5) A PR that merges or closes while the jail is up slides out of the list as the countdown ticks, instead of vanishing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.test.tsx | 42 +++- frontend-v2/src/components/JailMode.tsx | 214 +++++++++++++------ frontend-v2/src/styles.css | 18 ++ 3 files changed, 205 insertions(+), 69 deletions(-) diff --git a/frontend-v2/src/components/JailMode.test.tsx b/frontend-v2/src/components/JailMode.test.tsx index 7679b52e..5e0e5a84 100644 --- a/frontend-v2/src/components/JailMode.test.tsx +++ b/frontend-v2/src/components/JailMode.test.tsx @@ -101,7 +101,7 @@ describe('JailMode while the cell is down', () => { it('lifts ready PRs to the top and counts them in the why line', () => { const ready = pull(2, { ready: true }); render([...many(7), ready]); - expect(headers()).toEqual(['Ready to merge · 1', 'Waiting on others · 7']); + expect(headers()).toEqual(['Ready to merge', 'Waiting on others']); expect(document.querySelector('[role="dialog"] li a')?.textContent).toContain( ready.data.title ); @@ -125,20 +125,21 @@ describe('JailMode while the cell is down', () => { expect(document.querySelector('[aria-label^="PR jail"]')).toBeNull(); }); - it('snoozes only when Snooze is held down, by key or pointer', () => { + it('lets you out only when the button is held down, by key or pointer', () => { vi.useFakeTimers(); render(many(8)); const snooze = [ ...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button'), - ].find(b => b.textContent?.includes('Hold to snooze')); + ].find(b => b.textContent?.includes('Hold to get back to work')); // it has focus from the start, so the keyboard can hold it right away expect(document.activeElement).toBe(snooze); const key = (type: string, k = 'Enter') => act(() => snooze?.dispatchEvent(new KeyboardEvent(type, { key: k, bubbles: true }))); - // a tap of Enter, or Esc, does nothing + // a tap of Enter does nothing but say to hold it; neither does Esc key('keydown'); - act(() => vi.advanceTimersByTime(500)); + act(() => vi.advanceTimersByTime(200)); key('keyup'); + expect(snooze?.textContent).toBe('Hold it down'); act(() => vi.advanceTimersByTime(3000)); act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))); expect(dialog()).not.toBeNull(); @@ -148,4 +149,35 @@ describe('JailMode while the cell is down', () => { act(() => vi.advanceTimersByTime(700)); expect(dialog()).toBeNull(); }); + + it('slides a merged PR out of the list as the countdown ticks', () => { + vi.useFakeTimers(); + const nine = many(9); + render(nine); + expect(countdown()).toBe('2 to go'); + render(nine.slice(1)); + expect(countdown()).toBe('1 to go'); + // still there for the slide, then gone + expect(document.querySelectorAll('.jail-row-leave')).toHaveLength(1); + expect(listed()).toBe(9); + act(() => vi.advanceTimersByTime(450)); + expect(listed()).toBe(8); + }); + + it('copies a nudge listing the PRs waiting on others', async () => { + const writeText = vi.fn(() => Promise.resolve()); + Object.assign(navigator, { clipboard: { writeText } }); + const first = pull(3); + render([first, ...many(7)]); + const copy = [...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')].find( + b => b.textContent === 'Copy a nudge' + ); + await act(async () => copy?.click()); + const text = (writeText.mock.calls[0] as unknown as [string])[0]; + expect(text.split('\n')).toHaveLength(9); + expect(text).toContain( + `• ${first.data.title} https://github.com/iFixit/ifixit/pull/${first.data.number}` + ); + expect(copy?.textContent).toBe('Copied'); + }); }); diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index 1c811886..7360958d 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -16,8 +16,8 @@ import { } from '../model/jail'; import { useSettings } from '../settings'; import { readStorage, writeStorage } from '../storage'; -import { HeaderIconButton, RepoRef } from './bits'; -import { eyebrowText } from './Lane'; +import { HeaderIconButton, QuietButton, RepoRef } from './bits'; +import { GroupHeader } from './Lane'; import { CiGlyph } from './pips'; /** @@ -201,13 +201,17 @@ function sampleCase( }; } -/** How long the snooze button has to be held down. */ +/** How long "Hold to get back to work" has to be held down. */ const HOLD_MS = 2000; /** How long the open lock shows before the jail closes. */ const OPEN_MS = 700; +/** How long a merged or closed row takes to leave the list. */ +const LEAVE_MS = 450; -/** The modal over the board. No close button and no Esc: the only way out - * besides getting under the limits is holding Snooze down for a couple of +/** The modal over the board: a centered card on a desktop, a sheet up from + * the bottom on a phone. Its header and its one button stay put while the + * list scrolls between them. No close button and no Esc: the way out besides + * getting under the limits is holding the button down for a couple of * seconds, by pointer, finger, Enter or Space. */ function JailModal({ jail, @@ -225,11 +229,11 @@ function JailModal({ useEffect(() => { const root = rootRef.current; - // focus starts on Snooze, so holding Enter or Space works right away; - // the dialog itself when it's showing "You're out" - const snooze = root?.querySelector<HTMLElement>('[data-snooze]'); - (snooze ?? root)?.focus({ preventScroll: true }); - // Esc doesn't close it (holding Snooze does); Tab stays inside + // focus starts on the button, so holding Enter or Space works right + // away; the dialog itself when it's showing "You're out" + const hold = root?.querySelector<HTMLElement>('[data-hold]'); + (hold ?? root)?.focus({ preventScroll: true }); + // Esc doesn't close it (holding the button does); Tab stays inside const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') e.preventDefault(); if (e.key !== 'Tab' || !root) return; @@ -265,9 +269,9 @@ function JailModal({ aria-modal="true" aria-labelledby="jail-title" aria-describedby="jail-why" - className="fixed inset-0 z-[200] flex flex-col items-center overflow-y-auto bg-ink/40 px-4 pt-[8vh] pb-8 outline-none" + className="fixed inset-0 z-[200] flex flex-col items-center justify-end bg-ink/40 pt-8 outline-none sm:justify-start sm:px-4 sm:pt-[8vh] sm:pb-8" > - <section className="flex max-h-[calc(100dvh-8vh-2rem)] w-full max-w-[34rem] flex-col gap-5 rounded-2xl border border-line bg-surface p-5 text-ink shadow-2xl sm:p-6"> + <section className="flex max-h-full w-full flex-col gap-5 rounded-t-2xl border border-line bg-surface p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] text-ink shadow-2xl sm:max-h-[80vh] sm:max-w-[40rem] sm:rounded-2xl sm:p-6"> <div className="flex items-center gap-4"> <SadRobot /> <div className="min-w-0 flex-1"> @@ -297,12 +301,13 @@ function JailModal({ </div> {!free && ( <> - <div className="-mx-1 min-h-0 overflow-y-auto px-1"> + {/* the board's own sticky group headers, pinned to the top + of this list on the card's color instead of under the + app header on the canvas */} + <div className="-mx-1 min-h-0 overflow-y-auto px-1 [--canvas:var(--surface)] [--header-h:0px]"> <JailList pulls={jail.pulls} me={me} maxDays={maxDays} /> </div> - <div className="border-t border-line pt-3"> - <HoldToSnooze onDone={onDone} /> - </div> + <HoldToLeave onDone={onDone} /> </> )} </section> @@ -310,30 +315,47 @@ function JailModal({ ); } -/** Snooze, but only for someone who means it: hold the row down for - * HOLD_MS, by pointer, finger, Enter or Space. The key in the lock turns a - * quarter turn as you hold and the ring around it fills, both off the same - * clock; letting go early springs them back. At the end the shackle pops - * open and the jail closes. */ -function HoldToSnooze({ onDone }: { onDone: () => void }) { +/** The one button: hold it down for HOLD_MS, by pointer, finger, Enter or + * Space. The key in the lock turns a quarter turn as you hold and the ring + * around it fills, both off the same clock; letting go early springs them + * back, and a quick click says to hold it. At the end the lock opens and + * the jail closes. */ +function HoldToLeave({ onDone }: { onDone: () => void }) { const [holding, setHolding] = useState(false); const [opened, setOpened] = useState(false); + // a click too short to be a hold: the label teaches the gesture + const [tapped, setTapped] = useState(false); const timer = useRef<number | undefined>(undefined); + const tapTimer = useRef<number | undefined>(undefined); + const since = useRef(0); const start = () => { if (timer.current !== undefined || opened) return; + since.current = Date.now(); setHolding(true); + setTapped(false); timer.current = window.setTimeout(() => { setOpened(true); timer.current = window.setTimeout(onDone, OPEN_MS); }, HOLD_MS); }; const stop = () => { - if (opened) return; + if (opened || timer.current === undefined) return; clearTimeout(timer.current); timer.current = undefined; setHolding(false); + if (Date.now() - since.current < 500) { + setTapped(true); + clearTimeout(tapTimer.current); + tapTimer.current = window.setTimeout(() => setTapped(false), 2000); + } }; - useEffect(() => () => clearTimeout(timer.current), []); + useEffect( + () => () => { + clearTimeout(timer.current); + clearTimeout(tapTimer.current); + }, + [] + ); const isPress = (key: string) => key === 'Enter' || key === ' '; // the ring and the key share one timing: fill and turn while held, snap // back when let go @@ -341,7 +363,6 @@ function HoldToSnooze({ onDone }: { onDone: () => void }) { return ( <button type="button" - aria-describedby="jail-snooze-hint" onPointerDown={e => { e.currentTarget.setPointerCapture?.(e.pointerId); start(); @@ -356,13 +377,13 @@ function HoldToSnooze({ onDone }: { onDone: () => void }) { onKeyUp={e => isPress(e.key) && stop()} onBlur={stop} onContextMenu={e => e.preventDefault()} - data-snooze - className="pressable group -mx-2 flex touch-none items-center gap-4 rounded-lg px-2 py-2 text-left select-none [-webkit-touch-callout:none] hover:bg-secondary" + data-hold + className="pressable flex min-h-14 w-full shrink-0 touch-none items-center justify-center gap-3 rounded-xl border border-line px-4 py-2 select-none [-webkit-touch-callout:none] hover:border-brand hover:bg-secondary" > <svg aria-hidden viewBox="0 0 48 48" - className="size-12 shrink-0" + className="size-10 shrink-0" fill="none" strokeLinecap="round" strokeLinejoin="round" @@ -420,46 +441,99 @@ function HoldToSnooze({ onDone }: { onDone: () => void }) { /> </g> </svg> - <span className="min-w-0"> - <span className="block text-sm font-semibold text-ink"> - {opened ? 'Snoozed' : holding ? 'Keep holding…' : 'Hold to snooze'} - </span> - <span id="jail-snooze-hint" className="mt-0.5 block text-xs leading-relaxed text-ink-3"> - It comes back if things get worse, at most every 4 hours. - </span> + <span className="text-[15px] font-semibold text-ink" aria-live="polite"> + {opened + ? 'Unlocked' + : holding + ? 'Keep holding…' + : tapped + ? 'Hold it down' + : 'Hold to get back to work'} </span> </button> ); } +const pullId = (p: DerivedPull) => `${p.data.repo}#${p.data.number}`; + +/** The PRs that just left `pulls` (merged or closed), kept for LEAVE_MS so + * their rows can slide out instead of vanishing. */ +function useLeaving(pulls: DerivedPull[]): DerivedPull[] { + const last = useRef(new Map<string, DerivedPull>()); + const [leaving, setLeaving] = useState<DerivedPull[]>([]); + const timers = useRef<number[]>([]); + useEffect(() => { + const now = new Map(pulls.map(p => [pullId(p), p])); + const gone = [...last.current].filter(([id]) => !now.has(id)).map(([, p]) => p); + last.current = now; + if (!gone.length) return; + setLeaving(l => [...l, ...gone]); + timers.current.push( + window.setTimeout(() => setLeaving(l => l.filter(p => !gone.includes(p))), LEAVE_MS) + ); + }, [pulls]); + useEffect(() => () => timers.current.forEach(clearTimeout), []); + // one that came back (reopened) is just a row again + return leaving.filter(p => !pulls.some(q => pullId(q) === pullId(p))); +} + +/** Slack-ready text asking for the reviews your waiting PRs need: each + * title, its link, and who it waits on. */ +function nudgeText(rows: { pull: DerivedPull }[], me: string): string { + return [ + 'A few of my PRs are waiting on a review or a stamp:', + ...rows.map(({ pull: p }) => { + const who = rowNote(p, me).context; + return `• ${p.data.title} ${githubUrl(p.data.repo, p.data.number)}${ + who ? ` (${who})` : '' + }`; + }), + ].join('\n'); +} + +/** Copies nudgeText, and says whether it worked. */ +function CopyNudge({ rows, me }: { rows: { pull: DerivedPull }[]; me: string }) { + const [copied, setCopied] = useState<'copied' | 'failed' | null>(null); + const done = (result: 'copied' | 'failed') => { + setCopied(result); + window.setTimeout(() => setCopied(null), 3000); + }; + return ( + <QuietButton + onClick={() => { + if (!navigator.clipboard) return done('failed'); + navigator.clipboard.writeText(nudgeText(rows, me)).then( + () => done('copied'), + () => done('failed') + ); + }} + > + {copied === 'copied' ? 'Copied' : copied === 'failed' ? 'Couldn’t copy' : 'Copy a nudge'} + </QuietButton> + ); +} + /** Your PRs, the way My work groups them, with the ready ones on top: the - * quickest way out first. Each group says what to do with it, then lists - * oldest first. Someone else's PRs (the preview's fallback) list flat, since - * their groups would be a reviewer's. */ + * quickest way out first, each group under the board's own header with a + * word on what to do, oldest first. Someone else's PRs (the preview's + * fallback) list flat, since their groups would be a reviewer's. */ function JailList({ pulls, me, maxDays }: { pulls: DerivedPull[]; me: string; maxDays: number }) { - const mine = pulls.every(p => p.data.user.login === me); + const leaving = useLeaving(pulls); + const gone = new Set(leaving.map(pullId)); + const all = [...pulls, ...leaving]; + const mine = all.every(p => p.data.user.login === me); const groups = mine - ? jailGroups(pulls, me) - : { ready: [], move: [], waiting: pulls.map(pull => ({ pull })) }; + ? jailGroups(all, me) + : { ready: [], move: [], waiting: all.map(pull => ({ pull })) }; + const live = (rows: { pull: DerivedPull }[]) => rows.filter(r => !gone.has(pullId(r.pull))); const sections = [ - { - title: STAGE_WORDS.ready, - tone: 'text-brand-700', - hint: 'Quickest way out. Merge these first.', - rows: groups.ready, - ready: true, - }, - { - title: 'Your move', - tone: 'text-brand-700', - hint: 'Each of these is waiting on you.', - rows: groups.move, - }, + { title: STAGE_WORDS.ready, sub: 'Merge these first', rows: groups.ready, ready: true }, + { title: 'Your move', sub: 'Waiting on you', rows: groups.move }, { title: 'Waiting on others', - tone: 'text-ink-3', - hint: 'Nudge whoever has it, or close the ones you no longer need.', + sub: 'Nudge them, or close what you don’t need', rows: groups.waiting, + nudge: true, }, ].filter(g => g.rows.length); return ( @@ -467,22 +541,28 @@ function JailList({ pulls, me, maxDays }: { pulls: DerivedPull[]; me: string; ma {sections.map(g => ( <section key={g.title} aria-label={mine ? g.title : undefined}> {mine && ( - <div className="mb-1"> - <h3 className={`m-0 ${eyebrowText} ${g.tone}`}> - {g.title} <span className="text-ink-3 tabular-nums">· {g.rows.length}</span> - </h3> - <p className="m-0 mt-0.5 text-xs text-ink-3">{g.hint}</p> - </div> + <GroupHeader + level={3} + title={g.title} + sub={g.sub} + count={live(g.rows).length} + headerExtra={ + g.nudge && live(g.rows).length ? ( + <CopyNudge rows={live(g.rows)} me={me} /> + ) : undefined + } + /> )} <ul className="m-0 flex list-none flex-col divide-y divide-line p-0"> {g.rows.map(({ pull }) => ( <JailRow - key={`${pull.data.repo}#${pull.data.number}`} + key={pullId(pull)} pull={pull} me={me} maxDays={maxDays} ready={Boolean(g.ready)} mine={mine} + leaving={gone.has(pullId(pull))} /> ))} </ul> @@ -500,12 +580,15 @@ function JailRow({ maxDays, ready, mine, + leaving, }: { pull: DerivedPull; me: string; maxDays: number; ready: boolean; mine: boolean; + /** merged or closed: it slides out */ + leaving?: boolean; }) { const word = mine && !ready ? rowWord(p, me) : null; const context = mine ? rowNote(p, me).context : null; @@ -516,7 +599,10 @@ function JailRow({ const detail = context ?? (word?.kind === 'wait' ? word.word : null); const old = p.ageDays > maxDays; return ( - <li className="flex flex-col gap-1.5 py-3"> + <li + className={`flex flex-col gap-1.5 py-3 ${leaving ? 'jail-row-leave' : ''}`} + aria-hidden={leaving || undefined} + > <span className="flex items-baseline gap-4"> <a href={githubUrl(p.data.repo, p.data.number)} diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 6a0f6650..ff41a068 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -1048,9 +1048,27 @@ input[type="checkbox"] { transform: translateY(-5px) scaleX(-1); animation: jail-shackle-open 600ms cubic-bezier(0.22, 1, 0.36, 1); } +/* ...and a merged or closed PR's row sliding out of the list while the + countdown ticks. */ +@keyframes jail-row-leave { + to { + opacity: 0; + transform: translateX(1.5rem); + max-height: 0; + padding-block: 0; + } +} +.jail-row-leave { + max-height: 8rem; + overflow: hidden; + animation: jail-row-leave 450ms cubic-bezier(0.5, 0, 0.75, 0) forwards; +} @media (prefers-reduced-motion: reduce) { .jail-key-wiggle, .jail-shackle-open { animation: none; } + .jail-row-leave { + animation-duration: 1ms; + } } From 3c9fe0625106c8983c3281dfb30aa1e95fb62053 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 18:21:36 -0700 Subject: [PATCH 09/16] Fix PR jail's exits, preview and memory A review of the jail found: 1) Esc while previewing from Settings closed Settings behind the jail, and once you left the jail the page couldn't scroll until a reload. The jail now stops Esc before anything behind it sees it, and the preview closes Settings first. 2) The 2-second hold was the only way out, which shuts out screen readers, voice control and switch access: they send a single click. A click with no pointer or key behind it now lets them out. 3) Getting under the limits never cleared the last showing, so going over again later could stay silent. Being free now forgets it. 4) "Preview PR jail" worked once per page load; it's an event now. 5) Focus returns to where it was when the jail closes, and stays in the dialog while it says "You're out". 6) A PR that stopped counting (the rule switched, or it went draft) slid out as if merged; only a real close does now. Closing records the latest case, not the one from when the hold started. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.test.tsx | 46 +++++++++++ frontend-v2/src/components/JailMode.tsx | 84 +++++++++++++++++--- frontend-v2/src/components/Settings.tsx | 9 ++- 3 files changed, 125 insertions(+), 14 deletions(-) diff --git a/frontend-v2/src/components/JailMode.test.tsx b/frontend-v2/src/components/JailMode.test.tsx index 5e0e5a84..4a50989e 100644 --- a/frontend-v2/src/components/JailMode.test.tsx +++ b/frontend-v2/src/components/JailMode.test.tsx @@ -180,4 +180,50 @@ describe('JailMode while the cell is down', () => { ); expect(copy?.textContent).toBe('Copied'); }); + + it('lets a screen reader or voice control out with a lone click', () => { + render(many(8)); + const hold = [...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')].find( + b => b.textContent?.includes('Hold to get back to work') + ); + // a mouse click carries detail 1 and does nothing + act(() => hold?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }))); + expect(dialog()).not.toBeNull(); + // assistive tech sends a click with no pointer or key behind it + act(() => hold?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 }))); + expect(dialog()).toBeNull(); + }); + + it('keeps Esc from reaching anything behind it', () => { + render(many(8)); + let reached = false; + const behind = (e: KeyboardEvent) => { + if (!e.defaultPrevented) reached = true; + }; + document.addEventListener('keydown', behind); + act(() => { + document.body.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ); + }); + document.removeEventListener('keydown', behind); + expect(reached).toBe(false); + expect(dialog()).not.toBeNull(); + }); + + it('forgets the last showing once you are under the limits', () => { + vi.useFakeTimers(); + const nine = many(9); + render(nine); + expect(localStorage.getItem('pd2.jail')).not.toBeNull(); + render(nine.slice(0, 3)); + expect(localStorage.getItem('pd2.jail')).toBeNull(); + }); + + it('opens the preview every time Settings asks', () => { + render(many(2)); + expect(dialog()).toBeNull(); + act(() => window.dispatchEvent(new Event('pd2:preview-jail'))); + expect(dialog()).not.toBeNull(); + }); }); diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index 7360958d..308e20bc 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -15,7 +15,7 @@ import { type JailCase, } from '../model/jail'; import { useSettings } from '../settings'; -import { readStorage, writeStorage } from '../storage'; +import { readStorage, removeStorage, writeStorage } from '../storage'; import { HeaderIconButton, QuietButton, RepoRef } from './bits'; import { GroupHeader } from './Lane'; import { CiGlyph } from './pips'; @@ -67,6 +67,26 @@ export function JailMode({ [initialized, pulls, me, extraBots, settings] ); + // under the limits again: forget the last showing, so the next time you + // go over is a fresh offense, not "no worse than before" + useEffect(() => { + if (!initialized || found || settings.jailMode === 'off' || !record.current) return; + record.current = null; + removeStorage(JAIL_KEY); + }, [initialized, found, settings.jailMode]); + + // the latest case, for closing: the button's hold started seconds ago, and + // what you saw last is what the next showing compares against + const latest = useRef(found); + latest.current = found; + + // open PRs on the whole board, so a row leaving the jail's list only slides + // out as merged when it really closed (not when the rule switched) + const openIds = useMemo( + () => new Set(pulls.filter(p => p.data.state === 'open').map(pullId)), + [pulls] + ); + const remember = (jail: JailCase<DerivedPull>) => { record.current = jailRecord(jail, Date.now()); writeStorage(JAIL_KEY, JSON.stringify(record.current)); @@ -80,6 +100,10 @@ export function JailMode({ const onHash = () => { if (hasPreviewFlag()) setPreview(true); }; + // Settings' "Preview PR jail": an event, since setting the same hash + // twice fires nothing + const onPreview = () => setPreview(true); + window.addEventListener('pd2:preview-jail', onPreview); const onStorage = (e: StorageEvent) => { if (e.key === JAIL_KEY) record.current = parseJailRecord(e.newValue); }; @@ -87,6 +111,7 @@ export function JailMode({ window.addEventListener('storage', onStorage); return () => { window.removeEventListener('hashchange', onHash); + window.removeEventListener('pd2:preview-jail', onPreview); window.removeEventListener('storage', onStorage); }; }, []); @@ -149,10 +174,11 @@ export function JailMode({ } me={me} maxDays={settings.jailMaxDays} + openIds={openIds} onDone={() => { // what you saw last is what the next showing compares // against, so PRs opened while it was up don't re-drop it - if (shown === 'real' && found) remember(found); + if (shown === 'real' && latest.current) remember(latest.current); setShown(null); }} />, @@ -217,25 +243,39 @@ function JailModal({ jail, me, maxDays, + openIds, onDone, }: { jail: JailCase<DerivedPull>; me: string; maxDays: number; + openIds: ReadonlySet<string>; onDone: () => void; }) { const rootRef = useRef<HTMLDivElement>(null); const free = jail === FREE; + // "You're out" unmounts the focused button: keep focus in the dialog + useEffect(() => { + if (free) rootRef.current?.focus({ preventScroll: true }); + }, [free]); + useEffect(() => { const root = rootRef.current; + // focus goes back where it was (the header lock, say) when it closes + const before = document.activeElement as HTMLElement | null; // focus starts on the button, so holding Enter or Space works right // away; the dialog itself when it's showing "You're out" const hold = root?.querySelector<HTMLElement>('[data-hold]'); (hold ?? root)?.focus({ preventScroll: true }); // Esc doesn't close it (holding the button does); Tab stays inside const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') e.preventDefault(); + // Esc does nothing here, and must not reach a dialog behind this one + // (Settings) either: listening in the capture phase runs first + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + } if (e.key !== 'Tab' || !root) return; const focusables = root.querySelectorAll<HTMLElement>('a[href], button'); if (!focusables.length) return; @@ -252,12 +292,13 @@ function JailModal({ first.focus(); } }; - document.addEventListener('keydown', onKey); + document.addEventListener('keydown', onKey, true); const prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { - document.removeEventListener('keydown', onKey); + document.removeEventListener('keydown', onKey, true); document.body.style.overflow = prevOverflow; + before?.focus?.({ preventScroll: true }); }; }, []); @@ -305,7 +346,7 @@ function JailModal({ of this list on the card's color instead of under the app header on the canvas */} <div className="-mx-1 min-h-0 overflow-y-auto px-1 [--canvas:var(--surface)] [--header-h:0px]"> - <JailList pulls={jail.pulls} me={me} maxDays={maxDays} /> + <JailList pulls={jail.pulls} me={me} maxDays={maxDays} openIds={openIds} /> </div> <HoldToLeave onDone={onDone} /> </> @@ -367,6 +408,13 @@ function HoldToLeave({ onDone }: { onDone: () => void }) { e.currentTarget.setPointerCapture?.(e.pointerId); start(); }} + // screen readers, voice control and switch access send a lone click + // with no pointer or key behind it (detail 0): they can't hold, so + // that click lets them out. A mouse click (detail 1+) or a held key + // never gets here as detail 0. + onClick={e => { + if (e.detail === 0 && !holding && !opened) onDone(); + }} onPointerUp={stop} onPointerCancel={stop} onKeyDown={e => { @@ -458,19 +506,25 @@ const pullId = (p: DerivedPull) => `${p.data.repo}#${p.data.number}`; /** The PRs that just left `pulls` (merged or closed), kept for LEAVE_MS so * their rows can slide out instead of vanishing. */ -function useLeaving(pulls: DerivedPull[]): DerivedPull[] { +function useLeaving(pulls: DerivedPull[], openIds: ReadonlySet<string>): DerivedPull[] { const last = useRef(new Map<string, DerivedPull>()); const [leaving, setLeaving] = useState<DerivedPull[]>([]); const timers = useRef<number[]>([]); useEffect(() => { const now = new Map(pulls.map(p => [pullId(p), p])); - const gone = [...last.current].filter(([id]) => !now.has(id)).map(([, p]) => p); + // gone from the list and closed on the board; one that only stopped + // counting (the rule switched, or it went draft) just leaves + const gone = [...last.current] + .filter(([id]) => !now.has(id) && !openIds.has(id)) + .map(([, p]) => p); last.current = now; if (!gone.length) return; setLeaving(l => [...l, ...gone]); timers.current.push( window.setTimeout(() => setLeaving(l => l.filter(p => !gone.includes(p))), LEAVE_MS) ); + // keyed on pulls alone: openIds changes with every board update, and + // it's only read to tell a close from a rule switch }, [pulls]); useEffect(() => () => timers.current.forEach(clearTimeout), []); // one that came back (reopened) is just a row again @@ -517,8 +571,18 @@ function CopyNudge({ rows, me }: { rows: { pull: DerivedPull }[]; me: string }) * quickest way out first, each group under the board's own header with a * word on what to do, oldest first. Someone else's PRs (the preview's * fallback) list flat, since their groups would be a reviewer's. */ -function JailList({ pulls, me, maxDays }: { pulls: DerivedPull[]; me: string; maxDays: number }) { - const leaving = useLeaving(pulls); +function JailList({ + pulls, + me, + maxDays, + openIds, +}: { + pulls: DerivedPull[]; + me: string; + maxDays: number; + openIds: ReadonlySet<string>; +}) { + const leaving = useLeaving(pulls, openIds); const gone = new Set(leaving.map(pullId)); const all = [...pulls, ...leaving]; const mine = all.every(p => p.data.user.login === me); diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index e117aa53..0728d796 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -134,6 +134,8 @@ export function Settings({ useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { + // a dialog above this one (PR jail) handled it + if (e.defaultPrevented) return; if (e.key === 'Escape') setOpen(false); // aria-modal promises a focus trap; without this, Tab walks out of // the dialog into the live board behind the scrim @@ -387,10 +389,9 @@ export function Settings({ > <QuietButton onClick={() => { - // JailMode listens for jail=1 in the hash - const hash = new URLSearchParams(location.hash.slice(1)); - hash.set('jail', '1'); - location.hash = hash.toString(); + // close Settings first so the jail isn't stacked on it + setOpen(false); + window.dispatchEvent(new Event('pd2:preview-jail')); }} > Preview PR jail From b855563908ce8e4ea7c062c3332ad69f64ff5864 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 18:37:08 -0700 Subject: [PATCH 10/16] Close two ways around PR jail's hold 1) Firefox clicks a button on Space's keyup, so a quick Space tap produced a click with no pointer behind it, which the jail treats as assistive tech's lone click and lets you out. Key releases now cancel their default, and a click right after one doesn't count. 2) When the "who am I" lookup failed at load, the board started with no user, read that as "under the limits", and wiped the last showing in every tab, so the jail popped right back up. Jail now waits until it knows who you are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.test.tsx | 22 ++++++++++++++++++++ frontend-v2/src/components/JailMode.tsx | 22 +++++++++++++++----- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/frontend-v2/src/components/JailMode.test.tsx b/frontend-v2/src/components/JailMode.test.tsx index 4a50989e..b159522d 100644 --- a/frontend-v2/src/components/JailMode.test.tsx +++ b/frontend-v2/src/components/JailMode.test.tsx @@ -226,4 +226,26 @@ describe('JailMode while the cell is down', () => { act(() => window.dispatchEvent(new Event('pd2:preview-jail'))); expect(dialog()).not.toBeNull(); }); + + it('a quick Space tap is not a lone click, even where the browser clicks on keyup', () => { + render(many(8)); + const hold = [...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')].find( + b => b.textContent?.includes('Hold to get back to work') + ); + act(() => { + hold?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + hold?.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true })); + // Firefox's keyup click, with no pointer behind it + hold?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 })); + }); + expect(dialog()).not.toBeNull(); + }); + + it('keeps the last showing while it doesn’t know who you are', () => { + localStorage.setItem('pd2.jail', JSON.stringify({ at: Date.now(), count: 9, over: [] })); + const r = (root ??= createRoot(host)); + act(() => r.render(<JailMode pulls={many(9)} me="" extraBots={new Set()} initialized />)); + expect(localStorage.getItem('pd2.jail')).not.toBeNull(); + expect(dialog()).toBeNull(); + }); }); diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index 308e20bc..c2b23fcc 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -57,7 +57,9 @@ export function JailMode({ const found = useMemo( () => - initialized && settings.jailMode !== 'off' + // no case until we know who you are: an empty `me` (a failed whoami) + // would read as "under the limits" and wipe the last showing + initialized && me && settings.jailMode !== 'off' ? jailCase(pulls, me, extraBots, { maxOpen: settings.jailMaxOpen, maxDays: settings.jailMaxDays, @@ -70,10 +72,10 @@ export function JailMode({ // under the limits again: forget the last showing, so the next time you // go over is a fresh offense, not "no worse than before" useEffect(() => { - if (!initialized || found || settings.jailMode === 'off' || !record.current) return; + if (!initialized || !me || found || settings.jailMode === 'off' || !record.current) return; record.current = null; removeStorage(JAIL_KEY); - }, [initialized, found, settings.jailMode]); + }, [initialized, me, found, settings.jailMode]); // the latest case, for closing: the button's hold started seconds ago, and // what you saw last is what the next showing compares against @@ -369,6 +371,9 @@ function HoldToLeave({ onDone }: { onDone: () => void }) { const timer = useRef<number | undefined>(undefined); const tapTimer = useRef<number | undefined>(undefined); const since = useRef(0); + // when a key was last let go: Firefox clicks a button on Space's keyup, + // and that click must not pass for assistive tech's lone click + const keyUpAt = useRef(0); const start = () => { if (timer.current !== undefined || opened) return; since.current = Date.now(); @@ -413,7 +418,9 @@ function HoldToLeave({ onDone }: { onDone: () => void }) { // that click lets them out. A mouse click (detail 1+) or a held key // never gets here as detail 0. onClick={e => { - if (e.detail === 0 && !holding && !opened) onDone(); + if (e.detail !== 0 || holding || opened) return; + if (Date.now() - keyUpAt.current < 1000) return; + onDone(); }} onPointerUp={stop} onPointerCancel={stop} @@ -422,7 +429,12 @@ function HoldToLeave({ onDone }: { onDone: () => void }) { e.preventDefault(); if (!e.repeat) start(); }} - onKeyUp={e => isPress(e.key) && stop()} + onKeyUp={e => { + if (!isPress(e.key)) return; + e.preventDefault(); + keyUpAt.current = Date.now(); + stop(); + }} onBlur={stop} onContextMenu={e => e.preventDefault()} data-hold From 20134d05ffa853e2a4bcb06a1564a68477bd740c Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 18:49:22 -0700 Subject: [PATCH 11/16] Don't pop PR jail in two windows at once Two visible Pulldasher windows both checked on the same data push, a moment apart, and each read its own copy of the last showing before the other window's storage event arrived, so jail could open in both. The check now reads the stored record fresh before deciding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.test.tsx | 11 +++++++++++ frontend-v2/src/components/JailMode.tsx | 3 +++ 2 files changed, 14 insertions(+) diff --git a/frontend-v2/src/components/JailMode.test.tsx b/frontend-v2/src/components/JailMode.test.tsx index b159522d..f8f484a1 100644 --- a/frontend-v2/src/components/JailMode.test.tsx +++ b/frontend-v2/src/components/JailMode.test.tsx @@ -248,4 +248,15 @@ describe('JailMode while the cell is down', () => { expect(localStorage.getItem('pd2.jail')).not.toBeNull(); expect(dialog()).toBeNull(); }); + + it('stays down when another window showed it a moment ago', () => { + // another window wrote the record after this one loaded + const r = (root ??= createRoot(host)); + act(() => + r.render(<JailMode pulls={many(8)} me="me" extraBots={new Set()} initialized={false} />) + ); + localStorage.setItem('pd2.jail', JSON.stringify({ at: Date.now(), count: 8, over: [] })); + act(() => r.render(<JailMode pulls={many(8)} me="me" extraBots={new Set()} initialized />)); + expect(dialog()).toBeNull(); + }); }); diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index c2b23fcc..c5ba9af2 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -133,6 +133,9 @@ export function JailMode({ const check = () => { if (document.visibilityState === 'hidden') return; if (document.activeElement?.matches('input, textarea, select')) return; + // read the record fresh: a second visible window may have shown it + // a moment ago, before its storage event reached this one + record.current = parseJailRecord(readStorage(JAIL_KEY)) ?? record.current; if (!jailDue(found, record.current, Date.now())) return; show(found); }; From b9e10ddccc3c6e8899d4ea3553aedfc042bfdb96 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 19:03:10 -0700 Subject: [PATCH 12/16] Keep PR jail's 4-hour wait, and board keys behind modals 1) Getting under the limits deleted the last showing, so someone at the limit got jailed on every new PR: no record meant no 4-hour wait. Now it zeroes what was counted but keeps when it showed, so going over again is still a fresh offense and still waits out the 4 hours, as Settings promises. 2) The board's /, j, k and c keys still ran while the jail (or Settings) was open, moving focus behind the scrim. They stand down while a modal dialog is open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.test.tsx | 30 ++++++++++++++++++-- frontend-v2/src/components/JailMode.tsx | 15 ++++++---- frontend-v2/src/components/useRowKeys.ts | 3 ++ frontend-v2/src/hooks.ts | 10 +++++-- 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/frontend-v2/src/components/JailMode.test.tsx b/frontend-v2/src/components/JailMode.test.tsx index f8f484a1..43515b6a 100644 --- a/frontend-v2/src/components/JailMode.test.tsx +++ b/frontend-v2/src/components/JailMode.test.tsx @@ -211,13 +211,37 @@ describe('JailMode while the cell is down', () => { expect(dialog()).not.toBeNull(); }); - it('forgets the last showing once you are under the limits', () => { + it('zeroes the last showing once you are under the limits, but keeps its time', () => { vi.useFakeTimers(); const nine = many(9); render(nine); - expect(localStorage.getItem('pd2.jail')).not.toBeNull(); + const shownAt = JSON.parse(localStorage.getItem('pd2.jail') ?? 'null').at; render(nine.slice(0, 3)); - expect(localStorage.getItem('pd2.jail')).toBeNull(); + expect(JSON.parse(localStorage.getItem('pd2.jail') ?? 'null')).toEqual({ + at: shownAt, + count: 0, + over: [], + }); + }); + + it('going back over within 4 hours waits out the rest of them', () => { + const nine = many(9); + render(nine); + act(() => + document + .querySelector<HTMLButtonElement>('[data-hold]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 })) + ); + expect(dialog()).toBeNull(); + render(nine.slice(0, 3)); // under: the record zeroes + render([...nine, pull()]); // over again, minutes later + expect(dialog()).toBeNull(); + }); + + it('board hotkeys stand down while it is open', async () => { + const { modalOpen } = await import('../hooks'); + render(many(8)); + expect(modalOpen()).toBe(true); }); it('opens the preview every time Settings asks', () => { diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index c5ba9af2..e545a8bc 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -15,7 +15,7 @@ import { type JailCase, } from '../model/jail'; import { useSettings } from '../settings'; -import { readStorage, removeStorage, writeStorage } from '../storage'; +import { readStorage, writeStorage } from '../storage'; import { HeaderIconButton, QuietButton, RepoRef } from './bits'; import { GroupHeader } from './Lane'; import { CiGlyph } from './pips'; @@ -69,12 +69,15 @@ export function JailMode({ [initialized, pulls, me, extraBots, settings] ); - // under the limits again: forget the last showing, so the next time you - // go over is a fresh offense, not "no worse than before" + // under the limits again: zero what the last showing counted, so going + // over later is worse than it (a fresh offense), but keep when it showed, + // so it still comes back at most every 4 hours useEffect(() => { - if (!initialized || !me || found || settings.jailMode === 'off' || !record.current) return; - record.current = null; - removeStorage(JAIL_KEY); + const last = record.current; + if (!initialized || !me || found || settings.jailMode === 'off' || !last) return; + if (!last.count && !last.over.length) return; + record.current = { at: last.at, count: 0, over: [] }; + writeStorage(JAIL_KEY, JSON.stringify(record.current)); }, [initialized, me, found, settings.jailMode]); // the latest case, for closing: the button's hold started seconds ago, and diff --git a/frontend-v2/src/components/useRowKeys.ts b/frontend-v2/src/components/useRowKeys.ts index badbd9f1..60220fb9 100644 --- a/frontend-v2/src/components/useRowKeys.ts +++ b/frontend-v2/src/components/useRowKeys.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { modalOpen } from '../hooks'; /** * j and k move between a list's rows, the way they move between PRs on the @@ -26,6 +27,8 @@ export function useRowKeys(row: string, target: string) { }; const onKey = (e: KeyboardEvent) => { if ((e.key !== 'j' && e.key !== 'k') || e.metaKey || e.ctrlKey || e.altKey) return; + // a modal dialog is open: rows behind it are out of reach + if (modalOpen()) return; const t = e.target as HTMLElement; if (['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName) || t.isContentEditable) return; // drawn rows only: one in a closed fold has no box to land on diff --git a/frontend-v2/src/hooks.ts b/frontend-v2/src/hooks.ts index 84e60cc1..b9cec02b 100644 --- a/frontend-v2/src/hooks.ts +++ b/frontend-v2/src/hooks.ts @@ -1,10 +1,14 @@ import { type RefObject, useEffect, useLayoutEffect } from 'react'; +/** A modal dialog is open (PR jail, Settings): the board's own keys stand + * down, so `/` or `j` can't move focus behind the scrim. */ +export const modalOpen = () => document.querySelector('[aria-modal="true"]') != null; + /** A key press the board's keys leave alone: one a view's own keys - * (components/useRowKeys.ts) already took, one typed into a field, or one - * with a modifier, which belongs to the browser. */ + * (components/useRowKeys.ts) already took, one typed into a field, one with + * a modifier, which belongs to the browser, or one while a modal is open. */ function notOurs(e: KeyboardEvent): boolean { - if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return true; + if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || modalOpen()) return true; const t = e.target as HTMLElement; return ['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName) || t.isContentEditable; } From 455497a2e75408d71b354b13da8732bf9e4c7961 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 19:14:53 -0700 Subject: [PATCH 13/16] Hold PR jail while a dialog is up or a drag is under way 1) The automatic check could open the jail on top of Settings (say, right after switching PR jail to Automatic), or mid-drag on the Roadmap, where it swallowed the Esc that cancels the drag, so a move you meant to cancel got saved. It now waits while another modal is open or a pointer is held, and tries again on the next update. 2) The CI mark in jail rows is aria-hidden, so screen readers heard nothing about a failing build. Rows now say "CI failing" or "CI running" to them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.test.tsx | 9 ++++++++ frontend-v2/src/components/JailMode.tsx | 22 +++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/frontend-v2/src/components/JailMode.test.tsx b/frontend-v2/src/components/JailMode.test.tsx index 43515b6a..95bcf238 100644 --- a/frontend-v2/src/components/JailMode.test.tsx +++ b/frontend-v2/src/components/JailMode.test.tsx @@ -283,4 +283,13 @@ describe('JailMode while the cell is down', () => { act(() => r.render(<JailMode pulls={many(8)} me="me" extraBots={new Set()} initialized />)); expect(dialog()).toBeNull(); }); + + it('waits while another dialog is open', () => { + const other = document.createElement('div'); + other.setAttribute('aria-modal', 'true'); + document.body.append(other); + render(many(9)); + expect(dialog()).toBeNull(); + other.remove(); + }); }); diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index e545a8bc..5cd92c7c 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -17,6 +17,7 @@ import { import { useSettings } from '../settings'; import { readStorage, writeStorage } from '../storage'; import { HeaderIconButton, QuietButton, RepoRef } from './bits'; +import { modalOpen } from '../hooks'; import { GroupHeader } from './Lane'; import { CiGlyph } from './pips'; @@ -30,6 +31,15 @@ import { CiGlyph } from './pips'; * The jailMode setting picks: pop up (auto), the badge alone (manual), or off. */ +// whether a mouse button or finger is down anywhere: the jail never pops up +// mid-drag (a Roadmap bar, say) +let pointerHeld = false; +if (typeof document !== 'undefined') { + document.addEventListener('pointerdown', () => (pointerHeld = true), true); + for (const end of ['pointerup', 'pointercancel'] as const) + document.addEventListener(end, () => (pointerHeld = false), true); +} + const hasPreviewFlag = () => new URLSearchParams(location.hash.slice(1)).get('jail') === '1'; /** Renders the header badge in place, and the cell over the page. */ @@ -135,6 +145,9 @@ export function JailMode({ if (!found || shown || settings.jailMode !== 'auto') return; const check = () => { if (document.visibilityState === 'hidden') return; + // another dialog is up (Settings), or a drag is under way: the jail + // would land on top of it, and swallow the Esc that cancels a drag + if (modalOpen() || pointerHeld) return; if (document.activeElement?.matches('input, textarea, select')) return; // read the record fresh: a second visible window may have shown it // a moment ago, before its storage event reached this one @@ -710,7 +723,14 @@ function JailRow({ {move} </span> )} - {!ready && <CiGlyph pull={p} />} + {!ready && ( + <> + <CiGlyph pull={p} /> + {/* the glyph is aria-hidden; say what it shows */} + {p.ci === 'failing' && <span className="sr-only">CI failing</span>} + {p.ci === 'pending' && <span className="sr-only">CI running</span>} + </> + )} {detail && <span className="min-w-0">{detail}</span>} </span> </li> From 8e89b1756ca4749933a8dc291b456c4d56386017 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 19:27:48 -0700 Subject: [PATCH 14/16] Keep PR jail's drag check from sticking or missing a drag The "pointer held" flag that keeps the jail from popping up mid-drag could stick on after a right-click (the context menu swallows the pointerup), delaying the popup, and a native drag cleared it through pointercancel. It now counts only the main button, clears on the context menu and on leaving the window, and stays set through a native drag until it ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index 5cd92c7c..92a605d1 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -35,9 +35,14 @@ import { CiGlyph } from './pips'; // mid-drag (a Roadmap bar, say) let pointerHeld = false; if (typeof document !== 'undefined') { - document.addEventListener('pointerdown', () => (pointerHeld = true), true); - for (const end of ['pointerup', 'pointercancel'] as const) + // the main button only: a right-click's context menu swallows its + // pointerup and would leave this stuck on + document.addEventListener('pointerdown', e => (pointerHeld = e.button === 0), true); + // a native drag (a Roadmap row) cancels the pointer but is still a drag + document.addEventListener('dragstart', () => (pointerHeld = true), true); + for (const end of ['pointerup', 'dragend', 'contextmenu'] as const) document.addEventListener(end, () => (pointerHeld = false), true); + window.addEventListener('blur', () => (pointerHeld = false)); } const hasPreviewFlag = () => new URLSearchParams(location.hash.slice(1)).get('jail') === '1'; From f5fe2e6d078de2c61e4cdf7a5bfa196df2bc421d Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 19:28:17 -0700 Subject: [PATCH 15/16] Let a cancelled touch clear PR jail's press flag again The last change stopped pointercancel from clearing the press, to keep a native drag counted, but a touch that turns into a scroll also ends with pointercancel and no pointerup, so on a phone the flag stuck on after any scroll. A native drag is now tracked on its own, and pointercancel clears the press again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.tsx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index 92a605d1..c0fccfcd 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -31,18 +31,22 @@ import { CiGlyph } from './pips'; * The jailMode setting picks: pop up (auto), the badge alone (manual), or off. */ -// whether a mouse button or finger is down anywhere: the jail never pops up -// mid-drag (a Roadmap bar, say) -let pointerHeld = false; +// whether a mouse button or finger is down, or a native drag is under way: +// the jail never pops up mid-drag (a Roadmap bar or row, say) +let pressing = false; +let dragging = false; +const pointerBusy = () => pressing || dragging; if (typeof document !== 'undefined') { // the main button only: a right-click's context menu swallows its // pointerup and would leave this stuck on - document.addEventListener('pointerdown', e => (pointerHeld = e.button === 0), true); - // a native drag (a Roadmap row) cancels the pointer but is still a drag - document.addEventListener('dragstart', () => (pointerHeld = true), true); - for (const end of ['pointerup', 'dragend', 'contextmenu'] as const) - document.addEventListener(end, () => (pointerHeld = false), true); - window.addEventListener('blur', () => (pointerHeld = false)); + document.addEventListener('pointerdown', e => (pressing = e.button === 0), true); + // a touch that turns into a scroll, or a press that turns into a native + // drag, ends with pointercancel; the drag is tracked on its own + for (const end of ['pointerup', 'pointercancel', 'contextmenu'] as const) + document.addEventListener(end, () => (pressing = false), true); + document.addEventListener('dragstart', () => (dragging = true), true); + document.addEventListener('dragend', () => (dragging = false), true); + window.addEventListener('blur', () => (pressing = dragging = false)); } const hasPreviewFlag = () => new URLSearchParams(location.hash.slice(1)).get('jail') === '1'; @@ -152,7 +156,7 @@ export function JailMode({ if (document.visibilityState === 'hidden') return; // another dialog is up (Settings), or a drag is under way: the jail // would land on top of it, and swallow the Esc that cancels a drag - if (modalOpen() || pointerHeld) return; + if (modalOpen() || pointerBusy()) return; if (document.activeElement?.matches('input, textarea, select')) return; // read the record fresh: a second visible window may have shown it // a moment ago, before its storage event reached this one From a17d1aba75c16e3016ab339e01a0a43e4e1cd49b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox <jarstelfox@mac.com> Date: Fri, 9 Oct 2026 19:39:14 -0700 Subject: [PATCH 16/16] End PR jail's drag check on a drop, not only dragend dragend goes to the dragged node, and moving a Roadmap plan to another lane takes that node off the page first, so dragend never reached the document and the "drag under way" flag stayed on: the jail never popped up again until the window lost focus. A drop, or any new press, now ends it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-v2/src/components/JailMode.test.tsx | 10 ++++++++++ frontend-v2/src/components/JailMode.tsx | 15 +++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/frontend-v2/src/components/JailMode.test.tsx b/frontend-v2/src/components/JailMode.test.tsx index 95bcf238..8e0395fe 100644 --- a/frontend-v2/src/components/JailMode.test.tsx +++ b/frontend-v2/src/components/JailMode.test.tsx @@ -292,4 +292,14 @@ describe('JailMode while the cell is down', () => { expect(dialog()).toBeNull(); other.remove(); }); + + it('a drop ends a drag even when the dragged row left the page', () => { + const row = document.createElement('div'); + document.body.append(row); + row.dispatchEvent(new Event('dragstart', { bubbles: true })); + row.remove(); // moved to another lane: its dragend never reaches the page + document.body.dispatchEvent(new Event('drop', { bubbles: true })); + render(many(9)); + expect(dialog()).not.toBeNull(); + }); }); diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx index c0fccfcd..c690e029 100644 --- a/frontend-v2/src/components/JailMode.tsx +++ b/frontend-v2/src/components/JailMode.tsx @@ -39,13 +39,24 @@ const pointerBusy = () => pressing || dragging; if (typeof document !== 'undefined') { // the main button only: a right-click's context menu swallows its // pointerup and would leave this stuck on - document.addEventListener('pointerdown', e => (pressing = e.button === 0), true); + // a new press also means no drag is running, whatever became of the last + document.addEventListener( + 'pointerdown', + e => { + pressing = e.button === 0; + dragging = false; + }, + true + ); // a touch that turns into a scroll, or a press that turns into a native // drag, ends with pointercancel; the drag is tracked on its own for (const end of ['pointerup', 'pointercancel', 'contextmenu'] as const) document.addEventListener(end, () => (pressing = false), true); document.addEventListener('dragstart', () => (dragging = true), true); - document.addEventListener('dragend', () => (dragging = false), true); + // dragend goes to the dragged node, which may have left the page by then + // (a Roadmap plan moved to another lane), so the drop counts as the end too + for (const end of ['dragend', 'drop'] as const) + document.addEventListener(end, () => (dragging = false), true); window.addEventListener('blur', () => (pressing = dragging = false)); }