+
{label}
{showCount && count > 0 && (
- // a flex item drops a leading space, so the gutter's takes a fixed one
+ // a flex item drops a leading space, so a long band's takes a fixed one
- {gutter ? '\u00a0' : ' '}· {count}
+ {long ? '\u00a0' : ' '}· {count}
)}
@@ -458,7 +463,7 @@ export function Fold({
// with it, the way a mouse gets it on hover
aria-describedby={gloss ? glossId : undefined}
className={`flex cursor-pointer list-none items-center gap-x-2 bg-muted/40 py-[6px] pr-3.5 ${
- gutter ? 'flex-wrap' : ''
+ long ? 'flex-wrap' : ''
} transition-[background-color] duration-150 ease-out hover:bg-muted motion-reduce:transition-none [&::-webkit-details-marker]:hidden ${
gutter ? 'pl-8' : 'pl-3.5'
} ${caps ? eyebrowText : 'text-[11px] font-semibold'}`}
@@ -476,7 +481,7 @@ export function Fold({
// at most the band less its chevron, so a long name stays
// on the chevron's line and ends in an ellipsis
rootClass={
- gutter
+ long
? 'relative inline-flex min-w-0 max-w-[calc(100%-1.25rem)]'
: 'relative inline-flex'
}
@@ -516,11 +521,11 @@ export function Fold({
)}
{detail && (
{detail}
diff --git a/frontend-v2/src/views/projects/Portfolio.tsx b/frontend-v2/src/views/projects/Portfolio.tsx
index da29da1b..a98c23ce 100644
--- a/frontend-v2/src/views/projects/Portfolio.tsx
+++ b/frontend-v2/src/views/projects/Portfolio.tsx
@@ -1351,6 +1351,8 @@ export function Portfolio(
// logins keep their case
caps={by !== 'lead'}
keepEmpty={!!g.initiative && initiativeAsks.has(g.initiative.id)}
+ // an initiative's name runs long: the Roadmap band's layout
+ long={!!g.initiative}
id={`portfolio:${by}:${
g.initiative?.slug ?? g.title.replace(/\s+/g, '-')
}`}
From bb598c82a0a988fbb50fa286953e24381f125739 Mon Sep 17 00:00:00 2001
From: Jarred Stelfox
Date: Mon, 12 Oct 2026 04:30:41 -0700
Subject: [PATCH 092/148] Give Link the File in chooser's layout
No project yet's chooser sat right under the File choosers on Decide but
looked unrelated: "Its project" in a wide label column, the select
starting further right, and "Link to X" on its own line below. It now
reads "Its project [select] Link" on one line, as "File in [select]
File" does, Link the row's answer: off until a project is chosen, filled
once one is, named "Link X to Y". The two share the answer button's
class.
Co-Authored-By: Claude Opus 5.5
---
.../src/views/projects/HomeCalls.test.tsx | 25 +++--
frontend-v2/src/views/projects/HomeCalls.tsx | 98 ++++++++++++-------
2 files changed, 76 insertions(+), 47 deletions(-)
diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx
index 8e6dbaa1..f0ece757 100644
--- a/frontend-v2/src/views/projects/HomeCalls.test.tsx
+++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx
@@ -210,6 +210,15 @@ const click = (el: HTMLElement, words: string) =>
/** the chooser's File button, the answer or the opened chooser's */
const fileButton = (el: HTMLElement) =>
[...el.querySelectorAll('button')].find(b => /^File(Suggested)?$/.test(b.textContent ?? ''));
+/** what a chooser's line holds, in order: its label, the select, its buttons */
+const parts = (el: HTMLElement) => {
+ const line = el.querySelector('select')?.parentElement as HTMLElement;
+ return [...line.children]
+ .map(c =>
+ c.tagName === 'SELECT' ? 'select' : c.textContent === 'guessed' ? null : c.textContent
+ )
+ .filter(Boolean);
+};
/** a callback the test doesn't watch */
const nothing = () => undefined;
const settle = () => act(() => new Promise(resolve => setTimeout(resolve, 0)));
@@ -644,13 +653,18 @@ describe('a plan with no project', () => {
it('asks for its project, then links it, and Undo unlinks it', async () => {
const el = render(row);
- expect(buttons(el)).toEqual(['Link it']);
+ // the File in chooser's layout: "Its project [ ] Link", Link off till a pick
+ expect(parts(el)).toEqual(['Its project', 'select', 'Link']);
+ expect(el.querySelector('button')?.disabled).toBe(true);
const select = el.querySelector('select') as HTMLSelectElement;
act(() => {
select.value = 'cache';
select.dispatchEvent(new Event('change', { bubbles: true }));
});
- click(el, 'Link to cache');
+ expect(el.querySelector('button')?.getAttribute('aria-label')).toBe(
+ 'Link Checkout redesign to cache'
+ );
+ click(el, 'Link');
await settle();
expect(calls.at(-1)).toMatchObject({
method: 'PATCH',
@@ -1279,13 +1293,6 @@ describe('Undo on a row', () => {
});
describe('the one chooser, wherever a project is filed', () => {
- /** what its line holds, in order: its label, the select, its buttons */
- const parts = (el: HTMLElement) => {
- const line = el.querySelector('select')?.parentElement as HTMLElement;
- return [...line.children].map(c =>
- c.tagName === 'SELECT' ? 'select' : c.textContent === 'guessed' ? null : c.textContent
- ).filter(Boolean);
- };
const escape = (el: Element) =>
act(() => {
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
diff --git a/frontend-v2/src/views/projects/HomeCalls.tsx b/frontend-v2/src/views/projects/HomeCalls.tsx
index 4f54c0cd..c319b653 100644
--- a/frontend-v2/src/views/projects/HomeCalls.tsx
+++ b/frontend-v2/src/views/projects/HomeCalls.tsx
@@ -247,11 +247,7 @@ export function HomeChooser({
disabled={file.disabled}
title={file.disabled ? 'Choose another home first' : undefined}
onClick={file.onFile}
- className={`hit pressable inline-flex min-h-8 max-w-full items-center py-1 text-left gap-1.5 rounded-lg border px-3 text-[13px] font-medium disabled:opacity-60 ${
- file.filled
- ? 'border-brand bg-brand text-surface hover:opacity-90'
- : 'border-line bg-surface text-ink-2 hover:border-brand hover:text-brand'
- }`}
+ className={answerClass(file.filled)}
>
File
{file.filled && file.suggested && (
@@ -268,30 +264,43 @@ export function HomeChooser({
);
}
-/** A plan's project chooser, for a plan with no project: "Its project [▾]". */
+/** The filled or outlined button that answers a chooser's row: File, Link. */
+const answerClass = (filled: boolean | undefined) =>
+ `hit pressable inline-flex min-h-8 max-w-full items-center gap-1.5 rounded-lg border px-3 py-1 text-left text-[13px] font-medium disabled:opacity-60 ${
+ filled
+ ? 'border-brand bg-brand text-surface hover:opacity-90'
+ : 'border-line bg-surface text-ink-2 hover:border-brand hover:text-brand'
+ }`;
+
+/** A plan's project chooser, for a plan with no project, in the File in
+ * chooser's layout: "Its project [▾] Link", Link the row's answer, off
+ * until a project is chosen. */
function ProjectChooser({
name,
value,
onChange,
+ onLink,
+ describedBy,
}: {
name: string;
value: string;
onChange: (slug: string) => void;
+ onLink: () => void;
+ describedBy?: string;
}) {
const id = useId();
const items = useContext(AllItemsContext);
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
+ const chosen = items.find(i => i.slug === value);
return (
-
-
- Its project
-
+
+ Its project
onChange(e.target.value)}
- className={`h-8 max-w-full px-2 text-[13px] text-ink ${textInputClass}`}
+ className={`h-8 min-w-0 max-w-full px-2 text-[13px] text-ink ${textInputClass}`}
>
{!value && Choose its project }
{sorted.map(i => (
@@ -300,7 +309,20 @@ function ProjectChooser({
))}
-
+
+ Link
+
+
);
}
@@ -309,7 +331,7 @@ function ProjectChooser({
* initiative, in the same shape as Decide's CallStrip: a chooser first when
* the answer needs one, then the suggested answer filled, then the others.
* Unsorted: File in the chooser's pick. No project yet: Link to the chosen
- * project. Not tracked, but growing: Keep not tracked, or Track it, which
+ * project, the same layout with Link in File's place. Not tracked, but growing: Keep not tracked, or Track it, which
* opens the chooser. Goal maybe reached: Close as done, or Close as dropped
* or Keep open. Its initiative closed: Reopen it, or File elsewhere
* with the chooser. An answer that waits on an empty chooser is outlined and
@@ -348,15 +370,8 @@ export function HomeStrip({
const linked = items.find(i => i.slug === project);
type Option = { call: Call | null; words: string; suggested?: boolean };
const options: Option[] =
- primary.kind === 'unfiled'
+ primary.kind === 'unfiled' || primary.kind === 'no_project'
? []
- : primary.kind === 'no_project'
- ? [
- {
- call: linked ? { kind: 'link', project: linked.slug, name: linked.name } : null,
- words: linked ? `Link to ${linked.name}` : 'Link it',
- },
- ]
: primary.kind === 'untracked_grew'
? [
{
@@ -438,7 +453,16 @@ export function HomeStrip({
return (
{primary.kind === 'no_project' && (
-
+ // the question is its project: Link is the row's answer
+
+ linked && onCall({ kind: 'link', project: linked.slug, name: linked.name })
+ }
+ />
)}
{primary.kind === 'unfiled' && pickable.length > 0 && (
// the question is where it goes: File is the row's answer
@@ -455,7 +479,7 @@ export function HomeStrip({
}}
/>
)}
- {primary.kind !== 'unfiled' && (
+ {primary.kind !== 'unfiled' && primary.kind !== 'no_project' && (
{options.map(button)}
- {primary.kind !== 'no_project' && (
-
- {moreWords}
-
- )}
+
+ {moreWords}
+
{extras.map((o, k) => button(o, k + 1))}
)}
From 548eefb02beae4071c7627cc31a8ec86ffd9f410 Mon Sep 17 00:00:00 2001
From: Jarred Stelfox
Date: Mon, 12 Oct 2026 04:32:33 -0700
Subject: [PATCH 093/148] Keep one Undo for a run of lane moves
Moving one initiative and then another dropped the first move's receipt
and its Undo, so that move could only be dragged back. Lane moves now
share one run, whatever lane they move: it keeps the whole order as the
run found it, the receipt names every lane it moved ("Moved Beta to
priority 2 and FixBot chat to priority 4"), and its one Undo puts the
order back from the bottom up, moving only what's out of place.
Co-Authored-By: Claude Opus 5.5
---
frontend-v2/src/views/projects/Roadmap.tsx | 5 +-
.../src/views/projects/RoadmapLanes.test.tsx | 30 +++++++++
.../src/views/projects/RoadmapLanes.tsx | 62 +++++++++++++------
3 files changed, 78 insertions(+), 19 deletions(-)
diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx
index 0eff4a5d..10d0f69f 100644
--- a/frontend-v2/src/views/projects/Roadmap.tsx
+++ b/frontend-v2/src/views/projects/Roadmap.tsx
@@ -136,6 +136,7 @@ import {
NO_PROJECT_LANE,
ReceiptLine,
TimelineLane,
+ LANE_RUN,
type LaneRun,
type PlanRun,
type Receipt,
@@ -2598,7 +2599,9 @@ export function Roadmap({
);
};
const say = (next: Receipt | null, words?: string) => {
- if (next?.key !== run.current?.key) run.current = null;
+ // a lane's receipt goes on the lanes' one run; any other its own
+ const runKey = next?.key.startsWith('initiative:') ? LANE_RUN : next?.key;
+ if (runKey !== run.current?.key) run.current = null;
setReceipt(next);
setAnnounced(words ?? (next ? `${next.words.replace(/\.$/, '')}.` : ''));
};
diff --git a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx
index 766b6447..f87599e7 100644
--- a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx
+++ b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx
@@ -501,6 +501,36 @@ describe('the Roadmap by initiative', () => {
expect(writes().length).toBe(before);
});
+ it('keeps one Undo for a run that moves two lanes, and it puts both back', async () => {
+ server = [...START, initiative(4, 'alpha', 'Alpha', []), initiative(5, 'beta', 'Beta', [])];
+ await loadInitiatives();
+ const el = await mount();
+ const up = (id: number, key = 'ArrowUp') =>
+ act(() => {
+ (el.querySelector(`#roadmap-lane-grip-${id}`) as HTMLElement).dispatchEvent(
+ new KeyboardEvent('keydown', { key, bubbles: true })
+ );
+ });
+ // Beta up two, then FixBot chat down one
+ up(5);
+ await settle();
+ up(5);
+ await settle();
+ up(2, 'ArrowDown');
+ await settle();
+ const undos = [...el.querySelectorAll('button')].filter(b => b.textContent === 'Undo');
+ expect(undos).toHaveLength(1);
+ expect(undos[0].getAttribute('aria-label')).toBe(
+ 'Undo: Moved Beta to priority 2 and FixBot chat to priority 4'
+ );
+ act(() => undos[0].click());
+ await settle();
+ await settle();
+ const order = [...server].sort((a, b) => a.priority - b.priority).map(i => i.slug);
+ expect(order).toEqual(['site-speed', 'fixbot', 'catalog', 'alpha', 'beta', 'shelf']);
+ expect(el.textContent).toContain('Put Beta and FixBot chat back at their priorities.');
+ });
+
it('refuses a plan dragged onto a closed initiative’s lane', async () => {
// Catalog is closed, and holds a project still worked on, so it shows
server = START.map(i =>
diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx
index 8f37d3a1..4b682097 100644
--- a/frontend-v2/src/views/projects/RoadmapLanes.tsx
+++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx
@@ -22,7 +22,7 @@ import {
} from '../../model/initiativeBoard';
import { byRank, fileProject, moveInitiative, readInitiatives } from '../../model/initiativesData';
import type { PortfolioItem } from '../../model/portfolio';
-import { NO_PROJECT_YET, UNSORTED_WORD } from '../../model/words';
+import { andList, NO_PROJECT_YET, UNSORTED_WORD } from '../../model/words';
import { DecideCall } from './Decide';
import type { Navigate } from './parts';
import { inInk, SaidWords, type Said } from './roadmapHealth';
@@ -38,12 +38,30 @@ function hashWith(patch: Record): string {
return `#${p}`;
}
-/** A run of rank moves on one lane: the initiative it sat above and its
- * priority when the run began, which Undo puts back. */
+/** A run of rank moves, on one lane or several (arrow presses, or drag
+ * after drag): the whole order when it began, which its one Undo puts
+ * back, and each lane it moved, with the priority it had then. */
export interface LaneRun {
- key: string;
- was: number | null;
- wasAt: number;
+ key: typeof LANE_RUN;
+ order: number[];
+ moved: Map;
+}
+
+/** The run key every lane move shares, so a move of another lane goes on
+ * the same run instead of dropping the first one's Undo. */
+export const LANE_RUN = 'initiatives';
+
+/** Put the initiatives back in `order`, from the bottom up: each one moved
+ * just above the one after it, when it isn't there now. */
+async function restoreOrder(order: readonly number[]): Promise {
+ for (let k = order.length - 1; k >= 0; k--) {
+ const now = byRank(readInitiativesNow()).map(i => i.id);
+ const after = order[k + 1] ?? null;
+ if ((now[now.indexOf(order[k]) + 1] ?? null) === after) continue;
+ const moved = await moveInitiative(order[k], after);
+ if (moved !== true) return moved;
+ }
+ return true;
}
/** A run of moves on one plan (arrow presses, or drag after drag): where
@@ -348,9 +366,9 @@ export function initiativeLanes(c: LaneContext) {
});
});
};
- // an initiative moved just above `before` (null: the bottom), with Undo
- // back to the one it sat above before the run of moves began (arrow
- // presses, or drag after drag), as a plan row's run does
+ // an initiative moved just above `before` (null: the bottom), with one
+ // Undo back to the order before the run of moves began (arrow presses,
+ // or drag after drag, of one lane or several), as a plan row's run does
const rankMove = (moving: Initiative, before: number | null) => {
const all = byRank(initiatives ?? []);
const above = all[all.findIndex(i => i.id === moving.id) + 1]?.id ?? null;
@@ -358,10 +376,11 @@ export function initiativeLanes(c: LaneContext) {
const key = `initiative:${moving.id}`;
const going = run.current;
const start: LaneRun =
- going?.key === key && 'wasAt' in going
+ going?.key === LANE_RUN && 'order' in going
? going
- : { key, was: above, wasAt: rankOf(moving) };
- const { was, wasAt } = start;
+ : { key: LANE_RUN, order: all.map(i => i.id), moved: new Map() };
+ if (!start.moved.has(moving.id))
+ start.moved.set(moving.id, { name: moving.name, wasAt: rankOf(moving) });
run.current = start;
refocus.current = [`roadmap-lane-grip-${moving.id}`];
void moveInitiative(moving.id, before).then(r => {
@@ -372,21 +391,28 @@ export function initiativeLanes(c: LaneContext) {
const ranked = byRank(readInitiativesNow()).filter(
i => i.status === 'open' && i.kind !== 'untracked'
);
- const at = ranked.findIndex(i => i.id === moving.id) + 1;
+ const lanes = [...start.moved];
+ const now = lanes.map(
+ ([id, m]) => `${m.name} to priority ${ranked.findIndex(i => i.id === id) + 1}`
+ );
+ const back =
+ lanes.length === 1
+ ? `Put ${moving.name} back at priority ${lanes[0][1].wasAt}`
+ : `Put ${andList(lanes.map(([, m]) => m.name))} back at their priorities`;
const undo = () => {
run.current = null;
refocus.current = [`roadmap-lane-grip-${moving.id}`];
// said at once, as the board shows it back at once
keepInPlace(key, held => {
- said(key, `Put ${moving.name} back at priority ${wasAt}`, held);
- void moveInitiative(moving.id, was).then(back => {
+ said(key, back, held);
+ void restoreOrder(start.order).then(done => {
// the server's reason, with the Undo kept to try again
- if (back !== true)
- say({ key, words: back.error.replace(/\.$/, ''), failed: true, undo });
+ if (done !== true)
+ say({ key, words: done.error.replace(/\.$/, ''), failed: true, undo });
});
});
};
- say({ key, words: `Moved ${moving.name} to priority ${at}`, undo });
+ say({ key, words: `Moved ${andList(now)}`, undo });
});
};
// ----- by initiative: one lane per initiative, in its own rank -----
From 0a328af5faff95719286c22037a20e9d22afec56 Mon Sep 17 00:00:00 2001
From: Jarred Stelfox
Date: Mon, 12 Oct 2026 04:33:48 -0700
Subject: [PATCH 094/148] Say where Now, next, later's asked Not tracked plans
are
The timeline, Decide, the Overview and the page all ask "Still not
tracked?" about a Not tracked plan, but Now, next, later left it out
without a word. A Not tracked plan has no place in the order, so it
still gets no column; a line under the columns names it, says Decide
asks about it, and its name opens the question on the timeline.
Co-Authored-By: Claude Opus 5.5
---
.../src/views/projects/NowNextLater.tsx | 18 ++++++++++++++++++
frontend-v2/src/views/projects/Roadmap.tsx | 1 +
.../src/views/projects/RoadmapLanes.test.tsx | 15 +++++++++++++++
3 files changed, 34 insertions(+)
diff --git a/frontend-v2/src/views/projects/NowNextLater.tsx b/frontend-v2/src/views/projects/NowNextLater.tsx
index f245aacc..c7f2733b 100644
--- a/frontend-v2/src/views/projects/NowNextLater.tsx
+++ b/frontend-v2/src/views/projects/NowNextLater.tsx
@@ -155,6 +155,7 @@ export function NowNextLater({
today,
calls,
unplanned,
+ asked = [],
onOpen,
onPerson,
onTimeline,
@@ -174,6 +175,9 @@ export function NowNextLater({
calls: ReadonlyMap;
/** projects being worked on with no plan, which have no column here */
unplanned: number;
+ /** Not tracked plans Decide asks about: no place in the order, so no
+ * column, but said, with the way to their question */
+ asked?: RoadmapItem[];
/** a plan, open on the timeline; 'plan' puts the focus on its Plan
* line (a question or a clash), not on an answer */
onOpen: (id: number, focus?: 'plan') => void;
@@ -237,6 +241,20 @@ export function NowNextLater({
);
})}
+ {/* the timeline's Not tracked lane asks about these; here they'd
+ have no column, so where they are is said */}
+ {asked.length > 0 && (
+
+ {asked.map((item, k) => (
+
+ {k ? (k === asked.length - 1 ? ' and ' : ', ') : ''}
+ onOpen(item.id, 'plan')}>{item.name}
+
+ ))}{' '}
+ {asked.length === 1 ? 'is Not tracked, so it’s' : 'are Not tracked, so they’re'} in
+ no column here, but Decide asks about {asked.length === 1 ? 'it' : 'them'}.
+
+ )}
{/* without them, the columns would read as all the work */}
{unplanned > 0 && (
diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx
index 10d0f69f..58980d82 100644
--- a/frontend-v2/src/views/projects/Roadmap.tsx
+++ b/frontend-v2/src/views/projects/Roadmap.tsx
@@ -3737,6 +3737,7 @@ export function Roadmap({
today={today}
calls={planCalls}
unplanned={unplanned.length}
+ asked={notTrackedPlans.filter(i => planCalls.has(i.id))}
onOpen={(id, focus) => openFromReceipt(id, focus)}
onPerson={person}
onTimeline={() =>
diff --git a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx
index f87599e7..a6355ee7 100644
--- a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx
+++ b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx
@@ -357,6 +357,21 @@ describe('the Not tracked lane', () => {
}
});
+ it('says on Now, next, later where its asked plan is, its name the way to the question', async () => {
+ const grew: DecideRow = {
+ slug: 'ops',
+ item: null,
+ reasons: [{ kind: 'untracked_grew', since: 31, baseline: 2 }],
+ };
+ const el = await mount({ scale: 'now' }, { decisions: [grew] });
+ expect(el.textContent).toContain(
+ 'Runbooks is Not tracked, so it’s in no column here, but Decide asks about it.'
+ );
+ went.length = 0;
+ act(() => [...el.querySelectorAll('button')].find(b => b.textContent === 'Runbooks')?.click());
+ expect(went.at(-1)).toMatchObject({ item: 5 });
+ });
+
it('rests closed when nothing in it is asked about', async () => {
const el = await mount();
const fold = el.querySelector(
From ff2bea41abd3c4cfb03f5262bc5c6196c7f416fa Mon Sep 17 00:00:00 2001
From: Jarred Stelfox
Date: Mon, 12 Oct 2026 04:35:57 -0700
Subject: [PATCH 095/148] Offer filing once in a plan's details
While Decide asks where a plan's project goes, its details showed two
ways to file it: the Plan line's "File in [ ] File" and the Initiative
line's File elsewhere. The Plan line's is the question, so the
Initiative line now says the home and leaves filing to it, as the
project page already did.
Co-Authored-By: Claude Opus 5.5
---
frontend-v2/src/views/projects/Roadmap.tsx | 11 ++++++++++-
.../src/views/projects/RoadmapLanes.test.tsx | 15 +++++++++++++++
2 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx
index 58980d82..1a61d68c 100644
--- a/frontend-v2/src/views/projects/Roadmap.tsx
+++ b/frontend-v2/src/views/projects/Roadmap.tsx
@@ -128,7 +128,7 @@ import {
NO_PROJECT_YET,
} from '../../model/words';
import { DecideCall, useCallsMadeHere } from './Decide';
-import { askOf, reasonWords } from '../../model/decideCalls';
+import { askOf, isHomeReason, reasonWords } from '../../model/decideCalls';
import { HomeLine } from './Initiatives';
import {
initiativeLanes,
@@ -1054,6 +1054,15 @@ function PlanPanel({
initiatives={initiatives}
navigate={navigate}
onFile={onFile}
+ // filing is offered once: the Plan line's answer
+ // files it while Decide asks where it goes
+ movable={
+ !row.reasons.some(
+ r =>
+ isHomeReason(r) ||
+ (r.kind === 'new' && !homesOf(initiatives).has(project))
+ )
+ }
/>
) : (
diff --git a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx
index a6355ee7..9caa00a1 100644
--- a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx
+++ b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx
@@ -413,6 +413,21 @@ describe('File elsewhere in a plan’s details', () => {
});
});
+describe('a plan’s details while Decide asks where its project goes', () => {
+ it('offer filing once: the Plan line’s File, and no File elsewhere', async () => {
+ // Cache warmer's project is filed nowhere
+ server = START.map(i =>
+ i.slug === 'site-speed' ? { ...i, projects: i.projects.filter(f => f.project !== 'cache') } : i
+ );
+ await loadInitiatives();
+ const unfiled: DecideRow = { slug: 'cache', item: null, reasons: [{ kind: 'unfiled' }] };
+ const el = await mount({ item: 3 }, { decisions: [unfiled] });
+ const details = el.querySelector('[aria-label="Cache warmer: its plan"]') as HTMLElement;
+ expect(details.querySelector('[aria-label^="File cache"]')).not.toBeNull();
+ expect(details.querySelector('[aria-label^="File elsewhere"]')).toBeNull();
+ });
+});
+
describe('the Roadmap by initiative', () => {
it('opens a linked plan’s details without putting the focus on an end to promise', async () => {
// nobody has clicked or pressed a key on the page yet
From 836abd7df29dbc66ef1dc77e629d07ecc799b92d Mon Sep 17 00:00:00 2001
From: Jarred Stelfox
Date: Mon, 12 Oct 2026 04:35:57 -0700
Subject: [PATCH 096/148] Say Cleared when an edit empties a field
Clearing an initiative's goal said "Saved its goal." It now says
"Cleared its goal." (or "Saved its name and cleared its goal."). A failed
Undo of an edit keeps its reason and now a Try again, as the Roadmap's
receipts and the project page's do.
Co-Authored-By: Claude Opus 5.5
---
.../src/views/projects/HomeCalls.test.tsx | 15 ++++++-
.../src/views/projects/Initiatives.tsx | 39 ++++++++++++-------
2 files changed, 39 insertions(+), 15 deletions(-)
diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx
index f0ece757..c294945c 100644
--- a/frontend-v2/src/views/projects/HomeCalls.test.tsx
+++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx
@@ -896,7 +896,20 @@ describe('an initiative’s page', () => {
click(el, 'Undo');
await settle();
expect(el.textContent).toContain('the database is down');
- expect(buttons(el)).toContain('Close');
+ expect(buttons(el)).toContain('Try again');
+ });
+
+ it('says a goal emptied was cleared, not saved', async () => {
+ const el = page('onboarding');
+ click(el, 'Edit');
+ const goal = el.querySelectorAll('input')[1] as HTMLInputElement;
+ act(() => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(goal, '');
+ goal.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ click(el, 'Save');
+ await settle();
+ expect(el.textContent).toContain('Cleared its goal.');
});
});
diff --git a/frontend-v2/src/views/projects/Initiatives.tsx b/frontend-v2/src/views/projects/Initiatives.tsx
index ae9d0e1c..b6d61846 100644
--- a/frontend-v2/src/views/projects/Initiatives.tsx
+++ b/frontend-v2/src/views/projects/Initiatives.tsx
@@ -337,20 +337,29 @@ function EditFields({
const back = Object.fromEntries(
Object.keys(changed).map(k => [k, (r.was as unknown as Record)[k]])
) as Partial;
+ // what it did: a field emptied was cleared, not saved
+ const its = (keys: string[]) => andList(keys.map(k => `its ${k}`));
+ const keys = Object.keys(changed) as (keyof InitiativeFields)[];
+ const set = keys.filter(k => changed[k]);
+ const cleared = keys.filter(k => !changed[k]);
+ const undo = () =>
+ void updateInitiative(initiative.id, back, {
+ undo: {
+ updated_at: r.was.updated_at ?? Math.floor(Date.now() / 1000),
+ status_at: r.was.status_at,
+ },
+ }).then(u =>
+ // a failed Undo keeps its receipt, saying why, and a way to try again
+ onDone('error' in u ? { words: u.error.replace(/\.$/, ''), failed: true, undo } : null)
+ );
onDone({
- words: `Saved ${andList(
- Object.keys(changed).map(k => (k === 'owner' ? 'its owner' : `its ${k}`))
- )}`,
- undo: () =>
- void updateInitiative(initiative.id, back, {
- undo: {
- updated_at: r.was.updated_at ?? Math.floor(Date.now() / 1000),
- status_at: r.was.status_at,
- },
- }).then(u =>
- // a failed Undo keeps its receipt, saying why
- onDone('error' in u ? { words: u.error.replace(/\.$/, ''), failed: true } : null)
- ),
+ words: [
+ set.length ? `Saved ${its(set)}` : '',
+ cleared.length ? `${set.length ? 'cleared' : 'Cleared'} ${its(cleared)}` : '',
+ ]
+ .filter(Boolean)
+ .join(' and '),
+ undo,
});
};
const field = `h-8 w-full px-2.5 text-[13px] text-ink ${textInputClass}`;
@@ -637,7 +646,9 @@ export function InitiativePage({
{receipt.words}.
{receipt.undo ? (
- Undo
+
+ {receipt.failed ? 'Try again' : 'Undo'}
+
) : (
setReceipt(null)}>Close
)}
From 880417749c4c1f1abdaf747e33f218c0849cef84 Mon Sep 17 00:00:00 2001
From: Jarred Stelfox
Date: Mon, 12 Oct 2026 04:36:48 -0700
Subject: [PATCH 097/148] Say what Decide's Initiative switch counts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
With many projects, Decide's switch said "Unsorted · 8" and the
Roadmap's band "Unsorted · 13". Both were right: the switch counts the
decisions owed, the band its rows, and not every row is asked about.
The switch now says "Unsorted · 8 owed", so the two read as different
counts rather than one that disagrees.
Co-Authored-By: Claude Opus 5.5
---
frontend-v2/src/views/projects/HomeCalls.test.tsx | 8 ++++----
frontend-v2/src/views/projects/HomeCalls.tsx | 6 ++++--
2 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx
index c294945c..a8428b8b 100644
--- a/frontend-v2/src/views/projects/HomeCalls.test.tsx
+++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx
@@ -771,8 +771,8 @@ describe('Decide’s Initiative switch', () => {
);
expect([...el.querySelectorAll('option')].map(o => o.textContent)).toEqual([
'All initiatives',
- 'Site speed · 2',
- 'Catalog · 1',
+ 'Site speed · 2 owed',
+ 'Catalog · 1 owed',
]);
});
@@ -789,8 +789,8 @@ describe('Decide’s Initiative switch', () => {
const options = [...el.querySelectorAll('option')];
expect(options.map(o => o.textContent)).toEqual([
'All initiatives',
- 'Unsorted · 3',
- 'No project yet · 1',
+ 'Unsorted · 3 owed',
+ 'No project yet · 1 owed',
]);
// an initiative may be called "unsorted"; these keys can't be its slug
for (const o of options.slice(1)) expect(PROJECT_SLUG.test(o.value)).toBe(false);
diff --git a/frontend-v2/src/views/projects/HomeCalls.tsx b/frontend-v2/src/views/projects/HomeCalls.tsx
index c319b653..8501fd73 100644
--- a/frontend-v2/src/views/projects/HomeCalls.tsx
+++ b/frontend-v2/src/views/projects/HomeCalls.tsx
@@ -525,7 +525,7 @@ export function HomeStrip({
/**
* Decide's Initiative switch, beside the team switch: an owner runs their
- * slice. A select, since there can be twenty; each names how many are owed,
+ * slice. A select, since there can be twenty; each says how many are owed,
* and only the ones with something owed are offered (and the one picked):
* the open homes in rank order, a closed initiative while a project in it
* is asked about, then Unsorted and No project yet.
@@ -542,7 +542,9 @@ export function InitiativeSwitch({
onChange: (slug: string | null) => void;
}) {
const id = useId();
- const counted = (label: string, count: number) => (count ? `${label} · ${count}` : label);
+ // what it counts, said: a band elsewhere counts its rows, which aren't
+ // all asked about (Unsorted · 13 on the Roadmap holds 8 owed here)
+ const counted = (label: string, count: number) => (count ? `${label} · ${count} owed` : label);
const closed = byRank(initiatives).filter(i => i.status !== 'open');
const offered = [
...[...homeChoices(initiatives), ...closed].map(i => [i.slug, i.name] as const),
From c2ce1a2a8556dc713d12740f9e06d7c6b7604017 Mon Sep 17 00:00:00 2001
From: Jarred Stelfox
Date: Mon, 12 Oct 2026 04:38:30 -0700
Subject: [PATCH 098/148] Line up a lane's unplanned names with its plans'
In a lane, a project with no plan started its name 24 px left of the
numbered plans (152 against 176 at 1440), while a Not tracked plan with
no number kept the number's column. An unplanned row now keeps that
column empty too, so every name in a lane starts at one x, at 1440 and
at 390, by initiative and by team.
Co-Authored-By: Claude Opus 5.5
---
frontend-v2/src/views/projects/Roadmap.tsx | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx
index 1a61d68c..fe65a9b7 100644
--- a/frontend-v2/src/views/projects/Roadmap.tsx
+++ b/frontend-v2/src/views/projects/Roadmap.tsx
@@ -1840,6 +1840,10 @@ function InFlightRow({
)}
+ {/* the plan rows' rank column, empty: no plan, no place in the
+ order, but the names line up (the plus is 4px wider than the
+ grip it stands for) */}
+
Date: Mon, 12 Oct 2026 04:38:54 -0700
Subject: [PATCH 099/148] Record round 5's rules in DESIGN.md
The press guard after a click, where the result goes and when Undo's
held words leave, one Undo for a run of lane moves, the j and k landing
rule, the one opener behaviour and Link's layout, filing offered once,
and the Overview's initiative bands taking the Roadmap's layout.
Co-Authored-By: Claude Opus 5.5
---
DESIGN.md | 36 ++++++++++++++++++++++++++++++++----
1 file changed, 32 insertions(+), 4 deletions(-)
diff --git a/DESIGN.md b/DESIGN.md
index 58d85cef..5ed9c78e 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -328,6 +328,11 @@ icon with a class bolted on.
- **j and k move between a list's rows** wherever a list is long enough to
triage (`components/useRowKeys.ts`), opening a "+ N more" on the way past
so every row is reachable, the way they move between PRs on the board.
+ They land on a row's first target that can be pressed; a target that's
+ off (an answer waiting on its chooser) gives way to the row's first
+ control that isn't a field (its name: j typed in a select picks an
+ option), and a row with nowhere to land is stepped past, so j and k reach
+ every row from every row.
- **One filled button, one failure line.** `PrimaryButton` ends a form;
a failed load is `LoadFailed` (grey, with Try again: nobody owes a
failed fetch, so never amber, and red stays CI's); a save confirms in
@@ -865,7 +870,11 @@ icon with a class bolted on.
"Priority" is the one word for order, for lanes and rows alike, on grips,
hovers and receipts; a receipt names the number its row shows ("Moved
Cache warmer to priority 1 in Site speed"). A band with no grip keeps
- the grip's column, so every label starts at one x. A closed initiative
+ the grip's column, so every label starts at one x, and a row with no
+ number keeps the number's, so every name in a lane does too. An
+ initiative's band, on the Roadmap and the Overview alike, ends a long
+ name in an ellipsis, keeps its count, and on a phone puts its detail on
+ its own line. A closed initiative
says "Closed as done Sep 20" everywhere; an open one whose plans all
stopped says "Nothing under way since Sep 20".
"File" is the one word for putting a project in an initiative: the
@@ -875,6 +884,13 @@ icon with a class bolted on.
File Cancel". An Unsorted row's File is its answer; Track it, File
elsewhere, a plan's details and a project's page open it with Cancel;
Needs a plan shows it alone, since its plan answer carries the pick.
+ A plan with no project asks the same way: "Its project [select] Link".
+ An opener (Track it, File elsewhere) stays where it is on every view,
+ says it's open, and opens the chooser on the line below; a second click
+ on it closes it. With nothing offered chosen (a home that closed since),
+ the chooser says "Choose where it goes". Filing is offered once: while
+ a plan's Plan line asks where its project goes, its Initiative line
+ only says the home.
The chooser shows only while the project is filed nowhere; a filed
project's plan answer stays a plan answer. Filing from a plan's details
is a drop's keyboard twin: its receipt and Undo sit under the row in
@@ -888,9 +904,21 @@ icon with a class bolted on.
opened it**: a chooser (not the details around it), the answers Other
answers opened, an edit form. An answer strip's one Tab stop is its
first answer that can be pressed, so an answer waiting on an empty
- chooser never hides Other answers from the keyboard. Undo's held words
- can't be selected, and where the page can't scroll up to keep them
- under the pointer, a gap holds them there through a triple click.
+ chooser never hides Other answers from the keyboard.
+- **After a click, nothing new can be clicked under a still pointer**
+ (`components/settle.ts`). A click often changes what sits under the
+ pointer: a receipt replaces the answers, a row joins a section above, a
+ lane opens. On every view the initiatives feature draws on, a pointer
+ click (or a drop) holds its point, and a press within 24 px of it
+ reaches nothing until the pointer moves away or the page scrolls, or
+ 1.6 s with the pointer still. So a double or triple click is one click,
+ and a press "to be sure" a second later does nothing. What the click
+ left (a receipt, Undo's words) gets the focus and is shown whole below
+ the sticky headers, wherever it went. Undo's held words ("Put X back as
+ it was.") stand where the receipt was until the pointer moves away, a
+ Tab, or the next action. A failed Undo says the server's reason and
+ keeps Try again. A run of lane moves, on one lane or several, keeps one
+ receipt whose Undo puts the whole order back.
"Unsorted" is the one word for a project filed nowhere, on every band,
chooser, hover and split (the sentence form is "filed nowhere"). A plan
with no project can't be filed, so it sits in its own "No project yet"
From 6e60167dce91aafbdcac997136dd4787bb594e5b Mon Sep 17 00:00:00 2001
From: Jarred Stelfox
Date: Mon, 12 Oct 2026 04:44:17 -0700
Subject: [PATCH 100/148] Never drop a row's focus on an answer that's off
When Undo's held words left, or a row's answer came back, the focus went
to the row's first answer, which can be off (Link, File, waiting on a
pick again), so it fell to the page. Found in the browser after a Link's
Undo. It now goes where j and k land: the first answer that can be
pressed, else the row's name.
Co-Authored-By: Claude Opus 5.5
---
frontend-v2/src/views/projects/Decide.tsx | 21 +++++++----
.../src/views/projects/HomeCalls.test.tsx | 37 +++++++++++++++++++
2 files changed, 51 insertions(+), 7 deletions(-)
diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx
index 8ac35a01..534ddb7e 100644
--- a/frontend-v2/src/views/projects/Decide.tsx
+++ b/frontend-v2/src/views/projects/Decide.tsx
@@ -212,6 +212,18 @@ const calls = createMemoryStore<{ made: ReadonlyMap }>({
made: new Map(),
});
+/** Where the focus goes on a row once its answer is back: the answer that
+ * can be pressed, else the row's first control (its name), as j and k
+ * land; never an answer that's off, which would drop it on the page. */
+function leadOf(root: HTMLElement | null): HTMLElement | null {
+ const row = root?.closest('[data-decide-row]') ?? root;
+ return (
+ root?.querySelector('[data-decide-focus]:not(:disabled)') ??
+ row?.querySelector('button:not(:disabled)') ??
+ null
+ );
+}
+
/** Forget every call made (tests start clean). */
export function forgetCalls(): void {
calls.set({ made: new Map() });
@@ -680,10 +692,7 @@ export function DecideCall({
const letGo = () => {
const had = !!putBackRef.current?.contains(document.activeElement);
flushSync(() => setUndid(u => (u?.held ? { height: 0, held: false } : u)));
- if (had)
- rootRef.current
- ?.querySelector('[data-decide-focus]')
- ?.focus({ preventScroll: true });
+ if (had) leadOf(rootRef.current)?.focus({ preventScroll: true });
};
// the height the row had when a click here changed it, kept as the least
// it has, so the rows below never slide under the pointer, a phone's
@@ -707,9 +716,7 @@ export function DecideCall({
refocus.current = false;
// after an Undo, its held words; j and k still land on the answer.
// Shown below the sticky headers, wherever the row went
- focusResult(
- putBackRef.current ?? rootRef.current?.querySelector('[data-decide-focus]')
- );
+ focusResult(putBackRef.current ?? leadOf(rootRef.current));
}, [view]);
const act = (fn: () => void) => () => {
refocus.current = true;
diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx
index a8428b8b..27191911 100644
--- a/frontend-v2/src/views/projects/HomeCalls.test.tsx
+++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx
@@ -726,6 +726,43 @@ describe('a double-click on Link', () => {
});
});
+describe('the focus once Undo’s words go', () => {
+ it('lands on the row’s name when its answer is off, never on the page', async () => {
+ const row: DecideRow = { slug: null, item: unlinked, reasons: [{ kind: 'no_project' }] };
+ const el = mount(
+
+
+
+ );
+ const select = el.querySelector('select') as HTMLSelectElement;
+ act(() => {
+ select.value = 'cache';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ click(el, 'Link');
+ await settle();
+ const undo = [...el.querySelectorAll('button')].find(
+ b => b.textContent === 'Undo'
+ ) as HTMLButtonElement;
+ pressAt(undo, 50, 50);
+ await settle();
+ // Link waits off again for a project; the words go as the pointer leaves
+ act(() => {
+ document.dispatchEvent(
+ new MouseEvent('pointermove', { bubbles: true, clientX: 300, clientY: 300 })
+ );
+ });
+ expect(document.activeElement?.textContent).toBe('Checkout redesign');
+ });
+});
+
describe('a project whose closed initiative and done plan are both asked about', () => {
const both: DecideRow = {
slug: 'akeneo',
From 7071f71936e5f7c8b6709b02b546e4a149a6346b Mon Sep 17 00:00:00 2001
From: Jarred Stelfox
Date: Mon, 12 Oct 2026 04:51:32 -0700
Subject: [PATCH 101/148] Show a receipt's row with it, not under a header
A details filing into a lane above showed its receipt just below the
timeline header, with the plan's own line scrolled under it, so the
details read as a plan with no name. The receipt is now shown from the
top of its row, so the row's line, the receipt and its Undo are all in
view.
Co-Authored-By: Claude Opus 5.5
---
frontend-v2/src/views/projects/Roadmap.tsx | 7 +++++--
frontend-v2/src/views/projects/RoadmapLanes.test.tsx | 12 ++++++++----
2 files changed, 13 insertions(+), 6 deletions(-)
diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx
index fe65a9b7..aff994c0 100644
--- a/frontend-v2/src/views/projects/Roadmap.tsx
+++ b/frontend-v2/src/views/projects/Roadmap.tsx
@@ -2980,8 +2980,11 @@ export function Roadmap({
// way): it waits for the render that draws it
if (!el) return;
refocus.current = null;
- // the receipt beside it shows too, below the sticky headers
- focusResult(el, receipt && document.querySelector(`[data-receipt="${receipt.key}"]`));
+ // the receipt beside it shows too, below the sticky headers, from
+ // the top of its row, so a plan's name is never what's hidden
+ const said = receipt && document.querySelector(`[data-receipt="${receipt.key}"]`);
+ const row = said?.closest('[data-roadmap-row]');
+ focusResult(el, said, row?.firstElementChild as HTMLElement | null);
});
const planRows = (
diff --git a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx
index 9caa00a1..5751d4f9 100644
--- a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx
+++ b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx
@@ -668,7 +668,8 @@ const pointerTo = (x: number, y: number) =>
describe('after a click on the Roadmap', () => {
/** Lay the page out as a browser would, as far as these tests look: a
- * sticky header 120 px tall, and every receipt at `receiptTop`. */
+ * sticky header 120 px tall, every receipt at `receiptTop`, its row's
+ * line 40 px above it, and nothing else with a box. */
function layout(receiptTop: () => number) {
const header = document.createElement('div');
header.className = 'sticky';
@@ -681,7 +682,9 @@ describe('after a click on the Roadmap', () => {
HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
if (this === header) return box(0, 120);
if (this.closest('[data-receipt]')) return box(receiptTop(), 30);
- return box(0, 0);
+ if (this.parentElement?.matches('[data-roadmap-row]') && this === this.parentElement.firstElementChild)
+ return box(receiptTop() - 40, 40);
+ return { ...box(0, 0), width: 0, right: 0 } as DOMRect;
};
const scrolled: number[] = [];
const spy = vi.spyOn(window, 'scrollBy').mockImplementation(((_x: number, y: number) => {
@@ -719,8 +722,9 @@ describe('after a click on the Roadmap', () => {
expect((document.activeElement as HTMLElement).textContent).toBe(
'Filed Answer search in Site speed.'
);
- // brought down to just below the header, and no further
- expect(page.scrolled.at(-1)).toBe(50 - 120 - 8);
+ // brought down to just below the header with its row's line, and
+ // no further
+ expect(page.scrolled.at(-1)).toBe(50 - 40 - 120 - 8);
} finally {
page.restore();
}
From e7d6ffef1153a0f5d3e69b6576788ba54a4908a6 Mon Sep 17 00:00:00 2001
From: Jarred Stelfox
Date: Mon, 12 Oct 2026 05:28:31 -0700
Subject: [PATCH 102/148] Let only a person's click start or end a hold
The press guard took any click in its views for an action, code's own
.click() too (opening a "+ N more" on the way to a row, as the project
page's PR lists do after a load). Such a click ended the hold a
person's click had just started, so the presses after it reached
whatever had moved there. Only a trusted click counts now; jsdom makes
none, so tests count every click.
Co-Authored-By: Claude Opus 5.5
---
frontend-v2/src/components/settle.test.ts | 19 +++++++++++++++++++
frontend-v2/src/components/settle.ts | 6 ++++++
2 files changed, 25 insertions(+)
diff --git a/frontend-v2/src/components/settle.test.ts b/frontend-v2/src/components/settle.test.ts
index b222d27c..091baa27 100644
--- a/frontend-v2/src/components/settle.test.ts
+++ b/frontend-v2/src/components/settle.test.ts
@@ -125,6 +125,25 @@ describe('the press guard', () => {
expect(outsideHits).toBe(2);
});
+ it('lets code’s own click (opening a "+ N more") start nothing and end nothing', () => {
+ // outside tests, only a person's press counts: jsdom makes no trusted ones
+ vi.stubEnv('MODE', 'production');
+ try {
+ const { first, other, hits } = region();
+ guardAt(100, 100);
+ const gone = vi.fn();
+ onLeave(gone);
+ first.click();
+ expect(hits.first).toBe(1);
+ expect(gone).not.toHaveBeenCalled();
+ const down = new MouseEvent('pointerdown', { bubbles: true, cancelable: true, clientX: 100, clientY: 100 });
+ other.dispatchEvent(down);
+ expect(down.defaultPrevented).toBe(true);
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
it('holds at a drop’s point', () => {
const { other, hits } = region();
guardAt(50, 60);
diff --git a/frontend-v2/src/components/settle.ts b/frontend-v2/src/components/settle.ts
index 8ffedbc0..ed2cb3a7 100644
--- a/frontend-v2/src/components/settle.ts
+++ b/frontend-v2/src/components/settle.ts
@@ -79,6 +79,11 @@ export function clearHolds(): void {
hold = null;
}
+/** A press a person made: code's own `.click()` (opening a "+ N more"
+ * on the way to a row) is no action and starts no hold. jsdom makes no
+ * trusted events, so tests count every one. */
+const real = (e: Event) => e.isTrusted || import.meta.env.MODE === 'test';
+
if (typeof window !== 'undefined') {
const eat = (e: Event) => {
e.preventDefault();
@@ -94,6 +99,7 @@ if (typeof window !== 'undefined') {
addEventListener(
'click',
e => {
+ if (!real(e)) return;
// a key's click (detail 0) is never a stray press
if (e.detail > 0 && guarding(e)) return eat(e);
const control = (e.target as Element | null)?.closest?.(CONTROL);
From d421a37d6c1b59411aa13b0256c96608d2b32650 Mon Sep 17 00:00:00 2001
From: Jarred Stelfox
Date: Mon, 12 Oct 2026 05:59:49 -0700
Subject: [PATCH 103/148] Judge a press by when it was made
The hold compared a press's handling time with its end, so on a page
busy with a long render (the project page in dev, a slow laptop) a
double-click's second press was handled after the hold had run out and
reached what had moved there. Presses are now judged by their own time
stamp, when the person made them, and the hold starts from the click's.
Co-Authored-By: Claude Opus 5.5
---
frontend-v2/src/components/settle.test.ts | 12 ++++++++++++
frontend-v2/src/components/settle.ts | 15 +++++++++++----
2 files changed, 23 insertions(+), 4 deletions(-)
diff --git a/frontend-v2/src/components/settle.test.ts b/frontend-v2/src/components/settle.test.ts
index 091baa27..7a1fd205 100644
--- a/frontend-v2/src/components/settle.test.ts
+++ b/frontend-v2/src/components/settle.test.ts
@@ -99,6 +99,18 @@ describe('the press guard', () => {
expect(hits.other).toBe(1);
});
+ it('judges a press by when it was made, not when a busy page got to it', () => {
+ const { first, other, hits } = region();
+ pressAt(first, 100, 100);
+ // a long render: the page reads the second press of a double-click
+ // two seconds on, though it was made a moment after the first
+ now += 2000;
+ const click = new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 100, clientY: 100, detail: 2 });
+ Object.defineProperty(click, 'timeStamp', { value: 1080 });
+ other.dispatchEvent(click);
+ expect(hits.other).toBe(0);
+ });
+
it('holds at least a second and a half', () => {
expect(HOLD_MS).toBeGreaterThanOrEqual(1500);
});
diff --git a/frontend-v2/src/components/settle.ts b/frontend-v2/src/components/settle.ts
index ed2cb3a7..ec1ce5b1 100644
--- a/frontend-v2/src/components/settle.ts
+++ b/frontend-v2/src/components/settle.ts
@@ -51,7 +51,14 @@ let hold: Hold | null = null;
const near = (h: Hold, e: MouseEvent) =>
h.x != null && Math.hypot(e.clientX - h.x, e.clientY - h.y) <= GUARD_PX;
-const guarding = (e: MouseEvent) => !!hold && near(hold, e) && performance.now() < hold.until;
+/** When a press was made, not when the page got to it: a double-click
+ * made while a long render held the page is handled after it, and must
+ * still count as made inside the hold. (jsdom stamps events with
+ * Date.now(), another clock, so there it's now.) */
+const madeAt = (e: Event) =>
+ e.timeStamp > 0 && e.timeStamp <= performance.now() + 1 ? e.timeStamp : performance.now();
+
+const guarding = (e: MouseEvent) => !!hold && near(hold, e) && madeAt(e) < hold.until;
/** The pointer left, or a new action began: the hold and its words go. */
function leave(): void {
@@ -62,9 +69,9 @@ function leave(): void {
/** Start a hold at a point (a click, a drop), or with none (a key's
* click): the hold before it ends, as the next action. */
-export function guardAt(x: number | null, y = 0): void {
+export function guardAt(x: number | null, y = 0, at = performance.now()): void {
leave();
- hold = { x, y, until: x == null ? 0 : performance.now() + HOLD_MS, leave: [] };
+ hold = { x, y, until: x == null ? 0 : at + HOLD_MS, leave: [] };
}
/** Run `fn` when the current hold ends by the pointer moving away or the
@@ -104,7 +111,7 @@ if (typeof window !== 'undefined') {
if (e.detail > 0 && guarding(e)) return eat(e);
const control = (e.target as Element | null)?.closest?.(CONTROL);
if (!control?.closest('[data-press-guard]')) return;
- guardAt(e.detail > 0 ? e.clientX : null, e.clientY);
+ guardAt(e.detail > 0 ? e.clientX : null, e.clientY, madeAt(e));
},
true
);
From e695310c2bc88542d8be1aca1bfe30ce67f1dbbe Mon Sep 17 00:00:00 2001
From: Jarred Stelfox
Date: Mon, 12 Oct 2026 07:23:17 -0700
Subject: [PATCH 104/148] Remove a stray copy of HomeCalls' tests
HomeCalls.test.tsx.orig was committed in 9b8ce252 by mistake: a patch
leftover, byte for byte the test file before that commit. Nothing runs
it (vitest and eslint skip .orig), so it only misleads a grep and
inflates the diff.
Co-Authored-By: Claude Opus 5.5
---
.../views/projects/HomeCalls.test.tsx.orig | 1271 -----------------
1 file changed, 1271 deletions(-)
delete mode 100644 frontend-v2/src/views/projects/HomeCalls.test.tsx.orig
diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx.orig b/frontend-v2/src/views/projects/HomeCalls.test.tsx.orig
deleted file mode 100644
index 41941783..00000000
--- a/frontend-v2/src/views/projects/HomeCalls.test.tsx.orig
+++ /dev/null
@@ -1,1271 +0,0 @@
-// @vitest-environment jsdom
-import { act } from 'react';
-import { createRoot, type Root } from 'react-dom/client';
-import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
-import type { DecideRow } from '../../../../shared/model/decide';
-import type { Initiative } from '../../../../shared/model/initiatives';
-import type { RoadmapItem } from '../../../../shared/model/roadmap';
-import { fileProject, loadInitiatives } from '../../model/initiativesData';
-import type { PortfolioItem } from '../../model/portfolio';
-import { HomeLine, InitiativePage } from './Initiatives';
-import { clearHomePicks, InitiativeSwitch } from './HomeCalls';
-import { clearHolds } from '../../components/settle';
-import { NO_PROJECT, UNSORTED } from '../../model/initiativeBoard';
-import { PROJECT_SLUG } from '../../../../shared/model/roadmap';
-import type { ProjectsNav } from './parts';
-import {
- DecideCall,
- DecideRowView,
- keepCalls,
- SectionCalls,
- useKeptCalls,
- type Made,
-} from './Decide';
-import {
- AllItemsContext,
- answerWords,
- callWords,
- rowKey,
- sectionOf,
-} from '../../model/decideCalls';
-
-// React's act() warns unless the environment says it's a test
-(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
-
-const initiative = (
- id: number,
- slug: string,
- name: string,
- projects: string[],
- over: Partial = {}
-): Initiative => ({
- id,
- slug,
- name,
- kind: 'initiative',
- goal: '',
- owner: 'dana',
- priority: id,
- status: 'open',
- status_at: null,
- created_by: null,
- created_at: null,
- updated_by: null,
- updated_at: 100,
- projects: projects.map(project => ({ project, set_by: null, set_at: 50, baseline_prs: 0 })),
- ...over,
-});
-
-const INITIATIVES = [
- initiative(1, 'site-speed', 'Site speed', ['cache', 'filed']),
- initiative(2, 'fixbot', 'FixBot chat', ['answers']),
- initiative(3, 'catalog', 'Catalog', ['akeneo'], { status: 'done' }),
- initiative(4, 'onboarding', 'Onboarding', ['emails'], {
- goal: 'New customers get a welcome series',
- }),
- initiative(5, 'fragile', 'Fragile', []),
- // Not tracked under a slug of its own: the board finds it by its kind
- initiative(9, 'shelf', 'Not tracked', ['ops'], { kind: 'untracked' }),
-];
-
-const created: RoadmapItem = {
- id: 77,
- name: 'New thing',
- project: 'new-thing',
- team: null,
- lead: null,
- status: 'active',
- origin: null,
- start: '2026-10-05',
- weeks: 4,
- end_kind: 'hard',
- done_when: '',
- priority: 9,
- notes: '',
- waits_on: [],
- updated_by: null,
- updated_at: 1,
- created_at: 1,
- update: null,
-};
-
-/** An active plan with no project. */
-const unlinked: RoadmapItem = { ...created, id: 5, name: 'Checkout redesign', project: null };
-
-/** The server, by route: each write answers as the real one would. */
-const calls: { method: string; path: string; body: unknown }[] = [];
-let flakyFailed = false;
-beforeAll(async () => {
- vi.stubGlobal(
- 'fetch',
- vi.fn(async (path: string, init?: RequestInit) => {
- const method = init?.method ?? 'GET';
- const body = init?.body ? JSON.parse(String(init.body)) : null;
- calls.push({ method, path, body });
- const reply = (json: unknown, status = 200) =>
- ({ status, redirected: false, json: async () => json } as Response);
- if (path === '/initiatives' && method === 'GET')
- return reply({ initiatives: INITIATIVES });
- if (path === '/initiatives/file') {
- // as the server: nothing into a closed initiative but an undo
- if (body.initiative === 'catalog' && body.set_at == null)
- return reply(
- { error: 'that initiative is closed; reopen it before filing a project in it' },
- 409
- );
- // someone filed it elsewhere since the board last looked
- if (body.project === 'raced')
- return reply(
- {
- error: 'its home changed; here it is now',
- home: { initiative: 'fixbot', set_at: 60, baseline_prs: 0 },
- },
- 409
- );
- const before =
- body.project === 'ops'
- ? { initiative: 'shelf', set_at: 50, baseline_prs: 2 }
- : body.project === 'filed'
- ? { initiative: 'site-speed', set_at: 50, baseline_prs: 0 }
- : { initiative: null, set_at: null, baseline_prs: 0 };
- return reply({ project: body.project, before, home: null });
- }
- if (path.startsWith('/initiatives/')) {
- const id = Number(path.split('/')[2]);
- // a database gone between a save and its Undo
- if (id === 5 && body.undo) return reply({ error: 'the database is down' }, 500);
- const was = INITIATIVES.find(i => i.id === id) ?? INITIATIVES[3];
- return reply({ initiative: { ...was, ...body } });
- }
- if (path === '/roadmap' && method === 'POST') {
- // a plan that fails once, after its filing saved
- if (body.project === 'flaky' && !flakyFailed) {
- flakyFailed = true;
- return reply({ error: 'the database is down' }, 500);
- }
- return reply({ item: created }, 201);
- }
- if (path === '/roadmap' && method === 'GET') return reply({ items: [] });
- if (path.startsWith('/roadmap/') && method === 'DELETE') return reply({ ok: true });
- if (path.startsWith('/roadmap/') && method === 'PATCH')
- return reply({ item: { ...unlinked, ...body } });
- return reply({}, 404);
- })
- );
- await loadInitiatives();
-});
-
-let host: HTMLDivElement | null = null;
-// every tree a test mounts, unmounted after it, so none listens on to the
-// stores the next test writes
-const roots: Root[] = [];
-afterEach(() => {
- act(() => roots.splice(0).forEach(r => r.unmount()));
- clearHomePicks();
- clearHolds();
- host?.remove();
- host = null;
- calls.length = 0;
-});
-
-const person = (slug: string, developers: string[]) =>
- ({ slug, name: slug, developers, nonDevelopers: [] } as unknown as PortfolioItem);
-const ITEMS = [
- person('new-thing', ['erin']),
- person('cache', ['dana']),
- person('answers', ['erin']),
- person('filed', ['dana']),
- person('flaky', ['erin']),
- // nobody it shares people with: no guess
- person('lonely', ['zed']),
- person('bulk-a', ['erin']),
- person('bulk-b', ['dana']),
- person('ops', ['dana']),
-];
-
-function render(row: DecideRow): HTMLDivElement {
- return mount( );
-}
-
-function mount(node: React.ReactNode): HTMLDivElement {
- host = document.createElement('div');
- document.body.append(host);
- const root = createRoot(host);
- roots.push(root);
- act(() => root.render({node} ));
- return host;
-}
-
-const buttons = (el: HTMLElement) => [...el.querySelectorAll('button')].map(b => b.textContent);
-const click = (el: HTMLElement, words: string) =>
- act(() =>
- [...el.querySelectorAll('button')].find(b => b.textContent?.startsWith(words))?.click()
- );
-/** the chooser's File button, the answer or the opened chooser's */
-const fileButton = (el: HTMLElement) =>
- [...el.querySelectorAll('button')].find(b => /^File(Suggested)?$/.test(b.textContent ?? ''));
-/** a callback the test doesn't watch */
-const nothing = () => undefined;
-const settle = () => act(() => new Promise(resolve => setTimeout(resolve, 0)));
-/** A mouse's press at a point onto `el`, whatever sits there now. */
-const pressAt = (el: Element, x: number, y: number) =>
- act(() => {
- const at = { bubbles: true, cancelable: true, clientX: x, clientY: y };
- for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup'])
- el.dispatchEvent(new MouseEvent(type, at));
- el.dispatchEvent(new MouseEvent('click', { ...at, detail: 1 }));
- });
-
-describe('a row that asks about an initiative', () => {
- const goal: DecideRow = {
- slug: null,
- item: null,
- reasons: [{ kind: 'goal_reached', initiative: 4 }],
- };
-
- it('suggests closing it as done, with dropped and Keep open a click away', () => {
- const el = render(goal);
- expect(buttons(el)).toEqual(['Close as doneSuggested', 'Other answers']);
- click(el, 'Other answers');
- // the button clicked stays where it was; the others come after it
- expect(buttons(el)).toEqual([
- 'Close as doneSuggested',
- 'Other answers',
- 'Close as dropped',
- 'Keep open',
- ]);
- });
-
- it('keeps it open by saying its status again, and Undo puts back the times', async () => {
- const el = render(goal);
- click(el, 'Other answers');
- click(el, 'Keep open');
- await settle();
- expect(el.textContent).toContain(
- 'Kept open. Decide asks again once something in it changes.'
- );
- expect(calls.at(-1)).toMatchObject({
- method: 'PATCH',
- path: '/initiatives/4',
- body: { status: 'open', restate: true },
- });
- click(el, 'Undo');
- await settle();
- expect(calls.at(-1)).toMatchObject({
- method: 'PATCH',
- path: '/initiatives/4',
- body: { status: 'open', undo: { updated_at: 100, status_at: null } },
- });
- });
-
- it('suggests reopening a closed one a project is worked on in, or moving the project', () => {
- const el = render({
- slug: 'akeneo',
- item: null,
- reasons: [{ kind: 'reopened_in_closed', initiative: 3, as: 'done' }],
- });
- expect(buttons(el)).toEqual(['Reopen CatalogSuggested', 'File elsewhere']);
- click(el, 'File elsewhere');
- // the chooser leaves out the closed home, and offers Not tracked last,
- // as every other chooser does
- const options = [...el.querySelectorAll('option')].map(o => o.textContent);
- expect(options).not.toContain('Catalog');
- expect(options.at(-1)).toBe('Not tracked');
- });
-});
-
-describe('a Not tracked project that grew', () => {
- const grew: DecideRow = {
- slug: 'ops',
- item: null,
- reasons: [{ kind: 'untracked_grew', since: 31, baseline: 2 }],
- };
-
- it('suggests keeping it not tracked, which files it there again', async () => {
- const el = render(grew);
- expect(buttons(el)).toEqual(['Keep not trackedSuggested', 'Track it']);
- click(el, 'Keep not tracked');
- await settle();
- expect(calls.find(c => c.path === '/initiatives/file')?.body).toEqual({
- project: 'ops',
- initiative: 'shelf',
- expect: 'shelf',
- });
- // Undo brings back its count as it stood, so the row comes back
- click(el, 'Undo');
- await settle();
- expect(calls.filter(c => c.path === '/initiatives/file').at(-1)?.body).toEqual({
- project: 'ops',
- initiative: 'shelf',
- set_at: 50,
- baseline_prs: 2,
- });
- });
-
- it('opens the chooser under Track it, with the focus and its own File button', () => {
- const el = render(grew);
- const [keep, track] = [...el.querySelectorAll('button')];
- click(el, 'Track it');
- // nothing that files took Track it's place: the same two buttons lead
- expect([...el.querySelectorAll('button')].slice(0, 2)).toEqual([keep, track]);
- expect(track.getAttribute('aria-expanded')).toBe('true');
- const select = el.querySelector('select') as HTMLSelectElement;
- expect(document.activeElement).toBe(select);
- // its File button sits on the chooser's line, out of the toolbar
- const file = fileButton(el);
- expect(file?.closest('[role="toolbar"]')).toBeNull();
- // and Cancel beside it, as everywhere a project is filed
- expect(file?.nextElementSibling?.textContent).toBe('Cancel');
- // the guess's hover says where the project is now, never Unsorted
- const guessed = [...el.querySelectorAll('span')].find(x => x.textContent === 'guessed');
- expect(guessed?.title).toBe(
- 'It’s Not tracked now. This is the initiative its people work in most.'
- );
- });
-});
-
-describe('the Initiative chooser on a row Decide asks to plan', () => {
- const fresh = (slug: string): DecideRow => ({
- slug,
- item: null,
- reasons: [{ kind: 'new', since: '2026-09-10' }],
- });
- const select = (el: HTMLElement) =>
- el.querySelector('select[aria-label^="File in"]');
-
- it('isn’t shown for a filed project, so its answer stays a plan answer and moves nothing', async () => {
- const el = render(fresh('filed'));
- // filed in Site speed already: nothing asks to file it
- expect(select(el)).toBeNull();
- expect(el.textContent).not.toContain('File in');
- click(el, 'Promise to finish by');
- await settle();
- expect(calls.some(c => c.path === '/initiatives/file')).toBe(false);
- });
-
- it('guesses for an Unsorted project from where its people work', () => {
- const el = render(fresh('new-thing'));
- expect(select(el)?.value).toBe('fixbot');
- expect(el.textContent).toContain('guessed');
- });
-
- it('files first, then plans, in one receipt, and Undo takes both back', async () => {
- const el = render(fresh('new-thing'));
- click(el, 'Promise to finish by');
- await settle();
- const writes = calls.filter(c => c.method !== 'GET');
- expect(writes.map(c => `${c.method} ${c.path}`)).toEqual([
- 'PUT /initiatives/file',
- 'POST /roadmap',
- ]);
- expect(writes[0].body).toEqual({ project: 'new-thing', initiative: 'fixbot', expect: null });
- expect(el.textContent).toMatch(
- /Promised to finish by .+, in FixBot chat\. Decide asks again/
- );
- calls.length = 0;
- click(el, 'Undo');
- await settle();
- expect(calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`)).toEqual([
- 'DELETE /roadmap/77',
- 'PUT /initiatives/file',
- ]);
- expect(calls.find(c => c.path === '/initiatives/file')?.body).toMatchObject({
- project: 'new-thing',
- initiative: null,
- });
- });
- it('keeps the filing when the plan fails, says so, and Try again only plans', async () => {
- const el = render(fresh('flaky'));
- click(el, 'Promise to finish by');
- await settle();
- expect(calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`)).toEqual([
- 'PUT /initiatives/file',
- 'POST /roadmap',
- ]);
- expect(el.textContent).toContain('Filed in FixBot chat; the plan didn’t save.');
- calls.length = 0;
- click(el, 'Try again');
- await settle();
- // its expect would be stale now: the filing isn't sent twice
- expect(calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`)).toEqual([
- 'POST /roadmap',
- ]);
- expect(el.textContent).toMatch(/Promised to finish by .+, in FixBot chat\./);
- });
-});
-
-describe('a section’s one click on rows with a File in chooser', () => {
- const fresh = (slug: string): DecideRow => ({
- slug,
- item: null,
- reasons: [{ kind: 'new', since: '2026-09-10' }],
- });
- // its own rows: a call made here stays made for the tests after
- const rows = [fresh('bulk-a'), fresh('lonely'), fresh('bulk-b')];
-
- it('files each where its chooser says and skips one whose chooser is empty', async () => {
- const el = mount(
- undefined} main />
- );
- // lonely has no guess, so its own answer is off: the section leaves it,
- // and says "2", not "all 2"
- const all = [...el.querySelectorAll('button')].find(b => b.textContent?.startsWith('Promise'));
- expect(all?.textContent).toMatch(/^Promise 2 /);
- act(() => all?.click());
- await settle();
- await settle();
- // each guessed home rides along with its plan, as its own answer would
- expect(
- calls.filter(c => c.method !== 'GET').map(c => [c.path, (c.body as { project: string }).project])
- ).toEqual([
- ['/initiatives/file', 'bulk-a'],
- ['/roadmap', 'bulk-a'],
- ['/initiatives/file', 'bulk-b'],
- ['/roadmap', 'bulk-b'],
- ]);
- expect(calls.find(c => c.path === '/initiatives/file')?.body).toEqual({
- project: 'bulk-a',
- initiative: 'fixbot',
- expect: null,
- });
- // the board's homes as the server has them, for the tests after
- await act(() => loadInitiatives());
- });
-});
-
-describe('the words for where a project belongs', () => {
- const row: DecideRow = { slug: 'x', item: null, reasons: [{ kind: 'unfiled' }] };
- it('say what each answer does', () => {
- expect(answerWords({ kind: 'file', to: 'fixbot', name: 'FixBot chat' }, null)).toBe(
- 'File in FixBot chat'
- );
- // by the home's kind, never its slug: any Not tracked initiative
- expect(
- answerWords({ kind: 'file', to: 'shelf', name: 'Shelf', untracked: true }, null)
- ).toBe('Set not tracked');
- expect(answerWords({ kind: 'link', project: 'pp', name: 'Parts pages' }, null)).toBe(
- 'Link to Parts pages'
- );
- expect(
- callWords(
- { kind: 'file', to: 'fixbot', name: 'FixBot chat' },
- row,
- undefined,
- '2026-10-11'
- )
- ).toBe('Filed in FixBot chat.');
- expect(
- callWords({ kind: 'park' }, row, undefined, '2026-10-11', {
- name: 'FixBot chat',
- })
- ).toBe(
- 'Parked, so it stops counting in the weeks ahead. Decide asks again if its PRs move. It’s in FixBot chat now.'
- );
- });
-
- it('keep two initiatives’ questions apart', () => {
- const ask = (initiative: number): DecideRow => ({
- slug: null,
- item: null,
- reasons: [{ kind: 'goal_reached', initiative }],
- });
- const made = new Map([
- [
- 'initiative:1',
- {
- row: ask(1),
- call: { kind: 'keep_open' },
- words: '',
- state: 'made',
- origin: null,
- token: 1,
- },
- ],
- ]);
- const kept = keepCalls([ask(1), ask(2)], made);
- expect(kept.owed).toEqual([ask(2)]);
- expect(kept.all).toHaveLength(2);
- });
-});
-
-describe('the File in chooser and the answer that waits on it', () => {
- const unfiled = (slug: string): DecideRow => ({ slug, item: null, reasons: [{ kind: 'unfiled' }] });
- const order = (el: HTMLElement) =>
- [...el.querySelectorAll('select, button')].map(n => n.tagName.toLowerCase());
-
- it('sits above the buttons it brings to life, which wait while nothing is picked', () => {
- // nobody it shares work with is filed anywhere: no guess
- const el = render(unfiled('loner'));
- expect(order(el)[0]).toBe('select');
- const file = fileButton(el);
- expect(file?.disabled).toBe(true);
- });
-
- it('says Suggested only for the board’s own guess, never for a person’s pick', () => {
- const el = render(unfiled('new-thing'));
- expect(buttons(el)).toEqual(['FileSuggested']);
- expect(fileButton(el)?.getAttribute('aria-label')).toBe('File New thing in FixBot chat');
- const select = el.querySelector('select') as HTMLSelectElement;
- act(() => {
- select.value = 'site-speed';
- select.dispatchEvent(new Event('change', { bubbles: true }));
- });
- expect(buttons(el)).toEqual(['File']);
- expect(fileButton(el)?.getAttribute('aria-label')).toBe('File New thing in Site speed');
- });
-
- it('holds a plan answer the same way while its chooser is empty', () => {
- const el = render({ slug: 'loner', item: null, reasons: [{ kind: 'new', since: '2026-09-10' }] });
- expect(order(el)[0]).toBe('select');
- const promise = [...el.querySelectorAll('button')].find(b =>
- b.textContent?.startsWith('Promise to finish by')
- );
- expect(promise?.disabled).toBe(true);
- expect(promise?.textContent).not.toContain('Suggested');
- });
-
- it('gives the Tab stop to Other answers while its answer waits on the chooser', () => {
- const el = render({ slug: 'loner', item: null, reasons: [{ kind: 'new', since: '2026-09-10' }] });
- const [promise, other] = el.querySelectorAll('[role="toolbar"] button');
- expect(promise.disabled).toBe(true);
- expect(promise.tabIndex).toBe(-1);
- expect(other.textContent).toBe('Other answers');
- expect(other.tabIndex).toBe(0);
- // and j and k land there
- expect(el.querySelector('[data-decide-focus]')).toBe(other);
- });
-
- it('says the filing in the plan answer’s label', () => {
- const el = render({ slug: 'new-thing', item: null, reasons: [{ kind: 'new', since: '2026-09-10' }] });
- const promise = [...el.querySelectorAll('button')].find(b =>
- b.textContent?.startsWith('Promise to finish by')
- );
- expect(promise?.getAttribute('aria-label')).toMatch(/, in FixBot chat$/);
- });
-
- it('names where a project went when someone filed it first', async () => {
- await settle();
- calls.length = 0;
- const el = render(unfiled('raced'));
- const select = el.querySelector('select') as HTMLSelectElement;
- act(() => {
- select.value = 'site-speed';
- select.dispatchEvent(new Event('change', { bubbles: true }));
- });
- act(() => fileButton(el)?.click());
- await settle();
- expect(el.textContent).toContain('Someone filed it in FixBot chat since, so nothing changed.');
- // and the board reads the homes again
- expect(calls.filter(c => c.path === '/initiatives' && c.method === 'GET')).toHaveLength(1);
- });
-});
-
-describe('a plan with no project', () => {
- const row: DecideRow = { slug: null, item: unlinked, reasons: [{ kind: 'no_project' }] };
-
- it('asks for its project, then links it, and Undo unlinks it', async () => {
- const el = render(row);
- expect(buttons(el)).toEqual(['Link it']);
- const select = el.querySelector('select') as HTMLSelectElement;
- act(() => {
- select.value = 'cache';
- select.dispatchEvent(new Event('change', { bubbles: true }));
- });
- click(el, 'Link to cache');
- await settle();
- expect(calls.at(-1)).toMatchObject({
- method: 'PATCH',
- path: '/roadmap/5',
- body: { project: 'cache' },
- });
- expect(el.textContent).toContain('Linked to cache');
- click(el, 'Undo');
- await settle();
- expect(calls.at(-1)).toMatchObject({
- method: 'PATCH',
- path: '/roadmap/5',
- body: { project: null, undo: { updated_at: 1, status_at: null } },
- });
- });
-});
-
-describe('a double-click on Link', () => {
- const row: DecideRow = { slug: null, item: unlinked, reasons: [{ kind: 'no_project' }] };
-
- it('links once: its second press reaches nothing, and the focus is on the receipt', async () => {
- // under the press guard, as on every view that shows Decide's rows
- const el = mount(
-
-
-
- );
- const select = el.querySelector('select') as HTMLSelectElement;
- act(() => {
- select.value = 'cache';
- select.dispatchEvent(new Event('change', { bubbles: true }));
- });
- const link = [...el.querySelectorAll('button')].find(b =>
- b.textContent?.startsWith('Link')
- ) as HTMLButtonElement;
- pressAt(link, 300, 200);
- await settle();
- expect(el.textContent).toContain('Linked to cache');
- expect(document.activeElement?.textContent).toContain('Linked to cache');
- const sent = calls.length;
- // a row joined above, so Undo (or the plan's name) slid under the
- // pointer: the second press of the double-click finds it, and does nothing
- const undo = [...el.querySelectorAll('button')].find(
- b => b.textContent === 'Undo'
- ) as HTMLButtonElement;
- pressAt(undo, 300, 200);
- await settle();
- expect(calls.length).toBe(sent);
- expect(document.activeElement?.textContent).toContain('Linked to cache');
- // once the pointer has moved away and back, Undo is a click again
- act(() => {
- document.dispatchEvent(
- new MouseEvent('pointermove', { bubbles: true, clientX: 400, clientY: 300 })
- );
- });
- pressAt(undo, 300, 200);
- await settle();
- expect(calls.at(-1)).toMatchObject({ method: 'PATCH', body: { project: null } });
- });
-});
-
-describe('a project whose closed initiative and done plan are both asked about', () => {
- const both: DecideRow = {
- slug: 'akeneo',
- item: { ...created, id: 8, project: 'akeneo', status: 'done', status_at: 10 },
- reasons: [
- { kind: 'reopened', open: 3, late: 0, as: 'done', by: 'roadmap' },
- { kind: 'reopened_in_closed', initiative: 3, as: 'done' },
- ],
- };
-
- it('asks about the initiative first, on the one row', () => {
- const el = render(both);
- expect(buttons(el)).toEqual(['Reopen CatalogSuggested', 'File elsewhere']);
- });
-
- it('keeps the reopen’s receipt here, and the plan question owed everywhere else', () => {
- const made = new Map([
- [
- 'akeneo:8',
- { row: both, call: { kind: 'reopen' }, words: '', state: 'made', origin: null, token: 1 },
- ],
- ]);
- const plan: DecideRow = { ...both, reasons: [both.reasons[0]] };
- const kept = keepCalls([plan], made);
- // still owed, so the Roadmap and the Overview keep the project in
- // sight; shown once, as the row with its receipt
- expect(kept.owed).toEqual([plan]);
- expect(kept.settled.has('akeneo:8')).toBe(true);
- expect(kept.all).toEqual([both]);
- });
-});
-
-describe('Decide’s Initiative switch', () => {
- it('offers a closed initiative while a project in it is asked about', () => {
- const owed: Record = { catalog: 1, 'site-speed': 2 };
- const el = mount(
- owed[slug] ?? 0}
- value={null}
- onChange={nothing}
- />
- );
- expect([...el.querySelectorAll('option')].map(o => o.textContent)).toEqual([
- 'All initiatives',
- 'Site speed · 2',
- 'Catalog · 1',
- ]);
- });
-
- it('counts plans with no project apart from Unsorted, under keys no slug can take', () => {
- const owed: Record = { [UNSORTED]: 3, [NO_PROJECT]: 1 };
- const el = mount(
- owed[slug] ?? 0}
- value={null}
- onChange={nothing}
- />
- );
- const options = [...el.querySelectorAll('option')];
- expect(options.map(o => o.textContent)).toEqual([
- 'All initiatives',
- 'Unsorted · 3',
- 'No project yet · 1',
- ]);
- // an initiative may be called "unsorted"; these keys can't be its slug
- for (const o of options.slice(1)) expect(PROJECT_SLUG.test(o.value)).toBe(false);
- });
-});
-
-describe('an initiative’s page', () => {
- const page = (slug: string, calls: DecideRow[] = [], items = ITEMS, plans: RoadmapItem[] = []) =>
- mount(
-
- );
-
- it('asks a closed one’s reopen at its head, and keeps live work out of Done', () => {
- const done = { ...created, id: 8, project: 'akeneo', status: 'done' as const };
- const asked: DecideRow = {
- slug: 'akeneo',
- item: done,
- reasons: [{ kind: 'reopened_in_closed', initiative: 3, as: 'done' }],
- };
- const akeneo = { ...person('akeneo', ['erin']), name: 'Akeneo 4', open: 3 } as PortfolioItem;
- const el = page('catalog', [asked], [...ITEMS, akeneo], [done]);
- expect(el.textContent).toContain('Akeneo 4: Catalog was closed as done');
- expect(buttons(el)).toContain('Reopen CatalogSuggested');
- // its PRs are open: it's still going, so not folded under Done or dropped
- expect(el.textContent).not.toContain('Done or dropped');
- expect(el.textContent).toMatch(/Health.*1 question on Decide/);
- // closed: its head says how; no estimated end beside the reopen question
- expect(el.textContent).not.toContain('Ends');
- });
-
- it('says it has no plans once, with no end or people to name', () => {
- const el = page('onboarding');
- const terms = [...el.querySelectorAll('dt')].map(d => d.textContent);
- expect(terms).toEqual(['Span', 'Health']);
- expect(el.textContent).toContain('No plans yet');
- });
-
- it('says a reopened one’s end waits on the work going on in it', () => {
- const done = { ...created, id: 8, project: 'akeneo', status: 'done' as const, weeks: 2 };
- const akeneo = { ...person('akeneo', ['erin']), name: 'Akeneo 4', open: 3 } as PortfolioItem;
- const reopened = INITIATIVES.map(i =>
- i.slug === 'catalog' ? { ...i, status: 'open' as const } : i
- );
- const el = mount(
-
- );
- expect(el.textContent).toContain(
- 'Endsnot planned: its plans are finished, but Akeneo 4 is still worked on'
- );
- });
-
- it('says no initiative is at a link that names none, in plain words', () => {
- const el = page('gone');
- expect(el.textContent).toContain('No initiative is at this link.');
- expect(el.textContent).not.toContain('slug');
- });
-
- it('asks each project’s own question on its row', () => {
- const going = { ...created, id: 9, project: 'cache', status: 'active' as const };
- const asked: DecideRow = { slug: 'cache', item: going, reasons: [{ kind: 'stalled', days: 30 }] };
- const el = page('site-speed', [asked], ITEMS, [going]);
- expect(el.textContent).toContain('Park it for now?');
- });
-
- it('shows Not tracked as a ledger: no owner, Edit, span or end', () => {
- const el = page('shelf');
- expect(el.querySelector('h2')?.textContent).toBe('Not tracked');
- expect(el.textContent).not.toContain('Not trackedNot tracked');
- expect(buttons(el)).not.toContain('Edit');
- expect(el.textContent).not.toContain('Span');
- expect(el.textContent).not.toContain('Ends');
- });
-
- it('keeps an edit’s receipt, saying why, when its Undo fails', async () => {
- const el = page('fragile');
- click(el, 'Edit');
- const goal = el.querySelectorAll('input')[1] as HTMLInputElement;
- act(() => {
- Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(goal, 'Holds');
- goal.dispatchEvent(new Event('input', { bubbles: true }));
- });
- click(el, 'Save');
- await settle();
- expect(el.textContent).toContain('Saved its goal.');
- click(el, 'Undo');
- await settle();
- expect(el.textContent).toContain('the database is down');
- expect(buttons(el)).toContain('Close');
- });
-});
-
-describe('a project page’s home line', () => {
- it('puts the chooser in place of its home, and File waits for another pick', () => {
- const el = mount( );
- expect(el.textContent).toContain('in Site speed');
- click(el, 'File elsewhere');
- expect(el.textContent).not.toContain('in Site speed');
- const move = [...el.querySelectorAll('button')].find(b => b.textContent === 'File');
- expect(move?.disabled).toBe(true);
- const select = el.querySelector('select') as HTMLSelectElement;
- expect(select.value).toBe('site-speed');
- act(() => {
- select.value = '';
- select.dispatchEvent(new Event('change', { bubbles: true }));
- });
- expect([...select.options].at(-1)?.textContent).toBe('Unsorted');
- expect(move?.disabled).toBe(false);
- });
-
- it('offers no File elsewhere while Decide’s question on the page files it', () => {
- const el = mount(
-
- );
- expect(el.textContent).toBe('Unsorted');
- expect(el.querySelector('button')).toBeNull();
- });
-
- it('says the move once, and its Undo in the one Undo form', async () => {
- const said: string[] = [];
- const el = mount(
- said.push(words)}
- />
- );
- click(el, 'File elsewhere');
- const select = el.querySelector('select') as HTMLSelectElement;
- act(() => {
- select.value = 'fixbot';
- select.dispatchEvent(new Event('change', { bubbles: true }));
- });
- act(() => [...el.querySelectorAll('button')].find(b => b.textContent === 'File')?.click());
- await settle();
- // the receipt names the new home; the home word isn't said beside it
- expect(el.textContent?.match(/FixBot chat/g)).toHaveLength(1);
- expect(el.textContent).toContain('Filed in FixBot chat.');
- click(el, 'Undo');
- // said before it saves, so a second click finds no Undo to press
- expect(el.querySelector('[role="status"]')?.textContent).toBe(
- 'Put cache back in Site speed.'
- );
- await settle();
- expect(said.at(-1)).toBe('Put cache back in Site speed');
- // Undo's words hold the receipt's place with nothing to click; File
- // elsewhere comes back after them, on the line below
- const status = el.querySelector('[role="status"]') as HTMLElement;
- expect(status.textContent).toBe('Put cache back in Site speed.');
- expect(status.querySelector('button')).toBeNull();
- expect(buttons(el)).toEqual(['File elsewhere']);
- expect(document.activeElement?.textContent).toBe('Put cache back in Site speed.');
- });
-});
-
-describe('a row asking whether an initiative reached its goal', () => {
- it('shows the goal it asks about', () => {
- const el = mount(
-
- );
- expect(el.textContent).toContain('Goal: New customers get a welcome series');
- });
-});
-
-describe('a pick left in a chooser that closed', () => {
- const closedHome: DecideRow = {
- slug: 'akeneo',
- item: null,
- reasons: [{ kind: 'reopened_in_closed', initiative: 3, as: 'done' }],
- };
- const plan: DecideRow = {
- slug: 'akeneo',
- item: { ...created, id: 8, project: 'akeneo', status: 'done', status_at: 10 },
- reasons: [{ kind: 'reopened', open: 3, late: 0, as: 'done', by: 'roadmap' }],
- };
- const pick = (el: HTMLElement, slug: string) =>
- act(() => {
- const select = el.querySelector('select') as HTMLSelectElement;
- select.value = slug;
- select.dispatchEvent(new Event('change', { bubbles: true }));
- });
-
- it('files nothing with the next answer', async () => {
- // pick FixBot chat in File elsewhere, then close it without filing
- const first = render(closedHome);
- click(first, 'File elsewhere');
- pick(first, 'fixbot');
- click(first, 'File elsewhere');
- // the row that asks about its plan shows no chooser: its answer files nothing
- const el = render(plan);
- expect(el.querySelector('select')).toBeNull();
- click(el, 'Keep it done');
- await settle();
- expect(calls.some(c => c.path === '/initiatives/file')).toBe(false);
- });
-
- it('closes on Escape, forgets its pick, and gives the focus back', async () => {
- const el = render(closedHome);
- click(el, 'File elsewhere');
- pick(el, 'fixbot');
- const opener = [...el.querySelectorAll('button')].find(
- b => b.textContent === 'File elsewhere'
- ) as HTMLButtonElement;
- act(() => {
- (el.querySelector('select') as HTMLSelectElement).dispatchEvent(
- new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
- );
- });
- expect(el.querySelector('select')).toBeNull();
- expect(opener.getAttribute('aria-expanded')).toBe('false');
- expect(document.activeElement).toBe(opener);
- // opened again, it starts on the guess, not the abandoned pick
- click(el, 'File elsewhere');
- expect((el.querySelector('select') as HTMLSelectElement).value).not.toBe('fixbot');
- });
-
- it('is forgotten once a filing saves', async () => {
- const grew: DecideRow = {
- slug: 'ops',
- item: null,
- reasons: [{ kind: 'untracked_grew', since: 31, baseline: 2 }],
- };
- const el = render(grew);
- click(el, 'Track it');
- pick(el, 'fixbot');
- act(() => fileButton(el)?.click());
- await settle();
- click(el, 'Undo');
- await settle();
- calls.length = 0;
- // the row asks again, from a fresh chooser: nothing carries the old pick
- click(el, 'Track it');
- expect((el.querySelector('select') as HTMLSelectElement).value).not.toBe('fixbot');
- });
-});
-
-describe('filing into an initiative someone closed since', () => {
- it('says so and reads the initiatives again, so the choosers stop offering it', async () => {
- // an earlier test's reads land first, whatever order the tests run in
- await settle();
- calls.length = 0;
- const r = await act(() => fileProject('cache', 'catalog', { expect: 'site-speed' }));
- expect(r).toEqual({
- error: expect.stringContaining('that initiative is closed'),
- });
- expect(calls.filter(c => c.path === '/initiatives' && c.method === 'GET')).toHaveLength(1);
- });
-});
-
-describe('a pick whose initiative closed since', () => {
- it('is no pick: the chooser waits again, and the answer files nothing', async () => {
- const el = render({
- slug: 'lonely',
- item: null,
- reasons: [{ kind: 'new', since: '2026-09-10' }],
- });
- const select = el.querySelector('select') as HTMLSelectElement;
- act(() => {
- select.value = 'fragile';
- select.dispatchEvent(new Event('change', { bubbles: true }));
- });
- const fragile = INITIATIVES[4];
- try {
- fragile.status = 'done';
- await act(() => loadInitiatives());
- expect(select.value).toBe('');
- const promise = [...el.querySelectorAll('button')].find(b =>
- b.textContent?.startsWith('Promise to finish by')
- );
- expect(promise?.disabled).toBe(true);
- } finally {
- fragile.status = 'open';
- await act(() => loadInitiatives());
- }
- });
-});
-
-describe('Undo on a row', () => {
- const grew: DecideRow = {
- slug: 'ops',
- item: null,
- reasons: [{ kind: 'untracked_grew', since: 31, baseline: 2 }],
- };
-
- it('holds its words where the receipt was, so a second click there answers nothing', async () => {
- const el = render(grew);
- click(el, 'Keep not tracked');
- await settle();
- const undo = [...el.querySelectorAll('button')].find(
- b => b.textContent === 'Undo'
- ) as HTMLButtonElement;
- act(() => undo.click());
- await settle();
- // the slot says what Undo did, with nothing to click in it, and the
- // answers come back after it
- const slot = [...el.querySelectorAll('p')].find(
- p => p.textContent === 'Put ops back as it was.'
- ) as HTMLElement;
- expect(slot).toBeDefined();
- expect(slot.querySelector('button')).toBeNull();
- expect(slot.compareDocumentPosition(el.querySelector('button') as Node)).toBe(
- Node.DOCUMENT_POSITION_FOLLOWING
- );
- expect(document.activeElement).toBe(slot);
- // a double-click's second press lands on the old Undo: nothing is sent
- const sent = calls.length;
- act(() => undo.click());
- await settle();
- expect(calls.length).toBe(sent);
- expect(buttons(el)).toEqual(['Keep not trackedSuggested', 'Track it']);
- // j and k land on the row's answer, never on the held words
- expect(el.querySelector('[data-decide-focus]')?.textContent).toBe(
- 'Keep not trackedSuggested'
- );
- // and a Tab on from the words lets them go
- act(() => {
- slot.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
- (el.querySelector('button') as HTMLButtonElement).focus();
- });
- expect(el.textContent).not.toContain('Put ops back as it was.');
- });
-
- it('lets its words go when the pointer moves away, the focus back on the answer', async () => {
- const el = mount(
-
-
-
- );
- click(el, 'Keep not tracked');
- await settle();
- const undo = [...el.querySelectorAll('button')].find(
- b => b.textContent === 'Undo'
- ) as HTMLButtonElement;
- pressAt(undo, 120, 80);
- await settle();
- expect(el.textContent).toContain('Put ops back as it was.');
- // a wobble keeps them; moving off lets them go
- const move = (x: number, y: number) =>
- act(() => {
- document.dispatchEvent(
- new MouseEvent('pointermove', { bubbles: true, clientX: x, clientY: y })
- );
- });
- move(122, 81);
- expect(el.textContent).toContain('Put ops back as it was.');
- move(400, 300);
- expect(el.textContent).not.toContain('Put ops back as it was.');
- expect(document.activeElement?.textContent).toBe('Keep not trackedSuggested');
- });
-
- it('lets its words go when the next action starts', async () => {
- const el = mount(
-
-
-
- );
- click(el, 'Keep not tracked');
- await settle();
- click(el, 'Undo');
- await settle();
- expect(el.textContent).toContain('Put ops back as it was.');
- // the next answer, by key: the words go before it lands
- click(el, 'Track it');
- expect(el.textContent).not.toContain('Put ops back as it was.');
- });
-
- const both: DecideRow = {
- slug: 'akeneo',
- item: { ...created, id: 8, project: 'akeneo', status: 'done', status_at: 10 },
- reasons: [
- { kind: 'reopened', open: 3, late: 0, as: 'done', by: 'roadmap' },
- { kind: 'reopened_in_closed', initiative: 3, as: 'done' },
- ],
- };
- const planOnly: DecideRow = { ...both, reasons: [both.reasons[0]] };
- /** Decide's rows in their sections, as the queue reads now. */
- function Sections({ queue }: { queue: DecideRow[] }) {
- const { all } = useKeptCalls(queue);
- const sections = [...new Set(all.map(sectionOf))];
- return (
- <>
- {sections.map(k => (
-
- {all
- .filter(r => sectionOf(r) === k)
- .map(r => (
-
- ))}
-
- ))}
- >
- );
- }
-
- it('holds a reopen’s Undo words in place while the initiative is still open', async () => {
- host = document.createElement('div');
- document.body.append(host);
- const root = createRoot(host);
- roots.push(root);
- const show = (queue: DecideRow[]) =>
- act(() =>
- root.render(
-
-
-
- )
- );
- const el = host;
- show([both]);
- click(el, 'Reopen Catalog');
- await settle();
- // reopened: the queue asks only about the plan now
- show([planOnly]);
- const undo = [...el.querySelectorAll('button')].find(
- b => b.textContent === 'Undo'
- ) as HTMLButtonElement;
- const row = undo.closest('[data-decide-row]') as HTMLElement;
- const held = () =>
- [...row.querySelectorAll('p')].find(p => p.textContent === 'Put New thing back as it was.');
- act(() => undo.click());
- // the Undo is saving, so the queue still reads it reopened: the row
- // keeps its place, its words where the receipt was
- show([planOnly]);
- expect(row.isConnected).toBe(true);
- expect(held()).toBeDefined();
- await settle();
- await settle();
- // closed again, it asks both on the same row, the words before the answers
- show([both]);
- expect(row.isConnected).toBe(true);
- const answer = row.querySelector('[role="toolbar"] button') as HTMLButtonElement;
- expect(answer.textContent).toBe('Reopen CatalogSuggested');
- expect(held()?.compareDocumentPosition(answer)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
- expect(document.activeElement).toBe(held());
- });
-});
-
-describe('the one chooser, wherever a project is filed', () => {
- /** what its line holds, in order: its label, the select, its buttons */
- const parts = (el: HTMLElement) => {
- const line = el.querySelector('select')?.parentElement as HTMLElement;
- return [...line.children].map(c =>
- c.tagName === 'SELECT' ? 'select' : c.textContent === 'guessed' ? null : c.textContent
- ).filter(Boolean);
- };
- const escape = (el: Element) =>
- act(() => {
- el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
- });
- const frame = () => act(() => new Promise(resolve => setTimeout(resolve, 40)));
-
- it('reads "File in [ ] File" on Decide’s row and "File in [ ] File Cancel" where it opens', () => {
- const unfiled = render({ slug: 'new-thing', item: null, reasons: [{ kind: 'unfiled' }] });
- expect(parts(unfiled)).toEqual(['File in', 'select', 'FileSuggested']);
- const grew = render({
- slug: 'ops',
- item: null,
- reasons: [{ kind: 'untracked_grew', since: 31, baseline: 2 }],
- });
- click(grew, 'Track it');
- expect(parts(grew)).toEqual(['File in', 'select', 'File', 'Cancel']);
- const line = mount( );
- click(line, 'File elsewhere');
- expect(parts(line)).toEqual(['File in', 'select', 'File', 'Cancel']);
- });
-
- it('closes on Escape on a project’s line, the focus back on File elsewhere, and only it', async () => {
- const outer = vi.fn();
- const el = mount(
-
-
-
- );
- click(el, 'File elsewhere');
- escape(el.querySelector('select') as HTMLSelectElement);
- await frame();
- expect(el.querySelector('select')).toBeNull();
- expect(document.activeElement?.textContent).toBe('File elsewhere');
- // the plan details around it stay open
- expect(outer).not.toHaveBeenCalled();
- });
-
- it('closes what Other answers opened on Escape, the focus back on it', () => {
- const el = render({ slug: 'cache', item: null, reasons: [{ kind: 'stalled', days: 30 }] });
- click(el, 'Other answers');
- expect(el.textContent).toContain('Or instead');
- escape(el.querySelector('[role="toolbar"] button:not(:disabled)') as HTMLButtonElement);
- expect(el.textContent).not.toContain('Or instead');
- expect(document.activeElement?.textContent).toBe('Other answers');
- });
-
- it('closes an initiative’s Edit on Escape, the focus back on Edit', async () => {
- const el = mount(
-
- );
- click(el, 'Edit');
- escape(el.querySelector('input') as HTMLInputElement);
- await frame();
- expect(el.querySelector('form')).toBeNull();
- expect(document.activeElement?.textContent).toBe('Edit');
- });
-});
-
-describe('a plan linked to a project', () => {
- const row: DecideRow = { slug: null, item: unlinked, reasons: [{ kind: 'no_project' }] };
- it('says where the link put it: in that project’s initiative, or Unsorted and asked', () => {
- const words = (project: string) =>
- callWords({ kind: 'link', project, name: project }, row, undefined, '2026-10-11');
- expect(words('cache')).toBe('Linked to cache, so it’s in Site speed.');
- expect(words('ops')).toBe('Linked to ops, so it’s Not tracked.');
- expect(words('lonely')).toBe('Linked to lonely. It’s Unsorted, so Decide asks where it goes.');
- });
-
- it('names the Unsorted row it becomes by its project', () => {
- const el = mount(
-
- );
- expect(el.querySelector('button')?.textContent).toBe('Lonely');
- });
-});
From cd53db6cdcc8015e68debaeb5b349a6faec19efb Mon Sep 17 00:00:00 2001
From: Jarred Stelfox
Date: Mon, 12 Oct 2026 07:24:58 -0700
Subject: [PATCH 105/148] Guard only what came under a still pointer
The press guard ate any press within 24 px of the last click for 1.6 s,
whatever was there. That stopped stray double and triple clicks, but it
also ate presses people meant on controls that never moved: closing a
row or a lane just opened, the next week on the load chart, a lane
grip's drag, a neighbour 16 px away, and on a phone there was no way
out but to wait. Nothing said a press was eaten.
The hazard is a control that slid under the pointer, so the rule now
asks what changed. A guarded click notes the element under its point
and every control within 24 px with its rect. A later press is judged
once, at its pointerdown: it reaches nothing only if its control
arrived or moved since, if it lands on no control and on something
other than what was there, or if it's the same control again within a
double-click (500 ms) and not a toggle or a drag grip. The verdict
covers the whole press, so a slow press that started inside the hold
can't click after it. A pointer moved onto a control past a wobble
means it. The hold ends on a wheel, a key, a scrollbar press or a
touch pan as well. A protected control flashes an outline (held, not
faded, under reduced motion), so a press reads as kept, not dead.
The Roadmap's guard wraps its lanes and details, not the load chart,
which predates this feature. Tests swap pressedBy.person instead of a
MODE check in production code, and the listeners go on an HMR update.
Co-Authored-By: Claude Opus 5.5
---
DESIGN.md | 21 +-
frontend-v2/src/components/settle.test.ts | 333 +++++++++++++++---
frontend-v2/src/components/settle.ts | 242 ++++++++++---
.../src/views/projects/HomeCalls.test.tsx | 5 +-
frontend-v2/src/views/projects/Roadmap.tsx | 44 +--
.../src/views/projects/RoadmapLanes.test.tsx | 5 +-
6 files changed, 512 insertions(+), 138 deletions(-)
diff --git a/DESIGN.md b/DESIGN.md
index 5ed9c78e..2e55272b 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -905,14 +905,21 @@ icon with a class bolted on.
answers opened, an edit form. An answer strip's one Tab stop is its
first answer that can be pressed, so an answer waiting on an empty
chooser never hides Other answers from the keyboard.
-- **After a click, nothing new can be clicked under a still pointer**
+- **After a click, a still pointer can't press what came under it**
(`components/settle.ts`). A click often changes what sits under the
- pointer: a receipt replaces the answers, a row joins a section above, a
- lane opens. On every view the initiatives feature draws on, a pointer
- click (or a drop) holds its point, and a press within 24 px of it
- reaches nothing until the pointer moves away or the page scrolls, or
- 1.6 s with the pointer still. So a double or triple click is one click,
- and a press "to be sure" a second later does nothing. What the click
+ pointer: a receipt replaces the answers, a row joins a section above,
+ Other answers opens its ends. In the parts of a view that move when the
+ initiatives feature answers a click (Decide, the Roadmap's lanes and
+ details, the project and initiative pages, the Overview; not the load
+ chart), a pointer click or a drop notes what's within 24 px of its
+ point and where it sits. For 1.6 s, until the pointer moves away or a
+ person scrolls, a press there reaches nothing (and the control flashes
+ an outline) only if it lands on a control that arrived or moved since,
+ or on the same control again within a double-click. A control that
+ stayed put takes every press: a toggle closes again at once, the next
+ week, a neighbour, a grip's drag. So a double or triple click is one
+ click, and a press "to be sure" a second later lands on nothing new. A
+ pointer moved onto a control that arrived means it. What the click
left (a receipt, Undo's words) gets the focus and is shown whole below
the sticky headers, wherever it went. Undo's held words ("Put X back as
it was.") stand where the receipt was until the pointer moves away, a
diff --git a/frontend-v2/src/components/settle.test.ts b/frontend-v2/src/components/settle.test.ts
index 7a1fd205..3da9f8cf 100644
--- a/frontend-v2/src/components/settle.test.ts
+++ b/frontend-v2/src/components/settle.test.ts
@@ -1,6 +1,19 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { clearHolds, GUARD_PX, guardAt, HOLD_MS, onLeave, reveal, settle } from './settle';
+import {
+ clearHolds,
+ DOUBLE_MS,
+ GUARD_PX,
+ guardAt,
+ HOLD_MS,
+ onLeave,
+ pressedBy,
+ reveal,
+ settle,
+} from './settle';
+
+// jsdom makes no trusted events: every press here is a person's
+pressedBy.person = () => true;
const box = (top: number, height: number, left = 0, width = 400) =>
({
@@ -15,7 +28,13 @@ const box = (top: number, height: number, left = 0, width = 400) =>
toJSON: () => ({}),
} as DOMRect);
-/** A region under the rule, with a button that counts its clicks. */
+/** Put an element at a place on screen: what its rect says. */
+const place = (el: HTMLElement, left: number, top: number, width = 40, height = 20) => {
+ el.getBoundingClientRect = () => box(top, height, left, width);
+};
+
+/** A region under the rule, with two buttons that count their clicks:
+ * `first` around (100, 100), `other` far away until a test moves it. */
function region() {
const root = document.createElement('div');
root.setAttribute('data-press-guard', '');
@@ -23,6 +42,8 @@ function region() {
const other = document.createElement('button');
root.append(first, other);
document.body.append(root);
+ place(first, 80, 90);
+ place(other, 400, 400);
const hits = { first: 0, other: 0 };
first.addEventListener('click', () => hits.first++);
other.addEventListener('click', () => hits.other++);
@@ -30,137 +51,333 @@ function region() {
}
/** What a mouse sends for one press at a point, onto `el` (what's there). */
-function pressAt(el: HTMLElement, x: number, y: number, detail = 1) {
+function pressAt(el: Element, x: number, y: number, detail = 1) {
const at = { bubbles: true, cancelable: true, clientX: x, clientY: y };
const down = new MouseEvent('pointerdown', at);
el.dispatchEvent(down);
- el.dispatchEvent(new MouseEvent('mousedown', at));
+ const mouse = new MouseEvent('mousedown', at);
+ el.dispatchEvent(mouse);
el.dispatchEvent(new MouseEvent('mouseup', at));
el.dispatchEvent(new MouseEvent('click', { ...at, detail }));
- return down;
+ return { down, mouse };
}
const moveTo = (x: number, y: number) =>
document.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: x, clientY: y }));
let now = 1000;
+let flashed: Element[] = [];
beforeEach(() => {
now = 1000;
vi.spyOn(performance, 'now').mockImplementation(() => now);
+ flashed = [];
+ Element.prototype.animate = function (this: Element) {
+ flashed.push(this);
+ return {} as Animation;
+ };
+ // the page's own box, so a press at a point isn't on its scrollbar
+ vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1000);
+ vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(800);
});
afterEach(() => {
clearHolds();
document.body.innerHTML = '';
vi.restoreAllMocks();
+ delete (Element.prototype as Partial).animate;
});
-describe('the press guard', () => {
- it('lets no press at a click’s point reach what slid there', () => {
- const { first, other, hits } = region();
+describe('the press guard: a stray press', () => {
+ it('reaches no control that arrived under the point since the click', () => {
+ const { root, first, hits } = region();
pressAt(first, 100, 100);
expect(hits.first).toBe(1);
- // the click moved `other` under the pointer: a double-click's second
- // press finds it, and reaches nothing
- const down = pressAt(other, 100 + GUARD_PX - 2, 100);
- expect(hits.other).toBe(0);
- // nothing takes the focus or starts a selection either
+ // the click put a new control where the pointer is: a double-click's
+ // second press finds it, and reaches nothing
+ const came = document.createElement('button');
+ place(came, 85, 92);
+ root.append(came);
+ let cameHits = 0;
+ came.addEventListener('click', () => cameHits++);
+ const { down, mouse } = pressAt(came, 101, 100);
+ expect(cameHits).toBe(0);
+ // nothing takes the focus or starts a selection either, and the
+ // control says it was kept from the press
expect(down.defaultPrevented).toBe(true);
- // the same control again, too: a triple click is one click
- pressAt(first, 100, 100);
- expect(hits.first).toBe(1);
+ expect(mouse.defaultPrevented).toBe(true);
+ expect(flashed).toEqual([came]);
});
- it('lets a press away from the point through', () => {
+ it('reaches no control that slid under the point', () => {
const { first, other, hits } = region();
pressAt(first, 100, 100);
- pressAt(other, 100 + GUARD_PX + 2, 100);
- expect(hits.other).toBe(1);
+ place(other, 90, 95);
+ pressAt(other, 100 + GUARD_PX - 4, 100);
+ expect(hits.other).toBe(0);
});
- it('ends once the pointer moves away, and not for a wobble', () => {
+ it('reaches no control that moved, though it was near', () => {
const { first, other, hits } = region();
+ place(other, 125, 90);
pressAt(first, 100, 100);
- moveTo(103, 98);
- pressAt(other, 100, 100);
+ // a line above it grew: it slid down 18 px, still near the point
+ place(other, 125, 108);
+ pressAt(other, 118, 112);
expect(hits.other).toBe(0);
- moveTo(100, 100 + GUARD_PX + 1);
- pressAt(other, 100, 100);
- expect(hits.other).toBe(1);
});
- it('lets go after its time with the pointer still, and no sooner', () => {
- const { first, other, hits } = region();
+ it('takes a double-click on one control as one click', () => {
+ const { first, hits } = region();
pressAt(first, 100, 100);
- now += HOLD_MS - 1;
- pressAt(other, 100, 100);
+ now += DOUBLE_MS - 100;
+ pressAt(first, 100, 100);
+ expect(hits.first).toBe(1);
+ });
+
+ it('eats the click of a press it ate, however long the press is held', () => {
+ const { root, first } = region();
+ pressAt(first, 100, 100);
+ const came = document.createElement('button');
+ place(came, 85, 92);
+ root.append(came);
+ let cameHits = 0;
+ came.addEventListener('click', () => cameHits++);
+ const at = { bubbles: true, cancelable: true, clientX: 100, clientY: 100 };
+ now += HOLD_MS - 200;
+ came.dispatchEvent(new MouseEvent('pointerdown', at));
+ // held down past the hold's end, then let go
+ now += 600;
+ came.dispatchEvent(new MouseEvent('mouseup', at));
+ came.dispatchEvent(new MouseEvent('click', { ...at, detail: 1 }));
+ expect(cameHits).toBe(0);
+ });
+
+ it('reaches nothing new where no control is, so nothing that came is selected', () => {
+ const { root, first } = region();
+ pressAt(first, 100, 100);
+ first.remove();
+ const words = document.createElement('span');
+ root.append(words);
+ const { down } = pressAt(words, 100, 100);
+ expect(down.defaultPrevented).toBe(true);
+ });
+
+ it('holds at a drop’s point', () => {
+ const { root, hits, other } = region();
+ guardAt(50, 60);
+ place(other, 40, 50);
+ pressAt(other, 52, 61);
expect(hits.other).toBe(0);
- now += 2;
- pressAt(other, 100, 100);
- expect(hits.other).toBe(1);
+ const came = document.createElement('button');
+ root.append(came);
+ place(came, 45, 55);
+ pressAt(came, 52, 61);
+ expect(flashed).toEqual([other, came]);
});
it('judges a press by when it was made, not when a busy page got to it', () => {
- const { first, other, hits } = region();
+ const { first, hits } = region();
pressAt(first, 100, 100);
// a long render: the page reads the second press of a double-click
// two seconds on, though it was made a moment after the first
now += 2000;
- const click = new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 100, clientY: 100, detail: 2 });
- Object.defineProperty(click, 'timeStamp', { value: 1080 });
- other.dispatchEvent(click);
- expect(hits.other).toBe(0);
+ const at = { bubbles: true, cancelable: true, clientX: 100, clientY: 100 };
+ const down = new MouseEvent('pointerdown', at);
+ Object.defineProperty(down, 'timeStamp', { value: 1080 });
+ first.dispatchEvent(down);
+ first.dispatchEvent(new MouseEvent('click', { ...at, detail: 2 }));
+ expect(hits.first).toBe(1);
});
+});
- it('holds at least a second and a half', () => {
- expect(HOLD_MS).toBeGreaterThanOrEqual(1500);
+describe('the press guard: a press a person means', () => {
+ it('reaches a neighbour that stayed in place, at once', () => {
+ const { first, other, hits } = region();
+ place(other, 122, 90, 12, 20);
+ pressAt(first, 100, 100);
+ now += 200;
+ pressAt(other, 116 + 12, 100);
+ expect(hits.other).toBe(1);
+ expect(flashed).toEqual([]);
});
- it('lets go on a wheel: the page moved on purpose', () => {
+ it('reaches the same control again after a double-click’s span', () => {
+ const { first, hits } = region();
+ pressAt(first, 100, 100);
+ now += DOUBLE_MS + 1;
+ pressAt(first, 100, 100);
+ expect(hits.first).toBe(2);
+ });
+
+ it('closes a toggle it just opened, at 0.4 s or at once', () => {
+ const { first, hits } = region();
+ first.setAttribute('aria-expanded', 'false');
+ first.addEventListener('click', () =>
+ first.setAttribute('aria-expanded', String(first.getAttribute('aria-expanded') !== 'true'))
+ );
+ pressAt(first, 100, 100);
+ now += 400;
+ pressAt(first, 100, 100);
+ expect(first.getAttribute('aria-expanded')).toBe('false');
+ pressAt(first, 100, 100);
+ expect(hits.first).toBe(3);
+ // a fold's band: a summary, or what's inside one
+ const fold = document.createElement('details');
+ const band = document.createElement('summary');
+ const label = document.createElement('button');
+ band.append(label);
+ fold.append(band);
+ first.closest('[data-press-guard]')?.append(fold);
+ place(label, 300, 300);
+ let labelHits = 0;
+ label.addEventListener('click', () => labelHits++);
+ pressAt(label, 310, 310);
+ pressAt(label, 310, 310);
+ expect(labelHits).toBe(2);
+ });
+
+ it('never cancels a drag from a grip that stayed put', () => {
+ const { first } = region();
+ first.setAttribute('draggable', 'true');
+ pressAt(first, 100, 100);
+ now += 200;
+ const { down, mouse } = pressAt(first, 100, 100);
+ expect(down.defaultPrevented).toBe(false);
+ expect(mouse.defaultPrevented).toBe(false);
+ });
+
+ it('reaches a control the pointer moved onto, past a wobble', () => {
+ const { root, first } = region();
+ pressAt(first, 100, 100);
+ // Other answers opened its ends 12 px below the pointer
+ const end = document.createElement('button');
+ place(end, 80, 108);
+ root.append(end);
+ let endHits = 0;
+ end.addEventListener('click', () => endHits++);
+ moveTo(102, 104);
+ pressAt(end, 102, 104);
+ expect(endHits).toBe(0);
+ moveTo(100, 112);
+ pressAt(end, 100, 112);
+ expect(endHits).toBe(1);
+ });
+
+ it('reaches what the pointer moved away to', () => {
const { first, other, hits } = region();
+ place(other, 140, 90);
pressAt(first, 100, 100);
- document.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 40 }));
- pressAt(other, 100, 100);
+ pressAt(other, 100 + GUARD_PX + 26, 100);
expect(hits.other).toBe(1);
});
- it('holds nothing for a key’s click, or outside its regions', () => {
- const { first, other, hits } = region();
+ it('reaches a key’s press always, and holds nothing outside its regions', () => {
+ const { root, first } = region();
pressAt(first, 100, 100, 0);
- pressAt(other, 100, 100);
- expect(hits.other).toBe(1);
+ const came = document.createElement('button');
+ place(came, 85, 92);
+ root.append(came);
+ let cameHits = 0;
+ came.addEventListener('click', () => cameHits++);
+ pressAt(came, 100, 100);
+ expect(cameHits).toBe(1);
+ clearHolds();
const outside = document.createElement('button');
document.body.append(outside);
+ place(outside, 280, 290);
let outsideHits = 0;
outside.addEventListener('click', () => outsideHits++);
pressAt(outside, 300, 300);
- pressAt(outside, 300, 300);
+ outside.remove();
+ const next = document.createElement('button');
+ document.body.append(next);
+ place(next, 280, 290);
+ next.addEventListener('click', () => outsideHits++);
+ pressAt(next, 300, 300);
expect(outsideHits).toBe(2);
});
it('lets code’s own click (opening a "+ N more") start nothing and end nothing', () => {
- // outside tests, only a person's press counts: jsdom makes no trusted ones
- vi.stubEnv('MODE', 'production');
+ const person = pressedBy.person;
+ pressedBy.person = e => e.isTrusted;
try {
- const { first, other, hits } = region();
+ const { first, other } = region();
guardAt(100, 100);
const gone = vi.fn();
onLeave(gone);
first.click();
- expect(hits.first).toBe(1);
expect(gone).not.toHaveBeenCalled();
- const down = new MouseEvent('pointerdown', { bubbles: true, cancelable: true, clientX: 100, clientY: 100 });
- other.dispatchEvent(down);
+ // the hold is still the drop's: what came is still kept from a press
+ pressedBy.person = person;
+ place(other, 90, 95);
+ const { down } = pressAt(other, 100, 100);
expect(down.defaultPrevented).toBe(true);
} finally {
- vi.unstubAllEnvs();
+ pressedBy.person = person;
}
});
+});
- it('holds at a drop’s point', () => {
- const { other, hits } = region();
- guardAt(50, 60);
- pressAt(other, 52, 61);
+describe('the press guard lets go', () => {
+ /** A click on `first`, then a control that slid under the point. */
+ const slid = () => {
+ const r = region();
+ pressAt(r.first, 100, 100);
+ place(r.other, 90, 95);
+ return r;
+ };
+
+ it('once the pointer moves away, and not for a wobble', () => {
+ const { other, hits } = slid();
+ moveTo(103, 98);
+ pressAt(other, 100, 100);
+ expect(hits.other).toBe(0);
+ moveTo(100, 100 + GUARD_PX + 1);
+ pressAt(other, 100, 100);
+ expect(hits.other).toBe(1);
+ });
+
+ it('after its time with the pointer still, and no sooner', () => {
+ const { other, hits } = slid();
+ now += HOLD_MS - 1;
+ pressAt(other, 100, 100);
expect(hits.other).toBe(0);
+ now += 2;
+ pressAt(other, 100, 100);
+ expect(hits.other).toBe(1);
+ });
+
+ it('holds at least a second and a half', () => {
+ expect(HOLD_MS).toBeGreaterThanOrEqual(1500);
+ });
+
+ it.each([
+ [
+ 'a wheel',
+ () => document.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 40 })),
+ ],
+ [
+ 'a key',
+ () =>
+ document.dispatchEvent(
+ new KeyboardEvent('keydown', { bubbles: true, key: 'PageDown' })
+ ),
+ ],
+ [
+ 'a touch pan',
+ () => document.dispatchEvent(new MouseEvent('pointercancel', { bubbles: true })),
+ ],
+ [
+ 'the scrollbar',
+ () =>
+ document.documentElement.dispatchEvent(
+ new MouseEvent('pointerdown', { bubbles: true, clientX: 1005, clientY: 100 })
+ ),
+ ],
+ ])('once %s scrolls the page', (_how, scroll) => {
+ const { other, hits } = slid();
+ scroll();
+ pressAt(other, 100, 100);
+ expect(hits.other).toBe(1);
});
});
diff --git a/frontend-v2/src/components/settle.ts b/frontend-v2/src/components/settle.ts
index ec1ce5b1..59e97d81 100644
--- a/frontend-v2/src/components/settle.ts
+++ b/frontend-v2/src/components/settle.ts
@@ -2,18 +2,33 @@ import { flushSync } from 'react-dom';
/**
* One rule for what follows a click in the initiatives views (Decide, the
- * Roadmap and a plan's details, a project's and an initiative's page, the
- * Overview): nothing new can be clicked where the pointer is until it
- * moves. A click there often changes what sits under the pointer (a
- * receipt replaces the answers, a row joins a section above, a lane
- * opens), and a double-click's second press, or a "to be sure" press a
- * second later, would land on whatever slid there.
+ * Roadmap's lanes and a plan's details, a project's and an initiative's
+ * page, the Overview): a press near a click's point can't reach a control
+ * that arrived there, or slid there, since the click. A click often
+ * changes what sits under the pointer (a receipt replaces the answers, a
+ * row joins a section above, Other answers opens its ends), and a
+ * double-click's second press, or a "to be sure" press a second later,
+ * would land on whatever came.
*
* A pointer click on a control inside a `PRESS_GUARD` region starts a hold
- * at its point. While it lasts, a press within GUARD_PX of that point
- * reaches nothing: no focus, no selection, no click. It lasts until the
- * pointer moves away or a wheel scrolls, or HOLD_MS with the pointer
- * still. Nothing is drawn and nothing waits on a timer: each press checks.
+ * at its point, and notes what was under it and every control within
+ * GUARD_PX and where it sat. A later press near the point is judged once,
+ * at its `pointerdown`, and the verdict holds for all of its events (its
+ * click too, however long it's held). It reaches nothing (no focus, no
+ * selection, no click, and the control flashes an outline) when:
+ *
+ * - its control wasn't there, or has moved since (more than 1 px);
+ * - it lands on no control, and on something other than what was there;
+ * - it's the same control again within a double-click (DOUBLE_MS), unless
+ * that control is a toggle (it opens or closes something, or is
+ * pressed) or a grip that drags.
+ *
+ * So a control that stayed put takes every press: a toggle closed again at
+ * once, the next week on the load chart, a grip's drag. The hold ends when
+ * the pointer moves away, after HOLD_MS, or once the page is scrolled by a
+ * person (a wheel, a key, the scrollbar, a touch pan). A pointer that
+ * moved further than a wobble within it is aimed: what it lands on, it
+ * means.
*
* What the click left (a receipt, Undo's words) is put where it can be
* seen with `settle` or `reveal`, below the sticky headers, and the caller
@@ -33,23 +48,52 @@ export const GUARD_PX = 24;
* second press "to be sure". */
export const HOLD_MS = 1600;
+/** A double-click's span: the same control pressed again sooner is one
+ * click, not two. */
+export const DOUBLE_MS = 500;
+
+/** How far a pointer moves before it's aiming, not wobbling. */
+const WOBBLE_PX = 6;
+
const CONTROL =
'button, a[href], summary, [role="button"], [role="tab"], [role="radio"], [role="switch"], [role="menuitem"]';
+/** What a press can land on: a control, a field or a grip. */
+const TARGET = `${CONTROL}, input, select, textarea, label, [draggable="true"], [tabindex]:not([tabindex="-1"])`;
+
+/** A control whose second press undoes its first: a double-click on it
+ * does what it would anywhere. */
+const TOGGLE = '[aria-expanded], [aria-pressed], [role="switch"], summary, summary *';
+
+/** Who made a press: a person, not code's own `.click()` (opening a
+ * "+ N more" on the way to a row), which starts and ends nothing. Tests
+ * swap it, since jsdom makes no trusted events. */
+export const pressedBy = { person: (e: Event) => e.isTrusted };
+
interface Hold {
/** the press's point; null for a key's click, which holds no point */
x: number | null;
y: number;
- /** presses near the point reach nothing until then */
+ /** when the press was made */
+ at: number;
+ /** presses near the point are judged until then */
until: number;
+ /** the control pressed, and what was under the point */
+ first: Element | null;
+ under: Element | null;
+ /** every control near the point then, and where it was */
+ stayed: Map;
+ /** the furthest the pointer has gone from the point */
+ far: number;
/** what goes once the pointer moves away or the next action starts */
leave: (() => void)[];
}
let hold: Hold | null = null;
-const near = (h: Hold, e: MouseEvent) =>
- h.x != null && Math.hypot(e.clientX - h.x, e.clientY - h.y) <= GUARD_PX;
+const away = (h: Hold, e: MouseEvent) =>
+ h.x == null ? Infinity : Math.hypot(e.clientX - h.x, e.clientY - h.y);
+const near = (h: Hold, e: MouseEvent) => away(h, e) <= GUARD_PX;
/** When a press was made, not when the page got to it: a double-click
* made while a long render held the page is handled after it, and must
@@ -58,8 +102,6 @@ const near = (h: Hold, e: MouseEvent) =>
const madeAt = (e: Event) =>
e.timeStamp > 0 && e.timeStamp <= performance.now() + 1 ? e.timeStamp : performance.now();
-const guarding = (e: MouseEvent) => !!hold && near(hold, e) && madeAt(e) < hold.until;
-
/** The pointer left, or a new action began: the hold and its words go. */
function leave(): void {
const h = hold;
@@ -67,69 +109,167 @@ function leave(): void {
h?.leave.forEach(fn => fn());
}
+/** The controls within GUARD_PX of a point, and where they are. */
+function controlsNear(x: number, y: number): Map {
+ const found = new Map();
+ for (const el of document.querySelectorAll(TARGET)) {
+ const r = el.getBoundingClientRect();
+ if (!r.width && !r.height) continue;
+ const dx = Math.max(r.left - x, 0, x - r.right);
+ const dy = Math.max(r.top - y, 0, y - r.bottom);
+ if (Math.hypot(dx, dy) <= GUARD_PX) found.set(el, r);
+ }
+ return found;
+}
+
/** Start a hold at a point (a click, a drop), or with none (a key's
- * click): the hold before it ends, as the next action. */
-export function guardAt(x: number | null, y = 0, at = performance.now()): void {
+ * click): the hold before it ends, as the next action. Call it before
+ * the press changes anything, so it notes what was there. */
+export function guardAt(
+ x: number | null,
+ y = 0,
+ at = performance.now(),
+ first: Element | null = null
+): void {
leave();
- hold = { x, y, until: x == null ? 0 : at + HOLD_MS, leave: [] };
+ hold = {
+ x,
+ y,
+ at,
+ until: x == null ? 0 : at + HOLD_MS,
+ first,
+ under: x == null ? null : document.elementFromPoint?.(x, y) ?? first,
+ stayed: x == null ? new Map() : controlsNear(x, y),
+ far: 0,
+ leave: [],
+ };
}
/** Run `fn` when the current hold ends by the pointer moving away or the
* next action, not by the clock. */
export function onLeave(fn: () => void): void {
- hold ??= { x: null, y: 0, until: 0, leave: [] };
+ hold ??= {
+ x: null,
+ y: 0,
+ at: 0,
+ until: 0,
+ first: null,
+ under: null,
+ stayed: new Map(),
+ far: 0,
+ leave: [],
+ };
hold.leave.push(fn);
}
/** End the hold without running its words' leave (tests start clean). */
export function clearHolds(): void {
hold = null;
+ eating = false;
+}
+
+const moved = (a: DOMRect, b: DOMRect) =>
+ Math.abs(a.left - b.left) > 1 ||
+ Math.abs(a.top - b.top) > 1 ||
+ Math.abs(a.width - b.width) > 1 ||
+ Math.abs(a.height - b.height) > 1;
+
+/** Whether a press at this pointerdown lands on something that came under
+ * the point since the click: the rule above. */
+function stray(e: MouseEvent): Element | boolean {
+ const h = hold;
+ if (!h || !near(h, e) || madeAt(e) >= h.until) return false;
+ const target = e.target instanceof Element ? e.target : null;
+ const control = target?.closest(TARGET);
+ if (!control) {
+ // no control: only what was there all along takes it
+ const was = h.under;
+ return !(target && was?.isConnected && (target.contains(was) || was.contains(target)));
+ }
+ const was = h.stayed.get(control);
+ if (!was || moved(was, control.getBoundingClientRect()))
+ return h.far > WOBBLE_PX ? false : control;
+ const again =
+ control === h.first &&
+ madeAt(e) - h.at < DOUBLE_MS &&
+ !control.matches(TOGGLE) &&
+ control.getAttribute('draggable') !== 'true';
+ return again ? control : false;
}
-/** A press a person made: code's own `.click()` (opening a "+ N more"
- * on the way to a row) is no action and starts no hold. jsdom makes no
- * trusted events, so tests count every one. */
-const real = (e: Event) => e.isTrusted || import.meta.env.MODE === 'test';
+/** Outline a control a press didn't reach, a moment, so it reads as kept
+ * from a stray press, not as dead. */
+function flash(el: Element): void {
+ const still = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ const on = { outline: '2px solid var(--ring)', outlineOffset: '2px' };
+ el.animate?.([on, still ? on : { ...on, outlineColor: 'transparent' }], {
+ duration: 700,
+ easing: 'ease-in',
+ });
+}
+
+/** The verdict on the press under way, from its pointerdown. */
+let eating = false;
if (typeof window !== 'undefined') {
+ const listeners: [string, EventListener, AddEventListenerOptions][] = [];
+ const on = (
+ type: K,
+ fn: (e: WindowEventMap[K]) => void,
+ opts: AddEventListenerOptions = { capture: true }
+ ) => {
+ addEventListener(type, fn, opts);
+ listeners.push([type, fn as EventListener, opts]);
+ };
const eat = (e: Event) => {
e.preventDefault();
e.stopImmediatePropagation();
};
- const press = (e: MouseEvent) => {
- if (guarding(e)) return eat(e);
- // a press away from the point: the pointer moved there
- if (hold && e.type === 'pointerdown' && !near(hold, e)) leave();
+ const scrolled = () => {
+ if (hold) hold.until = 0;
};
- for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'dblclick'])
- addEventListener(type, press as EventListener, true);
- addEventListener(
- 'click',
- e => {
- if (!real(e)) return;
- // a key's click (detail 0) is never a stray press
- if (e.detail > 0 && guarding(e)) return eat(e);
- const control = (e.target as Element | null)?.closest?.(CONTROL);
- if (!control?.closest('[data-press-guard]')) return;
- guardAt(e.detail > 0 ? e.clientX : null, e.clientY, madeAt(e));
- },
- true
- );
- addEventListener(
+ on('pointerdown', e => {
+ if (!pressedBy.person(e)) return;
+ // the scrollbar, outside the page's own box: a scroll on purpose
+ const page = document.documentElement;
+ if (e.target === page && (e.clientX >= page.clientWidth || e.clientY >= page.clientHeight))
+ return scrolled();
+ const verdict = stray(e);
+ eating = !!verdict;
+ if (verdict instanceof Element) flash(verdict);
+ if (eating) return eat(e);
+ // a press away from the point: the pointer moved there
+ if (hold && !near(hold, e)) leave();
+ });
+ for (const type of ['mousedown', 'pointerup', 'mouseup', 'dblclick'] as const)
+ on(type, e => {
+ if (eating && pressedBy.person(e)) eat(e);
+ });
+ on('click', e => {
+ if (!pressedBy.person(e)) return;
+ // a key's click (detail 0) is never a stray press
+ if (e.detail > 0 && eating) return eat(e);
+ const control = (e.target as Element | null)?.closest?.(CONTROL);
+ if (!control?.closest('[data-press-guard]')) return;
+ guardAt(e.detail > 0 ? e.clientX : null, e.clientY, madeAt(e), control);
+ });
+ on(
'pointermove',
e => {
- if (hold?.x != null && !near(hold, e)) leave();
- },
- { capture: true, passive: true }
- );
- // a scroll moves the page on purpose: what's under the pointer is seen
- addEventListener(
- 'wheel',
- () => {
- if (hold) hold.until = 0;
+ if (hold?.x == null) return;
+ const d = away(hold, e);
+ if (d > GUARD_PX) leave();
+ else hold.far = Math.max(hold.far, d);
},
{ capture: true, passive: true }
);
+ // a person scrolls the page on purpose: what's under the pointer is seen
+ on('wheel', scrolled, { capture: true, passive: true });
+ on('keydown', scrolled, { capture: true, passive: true });
+ on('pointercancel', scrolled, { capture: true, passive: true });
+ import.meta.hot?.dispose(() => {
+ for (const [type, fn, opts] of listeners) removeEventListener(type, fn, opts);
+ });
}
/** Space kept between what's revealed and an edge or a sticky header. */
diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx
index 27191911..65f53f1f 100644
--- a/frontend-v2/src/views/projects/HomeCalls.test.tsx
+++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx
@@ -9,7 +9,7 @@ import { fileProject, loadInitiatives } from '../../model/initiativesData';
import type { PortfolioItem } from '../../model/portfolio';
import { HomeLine, InitiativePage } from './Initiatives';
import { clearHomePicks, InitiativeSwitch } from './HomeCalls';
-import { clearHolds } from '../../components/settle';
+import { clearHolds, pressedBy } from '../../components/settle';
import { NO_PROJECT, UNSORTED } from '../../model/initiativeBoard';
import { PROJECT_SLUG } from '../../../../shared/model/roadmap';
import type { ProjectsNav } from './parts';
@@ -30,6 +30,9 @@ import {
sectionOf,
} from '../../model/decideCalls';
+// jsdom makes no trusted events: every press here is a person's
+pressedBy.person = () => true;
+
// React's act() warns unless the environment says it's a test
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx
index aff994c0..8a5dfe87 100644
--- a/frontend-v2/src/views/projects/Roadmap.tsx
+++ b/frontend-v2/src/views/projects/Roadmap.tsx
@@ -3431,7 +3431,7 @@ export function Roadmap({
);
return (
-
+
{/* the timeline's toolbar and column names stay in place while its
rows scroll, from sm up; on a phone they'd eat the screen */}
- {newPlan}
- {receiptAdded}
- {!ordered.length && !unplanned.length && !adding ? (
-
- Nothing on the roadmap yet, and no projects {BEING_WORKED_ON}. Click Add to the
- roadmap to start a plan.
-
- ) : (
-
- {body}
- {notTrackedLane}
-
- )}
- {narrowed &&
- !shownPlans.length &&
- !(everything && shownUnplanned.length) &&
- nothingMatches(`on the roadmap or ${BEING_WORKED_ON}`)}
+ {/* the press guard covers what moves under a click (rows,
+ receipts, details), not the load chart above */}
+
+ {newPlan}
+ {receiptAdded}
+ {!ordered.length && !unplanned.length && !adding ? (
+
+ Nothing on the roadmap yet, and no projects {BEING_WORKED_ON}. Click Add to
+ the roadmap to start a plan.
+
+ ) : (
+
+ {body}
+ {notTrackedLane}
+
+ )}
+ {narrowed &&
+ !shownPlans.length &&
+ !(everything && shownUnplanned.length) &&
+ nothingMatches(`on the roadmap or ${BEING_WORKED_ON}`)}
+