master
Drew Owens Publish 0.80.0 eb7d34d
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961
#!/usr/bin/env node
// adom-gantt browser suite. Real Chrome, real server, seeded temp data.
//
//   node test/ui_test.mjs              everything: flows at 1440, viewport matrix, coverage gate, leak watch
//   node test/ui_test.mjs --keep       also leave screenshots and the temp dir (test/out/ + printed path)
//   node test/ui_test.mjs --only <s>   run only the flows whose name contains <s> (comma-separated
//                                      list allowed); the whole-suite gates (coverage by-registry,
//                                      untested count) are reported only on a full run
//   UI_TEST_PORTS=8960-8979         the only ports the suite will listen on (the default); it fails
//                                      loudly if none in the range is free
//   node test/ui_test.mjs --plan <p>   additionally run the header/legend/duration checks against a
//                                      real plan file (it is COPIED to a temp dir first; the server
//                                      writes sidecars beside whatever file it is given)
//
// Built to ~/.claude/skills/app-testing: the suite spawns its own server on a scratch
// port against a temp copy of seeded data, fails on ANY page error on any page, asserts
// the effect of each behaviour rather than the presence of an element, walks every
// state for a control-coverage manifest (no orphans, no fiction, click every safe
// control), renders a phone and two desktop widths, pins today's date in the page,
// always cleans up (also on SIGINT/SIGTERM, and a watchdog aborts a hung run after
// UI_TEST_WATCHDOG_MS, default 30 min), and exits non-zero on any failure.
// Failed requests are judged by URL: any 4xx/5xx or failed request to the app's own
// origin fails the run unless ALLOWED_FAILURES names it. Manifest entries with no
// effect-proving flow are marked `untested` and counted in the summary.
//
// Layers NOT built here: the protocol/integration layer (this app has one process, the
// server, and its API is exercised through the UI), golden-image visual regression
// (the container has no copies of the app's web fonts, so pixels would drift from any
// real browser's and a baseline would bless a fallback face), and the Ralph pass,
// which is a judgement loop run by a person or an agent over the --keep screenshots.
import puppeteer from './node_modules/puppeteer-core/lib/esm/puppeteer/puppeteer-core.js';
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync, readFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import net from 'node:net';
import http from 'node:http';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = dirname(HERE);
const argv = process.argv.slice(2);
const KEEP = argv.includes('--keep');
const REAL_PLAN = argv.includes('--plan') ? argv[argv.indexOf('--plan') + 1] : null;
const ONLY = argv.includes('--only') ? argv[argv.indexOf('--only') + 1].split(',').filter(Boolean) : null;
// A flow block runs when no --only is given or its name contains one of the --only terms.
const want = name => !ONLY || ONLY.some(o => name.includes(o));
const wantAny = (...names) => names.some(want);
const OUT = join(HERE, 'out');
const TMP = mkdtempSync(join(tmpdir(), 'adom-gantt-ui-'));
if (KEEP) mkdirSync(OUT, { recursive: true });

// ── Chrome: the Puppeteer-managed one, newest build ──
const CHROME_DIR = join(process.env.HOME || '/home/adom', '.cache/puppeteer/chrome');
const CHROME = process.env.CHROME_PATH || (() => {
  const dirs = readdirSync(CHROME_DIR).filter(d => d.startsWith('linux-'))
    .sort((a, b) => a.split('-')[1].localeCompare(b.split('-')[1], undefined, { numeric: true }));
  return join(CHROME_DIR, dirs.at(-1), 'chrome-linux64', 'chrome');
})();

// ── Results ──
let pass = 0, fail = 0;
const failures = [];
let section = '';
// Every flow (section) that executed at least one check. A terminal manifest entry's
// `by` must name one of these, so a `by` cannot point at a flow that does not exist
// or did not run.
const flowsRan = new Set();
function check(name, ok, detail = '') {
  if (section) flowsRan.add(section);
  const label = section ? `[${section}] ${name}` : name;
  if (ok) { pass++; console.log(`  ok   ${label}`); }
  else { fail++; failures.push(`${label}${detail ? ' :: ' + detail : ''}`); console.log(`  FAIL ${label}${detail ? '\n       ' + detail : ''}`); }
}
const sleep = ms => new Promise(r => setTimeout(r, ms));

// ── Seeded plan: 20 sections, first date 21-Sep-2026, last date 14-Jan-2027 ──
// Synthetic on purpose, so the suite ships without anyone's real plan in it and every
// expected figure below can be worked out by hand. Same shape as the reference launch
// plan Drew reported the legend and header problems against: 20 phases, ~110 rows.
const FIRST = '2026-09-21', LAST = '2027-01-14';
const TODAY = '2026-09-29';
function addDaysISO(ds, n) { const [y, m, d] = ds.split('-').map(Number); const t = new Date(Date.UTC(y, m - 1, d + n)); return t.toISOString().slice(0, 10); }
// `tags`: the first fifteen sections carry their group as a tag (Launch, Tech, Process) and
// the five Ops sections carry none, so the legend has tag chips AND the Setup page offers
// to tag the rest from their name prefixes. The coverage gate walks this one.
function seedPlan({ appName = 'launch-demo', title = 'Launch Demo: Jan 11, 2027', tags = false } = {}) {
  const groups = ['Launch', 'Tech', 'Process', 'Ops'];
  const phases = [], items = [];
  let uid = 1;
  const U = () => String(uid++).padStart(5, '0');
  for (let p = 0; p < 20; p++) {
    phases.push({ name: `${groups[Math.floor(p / 5)]} · Workstream ${String.fromCharCode(65 + p)} long name`, start: '2026-09', end: '2027-01',
      ...(tags && p < 15 ? { tags: [groups[Math.floor(p / 5)]] } : {}) });
    // five tasks per lane, staggered, all inside [FIRST, LAST - 7]
    for (let t = 0; t < 5; t++) {
      const s = addDaysISO(FIRST, 7 + p * 3 + t * 5);
      items.push({ type: 'task', id: `p${p}t${t}`, name: `Lane ${p + 1} task ${t + 1}`, start: s, end: addDaysISO(s, 4), phase: p, status: 'not-started', owner: ['Drew', 'John', 'Ana'][t % 3], uid: U(),
        // Row tags (0.60.0) on the tagged plan: one in a tagged section, one in an untagged one,
        // so the item editor and the batch panel open with chips to walk.
        ...(tags && t === 0 && (p === 0 || p === 19) ? { tags: ['long-lead'] } : {}) });
    }
    items.push({ type: 'milestone', name: `Lane ${p + 1} gate`, date: addDaysISO(FIRST, 30 + p * 2), phase: p, uid: U() });
  }
  // The plan's first date is a milestone in lane 1, the last is a task in lane 20.
  items.unshift({ type: 'milestone', name: 'Kickoff', date: FIRST, phase: 0, uid: U() });
  // "gate-a" ends Tue 12-Jan; "finale" (the last row of the plan) starts Mon 11-Jan
  // and waits on it, so Auto-schedule pushes finale later and moves the plan's end.
  items.push({ type: 'task', id: 'gate-a', name: 'Final gate prep', start: '2027-01-06', end: '2027-01-12', phase: 19, status: 'not-started', owner: 'Drew', uid: U() });
  items.push({ type: 'task', id: 'finale', name: 'Launch finale', start: '2027-01-11', end: LAST, phase: 19, status: 'not-started', owner: 'Drew', deps: ['gate-a'], uid: U() });
  // Cancelled work that would stretch the plan to June if it were counted. It is not:
  // the readouts leave cancelled rows out, the way Auto-schedule and the hours do.
  items.push({ type: 'task', id: 'dropped', name: 'Dropped export programme', start: '2027-03-01', end: '2027-06-30', phase: 10, status: 'cancelled', owner: 'Ana', uid: U() });
  return {
    project: { title, subtitle: 'Sixteen weeks from proof of concept to launch, one section per workstream', ...(appName ? { appName } : {}),
      view: { mode: 'gantt', grain: 'week', milestones: 'diamonds', today: true, labelWidth: 300 }, nextUid: uid },
    phases, items,
  };
}

// ── The oracle for the two readouts. Deliberately NOT imported from the page: if the
// app's formatter drifted, a test that asked the app what the answer is would drift
// with it. Inclusive day counts, the same rule a bar follows.
const dayNum = ds => { const [y, m, d] = ds.split('-').map(Number); return Math.round(Date.UTC(y, m - 1, d) / 86400000); };
// 0.74.0: "16.6 wks / 79 workdays". Its own US federal holiday calendar (observed: Saturday
// to Friday, Sunday to Monday), written here from the rules, not taken from the page.
const isoOf = x => new Date(x * 86400000).toISOString().slice(0, 10);
function obsDay(y, m, d) { const t = new Date(Date.UTC(y, m - 1, d)), w = t.getUTCDay(); if (w === 6) t.setUTCDate(d - 1); if (w === 0) t.setUTCDate(d + 1); return t.toISOString().slice(0, 10); }
function nthDowO(y, m, dow, n) { const t = new Date(Date.UTC(y, m - 1, 1)); while (t.getUTCDay() !== dow) t.setUTCDate(t.getUTCDate() + 1); t.setUTCDate(t.getUTCDate() + 7 * (n - 1)); return t.toISOString().slice(0, 10); }
function lastDowO(y, m, dow) { const t = new Date(Date.UTC(y, m, 0)); while (t.getUTCDay() !== dow) t.setUTCDate(t.getUTCDate() - 1); return t.toISOString().slice(0, 10); }
function holidaysO(y) { return new Set([obsDay(y, 1, 1), nthDowO(y, 1, 1, 3), nthDowO(y, 2, 1, 3), lastDowO(y, 5, 1), obsDay(y, 6, 19), obsDay(y, 7, 4), nthDowO(y, 9, 1, 1), nthDowO(y, 10, 1, 2), obsDay(y, 11, 11), nthDowO(y, 11, 4, 4), obsDay(y, 12, 25), obsDay(y + 1, 1, 1)].filter(d => +d.slice(0, 4) === y)); }
function workdaysO(a, b) { let n = 0; for (let x = dayNum(a); x <= dayNum(b); x++) { const w = new Date(x * 86400000).getUTCDay(), ds = isoOf(x); if (w === 0 || w === 6 || holidaysO(+ds.slice(0, 4)).has(ds)) continue; n++; } return n; }
function spanText(a, b) { const n = dayNum(b) - dayNum(a) + 1, wd = workdaysO(a, b); return (Math.round(n / 7 * 10) / 10).toFixed(1) + ' wks / ' + wd + ' workday' + (wd === 1 ? '' : 's'); }
const MN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const fmtDate = ds => { const [y, m, d] = ds.split('-').map(Number); return `${String(d).padStart(2, '0')}-${MN[m - 1]}-${y}`; };
// The app keeps its state inside a closure (one async IIFE), so the page has no
// globals to ask. The oracle reads the plan the SERVER holds instead, through the
// same export an AI or a person would use, after the edit's debounced save has
// landed. That makes every live-update check end to end: edit in the UI, saved to
// the sidecar, re-read, and compared with what the header says.
async function exportPlan(url) { return (await fetch(url + 'export/json')).json(); }
async function savedPlan(url, { minWait = 1100 } = {}) {
  await sleep(minWait);
  let prev = JSON.stringify(await exportPlan(url));
  for (let i = 0; i < 20; i++) {
    await sleep(250);
    const cur = JSON.stringify(await exportPlan(url));
    if (cur === prev) return JSON.parse(cur);
    prev = cur;
  }
  return JSON.parse(prev);
}
function spanOf(plan) {
  let f = '', l = '';
  const ok = s => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s);
  (plan.items || []).forEach(r => { if (r.type === 'group' || /^cancel/i.test(r.status || '')) return;
    (r.type === 'milestone' ? [r.date] : [r.start, r.end]).forEach(d => { if (!ok(d)) return; if (!f || d < f) f = d; if (!l || d > l) l = d; }); });
  return { first: f, last: l, n: (plan.items || []).filter(r => r.type !== 'group').length };
}
async function readouts(page) {
  return page.evaluate(() => ({
    dur: document.getElementById('spanDurV')?.textContent || null,
    left: document.getElementById('spanLeftV')?.textContent || null,
    durTip: document.getElementById('spanDur')?.title || '',
    leftTip: document.getElementById('spanLeft')?.title || '',
    leftState: document.getElementById('spanLeft')?.dataset.state || '',
  }));
}
// The readouts are right if they say what the oracle says about the plan's CURRENT dates.
let SRV = '';
async function expectReadouts(page, today, what) {
  const plan = await savedPlan(SRV);
  const { first, last, n } = spanOf(plan);
  const r = await readouts(page);
  const wantDur = spanText(first, last);
  const wantLeft = dayNum(today) > dayNum(last) ? 'ended' : spanText(today, last);
  check(`${what}: Project Duration = ${wantDur}`, r.dur === wantDur, `got ${r.dur} (first ${first}, last ${last})`);
  check(`${what}: Time Remaining = ${wantLeft}`, r.left === wantLeft, `got ${r.left}`);
  return { first, last, n, ...r };
}

// ── Server ──
const servers = [];
// The suite's ports are a FIXED, bounded range, so parallel agents and other tools can be
// given ports outside it safely: 8960-8979 by default (a full run starts about two dozen
// servers, a dozen at most up at once), or UI_TEST_PORTS=<lo>-<hi>. Until 0.59.0's review the probe walked upward from
// 8960 with no real bound, and a reviewer's own server on 8972 was driven by a run.
// Each call scans the whole range for a port this run has not handed out and nothing is
// listening on, and it fails loudly when there is none rather than wandering outside.
const PORT_RANGE = (() => { const m = String(process.env.UI_TEST_PORTS || '8960-8979').match(/^(\d+)-(\d+)$/);
  if (!m || +m[1] > +m[2]) throw new Error(`UI_TEST_PORTS must look like 8960-8979, got "${process.env.UI_TEST_PORTS}"`); return [+m[1], +m[2]]; })();
const portsGiven = new Set();
// A port is used only if nothing is listening on it, and the server that answers
// /health must be the one this suite spawned: if the spawn died on EADDRINUSE, some
// other process would be answering and the suite would be testing that instead.
async function freePort() {
  for (let p = PORT_RANGE[0]; p <= PORT_RANGE[1]; p++) {
    if (portsGiven.has(p)) continue;
    const ok = await new Promise(res => { const t = net.createServer(); t.once('error', () => res(false)); t.listen(p, '127.0.0.1', () => t.close(() => res(true))); });
    if (ok) { portsGiven.add(p); return p; }
  }
  throw new Error(`no free scratch port in ${PORT_RANGE[0]}..${PORT_RANGE[1]} (${portsGiven.size} used by this run; set UI_TEST_PORTS=<lo>-<hi> for another range)`);
}
async function startServer(plan, name) {
  const dir = mkdtempSync(join(TMP, name + '-'));
  const file = join(dir, 'plan.json');
  if (typeof plan === 'string') writeFileSync(file, readFileSync(plan)); else writeFileSync(file, JSON.stringify(plan, null, 1));
  const port = await freePort();
  const proc = spawn(process.execPath, [join(ROOT, 'server.js'), '--data', file, '--port', String(port), '--no-auth'], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
  let log = ''; proc.stdout.on('data', d => log += d); proc.stderr.on('data', d => log += d);
  const url = `http://127.0.0.1:${port}/`;
  for (let i = 0; i < 100; i++) {
    if (proc.exitCode !== null) throw new Error(`server ${name} exited (${proc.exitCode}): ${log}`);
    try { const r = await fetch(url + 'health'); if (r.ok && log.includes(`:${port}`)) break; } catch {}
    await sleep(100);
    if (i === 99) throw new Error(`server ${name} did not come up: ${log}`);
  }
  const s = { proc, url, dir, file, port, log: () => log };
  servers.push(s);
  return s;
}
// A dedicated flow is done with the servers it started once it returns: they stop and
// their ports go back to the range, so a run needs as many ports as are up at once, not
// as many as it ever started (0.74.1: 24 servers met the 20-port range and the run died).
async function stopServer(s) {
  const i = servers.indexOf(s); if (i >= 0) servers.splice(i, 1);
  if (s.proc.exitCode === null && s.proc.signalCode === null) {
    const gone = new Promise(r => s.proc.once('exit', r));
    s.proc.kill('SIGTERM');
    await Promise.race([gone, sleep(3000)]);
  }
  portsGiven.delete(s.port);
}
async function ownServers(flow, browser) {
  const before = servers.length;
  try { await flow(browser); }
  finally { for (const s of servers.slice(before)) await stopServer(s); }
}

// ── Pages: error gate, pinned clock, input modality ──
const pageErrors = [];
// Designed failures from the app's own server, each named with the reason. Empty: the
// app serves nothing that is meant to fail in these states. Status 0 = request failed.
const ALLOWED_FAILURES = [
  // The notify flow pastes a URL that is not a Chat webhook into the owner's field: the
  // server refuses it with a 400, which is the point of the check.
  { path: /^\/notify$/, status: 400 },
  // The clickup flow offers a file that is not a ClickUp export to ClickUp CSV: the server
  // refuses it with a 400 naming what it expected, which is the point of the check.
  { path: /^\/import\/clickup-csv$/, status: 400 },
];
async function openPage(browser, url, { w = 1440, h = 900, mobile = false, today = TODAY, clear = true, ctx = null } = {}) {
  const page = await (ctx || browser).newPage();
  const tag = `${w}px ${url.replace(/^.*\/\/[^/]+/, '').slice(0, 40) || '/'}`;
  page.on('pageerror', e => pageErrors.push(`[${tag}] pageerror: ${e.message}`));
  page.on('console', m => { if (m.type() === 'error') {
    const t = m.text();
    // A failed resource is judged by its URL, in the response/requestfailed handlers
    // below: Chrome's console line for a 404 or a 500 carries no URL, so it cannot be
    // told apart here. Everything else logged as an error is an error.
    if (/^Failed to load resource/.test(t)) return;
    pageErrors.push(`[${tag}] console: ${t.slice(0, 200)}`);
  } });
  // The app's own origin (its server, or file:// for an exported snapshot) must never
  // fail a request unless ALLOWED_FAILURES names that exact, designed failure.
  // Third-party failures are ignored: the web fonts come from Google and the container
  // has no network.
  const own = u => { try { const x = new URL(u); return x.protocol === 'file:' || x.hostname === '127.0.0.1' || x.hostname === 'localhost'; } catch { return false; } };
  const allowed = (u, status) => ALLOWED_FAILURES.some(a => a.path.test(new URL(u).pathname) && a.status === status);
  page.on('response', res => {
    const u = res.url(), st = res.status();
    if (st >= 400 && own(u) && !allowed(u, st)) pageErrors.push(`[${tag}] HTTP ${st} ${res.request().method()} ${u.replace(/^https?:\/\/[^/]+/, '')}`);
  });
  page.on('requestfailed', req => {
    const u = req.url(), why = req.failure()?.errorText || '';
    // ERR_ABORTED is the browser cancelling its own request (a reload or a navigation
    // while it was in flight), not the server failing it.
    // ERR_NETWORK_CHANGED is Chrome noticing the HOST's network configuration change
    // under it (a VPN, a container network restart): the request never reached the
    // server, and the app cannot have caused it. Seen twice on 2026-09-30, both times
    // during a container-side restart, both times on a run that was otherwise green.
    if (own(u) && why !== 'net::ERR_ABORTED' && why !== 'net::ERR_NETWORK_CHANGED' && !allowed(u, 0)) pageErrors.push(`[${tag}] request failed ${req.method()} ${u.replace(/^https?:\/\/[^/]+/, '')} (${why})`);
  });
  await page.setViewport({ width: w, height: h, isMobile: mobile, hasTouch: mobile, deviceScaleFactor: 1 });
  // Puppeteer's own emulateMediaFeatures refuses any-hover/any-pointer, so straight to CDP.
  const cdp = await page.createCDPSession();
  await cdp.send('Emulation.setEmulatedMedia', { features: mobile
    ? [{ name: 'any-hover', value: 'none' }, { name: 'any-pointer', value: 'coarse' }, { name: 'hover', value: 'none' }, { name: 'pointer', value: 'coarse' }]
    : [{ name: 'any-hover', value: 'hover' }, { name: 'any-pointer', value: 'fine' }, { name: 'hover', value: 'hover' }, { name: 'pointer', value: 'fine' }] });
  // Today is pinned: the Date constructor with no arguments and Date.now() both read a
  // clock offset to noon on `today`, and still tick, so timers behave.
  await page.evaluateOnNewDocument(iso => {
    const [y, m, d] = iso.split('-').map(Number);
    const Real = Date; let off = new Real(y, m - 1, d, 12, 0, 0).getTime() - Real.now();
    // The suite's own knob (not the app's): move the pinned clock on, to test rollover.
    Object.defineProperty(window, '__advanceClockDays', { value: n => { off += n * 86400000; } });
    function PinnedDate(...a) {
      if (!new.target) return new Real(Real.now() + off).toString();
      return a.length ? new Real(...a) : new Real(Real.now() + off);
    }
    PinnedDate.prototype = Real.prototype;
    PinnedDate.now = () => Real.now() + off; PinnedDate.parse = Real.parse; PinnedDate.UTC = Real.UTC;
    globalThis.Date = PinnedDate;
  }, today);
  // The suite's strict visibility test, the same everywhere a check says "visible":
  // checkVisibility with every option (so opacity:0, visibility:hidden and a hidden
  // ancestor all count as hidden) AND a non-zero box.
  await page.evaluateOnNewDocument(() => {
    Object.defineProperty(window, '__uiVis', { value: e => !!e && e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, opacityProperty: true, visibilityProperty: true })
      && (r => r.width > 0 && r.height > 0)(e.getBoundingClientRect()) });
  });
  await page.goto(url, { waitUntil: 'load' }).then(() => ready(page));
  if (clear) { await page.evaluate(() => { try { localStorage.clear(); } catch {} }); await page.reload({ waitUntil: 'load' }).then(() => ready(page)); }
  await settle(page);
  return page;
}
// Two frames, or 300ms if the tab is in the background (a hidden tab gets no frames).
// The app marks <html data-ready="1"> once the plan is loaded and painted. Since 0.66.0
// every page holds a live stream open, so "network idle" never comes; this is the wait.
async function ready(page) { try { await page.waitForSelector('html[data-ready="1"]', { timeout: 20000 }); } catch {} }
async function settle(page, ms = 250) { await sleep(ms); await page.evaluate(() => new Promise(r => { setTimeout(r, 300); requestAnimationFrame(() => requestAnimationFrame(r)); })); }
async function shot(page, name) { if (KEEP) await page.screenshot({ path: join(OUT, name + '.png') }); }
const fsPct = page => page.$eval('#fsPct', e => e.textContent);
async function setGrain(page, g) { await page.select('#grainSel', g); await settle(page, 200); }
async function view(page, v) { await page.click(`.vt-btn[data-view="${v}"]`); await settle(page, 200); }

// ── Geometry helpers ──
async function legendGeometry(page) {
  return page.evaluate(() => {
    const L = document.getElementById('legend').getBoundingClientRect();
    const chips = [...document.querySelectorAll('#legend .phase-chip')].map(c => {
      const r = c.getBoundingClientRect(), cx = r.left + r.width / 2, cy = r.top + r.height / 2;
      const hit = document.elementFromPoint(cx, cy);
      return { top: Math.round(r.top), left: r.left, right: r.right, bottom: r.bottom, w: r.width, clickable: !!hit && (hit === c || c.contains(hit)) };
    });
    const cw = document.querySelector('.chart-wrap').getBoundingClientRect();
    return { L: { top: L.top, bottom: L.bottom, left: L.left, right: L.right, h: L.height }, chips, lines: new Set(chips.map(c => c.top)).size,
      chartTop: cw.top, vw: innerWidth };
  });
}
// Scroll the chart down and measure the three things that stick: the two axis rows and
// the pinned section header. Each must sit exactly where the one above it ends.
async function stickyGeometry(page) {
  return page.evaluate(async () => {
    const tl = document.getElementById('timeline'), lc = document.getElementById('labels');
    tl.scrollTop = 900; lc.scrollTop = 900;
    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
    const T = tl.getBoundingClientRect();
    const m = document.querySelector('.m-row').getBoundingClientRect();
    const q = document.querySelector('.q-row').getBoundingClientRect();
    const lm = document.querySelector('.label-h-m').getBoundingClientRect();
    // the pinned header is the group row whose top is at the sticky offset
    const groups = [...document.querySelectorAll('.label-row.group')].map(g => g.getBoundingClientRect());
    const pinned = groups.filter(g => g.top <= lm.bottom + 1 && g.bottom > lm.bottom).sort((a, b) => b.top - a.top)[0] || null;
    const fs = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--fs')) || 1;
    const scrolled = tl.scrollTop;
    tl.scrollTop = 0; lc.scrollTop = 0;
    return { tTop: T.top, qTop: q.top, mBottom: m.bottom, lmBottom: lm.bottom, pinnedTop: pinned ? pinned.top : null, fs, scrolled };
  });
}
async function assertSticky(page, what) {
  const g = await stickyGeometry(page);
  check(`${what}: chart scrolled for the sticky check`, g.scrolled > 100, `scrollTop ${g.scrolled}`);
  check(`${what}: axis stays at the top of the chart pane`, Math.abs(g.qTop - g.tTop) <= 1.5, `q.top ${g.qTop} vs pane ${g.tTop}`);
  check(`${what}: axis height = 46px x text size`, Math.abs((g.mBottom - g.tTop) - 46 * g.fs) <= 2, `axis ${g.mBottom - g.tTop} fs ${g.fs}`);
  check(`${what}: pinned section header meets the axis`, g.pinnedTop !== null && Math.abs(g.pinnedTop - g.lmBottom) <= 2, `header ${g.pinnedTop} axis ${g.lmBottom}`);
}
async function assertNoSidewaysScroll(page, what) {
  const o = await page.evaluate(() => {
    const d = document.documentElement;
    const out = [...document.querySelectorAll('#hdr button, #hdr select, #legend .legend-item, #legend select, #legend input, #legend button')]
      .filter(e => window.__uiVis(e))
      .map(e => ({ e, r: e.getBoundingClientRect() }))
      .filter(x => x.r.width > 0 && (x.r.right > innerWidth + 0.5 || x.r.left < -0.5))
      .map(x => (x.e.id || x.e.className || x.e.tagName) + ' @' + Math.round(x.r.left) + '..' + Math.round(x.r.right));
    return { over: d.scrollWidth - d.clientWidth, bodyOver: document.body.scrollWidth - document.body.clientWidth, off: out };
  });
  check(`${what}: page does not scroll sideways`, o.over <= 0 && o.bodyOver <= 0, `doc +${o.over}px body +${o.bodyOver}px`);
  check(`${what}: every header and legend control is inside the window`, o.off.length === 0, o.off.slice(0, 6).join(' | '));
}

// ────────────────────────────────────────────────────────────────────────────
async function main() {
  const browser = BROWSER = await puppeteer.launch({ executablePath: CHROME, headless: 'new',
    args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--hide-scrollbars'] });
  try {
    const plan = seedPlan();
    const S = await startServer(plan, 'plan20');
    SRV = S.url;
    let page, r, lg;

    if (wantAny('header', 'dates', 'project dates', 'live')) {
      // ── 3. Header declutter ──
      section = 'header';
      page = await openPage(browser, S.url);
      const hdr = await page.evaluate(() => ({
        brand: document.querySelector('#hdr .brand .app-name').textContent,
        brandTip: document.querySelector('#hdr .brand').title,
        name: !!document.querySelector('.subject-name'), sub: !!document.querySelector('.subject-sub'),
        unitCount: !!document.getElementById('unitCount'),
        text: document.getElementById('hdr').innerText, docTitle: document.title,
      }));
      check('brand block shows the plan appName', hdr.brand === plan.project.appName, hdr.brand);
      check('no bold title (.subject-name) in the header', !hdr.name);
      check('no dimmed description (.subject-sub) in the header', !hdr.sub);
      check('the title and subtitle are not painted anywhere in the header', !hdr.text.includes(plan.project.title) && !hdr.text.includes(plan.project.subtitle));
      check('brand tooltip still carries the title and the subtitle', hdr.brandTip.includes(plan.project.title) && hdr.brandTip.includes(plan.project.subtitle), hdr.brandTip);
      check('document.title still names the plan', hdr.docTitle.includes(plan.project.title), hdr.docTitle);
      check('the old cell count (#unitCount) is gone', !hdr.unitCount);
      check('header text has no "weeks" cell count', !/\b\d+ (weeks|months|days|quarters|years)\b/.test(hdr.text), hdr.text.replace(/\s+/g, ' ').slice(0, 160));
      await shot(page, 'desktop-1440-header-legend');

      // ── 4 + 5. Exact start date and the two readouts, at every grain ──
      section = 'dates';
      const base = await expectReadouts(page, TODAY, 'on load');
      check('on load: duration is 16.6 wks with its workdays (116 days, 21-Sep-2026..14-Jan-2027)', base.dur === spanText('2026-09-21', '2027-01-14') && /^16\.6 wks \/ \d+ workdays$/.test(base.dur), base.dur);
      check('on load: remaining is 15.4 wks with its workdays (108 days from 29-Sep-2026)', base.left === spanText('2026-09-29', '2027-01-14') && /^15\.4 wks/.test(base.left), base.left);
      check('duration tooltip states both exact dates', base.durTip.includes('21-Sep-2026') && base.durTip.includes('14-Jan-2027') && base.durTip.includes('116 calendar days'), base.durTip);
      check('remaining tooltip states today and the end date', base.leftTip.includes('29-Sep-2026') && base.leftTip.includes('14-Jan-2027'), base.leftTip);
      check('the cancelled row to 30-Jun-2027 is left out, and the tooltip says so', base.dur === spanText('2026-09-21', '2027-01-14') && /1 cancelled row left out/.test(base.durTip), base.durTip);
      check('while running there is no "not started" note', await page.$eval('#spanLeftN', e => e.textContent) === '');
      const axes = [];
      for (const g of ['day', 'week', 'month', 'quarter', 'year']) {
        await setGrain(page, g);
        const r = await readouts(page);
        const ax = await page.evaluate(() => { const cs = [...document.querySelectorAll('.m-row > *')]; return { n: cs.length, labels: cs.slice(0, 4).map(c => c.textContent.trim()).join('|') }; });
        axes.push({ g, ...ax });
        check(`grain ${g}: duration and remaining do not move`, r.dur === base.dur && r.left === base.left, `${r.dur} / ${r.left}`);
      }
      // The axis must REDRAW for the grain: finer grains have more cells (day > week >
      // month >= quarter >= year) and no two grains paint the same cells.
      const [dA, wA, mA, qA, yA] = axes;
      check('the axis redraws with the grain: day > week > month >= quarter >= year cells', dA.n > wA.n && wA.n > mA.n && mA.n >= qA.n && qA.n >= yA.n && yA.n > 0, axes.map(a => `${a.g}:${a.n}`).join(' '));
      check('the axis redraws with the grain: every grain paints different cells', new Set(axes.map(a => a.n + '#' + a.labels)).size === 5, axes.map(a => `${a.g}:${a.labels}`).join(' ; '));
      await setGrain(page, 'week');

      // 0.75.0: the project's own dates, set on Setup, replace the start button. The
      // readouts count from them, the chart draws them, and rows past them are listed.
      section = 'project dates';
      check('the start button and its picker are gone', !(await page.$('#startBtn')) && !(await page.$('#datePop')));
      await view(page, 'setup');
      // Typed the way a person types into a date field: month, day, year. The field fires
      // change while the year is half typed (0002, 0020, 0202), and none of that commits.
      // Clicked at its left edge, on the month: the middle of a date field is the day.
      const typeDate = async (sel, mmddyyyy) => { await page.click(sel, { offset: { x: 14, y: 12 } }); await page.keyboard.type(mmddyyyy, { delay: 15 }); await settle(page, 150); };
      await typeDate('#pdStart', '09142026');
      await typeDate('#pdEnd', '12182026');
      await page.mouse.click(5, 5); await settle(page, 300);
      const PD = { start: '2026-09-14', end: '2026-12-18' };
      const pdState = async () => { await sleep(1000); return ((await (await fetch(SRV + 'state')).json()).dates) || null; };
      let pdSaved = await pdState();
      check('the typed dates are saved, and nothing half-typed was', pdSaved && pdSaved.start === PD.start && pdSaved.end === PD.end, JSON.stringify(pdSaved));
      const pdLog = await (await fetch(SRV + 'changelog?format=md')).text();
      check('the changelog says who set them', /set the project start to 14-Sep-2026 \(was not set\)/.test(pdLog) && /set the project end to 18-Dec-2026/.test(pdLog) && !/0002|0020|0202/.test(pdLog), pdLog.trim().split('\n').slice(-3).join(' | '));
      r = await readouts(page);
      check('Duration counts from the project start to its end', r.dur === spanText(PD.start, PD.end) && /from the project start 14-Sep-2026 to the project end 18-Dec-2026/.test(r.durTip), `${r.dur} | ${r.durTip}`);
      check('Remaining counts from today to the project end', r.left === spanText(TODAY, PD.end), r.left);
      const late = (await savedPlan(SRV)).items.filter(x => x.type !== 'group' && !/cancel/i.test(x.status || '') && (x.type === 'milestone' ? x.date : x.end) > PD.end);
      const card = await page.evaluate(() => ({ span: document.getElementById('pdSpan').textContent, status: document.getElementById('pdStatus').textContent, links: [...document.querySelectorAll('#pdOut li a')].map(a => a.textContent), outside: document.getElementById('spanDur').dataset.outside }));
      check(`the card lists the ${late.length} rows that end after the project end`, late.length > 0 && card.links.length === Math.min(late.length, 12) && late.slice(0, 12).every(x => card.links.includes(x.name)) && card.status.startsWith(late.length + ' row'), JSON.stringify(card));
      check('the card shows the span, and the readouts turn amber', card.span === spanText(PD.start, PD.end) && card.outside === '1', JSON.stringify(card));
      await page.evaluate(() => { const e = document.getElementById('pdEnd'); e.value = '2026-09-01'; e.dispatchEvent(new Event('change')); }); await settle(page, 200);
      pdSaved = await pdState();
      check('an end before the start is refused, with a reason, and nothing is saved', /end has to be on or after the start/.test(await page.$eval('#pdStatus', e => e.textContent)) && pdSaved.end === PD.end, JSON.stringify(pdSaved));
      await view(page, 'gantt');
      const lines = await page.evaluate(() => { const g = s => document.querySelector(s); const x = s => g(s).getBoundingClientRect();
        const fin = document.querySelector('.bar[data-id="finale"]').getBoundingClientRect();
        return { shown: ['.pdate-start', '.pdate-end', '.pdate-start-tag', '.pdate-end-tag', '.pdate-after'].every(s => g(s).style.display !== 'none'),
          dashed: getComputedStyle(g('.pdate-end')).borderLeftStyle, endX: x('.pdate-end').left, startX: x('.pdate-start').left, afterX: x('.pdate-after').left, finaleRight: fin.right, tags: [g('.pdate-start-tag').textContent, g('.pdate-end-tag').textContent] }; });
      check('the chart draws both dates as dashed lines, tagged start and end', lines.shown && lines.dashed === 'dashed' && lines.startX < lines.endX && lines.tags.join() === 'start,end', JSON.stringify(lines));
      check('the time after the end is hatched from the end line on, and a late bar runs into it', Math.abs(lines.afterX - lines.endX) <= 2 && lines.finaleRight > lines.endX, JSON.stringify(lines));
      // Undo history is the tab's own, so it is tried before the reload. Two commits, two
      // Ctrl+Z: the rows rule the readouts again; two Ctrl+Shift+Z put the dates back.
      await view(page, 'setup');
      await page.evaluate(() => document.activeElement && document.activeElement.blur());
      await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page, 300);
      r = await readouts(page);
      pdSaved = await pdState();
      check('Ctrl+Z takes both dates back, and the readouts count from the rows again', r.dur === base.dur && r.left === base.left && pdSaved.start === '' && pdSaved.end === '', `${r.dur} ${JSON.stringify(pdSaved)}`);
      await page.keyboard.down('Control'); await page.keyboard.down('Shift'); await page.keyboard.press('KeyZ'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Shift'); await page.keyboard.up('Control'); await settle(page, 300);
      r = await readouts(page);
      check('Ctrl+Shift+Z puts them back', r.dur === spanText(PD.start, PD.end) && await page.$eval('#pdEnd', e => e.value) === PD.end, r.dur);
      await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
      r = await readouts(page);
      check('the dates survive a reload', r.dur === spanText(PD.start, PD.end) && await page.$eval('.pdate-end', e => e.style.display !== 'none'), r.dur);
      // The team sees them and cannot change them.
      await view(page, 'setup');
      await page.click('#sharePreview'); await settle(page, 200);
      await view(page, 'setup');
      const teamPd = await page.evaluate(() => ({ s: document.getElementById('pdStart').disabled, e: document.getElementById('pdEnd').disabled, v: document.getElementById('pdEnd').value }));
      check('in the team view the dates are shown but cannot be changed', teamPd.s && teamPd.e && teamPd.v === PD.end, JSON.stringify(teamPd));
      await page.click('#previewBack'); await settle(page, 200);
      // Cleared the way a person clears a date field, and the plan is back on its rows.
      await view(page, 'setup');
      await page.evaluate(() => ['pdStart', 'pdEnd'].forEach(id => { const e = document.getElementById(id); e.value = ''; e.dispatchEvent(new Event('change')); })); await settle(page, 300);
      r = await readouts(page);
      pdSaved = await pdState();
      check('clearing both dates puts the readouts back on the rows', r.dur === base.dur && r.left === base.left && pdSaved.start === '' && pdSaved.end === '', `${r.dur} ${JSON.stringify(pdSaved)}`);
      await view(page, 'gantt');
      check('with no dates set, no line and no hatching', await page.evaluate(() => [...document.querySelectorAll('.pdate-line,.pdate-out,.pdate-tag')].every(e => e.style.display === 'none')));
      await setGrain(page, 'week');

      // Drag the last bar's right edge two weeks later.
      section = 'live: drag';
      const finaleBox = async () => page.evaluate(() => {
        const b = document.querySelector('.bar[data-id="finale"]'); b.scrollIntoView({ block: 'center', inline: 'center' });
        const h = b.querySelector('.drag-handle.right') || b; const r = h.getBoundingClientRect();
        return { x: r.left + r.width / 2, y: r.top + r.height / 2, col: document.querySelector('.m-row > *').getBoundingClientRect().width };
      });
      let fb = await finaleBox(); await settle(page, 150); fb = await finaleBox();
      await page.mouse.move(fb.x, fb.y); await page.mouse.down();
      for (let i = 1; i <= 8; i++) { await page.mouse.move(fb.x + fb.col * 2 * i / 8, fb.y); await sleep(20); }
      await page.mouse.up(); await settle(page, 300);
      const afterDrag = await expectReadouts(page, TODAY, 'after dragging the last bar later');
      check('the drag really moved the end of the plan', afterDrag.last > LAST, afterDrag.last);
      check('duration grew with it', afterDrag.dur !== base.dur, afterDrag.dur);
      section = 'live: undo/redo';
      await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);
      r = await expectReadouts(page, TODAY, 'undo');
      check('undo restores the duration', r.dur === base.dur, r.dur);
      await page.keyboard.down('Control'); await page.keyboard.down('Shift'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Shift'); await page.keyboard.up('Control'); await settle(page);
      r = await expectReadouts(page, TODAY, 'redo');
      check('redo brings the dragged end back', r.dur === afterDrag.dur, r.dur);
      await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);

      // Auto-schedule pushes "finale" after "gate-a", which moves the plan's last date.
      section = 'live: auto-schedule';
      await page.click('#btnAuto');
      await page.waitForFunction(() => !document.querySelector('#btnAuto.btn-busy'), { timeout: 15000 }); await settle(page, 400);
      r = await expectReadouts(page, TODAY, 'after Auto-schedule');
      check('Auto-schedule moved the end of the plan', r.last !== LAST, r.last);
      await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);
      r = await expectReadouts(page, TODAY, 'undo of Auto-schedule');
      check('undo of Auto-schedule restores the duration', r.dur === base.dur, r.dur);

      // Table edit of the last row's deadline.
      section = 'live: table edit';
      await view(page, 'table');
      const cellSel = 'tr[data-key] td.c-date[data-field="deadline"]';
      const keyOfFinale = await page.evaluate(() => [...document.querySelectorAll('#tableView tr[data-key]')].find(tr => /Launch finale/.test(tr.textContent)).dataset.key);
      await page.evaluate(k => document.querySelector(`tr[data-key="${k}"]`).scrollIntoView({ block: 'center' }), keyOfFinale);
      // The undo toast from the step before sits over the bottom of the table, and the
      // finale row is near the bottom (the cancelled row sorts after it); a person would
      // wait for it too.
      await page.mouse.move(700, 120);
      await page.waitForFunction(() => !document.querySelector('#undoToast.show'), { timeout: 8000 });
      await page.click(`tr[data-key="${keyOfFinale}"] td.c-date[data-field="deadline"]`); await settle(page, 100);
      await page.evaluate(() => { const i = document.querySelector('.cell-edit input'); i.value = '2027-01-21'; i.dispatchEvent(new Event('input', { bubbles: true })); });
      await page.keyboard.press('Enter'); await settle(page, 300);
      r = await expectReadouts(page, TODAY, 'after a table deadline edit');
      check('table edit moved the end to 21-Jan-2027 (17.6 wks)', r.last === '2027-01-21' && r.dur === spanText('2026-09-21', '2027-01-21'), `${r.last} ${r.dur}`);
      await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);
      r = await expectReadouts(page, TODAY, 'undo of the table edit');

      // Delete the last row, then undo.
      section = 'live: delete';
      await page.evaluate(k => document.querySelector(`tr[data-key="${k}"]`).scrollIntoView({ block: 'center' }), keyOfFinale);
      // The delete button is a hover affordance (opacity 0 until the row is hovered):
      // hover the row like a person, check it became visible, then click it with the mouse.
      // The undo toast from the step before sits over the bottom of the table, where
      // this row is; a person waits for it before reaching for the row, and so does this.
      // The toast pauses while the pointer is over it (by design), and the pointer is still
      // where the last click left it, so it is moved off first.
      // The "saved" pill sits at the bottom right, over this row's delete button, so it
      // is waited out too. Until 0.59.0 the toast stuck until the pointer moved (a bug),
      // and that wait happened to outlast the pill.
      await page.mouse.move(700, 120);
      await page.waitForFunction(() => !document.querySelector('#undoToast.show') && !document.querySelector('#saveInd.show'), { timeout: 8000 });
      await page.hover(`tr[data-key="${keyOfFinale}"]`); await settle(page, 200);
      // A live repaint can swap the row under a still pointer; a real hand is never
      // perfectly still, so nudge the pointer inside the row, as it would.
      const trb = await (await page.$(`tr[data-key="${keyOfFinale}"]`)).boundingBox(); await page.mouse.move(trb.x + trb.width / 2 + 3, trb.y + trb.height / 2); await settle(page, 200);
      const rd = await page.$eval(`tr[data-key="${keyOfFinale}"] .row-del`, b => ({ vis: window.__uiVis(b), op: getComputedStyle(b).opacity, hov: b.closest('tr').matches(':hover'), top: document.elementFromPoint(b.getBoundingClientRect().left + 5, b.getBoundingClientRect().top + 5)?.className }));
      check('hovering the row reveals its delete button', rd.vis && rd.op === '1', JSON.stringify(rd));
      await page.click(`tr[data-key="${keyOfFinale}"] .row-del`); await settle(page, 300);
      r = await expectReadouts(page, TODAY, 'after deleting the last row');
      check('deleting the last row shortened the plan', r.last < LAST, r.last);
      await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);
      r = await expectReadouts(page, TODAY, 'undo of the delete');
      check('undo of the delete restores the duration', r.dur === base.dur, r.dur);

      // Paste: copy the last row and paste it; the copy has the same dates, so the span
      // is unchanged, and the readouts must still agree with the plan.
      section = 'live: paste';
      await view(page, 'gantt');
      const nBefore = r.n;
      const lbl = await page.evaluateHandle(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => /Launch finale/.test(x.textContent)); e.scrollIntoView({ block: 'center' }); return e; });
      await settle(page, 100);
      const lb = await lbl.boundingBox();
      // Ctrl+click selects a row without opening its name editor (a plain click edits).
      await page.keyboard.down('Control'); await page.mouse.click(lb.x + 40, lb.y + lb.height / 2); await page.keyboard.up('Control'); await settle(page, 100);
      await page.keyboard.down('Control'); await page.keyboard.press('KeyC'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control');
      r = await expectReadouts(page, TODAY, 'after paste');
      check('paste added a row', r.n === nBefore + 1, `${nBefore} -> ${r.n}`);
      await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);

      // Create a task past the end with the New editor.
      section = 'live: create';
      await view(page, 'gantt');
      await page.click('#btnNew'); await settle(page, 150);
      await page.type('#ncName', 'Post-launch retro');
      await page.evaluate(() => { const s = document.getElementById('ncStart'), e = document.getElementById('ncEnd'); s.value = '2027-01-25'; e.value = '2027-01-29'; s.dispatchEvent(new Event('input', { bubbles: true })); e.dispatchEvent(new Event('input', { bubbles: true })); s.dispatchEvent(new Event('change', { bubbles: true })); e.dispatchEvent(new Event('change', { bubbles: true })); });
      await page.click('#ncCreate'); await settle(page, 800);
      r = await expectReadouts(page, TODAY, 'after creating a task past the end');
      check('the new task moved the end to 29-Jan-2027', r.last === '2027-01-29', r.last);
      await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page, 300);
      r = await expectReadouts(page, TODAY, 'undo of the create');
      check('undo of the create restores the duration', r.dur === base.dur, r.dur);
      await page.close();
    }

    if (wantAny('dates: clock')) {
      // Plan not started yet, and plan over.
      section = 'dates: clock';
      page = await openPage(browser, S.url, { today: '2026-09-01' });
      r = await readouts(page);
      check('before the plan starts: remaining counts from today (136 days = 19.4 wks)', r.left === spanText('2026-09-01', LAST), r.left);
      check('before the plan starts: state is not-started and the tooltip says so', r.leftState === 'not-started' && /not started/.test(r.leftTip), r.leftTip);
      check('before the plan starts: duration unchanged', r.dur === spanText('2026-09-21', LAST), r.dur);
      check('before the plan starts: the readout itself says "not started"', await page.$eval('#spanLeftN', e => e.textContent) === 'not started' && await page.$eval('#spanLeftN', e => window.__uiVis(e)));
      await page.close();
      page = await openPage(browser, S.url, { today: '2027-02-01' });
      r = await readouts(page);
      check('after the plan ends: remaining reads "ended"', r.left === 'ended' && r.leftState === 'ended', r.left);
      check('after the plan ends: tooltip says how long ago', /18 days ago/.test(r.leftTip), r.leftTip);
      await page.close();
      page = await openPage(browser, S.url, { today: LAST });
      r = await readouts(page);
      check('on the last day: 0.1 wks / 1 workday remaining', r.left === spanText(LAST, LAST) && r.left === '0.1 wks / 1 workday', r.left);
      // A tab left open overnight: the date rolls over with no edit at all, and coming
      // back to the tab repaints (the minute tick does the same for a tab left in view).
      await page.evaluate(() => { window.__advanceClockDays(1); document.dispatchEvent(new Event('visibilitychange')); });
      await settle(page, 100);
      r = await readouts(page);
      check('the day after, with no edit: remaining turns to "ended"', r.left === 'ended', r.left);
      await page.close();
    }

    if (wantAny('text size')) {
      // ── 2. Text size persists: live app ──
      section = 'text size (live)';
      page = await openPage(browser, S.url);
      await page.click('#fsUp'); await page.click('#fsUp'); await settle(page);
      check('A+ twice reads 120%', await fsPct(page) === '120%');
      const legendBefore = (await legendGeometry(page)).L.h;
      await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
      check('120% survives a refresh', await fsPct(page) === '120%');
      check('and it is applied, not just displayed', await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--fs').trim()) === '1.2');
      await assertSticky(page, 'at 120%');
      await page.click('#fsDown'); await page.click('#fsDown'); await settle(page);
      check('A- twice is back at 100%', await fsPct(page) === '100%');
      await page.click('#fsUp'); await page.click('#fsPct'); await settle(page);
      check('clicking the percentage resets to 100%', await fsPct(page) === '100%');
      await page.close();

      // Cause 2: a second tab opened BEFORE the change used to write its stale size back.
      section = 'text size (two tabs)';
      const ctx = await browser.createBrowserContext();
      const A = await openPage(browser, S.url, { ctx });
      const B = await openPage(browser, S.url, { ctx, clear: false });
      await B.click('#fsUp'); await B.click('#fsUp'); await settle(B);
      await settle(A, 300);
      check('the other open tab follows the new size live', await fsPct(A) === '120%', await fsPct(A));
      await A.bringToFront(); await A.select('#grainSel', 'month'); await settle(A);
      await B.bringToFront(); await B.reload({ waitUntil: 'load' }).then(() => ready(B)); await settle(B);
      check('a save in the other tab no longer resets the size on refresh', await fsPct(B) === '120%', await fsPct(B));
      // Every other pref too (review finding): tab A collapses everything, tab B, opened
      // before that, toggles the theme. B used to write its whole prefs object, empty
      // collapse set included, and A's refresh came back with every lane open.
      await A.bringToFront(); await A.click('#btnCollapseAll'); await settle(A);
      await B.bringToFront(); await B.click('#btnTheme'); await settle(B);
      await A.bringToFront(); await A.reload({ waitUntil: 'load' }).then(() => ready(A)); await settle(A);
      check('another tab saving the theme no longer reopens every lane', await A.$$eval('.lane-toggle.collapsed', e => e.length) === 20, String(await A.$$eval('.lane-toggle.collapsed', e => e.length)));
      check('and the theme that tab chose is kept too', await A.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'light');
      check('and the text size is still 120%', await fsPct(A) === '120%');
      await A.close(); await B.close(); await ctx.close();

      // Cause 1: the exported snapshot never read the size back.
      section = 'text size (snapshot)';
      const html = await (await fetch(S.url + 'export/html')).text();
      check('export/html produced a snapshot', html.includes('const SNAPSHOT = true;'));
      const snapFile = join(TMP, 'snapshot.html'); writeFileSync(snapFile, html);
      const snapUrl = 'file://' + snapFile;
      page = await openPage(browser, snapUrl);
      // A reader who has other prefs stored from some other chart:
      await page.evaluate(() => localStorage.setItem('adom-gantt:prefs', JSON.stringify({ granularity: 'day', theme: 'light', fs: 1.1, collapsed: [0, 1, 2] })));
      await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
      check('the snapshot takes the reader\'s text size (110%)', await fsPct(page) === '110%', await fsPct(page));
      check('the snapshot still opens at the author\'s grain', await page.$eval('#grainSel', s => s.value) === 'week');
      check('the snapshot still opens at the author\'s theme', await page.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'dark');
      check('the snapshot ignores a stored collapse set', await page.$$eval('.lane-toggle.collapsed', e => e.length) === 0);
      await page.click('#fsUp'); await settle(page);
      await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
      check('A+ in the snapshot survives a refresh (120%)', await fsPct(page) === '120%', await fsPct(page));
      const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('adom-gantt:prefs')));
      check('the snapshot did not overwrite the reader\'s other stored prefs', stored.granularity === 'day' && stored.theme === 'light' && stored.fs === 1.2, JSON.stringify(stored));
      // A stored value that parses to an ARRAY used to swallow the snapshot's write.
      await page.evaluate(() => localStorage.setItem('adom-gantt:prefs', '[1,2,3]'));
      // Open without the fragment the A+ above wrote, since this case is about storage.
      await page.goto(snapUrl, { waitUntil: 'load' }).then(() => ready(page)); await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
      await page.click('#fsUp'); await settle(page);
      await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
      check('a stored array does not swallow the size (110% after A+ from 100%)', await fsPct(page) === '110%', await fsPct(page));
      const snapR = await readouts(page);
      // The seeded plan runs 21-Sep-2026 to 14-Jan-2027; the oracle counts the workdays.
      check('the snapshot shows the same readouts', snapR.dur === spanText('2026-09-21', '2027-01-14') && snapR.left === spanText(TODAY, '2027-01-14'), JSON.stringify(snapR));
      await page.close();

      // Cause 3 (0.58.0): the wiki serves an uploaded snapshot under a CSP sandbox with
      // no allow-same-origin. The page gets an opaque origin, storage works for the life
      // of the page and every reload starts empty, which is what Drew saw on a 0.57.1
      // snapshot at wiki.adom.inc. Same header here, on a throwaway static server.
      section = 'text size (sandboxed snapshot)';
      const sandboxSrv = http.createServer((req, res) => {
        res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',
          'content-security-policy': "sandbox allow-scripts allow-downloads allow-popups; default-src 'none'; script-src 'unsafe-inline' https:; style-src 'unsafe-inline' https:; img-src data: https:; font-src data: https:; connect-src 'self'" });
        res.end(html);
      });
      const sandboxPort = await freePort();
      await new Promise(r => sandboxSrv.listen(sandboxPort, '127.0.0.1', r));
      try {
        const sbUrl = `http://127.0.0.1:${sandboxPort}/render/gantt/index.html`;
        page = await openPage(browser, sbUrl);
        check('sandboxed snapshot opens at 100%', await fsPct(page) === '100%', await fsPct(page));
        await page.click('#fsUp'); await settle(page); await page.click('#fsUp'); await settle(page);
        check('A+ twice reads 120% and the URL fragment carries it', await fsPct(page) === '120%' && await page.evaluate(() => location.hash) === '#fs=1.2', await page.evaluate(() => location.hash));
        const storedInSandbox = await page.evaluate(() => { try { return localStorage.getItem('adom-gantt:prefs'); } catch (e) { return 'THROWS ' + e.name; } });
        await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
        const storedAfter = await page.evaluate(() => { try { return localStorage.getItem('adom-gantt:prefs'); } catch (e) { return 'THROWS ' + e.name; } });
        check('the sandbox really does deny or forget storage across a reload (the test is honest)', storedAfter === null || /THROWS/.test(storedAfter), `${storedInSandbox} -> ${storedAfter}`);
        check('and the text size still survives the refresh (120%)', await fsPct(page) === '120%', await fsPct(page));
        await page.click('#fsPct'); await settle(page);
        check('reset to 100% clears the fragment', await page.evaluate(() => location.hash) === '#fs=1', await page.evaluate(() => location.hash));
        // A hash-only goto is a same-document navigation, so reload to load the page afresh.
        await page.goto(`${sbUrl}#fs=1.35`); await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
        check('a shared link with #fs=1.35 opens at 135%', await fsPct(page) === '135%', await fsPct(page));
        await page.goto(`${sbUrl}#fs=bogus`); await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
        check('a bad fragment value falls back to 100%', await fsPct(page) === '100%', await fsPct(page));
        await page.close();
      } finally {
        // Chrome keeps an idle socket to this server open, and close() waits for it: up to
        // the whole run's watchdog, once (0.75.1). The sockets are dropped first.
        sandboxSrv.closeAllConnections?.();
        await new Promise(r => sandboxSrv.close(r));
      }
    }

    if (wantAny('collapse all')) {
      // ── 1. Collapse / expand all ──
      section = 'collapse all';
      page = await openPage(browser, S.url);
      const collapseState = () => page.evaluate(() => ({
        label: document.querySelector('#btnCollapseAll .ca-label').textContent,
        pressed: document.getElementById('btnCollapseAll').getAttribute('aria-pressed'),
        lanes: document.querySelectorAll('.lane-toggle').length,
        shut: document.querySelectorAll('.lane-toggle.collapsed').length,
        visibleRows: [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.offsetHeight > 0).length,
      }));
      let c = await collapseState();
      check('starts as "Collapse all" with every lane open', c.label === 'Collapse all' && c.shut === 0 && c.visibleRows > 100, JSON.stringify(c));
      await page.click('#btnCollapseAll'); await settle(page);
      c = await collapseState();
      check('one press collapses every lane', c.shut === c.lanes && c.lanes === 20, JSON.stringify(c));
      check('and hides every task and milestone row', c.visibleRows === 0, String(c.visibleRows));
      check('the button now offers "Expand all"', c.label === 'Expand all' && c.pressed === 'true');
      await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
      c = await collapseState();
      check('collapse-all persists across a refresh', c.shut === 20 && c.label === 'Expand all', JSON.stringify(c));
      await view(page, 'table');
      const emptyMsg = await page.$eval('#tableView tbody', t => t.innerText);
      check('the table agrees: no rows, and says why', /Every section is collapsed/.test(emptyMsg), emptyMsg.slice(0, 80));
      await page.click('#btnCollapseAll'); await settle(page);
      const tRows = await page.$$eval('#tableView tbody tr[data-key]', e => e.length);
      const nItems = spanOf(await exportPlan(S.url)).n;
      check('Expand all from the table brings every row back', tRows === nItems, `${tRows} of ${nItems}`);
      await view(page, 'gantt');
      c = await collapseState();
      check('and the chart agrees: every lane open', c.shut === 0 && c.label === 'Collapse all', JSON.stringify(c));
      // Mixed state: one lane shut by hand, then the button.
      await page.click('.label-row.group .lane-toggle'); await settle(page);
      c = await collapseState();
      check('per-lane chevron still collapses one lane', c.shut === 1, JSON.stringify(c));
      check('mixed state offers "Collapse all"', c.label === 'Collapse all');
      await page.click('#btnCollapseAll'); await settle(page);
      c = await collapseState();
      check('from mixed, one press collapses the rest', c.shut === 20, JSON.stringify(c));
      // Open every lane by hand except... all of them: the button follows the chevrons.
      await page.evaluate(() => document.querySelectorAll('.lane-toggle.collapsed').forEach(b => b.click())); await settle(page);
      c = await collapseState();
      check('opening lanes by hand turns the button back to "Collapse all"', c.shut === 0 && c.label === 'Collapse all', JSON.stringify(c));
      await page.close();
    }

    if (wantAny('legend')) {
      // ── 6. Legend wraps ──
      section = 'legend';
      page = await openPage(browser, S.url);
      lg = await legendGeometry(page);
      check('20 phase chips rendered', lg.chips.length === 20, String(lg.chips.length));
      check('the legend wraps onto more than one line', lg.lines >= 2, `${lg.lines} line(s), ${lg.L.h}px`);
      check('every chip is inside the legend and the window', lg.chips.every(c => c.right <= lg.L.right + 0.5 && c.bottom <= lg.L.bottom + 0.5 && c.right <= lg.vw), JSON.stringify(lg.chips.filter(c => c.right > lg.L.right || c.bottom > lg.L.bottom).slice(0, 2)));
      check('every chip is clickable (nothing covers it)', lg.chips.every(c => c.clickable));
      check('the chart starts right below the legend', Math.abs(lg.chartTop - lg.L.bottom) <= 1.5, `${lg.chartTop} vs ${lg.L.bottom}`);
      // The last chip is the one a one-line legend clipped; filter with it.
      const last = (await page.$$('#legend .phase-chip')).at(-1);
      await last.click(); await settle(page);
      const dimmed = await page.$$eval('.label-row.dim:not(.group)', e => e.length);
      check('clicking the 20th chip filters its lane (its 8 rows dim, nothing else)', dimmed === 8 && await page.evaluate(() => document.querySelectorAll('#legend .phase-chip')[19].classList.contains('off')), `${dimmed} dimmed`);
      await last.click(); await settle(page);
      await assertSticky(page, '1440 wrapped legend');
      // Taller legend at 150% text, then a narrower window, then back.
      for (let i = 0; i < 4; i++) await page.click('#fsUp');
      await settle(page, 400);
      lg = await legendGeometry(page);
      check('at 150% the legend grows and still shows every chip', lg.lines >= 3 && lg.chips.every(c => c.clickable && c.bottom <= lg.L.bottom + 0.5), `${lg.lines} lines`);
      await assertSticky(page, '150% text');
      await page.setViewport({ width: 1100, height: 900 }); await settle(page, 500);
      lg = await legendGeometry(page);
      check('resized to 1100: chart still meets the legend', Math.abs(lg.chartTop - lg.L.bottom) <= 1.5);
      await assertSticky(page, 'resized to 1100');
      await page.setViewport({ width: 1440, height: 900 }); await settle(page, 500);
      await assertSticky(page, 'resized back to 1440');
      await page.click('#fsPct'); await settle(page, 300);
      // Table: its sticky header must stay at the top of its own scroller under a wrapped legend.
      await view(page, 'table');
      const th = await page.evaluate(async () => {
        const head = document.querySelector('#tableView thead th');
        let sc = head.parentElement; while (sc && sc !== document.body && !(sc.scrollHeight > sc.clientHeight + 4 && /(auto|scroll)/.test(getComputedStyle(sc).overflowY))) sc = sc.parentElement;
        sc.scrollTop = 600; await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
        const o = { scrolled: sc.scrollTop, thTop: head.getBoundingClientRect().top, scTop: sc.getBoundingClientRect().top, legendBottom: document.getElementById('legend').getBoundingClientRect().bottom };
        sc.scrollTop = 0; return o;
      });
      check('table header sticks to the top of the table under the legend', th.scrolled > 100 && Math.abs(th.thTop - th.scTop) <= 2 && th.thTop >= th.legendBottom - 0.5, JSON.stringify(th));
      await shot(page, 'desktop-1440-table');
      await page.close();
    }

    if (wantAny('print')) {
      // ── Print: the plan is named on paper even though the header no longer paints it ──
      section = 'print';
      page = await openPage(browser, S.url);
      await page.emulateMediaType('print'); await settle(page, 200);
      const pr = await page.evaluate(() => ({
        title: (() => { const e = document.getElementById('printTitle'); return window.__uiVis(e) ? e.innerText : ''; })(),
        ca: window.__uiVis(document.getElementById('btnCollapseAll')),
      }));
      check('a printed chart carries the title and subtitle', pr.title.includes(plan.project.title) && pr.title.includes(plan.project.subtitle), pr.title);
      check('Collapse all does not print', !pr.ca);
      await page.emulateMediaType('screen'); await settle(page, 150);
      check('the print title is not on screen', !(await page.$eval('#printTitle', e => window.__uiVis(e))));
      await page.close();
    }

    if (wantAny('edge plans')) {
      // ── All cancelled, and a plan written in months ──
      section = 'edge plans';
      const AC = await startServer({ project: { title: 'Called Off', appName: 'called-off' }, phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],
        items: [{ type: 'task', id: 'a', name: 'Was the plan', start: '2026-10-05', end: '2026-10-09', phase: 0, status: 'cancelled', uid: '00001' }], }, 'allcancelled');
      page = await openPage(browser, AC.url);
      r = await readouts(page);
      check('every row cancelled: both readouts say none, and why', r.dur === 'none' && r.left === 'none' && /Every dated row in the plan is cancelled/.test(r.durTip), JSON.stringify(r));
      await page.close();
      const MO = await startServer({ project: { title: 'Month Plan', appName: 'month-plan' }, phases: [{ name: 'Only', start: '2026-10', end: '2026-11' }],
        items: [{ type: 'task', id: 'a', name: 'Written in months', start: '2026-10', end: '2026-11', phase: 0, uid: '00001' }], }, 'months');
      page = await openPage(browser, MO.url);
      r = await readouts(page);
      check(`year-month data: duration runs 01-Oct to 30-Nov (61 days = ${spanText('2026-10-01', '2026-11-30')})`, r.dur === spanText('2026-10-01', '2026-11-30') && /01-Oct-2026/.test(r.durTip) && /30-Nov-2026/.test(r.durTip), `${r.dur} ${r.durTip}`);
      await page.close();
    }

    if (wantAny('empty plan')) {
      // ── Empty plan, and a plan with no appName ──
      section = 'empty plan';
      const E = await startServer({ project: { title: 'Nothing Scheduled Yet', subtitle: 'An empty plan' }, phases: [{ name: 'Only lane', start: '2026-09', end: '2026-10' }], items: [] }, 'empty');
      page = await openPage(browser, E.url);
      r = await readouts(page);
      check('empty plan: both readouts say none', r.dur === 'none' && r.left === 'none', JSON.stringify(r));
      check('no appName: the brand shows the project title', await page.$eval('#hdr .app-name', e => e.textContent) === 'Nothing Scheduled Yet');
      check('empty plan: Collapse all is disabled (nothing to hide)', await page.$eval('#btnCollapseAll', b => b.disabled));
      await page.close();
    }

    // The error gate tests itself: a failed request to the app's own server must be
    // caught even though Chrome's console line for it carries no URL, and a failed
    // third-party request (the web fonts) must not be.
    if (wantAny('error gate')) {
      section = 'error gate';
      page = await openPage(browser, S.url);
      const n0 = pageErrors.length;
      await page.evaluate(() => Promise.all([fetch('no-such-path-ui-test').catch(() => {}),
        new Promise(r => { const i = new Image(); i.onload = i.onerror = r; i.src = 'https://ui-test.invalid/missing.png'; })]));
      await settle(page, 300);
      const caught = pageErrors.slice(n0);
      check('a 404 from the app\'s own server is caught by URL', caught.some(e => /HTTP 404 GET \/no-such-path-ui-test/.test(e)), caught.join(' | ') || 'nothing caught');
      check('a failed third-party resource is not', caught.length === 1, caught.join(' | '));
      pageErrors.splice(n0); // these two were planted; the final gate must judge the rest
      await page.close();
    }
    if (wantAny('editor', 'new item fields', 'table filters')) await editorFlows(browser, S);
    if (wantAny('editor layout')) await ownServers(editorLayoutFlow, browser);
    if (wantAny('export menu', 'import menu')) await menuFlows(browser, S);
    if (wantAny('clickup import')) await ownServers(clickupFlow, browser);
    if (wantAny('sections')) await ownServers(sectionsFlow, browser);
    if (wantAny('tags')) await ownServers(tagsFlow, browser);
    if (wantAny('row tags')) await ownServers(rowTagsFlow, browser);
    if (wantAny('effort')) await ownServers(effortFlow, browser);
    if (wantAny('collab')) await ownServers(collabFlow, browser);
    if (wantAny('notify')) await ownServers(notifyFlow, browser);
    if (wantAny('share')) await ownServers(shareFlow, browser);
    if (wantAny('calendar')) await ownServers(calendarFlow, browser);
    if (wantAny('provenance')) await ownServers(provFlow, browser);
    if (wantAny('pick by mouse')) await ownServers(pickFlow, browser);
    if (wantAny('type ahead')) await ownServers(typeAheadFlow, browser);
    if (wantAny('row links')) await ownServers(rowLinksFlow, browser);
    if (wantAny('scroll sync')) await ownServers(scrollSyncFlow, browser);
    if (wantAny('subtasks')) await ownServers(subtasksFlow, browser);
    if (wantAny('quick add')) await ownServers(quickAddFlow, browser);
    if (wantAny('milestone deps')) await ownServers(milestoneDepsFlow, browser);
    if (wantAny('row select')) await ownServers(rowSelectFlow, browser);
    if (wantAny('requests')) await ownServers(requestsFlow, browser);

    if (wantAny('viewport')) {
      // ── Viewport matrix ──
      for (const vp of [{ w: 393, h: 852, mobile: true }, { w: 1440, h: 900 }, { w: 1920, h: 1080 }]) {
        section = `viewport ${vp.w}`;
        page = await openPage(browser, S.url, vp);
        await assertNoSidewaysScroll(page, 'chart');
        lg = await legendGeometry(page);
        check('all 20 phase chips visible and clickable', lg.chips.length === 20 && lg.chips.every(c => c.clickable && c.right <= vp.w));
        r = await readouts(page);
        check('duration readouts present', r.dur === spanText('2026-09-21', '2027-01-14') && r.left === spanText('2026-09-29', '2027-01-14'), JSON.stringify(r));
        const readoutVisible = await page.evaluate(() => { const e = document.getElementById('spanLeftV'); const x = e.getBoundingClientRect(); const s = document.querySelector('#hdr .subject').getBoundingClientRect();
          return window.__uiVis(e) && x.width > 0 && x.right <= innerWidth && x.right <= s.right + 0.5; });
        check('Time Remaining is on screen and not clipped', readoutVisible);
        // The version is on every lens at every width (0.64.2): under the app name, never shed.
        const verOn = [];
        for (const lens of ['gantt', 'table', 'analytics', 'setup']) {
          await view(page, lens);
          verOn.push(lens + ':' + await page.evaluate(() => { const e = document.querySelector('#hdr .brand .app-ver'); if (!e) return 'none';
            const x = e.getBoundingClientRect(); return window.__uiVis(e) && x.width > 0 && x.left >= 0 && x.right <= innerWidth ? e.textContent : 'hidden'; }));
        }
        await view(page, 'gantt');
        check('the version shows on all four pages', verOn.every(v => /:v\d+\.\d+\.\d+$/.test(v)), verOn.join(' '));
        await page.click('#btnCollapseAll'); await settle(page);
        check('Collapse all works here', await page.evaluate(() => document.querySelectorAll('.lane-toggle.collapsed').length) === 20);
        await page.click('#btnCollapseAll'); await settle(page);
        await shot(page, `viewport-${vp.w}`);
        await view(page, 'table');
        await assertNoSidewaysScroll(page, 'table');
        await shot(page, `viewport-${vp.w}-table`);
        // The other two lenses (0.58.0 review): Analytics, and Setup with its 20 sections,
        // plain and with each kind of row editor open, since an open editor is the widest
        // thing the page can show. Nothing inside the page may run past the window either.
        await view(page, 'analytics');
        await assertNoSidewaysScroll(page, 'analytics');
        await view(page, 'setup');
        const setupOver = () => page.evaluate(() => [...document.querySelectorAll('#setupView *')].filter(e => window.__uiVis(e))
          .map(e => ({ e, r: e.getBoundingClientRect() })).filter(x => x.r.right > innerWidth + 0.5 || x.r.left < -0.5)
          .map(x => (x.e.className || x.e.tagName) + ' @' + Math.round(x.r.right)).slice(0, 4));
        check('Setup lists all 20 sections', await page.$$eval('#setupView .sec-row', e => e.length) === 20);
        for (const [what, open] of [['setup', null], ['setup, rename open', 'edit'], ['setup, remove open', 'del']]) {
          if (open) { await page.click(open === 'edit' ? '.sec-row[data-i="1"] .sec-nm-t' : `.sec-row[data-i="1"] .sec-btn[data-a="${open}"]`); await settle(page, 150); }
          await assertNoSidewaysScroll(page, what);
          const o = await setupOver();
          check(`${what}: nothing on the page runs past the window`, o.length === 0, o.join(' | '));
          await shot(page, `viewport-${vp.w}-${what.replace(/[ ,]+/g, '-')}`);
        }
        await page.close();
      }
    }

    if (wantAny('header sweep')) {
      // ── Header width sweep ──
      // The two readouts Drew asked for must be whole at EVERY width, with the plan's
      // appName and with the title fallback (no appName, a longer brand), at 100% and
      // 150% text, and no header control may sit outside the window. The widths are a
      // 40px sweep plus the ones real screens and the adversary review used; set
      // SWEEP_STEP=20 for a denser pass.
      section = 'header sweep';
      const NA = await startServer(seedPlan({ appName: null, title: 'Product Launch: Jan 11, 2027' }), 'no-appname');
      await headerSweep(browser, [['appName', S.url], ['title fallback', NA.url]]);
    }

    if (wantAny('coverage')) {
      // ── Coverage gate ──
      section = 'coverage';
      // A plan WITH tags (0.59.0), so the legend's tag chips, the section fold, the Setup
      // tag editors, the Tags card and the prefix button are all on screen to be walked.
      // And a tag registry (0.61.0): two coloured tags and one that nothing carries, so the
      // coloured chips, the Tags card's swatches and an unused tag's row are walked too.
      const covPlan = seedPlan({ tags: true });
      covPlan.project.tags = [{ name: 'Launch', color: '#00b8b1' }, { name: 'long-lead', color: '#e8b64a' }, { name: 'someday' }];
      const ST = await startServer(covPlan, 'plan20-tags');
      await coverageGate(browser, ST.url);
    }

    if (wantAny('leak')) {
      // ── Leak watch (cheap): listeners and nodes across repeated toggles ──
      section = 'leak';
      page = await openPage(browser, S.url);
      // Collect first, both times: Nodes and listeners count detached nodes until a GC
      // runs, and when that happens is up to V8, so an uncollected run reads as a leak.
      const gcCdp = await page.createCDPSession();
      const metricsAfterGc = async () => { await gcCdp.send('HeapProfiler.collectGarbage'); await sleep(100); await gcCdp.send('HeapProfiler.collectGarbage'); return page.metrics(); };
      // one warm-up round, so lazily built things (the table body, caches) are not counted as growth
      await page.click('#btnCollapseAll'); await page.click('#btnCollapseAll'); await page.click('#fsUp'); await page.click('#fsDown'); await settle(page, 400);
      const m0 = await metricsAfterGc();
      for (let i = 0; i < 20; i++) { await page.click('#btnCollapseAll'); await page.click('#fsUp'); await page.click('#fsDown'); }
      for (const g of ['day', 'month', 'week', 'quarter', 'week']) await setGrain(page, g);
      await setGrain(page, 'week'); await settle(page, 600);
      const m1 = await metricsAfterGc();
      check('no listener growth over 20 collapse-all + 40 text-size + 5 grain changes', m1.JSEventListeners - m0.JSEventListeners <= 5, `${m0.JSEventListeners} -> ${m1.JSEventListeners}`);
      check('DOM nodes bounded (back at the same grain, same count within 500)', Math.abs(m1.Nodes - m0.Nodes) < 500, `${m0.Nodes} -> ${m1.Nodes}`);
      await page.close();
    }

    // ── Optional: the real plan ──
    if (REAL_PLAN && want('real plan')) {
      section = 'real plan';
      const R = await startServer(REAL_PLAN, 'real');
      SRV = R.url;
      page = await openPage(browser, R.url);
      const real = JSON.parse(readFileSync(REAL_PLAN, 'utf8'));
      lg = await legendGeometry(page);
      check(`all ${real.phases.length} chips visible and clickable`, lg.chips.length === real.phases.length && lg.chips.every(c => c.clickable));
      await expectReadouts(page, TODAY, 'real plan');
      const hd = await page.evaluate(() => document.getElementById('hdr').innerText);
      check('title not repeated in the header', !hd.includes(real.project.title));
      await assertSticky(page, 'real plan');
      await assertNoSidewaysScroll(page, 'real plan');
      await shot(page, 'real-plan-1440');
      await page.setViewport({ width: 393, height: 852, isMobile: true, hasTouch: true }); await settle(page, 500);
      await assertNoSidewaysScroll(page, 'real plan 393');
      await shot(page, 'real-plan-393');
      await page.close();
      // The same width sweep on the real plan, with its appName and without it.
      const noApp = JSON.parse(readFileSync(REAL_PLAN, 'utf8')); delete noApp.project.appName;
      const RN = await startServer(noApp, 'real-noapp');
      await headerSweep(browser, [['real plan, appName', R.url], ['real plan, title fallback', RN.url]]);
    }

    section = '';
    if (!ONLY) UNTESTED = manifestHonesty();
    check('NO uncaught page errors, console errors or failed app requests on any page', pageErrors.length === 0, pageErrors.slice(0, 5).join(' | '));
  } finally {
    await browser.close().catch(() => {});
  }
}

// The header controls each lens must show: PRESENT, visible (strictly: no opacity:0,
// no visibility:hidden, a non-zero box) and wholly inside the window, at every swept
// width. A control a lens hides on purpose is listed in HDR_HIDDEN and must be hidden.
// 0.58.0: the Sections button left the header for the Setup lens, which is the fourth tab.
const HDR_COMMON = ['.brand', '.vt-btn[data-view="gantt"]', '.vt-btn[data-view="table"]', '.vt-btn[data-view="analytics"]', '.vt-btn[data-view="setup"]',
  '#spanDur', '#spanLeft', '#btnTheme', '#btnImport', '#btnExport', '#btnHelp', '#btnNew', '#fsDown', '#fsPct', '#fsUp'];
const HDR_CHART_ONLY = ['#grainSel', '#btnFit', '#btnDeps', '#btnMsStyle'];
const HDR_EXPECT = {
  gantt: [...HDR_COMMON, ...HDR_CHART_ONLY, '#btnAuto', '#btnCrit'],
  table: [...HDR_COMMON, '#btnAuto', '#btnCrit'],
  analytics: [...HDR_COMMON],
  setup: [...HDR_COMMON],
};
const HDR_HIDDEN = { gantt: [], table: HDR_CHART_ONLY, analytics: [...HDR_CHART_ONLY, '#btnAuto', '#btnCrit'], setup: [...HDR_CHART_ONLY, '#btnAuto', '#btnCrit'] };
// Everything that could hide the readout: an ancestor that clips, the window edge, or
// the text itself being cut (a span whose content is wider than its box).
async function headerProbe(page, lens = 'gantt') {
  return page.evaluate((expect, hidden) => {
    const clipRect = el => {
      let r = el.getBoundingClientRect(); let L = r.left, R = r.right;
      for (let a = el.parentElement; a && a !== document.documentElement; a = a.parentElement) {
        const cs = getComputedStyle(a);
        if (cs.overflowX !== 'visible' || cs.overflow !== 'visible') { const ar = a.getBoundingClientRect(); L = Math.max(L, ar.left); R = Math.min(R, ar.right); }
      }
      return { L, R, full: r };
    };
    const bad = [];
    for (const id of ['spanDur', 'spanLeft']) {
      const e = document.getElementById(id);
      if (!window.__uiVis(e)) { bad.push(id + ' hidden'); continue; }
      const c = clipRect(e);
      if (c.full.width < 20 || c.L > c.full.left + 0.5 || c.R < c.full.right - 0.5 || c.full.right > innerWidth + 0.5) bad.push(`${id} clipped ${Math.round(c.full.left)}..${Math.round(c.full.right)} visible ${Math.round(c.L)}..${Math.round(c.R)}`);
      for (const k of e.children) if (k.scrollWidth > k.clientWidth + 1) bad.push(id + ' text cut');
    }
    // Every expected control: present, strictly visible, non-zero, inside the window,
    // and not clipped by an ancestor.
    const off = [];
    for (const sel of expect) {
      const e = document.querySelector('#hdr ' + sel);
      if (!e) { off.push(sel + ' missing'); continue; }
      if (!window.__uiVis(e)) { off.push(sel + ' hidden or zero-size'); continue; }
      const c = clipRect(e);
      if (c.full.right > innerWidth + 0.5 || c.full.left < -0.5) off.push(`${sel} outside the window ${Math.round(c.full.left)}..${Math.round(c.full.right)}`);
      else if (c.L > c.full.left + 0.5 || c.R < c.full.right - 0.5) off.push(`${sel} clipped`);
    }
    for (const sel of hidden) { const e = document.querySelector('#hdr ' + sel); if (e && window.__uiVis(e)) off.push(sel + ' shown in a lens that hides it'); }
    // And no OTHER header control may be off-screen either (one this list does not know).
    [...document.querySelectorAll('#hdr button, #hdr select')].filter(e => window.__uiVis(e))
      .map(e => ({ e, r: e.getBoundingClientRect() })).filter(x => x.r.right > innerWidth + 0.5 || x.r.left < -0.5)
      .forEach(x => off.push((x.e.id || x.e.className) + ' @' + Math.round(x.r.right)));
    return { bad, off, text: document.getElementById('spanDur')?.innerText.replace(/\s+/g, ' ') + ' / ' + document.getElementById('spanLeft')?.innerText.replace(/\s+/g, ' ') };
  }, HDR_EXPECT[lens], HDR_HIDDEN[lens]);
}
async function headerSweep(browser, targets) {
  const step = +(process.env.SWEEP_STEP || 40);
  const widths = new Set([393, 1181, 1210, 1250, 1280, 1330, 1366, 1392, 1440, 1500, 1536, 1620, 1680, 1745, 1830]);
  for (let w = 360; w <= 2000; w += step) widths.add(w);
  const W = [...widths].sort((a, b) => a - b);
  // Every target in the chart lens at 100% and 150%; the other three lenses (fewer
  // controls, so less at stake) on the first target at 100% and 150%.
  const runs = [];
  for (const [label, url] of targets) for (const fs of [1, 1.5]) runs.push([label, url, fs, 'gantt']);
  for (const lens of ['table', 'analytics', 'setup']) for (const fs of [1, 1.5]) runs.push([targets[0][0], targets[0][1], fs, lens]);
  let selfTested = false;
  for (const [label, url, fs, lens] of runs) {
    const page = await openPage(browser, url, { w: 1440, h: 900 });
    if (!selfTested) {
      // The probe tests itself once: a control hidden four different ways must be flagged.
      selfTested = true;
      const planted = [['display', 'none', 'hidden or zero-size'], ['visibility', 'hidden', 'hidden or zero-size'], ['opacity', '0', 'hidden or zero-size'], ['width', '0px', 'hidden or zero-size']];
      const missed = [];
      for (const [prop, val, want] of planted) {
        await page.$eval('#btnHelp', (e, p, v) => { e.style.setProperty(p, v, 'important'); if (p === 'width') { e.style.setProperty('padding', '0', 'important'); e.style.setProperty('border', '0', 'important'); e.style.setProperty('min-width', '0', 'important'); e.style.setProperty('overflow', 'hidden', 'important'); } }, prop, val);
        const pr = await headerProbe(page, 'gantt');
        if (!pr.off.some(o => o === '#btnHelp ' + want)) missed.push(`${prop}:${val} -> ${pr.off.join(',') || 'nothing flagged'}`);
        await page.$eval('#btnHelp', e => e.removeAttribute('style'));
      }
      await page.$eval('#btnHelp', e => e.setAttribute('style', "font-family:'Familjen Grotesk',sans-serif;font-size:14px;font-weight:600;padding:4px 9px"));
      check('the header probe flags a control hidden by display, visibility, opacity or zero width', missed.length === 0, missed.join(' | '));
    }
    if (fs !== 1) { for (let i = 0; i < 4; i++) await page.click('#fsUp'); await settle(page, 200); }
    if (lens !== 'gantt') await view(page, lens);
    const fails = [];
    for (const w of W) {
      await page.setViewport({ width: w, height: 900 });
      await settle(page, 120);
      const p = await headerProbe(page, lens);
      if (p.bad.length || p.off.length) fails.push(`${w}px: ${[...p.bad, ...p.off].join(', ')}`);
    }
    check(`${label}, ${lens} lens, at ${fs * 100}%: readouts whole and all ${HDR_EXPECT[lens].length} expected header controls present, visible and on screen at ${W.length} widths 360..2000`, fails.length === 0, `${fails.length} widths fail; ${fails.slice(0, 5).join(' | ')}`);
    await page.close();
  }
}

// ── Control-coverage manifest ──
// Every control the app renders in the states below.
//   safe:     the gate clicks it (in some state, at least once, or the gate fails naming
//             it) and asserts nothing throws.
//   terminal: it changes the plan, leaves the page or opens a file chooser, so it is
//             presence-checked here and its effect is proven by the flow named in `by`,
//             which must be a flow (section) that actually ran in this suite.
//   untested: '<reason>': declared so it is not an orphan, but no flow proves its effect.
//             Counted and printed in the summary, so the gap is visible.
// A FAMILY entry (a class shared by several controls) is pinned: `key` reads a control's
// identity (a data attribute, its text) and `pin` lists the members expected, so a new
// member of the family is an orphan until it is declared. `within` scopes an entry to
// an ancestor id.
const MANIFEST = [
  { sel: '.vt-btn', label: 'lens switch', safe: true, key: c => c.ds.view, pin: ['gantt', 'table', 'analytics', 'setup'] },
  { sel: '#grainSel', label: 'grain picker', terminal: true, by: 'dates' },
  { sel: '#btnFit', label: 'Fit', safe: true },
  { sel: '#btnDeps', label: 'dependency mode', safe: true, twice: true },
  { sel: '#btnAuto', label: 'Auto-schedule', terminal: true, by: 'live: auto-schedule' },
  { sel: '#btnCrit', label: 'critical path', safe: true, twice: true },
  { sel: '#btnMsStyle', label: 'milestone style', safe: true },
  { sel: '#btnTheme', label: 'theme', safe: true },
  { sel: '#btnImport', label: 'Import menu', safe: true },
  { sel: '#btnExport', label: 'Export menu', safe: true },
  { sel: '#btnHelp', label: 'help menu', safe: true },
  { sel: '#btnNew', label: 'New', safe: true },
  { sel: '#fsDown', label: 'A-', safe: true },
  { sel: '#fsPct', label: 'text size reset', safe: true },
  { sel: '#fsUp', label: 'A+', safe: true },
  { sel: '#btnCollapseAll', label: 'Collapse / expand all', safe: true, twice: true },
  { sel: '.lane-toggle', label: 'lane chevron', safe: true, twice: true },
  { sel: '.phase-chip', label: 'phase legend chip', safe: true, twice: true },
  { sel: '.legend-item', label: 'milestone / deps / today legend toggles, and the inert holiday key', safe: true, twice: true,
    key: c => c.cls.includes('static') ? 'holiday-key' : c.text.split(/\s+/)[0].toLowerCase(), pin: ['holiday-key', 'milestone', 'dependencies', 'today'] },
  { sel: '.dd-item', within: 'exportMenu', label: 'export formats', terminal: true, by: 'export menu', key: c => c.ds.fmt, pin: ['json', 'csv', 'svg', 'html'] },
  { sel: '.dd-item', within: 'exportMenu', label: 'help rows (inert text)', safe: true, key: c => (!c.ds.fmt && c.cursor === 'default') ? 'help-text' : undefined, pin: ['help-text'] },
  { sel: '.dd-item', within: 'importMenu', label: 'import JSON / CSV', terminal: true, by: 'import menu', key: c => c.ds.fmt, pin: ['json', 'csv'] },
  { sel: '.dd-item', within: 'importMenu', label: 'import MS Project XML / Jira CSV', untested: 'the import flow checks the file chooser opens for these with the right accept filter, but no MS Project or Jira file is parsed', key: c => c.ds.fmt, pin: ['msproject-xml', 'jira-csv'] },
  { sel: '.dd-item', within: 'importMenu', label: 'import ClickUp CSV / ClickUp API JSON', terminal: true, by: 'clickup import', key: c => c.ds.fmt, pin: ['clickup-csv', 'clickup-json'] },
  // Renders only after a ClickUp import, in the tab that made it; the clickup flow imports.
  { sel: '#irClose', label: 'ClickUp import report: close', terminal: true, by: 'clickup import', required: false },
  { sel: '.sec-sw', label: 'section colour', terminal: true, by: 'sections', key: c => c.title, pin: ['Colour'] },
  // 0.61.0: the up/down arrows and Edit are gone (a row is dragged, or moved with Alt+Up/Down,
  // and its name and aside are edited by a click on them). Remove is the one row button left.
  { sel: '.sec-btn', label: 'section row actions', terminal: true, by: 'sections', key: c => c.ds.a, pin: ['del'] },
  { sel: '.sec-nm-t', label: 'section name: click to edit in place', terminal: true, by: 'sections', key: c => c.ds.f, pin: ['name'] },
  { sel: '.sec-ld-t', label: 'section aside: click to edit in place', terminal: true, by: 'sections', key: c => c.ds.f, pin: ['lead'] },
  { sel: '#secNew', label: 'new section name', terminal: true, by: 'sections' },
  { sel: '#secAdd', label: 'add section', terminal: true, by: 'sections' },
  // The Setup page's inline row editors (0.58.0: they were never opened by the gate while
  // they lived in the modal, so they were not enumerated; on the page they are states).
  { sel: '.sec-nm', label: 'section name, in place', terminal: true, by: 'sections' },
  { sel: '.sec-ld', label: 'section aside, in place', terminal: true, by: 'sections' },
  { sel: '.sec-chip', label: 'section colour palette', terminal: true, by: 'sections', key: c => c.title, pin: ['Theme', 'Teal', 'Purple', 'Blue', 'Green', 'Orange', 'Red', 'Slate'] },
  { sel: '.sec-hex', label: 'section colour: hex', terminal: true, by: 'sections' },
  { sel: '.sec-dest', label: 'section remove: destination', terminal: true, by: 'sections' },
  { sel: '.sec-go', label: 'section remove: confirm', terminal: true, by: 'sections' },
  { sel: '.sec-no', label: 'section remove: Cancel', safe: true },
  // Tags (0.59.0). The legend's tag chips are a filter, a view setting like the section
  // chips, so they are safe and clicked twice; the Setup page's tag edits change the plan
  // and are proven by the `tags` flow.
  { sel: '.tag-chip', label: 'legend tag chip (filter)', safe: true, twice: true, key: c => c.ds.tag, pin: ['launch', 'long-lead', 'tech', 'process'] },
  // Row tags (0.60.0): the tag box in the item editor and the New panel, its chip x, and the
  // batch panel's add and remove. They change the plan; the `row tags` flow proves each.
  { sel: '#imTagsIn', label: 'item editor: tags', terminal: true, by: 'row tags' },
  { sel: '#ncEffort', label: 'new: effort', terminal: true, by: 'effort' },
  { sel: '#ncTagsIn', label: 'new: tags', terminal: true, by: 'row tags' },
  { sel: '.tb-x', label: 'tag box: remove a chip', terminal: true, by: 'row tags', key: c => c.ds.tb, pin: ['im'] },
  { sel: '#bTag', label: 'batch: tag to add', terminal: true, by: 'row tags' },
  { sel: '#bTagAdd', label: 'batch: add the tag to all', terminal: true, by: 'row tags' },
  { sel: '#bEffort', label: 'batch: effort to set', terminal: true, by: 'effort' },
  { sel: '#chatCadence', label: 'setup: how often changes go to Google Chat', terminal: true, by: 'notify' },
  { sel: '#chatTest', label: 'setup: send a test message to Google Chat', terminal: true, by: 'notify' },
  { sel: '#chatHook', label: 'setup: the owner\'s webhook field', terminal: true, by: 'notify' },
  { sel: '#chatHookSave', label: 'setup: set the webhook', terminal: true, by: 'notify' },
  // Renders only once a webhook is set, which the coverage walk does not do (a real-looking
  // Chat URL would have the notifier post to it). The notify flow sets one and removes it.
  { sel: '#chatHookClear', label: 'setup: remove the webhook', terminal: true, by: 'notify', required: false },
  { sel: '#pdStart', label: 'setup: project start', terminal: true, by: 'project dates' },
  { sel: '#pdEnd', label: 'setup: project end', terminal: true, by: 'project dates' },
  { sel: '.pd-out a', label: 'setup: a row outside the project dates (opens it)', terminal: true, by: 'project dates', required: false },
  { sel: '.cal-hol', label: 'setup: a federal holiday the plan takes off', terminal: true, by: 'calendar' },
  { sel: '#calNew', label: 'setup: an extra day off', terminal: true, by: 'calendar' },
  { sel: '#calAdd', label: 'setup: add the extra day off', terminal: true, by: 'calendar' },
  { sel: '.cal-rm', label: 'setup: remove an extra day off', terminal: true, by: 'calendar', required: false },
  { sel: '#shareMode', label: 'setup: who can open the plan', terminal: true, by: 'share' },
  // Renders only when the plan is shared with an org; the share flow switches to that mode.
  { sel: '#shareOrg', label: 'setup: which organization', terminal: true, by: 'share', required: false },
  { sel: '#bEffortSet', label: 'batch: set the effort on all', terminal: true, by: 'effort' },
  { sel: '.bt-x', label: 'batch: remove a tag from all', terminal: true, by: 'row tags', key: c => c.ds.tag, pin: ['long-lead'] },
  { sel: '#legendSecs', label: 'legend: fold or unfold the section chips', safe: true, twice: true },
  { sel: '#tagClear', label: 'legend: clear the tag filter', safe: true },
  // Owner and Overdue (0.64.0): view filters in the legend. The owner select is the chart's
  // copy of the table's and is proven by its flow; the Overdue chip toggles, so it is clicked twice.
  { sel: '#ganttOwner', label: 'legend owner filter', terminal: true, by: 'owner and overdue' },
  { sel: '.owner-pick', label: 'legend owner filter (its wrapper)', terminal: true, by: 'owner and overdue' },
  { sel: '#overdueChip', label: 'legend: overdue only', safe: true, twice: true },
  { sel: '#currentChip', label: 'legend: current only (started, not done)', safe: true, twice: true },
  // 0.73.0: links to rows, the team's view, the tag fold.
  { sel: '.row-link', label: 'chart row: copy a link to the row', terminal: true, by: 'row links' },
  { sel: '.uid-link', label: 'table: the row ID, copies a link', terminal: true, by: 'row links' },
  { sel: '#imCopyLink', label: 'item editor: copy link', terminal: true, by: 'row links' },
  { sel: '#sharePreview', label: 'setup: see what the team sees', terminal: true, by: 'row links' },
  { sel: '#previewBack', label: 'team preview: back to my view', terminal: true, by: 'row links', required: false },
  { sel: '#legendTags', label: 'legend: fold or unfold the tag chips', safe: true, twice: true },
  { sel: '.sec-tag-in', label: 'section tag: type to add', terminal: true, by: 'tags' },
  { sel: '.sec-tag-x', label: 'section tag: remove one', terminal: true, by: 'tags', key: c => c.ds.tag, pin: ['Launch', 'Tech', 'Process'] },
  { sel: '#tagPrefixBtn', label: 'tags from name prefixes', terminal: true, by: 'tags' },
  // 0.61.0: Rename is a click on the tag's name; the card also makes tags and colours them.
  { sel: '.tag-btn', label: 'Tags card: remove everywhere / no colour', terminal: true, by: 'tags', key: c => c.ds.a, pin: ['del', 'nocolor'] },
  { sel: '.tag-nm-t', label: 'Tags card: click a name to rename it everywhere', terminal: true, by: 'tags' },
  { sel: '.tag-nm', label: 'Tags card: new name, in place', terminal: true, by: 'tags' },
  { sel: '#tagNew', label: 'Tags card: new tag', terminal: true, by: 'tags' },
  { sel: '#tagAdd', label: 'Tags card: add the new tag', terminal: true, by: 'tags' },
  { sel: '.tag-sw', label: 'Tags card: a tag\'s colour swatch (opens the palette)', terminal: true, by: 'tags' },
  { sel: '.tag-pc', label: 'tag colour palette', terminal: true, by: 'tags', key: c => c.ds.hex, pin: ['#00b8b1', '#8c6bf7', '#64abff', '#ff8a7a', '#e8b64a', '#5fd7a3', '#ec7ab8', '#6f7cff', '#8b9bb4'] },
  { sel: '.tag-hex', label: 'tag colour: hex', terminal: true, by: 'tags' },
  { sel: '#ncType', label: 'new: type', terminal: true, by: 'new item fields' },
  { sel: '#ncPhase', label: 'new: section', terminal: true, by: 'new item fields' },
  { sel: '#ncName', label: 'new: name', terminal: true, by: 'live: create' },
  { sel: '#ncOwner', label: 'new: owner', terminal: true, by: 'new item fields' },
  { sel: '#ncStatus', label: 'new: status', terminal: true, by: 'new item fields' },
  { sel: '#ncStart', label: 'new: start', terminal: true, by: 'live: create' },
  { sel: '#ncEnd', label: 'new: deadline', terminal: true, by: 'live: create' },
  { sel: '#ncDesc', label: 'new: description', terminal: true, by: 'new item fields' },
  { sel: '#ncProv', label: 'new: provenance', terminal: true, by: 'new item fields' },
  { sel: '#ncCreate', label: 'new: create', terminal: true, by: 'live: create' },
  { sel: '#ncCancel', label: 'new: cancel', safe: true },
  { sel: '#tblStatus', label: 'table status filter', terminal: true, by: 'table filters' },
  { sel: '#tblSearch', label: 'table search', terminal: true, by: 'table filters' },
  { sel: '#toPlanOrder', label: 'table: back to plan order', safe: true },
  // 0.65.0: a click on a bar or a milestone opens the item editor; the small bar and
  // milestone panels are gone, and their label and dependencies are fields of the editor.
  { sel: '#imNote', label: 'item editor: label on the bar', terminal: true, by: 'editor: bar' },
  { sel: '#imAddDep', label: 'item editor: waits on (add dependency)', terminal: true, by: 'editor: bar' },
  { sel: '.dep-rm', label: 'item editor: remove a dependency', terminal: true, by: 'editor: bar' },
  { sel: '#imEffort', label: 'item editor: effort', terminal: true, by: 'effort' },
  // The full item editor (hover a row label, click its pencil).
  { sel: '.row-edit', label: 'row label: Edit all fields (hover only)', safe: true },
  { sel: '#imType', label: 'item editor: type', untested: 'switching a task to a milestone in the full editor is not driven' },
  { sel: '#imPhase', label: 'item editor: section', terminal: true, by: 'editor: item' },
  { sel: '#imName', label: 'item editor: name', terminal: true, by: 'editor: item' },
  { sel: '#imOwner', label: 'item editor: owner', terminal: true, by: 'editor: item' },
  { sel: '#imStatus', label: 'item editor: status', terminal: true, by: 'editor: item' },
  { sel: '#imStart', label: 'item editor: start', terminal: true, by: 'editor: item' },
  { sel: '#imEnd', label: 'item editor: end', terminal: true, by: 'editor: item' },
  { sel: '#imDesc', label: 'item editor: description', terminal: true, by: 'editor: item' },
  { sel: '#imProv', label: 'item editor: provenance', terminal: true, by: 'editor: item' },
  { sel: '#imApply', label: 'item editor: Apply', terminal: true, by: 'editor: item' },
  { sel: '#imCancel', label: 'item editor: Cancel', safe: true },
  { sel: '#imDelete', label: 'item editor: Delete', terminal: true, by: 'editor: item' },
  // The batch panel (Ctrl+click two bars).
  { sel: '#bStart', label: 'batch: earliest start', untested: 'the batch flow drives shift-all only; moving the earliest start is not driven' },
  { sel: '#bEnd', label: 'batch: latest end', untested: 'the batch flow drives shift-all only; moving the latest end is not driven' },
  { sel: '#bShiftAll', label: 'batch: shift all', terminal: true, by: 'editor: batch' },
  { sel: '#bShiftS', label: 'batch: shift start', terminal: true, by: 'editor: batch' },
  { sel: '#bShiftE', label: 'batch: shift end', terminal: true, by: 'editor: batch' },
  { sel: '#bApply', label: 'batch: Apply', terminal: true, by: 'editor: batch' },
  { sel: '#bClose', label: 'batch: Cancel', safe: true },
  { sel: '#bDelete', label: 'batch: Delete', terminal: true, by: 'editor: batch' },
  { sel: '#beCopyIds', label: 'batch: Copy IDs', untested: 'writes the system clipboard, which headless Chrome does not expose to the suite' },
  { sel: '#selCount', label: 'selection count chip (Copy IDs)', untested: 'writes the system clipboard, which headless Chrome does not expose to the suite' },
  { sel: '#btnCopyIds', label: 'table: Copy IDs', required: false, untested: 'writes the system clipboard, which headless Chrome does not expose to the suite' },
  // The table's hover-only delete.
  { sel: '.row-del', label: 'table row delete (hover only)', terminal: true, by: 'live: delete' },
  // Subtasks and quick add (0.79.0). The chevron shows only on a plan with subtasks, which the
  // walk's plan has none of; the header's + and the blank row's field only on hover and while
  // adding. The + on a task row renders in the walk; there is no "+ Add task" line (0.79.0).
  { sel: '.sub-tog', label: 'a task\'s chevron: fold or unfold its subtasks', terminal: true, by: 'subtasks', required: false },
  { sel: '.row-add', label: 'a task\'s +: add a subtask in place (hover only)', terminal: true, by: 'quick add' },
  { sel: '.lane-add', label: 'a section header\'s +: add a task in place (hover only)', terminal: true, by: 'quick add', required: false },
  // The Table's quick add is the + in a row's Section cell, on a row's hover (0.79.0).
  { sel: '.sec-add', label: 'table: a row\'s Section cell +, add a task to that section (hover only)', terminal: true, by: 'quick add' },
  { sel: '.qa-in', label: 'quick add: the new row\'s name', terminal: true, by: 'quick add', required: false },
  // The row handle: selects the row and nothing else, a view state, so the walk clicks it.
  // Shown on hover, and on a selected row; the `row select` flow proves what it does.
  { sel: '.row-sel', within: 'labels', label: 'chart row: the handle, selects the row (hover only)', safe: true },
  { sel: '.row-sel', within: 'tableBody', label: 'table row: the handle, selects the row (hover only)', safe: true },
  // Requests (0.80.0). Rendered only for a signed-in person on a plan with requests on, and
  // the coverage gate runs --no-auth, so none is required here; the `requests` flow (its own
  // server with sign-in) drives every one of them.
  { sel: '#btnReq', label: 'Requests inbox button', required: false, terminal: true, by: 'requests' },
  { sel: '.req-pill', label: 'a row\'s pending request pill (opens the inbox)', required: false, terminal: true, by: 'requests' },
  { sel: '.rq-row', label: 'inbox: the row link', required: false, terminal: true, by: 'requests' },
  { sel: '.rq-in', label: 'inbox: the note field', required: false, terminal: true, by: 'requests' },
  { sel: '.rq-b', label: 'inbox and editor: Accept, Decline, Withdraw', required: false, terminal: true, by: 'requests' },
  { sel: '#imReqYes', label: 'item editor: Accept the request', required: false, terminal: true, by: 'requests' },
  { sel: '#imReqNo', label: 'item editor: Decline the request', required: false, terminal: true, by: 'requests' },
  { sel: '#imReqWd', label: 'item editor: Withdraw the request', required: false, terminal: true, by: 'requests' },
  { sel: '#rqAi', label: 'inbox: let my AI answer requests for me', required: false, terminal: true, by: 'requests' },
  { sel: '.pp-acc', label: 'Setup, People: the account behind an owner name', required: false, terminal: true, by: 'requests' },
];
function ctrlMatches(c, d) {
  let ok;
  if (d.sel.startsWith('#')) ok = c.id === d.sel.slice(1);
  else if (d.sel.startsWith('.')) ok = c.cls.split(/\s+/).includes(d.sel.slice(1));
  else ok = c.tag === d.sel;
  if (!ok) return false;
  if (d.within && !c.anc.includes(d.within)) return false;
  if (d.pin && !d.pin.includes(d.key(c))) return false;
  return true;
}
// A family member whose key is not pinned matches nothing, so it is reported as an
// orphan with its key, which is exactly what has to be added.
const covKey = (d, c) => d.pin ? `${d.sel}${d.within ? '@' + d.within : ''}=${d.key(c)}` : d.sel + (d.within ? '@' + d.within : '');
const entryId = d => d.sel + (d.within ? '@' + d.within : '') + (d.pin ? '[' + d.pin.join(',') + ']' : '');
// Most specific entry wins: an id beats a class, so #btnCollapseAll is not read as a
// plain .legend-item and a phase chip is not read as the generic toggle.
function manifestFor(c) {
  return MANIFEST.find(d => d.sel.startsWith('#') && ctrlMatches(c, d))
    || MANIFEST.find(d => d.sel === '.phase-chip' && ctrlMatches(c, d))
    || (c.id ? null : MANIFEST.find(d => ctrlMatches(c, d)));
}
async function liveControls(page) {
  return page.evaluate(() => {
    // .legend-item, .dd-item, .dep-rm and the selection chip are divs or spans with
    // listeners, not buttons, and are real controls all the same, so they are
    // enumerated too. Nothing is skipped by class: an inert element is declared as such.
    // Clear the numbering from the previous call first: a control hidden since then keeps
    // its old data-cov, and a lookup by number would find it (no box) instead of the
    // live one that now carries that number.
    document.querySelectorAll('[data-cov]').forEach(e => e.removeAttribute('data-cov'));
    return [...document.querySelectorAll('button,[onclick],select,textarea,input:not([type=hidden]),[role="button"],.legend-item,.dd-item,.dep-rm,#selCount')]
      .filter(e => window.__uiVis(e))
      .map((e, i) => { e.dataset.cov = String(i);
        const ds = { ...e.dataset }; delete ds.cov;
        const anc = []; for (let a = e.parentElement; a; a = a.parentElement) if (a.id) anc.push(a.id);
        return { i, tag: e.tagName.toLowerCase(), id: e.id || '', cls: typeof e.className === 'string' ? e.className : '', onclick: e.getAttribute('onclick') || '',
          text: (e.textContent || '').trim().slice(0, 30), ds, anc, title: e.title || '', cursor: getComputedStyle(e).cursor }; })
      // A signature plus its ordinal among equal signatures is how a control is found
      // again after the state is reopened; a bare index would drift onto a neighbour.
      .map(c => ({ ...c, sig: `${c.tag}|${c.id}|${c.cls.replace(/\b(off-default|on|off|collapsed|is-all|sel|shifted|muted)\b/g, '').trim()}|${c.text.replace(/\d+%/g, 'N%')}` }))
      .map((c, i, all) => ({ ...c, nth: all.slice(0, i).filter(x => x.sig === c.sig).length }));
  });
}
// Bring a bar into view and click its middle, as a person would.
// Short tasks are a few px wide at the week grain, all drag handle, so the bar is
// clicked at the day grain, where its middle is the bar's own body.
async function clickBar(page, id, mods = []) {
  if (await page.$eval('#grainSel', s => s.value) !== 'day') await setGrain(page, 'day');
  const p = await page.evaluate(i => { const b = document.querySelector(`.bar[data-id="${i}"]`); b.scrollIntoView({ block: 'center', inline: 'center' });
    const r = b.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, id);
  await settle(page, 100);
  const q = await page.evaluate(i => { const r = document.querySelector(`.bar[data-id="${i}"]`).getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, id);
  for (const m of mods) await page.keyboard.down(m);
  await page.mouse.click(q.x, q.y);
  for (const m of mods) await page.keyboard.up(m);
  await settle(page, 150);
}
async function clickMilestone(page, uid) {
  // The diamond's hit strip, or in line style (where the milestone's own row is hidden)
  // the cap on its dashed rule: whichever of the two has a box. The coverage walk cycles
  // the milestone style as it clicks, so either can be the one on screen.
  const target = `.ms-hit[data-msuid="${uid}"], .ms-cap[data-msuid="${uid}"]`;
  const pick = sel => page.evaluate(s => { const e = [...document.querySelectorAll(s)].find(x => { const r = x.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
    if (!e) return null; e.scrollIntoView({ block: 'center', inline: 'center' }); return true; }, sel);
  if (!await pick(target)) throw new Error(`clickMilestone(${uid}): no hit strip or cap with a box`);
  await settle(page, 100);
  const q = await page.evaluate(s => { const e = [...document.querySelectorAll(s)].find(x => { const r = x.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
    const r = e.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, target);
  await page.mouse.click(q.x, q.y); await settle(page, 150);
}
// Hover a row label (the pencil is opacity 0 until then) and click the pencil.
async function openItemEditor(page, name) {
  const found = await page.evaluate(n => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.textContent.includes(n));
    if (!e) return 'no label row'; e.dataset.covrow = '1'; e.scrollIntoView({ block: 'center' }); const r = e.getBoundingClientRect(); return r.height > 0 ? '' : `row has no box (${e.className})`; }, name);
  if (found) throw new Error(`openItemEditor(${name}): ${found}`);
  await settle(page, 100);
  await page.hover('.label-row[data-covrow="1"]'); await settle(page, 200);
  const shown = await page.$eval('.label-row[data-covrow="1"] .row-edit', b => window.__uiVis(b));
  await page.click('.label-row[data-covrow="1"] .row-edit'); await settle(page, 200);
  await page.evaluate(() => document.querySelector('.label-row[data-covrow="1"]')?.removeAttribute('data-covrow'));
  return shown;
}
async function setVal(page, sel, v) {
  await page.$eval(sel, (e, v) => { e.value = v; e.dispatchEvent(new Event('input', { bubbles: true })); e.dispatchEvent(new Event('change', { bubbles: true })); }, v);
}
const editorShown = page => page.$eval('#editor', e => e.classList.contains('show') && window.__uiVis(e));
// Escape steps back one rung at a time (field, row, selection); three clear everything.
async function clearSel(page) { await page.mouse.click(60, 22); for (let i = 0; i < 3; i++) await page.keyboard.press('Escape'); await settle(page, 100); }
// 0.61.0: a tag is renamed by clicking its name in the Tags card and typing over it.
async function renameTag(page, key, to) {
  await page.click(`#suTags .tag-row[data-tag="${key}"] .tag-nm-t`); await settle(page, 100);
  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');
  await page.keyboard.type(to); await page.keyboard.press('Enter'); await settle(page, 250);
}
async function undo(page) { await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page, 250); }

async function coverageGate(browser, url) {
  const page = await openPage(browser, url);
  const reset = async () => {
    await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
    // The tag filter and the unfolded section chips are view settings a state turned on;
    // they are turned off again so the next state opens on the plain legend.
    await page.evaluate(() => { const c = document.getElementById('tagClear'); if (c && c.isConnected) c.click();
      const t = document.getElementById('legendSecs'); if (t && t.isConnected && t.classList.contains('open')) t.click(); });
    // an outside click is how the menus and the date picker close for a person too
    await page.mouse.click(60, 22); await settle(page, 120);
    await page.mouse.move(700, 5);
  };
  const plan0 = await exportPlan(url);
  const kick = plan0.items.find(r => r.name === 'Kickoff');
  const STATES = [
    ['chart', async () => { await view(page, 'gantt'); }],
    ['export menu', async () => { await page.click('#btnExport'); }],
    ['import menu', async () => { await page.click('#btnImport'); }],
    ['help menu', async () => { await page.click('#btnHelp'); }],
    ['setup', async () => { await view(page, 'setup'); }],
    ['setup: rename', async () => { await view(page, 'setup'); await page.click('.sec-row[data-i="1"] .sec-nm-t'); }],
    ['setup: aside', async () => { await view(page, 'setup'); await page.click('.sec-row[data-i="1"] .sec-ld-t'); }],
    ['setup: colour', async () => { await view(page, 'setup'); await page.click('.sec-row[data-i="1"] .sec-sw'); }],
    ['setup: remove', async () => { await view(page, 'setup'); await page.click('.sec-row[data-i="1"] .sec-btn[data-a="del"]'); }],
    ['setup: tag rename', async () => { await view(page, 'setup'); await page.click('#suTags .tag-row .tag-nm-t'); }],
    ['setup: tag colour', async () => { await view(page, 'setup'); await page.click('#suTags .tag-row[data-tag="launch"] .tag-sw'); }],
    ['legend: sections unfolded', async () => { await view(page, 'gantt'); await page.click('#legendSecs'); }],
    ['legend: tag filter on', async () => { await view(page, 'gantt'); await page.click('.tag-chip[data-tag="tech"]'); }],
    ['new item', async () => { await page.click('#btnNew'); }],
    ['bar editor', async () => { await view(page, 'gantt'); await clickBar(page, 'finale'); }],
    ['milestone editor', async () => { await view(page, 'gantt'); await clickMilestone(page, kick.uid); }],
    ['chart row hover', async () => { await view(page, 'gantt');
      await page.evaluate(() => document.querySelectorAll('.label-row:not(.group)')[2].scrollIntoView({ block: 'center' }));
      const r = await page.evaluate(() => { const b = document.querySelectorAll('.label-row:not(.group)')[2].getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; });
      await page.mouse.move(r.x, r.y); }],
    ['item editor', async () => { await view(page, 'gantt'); await openItemEditor(page, 'Lane 1 task 1'); }],
    ['batch panel', async () => { await view(page, 'gantt'); await clickBar(page, 'p0t0', ['Control']); await clickBar(page, 'p0t1', ['Control']); }],
    // 0.79.0: the item editor opened from the Table (its Deps cell) is the same panel, walked
    // there too, for a task and for a milestone.
    ['table: item editor', async () => { await view(page, 'table'); await page.click('#tableView tr[data-key="t:finale"] td.c-deps'); }],
    ['table: milestone editor', async () => { await view(page, 'table'); await page.click(`#tableView tr[data-key="m:${kick.uid}"] td.c-deps`); }],
    ['table', async () => { await view(page, 'table'); }],
    ['table row hover', async () => { await view(page, 'table');
      const r = await page.evaluate(() => { const tr = document.querySelectorAll('#tableView tbody tr[data-key]')[3]; tr.scrollIntoView({ block: 'center' }); const b = tr.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; });
      await page.mouse.move(r.x, r.y); }],
    ['table sorted', async () => { await view(page, 'table'); await page.evaluate(() => document.querySelector('#tableView thead th[data-sort]')?.click()); }],
    ['analytics', async () => { await view(page, 'analytics'); }],
  ];
  const seen = new Set(), orphans = [], stateControls = {};
  const openFailed = [];
  const tryOpen = async (name, open) => { try { await open(); return true; } catch (e) { openFailed.push(`${name}: ${e.message.slice(0, 80)}`); await reset(); return false; } };
  for (const [name, open] of STATES) {
    if (!await tryOpen(name, open)) continue;
    await settle(page, 200);
    const live = await liveControls(page);
    stateControls[name] = live.length;
    for (const c of live) {
      const hit = manifestFor(c);
      if (hit) { seen.add(entryId(hit)); seen.add(covKey(hit, c)); }
      else orphans.push(`[${name}] <${c.tag} id="${c.id}" class="${c.cls.slice(0, 40)}" data=${JSON.stringify(c.ds).slice(0, 40)}> "${c.text}"`);
    }
    await reset();
    // A state that renders nothing new would make every check about it vacuous.
  }
  // The added states must really have opened what they claim to.
  for (const [st, sel] of [['setup: rename', '.sec-nm'], ['setup: aside', '.sec-ld'], ['setup: tag rename', '.tag-nm'], ['setup: tag colour', '.tag-hex'], ['legend: tag filter on', '#tagClear'], ['bar editor', '#imNote'], ['milestone editor', '#imApply'], ['item editor', '#imApply'], ['batch panel', '#bApply'], ['table: item editor', '#imAddDep'], ['table: milestone editor', '#imAddDep'], ['table row hover', '.row-del'], ['chart row hover', '.row-edit'], ['table sorted', '#toPlanOrder']])
    check(`state "${st}" opened (its ${sel} rendered)`, seen.has(entryId(MANIFEST.find(d => d.sel === sel))), `controls seen in that state: ${stateControls[st]}`);
  await view(page, 'gantt');
  check('every rendered control is declared (no orphans)', orphans.length === 0, [...new Set(orphans)].slice(0, 8).join(' | '));
  const missing = [];
  for (const d of MANIFEST) {
    if (d.required === false) continue;
    if (!seen.has(entryId(d))) { missing.push(entryId(d)); continue; }
    if (d.pin) for (const k of d.pin) { const ck = `${d.sel}${d.within ? '@' + d.within : ''}=${k}`; if (!seen.has(ck)) missing.push(ck); }
  }
  check('every required control (and every pinned family member) rendered in some state (no fiction)', missing.length === 0, missing.join(', '));

  // Click every safe control, in the state where it lives, and fail on any page error.
  const before = pageErrors.length;
  const walkBefore = spanOf(await savedPlan(url, { minWait: 200 }));
  let clicked = 0, covered = 0; const skipped = [], clickedKeys = new Set();
  for (const [name, open] of STATES) {
    if (!await tryOpen(name, open)) continue;
    await settle(page, 150);
    const ctrls = await liveControls(page);
    for (const c of ctrls) {
      const d = manifestFor(c);
      if (!d || !d.safe) continue;
      // Re-find it right before each click, by signature: the chart repaints, detaches
      // nodes and renumbers, and a stale handle clicks whatever took its place.
      const fresh = (await liveControls(page)).find(x => x.sig === c.sig && x.nth === c.nth);
      const h = fresh && await page.$(`[data-cov="${fresh.i}"]`);
      const box = h && await h.boundingBox();
      if (!box) { skipped.push(`${name}:${covKey(d, c)} (no box)`); continue; }
      // A mouse click lands on whatever is on top at that point. In an overlay state
      // (the date picker hangs over the legend) a chip under the overlay is not
      // clickable by a person either, and "clicking" it would really click the
      // overlay's month cell and move the plan. It must be clicked in another state;
      // the per-entry check below fails if it never is.
      const onTop = await h.evaluate(e => { const r = e.getBoundingClientRect(); const t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return !!t && (t === e || e.contains(t)); });
      if (!onTop) { covered++; skipped.push(`${name}:${covKey(d, c)} (covered)`); continue; }
      try {
        if (process.env.COV_DEBUG) console.log('    click', name, c.sig, c.nth);
        await h.click(); clicked++; clickedKeys.add(entryId(d)); clickedKeys.add(covKey(d, c));
        // The second click of a toggle goes to the same element when it is still in the
        // page. Its text may have changed ("Collapse all" -> "Expand all"), so a lookup
        // by signature would miss it and silently leave the toggle flipped.
        if (d.twice) { await settle(page, 60);
          let h2 = (await h.evaluate(e => e.isConnected)) ? h : null;
          if (!h2) { const bare = x => x.sig.split('|').slice(0, 3).join('|'); const again = (await liveControls(page)).find(x => bare(x) === bare(c) && x.nth === c.nth); h2 = again && await page.$(`[data-cov="${again.i}"]`); }
          if (h2 && await h2.boundingBox()) await h2.click(); else skipped.push(`${name}:${covKey(d, c)} (second click of a toggle found nothing)`); }
      } catch (e) { skipped.push(`${name}:${covKey(d, c)} (${e.message.slice(0, 40)})`); }
      await settle(page, 60);
      // A click that opened something (a menu, the modal, a lens) is closed before the
      // next, so each control is clicked in the state the walk opened, not a stray one.
      await reset(); if (!await tryOpen(name, open)) break; await settle(page, 60);
    }
    await reset();
  }
  check('every walk state could be opened', openFailed.length === 0, [...new Set(openFailed)].join(' | '));
  // The walk clicks only safe controls, so the plan must come out of it untouched.
  const after = spanOf(await savedPlan(url, { minWait: 200 }));
  check('the safe-control walk did not change the plan', after.first === walkBefore.first && after.last === walkBefore.last && after.n === walkBefore.n, JSON.stringify({ walkBefore, after }));
  console.log(`       (${covered} control instances sat under an open overlay in some state; ${skipped.length} skipped: ${skipped.slice(0, 4).join(', ')})`);
  check(`clicked safe controls (${clicked} clicks) with no page error`, pageErrors.length === before && clicked > 40, pageErrors.slice(before, before + 3).join(' | '));
  // Every safe entry, and every pinned member of a safe family, was clicked at least once
  // in some state; a skip (no box, covered, click threw) in every state is a failure.
  const neverClicked = [];
  for (const d of MANIFEST.filter(d => d.safe && d.required !== false)) {
    if (!clickedKeys.has(entryId(d))) { neverClicked.push(entryId(d)); continue; }
    if (d.pin) for (const k of d.pin) { const ck = `${d.sel}${d.within ? '@' + d.within : ''}=${k}`; if (!clickedKeys.has(ck)) neverClicked.push(ck); }
  }
  const whyNot = neverClicked.map(k => skipped.filter(x => x.includes(k.split('[')[0])).slice(0, 2).join(' / ')).filter(Boolean);
  check('every safe control was clicked at least once in some state', neverClicked.length === 0, neverClicked.join(', ') + (whyNot.length ? ' | why: ' + whyNot.join(' ; ') : ''));
  await page.close();
}
// Run at the very end of a full run, when every flow has had its chance to run.
function manifestHonesty() {
  const terminal = MANIFEST.filter(d => d.terminal);
  const noBy = terminal.filter(d => !d.by).map(entryId);
  check('every terminal control names the flow that proves it', noBy.length === 0, noBy.join(', '));
  const dangling = terminal.filter(d => d.by && !flowsRan.has(d.by)).map(d => `${entryId(d)} -> "${d.by}"`);
  check('every terminal control\'s `by` names a flow that actually ran', dangling.length === 0, dangling.join(' | ') + ` (flows that ran: ${[...flowsRan].join(', ')})`);
  const bad = MANIFEST.filter(d => !d.safe && !d.terminal && !d.untested).map(entryId);
  check('every manifest entry is safe, terminal or explicitly untested', bad.length === 0, bad.join(', '));
  return MANIFEST.filter(d => d.untested);
}

// ── Flows for the editors, the menus, the table filters and the Sections panel ──
// Each asserts the EFFECT on the plan the server holds (via export/json after the
// debounced save), then undoes it, and checks the plan came back.
const rowOf = (plan, pred) => plan.items.find(pred);
async function editorFlows(browser, S) {
  SRV = S.url;
  const page = await openPage(browser, S.url);
  const plan0 = await savedPlan(S.url, { minWait: 200 });
  const n0 = spanOf(plan0).n;
  const back = async what => { const p = await savedPlan(S.url); check(`${what}: undo puts the plan back`, JSON.stringify(spanOf(p)) === JSON.stringify(spanOf(plan0)), JSON.stringify(spanOf(p))); return p; };

  section = 'editor: bar';
  // 0.65.0: a bar click opens THE item editor, beside the bar, with every field of the row.
  await clickBar(page, 'finale');
  check('clicking a bar opens the item editor for it', await editorShown(page) && await page.$eval('#imName', e => e.value) === 'Launch finale' && !!(await page.$('#imNote')) && !!(await page.$('#imAddDep')));
  check('there is no other kind of panel for a bar', !(await page.$('#deApply')) && !(await page.$('#deTitle')));
  await setVal(page, '#imEnd', '2027-01-20'); await setVal(page, '#imNote', 'late');
  await page.click('#imApply'); await settle(page, 200);
  let p = await savedPlan(S.url); let f = rowOf(p, r => r.id === 'finale');
  check('Apply saves the new end and the bar label', f.end === '2027-01-20' && f.note === 'late', JSON.stringify({ end: f.end, note: f.note }));
  check('Apply closes the editor', !(await editorShown(page)));
  await undo(page); await back('bar editor Apply');
  await clickBar(page, 'finale');
  await setVal(page, '#imEnd', '2027-02-20');
  await page.click('#imCancel'); await settle(page, 200);
  p = await savedPlan(S.url);
  check('Cancel closes the editor and saves nothing', !(await editorShown(page)) && rowOf(p, r => r.id === 'finale').end === LAST);
  // Dependencies are a draft now: nothing lands until Apply, and Cancel drops them.
  await clickBar(page, 'finale');
  await page.select('#imAddDep', 'p0t0'); await settle(page, 150);
  const rmN = await page.$$eval('#imDepList .dep-rm', e => e.length);
  await page.click('#imDepList .dep-rm[data-dep="gate-a"]'); await settle(page, 150);
  p = await savedPlan(S.url); f = rowOf(p, r => r.id === 'finale');
  check('adding and removing a dependency waits for Apply', JSON.stringify(f.deps) === JSON.stringify(['gate-a']) && rmN === 2, `${rmN} listed; saved ${JSON.stringify(f.deps)}`);
  await page.click('#imApply'); await settle(page, 250);
  p = await savedPlan(S.url); f = rowOf(p, r => r.id === 'finale');
  check('Apply lands both: now waits on p0t0 and not gate-a', JSON.stringify(f.deps) === JSON.stringify(['p0t0']), JSON.stringify(f.deps));
  await undo(page); await settle(page, 250);
  p = await savedPlan(S.url); f = rowOf(p, r => r.id === 'finale');
  check('one undo restores the dependencies', JSON.stringify(f.deps) === JSON.stringify(['gate-a']), JSON.stringify(f.deps));
  await clickBar(page, 'finale');
  await page.select('#imAddDep', 'p0t1'); await page.click('#imCancel'); await settle(page, 250);
  p = await savedPlan(S.url); f = rowOf(p, r => r.id === 'finale');
  check('Cancel drops a drafted dependency', JSON.stringify(f.deps) === JSON.stringify(['gate-a']), JSON.stringify(f.deps));
  // A "blocks" line: gate-a is blocked by nothing, finale waits on gate-a, so gate-a's
  // editor lists "blocks Launch finale", and its x takes gate-a out of finale's list.
  await clickBar(page, 'gate-a');
  check('the editor lists what waits on this task', (await page.$$eval('#imDepList .dep-rm[data-dir="out"]', e => e.map(x => x.dataset.dep))).includes('finale'));
  check('and offers no task that would close a loop (finale waits on gate-a)', !(await page.$$eval('#imAddDep option', o => o.map(x => x.value))).includes('finale'));
  await page.click('#imDepList .dep-rm[data-dir="out"][data-dep="finale"]'); await page.click('#imApply'); await settle(page, 250);
  p = await savedPlan(S.url);
  check('removing a "blocks" line on Apply takes it out of the other task', !(rowOf(p, r => r.id === 'finale').deps || []).includes('gate-a'));
  await undo(page); await settle(page, 250);
  p = await savedPlan(S.url);
  check('and one undo puts it back', (rowOf(p, r => r.id === 'finale').deps || []).includes('gate-a'));
  await clickBar(page, 'finale');
  await page.click('#imDelete'); await settle(page, 300);
  p = await savedPlan(S.url);
  check('Delete removes the task', spanOf(p).n === n0 - 1 && !rowOf(p, r => r.id === 'finale'), `${n0} -> ${spanOf(p).n}`);
  await undo(page); await back('bar editor Delete');

  section = 'editor: milestone';
  const kick = rowOf(plan0, r => r.name === 'Kickoff');
  await clickMilestone(page, kick.uid);
  // Since 0.78.0 a milestone has dependencies (it waits on work, work waits on it), so that field shows.
  check('clicking a milestone opens the item editor for it, with no bar label or effort, and with its dependencies', await editorShown(page) && await page.$eval('#imName', e => e.value) === 'Kickoff'
    && await page.$eval('#imNoteWrap', e => getComputedStyle(e).display) === 'none' && await page.$eval('#imDepsWrap', e => getComputedStyle(e).display) !== 'none' && await page.$eval('#imEffWrap', e => getComputedStyle(e).display) === 'none');
  await setVal(page, '#imName', 'Kickoff moved'); await setVal(page, '#imEnd', '2026-09-22'); await setVal(page, '#imDesc', 'moved a day');
  await page.click('#imApply'); await settle(page, 200);
  p = await savedPlan(S.url); let k = rowOf(p, r => r.uid === kick.uid);
  check('Apply saves the name, date and notes', k.name === 'Kickoff moved' && k.date === '2026-09-22' && k.description === 'moved a day', JSON.stringify(k));
  await undo(page); p = await back('milestone Apply');
  check('and undo restores the name', rowOf(p, r => r.uid === kick.uid).name === 'Kickoff');
  await clickMilestone(page, kick.uid);
  await setVal(page, '#imEnd', '2026-12-01'); await page.click('#imCancel'); await settle(page, 200);
  p = await savedPlan(S.url);
  check('Cancel saves nothing', !(await editorShown(page)) && rowOf(p, r => r.uid === kick.uid).date === FIRST);
  await clickMilestone(page, kick.uid);
  await page.click('#imDelete'); await settle(page, 300);
  p = await savedPlan(S.url);
  check('Delete removes the milestone', spanOf(p).n === n0 - 1 && !rowOf(p, r => r.uid === kick.uid));
  await undo(page); await back('milestone Delete');

  // The name in the label column opens the same editor (0.65.0), and so does the Deps cell.
  await view(page, 'gantt');
  await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.querySelector('.name-text')?.textContent === 'Lane 1 task 2'); e.scrollIntoView({ block: 'center' }); e.querySelector('.name-text').dataset.pick = '1'; });
  await page.click('.name-text[data-pick="1"]'); await settle(page, 200);
  // 0.66.1: a click on the name renames it in place; the item editor does NOT open.
  check('clicking a name in the label column renames it in place, with no editor panel', !(await editorShown(page))
    && await page.evaluate(() => { const a = document.activeElement; return !!a && a.classList.contains('label-edit') && a.value === 'Lane 1 task 2'; }));
  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');
  await page.keyboard.type('Lane 1 task 2 renamed'); await page.keyboard.press('Enter'); await settle(page, 300);
  let rn = rowOf(await savedPlan(S.url), r => r.id === 'p0t1');
  check('Enter saves the new name', rn.name === 'Lane 1 task 2 renamed' && !(await page.$('.label-edit')), JSON.stringify(rn && rn.name));
  await undo(page); await settle(page, 300);
  rn = rowOf(await savedPlan(S.url), r => r.id === 'p0t1');
  check('and one undo takes it back', rn.name === 'Lane 1 task 2', JSON.stringify(rn && rn.name));
  await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.querySelector('.name-text')?.textContent === 'Lane 1 task 2'); e.querySelector('.name-text').dataset.pick = '1'; });
  await page.click('.name-text[data-pick="1"]'); await settle(page, 150);
  await page.keyboard.press('Escape'); await settle(page, 150);
  check('Escape leaves the name as it was and opens nothing', !(await page.$('.label-edit')) && !(await editorShown(page)));
  await view(page, 'table');
  await page.click('#tableView tr[data-key="t:finale"] td.c-deps'); await settle(page, 200);
  check('the Deps cell opens the item editor with "waits on" focused', await editorShown(page) && await page.evaluate(() => document.activeElement && document.activeElement.id === 'imAddDep'));
  await page.keyboard.press('Escape'); await settle(page, 150);
  await view(page, 'gantt');

  // One picker per kind of field: the app's list for a select, one owner picker.
  await openItemEditor(page, 'Lane 1 task 3');
  const ph = await page.$eval('#imPhase', e => { const r = e.getBoundingClientRect(); return { x: r.left + 20, y: r.top + r.height / 2 }; });
  await page.mouse.click(ph.x, ph.y); await settle(page, 150);
  check('the section select opens the app\'s own list, above the panel', await page.evaluate(() => { const l = document.querySelector('.cell-list:not(.owner-list)'); return !!l && +getComputedStyle(l).zIndex > +getComputedStyle(document.getElementById('editor')).zIndex; }));
  await page.mouse.click(ph.x, ph.y); await settle(page, 100);
  await page.click('#imOwner'); await settle(page, 150);
  const ol = await page.$$eval('.owner-combo .cl-opt', e => e.map(x => x.textContent));
  check('the owner field lists "no owner" and the plan\'s people under it', ol[0] === 'no owner' && ol.includes('Drew') && ol.includes('Ana'), JSON.stringify(ol));
  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');
  await page.keyboard.type('an'); await settle(page, 100);
  check('typing filters the list, the matches first and "no owner" last', JSON.stringify(await page.$$eval('.owner-combo .cl-opt', e => e.map(x => x.textContent))) === JSON.stringify(['Ana', 'no owner']));
  // 0.66.1: the keyboard walks the owner list (the datalist it replaced did; Drew noticed).
  const act = () => page.evaluate(() => { const a = document.querySelector('.owner-combo .cl-opt.active'); return a ? a.textContent : ''; });
  check('nothing is highlighted until a key asks for it', await act() === '');
  await page.keyboard.press('ArrowDown'); await settle(page, 60);
  check('Down highlights the first match', await act() === 'Ana', await act());
  await page.keyboard.press('ArrowDown'); await settle(page, 60);
  await page.keyboard.press('ArrowUp'); await settle(page, 60);
  check('Down and Up walk the list', await act() === 'Ana', await act());
  await page.keyboard.press('Enter'); await settle(page, 120);
  check('Enter takes the highlighted name, closes the list, and does NOT apply the panel', await page.$eval('#imOwner', e => e.value) === 'Ana' && !(await page.$('.owner-combo')) && await editorShown(page));
  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');
  await page.keyboard.type('Dre'); await settle(page, 80);
  check('a prefix of a known name lists it, unhighlighted', JSON.stringify(await page.$$eval('.owner-combo .cl-opt', e => e.map(x => x.textContent))) === JSON.stringify(['Drew', 'no owner']) && await act() === '');
  await page.keyboard.press('Enter'); await settle(page, 400);
  let o3 = rowOf(await savedPlan(S.url), r => r.id === 'p0t2');
  check('Enter with nothing highlighted keeps what was typed (a NEW name) and applies the panel', o3.owner === 'Dre' && !(await editorShown(page)), JSON.stringify(o3 && o3.owner));
  await undo(page); await settle(page, 300);
  await openItemEditor(page, 'Lane 1 task 3');
  await page.click('#imOwner'); await settle(page, 120);
  await page.keyboard.press('Escape'); await settle(page, 80);
  check('Escape closes the list and leaves the panel open', !(await page.$('.owner-combo')) && await editorShown(page));
  await page.keyboard.press('ArrowDown'); await settle(page, 80);
  check('Down on the closed field opens the list, highlighted', !!(await page.$('.owner-combo')) && await act() === 'no owner', await act());
  await page.keyboard.press('Escape'); await settle(page, 60);
  // The pick lists: the arrows walk them and the list stays open; Enter closes it.
  await page.focus('#imStatus'); await settle(page, 60);
  const st0 = await page.$eval('#imStatus', e => e.value);
  await page.keyboard.press('ArrowDown'); await settle(page, 80);
  const stOpen = await page.evaluate(() => { const l = document.querySelector('.cell-list:not(.owner-list)'); const on = l && l.querySelector('.cl-opt.on'); return l ? (on ? on.dataset.value : '?') : ''; });
  check('Down on the status select opens the list and moves the value, and the list follows', stOpen && stOpen !== st0 && stOpen === await page.$eval('#imStatus', e => e.value), `${st0} -> ${stOpen}`);
  await page.keyboard.press('ArrowDown'); await settle(page, 80);
  check('a second Down keeps the list open (it used to close on the first)', !!(await page.$('.cell-list:not(.owner-list)')));
  await page.keyboard.press('Enter'); await settle(page, 100);
  check('Enter closes the list and does not apply the panel', !(await page.$('.cell-list:not(.owner-list)')) && await editorShown(page));
  // "waits on": the arrows walk it WITHOUT adding a dependency for every task passed.
  const ins = () => page.$$eval('#imDepList .dep-rm[data-dir="in"]', e => e.length);
  const in0 = await ins();
  await page.focus('#imAddDep'); await settle(page, 60);
  for (let k = 0; k < 3; k++) { await page.keyboard.press('ArrowDown'); await settle(page, 60); }
  check('arrowing through "waits on" adds nothing', await ins() === in0, String(await ins()));
  await page.keyboard.press('Enter'); await settle(page, 120);
  check('Enter adds the highlighted task, once', await ins() === in0 + 1 && await editorShown(page), String(await ins()));
  await page.click('#imCancel'); await settle(page, 150);
  // Let any pending save land first: a save re-renders the table, which (as for every cell
  // editor) ends an edit that happens to be open at that moment.
  await view(page, 'table'); await settle(page, 1000);
  await page.click('#tableView tr[data-key="t:p0t2"] td[data-field="owner"]'); await settle(page, 150);
  check('the Table\'s Owner cell opens the same owner list', !!(await page.$('.owner-combo .cl-opt[data-value=""]')));
  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');
  await page.keyboard.type('jo'); await settle(page, 80);
  await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await settle(page, 400);
  o3 = rowOf(await savedPlan(S.url), r => r.id === 'p0t2');
  check('in the Table: type, Down, Enter takes the name and saves the cell', o3.owner === 'John' && !(await page.$('.cell-edit')), JSON.stringify(o3 && o3.owner));
  await undo(page); await settle(page, 300);
  await page.click('#tableView tr[data-key="t:p0t2"] td[data-field="owner"]'); await settle(page, 150);
  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');
  await page.keyboard.type('dr'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Tab'); await settle(page, 400);
  o3 = rowOf(await savedPlan(S.url), r => r.id === 'p0t2');
  check('Tab takes the highlighted name, saves, and moves to the next cell', o3.owner === 'Drew' && await page.evaluate(() => !!document.activeElement && !!document.activeElement.closest('td') && document.activeElement.closest('td').dataset.field !== 'owner'), JSON.stringify(o3 && o3.owner));
  await page.keyboard.press('Escape'); await settle(page, 150);
  await undo(page); await settle(page, 300);
  // The owner pill on the chart opens the SAME picker, so it walks the same way.
  await view(page, 'gantt');
  await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.querySelector('.name-text')?.textContent === 'Lane 1 task 2'); e.scrollIntoView({ block: 'center' }); e.querySelector('.owner-pill').dataset.pick = '1'; });
  await page.click('.owner-pill[data-pick="1"]'); await settle(page, 150);
  check('the owner pill opens the owner field over it, holding the owner, with the list', await page.evaluate(() => { const a = document.activeElement; return !!a && a.classList.contains('pill-owner-inp') && a.value === 'John'; }) && !!(await page.$('.owner-combo')));
  await page.keyboard.type('an'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await settle(page, 400);
  const p1 = rowOf(await savedPlan(S.url), r => r.id === 'p0t1');
  check('type, Down, Enter on the pill sets the owner', p1.owner === 'Ana' && !(await page.$('.pill-owner-inp')), JSON.stringify(p1 && p1.owner));
  await undo(page); await settle(page, 300);
  check('and one undo puts it back', rowOf(await savedPlan(S.url), r => r.id === 'p0t1').owner === 'John');
  await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.querySelector('.name-text')?.textContent === 'Lane 1 task 2'); e.querySelector('.owner-pill').dataset.pick = '1'; });
  await page.click('.owner-pill[data-pick="1"]'); await settle(page, 150);
  await page.keyboard.press('Escape'); await page.keyboard.press('Escape'); await settle(page, 150);
  check('Escape twice closes the list, then the field, changing nothing', !(await page.$('.pill-owner-inp')) && rowOf(await savedPlan(S.url), r => r.id === 'p0t1').owner === 'John');
  // The status pill's list walks with the arrows too (0.69.0). It opens from a click, so
  // the keys are read while it is open; Enter takes the choice, Escape leaves it.
  const pickPill = () => page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.querySelector('.name-text')?.textContent === 'Lane 1 task 2'); e.scrollIntoView({ block: 'center' }); e.querySelector('.status-pill').dataset.pick = '1'; });
  const onOpt = () => page.evaluate(() => (document.querySelector('.cell-list .cl-opt.on') || {}).textContent || '');
  await pickPill(); await page.click('.status-pill[data-pick="1"]'); await settle(page, 150);
  check('the status pill opens the list on the row\'s status', (await onOpt()) === 'not started', await onOpt());
  await page.keyboard.press('ArrowDown'); await settle(page, 80);
  check('Down walks the list without committing, and the row keeps its focus where it was', (await onOpt()) === 'in progress' && rowOf(await savedPlan(S.url, { minWait: 0 }), r => r.id === 'p0t1').status === 'not-started', await onOpt());
  await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await settle(page, 400);
  check('Enter takes the choice and the row is saved with it', !(await page.$('.cell-list')) && rowOf(await savedPlan(S.url), r => r.id === 'p0t1').status === 'complete', JSON.stringify(rowOf(await savedPlan(S.url, { minWait: 0 }), r => r.id === 'p0t1').status));
  await undo(page); await settle(page, 300);
  await pickPill(); await page.click('.status-pill[data-pick="1"]'); await settle(page, 150);
  await page.keyboard.type('b'); await settle(page, 80);
  check('a letter jumps to the option starting with it', (await onOpt()) === 'blocked', await onOpt());
  await page.keyboard.press('Escape'); await settle(page, 150);
  check('Escape closes the list, changing nothing', !(await page.$('.cell-list')) && rowOf(await savedPlan(S.url), r => r.id === 'p0t1').status === 'not-started');

  section = 'editor: item';
  const t1 = rowOf(plan0, r => r.name === 'Lane 1 task 1');
  check('hovering a row label reveals its pencil', await openItemEditor(page, 'Lane 1 task 1'));
  check('the pencil opens the full editor on that row', await page.$eval('#imName', e => window.__uiVis(e) && e.value) === 'Lane 1 task 1');
  await setVal(page, '#imName', 'Lane 1 task 1 renamed'); await setVal(page, '#imOwner', 'Zed');
  await page.select('#imStatus', 'in-progress'); await page.select('#imPhase', '3');
  await setVal(page, '#imStart', '2026-10-05'); await setVal(page, '#imEnd', '2026-10-09');
  await setVal(page, '#imDesc', 'full edit'); await setVal(page, '#imProv', 'ui-test prov');
  await page.click('#imApply'); await settle(page, 300);
  p = await savedPlan(S.url); let t = rowOf(p, r => r.uid === t1.uid);
  check('Apply saves name, owner, status, section, dates, description and provenance',
    t.name === 'Lane 1 task 1 renamed' && t.owner === 'Zed' && t.status === 'in-progress' && t.phase === 3 && t.start === '2026-10-05' && t.end === '2026-10-09' && t.description === 'full edit' && JSON.stringify(t.provenance || '').includes('ui-test prov'), JSON.stringify(t));
  await undo(page);
  p = await savedPlan(S.url); t = rowOf(p, r => r.uid === t1.uid);
  // This check caught a real bug, inherited from 0.56.0 and fixed in 0.57.1: Apply
  // recorded the field edits for undo and then moved the row to its new section without
  // recording the move, so Ctrl+Z restored every field except the section.
  check('one undo takes the whole full-editor apply back (section included)', t.name === 'Lane 1 task 1' && t.owner === t1.owner && t.phase === 0 && t.start === t1.start, JSON.stringify(t));
  if (t.phase !== 0) { // put it back by hand so the flows after this one start from the seeded plan
    await openItemEditor(page, 'Lane 1 task 1'); await page.select('#imPhase', '0'); await page.click('#imApply'); await settle(page, 300);
  }
  await openItemEditor(page, 'Lane 1 task 1');
  await setVal(page, '#imName', 'should not stick'); await page.click('#imCancel'); await settle(page, 200);
  p = await savedPlan(S.url);
  check('Cancel saves nothing', rowOf(p, r => r.uid === t1.uid).name === 'Lane 1 task 1');
  await openItemEditor(page, 'Lane 1 task 1');
  await page.click('#imDelete'); await settle(page, 300);
  p = await savedPlan(S.url);
  check('Delete removes the row', spanOf(p).n === n0 - 1 && !rowOf(p, r => r.uid === t1.uid));
  await undo(page); await back('full editor Delete');

  section = 'editor: batch';
  await clearSel(page);
  await clickBar(page, 'p0t0', ['Control']); await clickBar(page, 'p0t1', ['Control']);
  check('Ctrl+clicking two bars opens the batch panel for both', await editorShown(page) && /2 tasks selected/.test(await page.$eval('#editor', e => e.textContent)));
  const a0 = rowOf(plan0, r => r.id === 'p0t0'), b0 = rowOf(plan0, r => r.id === 'p0t1');
  await setVal(page, '#bShiftAll', '3'); await setVal(page, '#bShiftS', '1'); await setVal(page, '#bShiftE', '2');
  await page.click('#bApply'); await settle(page, 300);
  p = await savedPlan(S.url);
  const a1 = rowOf(p, r => r.id === 'p0t0'), b1 = rowOf(p, r => r.id === 'p0t1');
  check('batch Apply shifts every selected start by all+start (4) and end by all+end (5)',
    a1.start === addDaysISO(a0.start, 4) && a1.end === addDaysISO(a0.end, 5) && b1.start === addDaysISO(b0.start, 4) && b1.end === addDaysISO(b0.end, 5), JSON.stringify({ a0, a1 }));
  await undo(page); p = await back('batch Apply');
  // The span check above cannot see a row that stayed shifted inside the plan's range,
  // and until 0.57.1 one did: Apply pushed an undo entry per task, so one Ctrl+Z put a
  // single row back. Both rows are read here.
  check('one undo takes the whole batch Apply back (every selected row)',
    rowOf(p, r => r.id === 'p0t0').start === a0.start && rowOf(p, r => r.id === 'p0t0').end === a0.end && rowOf(p, r => r.id === 'p0t1').start === b0.start && rowOf(p, r => r.id === 'p0t1').end === b0.end,
    JSON.stringify({ a: rowOf(p, r => r.id === 'p0t0'), b: rowOf(p, r => r.id === 'p0t1') }));
  await clearSel(page);
  await clickBar(page, 'p0t0', ['Control']); await clickBar(page, 'p0t1', ['Control']);
  await setVal(page, '#bShiftAll', '9'); await page.click('#bClose'); await settle(page, 200);
  p = await savedPlan(S.url);
  check('batch Cancel saves nothing', rowOf(p, r => r.id === 'p0t0').start === a0.start, JSON.stringify({ want: a0.start, got: rowOf(p, r => r.id === 'p0t0') }));
  await clearSel(page);
  await clickBar(page, 'p0t0', ['Control']); await clickBar(page, 'p0t1', ['Control']);
  await page.click('#bDelete'); await settle(page, 300);
  p = await savedPlan(S.url);
  check('batch Delete removes both rows', spanOf(p).n === n0 - 2 && !rowOf(p, r => r.id === 'p0t0') && !rowOf(p, r => r.id === 'p0t1'), `${n0} -> ${spanOf(p).n}`);
  await undo(page); await back('batch Delete');

  section = 'new item fields';
  await page.mouse.click(60, 22); await settle(page, 100);
  await page.click('#btnNew'); await settle(page, 150);
  await page.select('#ncType', 'task'); await page.select('#ncPhase', '4');
  await page.type('#ncName', 'Fielded task'); await setVal(page, '#ncOwner', 'Quinn');
  await page.select('#ncStatus', 'blocked'); await setVal(page, '#ncStart', '2026-11-02'); await setVal(page, '#ncEnd', '2026-11-06');
  await setVal(page, '#ncDesc', 'new desc'); await setVal(page, '#ncProv', 'typed-prov');
  await page.click('#ncCreate'); await settle(page, 800);
  p = await savedPlan(S.url); t = rowOf(p, r => r.name === 'Fielded task');
  check('New saves type, section, owner, status, dates, description and provenance', !!t && t.type === 'task' && t.phase === 4 && t.owner === 'Quinn' && t.status === 'blocked' && t.start === '2026-11-02' && t.end === '2026-11-06' && t.description === 'new desc' && JSON.stringify(t.provenance || '').includes('typed-prov'), JSON.stringify(t));
  await undo(page); await back('create with every field');
  await page.click('#btnNew'); await settle(page, 150);
  check('the next New panel starts with empty notes (0.66.2) but still carries the owner', await page.$eval('#ncDesc', e => e.value) === '' && await page.$eval('#ncOwner', e => e.value) === 'Quinn',
    JSON.stringify([await page.$eval('#ncDesc', e => e.value), await page.$eval('#ncOwner', e => e.value)]));
  await page.select('#ncType', 'milestone'); await settle(page, 100);
  await page.type('#ncName', 'Fielded gate'); await setVal(page, '#ncEnd', '2026-11-20');
  await page.click('#ncCreate'); await settle(page, 800);
  p = await savedPlan(S.url); t = rowOf(p, r => r.name === 'Fielded gate');
  check('type = milestone creates a milestone on the chosen date', !!t && t.type === 'milestone' && t.date === '2026-11-20' && !t.start, JSON.stringify(t));
  await undo(page); await back('create a milestone');
  await page.click('#btnNew'); await settle(page, 150);
  await page.type('#ncName', 'Never made'); await page.click('#ncCancel'); await settle(page, 300);
  p = await savedPlan(S.url);
  check('New then Cancel creates nothing', !rowOf(p, r => r.name === 'Never made') && spanOf(p).n === n0);

  section = 'table filters';
  await view(page, 'table');
  const tableRows = () => page.$$eval('#tableView tbody tr[data-key]', trs => trs.map(tr => tr.textContent));
  const all = await tableRows();
  const johns = plan0.items.filter(r => r.owner === 'John').length;
  await page.select('#ganttOwner', 'John'); await settle(page, 200);
  let rows = await tableRows();
  check(`owner filter John shows exactly his ${johns} rows`, rows.length === johns && rows.every(x => x.includes('John')), `${rows.length} rows`);
  await page.select('#ganttOwner', ''); await settle(page, 200);
  check('owner filter back to everyone', (await tableRows()).length === all.length);
  const cancelled = plan0.items.filter(r => r.status === 'cancelled').length;
  await page.select('#tblStatus', 'cancelled'); await settle(page, 200);
  rows = await tableRows();
  check(`status filter "cancelled" shows the ${cancelled} cancelled row`, rows.length === cancelled && rows[0]?.includes('Dropped export'), `${rows.length} rows`);
  await page.select('#tblStatus', ''); await settle(page, 200);
  await page.type('#tblSearch', 'Lane 13 '); await settle(page, 300);
  rows = await tableRows();
  check('search "Lane 13 " shows only lane 13\'s 6 rows', rows.length === 6 && rows.every(x => x.includes('Lane 13 ')), `${rows.length} rows`);
  await page.$eval('#tblSearch', e => { e.value = ''; e.dispatchEvent(new Event('input', { bubbles: true })); }); await settle(page, 200);
  check('clearing the search shows every row again', (await tableRows()).length === all.length);
  // Sort, then Back to plan order.
  await page.evaluate(() => [...document.querySelectorAll('#tableView thead th[data-sort]')].find(th => /owner/i.test(th.textContent)).click()); await settle(page, 200);
  const sorted = await tableRows();
  check('sorting by owner reorders the rows and offers "Back to plan order"', sorted.join() !== all.join() && await page.$eval('#toPlanOrder', b => window.__uiVis(b)));
  await page.click('#toPlanOrder'); await settle(page, 200);
  // The table opens sorted by deadline (the app's default), so "plan order" is the
  // order the rows have in the plan itself, read from the server, not the first view.
  const restored = await tableRows();
  const planOrder = (await savedPlan(S.url, { minWait: 200 })).items.filter(r => r.type !== 'group').map(r => r.name);
  const firstDiff = restored.findIndex((x, i) => !x.includes(planOrder[i]));
  check('"Back to plan order" puts the rows in the plan\'s own order and hides itself', firstDiff === -1 && restored.length === planOrder.length && !(await page.$('#toPlanOrder').then(b => b && b.evaluate(e => window.__uiVis(e)))),
    `first difference at row ${firstDiff}: "${(restored[firstDiff] || '').slice(0, 50)}" vs "${planOrder[firstDiff]}"`);
  await view(page, 'gantt');

  // 0.64.0: the owner filter on the chart, shared with the table, and the Overdue chip.
  // The oracle is the plan itself: overdue = a deadline before the pinned today that is
  // not complete or cancelled, worked out here rather than asked of the page.
  section = 'owner and overdue';
  const ITEM_RE = /Lane \d+ (task \d+|gate)|Kickoff|Final gate prep|Launch finale|Dropped export/;
  const chartNames = () => page.$$eval('#labels .label-row:not(.row-collapsed)', (ls, re) => ls.map(l => l.textContent.trim()).filter(t => new RegExp(re).test(t)), ITEM_RE.source);
  const plan1 = await savedPlan(S.url, { minWait: 200 });
  const live = plan1.items.filter(r => r.type !== 'group');
  const chartAll = await chartNames();
  check('every row is on the chart before any owner or overdue filter', chartAll.length === live.length, `${chartAll.length} of ${live.length}`);
  const opts = await page.$$eval('#ganttOwner option', os => os.map(o => o.value));
  check('the legend owner pick lists every owner, All owners first and Unassigned last', opts[0] === '' && opts[opts.length - 1] === '__unassigned__' && ['Ana', 'Drew', 'John'].every(o => opts.includes(o)), opts.join(','));
  const johnN = live.filter(r => r.owner === 'John').length;
  await page.select('#ganttOwner', 'John'); await settle(page, 250);
  let shown = await chartNames();
  check(`owner John on the chart shows exactly his ${johnN} rows`, shown.length === johnN && shown.every(t => live.find(r => t.includes(r.name))?.owner === 'John'), `${shown.length} rows`);
  await view(page, 'table');
  // 0.77.1: one Owner filter for both lenses, in the same place in the legend line. The Table
  // had its own at the far end of the line, so the chart's seemed to vanish on the way in.
  const tOwner = await page.evaluate(() => { const s = document.getElementById('ganttOwner'), b = s && s.closest('.legend-item'), r = b && b.getBoundingClientRect();
    return { value: s && s.value, shown: !!b && b.offsetParent !== null && r.width > 0, x: r && Math.round(r.left), own: !!document.getElementById('tblOwner') }; });
  check('the Table shows the same Owner filter, with the chart\'s pick (one choice for both lenses)', tOwner.value === 'John' && tOwner.shown && !tOwner.own, JSON.stringify(tOwner));
  check('and the table shows the same rows', (await tableRows()).length === johnN);
  await view(page, 'gantt');
  const noneN = live.filter(r => !(r.owner || '').trim()).length;
  await page.select('#ganttOwner', '__unassigned__'); await settle(page, 250);
  check(`Unassigned shows the ${noneN} rows nobody owns`, (await chartNames()).length === noneN, `${(await chartNames()).length}`);
  await page.select('#ganttOwner', ''); await settle(page, 250);
  check('All owners puts every row back', (await chartNames()).length === live.length);
  const today = TODAY;
  const od = live.filter(r => { const dl = r.type === 'milestone' ? r.date : r.end; return dl && dayNum(dl) < dayNum(today) && !['complete', 'cancelled'].includes(r.status); });
  const chip = await page.$eval('#overdueChip', e => ({ tk: Number(e.dataset.tasks), ms: Number(e.dataset.ms), text: e.textContent }));
  const odMs = od.filter(r => r.type === 'milestone').length;
  check(`the Overdue chip counts tasks and milestones apart (${od.length - odMs} + ${odMs}: ${od.map(r => r.name).join(', ')})`, od.length > 0 && chip.tk === od.length - odMs && chip.ms === odMs && (odMs === 0 || chip.text.includes('\u25C6 ' + odMs)), JSON.stringify(chip));
  const marked = await page.$$eval('#labels .label-row.is-overdue', ls => ls.map(l => l.textContent.trim()));
  check('exactly the overdue rows carry the red mark without any filter on', marked.length === od.length && od.every(r => marked.some(t => t.includes(r.name))), marked.join(' | '));
  await page.click('#overdueChip'); await settle(page, 250);
  shown = await chartNames();
  check('Overdue on shows only the overdue rows', shown.length === od.length && od.every(r => shown.some(t => t.includes(r.name))), shown.join(' | '));
  await page.click('#overdueChip'); await settle(page, 250);
  check('Overdue off shows every row again', (await chartNames()).length === live.length);
  // 0.70.0: the Current chip. Current = started (a task's start, a milestone's date, on or
  // before the pinned today) and not complete or cancelled; every overdue row is current.
  const cur = live.filter(r => { const s = r.type === 'milestone' ? r.date : r.start; return s && dayNum(s) <= dayNum(today) && !['complete', 'cancelled'].includes(r.status); });
  const cuMs = cur.filter(r => r.type === 'milestone').length;
  const cchip = await page.$eval('#currentChip', e => ({ tk: Number(e.dataset.tasks), ms: Number(e.dataset.ms) }));
  check(`the Current chip counts what has started and is not done (${cur.length - cuMs} + ${cuMs} of ${live.length})`, cur.length > od.length && cur.length < live.length && cchip.tk === cur.length - cuMs && cchip.ms === cuMs, JSON.stringify(cchip));
  await page.click('#currentChip'); await settle(page, 250);
  shown = await chartNames();
  check('Current on shows exactly those rows: nothing in the future, nothing finished', shown.length === cur.length && cur.every(r => shown.some(t => t.includes(r.name))), shown.join(' | '));
  await view(page, 'table');
  check('the table shows the same rows under Current', (await tableRows()).length === cur.length, String((await tableRows()).length));
  await view(page, 'gantt');
  await page.click('#overdueChip'); await settle(page, 250);
  check('Current and Overdue together show just the overdue rows', (await chartNames()).length === od.length);
  await page.click('#overdueChip'); await settle(page, 150); await page.click('#currentChip'); await settle(page, 250);
  check('Current off shows every row again', (await chartNames()).length === live.length);
  // Nothing about either filter is written to the plan: they are one viewer's lens.
  const plan2 = await savedPlan(S.url, { minWait: 300 });
  check('neither filter writes to the plan (no Overdue tag, rows unchanged)', JSON.stringify(plan2.items) === JSON.stringify(plan1.items) && !plan2.items.some(r => (r.tags || []).some(t => /overdue/i.test(t))));
  await page.close();
}

// ── The edit panels' one layout (0.79.0) ──
// Drew, 2026-11-03: at 120% on a 1366px window the item editor was 915px wide, cut a long
// name and long notes to one line, had a title the size of its labels and kept its own
// 100% type while the rest of the app grew. Every panel that edits a task (the item editor
// on a task and on a milestone, from the chart and from the Table, New, the batch panel) is
// one layout now: 400px at 100% in the app's text size, fields that wrap and grow, a real
// title, and buttons that never scroll away and never sit under the selection chip.
async function editorLayoutFlow(browser) {
  const plan = seedPlan();
  const waitsOn = plan.items.filter(r => r.type === 'task' && /^p[0-2]t\d$/.test(r.id || '')).map(r => r.id).slice(0, 14);
  const LONG = 'Design, fabricate and qualify the vacuum fixture plates for the pick-and-place line, including the second-source review and the first-article inspection';
  const NOTES = 'The first supplier quoted six weeks and could not promise the flatness we need, so a second source is qualified in parallel. Inspection needs the CMM for two days and the result goes to quality before the readiness review.';
  const LINK = { label: 'Chat: the fixture plates thread with the supplier, review notes', url: 'https://example.com/thread/plates' };
  plan.items.push({ type: 'task', id: 'long', uid: '09001', name: LONG, description: NOTES, provenance: LINK, start: '2026-11-30', end: '2026-12-18', phase: 4, status: 'in-progress', owner: 'Drew', note: '6m', tags: ['long-lead', 'fixtures'], deps: waitsOn });
  plan.items.push({ type: 'milestone', id: 'ready', uid: '09002', name: 'Production readiness review: every fixture, feeder and program signed off', description: NOTES, provenance: LINK, date: '2026-12-21', phase: 4, deps: ['long', 'p4t0', 'p4t1'] });
  plan.project.nextUid = 9003;
  const X = await startServer(plan, 'layout');
  const esc3 = async page => { for (let i = 0; i < 3; i++) await page.keyboard.press('Escape'); await settle(page, 120); };
  // What a panel measures: its box, its type sizes, its growing fields, its rows.
  const geo = page => page.evaluate(() => {
    const ed = document.getElementById('editor'), r = ed.getBoundingClientRect(), body = ed.querySelector('.ed-body');
    const fz = s => { const e = ed.querySelector(s); return e ? parseFloat(getComputedStyle(e).fontSize) : 0; };
    const ta = id => { const t = document.getElementById(id); if (!t) return null; return { h: t.getBoundingClientRect().height, sh: t.scrollHeight, ch: t.clientHeight, lh: parseFloat(getComputedStyle(t).lineHeight) }; };
    const top = id => { const e = document.getElementById(id); return e ? Math.round(e.getBoundingClientRect().top) : null; };
    const out = [...ed.querySelectorAll('*')].filter(e => { const b = e.getBoundingClientRect(); return b.width > 0 && b.height > 0 && (b.left < r.left - 0.5 || b.right > r.right + 0.5); }).map(e => e.id || e.className || e.tagName);
    return { shown: ed.classList.contains('show'), w: r.width, l: r.left, rt: r.right, t: r.top, b: r.bottom, vw: innerWidth, vh: innerHeight,
      band: parseFloat(getComputedStyle(ed).getPropertyValue('--ed-bottom')), scrolls: !!body && body.scrollHeight > body.clientHeight + 1,
      title: fz('.ed-title'), label: fz('.ed-f > label'), field: fz('.ed-f textarea, .ed-f input'), btn: fz('.de-actions .de-btn'),
      pad: body ? getComputedStyle(body).paddingLeft : '', out, name: ta('imName'), desc: ta('imDesc'),
      rows: { owner: top('imOwner'), status: top('imStatus'), start: top('imStart'), end: top('imEnd') } };
  });
  // Every button in the panel's foot is the element a click at its centre lands on.
  // (No panel, or a panel with no buttons, is a failure too, so this cannot pass vacuously.)
  const buttonsOnTop = page => page.evaluate(() => { const bs = [...document.querySelectorAll('#editor.show .de-actions button')].filter(b => b.offsetParent);
    if (bs.length < 2) return ['the panel and its buttons are not there'];
    return bs.map(b => { const r = b.getBoundingClientRect(), t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
      return t === b || b.contains(t) ? '' : `${b.id} under ${t ? (t.id || t.className) : 'nothing'}`; }).filter(Boolean); });
  // The font size of every element in the panel, in document order, to compare across sizes.
  const fonts = page => page.evaluate(() => [...document.querySelectorAll('#editor *')].filter(e => e.offsetParent !== null).map(e => (e.id || e.className || e.tagName) + '=' + getComputedStyle(e).fontSize));
  const fsTo = async (page, steps) => { await esc3(page); await page.click('#fsPct'); for (let i = 0; i < Math.abs(steps); i++) await page.click(steps > 0 ? '#fsUp' : '#fsDown'); await settle(page, 200); };

  section = 'editor: layout';
  let page = await openPage(browser, X.url, { w: 1366, h: 768 });
  await fsTo(page, 2);                                     // 120%, Drew's size
  await clickBar(page, 'long');
  let g = await geo(page);
  check('at 120% on a 1366px window the item editor is 480px wide (it was 915)', g.shown && Math.abs(g.w - 480) < 1, JSON.stringify({ w: g.w }));
  check('it sits inside the window and clear of the bottom band', g.l >= 7.5 && g.rt <= g.vw - 7.5 && g.t >= 7.5 && g.b <= g.vh - g.band + 0.5, JSON.stringify(g));
  check('nothing in it runs past its sides', g.out.length === 0, g.out.slice(0, 5).join(', '));
  check('taller than the window allows, it scrolls inside itself', g.scrolls);
  check('and its buttons stay on screen and on top', (await buttonsOnTop(page)).length === 0, (await buttonsOnTop(page)).join(', '));
  check(`the title reads as a title: ${g.title}px over ${g.label}px labels`, Math.abs(g.title - 19.2) < 0.3 && Math.abs(g.label - 13.2) < 0.3 && g.title >= g.label * 1.4, JSON.stringify({ title: g.title, label: g.label }));
  check('a long name opens whole, wrapped over several lines', g.name.sh <= g.name.ch + 1 && g.name.h > 2.5 * g.name.lh, JSON.stringify(g.name));
  check('long notes open whole, wrapped', g.desc.sh <= g.desc.ch + 1 && g.desc.h > 3 * g.desc.lh, JSON.stringify(g.desc));
  check('fields that share a row line up (owner with status, start with deadline)', g.rows.owner === g.rows.status && g.rows.start === g.rows.end, JSON.stringify(g.rows));
  const lk = await page.evaluate(() => { const a = document.getElementById('imProvLink'); return a ? { h: a.getBoundingClientRect().height, text: a.textContent, over: a.scrollWidth > a.clientWidth + 1 } : null; });
  check('a long linked provenance wraps instead of being cut', !!lk && !lk.over && lk.h > 30, JSON.stringify(lk));
  const deps = await page.$$eval('#imDepList .dep-rm', ls => ls.map(x => x.dataset.dir + ':' + x.dataset.dep));
  check('all 14 dependencies are listed, and the milestone waiting on it, in the body that scrolls', deps.filter(d => d.startsWith('in:')).length === 14 && deps.includes('out:ready'), deps.join(' '));
  await shot(page, 'editor-layout-1366-120');

  section = 'editor: growing fields';
  const h = id => page.$eval('#' + id, e => Math.round(e.getBoundingClientRect().height));
  const one = await h('imOwner');
  await page.click('#imName'); await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');
  await page.keyboard.type('Short name'); await settle(page, 80);
  check('a short name is one line, as high as the one-line fields', await h('imName') === one, `${await h('imName')} vs ${one}`);
  const more = ' that keeps on going until it cannot fit on one line, and then on two';
  await page.keyboard.type(more); await settle(page, 80);
  const grown = await h('imName');
  const lines = await page.$eval('#imName', e => { const cs = getComputedStyle(e); return Math.round((e.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)) / parseFloat(cs.lineHeight)); });
  check('typing past the edge grows the field a line at a time, hiding nothing', lines === 2 && grown > one && await page.$eval('#imName', e => e.scrollHeight <= e.clientHeight + 1), `${one} -> ${grown}, ${lines} lines`);
  for (let i = 0; i < more.length; i++) await page.keyboard.press('Backspace');
  await settle(page, 80);
  check('deleting it shrinks the field back to one line', await h('imName') === one, `${await h('imName')} vs ${one}`);
  await page.$eval('#imName', e => { e.value = 'pasted\nover two lines'; e.dispatchEvent(new Event('input', { bubbles: true })); });
  check('a pasted line break becomes a space: the name is still one line of text', await page.$eval('#imName', e => e.value) === 'pasted over two lines');
  await page.keyboard.press('Escape'); await settle(page, 300);
  check('Escape cancels the panel and saves none of it', !(await editorShown(page)) && rowOf(await savedPlan(X.url), r => r.id === 'long').name === LONG);
  await clickBar(page, 'p0t2');
  await page.click('#imDesc'); await page.keyboard.type('a note typed here'); await page.keyboard.press('Enter'); await settle(page, 300);
  const t2 = rowOf(await savedPlan(X.url), r => r.id === 'p0t2');
  check('Enter in a growing field applies the panel, with no line break in the value', !(await editorShown(page)) && t2.description === 'a note typed here', JSON.stringify(t2.description));
  await undo(page);
  check('and one undo takes it back', !rowOf(await savedPlan(X.url), r => r.id === 'p0t2').description);

  section = 'editor: text size';
  await fsTo(page, 0);
  await clickBar(page, 'long');
  const base = await fonts(page); g = await geo(page);
  check('at 100% the panel is 400px wide', Math.abs(g.w - 400) < 1, String(g.w));
  for (const [steps, fs] of [[-2, 0.85], [-1, 0.925], [1, 1.1], [3, 1.35], [4, 1.5]]) {
    await fsTo(page, steps);
    await clickBar(page, 'long');
    g = await geo(page);
    const now = await fonts(page);
    const off = now.map((x, i) => { const a = parseFloat(base[i].split('=').pop()), b = parseFloat(x.split('=').pop()); return Math.abs(b - a * fs) > 0.05 ? `${x} (${a}px at 100%)` : ''; }).filter(Boolean);
    check(`at ${Math.round(fs * 1000) / 10}%: ${Math.round(400 * fs)}px wide, and every type size in it is ${fs} times its 100% size`, Math.abs(g.w - 400 * fs) < 1 && now.length === base.length && off.length === 0,
      `w ${g.w}; ${now.length}/${base.length} elements; ${off.slice(0, 4).join(', ')}`);
    check(`at ${Math.round(fs * 1000) / 10}%: inside the window, buttons on top, nothing past its sides`, g.l >= 7.5 && g.rt <= g.vw - 7.5 && g.t >= 7.5 && g.b <= g.vh - g.band + 0.5 && g.out.length === 0 && (await buttonsOnTop(page)).length === 0, JSON.stringify({ l: g.l, rt: g.rt, t: g.t, b: g.b, out: g.out.slice(0, 3) }));
  }
  // The pickers follow it too: the phase list opens over the panel in the panel's type.
  await page.click('#imPhase'); await settle(page, 120);
  const pl = await page.evaluate(() => { const l = document.querySelector('.cell-list'), s = document.getElementById('imPhase'); return l ? { fs: getComputedStyle(l).fontSize, w: l.getBoundingClientRect().width, sw: s.getBoundingClientRect().width, right: l.getBoundingClientRect().right, vw: innerWidth } : null; });
  check('the panel\'s pick lists open in its type size, as wide as the field, inside the window', !!pl && pl.fs === '18px' && pl.w >= pl.sw - 1 && pl.right <= pl.vw, JSON.stringify(pl));
  await esc3(page);

  section = 'editor: one look';
  await fsTo(page, 2);
  const look = () => page.evaluate(() => { const ed = document.getElementById('editor'); if (!ed.classList.contains('show')) return null; const fz = s => parseFloat(getComputedStyle(ed.querySelector(s)).fontSize);
    return { w: Math.round(ed.getBoundingClientRect().width), title: fz('.ed-title'), label: fz('.ed-f > label'), btn: fz('.de-actions .de-btn'), pad: getComputedStyle(ed.querySelector('.ed-body')).paddingLeft, primary: !!ed.querySelector('.ed-foot .de-actions .de-btn.primary') }; });
  const kinds = {};
  await clickBar(page, 'long'); kinds['task, chart'] = await look(); await esc3(page);
  await clickMilestone(page, '09002'); kinds['milestone, chart'] = await look(); await esc3(page);
  await view(page, 'table');
  await page.click('#tableView tr[data-key="t:long"] td.c-deps'); await settle(page, 200); kinds['task, Table'] = await look(); await esc3(page);
  await page.click('#tableView tr[data-key="m:09002"] td.c-deps'); await settle(page, 200); kinds['milestone, Table'] = await look(); await esc3(page);
  await view(page, 'gantt');
  await page.click('#btnNew'); await settle(page, 150); kinds['New'] = await look(); await esc3(page);
  // The first Ctrl+click opens the item editor on that row; Escape closes it and keeps the
  // selection, so the panel cannot take the second click on a 768px window at 120%.
  await clearSel(page); await clickBar(page, 'p0t0', ['Control']); await page.keyboard.press('Escape'); await settle(page, 120); await clickBar(page, 'p0t1', ['Control']); kinds['batch'] = await look();
  const ref = JSON.stringify(kinds['task, chart']);
  check('every panel that edits a task has one width, title, label and button size, padding and button row', Object.keys(kinds).length === 6 && Object.values(kinds).every(k => k && k.w > 0 && JSON.stringify(k) === ref), JSON.stringify(kinds));

  section = 'editor: buttons clear';
  // The selection chip is up while the batch panel is (two rows are selected). Dragged by its
  // title into the chip's corner, the panel stops above the band, and every button is on top.
  const dragTo = async (x, y) => { const hd = await page.$eval('#editor .ed-title', e => { const r = e.getBoundingClientRect(); return { x: r.left + 20, y: r.top + r.height / 2 }; });
    await page.mouse.move(hd.x, hd.y); await page.mouse.down(); await page.mouse.move(x, y, { steps: 6 }); await page.mouse.up(); await settle(page, 150); };
  await dragTo(10, 760);
  const near = await page.evaluate(() => { const ed = document.getElementById('editor'), e = ed.getBoundingClientRect(), c = document.getElementById('selCount'), cr = c.getBoundingClientRect();
    return { open: ed.classList.contains('show') && e.height > 0, chip: c.classList.contains('show'), apart: !(cr.left < e.right && e.left < cr.right && cr.top < e.bottom && e.top < cr.bottom), b: Math.round(e.bottom), vh: innerHeight }; });
  // The pointer is released below the panel, which stopped at the band: that release is the
  // end of a drag, not a click away, and the panel stays open (it closed until 0.79.0).
  check('dragged into the selection chip\'s corner, the panel stops above it and stays open', near.open && near.chip && near.apart && near.b <= near.vh - 60, JSON.stringify(near));
  check('and the chip covers none of its buttons', (await buttonsOnTop(page)).length === 0, (await buttonsOnTop(page)).join(', '));
  await page.type('#bTag', 'clearance'); await page.click('#bTagAdd'); await settle(page, 120);
  await dragTo(1360, 760);
  const ind = await page.evaluate(() => document.getElementById('saveInd').classList.contains('show') && document.getElementById('editor').classList.contains('show'));
  check('with the save indicator up in the other corner, no button is under it either', ind && (await buttonsOnTop(page)).length === 0, `${ind} ${(await buttonsOnTop(page)).join(', ')}`);
  await esc3(page); await undo(page);
  await page.close();

  section = 'editor: phone';
  page = await openPage(browser, X.url, { w: 390, h: 844, mobile: true });
  await page.evaluate(() => localStorage.setItem('adom-gantt:prefs', JSON.stringify({ fs: 1.2 }))); await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
  await clickBar(page, 'long');
  g = await geo(page);
  const cols = await page.evaluate(() => { const b = document.querySelector('#editor .ed-body .ed-grid').getBoundingClientRect();
    return [...document.querySelectorAll('#editor .ed-f.half, #editor .ed-f.third, #editor .ed-f.twothirds')].filter(f => f.offsetParent).map(f => Math.round(f.getBoundingClientRect().width - b.width)); });
  check('on a 390px phone at 120% the panel takes the window less 8px a side', Math.abs(g.w - 374) < 1 && g.l >= 7.5 && g.rt <= 382.5, JSON.stringify({ w: g.w, l: g.l }));
  check('and its paired fields stack, one to a row, rather than squeezing a date', cols.length > 0 && cols.every(d => Math.abs(d) < 1), JSON.stringify(cols));
  check('nothing runs past its sides, its buttons are on top', g.out.length === 0 && (await buttonsOnTop(page)).length === 0, g.out.slice(0, 4).join(', '));
  await assertNoSidewaysScroll(page, 'phone with the editor open');
  await shot(page, 'editor-layout-390-120');
  await page.close();
}

async function menuFlows(browser, S) {
  section = 'export menu';
  const page = await openPage(browser, S.url);
  const dl = join(TMP, 'downloads'); mkdirSync(dl, { recursive: true });
  const cdp = await page.createCDPSession();
  await cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: dl });
  const got = {};
  for (const fmt of ['json', 'csv', 'svg', 'html']) {
    const before = new Set(readdirSync(dl));
    await page.click('#btnExport'); await settle(page, 100);
    await page.click(`#exportMenu .dd-item[data-fmt="${fmt}"]`);
    let f = null;
    for (let i = 0; i < 50 && !f; i++) { await sleep(100); f = readdirSync(dl).find(x => !before.has(x) && !x.endsWith('.crdownload')); }
    got[fmt] = f ? readFileSync(join(dl, f), 'utf8') : '';
    check(`Export ${fmt} downloads a file`, !!f, 'no download appeared');
  }
  // The keyboard on the menu (0.69.0): Enter on the button opens it, the arrows walk it,
  // Enter takes the marked item, Escape closes it.
  {
    // The loop above already downloaded <title>.csv, and a CDP-driven download with the
    // same name overwrites it instead of adding a file, so that one is cleared first.
    for (const x of readdirSync(dl)) if (x.endsWith('.csv')) rmSync(join(dl, x), { force: true });
    const before = new Set(readdirSync(dl));
    await page.focus('#btnExport'); await page.keyboard.press('Enter'); await settle(page, 150);
    check('Enter on the Export button opens its menu', await page.$eval('#exportMenu', m => m.classList.contains('show')));
    await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown'); await settle(page, 100);
    const marked = await page.$eval('#exportMenu', m => (m.querySelector('.dd-item.active') || { dataset: {} }).dataset.fmt);
    check('the arrows walk the menu, marking an item', marked === 'csv', String(marked));
    await page.keyboard.press('Enter');
    let f = null;
    for (let i = 0; i < 50 && !f; i++) { await sleep(100); f = readdirSync(dl).find(x => !before.has(x) && !x.endsWith('.crdownload')); }
    check('Enter takes the marked item', !!f && /\.csv$/.test(f), String(f));
    await page.click('#btnExport'); await settle(page, 100);
    await page.keyboard.press('Escape'); await settle(page, 100);
    check('Escape closes the menu', !(await page.$eval('#exportMenu', m => m.classList.contains('show'))));
  }
  const n = spanOf(await exportPlan(S.url)).n;
  let parsed = null; try { parsed = JSON.parse(got.json); } catch {}
  check('the JSON export is the plan (same number of rows as the server holds)', !!parsed && spanOf(parsed).n === n, parsed ? String(spanOf(parsed).n) : 'unparseable');
  check('the CSV export has a header and a line per row', got.csv.trim().split('\n').length >= n + 1 && /name/i.test(got.csv.split('\n')[0]), got.csv.split('\n')[0].slice(0, 80));
  check('the SVG export is an SVG with the plan in it', /<svg[\s>]/.test(got.svg) && got.svg.includes('Launch finale'));
  check('the HTML export is a self-contained snapshot', got.html.includes('const SNAPSHOT = true;'));
  await page.close();

  // Import on a server of its own, so the main plan is not replaced.
  section = 'import menu';
  const small = { project: { title: 'Import Target', appName: 'import-target' }, phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],
    items: [{ type: 'task', id: 'x', name: 'Before import', start: '2026-10-05', end: '2026-10-09', phase: 0, uid: '00001' }] };
  const I = await startServer(small, 'import');
  const ip = await openPage(browser, I.url);
  // The two formats whose parse is not driven: the chooser must still open, filtered right.
  for (const [fmt, accept] of [['msproject-xml', '.xml'], ['jira-csv', '.csv']]) {
    await ip.click('#btnImport'); await settle(ip, 100);
    const [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }).catch(() => null), ip.click(`#importMenu .dd-item[data-fmt="${fmt}"]`)]);
    check(`Import ${fmt} opens a file chooser for ${accept}`, !!ch && (await ip.$eval('#importFile', e => e.accept)) === accept);
    if (ch) await ch.cancel();
  }
  const jsonFile = join(TMP, 'import.json'); writeFileSync(jsonFile, got.json);
  await ip.click('#btnImport'); await settle(ip, 100);
  let [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }), ip.click('#importMenu .dd-item[data-fmt="json"]')]);
  await Promise.all([ip.waitForNavigation({ waitUntil: 'load', timeout: 10000 }).then(() => ready(ip)).catch(() => null), ch.accept([jsonFile])]);
  await settle(ip, 300);
  let ipl = await exportPlan(I.url);
  check('Import JSON (the export of the main plan) replaces the plan with it', spanOf(ipl).n === n && !!rowOf(ipl, r => r.name === 'Launch finale') && !rowOf(ipl, r => r.name === 'Before import'), `${spanOf(ipl).n} rows`);
  check('and the page reloaded onto the imported plan', await ip.evaluate(() => [...document.querySelectorAll('.label-row')].some(e => e.textContent.includes('Launch finale'))));
  // CSV: import the small plan's own CSV export back over it.
  const csvFile = join(TMP, 'import.csv'); writeFileSync(csvFile, got.csv);
  await ip.click('#btnImport'); await settle(ip, 100);
  [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }), ip.click('#importMenu .dd-item[data-fmt="csv"]')]);
  await Promise.all([ip.waitForNavigation({ waitUntil: 'load', timeout: 10000 }).then(() => ready(ip)).catch(() => null), ch.accept([csvFile])]);
  await settle(ip, 300);
  ipl = await exportPlan(I.url);
  check('Import CSV (the main plan\'s CSV export) brings in every row', spanOf(ipl).n === n && !!rowOf(ipl, r => r.name === 'Launch finale'), `${spanOf(ipl).n} rows`);
  await ip.close();
}

// ClickUp (test/fixtures, synthetic files in ClickUp's real shapes): both menu entries and
// their choosers, a workspace export through the chooser, the plan the server then holds,
// the report card shown once after the reload, the API JSON with its dependencies drawn,
// and a file that is not a ClickUp export refused with the reason.
async function clickupFlow(browser) {
  section = 'clickup import';
  const FIX = join(HERE, 'fixtures');
  const I = await startServer({ project: { title: 'Before ClickUp', appName: 'clickup-target' }, phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],
    items: [{ type: 'task', id: 'x', name: 'Before import', start: '2026-10-05', end: '2026-10-09', phase: 0, uid: '00001' }] }, 'clickup');
  const ip = await openPage(browser, I.url);
  const pick = async (fmt, file) => {
    await ip.click('#btnImport'); await settle(ip, 100);
    const [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }), ip.click(`#importMenu .dd-item[data-fmt="${fmt}"]`)]);
    const accept = await ip.$eval('#importFile', e => e.accept);
    if (!file) { await ch.cancel(); return accept; }
    await Promise.all([ip.waitForNavigation({ waitUntil: 'load', timeout: 10000 }).then(() => ready(ip)).catch(() => null), ch.accept([join(FIX, file)])]);
    await settle(ip, 400);
    return accept;
  };
  const cardText = () => ip.evaluate(() => { const b = document.getElementById('importReport'); return b && window.__uiVis(b) ? b.innerText : ''; });
  const by = (p, name) => p.items.find(r => r.name === name) || {};

  await ip.click('#btnImport'); await settle(ip, 100);
  const menu = await ip.$$eval('#importMenu .dd-item', e => e.map(x => x.textContent.trim()));
  check('the Import menu offers ClickUp CSV and ClickUp API JSON', menu.includes('ClickUp CSV') && menu.includes('ClickUp API JSON'), menu.join(' | '));
  await ip.keyboard.press('Escape'); await settle(ip, 100);
  check('ClickUp API JSON opens a file chooser for .json', await pick('clickup-json') === '.json');

  // A workspace export, exported from GMT+5:30: a date with no time is 04:00 there, which is
  // the evening before in UTC, so a reader that took the UTC day would be a day early.
  check('ClickUp CSV opens a file chooser for .csv', await pick('clickup-csv', 'clickup-workspace.csv') === '.csv');
  let plan = await exportPlan(I.url);
  check('the workspace export replaces the plan: a section per List, in the order they came', JSON.stringify(plan.phases.map(p => p.name)) === '["Sensor Board · EVT Build","Sensor Board · Firmware","Launch"]', plan.phases.map(p => p.name).join(' | '));
  check('20 rows, the old plan gone; the undated task is a one-day bar on the page\'s today, tagged undated',
    plan.items.length === 20 && !by(plan, 'Before import').name && by(plan, 'Write the EVT report').start === TODAY && by(plan, 'Write the EVT report').end === TODAY
    && JSON.stringify(by(plan, 'Write the EVT report').tags) === '["undated"]', JSON.stringify([plan.items.length, by(plan, 'Write the EVT report')]));
  const evt = plan.items.filter(r => r.phase === 0).map(r => r.name);
  check('ClickUp\'s order, each subtask right after its parent (the file lists one before it)', JSON.stringify(evt.slice(2, 6)) === '["Lay out the board","Place components","Route power planes","Check return paths"]', evt.join(' | '));
  check('and each subtask names its parent row (the subtasks model)', by(plan, 'Place components').parent === by(plan, 'Lay out the board').uid && by(plan, 'Check return paths').parent === by(plan, 'Route power planes').uid && !by(plan, 'Lay out the board').parent);
  const lt = by(plan, 'Order long-lead parts');
  check('owner is the first assignee, the others are in the notes; status, effort and tags come across',
    lt.owner === 'Owen Price' && /Also assigned in ClickUp: Raj Patel\./.test(lt.description || '') && lt.status === 'in-progress' && lt.effort === 4 && JSON.stringify(lt.tags) === '["long-lead","purchasing"]', JSON.stringify(lt));
  check('a task with only a due date starts on it', lt.start === '2026-10-16' && lt.end === '2026-10-16', `${lt.start} ${lt.end}`);
  check('dates are the exporter\'s days, not UTC\'s', by(plan, 'Finalize schematic').start === '2026-10-05' && by(plan, 'Finalize schematic').end === '2026-10-09', JSON.stringify(by(plan, 'Finalize schematic')));
  check('a parent with no dates spans its subtasks', by(plan, 'Bring-up and test').start === '2026-11-16' && by(plan, 'Bring-up and test').end === '2026-11-20');
  check('a ClickUp milestone is a milestone on its due date', by(plan, 'EVT complete').type === 'milestone' && by(plan, 'EVT complete').date === '2026-11-20');
  check('each row links back to its ClickUp task', JSON.stringify(by(plan, 'Bootloader').provenance) === '{"label":"ClickUp 86b4p5h2p","url":"https://app.clickup.com/t/86b4p5h2p"}');
  let card = await cardText();
  check('after the reload the import report is on screen', !!card);
  check('it says what changed on the way in', /5 subtasks keep their parent task/.test(card) && /4 tasks have a due date and no start date/.test(card), card.slice(0, 300));
  check('it says what did not come across, priorities and dependencies included', /Priority on 6 tasks: urgent 1, high 3, normal 1, low 1/.test(card) && /CSV exports do not include them/.test(card), card);
  check('it shows how each ClickUp status was read, the guess marked', /spec\s*→\s*in progress \(1, a guess\)/.test(card) && /waiting on parts\s*→\s*blocked/.test(card), card);
  check('it counts the undated row, on the day it was placed', /1 undated/.test(card) && /1 task has no start or due date in ClickUp, so it is a one-day bar on 29-Sep-2026, tagged undated/.test(card), card.slice(0, 400));
  check('and says, quietly, that the importer has not been checked against a real ClickUp project', /Not yet checked against a real ClickUp project\. Compare this plan with ClickUp before relying on it\./.test(card), card.slice(0, 400));
  check('and the chart shows the imported rows', await ip.evaluate(() => [...document.querySelectorAll('.label-row')].some(e => e.textContent.includes('Route power planes'))));
  await shot(ip, 'clickup-report');
  await ip.click('#irClose'); await settle(ip, 100);
  check('the x closes the report', !(await ip.$eval('#importReport', b => window.__uiVis(b))));
  await ip.reload({ waitUntil: 'load' }); await ready(ip); await settle(ip, 300);
  check('a reload does not bring it back', !(await ip.$eval('#importReport', b => window.__uiVis(b))) && !(await cardText()));

  // The API's JSON: ClickUp's own order (orderindex), and the dependencies a CSV cannot carry.
  await pick('clickup-json', 'clickup-tasks.json');
  plan = await exportPlan(I.url);
  const bu = plan.items.filter(r => r.phase === 0).map(r => r.name);
  check('the API JSON lands in ClickUp\'s order, by orderindex, subtasks by theirs', bu[0] === 'Level and anchor the machine' && bu.indexOf('Teach fiducials') === bu.indexOf('Load the first program') + 1, bu.join(' | '));
  check('dependencies come across as deps, to and from a milestone too',
    JSON.stringify(by(plan, 'Install the feeder bank').deps) === '["cu-86c3a0002"]' && JSON.stringify(by(plan, 'Load the first program').deps) === '["cu-86c3a0001"]'
    && JSON.stringify(by(plan, 'First panel off the line').deps) === '["cu-86c3a0003"]' && JSON.stringify(by(plan, 'Run the 50-panel qualification').deps) === '["cu-86c3a0006"]', JSON.stringify(plan.items.map(r => [r.name, r.deps])));
  check('the chart draws them, into and out of the milestone', await ip.evaluate(() => ['cu-86c3a0002>cu-86c3a0001', 'cu-86c3a0001>cu-86c3a0003', 'cu-86c3a0003>cu-86c3a0006', 'cu-86c3a0006>cu-86c3a0007']
    .every(e => { const [f, t] = e.split('>'); return !!document.querySelector(`.dep-svg path[data-from="${f}"][data-to="${t}"]`); })));
  check('a date set in Sydney with no time keeps its Sydney day', by(plan, 'Supplier visit from Sydney').start === '2026-11-23' && by(plan, 'Supplier visit from Sydney').end === '2026-11-24');
  card = await cardText();
  check('its report names the link that had nowhere to go', /Dependencies on tasks that did not come across on 1 task/.test(card) && !/milestones on/.test(card) && !/CSV exports do not include them/.test(card), card);
  // A phone's width (the viewport only: switching to mobile emulation would reload the page,
  // and the report is shown once).
  await ip.setViewport({ width: 393, height: 852, deviceScaleFactor: 1 }); await settle(ip, 200);
  const fit = await ip.evaluate(() => { const b = document.getElementById('importReport').getBoundingClientRect(), body = document.querySelector('#importReport .ir-body');
    return { l: Math.round(b.left), r: Math.round(b.right), w: innerWidth, h: Math.round(b.height), vh: innerHeight, sw: body.scrollWidth, cw: body.clientWidth, x: document.getElementById('irClose').getBoundingClientRect().right }; });
  check('at 393px the report fits the screen, its x on it, nothing in it running sideways', fit.l >= 0 && fit.r <= fit.w && fit.h <= fit.vh && fit.sw <= fit.cw + 1 && fit.x <= fit.w, JSON.stringify(fit));
  await shot(ip, 'clickup-report-393');
  await ip.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); await settle(ip, 200);
  await ip.click('#irClose'); await settle(ip, 100);

  // The wrong file for the format: refused with the reason, and the plan is left alone.
  const before = JSON.stringify(await exportPlan(I.url));
  const dialogs = [];
  const onDialog = d => { dialogs.push(d.message()); d.accept().catch(() => {}); };
  ip.on('dialog', onDialog);
  const wrong = join(TMP, 'not-clickup.csv'); writeFileSync(wrong, 'name,start,end\nA,2026-10-05,2026-10-09\n');
  await ip.click('#btnImport'); await settle(ip, 100);
  const [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }), ip.click('#importMenu .dd-item[data-fmt="clickup-csv"]')]);
  await ch.accept([wrong]);
  for (let i = 0; i < 30 && !dialogs.length; i++) await sleep(100);
  ip.off('dialog', onDialog);
  check('a file that is not a ClickUp export is refused, saying what ClickUp CSV expects', dialogs.length === 1 && /does not look like a ClickUp export/.test(dialogs[0]), dialogs.join(' | '));
  check('and the plan is left as it was', JSON.stringify(await exportPlan(I.url)) === before);
  await ip.close();
}

// The Setup lens (0.58.0). Until 0.57.1 this flow drove the header's Sections modal; the
// same operations now live on a page, so it drives the page and asserts the effects
// against the plan the SERVER saved.
//
// Hardened after the 0.58.0 review, which found three data bugs this flow could not see:
// its seed had rows in only the first two sections and no uids, it never reloaded after a
// structural change, and it never undid one. So now: a task in EVERY section and a
// milestone in two, no phase uids (like the reference launch plan), and after every
// structural step (add, reorder, remove, and each of their undos and redos) the WHOLE
// section list and EVERY row's section are compared with /export/json by name, and the
// page is checked to have repainted to match.
async function sectionsFlow(browser) {
  section = 'sections';
  const T = (id, name, ph) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph });
  const X = await startServer({ project: { title: 'Sections Test', appName: 'sections-test' },
    phases: [{ name: 'Alpha', start: '2026-09', end: '2026-12' }, { name: 'Beta', start: '2026-09', end: '2026-12' },
      { name: 'Gamma', start: '2026-09', end: '2026-12' }, { name: 'Delta', start: '2026-09', end: '2026-12' }],
    items: [T('a', 'Alpha work', 0), { type: 'milestone', name: 'Alpha gate', date: '2026-11-02', phase: 0 },
      T('b', 'Beta work', 1), T('g', 'Gamma work', 2), { type: 'milestone', name: 'Gamma gate', date: '2026-11-09', phase: 2 },
      T('d', 'Delta work', 3)] }, 'sections');
  let page = await openPage(browser, X.url);
  // Which section each row is in, by NAME, from a saved plan. Names are unique in this seed.
  const assign = pl => Object.fromEntries((pl.items || []).filter(r => r.type !== 'group').map(r => [r.name, pl.phases[r.phase]?.name ?? `#${r.phase}?`]));
  // The rows every section should hold, which only a remove may change.
  let HOME = { 'Alpha work': 'Alpha', 'Alpha gate': 'Alpha', 'Beta work': 'Beta', 'Gamma work': 'Gamma', 'Gamma gate': 'Gamma', 'Delta work': 'Delta' };
  const listNames = (pg = page) => pg.$$eval('#setupView .sec-row .sec-name', els => els.map(e => e.firstChild.textContent));
  const counts = (pg = page) => pg.$$eval('#setupView .sec-row .sec-count', els => els.map(e => parseInt(e.textContent, 10)));
  const want = (names, home) => names.map(n => Object.values(home).filter(v => v === n).length);
  // The whole state, three ways: the saved section list, every row's saved section, and
  // the page (names and per-section counts) repainted to the same.
  const expectState = async (what, names, home = HOME) => {
    const pl = await savedPlan(X.url);
    const got = pl.phases.map(p => p.name), a = assign(pl);
    const bad = Object.keys(home).filter(k => a[k] !== home[k]).map(k => `${k}: ${a[k]} (want ${home[k]})`);
    check(`${what}: sections saved in order ${names.join(',')}`, JSON.stringify(got) === JSON.stringify(names), JSON.stringify(got));
    check(`${what}: every row is saved in its own section`, bad.length === 0 && Object.keys(a).length === Object.keys(home).length, bad.join('; ') || JSON.stringify(a));
    const ln = await listNames(), lc = await counts();
    check(`${what}: the page shows the same list and counts`, JSON.stringify(ln) === JSON.stringify(names) && JSON.stringify(lc) === JSON.stringify(want(names, home)), `${JSON.stringify(ln)} ${JSON.stringify(lc)}`);
  };
  const idx = async name => (await listNames()).indexOf(name);
  const rowBtn = async (name, a) => page.click(`.sec-row[data-i="${await idx(name)}"] .sec-btn[data-a="${a}"]`);
  // 0.61.0: the arrows and Edit are gone. A row moves by a drag (real mouse events, from its
  // grip) or by Alt+Up/Down with the row focused; a name or an aside is edited by a click.
  const rowGrip = async (name, pg = page) => pg.$eval(`.sec-row[data-i="${(await listNames(pg)).indexOf(name)}"] .sec-grip`, e => { e.scrollIntoView({ block: 'center' }); const b = e.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; });
  const dragTo = async (name, to, { pg = page, probe = false } = {}) => {
    const from = (await listNames(pg)).indexOf(name);
    const gp = await rowGrip(name, pg);
    const tb = await pg.$eval(`.sec-row[data-i="${to}"]`, r => { const b = r.getBoundingClientRect(); return { top: b.top, bottom: b.bottom }; });
    const y = to < from ? tb.top + 4 : to > from ? tb.bottom - 4 : (tb.top + tb.bottom) / 2;
    await pg.mouse.move(gp.x, gp.y); await pg.mouse.down();
    for (let k = 1; k <= 10; k++) { await pg.mouse.move(gp.x + k, gp.y + (y - gp.y) * k / 10); await sleep(15); }
    const seen = probe ? await pg.evaluate(() => { const d = document.querySelector('.sec-list .sec-drop'); return !!d && getComputedStyle(d).display !== 'none' && !!document.querySelector('.sec-row.sec-dragging'); }) : null;
    await pg.mouse.up(); await settle(pg, 250);
    return seen;
  };
  const altMove = async (name, key, pg = page) => { await pg.focus(`.sec-row[data-i="${(await listNames(pg)).indexOf(name)}"]`); await pg.keyboard.down('Alt'); await pg.keyboard.press(key); await pg.keyboard.up('Alt'); await settle(pg, 200); };
  const fieldClick = async (name, f, pg = page) => { await pg.click(`.sec-row[data-i="${(await listNames(pg)).indexOf(name)}"] ${f === 'name' ? '.sec-nm-t' : '.sec-ld-t'}`); await settle(pg, 120); };
  const redo = async (pg = page) => { await pg.keyboard.down('Control'); await pg.keyboard.down('Shift'); await pg.keyboard.press('KeyZ'); await pg.keyboard.up('Shift'); await pg.keyboard.up('Control'); await settle(pg, 250); };
  const axisText = pg => pg.evaluate(() => [...document.querySelectorAll('.q-row > *, .m-row > *')].map(c => c.textContent).join(' '));

  check('the header has no Sections button any more', !(await page.$('#btnSections')));
  await view(page, 'setup');
  check('no "tags from name prefixes" button on a plan whose names have no " · "', !(await page.$('#tagPrefixBtn')) && await page.$eval('#tagPrefix', e => !window.__uiVis(e)));
  check('Setup is a page: the list is on screen and no modal is open', await page.$eval('#setupView', e => window.__uiVis(e)) && !(await page.$('.sec-back')));
  check('Setup hides the chart and the legend, as Analytics does', await page.evaluate(() => !window.__uiVis(document.querySelector('.chart-wrap')) && !window.__uiVis(document.querySelector('.legend'))));
  const rowInfo = await page.$eval('.sec-row[data-i="0"]', r => ({ n: r.querySelector('.sec-count').textContent, d: r.querySelector('.sec-dates').textContent }));
  check('each row shows its row count and the dates its rows span', rowInfo.n === '2 rows' && rowInfo.d === '05-Oct-2026 to 02-Nov-2026', JSON.stringify(rowInfo));
  let N = ['Alpha', 'Beta', 'Gamma', 'Delta'];
  await expectState('on load', N);

  // ── Add, and its guards ──
  await page.type('#secNew', 'Echo'); await page.click('#secAdd'); await settle(page, 300);
  N = [...N, 'Echo'];
  await expectState('Add section appends it', N);
  check('a section with no rows says it has no dates', await page.$eval(`.sec-row[data-i="4"] .sec-dates`, e => e.textContent) === 'no dates');
  // D-3: Enter pressed five times while the id is being fetched makes ONE section.
  await page.type('#secNew', 'Foxtrot');
  await page.$eval('#secNew', e => { for (let i = 0; i < 5; i++) e.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
  await settle(page, 400);
  N = [...N, 'Foxtrot'];
  await expectState('Enter pressed five times at once adds one section', N);
  // D-2: a name another section already has (any case, any spacing) is refused, and says so.
  await page.type('#secNew', '  beta '); await page.click('#secAdd'); await settle(page, 300);
  check('Add refuses a name another section has, and says why', JSON.stringify(await listNames()) === JSON.stringify(N)
    && await page.$eval('#setupView .sec-err', e => window.__uiVis(e) && /already a section called/.test(e.textContent)));
  // Out of the box, so the next Ctrl+Z is the page's and not the text field's own undo.
  await page.$eval('#secNew', e => { e.value = ''; e.dispatchEvent(new Event('input')); e.blur(); });
  // Undo and redo of an add.
  await undo(page);
  await expectState('Ctrl+Z takes the added section back', N.slice(0, 5));
  await redo();
  await expectState('redo adds it again', N);
  // V-B: a section made here has no dates of its own, and a reload must not read that as 1970.
  await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
  await view(page, 'gantt');
  let ax = await axisText(page);
  check('after adding sections and a reload the chart axis is the plan\'s own years, not 1970', ax.includes('2026') && !/19(69|70)/.test(ax), ax.slice(0, 120));
  await view(page, 'setup');

  // ── Reorder: drag a row (0.61.0), with real mouse events ──
  check('the up and down arrows and the Edit button are gone', !(await page.$('.sec-btn[data-a="up"], .sec-btn[data-a="down"], .sec-btn[data-a="edit"]')));
  check('every row has a grip and says how to move it, keys included', await page.$$eval('#setupView .sec-row', rs => rs.every(r => r.querySelector('.sec-grip') && /drag/i.test(r.title) && /Alt\+Up/.test(r.title))));
  const phaseReorders = async () => (await (await fetch(X.url + 'changelog')).json()).events.filter(e => e.action === 'phase.reorder');
  let nr0 = (await phaseReorders()).length;
  const shown = await dragTo('Gamma', 0, { probe: true });
  check('mid-drag the row is lifted and a drop line shows where it will land', shown === true);
  await expectState('dragging row 3 to the top moves it there and its rows go with it', N = ['Gamma', 'Alpha', 'Beta', 'Delta', 'Echo', 'Foxtrot']);
  let RO = (await phaseReorders()).slice(nr0);
  check('the drop is ONE phase.reorder event, as the arrows logged', RO.length === 1 && RO[0].phase === 'Gamma' && RO[0].from.i === 2 && RO[0].to.i === 0 && RO[0].via === 'sections', JSON.stringify(RO));
  check('the dropped row is focused, so Alt+Up/Down can carry on', await page.evaluate(() => document.activeElement && document.activeElement.classList.contains('sec-row') && document.activeElement.dataset.i === '0'));
  await page.evaluate(() => document.activeElement.blur());
  await undo(page);
  await expectState('ONE Ctrl+Z puts the dragged section back', N = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Echo', 'Foxtrot']);
  await redo();
  await expectState('redo drags it to the top again', ['Gamma', 'Alpha', 'Beta', 'Delta', 'Echo', 'Foxtrot']);
  await undo(page);
  await expectState('(undone, for what follows)', N);
  // A press that moves a few pixels is a click, not a drag.
  nr0 = (await phaseReorders()).length;
  const g = await rowGrip('Beta');
  await page.mouse.move(g.x, g.y); await page.mouse.down(); await page.mouse.move(g.x + 2, g.y + 2); await page.mouse.up(); await settle(page, 250);
  check('a press that moves 2px is a click: nothing moves and nothing is logged', JSON.stringify(await listNames()) === JSON.stringify(N) && (await phaseReorders()).length === nr0 && !(await page.$('.sec-drop')));
  // Dropping a row where it already is changes nothing.
  await dragTo('Beta', 1);
  check('a drag dropped back where it started changes nothing', JSON.stringify(await listNames()) === JSON.stringify(N) && (await phaseReorders()).length === nr0);
  // The keyboard path.
  await altMove('Alpha', 'ArrowDown');
  await expectState('Alt+Down on a focused row moves it down one', N = ['Beta', 'Alpha', 'Gamma', 'Delta', 'Echo', 'Foxtrot']);
  check('and the focus stays on the moved row', await page.evaluate(() => document.activeElement?.querySelector('.sec-nm-t')?.textContent) === 'Alpha');
  await page.keyboard.down('Alt'); await page.keyboard.press('ArrowUp'); await page.keyboard.up('Alt'); await settle(page, 200);
  await expectState('Alt+Up moves it back', N = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Echo', 'Foxtrot']);
  await page.evaluate(() => document.activeElement.blur());
  // V-A: a populated section moved PAST sections added since the data file was written.
  // Its rows' saved indices are beyond the data file's list, and until the review a reload
  // checked them against that list, kept the old index and put them in another section.
  await dragTo('Delta', 5);
  await expectState('Delta dragged down past the two new sections', N = ['Alpha', 'Beta', 'Gamma', 'Echo', 'Foxtrot', 'Delta']);
  await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
  await expectState('after a reload every row is still in its own section', N);
  const snapHtml0 = await (await fetch(X.url + 'export/html')).text();

  // ── Click to edit: the name and the aside, in place (0.61.0) ──
  let gi = await idx('Gamma');
  const G = `.sec-row[data-i="${gi}"]`;
  await fieldClick('Gamma', 'name');
  check('clicking the name opens it in place, focused, with the text selected', await page.evaluate(s => { const i = document.querySelector(s + ' .sec-name input.sec-nm'); return !!i && document.activeElement === i && i.selectionStart === 0 && i.selectionEnd === i.value.length && i.value === 'Gamma'; }, G));
  await page.keyboard.type('Gamma renamed'); await page.keyboard.press('Enter'); await settle(page, 200);
  let pl = await savedPlan(X.url);
  check('Enter renames the section', pl.phases[gi].name === 'Gamma renamed' && !(await page.$('.sec-row.sec-editing')), JSON.stringify(pl.phases[gi]));
  HOME = Object.fromEntries(Object.entries(HOME).map(([k, v]) => [k, v === 'Gamma' ? 'Gamma renamed' : v]));
  N = N.map(n => n === 'Gamma' ? 'Gamma renamed' : n);
  await page.hover('#suSections h3'); await page.evaluate(() => document.activeElement && document.activeElement.blur()); await settle(page, 80);
  const aside = await page.$eval(`${G} .sec-ld-t`, e => ({ empty: e.classList.contains('empty'), t: e.textContent, o: getComputedStyle(e).opacity }));
  // 0.62.0: a section with no owner or aside says NO OWNER, dim, at rest (it used to be a
  // placeholder that only showed on hover), and brightens on hover.
  check('an empty aside reads NO OWNER, dim but visible without hovering', aside.empty && aside.t === 'NO OWNER' && +aside.o >= 0.5 && +aside.o < 1, JSON.stringify(aside));
  await page.hover(`${G} .sec-count`); await settle(page, 80);
  check('and shown on hover', await page.$eval(`${G} .sec-ld-t`, e => getComputedStyle(e).opacity) === '1');
  await fieldClick('Gamma renamed', 'lead');
  await page.keyboard.type('the long pole');
  await page.click('#suSections h3'); await settle(page, 250);
  pl = await savedPlan(X.url);
  check('clicking the aside edits it in place, and clicking away commits it', pl.phases[gi].lead === 'the long pole' && pl.phases[gi].name === 'Gamma renamed' && !(await page.$('.sec-row.sec-editing')), JSON.stringify(pl.phases[gi]));
  check('the page shows the new aside', await page.$eval(`${G} .sec-ld-t`, e => e.textContent) === 'the long pole');
  await undo(page);
  pl = await savedPlan(X.url);
  check('Ctrl+Z takes back the aside alone', pl.phases[gi].name === 'Gamma renamed' && !pl.phases[gi].lead, JSON.stringify(pl.phases[gi]));
  await undo(page);
  pl = await savedPlan(X.url);
  check('a second Ctrl+Z takes back the rename', pl.phases[gi].name === 'Gamma' && (await listNames())[gi] === 'Gamma', JSON.stringify(pl.phases[gi]));
  await redo(); await redo();
  pl = await savedPlan(X.url);
  check('two redos put both back', pl.phases[gi].name === 'Gamma renamed' && pl.phases[gi].lead === 'the long pole', JSON.stringify(pl.phases[gi]));
  // Escape cancels; a click away commits.
  await fieldClick('Gamma renamed', 'name'); await page.keyboard.type('Nope'); await page.keyboard.press('Escape'); await settle(page, 200);
  check('Escape cancels an edit in place', !(await page.$('.sec-row.sec-editing')) && (await listNames())[gi] === 'Gamma renamed' && (await savedPlan(X.url)).phases[gi].name === 'Gamma renamed');
  await fieldClick('Echo', 'name'); await page.keyboard.type('Echo blurred'); await page.click('#suSections h3'); await settle(page, 250);
  check('a click away commits a name', (await savedPlan(X.url)).phases[await idx('Echo blurred')]?.name === 'Echo blurred');
  N = N.map(n => n === 'Echo' ? 'Echo blurred' : n);
  // Refusals, with the existing message.
  await fieldClick('Gamma renamed', 'name'); await page.keyboard.type('ALPHA'); await page.keyboard.press('Enter'); await settle(page, 200);
  check('rename refuses a name another section has, says why, and stays open', await page.$eval(`${G} .sec-msg`, e => /already a section called/.test(e.textContent)).catch(() => false)
    && !!(await page.$(`${G} input.sec-nm`)) && (await savedPlan(X.url)).phases[gi].name === 'Gamma renamed');
  await page.click('#suSections h3'); await settle(page, 250);
  check('a click away does not commit a refused name either', (await savedPlan(X.url)).phases[gi].name === 'Gamma renamed' && await page.$eval(`${G} .sec-msg`, e => /already a section called/.test(e.textContent)).catch(() => false));
  await page.keyboard.press('Escape'); await settle(page, 150);
  check('Escape with nothing focused cancels the refused edit', !(await page.$('.sec-row.sec-editing')) && (await listNames())[gi] === 'Gamma renamed');
  await fieldClick('Gamma renamed', 'name'); await page.keyboard.press('Backspace'); await page.keyboard.press('Enter'); await settle(page, 200);
  check('an empty name is refused and says why', await page.$eval(`${G} .sec-msg`, e => /needs a name/.test(e.textContent)).catch(() => false) && (await savedPlan(X.url)).phases[gi].name === 'Gamma renamed');
  await page.keyboard.press('Escape'); await settle(page, 150);
  check('Escape in the name field cancels it', !(await page.$('.sec-row.sec-editing')) && (await listNames())[gi] === 'Gamma renamed');
  await rowBtn('Alpha', 'del'); await settle(page, 100);
  await page.focus('.sec-row.sec-editing .sec-go'); await page.keyboard.press('Escape'); await settle(page, 150);
  check('Escape with the focus on a row button cancels that row too', !(await page.$('.sec-row.sec-editing')));
  // Keyboard: Enter on a focused name opens it.
  await page.focus(`${G} .sec-nm-t`); await page.keyboard.press('Enter'); await settle(page, 100);
  check('Enter on a focused name opens it for editing', await page.evaluate(s => document.activeElement === document.querySelector(s + ' input.sec-nm'), G));
  await page.keyboard.press('Escape'); await settle(page, 100);

  // ── Colour ──
  await rowBtn('Alpha', 'del'); await page.click(`.sec-row[data-i="${gi}"] .sec-sw`); await settle(page, 100);
  check('opening a second row editor closes the first', await page.$$eval('.sec-row.sec-editing', e => e.length) === 1 && !!(await page.$('.sec-row.sec-editing .sec-pal')));
  const hex = await page.$eval(`.sec-row[data-i="${gi}"] .sec-chip[data-hex^="#"]`, b => { b.click(); return b.dataset.hex; }); await settle(page, 200);
  check('the colour swatch recolours the section', (await savedPlan(X.url)).phases[gi].color === hex);
  await page.click(`.sec-row[data-i="${gi}"] .sec-sw`); await settle(page, 100);
  await page.$eval(`.sec-row[data-i="${gi}"] .sec-hex`, e => { e.value = ''; });
  await page.type(`.sec-row[data-i="${gi}"] .sec-hex`, 'zzzzzz'); await page.keyboard.press('Enter'); await settle(page, 150);
  check('a colour that is not one is refused with a message saying why', await page.$eval(`.sec-row[data-i="${gi}"] .sec-msg`, e => /not a colour/.test(e.textContent)).catch(() => false)
    && (await savedPlan(X.url)).phases[gi].color === hex);
  await page.$eval(`.sec-row[data-i="${gi}"] .sec-hex`, e => { e.value = ''; });
  await page.type(`.sec-row[data-i="${gi}"] .sec-hex`, '#ABC'); await page.keyboard.press('Enter'); await settle(page, 200);
  check('#ABC is stored as #aabbcc', (await savedPlan(X.url)).phases[gi].color === '#aabbcc', (await exportPlan(X.url)).phases[gi].color);
  const swatchIs = h => page.$eval(`.sec-row[data-i="${gi}"] .sec-sw`, (e, h) => { const c = document.createElement('i'); c.style.color = h; document.body.appendChild(c); const w = getComputedStyle(c).color; c.remove(); return getComputedStyle(e).backgroundColor === w; }, h);
  await undo(page);
  check('Ctrl+Z puts the previous colour back, in the plan and on the page', (await savedPlan(X.url)).phases[gi].color === hex && await swatchIs(hex));
  await redo();
  check('redo puts #aabbcc back, in the plan and on the page', (await savedPlan(X.url)).phases[gi].color === '#aabbcc' && await swatchIs('#aabbcc'));
  pl = await savedPlan(X.url);
  check('the name and aside are untouched by the colour undo and redo', pl.phases[gi].name === 'Gamma renamed' && pl.phases[gi].lead === 'the long pole');

  // ── D-1: an open Remove form survives an undo and still means the section it was opened on ──
  await altMove('Beta', 'ArrowDown'); await page.evaluate(() => document.activeElement.blur());      // Alpha, Gamma renamed, Beta, ...
  await rowBtn('Beta', 'del'); await settle(page, 100);        // the form, focus on its select
  await undo(page);                                             // Beta goes back ABOVE Gamma renamed
  check('after Ctrl+Z the open Remove form is on Beta\'s new row', await page.$eval('.sec-row.sec-editing .sec-name', e => e.firstChild.textContent) === 'Beta');
  await page.click('.sec-row.sec-editing .sec-go'); await settle(page, 200);
  HOME = Object.fromEntries(Object.entries(HOME).map(([k, v]) => [k, v === 'Beta' ? 'Alpha' : v]));
  await expectState('and Move and remove removes Beta (into Alpha), not the section now in its old slot', N = ['Alpha', 'Gamma renamed', 'Echo blurred', 'Foxtrot', 'Delta']);
  await undo(page);
  const HOME1 = Object.fromEntries(Object.entries(HOME).map(([k, v]) => [k, k === 'Beta work' ? 'Beta' : v]));
  await expectState('Ctrl+Z of a remove puts the section and its rows back', ['Alpha', 'Beta', 'Gamma renamed', 'Echo blurred', 'Foxtrot', 'Delta'], HOME1);
  await redo();
  await expectState('redo removes it again', N);

  // ── E-1: remove a MIDDLE section into a LATER one, rows after it keep their sections ──
  await rowBtn('Gamma renamed', 'del'); await settle(page, 100);
  await page.$eval('.sec-row.sec-editing .sec-dest', s => { s.value = [...s.options].find(o => o.textContent === 'Delta').value; });
  await page.click('.sec-row.sec-editing .sec-go'); await settle(page, 200);
  HOME = Object.fromEntries(Object.entries(HOME).map(([k, v]) => [k, v === 'Gamma renamed' ? 'Delta' : v]));
  await expectState('Remove a middle section into a later one', N = ['Alpha', 'Echo blurred', 'Foxtrot', 'Delta']);
  await rowBtn('Echo blurred', 'del'); await settle(page, 100);
  await page.click('.sec-row.sec-editing .sec-no'); await settle(page, 100);
  check('Cancel on a remove leaves the section alone', JSON.stringify(await listNames()) === JSON.stringify(N));
  await rowBtn('Echo blurred', 'del'); await settle(page, 100);
  await page.click('.sec-row.sec-editing .sec-go'); await settle(page, 200);
  await expectState('Remove deletes an empty section', N = ['Alpha', 'Foxtrot', 'Delta']);

  const log = await (await fetch(X.url + 'changelog')).json();
  const acts = log.events.filter(e => e.via === 'sections').map(e => e.action);
  check('every section change is in the changelog, as it was from the modal', ['phase.create', 'phase.reorder', 'phase.rename', 'phase.lead', 'phase.color', 'phase.delete'].every(a => acts.includes(a)), JSON.stringify([...new Set(acts)]));

  // ── D-4: the row chords do nothing on Setup ──
  await view(page, 'table');
  await page.click('#tableView tbody tr[data-key] td.c-owner').catch(() => {});
  await page.evaluate(() => document.querySelector('#tableView tbody tr[data-key] td:first-child')?.click()); await settle(page, 150);
  await page.keyboard.press('Escape'); await settle(page, 100);
  await page.keyboard.down('Control'); await page.keyboard.press('KeyC'); await page.keyboard.up('Control');
  const before = JSON.stringify((await savedPlan(X.url)).items.map(r => r.name + '@' + r.phase));
  await view(page, 'setup');
  await page.keyboard.down('Alt'); await page.keyboard.press('ArrowDown'); await page.keyboard.up('Alt');
  await page.keyboard.down('Control'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control'); await settle(page, 300);
  check('Alt+Down and Ctrl+V on Setup move and paste nothing', JSON.stringify((await savedPlan(X.url)).items.map(r => r.name + '@' + r.phase)) === before);

  await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
  check('a refresh on Setup comes back to Setup', await page.evaluate(() => document.documentElement.getAttribute('data-view')) === 'setup' && JSON.stringify(await listNames()) === JSON.stringify(N));
  await page.close();

  // ── E-5: the snapshot (file://, no server) ──
  // Taken after the V-A reorder above. Every change is checked on the snapshot's own page:
  // the Setup list and counts, and the Table's Phase column, which is each row's section.
  const snapFile = join(TMP, 'sections-snapshot.html'); writeFileSync(snapFile, snapHtml0);
  const sp = await openPage(browser, 'file://' + snapFile);
  const snapPhaseCol = async () => { await view(sp, 'table'); const m = await sp.$$eval('#tableView tbody tr[data-key]', trs => trs.map(tr => [tr.querySelector('td.c-name')?.textContent.trim(), tr.querySelector('td.c-phase')?.textContent.trim()])); await view(sp, 'setup'); return m; };
  await view(sp, 'setup');
  let SN = ['Alpha', 'Beta', 'Gamma', 'Echo', 'Foxtrot', 'Delta'];
  check('the snapshot opens with the sections and their rows as saved', JSON.stringify(await listNames(sp)) === JSON.stringify(SN) && JSON.stringify(await counts(sp)) === '[2,1,2,0,0,1]', `${await listNames(sp)} ${await counts(sp)}`);
  await sp.type('#secNew', 'Offline'); await sp.click('#secAdd'); await settle(sp, 300);
  check('the snapshot adds a section with no server', JSON.stringify(await listNames(sp)) === JSON.stringify([...SN, 'Offline']));
  await fieldClick('Beta', 'name', sp); await sp.keyboard.type('Beta offline'); await sp.keyboard.press('Enter'); await settle(sp, 200);
  await dragTo('Beta offline', 0, { pg: sp });
  SN = ['Beta offline', 'Alpha', 'Gamma', 'Echo', 'Foxtrot', 'Delta', 'Offline'];
  check('the snapshot renames and reorders', JSON.stringify(await listNames(sp)) === JSON.stringify(SN) && JSON.stringify(await counts(sp)) === '[1,2,2,0,0,1,0]', `${await listNames(sp)} ${await counts(sp)}`);
  await sp.click('.sec-row[data-i="2"] .sec-btn[data-a="del"]'); await settle(sp, 100);
  await sp.$eval('.sec-row.sec-editing .sec-dest', s => { s.value = [...s.options].find(o => o.textContent === 'Delta').value; });
  await sp.click('.sec-row.sec-editing .sec-go'); await settle(sp, 200);
  const col = Object.fromEntries(await snapPhaseCol());
  check('the snapshot removes a middle section into a later one, and the table agrees', JSON.stringify(await listNames(sp)) === JSON.stringify(['Beta offline', 'Alpha', 'Echo', 'Foxtrot', 'Delta', 'Offline'])
    && col['Gamma work'] === 'Delta' && col['Beta work'] === 'Beta offline' && col['Alpha work'] === 'Alpha' && col['Delta work'] === 'Delta', JSON.stringify(col));
  await sp.close();

  // A plan authored to open on Setup does.
  const Y = await startServer({ project: { title: 'Opens on setup', view: { mode: 'setup' } },
    phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],
    items: [{ type: 'task', id: 'x', name: 'X', start: '2026-10-05', end: '2026-10-09', phase: 0, uid: '00001' }] }, 'opens-setup');
  const yp = await openPage(browser, Y.url);
  check('project.view.mode "setup" opens the plan on the Setup lens', await yp.evaluate(() => document.documentElement.getAttribute('data-view')) === 'setup' && await yp.$eval('#setupView', e => window.__uiVis(e)));
  await yp.close();
}

// ── Tags on sections (0.59.0) ──
// Every edit is asserted against the plan the server holds (/export/json after the save),
// then undone and redone; the changelog, a reload, the legend filter in both lenses, the
// fold, the snapshot, and the widths are checked on the same plan.
async function tagsFlow(browser) {
  section = 'tags';
  const T = (id, name, ph) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph });
  const PH = ['Launch · Gates', 'Launch · Party', 'Tech · Fab', 'Tech · Oven', 'Ops · Supply', 'Misc'];
  // A narrow label column and a long aside on the first two sections, so the header chips
  // have to win their room from the aside (0.59.0 review: they were the thing cut off).
  const LONG = 'what launched means, the scorecard, the rehearsals, the party and everything after it (Drew)';
  const X = await startServer({ project: { title: 'Tags Test', appName: 'tags-test', view: { mode: 'gantt', grain: 'week', labelWidth: 300 } },
    // "Misc" arrives with a messy list, to be normalised on the way in exactly as the server does.
    phases: PH.map((name, i) => ({ name, start: '2026-09', end: '2026-12', ...(i < 2 ? { lead: LONG } : {}), ...(i === 5 ? { tags: [' misc ', 'MISC', 'x'.repeat(40), 5, ''] } : {}) })),
    items: [T('g1', 'Gates one', 0), T('g2', 'Gates two', 0), { type: 'milestone', name: 'Gate', date: '2026-11-02', phase: 0 },
      T('p1', 'Party one', 1), T('p2', 'Party two', 1), T('f1', 'Fab one', 2), T('f2', 'Fab two', 2),
      T('o1', 'Oven one', 3), T('o2', 'Oven two', 3), T('s1', 'Supply one', 4), T('s2', 'Supply two', 4),
      T('m1', 'Misc one', 5), T('m2', 'Misc two', 5)] }, 'tags');
  const ROWS = [3, 2, 2, 2, 2, 2];      // rows per section, in PH order
  let page = await openPage(browser, X.url);
  const X32 = 'x'.repeat(32);
  const tagsOf = pl => Object.fromEntries(pl.phases.map(p => [p.name, p.tags || []]));
  const expectTags = async (what, want) => {
    const got = tagsOf(await savedPlan(X.url));
    const bad = Object.keys(want).filter(k => JSON.stringify(got[k]) !== JSON.stringify(want[k])).map(k => `${k}: ${JSON.stringify(got[k])} (want ${JSON.stringify(want[k])})`);
    check(`${what}: saved tags`, bad.length === 0, bad.join('; '));
    // and the Setup page shows the same chips on every section
    const shown = await page.$$eval('#setupView .sec-row', rows => Object.fromEntries(rows.map(r => [r.querySelector('.sec-name').firstChild.textContent, [...r.querySelectorAll('.sec-tag')].map(c => c.firstChild.textContent)])));
    const bad2 = Object.keys(want).filter(k => JSON.stringify(shown[k]) !== JSON.stringify(want[k])).map(k => `${k}: ${JSON.stringify(shown[k])}`);
    check(`${what}: the Setup page shows the same`, bad2.length === 0, bad2.join('; '));
  };
  const rowSel = async name => `.sec-row[data-i="${(await page.$$eval('#setupView .sec-row .sec-name', e => e.map(x => x.firstChild.textContent))).indexOf(name)}"]`;
  const redo = async () => { await page.keyboard.down('Control'); await page.keyboard.down('Shift'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Shift'); await page.keyboard.up('Control'); await settle(page, 250); };
  const blur = () => page.evaluate(() => document.activeElement && document.activeElement.blur());
  const log = async () => (await (await fetch(X.url + 'changelog')).json()).events.filter(e => e.action === 'phase.tags');
  const md = async () => (await fetch(X.url + 'changelog?format=md')).text();

  await view(page, 'setup');
  let W = Object.fromEntries(PH.map(n => [n, []])); W.Misc = ['misc', X32];
  await expectTags('on load, a messy data-file list is normalised (trim, case-blind de-dupe, 32 chars, non-strings dropped)', W);

  // ── The prefix button ──
  check('the prefix button is offered, naming the prefixes', /Launch, Tech, Ops/.test(await page.$eval('#tagPrefixBtn', b => b.textContent).catch(() => '')));
  const n0 = (await log()).length;
  await page.click('#tagPrefixBtn'); await settle(page, 300);
  W = { 'Launch · Gates': ['Launch'], 'Launch · Party': ['Launch'], 'Tech · Fab': ['Tech'], 'Tech · Oven': ['Tech'], 'Ops · Supply': ['Ops'], Misc: ['misc', X32] };
  await expectTags('the prefix button tags each section with its name prefix', W);
  check('it renamed nothing', JSON.stringify((await exportPlan(X.url)).phases.map(p => p.name)) === JSON.stringify(PH));
  check('the button is gone once there is nothing left to add', !(await page.$('#tagPrefixBtn')));
  let L = (await log()).slice(n0);
  check('the changelog has one phase.tags event per section, via setup', L.length === 5 && L.every(e => e.via === 'setup') && L[0].name === 'Launch · Gates' && L[0].phase === 0
    && JSON.stringify(L[0].from.tags) === '[]' && JSON.stringify(L[0].to.tags) === '["Launch"]', JSON.stringify(L.slice(0, 2)));
  check('in one batch (one save, consecutive revs)', L.length === 5 && L.every((e, i) => i === 0 || e.rev === L[i - 1].rev + 1));
  check('the markdown changelog reads it as a sentence', (await md()).includes('tagged section "Launch · Gates" with Launch (was none) (setup)'));
  await undo(page);
  await expectTags('ONE Ctrl+Z takes the whole prefix pass back', Object.fromEntries(PH.map(n => [n, n === 'Misc' ? ['misc', X32] : []])));
  check('and the button is offered again', !!(await page.$('#tagPrefixBtn')));
  await redo();
  await expectTags('redo puts it all back', W);
  L = (await log()).slice(n0);
  check('the undo and the redo are logged too', L.filter(e => e.via === 'undo').length === 5 && L.filter(e => e.via === 'redo').length === 5, JSON.stringify(L.map(e => e.via)));

  // ── Type to add, comma to add, x to remove ──
  let fab = await rowSel('Tech · Fab');
  await page.click(`${fab} .sec-tag-in`); await page.keyboard.type('long-lead'); await page.keyboard.press('Enter'); await settle(page, 200);
  W['Tech · Fab'] = ['Tech', 'long-lead'];
  check('after Enter the box is empty and still has the focus, on the same section', await page.evaluate(s => { const a = document.activeElement; return a && a.classList.contains('sec-tag-in') && a.value === '' && a.closest('.sec-row') === document.querySelector(s); }, fab));
  await page.keyboard.type('TECH'); await page.keyboard.press('Enter'); await settle(page, 150);
  await page.keyboard.type('alpha, beta,'); await settle(page, 200);
  W['Tech · Fab'] = ['Tech', 'long-lead', 'alpha', 'beta'];
  await expectTags('Enter adds a tag, a comma adds one, and a tag the section has (any case) is not added twice', W);
  await page.click(`${fab} .sec-tag-x[data-tag="alpha"]`); await settle(page, 200);
  W['Tech · Fab'] = ['Tech', 'long-lead', 'beta'];
  await expectTags('the x on a chip removes that tag', W);
  await blur();
  await undo(page);
  await expectTags('Ctrl+Z puts it back', { ...W, 'Tech · Fab': ['Tech', 'long-lead', 'alpha', 'beta'] });
  await redo();
  await expectTags('redo removes it again', W);
  // Clicking away commits what was typed (0.59.0 review); Escape discards it.
  let ovenR = await rowSel('Tech · Oven');
  await page.click(`${ovenR} .sec-tag-in`); await page.keyboard.type('blurtag');
  await page.click('#suSections h3'); await settle(page, 250);
  await expectTags('clicking away commits a typed tag', { ...W, 'Tech · Oven': ['Tech', 'blurtag'] });
  ovenR = await rowSel('Tech · Oven');
  await page.click(`${ovenR} .sec-tag-in`); await page.keyboard.type('nope'); await page.keyboard.press('Escape');
  await page.click('#suSections h3'); await settle(page, 250);
  await expectTags('Escape discards it instead', { ...W, 'Tech · Oven': ['Tech', 'blurtag'] });
  await page.click(`${ovenR} .sec-tag-x[data-tag="blurtag"]`); await settle(page, 250);
  await expectTags('(and the x takes it off again)', W);
  check('the Tags card sits above the section list, where the prefix button is seen', await page.evaluate(() => document.getElementById('suTags').getBoundingClientRect().top < document.getElementById('suSections').getBoundingClientRect().top));
  check('the suggestions are the plan\'s tags', JSON.stringify(await page.$$eval('#tagList option', o => o.map(x => x.value))) === JSON.stringify(['Launch', 'Tech', 'long-lead', 'beta', 'Ops', 'misc', X32]));

  // ── The Tags card: counts, rename everywhere, remove everywhere ──
  const card = () => page.$$eval('#suTags .tag-row', rows => rows.map(r => r.querySelector('.tag-name').textContent + ':' + parseInt(r.querySelector('.tag-n').textContent, 10)));
  check('the Tags card lists every tag with how many sections carry it', JSON.stringify(await card()) === JSON.stringify(['Launch:2', 'Tech:2', 'long-lead:1', 'beta:1', 'Ops:1', 'misc:1', X32 + ':1']), JSON.stringify(await card()));
  await renameTag(page, 'tech', 'Engineering');
  W['Tech · Fab'] = ['Engineering', 'long-lead', 'beta']; W['Tech · Oven'] = ['Engineering'];
  await expectTags('Rename changes the tag on every section, in place', W);
  await undo(page);
  await expectTags('ONE Ctrl+Z takes the rename back on both', { ...W, 'Tech · Fab': ['Tech', 'long-lead', 'beta'], 'Tech · Oven': ['Tech'] });
  await redo();
  await expectTags('redo renames both again', W);
  await page.click('#suTags .tag-row[data-tag="beta"] .tag-btn[data-a="del"]'); await settle(page, 250);
  W['Tech · Fab'] = ['Engineering', 'long-lead'];
  await expectTags('Remove takes the tag off every section', W);
  // A rename onto a tag a section already has merges rather than duplicating.
  await renameTag(page, 'long-lead', 'engineering');
  W['Tech · Fab'] = ['Engineering'];
  await expectTags('a rename onto a tag the section already has merges into it', W);
  await blur(); await undo(page);
  W['Tech · Fab'] = ['Engineering', 'long-lead'];
  await expectTags('and one Ctrl+Z splits it again', W);
  // Escape closes a rename box whatever holds the focus.
  await page.click('#suTags .tag-row[data-tag="ops"] .tag-nm-t'); await settle(page, 100);
  check('clicking a tag\'s name opens it for renaming in place, text selected', await page.evaluate(() => { const i = document.querySelector('#suTags .tag-row[data-tag="ops"] .tag-nm'); return !!i && document.activeElement === i && i.selectionStart === 0 && i.selectionEnd === 3; }));
  await page.keyboard.type('Nope'); await page.keyboard.press('Escape'); await settle(page, 100);
  check('Escape in a tag\'s rename box cancels it', !(await page.$('#suTags .tag-nm')) && JSON.stringify(tagsOf(await savedPlan(X.url))) === JSON.stringify(W));
  await page.click('#suTags .tag-row[data-tag="ops"] .tag-nm-t'); await settle(page, 100);
  await page.keyboard.type('Operations'); await page.click('#suTags h3'); await settle(page, 300);
  await expectTags('a click away commits a tag rename', { ...W, 'Ops · Supply': ['Operations'] });
  await undo(page);
  await expectTags('(and Ctrl+Z takes it back)', W);

  // ── Coordinator item: Escape closes an open row editor, WHATEVER has the focus ──
  for (const [what, a, focus] of [['in another section\'s tag box', 'del', async () => page.focus(`${await rowSel('Misc')} .sec-tag-in`)],
    ['in the new-section box', 'del', async () => page.focus('#secNew')], ['nowhere', 'del', blur], ['on the swatch', 'sw', async () => page.focus(`${await rowSel('Tech · Oven')} .sec-sw`)]]) {
    const r = await rowSel('Tech · Oven');
    await page.click(a === 'sw' ? `${r} .sec-sw` : `${r} .sec-btn[data-a="${a}"]`); await settle(page, 100);
    await focus(); await page.keyboard.press('Escape'); await settle(page, 150);
    check(`Escape with the focus ${what} closes the open row editor (${a})`, !(await page.$('.sec-row.sec-editing')));
  }

  // ── Persistence ──
  await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
  await expectTags('after a reload', W);
  const savedEvents = (await log()).length;

  // ── The chart: header chips, the legend, the filter ──
  await view(page, 'gantt');
  const hdrTags = await page.$$eval('.label-row.group', g => g.map(x => [...x.querySelectorAll('.lane-tag')].map(t => t.textContent).join('+')));
  check('each section header shows its tags as chips', JSON.stringify(hdrTags) === JSON.stringify(['Launch', 'Launch', 'Engineering+long-lead', 'Engineering', 'Ops', 'misc+' + X32]), JSON.stringify(hdrTags));
  const clip = await page.evaluate(() => { const col = document.getElementById('labels').getBoundingClientRect();
    return [...document.querySelectorAll('.label-row.group')].flatMap(g => { const nm = g.querySelector('.lane-name').getBoundingClientRect();
      return [...g.querySelectorAll('.lane-tag')].map(t => ({ t: t.textContent, r: t.getBoundingClientRect() })).filter(x => x.r.right > Math.min(nm.right, col.right) + 0.5 || x.r.left < col.left || x.r.width < 8)
        .map(x => `${g.querySelector('.lane-title').textContent}: ${x.t.slice(0, 12)} @${Math.round(x.r.left)}..${Math.round(x.r.right)} (name ends ${Math.round(nm.right)})`); }); });
  check('every header\'s tag chips are whole and inside the label column, even after a long aside', clip.length === 0, clip.join(' | '));
  check('the long aside gave way instead (it is cut)', await page.evaluate(() => { const l = document.querySelector('.label-row.group .lane-lead'); return !!l && l.scrollWidth > l.clientWidth; }));
  check('the header chips carry no section colour', await page.evaluate(() => { const t = document.querySelector('.lane-tag'); const cs = getComputedStyle(t); return cs.backgroundColor === 'rgba(0, 0, 0, 0)' || cs.backgroundColor === 'transparent'; }));
  const vis = () => page.evaluate(() => ({
    rows: [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.checkVisibility()).length,
    heads: [...document.querySelectorAll('.label-row.group')].filter(e => e.checkVisibility()).map(e => e.querySelector('.lane-name').firstChild.textContent),
    chips: [...document.querySelectorAll('#legend .phase-chip')].map(c => c.textContent.trim()),
    tagChips: [...document.querySelectorAll('#legend .tag-chip')].map(c => c.dataset.tag + (c.classList.contains('on') ? '*' : '')),
    fold: (e => e && e.isConnected ? e.textContent.trim() : null)(document.getElementById('legendSecs')),
    clear: !!document.getElementById('tagClear')?.isConnected,
  }));
  const tableCount = async () => { await view(page, 'table'); const n = await page.$$eval('#tableView tbody tr[data-key]', e => e.length); await view(page, 'gantt'); return n; };
  let v = await vis();
  check('the legend leads with a chip per tag, in plan order', JSON.stringify(v.tagChips) === JSON.stringify(['launch', 'engineering', 'long-lead', 'ops', 'misc', X32.toLowerCase()]), JSON.stringify(v.tagChips));
  check('with tags in the plan the section chips fold behind one "6 sections" chip', v.chips.length === 0 && v.fold === '6 sections', JSON.stringify(v));
  // The chart shows 12 label rows for 13 items: in line style a milestone's own grid row is
  // hidden, its dashed rule carries it. The table lists all 13.
  check('with no tag chosen every section and row is shown (12 chart rows, 13 table rows)', v.rows === 12 && v.heads.length === 6 && await tableCount() === 13, JSON.stringify(v));
  await page.click('#legendSecs'); await settle(page, 150);
  v = await vis();
  check('the fold chip opens all six section chips', v.chips.length === 6 && v.fold === 'Fewer');
  await page.click('#legendSecs'); await settle(page, 150);
  await page.click('.tag-chip[data-tag="engineering"]'); await settle(page, 200);
  v = await vis();
  check('choosing a tag shows only its sections in the chart (4 rows, 2 headers)', v.rows === 4 && JSON.stringify(v.heads) === JSON.stringify(['Tech · Fab', 'Tech · Oven']), JSON.stringify(v));
  check('and only their section chips come back, in front of the rest of the legend', JSON.stringify(v.chips) === JSON.stringify(['Tech · Fab', 'Tech · Oven']) && v.fold === null && v.clear, JSON.stringify(v));
  check('and the table agrees (4 rows)', await tableCount() === 4);
  await page.click('.tag-chip[data-tag="launch"]'); await settle(page, 200);
  v = await vis();
  check('a second tag shows the sections carrying EITHER (8 chart rows, 9 table rows)', v.rows === 8 && v.heads.length === 4 && await tableCount() === 9, JSON.stringify(v));
  check('behind a chosen tag that is its prefix, a section chip drops the repeated prefix', JSON.stringify(v.chips) === JSON.stringify(['Gates', 'Party', 'Tech · Fab', 'Tech · Oven']), JSON.stringify(v.chips));
  // composes with a section chip ...
  await page.click('#legend .phase-chip'); await settle(page, 200);
  const dimmed = await page.$$eval('.label-row.dim:not(.group)', e => e.filter(x => x.checkVisibility()).length);
  check('a section chip still switches its section off inside the filter (2 chart rows dim, 6 left in the table)', dimmed === 2 && await tableCount() === 6, `${dimmed}`);
  await page.click('#legend .phase-chip'); await settle(page, 200);
  // ... and with Collapse all
  await page.click('#btnCollapseAll'); await settle(page, 200);
  v = await vis();
  check('Collapse all folds the filtered sections to their headers', v.rows === 0 && v.heads.length === 4 && await page.$eval('#btnCollapseAll', b => b.textContent.trim()) === 'Expand all', JSON.stringify(v));
  await page.click('#btnCollapseAll'); await settle(page, 200);
  check('Expand all brings back the filtered rows only', (await vis()).rows === 8);
  await page.click('#btnCollapseAll'); await settle(page, 200);
  await page.click('.tag-chip[data-tag="launch"]'); await settle(page, 200);
  check('a filtered Collapse all folded only the sections it showed', await page.evaluate(() => [...document.querySelectorAll('.lane-toggle.collapsed')].filter(e => e.checkVisibility()).length) === 2
    && (await vis()).rows === 0);
  await page.click('#btnCollapseAll'); await settle(page, 200);         // Expand all: the two Tech lanes
  await page.click('.tag-chip[data-tag="launch"]'); await settle(page, 200);
  check('and a filtered Expand all opened only the sections it showed (Launch still folded)', (await vis()).rows === 4, JSON.stringify(await vis()));
  await page.click('#btnCollapseAll'); await settle(page, 200);         // mixed, so Collapse all
  await page.click('#btnCollapseAll'); await settle(page, 200);         // then Expand all
  check('Collapse all then Expand all opens every shown section', (await vis()).rows === 8, JSON.stringify(await vis()));
  const flt = async () => page.evaluate(() => JSON.parse(localStorage.getItem('adom-gantt:prefs') || '{}').tagFilter);
  check('the filter is a view pref, not the plan', JSON.stringify(await flt()) === '["Engineering","Launch"]' && (await log()).length === savedEvents
    && JSON.stringify(tagsOf(await exportPlan(X.url))) === JSON.stringify(W), JSON.stringify(await flt()));
  await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
  v = await vis();
  check('the filter survives a reload', JSON.stringify(v.tagChips.filter(t => t.endsWith('*'))) === '["launch*","engineering*"]' && v.rows === 8 && await tableCount() === 9, JSON.stringify(v));
  await shot(page, 'tags-1440-filtered');
  await assertNoSidewaysScroll(page, '1440, legend with tag chips and a filter');

  // The filter follows a rename, and the rename's Ctrl+Z takes the filter back with it.
  await view(page, 'setup');
  await renameTag(page, 'launch', 'Go');
  await view(page, 'gantt');
  v = await vis();
  check('renaming a chosen tag keeps it chosen under its new name', JSON.stringify(await flt()) === '["Engineering","Go"]' && v.tagChips.includes('go*') && v.rows === 8, JSON.stringify({ f: await flt(), v }));
  await undo(page);
  v = await vis();
  check('Ctrl+Z of the rename puts the filter back on the old name', JSON.stringify(await flt()) === '["Engineering","Launch"]' && v.tagChips.includes('launch*') && v.rows === 8, JSON.stringify({ f: await flt(), v }));
  await redo();
  check('redo moves it on again', JSON.stringify(await flt()) === '["Engineering","Go"]' && (await vis()).rows === 8, JSON.stringify(await flt()));
  await undo(page);
  check('(undone, for what follows)', JSON.stringify(await flt()) === '["Engineering","Launch"]' && JSON.stringify(tagsOf(await savedPlan(X.url))) === JSON.stringify(W));

  // A tag that stops existing leaves the filter.
  await view(page, 'setup');
  await page.click('#suTags .tag-row[data-tag="launch"] .tag-btn[data-a="del"]'); await settle(page, 250);
  W['Launch · Gates'] = []; W['Launch · Party'] = [];
  await expectTags('Remove everywhere', W);
  await view(page, 'gantt');
  v = await vis();
  check('a tag removed from the plan drops out of the filter', JSON.stringify(await flt()) === '["Engineering"]' && v.rows === 4 && !v.tagChips.some(t => t.startsWith('launch')), JSON.stringify(v));
  await page.click('#tagClear'); await settle(page, 200);
  v = await vis();
  check('Clear tags shows every section again', v.rows === 12 && JSON.stringify(await flt()) === '[]');
  // The toast (coordinator item): with the pointer resting where the toast appears, Ctrl+Z's
  // toast still goes away on its own.
  await page.mouse.move(720, 872);
  await undo(page);
  await page.waitForFunction(() => document.querySelector('#undoToast.show'), { timeout: 2000 }).catch(() => {});
  const gone = await page.waitForFunction(() => !document.querySelector('#undoToast.show'), { timeout: 6000 }).then(() => true, () => false);
  check('an undo toast under a pointer that never moves still goes away', gone);
  await page.mouse.move(700, 120);
  W['Launch · Gates'] = ['Launch']; W['Launch · Party'] = ['Launch'];
  await view(page, 'setup'); await expectTags('(that undo put Launch back)', W); await view(page, 'gantt');

  // ── The tag registry (0.61.0): New tag, colours, persistence ──
  await view(page, 'setup');
  const reg = async () => (await savedPlan(X.url)).project.tags || [];
  const regOf = async name => (await reg()).find(t => t.name === name);
  // Since 0.62.0 a registry edit lists every tag the plan has, so the order can be dragged.
  // What a tag edit MEANS is in the entries that say something: a colour, or a tag that
  // nothing carries. The plain entries of carried tags are bookkeeping.
  const regOwn = async () => { const p = await savedPlan(X.url); const car = new Set();
    (p.phases || []).forEach(ph => (ph.tags || []).forEach(t => car.add(t.toLowerCase())));
    (p.items || []).forEach(r => (r.tags || []).forEach(t => car.add(t.toLowerCase())));
    return (p.project.tags || []).filter(t => t.color || !car.has(t.name.toLowerCase())); };
  const regLog = async () => (await (await fetch(X.url + 'changelog')).json()).events.filter(e => /^tag\./.test(e.action));
  check('a plan that never used the registry exports no project.tags key', !('tags' in (await exportPlan(X.url)).project));
  const rl0 = (await regLog()).length;
  await page.click('#tagNew'); await page.keyboard.type('someday'); await page.keyboard.press('Enter'); await settle(page, 300);
  check('New tag (Enter) adds a tag nothing carries yet, listed as "0 sections"', await page.$eval('#suTags .tag-row[data-tag="someday"] .tag-n', e => e.textContent).catch(() => '') === '0 sections');
  check('it is saved in project.tags', JSON.stringify(await regOwn()) === '[{"name":"someday"}]', JSON.stringify(await reg()));
  check('it is offered as a suggestion wherever tags are typed', (await page.$$eval('#tagList option', o => o.map(x => x.value))).includes('someday'));
  check('the box is empty and keeps the focus for the next one', await page.evaluate(() => document.activeElement.id === 'tagNew' && document.activeElement.value === ''));
  await page.keyboard.type('ENGINEERING'); await page.keyboard.press('Enter'); await settle(page, 200);
  check('New tag refuses a tag the plan already has (any case), and says why', /already a tag called/.test(await page.$eval('#setupView .tag-err', e => window.__uiVis(e) ? e.textContent : ''))
    && JSON.stringify(await regOwn()) === '[{"name":"someday"}]');
  await page.$eval('#tagNew', e => { e.value = ''; e.dispatchEvent(new Event('input')); e.blur(); });
  await view(page, 'gantt');
  check('an unused tag gets no legend chip (it would filter to nothing)', !(await page.$('.tag-chip[data-tag="someday"]')) && !!(await page.$('.tag-chip[data-tag="engineering"]')));
  await view(page, 'setup');
  await undo(page);
  check('Ctrl+Z takes the new tag back', !(await page.$('#suTags .tag-row[data-tag="someday"]')) && (await reg()).length === 0);
  check('and an emptied registry exports no project.tags key', !('tags' in (await exportPlan(X.url)).project));
  await redo();
  check('redo makes it again', !!(await page.$('#suTags .tag-row[data-tag="someday"]')) && JSON.stringify(await regOwn()) === '[{"name":"someday"}]');
  // Colour from the palette.
  await page.click('#suTags .tag-row[data-tag="engineering"] .tag-sw'); await settle(page, 150);
  const PAL = ['#00b8b1', '#8c6bf7', '#64abff', '#ff8a7a', '#e8b64a', '#5fd7a3', '#ec7ab8', '#6f7cff', '#8b9bb4'];
  check('the swatch opens a palette: the brand colours first, six more, a hex box and No colour', JSON.stringify(await page.$$eval('#suTags .tag-pal .tag-pc', b => b.map(x => x.dataset.hex))) === JSON.stringify(PAL)
    && !!(await page.$('#suTags .tag-pal .tag-hex')) && !!(await page.$('#suTags .tag-pal .tag-btn[data-a="nocolor"]')));
  await page.click('#suTags .tag-pal .tag-pc[data-hex="#8c6bf7"]'); await settle(page, 300);
  check('a palette colour is saved on the tag', (await regOf('Engineering'))?.color === '#8c6bf7', JSON.stringify(await reg()));
  check('the palette closes and the swatch shows the colour', !(await page.$('#suTags .tag-pal')) && await page.$eval('#suTags .tag-row[data-tag="engineering"] .tag-sw', e => getComputedStyle(e).backgroundColor) === 'rgb(140, 107, 247)');
  // Colour from the hex box, and its refusal.
  await page.click('#suTags .tag-row[data-tag="ops"] .tag-sw'); await settle(page, 150);
  await page.keyboard.type('zzz'); await page.keyboard.press('Enter'); await settle(page, 150);
  check('a hex that is not a colour is refused, and says why', /not a colour/.test(await page.$eval('#suTags .tag-pal .sec-msg', e => e.textContent).catch(() => '')) && !((await regOf('Ops')) || {}).color);
  await page.$eval('#suTags .tag-hex', e => { e.value = ''; }); await page.type('#suTags .tag-hex', 'E8B'); await page.keyboard.press('Enter'); await settle(page, 300);
  check('a hex colour is saved, as lowercase #rrggbb', (await regOf('Ops'))?.color === '#ee88bb', JSON.stringify(await reg()));
  await page.click('#suTags .tag-row[data-tag="ops"] .tag-sw'); await settle(page, 150);
  await page.keyboard.press('Escape'); await settle(page, 100);
  check('Escape closes a tag\'s palette', !(await page.$('#suTags .tag-pal')));
  await page.click('#suTags .tag-row[data-tag="ops"] .tag-sw'); await settle(page, 150);
  await page.click('#suTags .tag-pal .tag-btn[data-a="nocolor"]'); await settle(page, 300);
  check('No colour takes it off', (await regOf('Ops')) && !(await regOf('Ops')).color);
  await undo(page);
  check('Ctrl+Z puts the colour back', (await regOf('Ops'))?.color === '#ee88bb');
  // Drawn wherever the tag is a chip or a pill: computed styles, both themes.
  await page.click('.vt-btn[data-view="gantt"]'); await settle(page, 250);
  const paint = sel => page.$eval(sel, e => { const cs = getComputedStyle(e); const rgb = s => s.match(/[\d.]+/g).slice(0, 3).map(Number);
    const lum = t => { const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; return 0.2126 * f(t[0]) + 0.7152 * f(t[1]) + 0.0722 * f(t[2]); };
    const bg = rgb(getComputedStyle(document.body).backgroundColor), ink = rgb(cs.color);
    const a = lum(ink), b = lum(bg); return { tc: e.dataset.tc, color: cs.color, fill: cs.backgroundColor, border: cs.borderTopColor, ratio: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) }; });
  await page.evaluate(() => { const c = document.getElementById('tagClear'); if (c && c.isConnected) c.click(); }); await settle(page, 150);
  let lc = await paint('.tag-chip[data-tag="engineering"]');
  check('the legend chip takes the colour: a low-alpha fill of it and an ink that reads (3:1) on the dark page', lc.tc === '#8c6bf7' && lc.fill === 'rgba(140, 107, 247, 0.14)' && lc.ratio >= 3, JSON.stringify(lc));
  const plain = await paint('.tag-chip[data-tag="misc"]');
  check('a tag with no colour looks as it always did', !plain.tc && plain.fill === 'rgba(0, 0, 0, 0)', JSON.stringify(plain));
  check('the section header chips take it too', await page.$$eval('.lane-tag', e => e.filter(x => x.textContent === 'Engineering').every(x => x.dataset.tc === '#8c6bf7' && getComputedStyle(x).backgroundColor === 'rgba(140, 107, 247, 0.14)')));
  await view(page, 'table');
  const tp = await paint('#tableView tbody .row-tag[data-tc="#8c6bf7"]').catch(() => null);
  check('and the Table\'s Tags pills (a section\'s, dashed, in the ink)', !!tp && tp.color === lc.color && tp.ratio >= 3, JSON.stringify(tp));
  await page.click('#btnTheme'); await settle(page, 250);
  const tpl = await paint('#tableView tbody .row-tag[data-tc="#8c6bf7"]');
  await view(page, 'gantt');
  lc = await paint('.tag-chip[data-tag="engineering"]');
  check('on the light theme the ink is walked again against the paper, and still reads', lc.ratio >= 3 && tpl.ratio >= 3 && lc.fill === 'rgba(140, 107, 247, 0.14)', JSON.stringify({ lc, tpl }));
  await shot(page, 'tags-1440-coloured-light');
  await page.click('#btnTheme'); await settle(page, 250);
  // Rename and remove everywhere take the registry entry with them.
  await view(page, 'setup');
  await renameTag(page, 'engineering', 'Eng');
  check('a rename everywhere keeps the colour on the new name', (await regOf('Eng'))?.color === '#8c6bf7' && !(await regOf('Engineering')));
  await undo(page);
  check('and its Ctrl+Z takes the colour back to the old name', (await regOf('Engineering'))?.color === '#8c6bf7' && !(await regOf('Eng')));
  await page.click('#suTags .tag-row[data-tag="ops"] .tag-btn[data-a="del"]'); await settle(page, 300);
  check('Remove everywhere takes the tag out of the registry too', !(await regOf('Ops')) && !tagsOf(await savedPlan(X.url))['Ops · Supply'].length);
  await undo(page);
  check('and Ctrl+Z puts it back with its colour', (await regOf('Ops'))?.color === '#ee88bb' && JSON.stringify(tagsOf(await savedPlan(X.url))) === JSON.stringify(W));
  await renameTag(page, 'someday', 'later');
  check('an unused tag renames in the registry', JSON.stringify((await regOwn()).map(t => t.name).sort()) === JSON.stringify(['Engineering', 'Ops', 'later']), JSON.stringify(await reg()));
  // The changelog.
  const RL = (await regLog()).slice(rl0).map(e => `${e.action}:${e.tag}:${e.via}`);
  const want = ['tag.create:someday:setup', 'tag.delete:someday:undo', 'tag.create:someday:redo', 'tag.color:Engineering:setup', 'tag.color:Ops:setup', 'tag.color:Ops:setup', 'tag.color:Ops:undo', 'tag.rename:Eng:setup', 'tag.rename:Engineering:undo', 'tag.delete:Ops:setup', 'tag.color:Ops:undo', 'tag.rename:later:setup'];
  check('the changelog has tag.create, tag.color, tag.rename and tag.delete, undo and redo included', JSON.stringify(RL) === JSON.stringify(want), JSON.stringify(RL));
  const mdt = await md();
  check('and reads them as sentences', ['created the tag "someday" (setup)', 'coloured the tag "Engineering" #8c6bf7 (setup)', 'cleared the colour on the tag "Ops" (was #ee88bb) (setup)', 'renamed the tag "someday" to "later" (setup)', 'removed the tag "Ops" from the plan\'s tag list (setup)'].every(t => mdt.includes(t)),
    mdt.split('\n').filter(l => /the tag/.test(l)).slice(-6).join(' | '));
  // Persistence.
  await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
  await view(page, 'setup');
  check('after a reload the registry and its colours are as saved', JSON.stringify(await page.$$eval('#suTags .tag-row', r => r.map(x => x.dataset.tag + (x.querySelector('.tag-nm-t').dataset.tc || ''))).then(a => a.filter(x => /^(engineering|ops|later)/.test(x))))
    === JSON.stringify(['engineering#8c6bf7', 'ops#ee88bb', 'later']));
  check('and the unused tag is still listed, as 0 sections', await page.$eval('#suTags .tag-row[data-tag="later"] .tag-n', e => e.textContent).catch(() => '') === '0 sections');
  await view(page, 'gantt');
  check('and the legend chip is still coloured', await page.$eval('.tag-chip[data-tag="engineering"]', e => e.dataset.tc) === '#8c6bf7');

  // ── Phone width: the legend with tag chips and a filter ──
  const m = await openPage(browser, X.url, { w: 393, h: 852, mobile: true });
  await assertNoSidewaysScroll(m, '393, legend with tag chips');
  await m.click('.tag-chip[data-tag="engineering"]'); await settle(m, 200);
  await assertNoSidewaysScroll(m, '393, legend with a tag chosen');
  check('393: the filter works there too', await m.evaluate(() => [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.checkVisibility()).length) === 4);
  await shot(m, 'tags-393-filtered');
  await m.evaluate(() => document.querySelector('.vt-btn[data-view="setup"]').click()); await settle(m, 200);
  // What is SEEN past the edge: an element cut off by an ancestor that clips (the aside
  // inside a section name's ellipsis) ends where that ancestor ends. A scroll container
  // does not count as a clip: scrolling sideways is exactly what this must catch.
  const over = await m.evaluate(() => [...document.querySelectorAll('#setupView *')].filter(e => window.__uiVis(e)).filter(e => {
      let right = e.getBoundingClientRect().right;
      for (let a = e.parentElement; a && a.id !== 'setupView'; a = a.parentElement) if (/^(hidden|clip)$/.test(getComputedStyle(a).overflowX)) right = Math.min(right, a.getBoundingClientRect().right);
      return right > innerWidth + 0.5; })
    .map(e => (e.className || e.tagName) + ' @' + Math.round(e.getBoundingClientRect().right)));
  check('393: nothing on the Setup page, tags included, runs past the window', over.length === 0, over.join(' | '));
  await assertNoSidewaysScroll(m, '393, setup with tags');
  await shot(m, 'tags-393-setup');
  await m.close();

  // ── 0.62.0: drag a tag row to reorder; NO OWNER where nobody owns a row ──
  await view(page, 'setup'); await settle(page, 250);
  const tagOrder = () => page.$$eval('#suTags .tag-row', rs => rs.map(r => r.dataset.tag));
  const legendOrder = () => page.$$eval('#legend .tag-chip', cs => cs.map(c => c.dataset.tag));
  const o0 = await tagOrder();
  check('every tag row has a drag grip and no arrow or Rename button', o0.length >= 3 && await page.$$eval('#suTags .tag-row', rs => rs.every(r => r.querySelector('.sec-grip') && !r.querySelector('[data-a="up"],[data-a="down"],[data-a="ren"]'))), JSON.stringify(o0));
  const lastKey = o0[o0.length - 1];
  // The card sits below Project dates and the Working calendar (0.76.0), so it is brought
  // into view first: a press below the window lands on nothing.
  await page.$eval('#suTags', e => e.scrollIntoView({ block: 'start' })); await settle(page, 150);
  const from = await page.$eval(`#suTags .tag-row[data-tag="${lastKey}"] .tag-n`, e => { const r = e.getBoundingClientRect(); return { x: r.left + 4, y: r.top + r.height / 2 }; });
  const to = await page.$eval('#suTags .tag-row', e => { const r = e.getBoundingClientRect(); return { x: r.left + 60, y: r.top + 2 }; });
  await page.mouse.move(from.x, from.y); await page.mouse.down();
  await page.mouse.move(from.x, from.y - 12, { steps: 3 });
  check('a drop line shows while a tag row is dragged', !!(await page.$('#suTags .tag-list .sec-drop')));
  await page.mouse.move(to.x, to.y, { steps: 10 }); await page.mouse.up(); await settle(page, 500);
  const o1 = await tagOrder();
  check('dragging the last tag row to the top puts it first and keeps the rest in order', o1[0] === lastKey && JSON.stringify(o1.slice(1)) === JSON.stringify(o0.slice(0, -1)), JSON.stringify({ o0, o1 }));
  check('the new order is saved as the registry order', JSON.stringify((await reg()).map(t => t.name.toLowerCase())) === JSON.stringify(o1), JSON.stringify(await reg()));
  await view(page, 'gantt'); await settle(page, 200);
  const used = new Set(await legendOrder());
  check('the legend chips follow the new order', JSON.stringify(await legendOrder()) === JSON.stringify(o1.filter(k => used.has(k))), JSON.stringify(await legendOrder()));
  await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page, 300);
  await view(page, 'setup'); await settle(page, 200);
  check('the order survives a reload', JSON.stringify(await tagOrder()) === JSON.stringify(o1), JSON.stringify(await tagOrder()));
  // Alt+Down on the focused first row moves it one place; one Ctrl+Z takes that back.
  await page.focus(`#suTags .tag-row[data-tag="${lastKey}"]`);
  await page.keyboard.down('Alt'); await page.keyboard.press('ArrowDown'); await page.keyboard.up('Alt'); await settle(page, 400);
  const o2 = await tagOrder();
  check('Alt+Down moves the focused tag row one place', o2[1] === lastKey && o2[0] === o1[1], JSON.stringify(o2));
  await page.evaluate(() => document.activeElement && document.activeElement.blur());
  await undo(page); await settle(page, 400);
  check('one undo takes the move back', JSON.stringify(await tagOrder()) === JSON.stringify(o1), JSON.stringify(await tagOrder()));
  // A press that does not move is a click, not a drag: the name still opens its rename box.
  await page.click(`#suTags .tag-row[data-tag="${o1[1]}"] .tag-nm-t`); await settle(page, 200);
  check('a click on a tag name still opens the rename box (not a drag)', !!(await page.$('#suTags .tag-nm')));
  await page.keyboard.press('Escape'); await settle(page, 150);
  // NO OWNER: the chart's owner chip, the Table's Owner cell and a section with no aside.
  const noLead = await page.$$eval('#suSections .sec-ld-t.empty', es => es.map(e => ({ t: e.textContent, o: +getComputedStyle(e).opacity })));
  check('a section with no owner or aside reads NO OWNER, visible at rest', noLead.length > 0 && noLead.every(x => x.t === 'NO OWNER' && x.o >= 0.5), JSON.stringify(noLead.slice(0, 3)));
  await page.click('#suSections .sec-ld-t.empty'); await settle(page, 200);
  check('and a click on it opens the field to type one', await page.evaluate(() => { const a = document.activeElement; return !!a && a.tagName === 'INPUT' && !!a.closest('#suSections'); }));
  await page.keyboard.press('Escape'); await settle(page, 150);
  await view(page, 'gantt'); await settle(page, 200);
  const pills = await page.$$eval('.label-row .owner-pill.none', es => es.filter(e => e.offsetParent).map(e => e.textContent));
  check('a task nobody owns shows NO OWNER on the chart', pills.length > 0 && pills.every(t => t === 'NO OWNER'), JSON.stringify(pills.slice(0, 3)));
  const pillAt = await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row .owner-pill.none')].find(x => x.offsetParent && x.getBoundingClientRect().top > 120 && x.getBoundingClientRect().bottom < innerHeight - 20);
    if (!e) return null; const r = e.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
  if (pillAt) await page.mouse.click(pillAt.x, pillAt.y); await settle(page, 250);
  check('and a click on it opens the owner list', await page.evaluate(() => !!document.querySelector('.cl-opt')));
  await page.keyboard.press('Escape'); await settle(page, 150);
  await view(page, 'table'); await settle(page, 250);
  const cells = await page.$$eval('td.c-owner.empty', es => es.map(e => e.textContent));
  check('and in the Table', cells.length > 0 && cells.every(t => t === 'NO OWNER'), JSON.stringify(cells.slice(0, 3)));
  await page.close();

  // ── The snapshot: tags shown, filterable, and editable with no server ──
  const snapFile = join(TMP, 'tags-snapshot.html'); writeFileSync(snapFile, await (await fetch(X.url + 'export/html')).text());
  const sp = await openPage(browser, 'file://' + snapFile);
  check('the snapshot shows the section tags in the chart', (await sp.$$eval('.lane-tag', e => e.map(x => x.textContent))).includes('long-lead'));
  check('the snapshot carries the tag colours', await sp.$$eval('.lane-tag', e => e.filter(x => x.textContent === 'Engineering').every(x => x.dataset.tc === '#8c6bf7')));
  await sp.click('.tag-chip[data-tag="engineering"]'); await settle(sp, 200);
  check('the snapshot filters by tag', await sp.evaluate(() => [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.checkVisibility()).length) === 4);
  await view(sp, 'setup');
  const ms = `.sec-row[data-i="5"]`;
  await sp.click(`${ms} .sec-tag-in`); await sp.keyboard.type('offline'); await sp.keyboard.press('Enter'); await settle(sp, 200);
  check('the snapshot adds a tag with no server', JSON.stringify(await sp.$$eval(`${ms} .sec-tag`, e => e.map(x => x.firstChild.textContent))) === JSON.stringify(['misc', X32, 'offline']));
  await sp.evaluate(() => document.activeElement.blur()); await undo(sp);
  check('and takes it back with Ctrl+Z', JSON.stringify(await sp.$$eval(`${ms} .sec-tag`, e => e.map(x => x.firstChild.textContent))) === JSON.stringify(['misc', X32]));
  await sp.close();
}

// ── Row tags (0.60.0) ──
// Tags on tasks and milestones, own plus section ("effective"). Every edit path is driven
// and its effect read back from /export/json after the save: the item editor, the New
// panel, the table's Tags cell, the batch panel's add and remove, a paste, and the undo
// and redo of each. Then the changelog's `tags` lines, the filter cases with row counts in
// both lenses, the Analytics per-tag card against numbers worked out by hand from the
// seed, the Setup card's row counts and its rename/remove everywhere, CSV out and back in,
// a reload, and the snapshot with no server.
async function rowTagsFlow(browser) {
  section = 'row tags';
  const T = (id, name, ph, x = {}) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, ...x });
  // Hours (0.63.0: Effort estimates, seeded to what the bars used to be worth):
  //   Fab one 40, Fab two 40, Ops one 40, Ops two 40, Misc one 16,
  //   Ops dropped 0 (cancelled; its 80h estimate does not count). Total 176h.
  const X = await startServer({ project: { title: 'Row Tags Test', appName: 'row-tags-test', view: { mode: 'gantt', grain: 'week', labelWidth: 300 } },
    phases: [{ name: 'Tech · Fab', start: '2026-09', end: '2026-12', tags: ['Tech'] }, { name: 'Ops', start: '2026-09', end: '2026-12' }, { name: 'Misc', start: '2026-09', end: '2026-12' }],
    items: [T('f1', 'Fab one', 0, { effort: 40 }), T('f2', 'Fab two', 0, { effort: 40 }), { type: 'milestone', name: 'Fab gate', date: '2026-10-20', phase: 0 },
      T('o1', 'Ops one', 1, { tags: ['long-lead'], effort: 40 }), T('o2', 'Ops two', 1, { effort: 40 }), T('o3', 'Ops dropped', 1, { end: '2026-10-16', status: 'cancelled', tags: ['long-lead'], effort: 80 }),
      T('m1', 'Misc one', 2, { start: '2026-10-06', end: '2026-10-07', effort: 16 })] }, 'row-tags');
  let page = await openPage(browser, X.url);
  const redo = async p => { p = p || page; await p.keyboard.down('Control'); await p.keyboard.down('Shift'); await p.keyboard.press('KeyZ'); await p.keyboard.up('Shift'); await p.keyboard.up('Control'); await settle(p, 250); };
  const blur = () => page.evaluate(() => document.activeElement && document.activeElement.blur());
  const own = pl => Object.fromEntries(pl.items.filter(r => r.type !== 'group').map(r => [r.name, r.tags || []]));
  let W = { 'Fab one': [], 'Fab two': [], 'Fab gate': [], 'Ops one': ['long-lead'], 'Ops two': [], 'Ops dropped': ['long-lead'], 'Misc one': [] };
  const expectOwn = async (what, want) => {
    const got = own(await savedPlan(X.url));
    const keys = [...new Set([...Object.keys(want), ...Object.keys(got)])];
    const bad = keys.filter(k => JSON.stringify(got[k]) !== JSON.stringify(want[k])).map(k => `${k}: ${JSON.stringify(got[k])} (want ${JSON.stringify(want[k])})`);
    check(`${what}: saved row tags`, bad.length === 0, bad.join('; '));
  };
  const tagLog = async () => (await (await fetch(X.url + 'changelog')).json()).events.filter(e => e.action === 'tags');
  const md = async () => (await fetch(X.url + 'changelog?format=md')).text();
  const vis = () => page.evaluate(() => ({
    tasks: [...document.querySelectorAll('.label-row:not(.group):not(.ms)')].filter(e => e.checkVisibility()).map(e => e.querySelector('.name-text').textContent),
    heads: [...document.querySelectorAll('.label-row.group')].filter(e => e.checkVisibility()).map(e => e.querySelector('.lane-title').textContent),
  }));
  const tableNames = async () => { await view(page, 'table'); const n = await page.$$eval('#tableView tbody tr[data-key] td.c-name', e => e.map(x => x.textContent.replace('◆', '').trim())); await view(page, 'gantt'); return n; };

  // ── On the chart: the label row and the tooltip ──
  await view(page, 'gantt');
  const lbl = n => page.evaluate(n => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.querySelector('.name-text')?.textContent === n); const t = e && e.querySelector('.row-tags'); return t ? t.textContent : ''; }, n);
  check('a row\'s own tags show quietly in its label row, after the owner', await lbl('Ops one') === '#long-lead' && await lbl('Fab one') === '');
  check('and on its bar\'s tooltip', await page.$eval('.bar[data-id="o1"]', b => b.dataset.tags) === 'long-lead');
  await page.hover('.bar[data-id="o1"]'); await sleep(700);
  check('the tooltip reads "tags: long-lead"', (await page.$eval('#tip', e => e.textContent).catch(() => '')).includes('tags: long-lead'));
  await page.mouse.move(700, 5);
  const tc = async t => page.$eval(`.tag-chip[data-tag="${t}"] .tc-n`, e => e.textContent).catch(() => null);
  check('the legend has a chip for a tag only rows carry, and its count is the rows it finds', await tc('long-lead') === '2' && await tc('tech') === '3', `${await tc('long-lead')} ${await tc('tech')}`);

  // ── The filter: effective tags ──
  await page.click('.tag-chip[data-tag="long-lead"]'); await settle(page, 200);
  let v = await vis();
  check('"long-lead" in an untagged section: its rows are shown with their section header, the untagged row beside them is not (2 chart rows)',
    JSON.stringify(v.tasks) === JSON.stringify(['Ops one', 'Ops dropped']) && JSON.stringify(v.heads) === JSON.stringify(['Ops']), JSON.stringify(v));
  check('and the table agrees (2 rows)', JSON.stringify(await tableNames()) === JSON.stringify(['Ops one', 'Ops dropped']));
  check('the Ops header\'s count says "2 of 3" while the filter hides one of its rows', await page.evaluate(() => [...document.querySelectorAll('.label-row.group')].find(g => g.querySelector('.lane-title').textContent === 'Ops').querySelector('.lane-count').textContent) === '2 of 3');
  await page.click('.tag-chip[data-tag="long-lead"]'); await page.click('.tag-chip[data-tag="tech"]'); await settle(page, 200);
  v = await vis();
  check('"Tech" on a section: every row in it is shown, none of them tagged on its own (2 chart task rows)', JSON.stringify(v.tasks) === JSON.stringify(['Fab one', 'Fab two']) && JSON.stringify(v.heads) === JSON.stringify(['Tech · Fab']), JSON.stringify(v));
  check('and the table has them and the section\'s milestone (3 rows)', JSON.stringify(await tableNames()) === JSON.stringify(['Fab one', 'Fab two', 'Fab gate']));
  await page.click('.tag-chip[data-tag="long-lead"]'); await settle(page, 200);
  v = await vis();
  check('both tags: either kind of match (4 chart rows, 2 headers; 5 table rows)', v.tasks.length === 4 && v.heads.length === 2 && (await tableNames()).length === 5, JSON.stringify(v));
  await page.click('#tagClear'); await settle(page, 200);
  check('Clear tags shows all 6 task rows again', (await vis()).tasks.length === 6);

  // ── Analytics: per tag, effective, cancelled out of the hours ──
  const anTags = async () => { await view(page, 'analytics'); const r = await page.$$eval('#anTags tbody tr', rows => rows.map(tr => [...tr.querySelectorAll('td')].map(td => td.textContent.trim()).filter((x, i) => i !== 1).join('|'))); await view(page, 'gantt'); return r; };
  let an = await anTags();
  // Tech: Fab one + Fab two (80h) and Fab gate. long-lead: Ops one 40h + Ops dropped 0h (cancelled). No tag: Ops two 40 + Misc one 16.
  // Shares of 176h: 80 -> 45.5%, 40 -> 22.7%, 56 -> 31.8%.
  check('Analytics has a per-tag card counting effective tags, cancelled hours left out, and a "no tag" line',
    JSON.stringify(an) === JSON.stringify(['Tech|2 +1M|80h|45.5%', 'long-lead|2|40h|22.7%', 'no tag|2|56h|31.8%']), JSON.stringify(an));

  // ── Edit path 1: the item editor ──
  let n0 = (await tagLog()).length;
  await openItemEditor(page, 'Ops two');
  await page.click('#imTagsIn'); await page.keyboard.type('urgent'); await page.keyboard.press('Enter'); await settle(page, 80);
  check('Enter in the tag box adds a chip and does NOT apply the editor', await editorShown(page) && JSON.stringify(await page.$$eval('#imTags .sec-tag', e => e.map(x => x.firstChild.textContent))) === '["urgent"]');
  await page.keyboard.type('long-lead,'); await page.keyboard.type('zap'); await page.keyboard.press('Enter');
  await page.click('#imTags .tb-x[data-tag="zap"]'); await settle(page, 80);
  check('a comma adds one, and the x takes one off (draft: urgent, long-lead)', JSON.stringify(await page.$$eval('#imTags .sec-tag', e => e.map(x => x.firstChild.textContent))) === '["urgent","long-lead"]');
  await page.keyboard.type('late'); // typed and not yet added: Apply keeps it, as clicking away does
  await page.click('#imApply'); await settle(page, 250);
  W['Ops two'] = ['urgent', 'long-lead', 'late'];
  await expectOwn('the item editor saves the row\'s tags (a typed tag not yet added is kept)', W);
  let L = (await tagLog()).slice(n0);
  check('one changelog `tags` line, via item-editor, with the row\'s uid', L.length === 1 && L[0].via === 'item-editor' && L[0].name === 'Ops two' && !!L[0].uid
    && JSON.stringify(L[0].from.tags) === '[]' && JSON.stringify(L[0].to.tags) === '["urgent","long-lead","late"]', JSON.stringify(L));
  check('the markdown changelog reads it', (await md()).includes('tagged Ops two with urgent, long-lead, late (was none) (item-editor)') || /tagged "?Ops two"? with urgent, long-lead, late/.test(await md()));
  check('the label row shows the new tags', await lbl('Ops two') === '#urgent #long-lead #late');
  await undo(page);
  W['Ops two'] = [];
  await expectOwn('Ctrl+Z takes the editor\'s tags back', W);
  await redo();
  W['Ops two'] = ['urgent', 'long-lead', 'late'];
  await expectOwn('redo puts them back', W);
  L = (await tagLog()).slice(n0);
  check('the undo and redo are logged as `tags` lines too', L.length === 3 && L[1].via === 'undo' && L[2].via === 'redo', JSON.stringify(L.map(e => e.via)));
  // Clearing: the sidecar has to say "cleared", or a data-file tag comes back on reload.
  await openItemEditor(page, 'Ops one');
  await page.click('#imTags .tb-x[data-tag="long-lead"]'); await page.click('#imApply'); await settle(page, 250);
  W['Ops one'] = [];
  await expectOwn('clearing a data-file tag in the editor', W);
  await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
  check('the cleared data-file tag stays cleared after a reload', await lbl('Ops one') === '' && JSON.stringify(own(await exportPlan(X.url))['Ops one']) === '[]');
  await undo(page).catch(() => {});      // nothing to undo after a reload; harmless
  await openItemEditor(page, 'Ops one');
  await page.click('#imTagsIn'); await page.keyboard.type('long-lead'); await page.keyboard.press('Enter'); await page.click('#imApply'); await settle(page, 250);
  W['Ops one'] = ['long-lead'];
  await expectOwn('(and put back)', W);

  // ── Edit path 2: the table's Tags cell ──
  n0 = (await tagLog()).length;
  await view(page, 'table');
  const cell = '#tableView tr[data-key="t:f1"] td[data-field="tags"]';
  check('the table has a Tags column showing own tags and, dashed, the section\'s', await page.$eval('#tableView tr[data-key="t:f1"] td.c-tags', td => [...td.querySelectorAll('.row-tag')].map(x => x.textContent + (x.classList.contains('inh') ? '~' : '')).join(',')) === 'Tech~');
  await page.click(cell); await settle(page, 100);
  check('clicking the cell opens the chip editor over it, focused', await page.evaluate(() => document.activeElement?.classList.contains('tb-in') && !!document.activeElement.closest('.cell-edit.tags-edit')));
  // 0.60.0 build: the cell clipped (overflow hidden makes it a scroll container), and the
  // focus scrolled the editor up and left inside it. It must open ON the cell.
  const at = await page.evaluate(() => { const w = document.querySelector('.cell-edit.tags-edit'), td = w.closest('td'); const a = w.getBoundingClientRect(), b = td.getBoundingClientRect(); return [Math.round(a.top - b.top), Math.round(a.left - b.left)]; });
  check('the cell\'s chip editor opens on the cell, not scrolled inside it', at[0] >= 0 && at[0] <= 3 && at[1] >= 0 && at[1] <= 4, JSON.stringify(at));
  // Saved chip by chip while the cell stays open (0.60.0 review: "type, Enter, done" showed
  // a pill the plan did not have until a second Enter or a click away).
  await page.keyboard.type('hot'); await page.keyboard.press('Enter'); await settle(page, 80);
  W['Fab one'] = ['hot'];
  await expectOwn('Enter with text saves the tag at once', W);
  check('and the cell stays open, focused, with the chip in it', await page.evaluate(() => document.activeElement?.closest('.cell-edit.tags-edit') && [...document.querySelectorAll('.cell-edit .sec-tag')].map(x => x.firstChild.textContent).join(',') === 'hot'));
  await page.keyboard.type('cold'); await page.keyboard.press('Enter'); await settle(page, 80);
  await page.click('.cell-edit .tb-x[data-tag="cold"]'); await settle(page, 80);
  check('the x removes a chip and the cell stays open', await page.$$eval('.cell-edit .sec-tag', e => e.map(x => x.firstChild.textContent).join(',')) === 'hot');
  await expectOwn('each chip added or removed is saved as it happens', W);
  await page.keyboard.type('pending'); await page.keyboard.press('Escape'); await page.keyboard.press('Escape'); await settle(page, 250);
  check('Escape discards only the text still in the box, then closes the cell', !(await page.$('.cell-edit')));
  await expectOwn('(the chips stay saved; the typed text did not)', W);
  L = (await tagLog()).slice(n0);
  check('three `tags` lines via table-edit, one per chip gesture', L.length === 3 && L.every(e => e.via === 'table-edit') && JSON.stringify(L.map(e => e.to.tags)) === '[["hot"],["hot","cold"],["hot"]]', JSON.stringify(L.map(e => e.to.tags)));
  check('the cell shows it, then the section\'s', await page.$eval('#tableView tr[data-key="t:f1"] td.c-tags', td => [...td.querySelectorAll('.row-tag')].map(x => x.textContent + (x.classList.contains('inh') ? '~' : '')).join(',')) === 'hot,Tech~');
  await page.click(cell); await settle(page, 100); await page.keyboard.type('late2'); await page.click('#tblCount'); await settle(page, 250);
  W['Fab one'] = ['hot', 'late2'];
  await expectOwn('clicking away commits text left in the box', W);
  await undo(page);
  W['Fab one'] = ['hot'];
  await expectOwn('Ctrl+Z takes back one chip gesture at a time', W);
  await undo(page); await undo(page); await undo(page);
  W['Fab one'] = [];
  await expectOwn('and three more take the cell back to where it started', W);
  await redo(); await redo(); await redo();
  W['Fab one'] = ['hot'];
  await expectOwn('redo', W);
  // `;` is the CSV separator, so it cannot be inside a tag: it becomes a space, on the page
  // exactly as on the server (0.60.0 review).
  await page.click('#tableView tr[data-key="t:f2"] td[data-field="tags"]'); await settle(page, 100);
  await page.keyboard.type('x;y'); await page.keyboard.press('Enter'); await settle(page, 100);
  check('a ";" typed into a tag becomes a space', await page.$$eval('.cell-edit .sec-tag', e => e.map(x => x.firstChild.textContent).join('|')) === 'x y');
  await page.keyboard.press('Escape'); await settle(page, 150);
  W['Fab two'] = ['x y'];
  await expectOwn('and is saved that way', W);
  await undo(page);
  W['Fab two'] = [];
  await expectOwn('(undone)', W);
  // The editor near the bottom of the table grows downward; the caret stays in view.
  await page.setViewport({ width: 1440, height: 800 }); await settle(page, 200);
  await page.click('#tableView tr[data-key="t:m1"] td[data-field="tags"]'); await settle(page, 100);
  for (let i = 1; i <= 10; i++) { await page.keyboard.type('chip' + i); await page.keyboard.press('Enter'); }
  await settle(page, 200);
  const caret = await page.evaluate(() => { const i = document.activeElement, sc = document.querySelector('.table-scroll').getBoundingClientRect(), r = i.getBoundingClientRect(); return { ok: i.classList.contains('tb-in') && r.bottom <= Math.min(sc.bottom, innerHeight) + 1 && r.top >= sc.top - 1, r: Math.round(r.bottom), sc: Math.round(sc.bottom) }; });
  check('10 chips in the last row\'s cell: the box grew, and its caret is still in view', caret.ok, JSON.stringify(caret));
  await page.keyboard.press('Escape'); await settle(page, 200);
  for (let i = 0; i < 10; i++) await undo(page);
  await page.setViewport({ width: 1440, height: 900 }); await settle(page, 200);
  await expectOwn('(the ten chips undone)', W);
  // sortable
  await page.click('#tableView thead th[data-sort="tags"]'); await settle(page, 150);
  const sorted = await page.$$eval('#tableView tbody tr[data-key] td.c-tags', e => e.map(td => [...td.querySelectorAll('.row-tag')].map(x => x.textContent).join(' ').toLowerCase()));
  const nonBlank = sorted.filter(Boolean);
  check('the Tags column sorts (A to Z, untagged rows last)', JSON.stringify(nonBlank) === JSON.stringify([...nonBlank].sort()) && sorted.slice(nonBlank.length).every(x => !x) && sorted.at(-1) === '', JSON.stringify(sorted));
  await page.click('#tableView thead th[data-sort="tags"]'); await settle(page, 100); await page.click('#tableView thead th[data-sort="tags"]'); await settle(page, 150);
  // the text filter finds own tags and section tags
  await page.type('#tblSearch', 'urgent'); await settle(page, 300);
  check('the table\'s text filter matches a row\'s own tag', JSON.stringify(await page.$$eval('#tableView tbody tr[data-key] td.c-name', e => e.map(x => x.textContent.trim()))) === '["Ops two"]');
  await page.$eval('#tblSearch', e => { e.value = ''; e.dispatchEvent(new Event('input', { bubbles: true })); }); await settle(page, 300);
  await assertNoSidewaysScroll(page, '1440, table with the Tags column');
  await shot(page, 'row-tags-1440-table');

  // ── Edit path 3: the New panel (and its memory) ──
  await view(page, 'gantt');
  n0 = (await tagLog()).length;
  await page.click('#btnNew'); await settle(page, 150);
  await page.select('#ncPhase', '1');
  await page.type('#ncName', 'New one');
  await page.click('#ncTagsIn'); await page.keyboard.type('fresh'); await page.keyboard.press('Enter'); await settle(page, 60);
  check('Enter in the New panel\'s tag box adds a chip and does not create', await editorShown(page));
  await page.click('#ncCreate'); await settle(page, 400);
  W['New one'] = ['fresh'];
  await expectOwn('the New panel creates the row with its tags', W);
  L = (await tagLog()).slice(n0);
  check('and says so in a `tags` line via create', L.length === 1 && L[0].via === 'create' && L[0].name === 'New one' && JSON.stringify(L[0].to.tags) === '["fresh"]', JSON.stringify(L));
  await page.click('#btnNew'); await settle(page, 150);
  check('the next New panel remembers the tags, like every field but the name', JSON.stringify(await page.$$eval('#ncTags .sec-tag', e => e.map(x => x.firstChild.textContent))) === '["fresh"]');
  await page.click('#ncCancel'); await settle(page, 100);
  await undo(page);
  delete W['New one'];
  await expectOwn('Ctrl+Z takes the created row away', W);
  await redo();
  W['New one'] = ['fresh'];
  await expectOwn('redo brings it back with its tags', W);
  // Deleting the only row carrying the chosen tag prunes the filter; Ctrl+Z brings back the
  // row AND the view (0.60.0 review). The same for clearing that row's tag.
  const flt = () => page.evaluate(() => JSON.parse(localStorage.getItem('adom-gantt:prefs') || '{}').tagFilter);
  await clearSel(page);
  await page.click('.tag-chip[data-tag="fresh"]'); await settle(page, 200);
  await view(page, 'table');
  await page.click('#tableView tr[data-key] .row-del'); await settle(page, 300);
  check('deleting the only "fresh" row drops "fresh" from the filter', JSON.stringify(await flt()) === '[]');
  await undo(page);
  check('Ctrl+Z restores the row and the filter with it', JSON.stringify(await flt()) === '["fresh"]' && JSON.stringify(await page.$$eval('#tableView tbody tr[data-key] td.c-name', e => e.map(x => x.textContent.trim()))) === '["New one"]', JSON.stringify(await flt()));
  await page.click('#tableView tr[data-key] td[data-field="tags"]'); await settle(page, 100);
  await page.click('.cell-edit .tb-x[data-tag="fresh"]'); await settle(page, 150); await page.click('#tblCount'); await settle(page, 300);
  check('clearing that row\'s tag drops it from the filter too', JSON.stringify(await flt()) === '[]');
  await undo(page);
  check('and Ctrl+Z brings the tag and the filter back', JSON.stringify(await flt()) === '["fresh"]' && JSON.stringify(await page.$$eval('#tableView tbody tr[data-key] td.c-name', e => e.map(x => x.textContent.trim()))) === '["New one"]');
  await page.click('#tagClear'); await settle(page, 200);
  await view(page, 'gantt');
  await expectOwn('(the plan is as it was)', W);

  // ── Edit path 4: the batch panel ──
  await clearSel(page);
  n0 = (await tagLog()).length;
  await clickBar(page, 'f1', ['Control']); await clickBar(page, 'm1', ['Control']);
  check('the batch panel opens on two rows with a tags row', !!(await page.$('#bTag')) && !!(await page.$('#bTagAdd')));
  check('it lists the own tags in the selection with a count', await page.$$eval('.bt-chips .sec-tag', e => e.map(x => x.firstChild.textContent.trim() + x.querySelector('small').textContent).join(',')) === 'hot1');
  await page.type('#bTag', 'batchy'); await page.click('#bTagAdd'); await settle(page, 250);
  W['Fab one'] = ['hot', 'batchy']; W['Misc one'] = ['batchy'];
  await expectOwn('Add to all puts the tag on every selected row', W);
  L = (await tagLog()).slice(n0);
  check('two `tags` lines via batch-editor, in one save', L.length === 2 && L.every(e => e.via === 'batch-editor') && L[1].rev === L[0].rev + 1, JSON.stringify(L.map(e => e.via + ':' + e.name + ':' + e.rev)));
  await page.click('.bt-x[data-tag="batchy"]'); await settle(page, 250);
  W['Fab one'] = ['hot']; W['Misc one'] = [];
  await expectOwn('the x in the batch panel takes the tag off every selected row', W);
  await blur(); await undo(page);
  W['Fab one'] = ['hot', 'batchy']; W['Misc one'] = ['batchy'];
  await expectOwn('ONE Ctrl+Z puts it back on both', W);
  await undo(page);
  W['Fab one'] = ['hot']; W['Misc one'] = [];
  await expectOwn('and one more takes the add back on both', W);
  await redo();
  W['Fab one'] = ['hot', 'batchy']; W['Misc one'] = ['batchy'];
  await expectOwn('redo', W);
  await clearSel(page);

  // ── Copy and paste carry tags ──
  await view(page, 'table');
  await page.keyboard.down('Control'); await page.click('#tableView tr[data-key="t:f1"] td.c-deps'); await page.keyboard.up('Control'); await settle(page, 100);
  await page.keyboard.down('Control'); await page.keyboard.press('KeyC'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control'); await settle(page, 500);
  W['Fab one 2'] = ['hot', 'batchy'];
  await expectOwn('a pasted copy carries the row\'s own tags', W);
  await undo(page);
  delete W['Fab one 2'];
  await expectOwn('Ctrl+Z takes the paste back', W);
  await clearSel(page);
  await view(page, 'gantt');

  // ── Setup: the Tags card counts rows, and rename/remove everywhere reach them ──
  await view(page, 'setup');
  const card = () => page.$$eval('#suTags .tag-row', rows => rows.map(r => r.querySelector('.tag-name').textContent + ':' + r.querySelector('.tag-n').textContent));
  check('the Tags card counts sections and rows', JSON.stringify(await card()) === JSON.stringify(['Tech:1 section', 'hot:1 row', 'batchy:2 rows', 'long-lead:3 rows', 'urgent:1 row', 'late:1 row', 'fresh:1 row']), JSON.stringify(await card()));
  n0 = (await tagLog()).length;
  await renameTag(page, 'long-lead', 'LL');
  W['Ops one'] = ['LL']; W['Ops dropped'] = ['LL']; W['Ops two'] = ['urgent', 'LL', 'late'];
  await expectOwn('Rename everywhere renames the tag on every row, in place', W);
  L = (await tagLog()).slice(n0);
  check('three `tags` lines via setup, one save', L.length === 3 && L.every(e => e.via === 'setup') && L.every((e, i) => !i || e.rev === L[i - 1].rev + 1), JSON.stringify(L.map(e => e.via)));
  await undo(page);
  W['Ops one'] = ['long-lead']; W['Ops dropped'] = ['long-lead']; W['Ops two'] = ['urgent', 'long-lead', 'late'];
  await expectOwn('ONE Ctrl+Z takes the rename back on every row', W);
  await page.click('#suTags .tag-row[data-tag="batchy"] .tag-btn[data-a="del"]'); await settle(page, 250);
  W['Fab one'] = ['hot']; W['Misc one'] = [];
  await expectOwn('Remove everywhere takes it off every row', W);
  await undo(page);
  W['Fab one'] = ['hot', 'batchy']; W['Misc one'] = ['batchy'];
  await expectOwn('and one Ctrl+Z puts it back', W);
  // A row moved into a tagged section gains its tags: the filter follows the move.
  await view(page, 'gantt');

  // ── Analytics after the edits, by hand again ──
  an = await anTags();
  // Tech: Fab one, Fab two, Fab gate (80h). hot/batchy: Fab one 40h; batchy also Misc one 16h -> 56h.
  // long-lead: Ops one, Ops two, Ops dropped -> 80h (dropped cancelled). urgent, late: Ops two 40h.
  // fresh: New one, made with no Effort estimate, so 0h (0.63.0; its bar no longer counts).
  // Total 176h. No tag: Fab two? no (Tech). Nothing is untagged now: 0.
  check('the per-tag card follows the edits (hand-checked)', JSON.stringify(an) === JSON.stringify([
    'Tech|2 +1M|80h|45.5%', 'hot|1|40h|22.7%', 'batchy|2|56h|31.8%', 'long-lead|3|80h|45.5%', 'urgent|1|40h|22.7%', 'late|1|40h|22.7%', 'fresh|1|0h|0%', 'no tag|0|0h|0%']), JSON.stringify(an));

  // ── Filter after the edits: a row tag in a tagged section, and in an untagged one ──
  await page.click('.tag-chip[data-tag="batchy"]'); await settle(page, 200);
  v = await vis();
  check('"batchy": Fab one (in Tech) and Misc one (untagged section), each with its header, nothing else', JSON.stringify(v.tasks) === JSON.stringify(['Fab one', 'Misc one']) && JSON.stringify(v.heads) === JSON.stringify(['Tech · Fab', 'Misc']), JSON.stringify(v));
  check('table: the same 2', JSON.stringify(await tableNames()) === JSON.stringify(['Fab one', 'Misc one']));
  await shot(page, 'row-tags-1440-filtered');
  // Persistence: the filter and the tags survive a reload.
  await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
  v = await vis();
  check('after a reload the row tags and the filter are both still there', JSON.stringify(v.tasks) === JSON.stringify(['Fab one', 'Misc one']) && await lbl('Fab one') === '#hot #batchy', JSON.stringify(v));
  await page.click('#tagClear'); await settle(page, 200);
  // The label never gives up the name for the tags.
  const squeeze = await page.evaluate(() => [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.checkVisibility() && e.querySelector('.row-tags'))
    .map(e => { const n = e.querySelector('.name-text'); return n.scrollWidth > n.clientWidth + 1 ? n.textContent : ''; }).filter(Boolean));
  check('1440: no row name is cut to make room for its tags', squeeze.length === 0, squeeze.join(', '));
  await shot(page, 'row-tags-1440');
  await page.close();

  // ── Phone width: the chart and the table with the Tags column ──
  const m = await openPage(browser, X.url, { w: 393, h: 852, mobile: true });
  await assertNoSidewaysScroll(m, '393, chart with row tags');
  const sq393 = await m.evaluate(() => [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.checkVisibility() && e.querySelector('.row-tags'))
    .map(e => { const n = e.querySelector('.name-text'); return n.scrollWidth > n.clientWidth + 1 ? n.textContent : ''; }).filter(Boolean));
  check('393: no row name is cut to make room for its tags', sq393.length === 0, sq393.join(', '));
  await shot(m, 'row-tags-393');
  await m.evaluate(() => document.querySelector('.vt-btn[data-view="table"]').click()); await settle(m, 200);
  await assertNoSidewaysScroll(m, '393, table with the Tags column');
  await shot(m, 'row-tags-393-table');
  await m.close();

  // ── A narrow label column: the TAGS give way, never the name (0.60.0 review) ──
  const N = await startServer({ project: { title: 'Narrow', appName: 'narrow', view: { mode: 'gantt', grain: 'week', labelWidth: 270 } },
    phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],
    items: [T('n1', 'NEWRO onboarding', 0, { owner: 'Drew', status: 'in-progress', tags: ['northeast', 'regulatory', 'procurement'] })] }, 'narrow');
  const np = await openPage(browser, N.url);
  const nr = await np.evaluate(() => { const e = document.querySelector('.label-row:not(.group)'), n = e.querySelector('.name-text'), t = e.querySelector('.row-tags');
    const rg = document.createRange(); rg.selectNodeContents(n); const full = rg.getBoundingClientRect().width;
    return { col: Math.round(document.getElementById('labels').getBoundingClientRect().width), name: n.getBoundingClientRect().width, full, nameCut: n.scrollWidth > n.clientWidth, tagsCut: t.scrollWidth > t.clientWidth + 1 }; });
  check('270px label column: the name is whole and the tags are the part cut', nr.col <= 272 && !nr.nameCut && nr.name + 0.5 >= nr.full && nr.tagsCut, JSON.stringify(nr));
  await shot(np, 'row-tags-270-label');
  await np.close();

  // ── CSV: out with a tags column, and back in ──
  const csv = await (await fetch(X.url + 'export/csv')).text();
  const hdr = csv.split('\n')[0].split(',');
  const ti = hdr.indexOf('tags');
  const csvRow = n => csv.split('\n').find(l => l.includes(',' + n + ','));
  check('the CSV export has a tags column, joined with ;', ti > 0 && csvRow('Ops two').split(',')[ti] === 'urgent;long-lead;late', csvRow('Ops two'));
  const I = await startServer({ project: { title: 'Import Target', appName: 'row-tags-import' }, phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],
    items: [{ type: 'task', id: 'x', name: 'Before import', start: '2026-10-05', end: '2026-10-09', phase: 0, uid: '00001' }] }, 'row-tags-import');
  const ip = await openPage(browser, I.url);
  const csvFile = join(TMP, 'row-tags.csv'); writeFileSync(csvFile, csv);
  await ip.click('#btnImport'); await settle(ip, 100);
  const [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }), ip.click('#importMenu .dd-item[data-fmt="csv"]')]);
  await Promise.all([ip.waitForNavigation({ waitUntil: 'load', timeout: 10000 }).then(() => ready(ip)).catch(() => null), ch.accept([csvFile])]);
  await settle(ip, 300);
  const imp = own(await exportPlan(I.url));
  check('importing that CSV reads the tags back onto the rows', JSON.stringify(imp['Ops two']) === '["urgent","long-lead","late"]' && JSON.stringify(imp['Fab one']) === '["hot","batchy"]' && JSON.stringify(imp['Fab two']) === '[]', JSON.stringify(imp));
  check('and the imported page shows them', (await ip.$$eval('.row-tags', e => e.map(x => x.textContent))).includes('#urgent #long-lead #late'));
  await ip.close();

  // ── The snapshot: row tags shown, filtered, and edited with no server ──
  const snapFile = join(TMP, 'row-tags-snapshot.html'); writeFileSync(snapFile, await (await fetch(X.url + 'export/html')).text());
  const sp = await openPage(browser, 'file://' + snapFile);
  check('the snapshot shows row tags in the label rows', (await sp.$$eval('.row-tags', e => e.map(x => x.textContent))).includes('#hot #batchy'));
  await sp.click('.tag-chip[data-tag="urgent"]'); await settle(sp, 200);
  check('the snapshot filters by a row tag', JSON.stringify(await sp.evaluate(() => [...document.querySelectorAll('.label-row:not(.group):not(.ms)')].filter(e => e.checkVisibility()).map(e => e.querySelector('.name-text').textContent))) === '["Ops two"]');
  await sp.click('#tagClear'); await settle(sp, 200);
  await view(sp, 'table');
  await sp.click('#tableView tr[data-key="t:f2"] td[data-field="tags"]'); await settle(sp, 100);
  await sp.keyboard.type('offline'); await sp.keyboard.press('Enter'); await sp.keyboard.press('Enter'); await settle(sp, 200);
  const f2 = () => sp.$eval('#tableView tr[data-key="t:f2"] td.c-tags', td => [...td.querySelectorAll('.row-tag:not(.inh)')].map(x => x.textContent).join(','));
  check('the snapshot edits a row\'s tags in the table with no server (Enter twice: add, then commit)', await f2() === 'offline', await f2());
  await sp.evaluate(() => document.activeElement && document.activeElement.blur()); await undo(sp);
  check('and takes it back with Ctrl+Z', await f2() === '');
  await redo(sp);
  check('and redoes it', await f2() === 'offline');
  await sp.close();
}

// ── Cleanup: always, however the run ends ──
// The normal path, an external SIGINT/SIGTERM (Ctrl+C, a CI timeout) and the watchdog
// all come through here, so no server, Chrome or temp dir is left behind.
let BROWSER = null, cleanedUp = false, UNTESTED = null;
// ── Effort (0.63.0): estimated hours per task, and Analytics counting only those ──
// Four-week bars, so the old rule (every bar = one person full time) would have put each
// at 152h. Effort is what counts now: 16 + 8 = 24h, the cancelled 40 out, one task not
// estimated, and the milestone's 99 dropped because a milestone is a date, not work.
async function effortFlow(browser) {
  section = 'effort';
  const T = (id, name, ph, x = {}) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-30', phase: ph, owner: 'Drew', ...x });
  const X = await startServer({ project: { title: 'Effort Test', appName: 'effort-test', view: { mode: 'gantt', grain: 'week', milestones: 'diamonds' } },
    phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],
    items: [T('b1', 'Build one', 0, { effort: 16 }), T('b2', 'Build two', 0), T('s1', 'Ship one', 1, { effort: '8', owner: 'Ana' }),
      T('s2', 'Ship dropped', 1, { effort: 40, status: 'cancelled', owner: 'Ana' }), { type: 'milestone', name: 'Ship gate', date: '2026-11-02', phase: 1, effort: 99 }] }, 'effort');
  let page = await openPage(browser, X.url);
  const md = async () => (await fetch(X.url + 'changelog?format=md')).text();
  const row = async id => ((await savedPlan(X.url)).items || []).find(r => r.id === id) || {};
  const effLog = async () => (await (await fetch(X.url + 'changelog')).json()).events.filter(e => e.action === 'effort');
  const hero = async () => { await view(page, 'analytics'); const r = await page.$$eval('#anScroll .an-hero .an-stat', es => es.map(e => e.querySelector('.k').textContent + '=' + e.querySelector('.v').textContent)); return r; };
  const table = sel => page.$$eval(sel + ' tbody tr', rs => rs.map(tr => [...tr.querySelectorAll('td')].map(td => td.textContent.trim()).filter(Boolean).join('|')));
  let p = await exportPlan(X.url);
  check('a data-file effort is normalised ("8" -> 8) and a milestone carries none', p.items.find(r => r.id === 's1').effort === 8 && !('effort' in p.items.find(r => r.type === 'milestone')), JSON.stringify(p.items.map(r => [r.name, r.effort])));
  let h = await hero();
  check('total effort is the sum of the ESTIMATES (16 + 8), not the bars, and the cancelled 40 is out', h.some(x => x === 'total effort, from estimates=24h'), JSON.stringify(h));
  check('the total card says how complete it is: 2 of 3 live tasks estimated, 1 not yet', h.some(x => x === 'tasks estimated, 1 not yet=2 of 3'), JSON.stringify(h));
  const people = await page.$$eval('#anScroll .an-table.people tbody tr', rs => rs.map(tr => [...tr.querySelectorAll('td')].map(td => td.textContent.trim())).map(c => [c[0], c[2], c[5]].join('|')));
  // (the milestone has no owner, so the unassigned bucket is there with 0h and +1M)
  check('per person: Drew 16h with 1 not estimated, Ana 8h with none', JSON.stringify(people) === JSON.stringify(['Drew|16h|1', 'Ana|8h|0', 'unassigned|0h|0', 'Total|24h|1']), JSON.stringify(people));
  const sec = await page.$$eval('#anScroll .an-card', cs => { const c = cs.find(x => x.querySelector('h3').textContent === 'Per section'); return [...c.querySelectorAll('tbody tr')].map(tr => [...tr.querySelectorAll('td')].slice(0, 4).map(td => td.textContent.trim()).join('|')); });
  check('per section: Build 16h, 1 not estimated; Ship 8h, none', JSON.stringify(sec) === JSON.stringify(['Build|2|16h|1', 'Ship|3|8h|0', 'Total|5|24h|1']), JSON.stringify(sec));
  await shot(page, 'effort-analytics');

  // ── The Table's Effort column ──
  await view(page, 'table');
  const cell = id => `#tableView tr[data-key="t:${id}"] td[data-field="effort"]`;
  const cellText = id => page.$eval(cell(id), td => td.textContent.trim());
  check('the Table has an Effort column: 16h, and "not est." where there is none', await cellText('b1') === '16h' && await cellText('b2') === 'not est.', (await cellText('b1')) + ' / ' + (await cellText('b2')));
  check('a milestone has no Effort cell to edit', await page.$$eval('#tableView tr.is-ms td.c-effort', tds => tds.length > 0 && tds.every(td => !td.classList.contains('editable') && !td.dataset.field)));
  let n0 = (await effLog()).length;
  await page.click(cell('b2')); await settle(page, 100);
  await page.keyboard.type('2d'); await page.keyboard.press('Enter'); await settle(page, 300);
  check('typing 2d sets 16 hours (two 8-hour days)', (await row('b2')).effort === 16 && await cellText('b2') === '16h', JSON.stringify(await row('b2')));
  let L = (await effLog()).slice(n0);
  check('one `effort` line via table-edit, from not estimated to 16', L.length === 1 && L[0].via === 'table-edit' && L[0].from.effort === null && L[0].to.effort === 16, JSON.stringify(L));
  check('and it reads as a sentence', (await md()).includes('set the effort on "Build two" to 16h (was not estimated) (table-edit)'), (await md()).split('\n').filter(l => /effort/.test(l)).join(' | '));
  h = await hero(); await view(page, 'table');
  check('Analytics follows: 40h, 3 of 3 estimated', h.includes('total effort, from estimates=40h') && h.some(x => x.startsWith('tasks estimated=3 of 3')), JSON.stringify(h));
  await undo(page); await settle(page, 300);
  check('Ctrl+Z takes it back to not estimated', !('effort' in (await row('b2'))) && await cellText('b2') === 'not est.', JSON.stringify(await row('b2')));
  await page.keyboard.down('Control'); await page.keyboard.down('Shift'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Shift'); await page.keyboard.up('Control'); await settle(page, 300);
  check('redo puts the 16 back', (await row('b2')).effort === 16);
  await page.click(cell('b2')); await settle(page, 100);
  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control'); await page.keyboard.type('lots'); await page.keyboard.press('Enter'); await settle(page, 300);
  check('something that is not hours is refused, and nothing changes', (await row('b2')).effort === 16 && await cellText('b2') === '16h');
  // Cleared, on a row whose effort came from the DATA FILE: the sidecar has to say so.
  await page.click(cell('b1')); await settle(page, 100);
  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control'); await page.keyboard.press('Backspace'); await page.keyboard.press('Enter'); await settle(page, 300);
  check('emptying the cell clears a data-file estimate', !('effort' in (await row('b1'))) && await cellText('b1') === 'not est.', JSON.stringify(await row('b1')));
  await page.click('#tableHead th[data-sort="effort"]'); await settle(page, 200);
  const order = await page.$$eval('#tableView tbody tr[data-key] td[data-field="effort"]', tds => tds.map(td => td.closest('tr').dataset.key));
  check('the column sorts by hours, the not-estimated rows last', JSON.stringify(order) === JSON.stringify(['t:s1', 't:b2', 't:s2', 't:b1']), JSON.stringify(order));
  await page.click('#tableHead th[data-sort="effort"]'); await page.click('#tableHead th[data-sort="effort"]'); await settle(page, 200);

  // ── The task editor ──
  await view(page, 'gantt');
  await openItemEditor(page, 'Ship one');
  check('the task editor has an effort field holding the estimate', await page.$eval('#imEffort', e => e.value) === '8');
  await setVal(page, '#imEffort', 'eight'); await page.click('#imApply'); await settle(page, 200);
  check('a bad value is refused with a reason and the editor stays open', await editorShown(page) && /Effort is hours/.test(await page.$eval('#imErr', e => e.textContent)) && (await row('s1')).effort === 8);
  await setVal(page, '#imEffort', '1.5w'); await page.click('#imApply'); await settle(page, 300);
  check('1.5w is 60 hours, saved on Apply', (await row('s1')).effort === 60, JSON.stringify(await row('s1')));
  await undo(page); await settle(page, 300);
  check('one undo takes the editor\'s effort back', (await row('s1')).effort === 8);
  await openItemEditor(page, 'Ship gate');
  check('a milestone\'s editor has no effort field', await page.$eval('#imEffWrap', e => getComputedStyle(e).display) === 'none');
  await page.keyboard.press('Escape'); await settle(page, 150);

  // ── The New panel ──
  await page.click('#btnNew'); await settle(page, 200);
  await page.type('#ncName', 'Fresh build'); await setVal(page, '#ncEffort', '3');
  await page.click('#ncCreate'); await settle(page, 900);
  const fresh = ((await savedPlan(X.url)).items || []).find(r => r.name === 'Fresh build') || {};
  check('New saves the effort on the row it makes', fresh.effort === 3, JSON.stringify(fresh));
  check('and logs it via create', (await effLog()).some(e => e.name === 'Fresh build' && e.via === 'create' && e.to.effort === 3));
  await page.click('#btnNew'); await settle(page, 200);
  check('the next New panel remembers the effort, like every field but the name', await page.$eval('#ncEffort', e => e.value) === '3');
  await page.select('#ncType', 'milestone'); await settle(page, 100);
  check('and hides it for a milestone', await page.$eval('#ncEffWrap', e => getComputedStyle(e).display) === 'none');
  await page.click('#ncCancel'); await settle(page, 150);

  // ── Copy and paste carry it; CSV carries it; a reload keeps it ──
  await view(page, 'table');
  await page.keyboard.down('Control'); await page.click('#tableView tr[data-key="t:s1"] td.c-deps'); await page.keyboard.up('Control'); await settle(page, 100);
  await page.keyboard.down('Control'); await page.keyboard.press('KeyC'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control'); await settle(page, 700);
  const clone = ((await savedPlan(X.url)).items || []).find(r => r.name === 'Ship one 2') || {};
  check('a pasted copy carries the estimate', clone.effort === 8, JSON.stringify(clone));
  const csv = await (await fetch(X.url + 'export/csv')).text();
  const hd = csv.split('\n')[0].split(','), ei = hd.indexOf('effort');
  const csvRow = csv.split('\n').find(l => l.includes(',s1,'));
  check('the CSV has an effort column', ei > 0 && csvRow && csvRow.split(',')[ei] === '8', hd.join(',') + ' :: ' + csvRow);
  await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page, 300);
  await view(page, 'table');
  check('a reload keeps every estimate, and the cleared one stays cleared', await cellText('b2') === '16h' && await cellText('b1') === 'not est.' && await cellText('s1') === '8h');
  await shot(page, 'effort-table');

  // ── The batch panel sets (or clears) effort on every selected task at once (0.63.1) ──
  await view(page, 'gantt'); await clearSel(page);
  await clickBar(page, 'b1', ['Control']); await clickBar(page, 'b2', ['Control']); await settle(page, 200);
  check('the batch panel has an effort row and says where the selection stands', await page.$eval('#bEffSum', e => e.textContent) === '1 of 2 tasks estimated, 16h in all', await page.$eval('#bEffSum', e => e.textContent).catch(() => 'no #bEffSum'));
  n0 = (await effLog()).length;
  await page.click('#bEffort'); await page.keyboard.type('4'); await page.keyboard.press('Enter'); await settle(page, 400);
  check('Enter sets 4h on both selected tasks', (await row('b1')).effort === 4 && (await row('b2')).effort === 4, JSON.stringify([await row('b1'), await row('b2')]));
  check('the panel stays open and its summary follows', await editorShown(page) && await page.$eval('#bEffSum', e => e.textContent) === '2 of 2 tasks estimated, 8h in all');
  L = (await effLog()).slice(n0);
  check('two `effort` lines via batch-editor', L.length === 2 && L.every(e => e.via === 'batch-editor' && e.to.effort === 4), JSON.stringify(L));
  await page.$eval('#bEffort', e => e.blur());
  await undo(page); await settle(page, 400);
  check('one undo puts both back (not estimated, 16h)', !('effort' in (await row('b1'))) && (await row('b2')).effort === 16, JSON.stringify([await row('b1'), await row('b2')]));
  await clearSel(page);
  await clickBar(page, 'b1', ['Control']); await clickBar(page, 'b2', ['Control']); await settle(page, 200);
  await page.click('#bEffort'); await page.keyboard.type('soon'); await page.click('#bEffortSet'); await settle(page, 300);
  check('a bad value is refused with a reason, nothing changes', /Effort is hours/.test(await page.$eval('#bEffErr', e => e.textContent)) && (await row('b2')).effort === 16);
  await page.$eval('#bEffort', e => { e.value = ''; }); await page.click('#bEffortSet'); await settle(page, 400);
  check('an empty box clears the estimate on all of them', !('effort' in (await row('b1'))) && !('effort' in (await row('b2'))));
  await clearSel(page);
  await page.close();
}

// ── Live tabs (0.66.0): two people in one plan, no clobbering, presence ──
// Two tabs in one browser context. Through 0.65 the second tab's first save after the
// first tab's was refused (409) and the red banner offered discard or overwrite. Now a
// tab hears every landed save and merges it under its own unsaved rows, so both edits
// land, both tabs show both, and a refresh has nothing to clobber.
async function collabFlow(browser) {
  section = 'collab';
  const T = (id, name, ph, x = {}) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew', ...x });
  const X = await startServer({ project: { title: 'Collab Test', appName: 'collab-test', view: { mode: 'gantt', grain: 'week' } },
    phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],
    items: [T('b1', 'Build one', 0), T('b2', 'Build two', 0), T('b3', 'Build three', 0), T('s1', 'Ship one', 1), T('s2', 'Ship two', 1),
      { type: 'milestone', name: 'Ship gate', date: '2026-11-02', phase: 1 }] }, 'collab');
  const ctx = await browser.createBrowserContext();
  const A = await openPage(browser, X.url, { ctx });
  const B = await openPage(browser, X.url, { ctx, clear: false });
  const row = async id => ((await savedPlan(X.url)).items || []).find(r => r.id === id) || {};
  const ownerShown = (pg, id) => pg.evaluate(id => { const r = [...document.querySelectorAll('.label-row:not(.group)')].find(e => e.dataset.id === id); const p = r && r.querySelector('.owner-pill'); return p ? p.textContent : null; }, id);
  const endShown = (pg, id) => pg.evaluate(id => { const r = [...document.querySelectorAll('.label-row:not(.group)')].find(e => e.dataset.id === id); return r ? r.querySelector('.name-text').textContent : null; }, id);
  const until = async (fn, ms = 4000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await sleep(120); } return fn(); };
  const banner = pg => pg.$eval('#conflictBar', e => e.classList.contains('show'));
  // A live merge can repaint the other tab's table between finding a cell and clicking it.
  const clickFresh = async (pg, sel) => { for (let i = 0; i < 4; i++) { try { await pg.click(sel); return; } catch (e) { if (!/detached|not clickable|No element/.test(String(e))) throw e; await sleep(150); } } await pg.click(sel); };
  const presence = pg => pg.evaluate(() => { const e = document.getElementById('presence'); return e ? e.querySelector('.presence-n').textContent : ''; });

  // Presence: both tabs see two people here.
  check('both tabs show "2 tabs" in the legend', await until(async () => (await presence(A)) === '2 tabs' && (await presence(B)) === '2 tabs'), `${await presence(A)} / ${await presence(B)}`);
  // 0.74.0: the row one tab has selected is marked, with initials, in the other tab.
  const peerOn = (pg, id) => pg.evaluate(id => { const r = [...document.querySelectorAll('.label-row:not(.group)')].find(e => e.dataset.id === id); return r ? { on: r.classList.contains('peer-focus'), chip: (r.querySelector('.peer-chip') || {}).textContent || '' } : null; }, id);
  await A.bringToFront(); await clickBar(A, 'b2'); await settle(A, 300);
  check('a row selected in one tab is marked with initials in the other', await until(async () => { const p = await peerOn(B, 'b2'); return p && p.on && p.chip === 'G'; }), JSON.stringify(await peerOn(B, 'b2')));
  check('and not in the tab that selected it', !(await peerOn(A, 'b2')).on);
  await B.bringToFront(); await view(B, 'table');
  check('the table marks the same row', await until(async () => B.evaluate(() => { const t = document.querySelector('#tableView tr[data-key="t:b2"]'); return !!t && t.classList.contains('peer-focus') && !!t.querySelector('.peer-chip'); })));
  await view(B, 'gantt');
  await A.bringToFront(); await A.keyboard.press('Escape'); await A.keyboard.press('Escape'); await settle(A, 400);
  check('letting go of the row clears the mark in the other tab', await until(async () => { const p = await peerOn(B, 'b2'); return p && !p.on; }), JSON.stringify(await peerOn(B, 'b2')));
  check('the chips carry initials with the name on hover', await A.$eval('#presence .presence-chip', e => e.textContent.length >= 1 && !!e.title));

  // 1. A edits one row; B sees it without reloading.
  await A.bringToFront(); await view(A, 'table');
  await clickFresh(A, '#tableView tr[data-key="t:b1"] td[data-field="owner"]'); await settle(A, 100);
  await A.keyboard.down('Control'); await A.keyboard.press('KeyA'); await A.keyboard.up('Control'); await A.keyboard.type('Ana'); await A.keyboard.press('Enter'); await settle(A, 300);
  check('A\'s edit is saved', (await row('b1')).owner === 'Ana', JSON.stringify(await row('b1')));
  check('B shows A\'s edit live, with no reload', await until(async () => (await ownerShown(B, 'b1')) === 'Ana'), await ownerShown(B, 'b1'));

  // 2. B edits another row; A sees it.
  await B.bringToFront(); await clickBar(B, 's1'); await setVal(B, '#imEnd', '2026-10-16'); await B.click('#imApply'); await settle(B, 300);
  check('B\'s edit is saved and B\'s save was not refused', (await row('s1')).end === '2026-10-16' && !(await banner(B)), JSON.stringify(await row('s1')));
  check('A\'s saved baseline followed (its next save will not be refused)', await until(async () => (await A.evaluate(() => window.__collab.rev())) === (await B.evaluate(() => window.__collab.rev()))));
  await A.bringToFront(); await view(A, 'gantt');
  check('A shows B\'s new end live (the bar\'s dates read 16-Oct)', await until(async () => /16-Oct-2026/.test(await A.$eval('.bar[data-id="s1"]', e => e.dataset.dates || ''))), await A.$eval('.bar[data-id="s1"]', e => e.dataset.dates || ''));

  // 3. Both edit at once, different rows: both land, nobody is refused, both tabs agree.
  await view(A, 'table'); await B.bringToFront(); await view(B, 'table'); await A.bringToFront();
  await clickFresh(A, '#tableView tr[data-key="t:b2"] td[data-field="owner"]'); await settle(A, 80);
  await A.keyboard.down('Control'); await A.keyboard.press('KeyA'); await A.keyboard.up('Control'); await A.keyboard.type('Kai'); await A.keyboard.press('Enter');
  await B.bringToFront(); await clickFresh(B, '#tableView tr[data-key="t:s2"] td[data-field="owner"]'); await settle(B, 80);
  await B.keyboard.down('Control'); await B.keyboard.press('KeyA'); await B.keyboard.up('Control'); await B.keyboard.type('Noah'); await B.keyboard.press('Enter');
  await settle(A, 1500); await settle(B, 300);
  const both = async () => { const p = await savedPlan(X.url, { minWait: 200 }); const g = id => (p.items.find(r => r.id === id) || {}).owner; return g('b2') === 'Kai' && g('s2') === 'Noah'; };
  check('two edits in the same second, different rows: both are saved', await until(both, 6000), JSON.stringify(((await savedPlan(X.url, { minWait: 100 })).items || []).map(r => [r.id, r.owner])));
  check('neither tab was told its changes are not saving', !(await banner(A)) && !(await banner(B)));
  const cellOwner = (pg, id) => pg.$eval(`#tableView tr[data-key="t:${id}"] td[data-field="owner"]`, e => e.textContent);
  check('both tabs show both edits', await until(async () => (await cellOwner(A, 's2')) === 'Noah' && (await cellOwner(B, 'b2')) === 'Kai'), `${await cellOwner(A, 's2')} / ${await cellOwner(B, 'b2')}`);

  // 4. A refresh has nothing to clobber.
  await B.reload({ waitUntil: 'load' }).then(() => ready(B)); await settle(B, 400); await view(B, 'table');
  check('after a refresh B still has every edit', (await cellOwner(B, 'b1')) === 'Ana' && (await cellOwner(B, 'b2')) === 'Kai' && (await cellOwner(B, 's2')) === 'Noah');

  // 5. A stale tab: A stops listening, B saves, A saves. Since 0.77.0 A's save is a field op
  //    and lands as it is (no 409); A notices it missed a revision and catches up, and the
  //    plan ends with BOTH edits and no banner. A 409 stays allowed for a build that refuses.
  const allow409 = { path: /^\/save$/, status: 409 }; ALLOWED_FAILURES.push(allow409);
  await A.bringToFront(); await A.evaluate(() => window.__collab.pause());
  await B.bringToFront(); await clickFresh(B, '#tableView tr[data-key="t:b3"] td[data-field="owner"]'); await settle(B, 80);
  await B.keyboard.down('Control'); await B.keyboard.press('KeyA'); await B.keyboard.up('Control'); await B.keyboard.type('Ray'); await B.keyboard.press('Enter'); await settle(B, 1300);
  check('B\'s edit landed while A was not listening', (await row('b3')).owner === 'Ray');
  await A.bringToFront();
  const m0 = await A.evaluate(() => window.__collab.merges());
  await clickFresh(A, '#tableView tr[data-key="t:s1"] td[data-field="owner"]'); await settle(A, 80);
  await A.keyboard.down('Control'); await A.keyboard.press('KeyA'); await A.keyboard.up('Control'); await A.keyboard.type('Zoe'); await A.keyboard.press('Enter'); await settle(A, 1500);
  check('A\'s save landed while A was not listening, and A caught up on what it missed', await until(async () => (await A.evaluate(() => window.__collab.merges())) > m0 && (await row('s1')).owner === 'Zoe', 6000), JSON.stringify({ merges: await A.evaluate(() => window.__collab.merges()), s1: await row('s1') }));
  check('and B\'s edit from the gap survived it', (await row('b3')).owner === 'Ray' && (await cellOwner(A, 'b3')) === 'Ray', await cellOwner(A, 'b3'));
  check('no banner on A either', !(await banner(A)));
  await A.evaluate(() => window.__collab.resume());
  ALLOWED_FAILURES.splice(ALLOWED_FAILURES.indexOf(allow409), 1);

  // 6. A row created in one tab appears in the other; a row deleted in one disappears in the other.
  await B.bringToFront(); await B.click('#btnNew'); await settle(B, 200); await B.type('#ncName', 'Made in B'); await B.click('#ncCreate'); await settle(B, 900);
  check('a row made in B appears in A live', await until(async () => (await A.$$eval('#tableView tr[data-key] td.c-name', es => es.map(e => e.textContent))).some(t => t.includes('Made in B'))));
  await A.bringToFront(); await A.hover('#tableView tr[data-key="t:b2"]'); await settle(A, 150);
  await clickFresh(A, '#tableView tr[data-key="t:b2"] .row-del'); await settle(A, 900);
  check('a row deleted in A disappears from B live', await until(async () => !(await B.$('#tableView tr[data-key="t:b2"]'))));
  await undo(A); await settle(A, 900);
  check('and comes back in B when A undoes', await until(async () => !!(await B.$('#tableView tr[data-key="t:b2"]'))));

  // 7. One tab closes: presence drops to one and the strip goes away.
  await B.close(); 
  check('when B closes, A is alone and the strip goes', await until(async () => (await presence(A)) === '', 6000), await presence(A));
  await A.close(); await ctx.close();
}

// ── Google Chat digests (0.67.0): the Setup card, against a fake Chat on loopback ──
async function notifyFlow(browser) {
  section = 'notify';
  const http = await import('node:http');
  const posts = [];
  const fake = http.createServer((req, res) => { let b = ''; req.on('data', d => b += d); req.on('end', () => { try { posts.push(JSON.parse(b)); } catch { posts.push(null); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{}'); }); });
  const fp = await new Promise(r => fake.listen(0, '127.0.0.1', () => r(fake.address().port)));
  const env0 = { ...process.env };
  Object.assign(process.env, { ADOM_GANTT_CHAT_ALLOW_LOOPBACK: '1', ADOM_GANTT_CHAT_QUIET_MS: '1500', ADOM_GANTT_CHAT_MAX_MS: '8000', ADOM_GANTT_CHAT_TICK_MS: '300' });
  const T = (id, name, ph) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew' });
  const X = await startServer({ project: { title: 'Notify Test', appName: 'notify-test', view: { mode: 'setup' } },
    phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }], items: [T('n1', 'Notify one', 0), T('n2', 'Notify two', 0)] }, 'notify');
  for (const k of ['ADOM_GANTT_CHAT_ALLOW_LOOPBACK', 'ADOM_GANTT_CHAT_QUIET_MS', 'ADOM_GANTT_CHAT_MAX_MS', 'ADOM_GANTT_CHAT_TICK_MS']) { if (k in env0) process.env[k] = env0[k]; else delete process.env[k]; }
  const post = (body) => fetch(X.url + 'notify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
  const page = await openPage(browser, X.url);
  await view(page, 'setup'); await settle(page, 400);
  const card = () => page.evaluate(() => ({ state: document.getElementById('chatState').textContent, status: document.getElementById('chatStatus').textContent,
    cadence: document.getElementById('chatCadence').value, disabled: document.getElementById('chatCadence').disabled, test: document.getElementById('chatTest').disabled }));
  let c = await card();
  check('with no webhook, the card says so and its controls are off', c.state === 'not set up' && c.disabled && c.test && /No space is set up/.test(c.status), JSON.stringify(c));
  const wr = await fetch(X.url + 'notify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '1.2.3.4' }, body: JSON.stringify({ webhook: `http://127.0.0.1:${fp}/v1/spaces/UITEST/messages?key=K&token=T` }) });
  check('a request that is not from this machine, and not the owner, cannot set the webhook', wr.status === 403, String(wr.status));
  // The owner's field (0.68.0). On this machine the page counts as the operator, so it shows.
  const hookRow = await page.evaluate(() => ({ shown: document.getElementById('chatHookRow').style.display !== 'none', clear: document.getElementById('chatHookClear').style.display }));
  check('the webhook field shows for the owner, without a remove button while nothing is set', hookRow.shown && hookRow.clear === 'none', JSON.stringify(hookRow));
  await page.click('#chatHook'); await page.type('#chatHook', 'https://example.com/not-a-webhook'); await page.click('#chatHookSave'); await settle(page, 400);
  c = await card();
  check('a URL that is not a Chat webhook is refused from the field, and the card stays not set up', c.state === 'not set up' && /not set|was not set/.test(await page.evaluate(() => document.getElementById('saveInd').textContent)), JSON.stringify(c));
  await page.click('#chatHook', { clickCount: 3 }); await page.keyboard.press('Backspace');
  await page.type('#chatHook', `http://127.0.0.1:${fp}/v1/spaces/UITEST/messages?key=K&token=T`); await page.keyboard.press('Enter'); await settle(page, 500);
  check('the webhook is set from the field and the field is emptied: the URL is never shown again', (await page.$eval('#chatHook', e => e.value)) === '' && (await page.evaluate(() => document.getElementById('chatHookClear').style.display)) !== 'none');
  await view(page, 'gantt'); await view(page, 'setup'); await settle(page, 400);
  c = await card();
  const leaks = await page.evaluate(() => /UITEST\/messages|key=K|token=T/.test(document.getElementById('suChat').outerHTML + document.body.innerText));
  check('once set, the card says on, names the space by its first characters, never the URL', c.state === 'on' && /spaces\/UITE\.\.\./.test(c.status) && !c.disabled && !leaks, JSON.stringify(c));
  await page.select('#chatCadence', 'hourly'); await settle(page, 400);
  check('the cadence is set from the card', (await (await fetch(X.url + 'notify')).json()).cadence === 'hourly');
  await page.select('#chatCadence', 'live'); await settle(page, 300);
  await page.click('#chatTest'); await settle(page, 600);
  check('Send a test message posts one', posts.length === 1 && /test message from adom-gantt/.test(posts[0].text), JSON.stringify(posts));
  // An edit in the app becomes a digest once the plan is quiet.
  await view(page, 'table');
  await page.click('#tableView tr[data-key="t:n1"] td[data-field="owner"]'); await settle(page, 100);
  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');
  await page.keyboard.type('Kai'); await page.keyboard.press('Enter');
  const t0 = Date.now(); while (Date.now() - t0 < 8000 && posts.length < 2) await sleep(200);
  check('an edit in the app arrives as a digest a moment after the plan goes quiet', posts.length === 2 && /reassigned "Notify one" from Drew to Kai/.test(posts[1].text), JSON.stringify(posts[1]));
  // Removing the webhook takes two clicks, and then the card is back to not set up.
  await view(page, 'setup'); await settle(page, 400);
  await page.click('#chatHookClear'); await settle(page, 200);
  check('one click on Remove webhook only arms it', (await (await fetch(X.url + 'notify')).json()).configured === true && /Confirm/.test(await page.$eval('#chatHookClear', e => e.textContent)));
  await page.click('#chatHookClear'); await settle(page, 500);
  c = await card();
  check('the second click removes it', (await (await fetch(X.url + 'notify')).json()).configured === false && c.state === 'not set up', JSON.stringify(c));
  await page.close(); fake.close();
}
// 0.73.0: every row has a link (#row=<uid>) with a copy button on the chart, in the table and
// in the editor; opening a link selects and shows the row; the team does not see Analytics or
// the Sharing and Chat settings, and the owner can preview that; tags fold like sections.
async function rowLinksFlow(browser) {
  section = 'row links';
  const T = (id, name, ph, extra) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew', ...extra });
  const tags = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', 'golf', 'hotel', 'india', 'juliet'];
  const X = await startServer({ project: { title: 'Links Test', appName: 'links-test' }, phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],
    items: [T('l1', 'Link task', 0, { tags: tags.slice(0, 5) }), T('l2', 'Far task', 1, { tags: tags.slice(5) }), { type: 'milestone', name: 'Link gate', date: '2026-10-20', phase: 1 }] }, 'links');
  const plan = await savedPlan(X.url, { minWait: 200 });
  const u1 = plan.items.find(r => r.id === 'l1').uid, u2 = plan.items.find(r => r.id === 'l2').uid;
  const base = X.url.replace(/\/$/, '') + '/';
  try { await browser.defaultBrowserContext().overridePermissions(X.url.replace(/\/$/, ''), ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']); } catch (e) {}
  const page = await openPage(browser, X.url);
  const last = () => page.evaluate(() => window.__lastRowLink || '');
  const want = (u, n) => `${base}#row=${u}-${n.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
  // Chart: the link button on the row.
  const vis = await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.textContent.includes('Link task')); const b = e && e.querySelector('.row-link'); if (!b) return null; const r = b.getBoundingClientRect(); return { w: r.width, op: +getComputedStyle(b).opacity }; });
  check('every chart row shows a copy-link button without hovering', !!vis && vis.w > 0 && vis.op > 0.3, JSON.stringify(vis));
  await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.textContent.includes('Link task')); e.querySelector('.row-link').dataset.t = '1'; });
  await page.click('.row-link[data-t="1"]'); await settle(page, 300);
  check('the chart button copies the bare link, with the name readable in it', (await last()) === want(u1, 'Link task'), await last());
  check('and does not open the editor or rename the row', !(await page.$('.editor.show')) && !(await page.$('.label-row input')));
  // Table: the ID column.
  await view(page, 'table');
  const idTxt = await page.$eval('#tableView tr[data-key="t:l2"] .uid-link', b => b.textContent.trim());
  check('the table has an ID column showing each row\'s permanent ID', idTxt === '#' + u2, idTxt);
  await page.click('#tableView tr[data-key="t:l2"] .uid-link'); await settle(page, 300);
  check('clicking the ID copies the link to that row', (await last()) === want(u2, 'Far task') && !(await page.$('.cell-edit')), await last());
  // The editor.
  await view(page, 'gantt'); await openItemEditor(page, 'Link task');
  check('the item editor has Copy link #ID', await page.$eval('#imCopyLink', b => b.textContent) === 'Copy link #' + u1);
  await page.click('#imCopyLink'); await settle(page, 300);
  check('and it copies the same link, leaving the editor open', (await last()) === want(u1, 'Link task') && !!(await page.$('.editor.show')));
  await page.keyboard.press('Escape'); await settle(page, 200);
  // Opening a link: a collapsed section opens, the row is selected and the fragment is consumed.
  await page.goto(want(u2, 'Far task')); await ready(page); await settle(page, 600);
  const opened = await page.evaluate(() => ({ sel: !!document.querySelector('.bar.selected'), hash: location.hash, flash: document.getElementById('saveInd').textContent }));
  check('opening a link selects that row and says which', opened.sel && opened.flash.includes('#' + u2 + ' Far task'), JSON.stringify(opened));
  check('and the link is consumed, so a reload does not jump again', !/row=/.test(opened.hash), opened.hash);
  // From the table, behind a filter that hides the row: the link goes to the chart, clears
  // the filter and selects the row.
  await view(page, 'table');
  await page.select('#ganttOwner', '__unassigned__'); await settle(page, 300);
  await page.evaluate(u => { location.hash = '#row=' + u; }, u1); await settle(page, 700);
  const lk = await page.evaluate(() => ({ view: document.documentElement.dataset.view, sel: [...document.querySelectorAll('.bar.selected')].map(b => b.dataset.id), owner: (document.getElementById('ganttOwner') || {}).value, flash: document.getElementById('saveInd').textContent }));
  check('a link opened from the table goes to the chart, clears the filter hiding the row, and selects it', lk.view === 'gantt' && lk.sel.join() === 'l1' && lk.owner === '' && /filters were hiding it/.test(lk.flash), JSON.stringify(lk));
  await page.evaluate(() => { location.hash = '#row=99999'; }); await settle(page, 400);
  check('a link to a row that is gone says so', /no row #99999/.test(await page.$eval('#saveInd', e => e.textContent)));
  // Tags fold: ten tags start folded; a chosen tag stays out.
  await view(page, 'gantt');
  const legend = () => page.evaluate(() => ({ chips: [...document.querySelectorAll('#legend .tag-chip')].map(c => c.dataset.tag), toggle: (document.getElementById('legendTags') || {}).textContent || '' }));
  let lg = await legend();
  check('with more than eight tags the legend folds them behind one chip', lg.chips.length === 0 && /10 tags/.test(lg.toggle), JSON.stringify(lg));
  await page.click('#legendTags'); await settle(page, 200);
  lg = await legend();
  check('the chip unfolds them', lg.chips.length === 10 && /Fewer tags/.test(lg.toggle), JSON.stringify(lg));
  await page.click('.tag-chip[data-tag="golf"]'); await settle(page, 200); await page.click('#legendTags'); await settle(page, 200);
  lg = await legend();
  check('folded again, a chosen tag keeps its chip and the fold says so', lg.chips.join() === 'golf' && /1 on/.test(lg.toggle), JSON.stringify(lg));
  await page.click('.tag-chip[data-tag="golf"]'); await settle(page, 200);
  // The owner previews the team's view.
  await view(page, 'setup');
  check('the owner sees the Sharing and Chat cards and the Analytics lens', await page.evaluate(() => !!document.querySelector('#suShare').offsetHeight && !!document.querySelector('#suChat').offsetHeight && !!document.querySelector('.vt-btn[data-view="analytics"]').offsetHeight));
  await page.click('#sharePreview'); await settle(page, 300);
  const pv = await page.evaluate(() => ({ role: document.documentElement.dataset.role, an: !!document.querySelector('.vt-btn[data-view="analytics"]').offsetHeight, banner: !!document.getElementById('memberPreview'), view: document.documentElement.dataset.view }));
  check('"See what the team sees" hides Analytics, shows a banner and goes to the chart', pv.role === 'member' && !pv.an && pv.banner && pv.view === 'gantt', JSON.stringify(pv));
  check('and hides Auto-schedule and the critical path (they re-time everybody\'s rows)', await page.evaluate(() => !document.getElementById('btnAuto').offsetWidth && !document.getElementById('btnCrit').offsetWidth));
  await view(page, 'setup');
  check('in the team view, Setup keeps sections and tags but not Sharing or Chat', await page.evaluate(() => !document.querySelector('#suShare').offsetHeight && !document.querySelector('#suChat').offsetHeight && !!document.querySelector('#suSections').offsetHeight));
  await page.click('#previewBack'); await settle(page, 300);
  check('"Back to my view" restores everything', await page.evaluate(() => document.documentElement.dataset.role === 'owner' && !document.getElementById('memberPreview') && !!document.querySelector('#suShare').offsetHeight));
  await page.close();
  // A real team member (the server says this viewer cannot change the plan's settings).
  const m = await openPage(browser, X.url, { clear: false });
  await m.setRequestInterception(true);
  m.on('request', req => { if (/\/access$/.test(new URL(req.url()).pathname) && req.method() === 'GET') req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, mode: 'org', org: 'adom', owner: { username: 'drew2' }, you: { username: 'ana' }, youAreOwner: false, canChange: false }) }); else req.continue(); });
  await m.reload(); await ready(m); await settle(m, 400);
  check('a team member has no Analytics lens and no banner', await m.evaluate(() => document.documentElement.dataset.role === 'member' && !document.querySelector('.vt-btn[data-view="analytics"]').offsetHeight && !document.getElementById('memberPreview')));
  await m.close();
}
// 0.72.0: typing in a list HIGHLIGHTS and never picks; letters typed together build one
// search; the arrows move the mark; only Enter or a click picks. In every list.
async function typeAheadFlow(browser) {
  section = 'type ahead';
  const names = ['Molecule storage', 'Scaffold storage', 'Final check, pack and ship', 'Traceability', 'Barcode', 'factoryOS', 'Quality and standards'];
  const X = await startServer({ project: { title: 'Type Test', appName: 'type-test' }, phases: names.map(n => ({ name: n, start: '2026-10', end: '2026-11' })),
    items: [{ type: 'task', id: 'a1', name: 'Type task', start: '2026-10-05', end: '2026-10-09', phase: 0, status: 'not-started', owner: 'Kale' },
      { type: 'task', id: 'a2', name: 'Other task', start: '2026-10-05', end: '2026-10-09', phase: 1, status: 'not-started', owner: 'Noah' }] }, 'typeahead');
  const page = await openPage(browser, X.url);
  const mark = () => page.evaluate(() => (document.querySelector('.cell-list .cl-opt.on') || {}).textContent || '');
  const row = async (id, ms = 300) => (await savedPlan(X.url, { minWait: ms })).items.find(r => r.id === id);
  // Table: the section cell.
  await view(page, 'table');
  await page.click('#tableView tr[data-key="t:a1"] td[data-field="phase"]'); await settle(page, 150);
  await page.keyboard.type('f'); await settle(page, 100);
  check('Table: one letter highlights the first section starting with it, and saves nothing', (await mark()) === names[2] && !!(await page.$('.cell-list')) && (await row('a1')).phase === 0, await mark());
  await page.keyboard.type('a'); await settle(page, 100);
  check('Table: letters typed together search together ("fa" is factoryOS)', (await mark()) === 'factoryOS' && (await row('a1')).phase === 0, await mark());
  await page.keyboard.press('Enter'); await settle(page, 400);
  check('Table: Enter takes the highlighted one', (await row('a1')).phase === 5, JSON.stringify(await row('a1')));
  // Table: the status cell, with the arrows.
  await page.click('#tableView tr[data-key="t:a2"] td[data-field="status"]'); await settle(page, 150);
  const st0 = await mark();
  await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown'); await settle(page, 100);
  check('Table: the arrows move the highlight and save nothing', (await mark()) !== st0 && (await row('a2')).status === 'not-started', `${st0} -> ${await mark()}`);
  await page.keyboard.press('Escape'); await settle(page, 300);
  check('Table: Escape leaves the cell as it was', (await row('a2')).status === 'not-started' && !(await page.$('.cell-list')));
  // The owner filter, from the Table: typing highlights, Enter applies.
  await page.focus('#ganttOwner'); await page.keyboard.type('n'); await settle(page, 150);
  const rowsNow = () => page.$$eval('#tableView tbody tr[data-key]', t => t.length);
  check('Table filter: typing highlights an owner without filtering yet', (await mark()).startsWith('Noah') && (await rowsNow()) === 2, `${await mark()} / ${await rowsNow()}`);
  await page.keyboard.press('Enter'); await settle(page, 300);
  check('Table filter: Enter applies it', (await rowsNow()) === 1);
  await page.select('#ganttOwner', ''); await settle(page, 200);
  // The item editor: typing in the section list.
  await view(page, 'gantt');
  await openItemEditor(page, 'Other task');
  await page.focus('#imPhase'); await page.keyboard.type('tr'); await settle(page, 150);
  check('Editor: typing highlights in the section list and the editor stays open', (await mark()) === 'Traceability' && !!(await page.$('.editor.show')), await mark());
  await page.keyboard.press('Enter'); await settle(page, 150);
  check('Editor: Enter takes it into the field, not yet saved', await page.$eval('#imPhase', e => e.value) === '3' && (await row('a2')).phase === 1);
  await page.keyboard.press('Escape'); await settle(page, 200);
  // The status pill on the chart: a two-letter search.
  await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.textContent.includes('Other task')); e.scrollIntoView({ block: 'center' }); e.querySelector('.status-pill').dataset.ta = '1'; });
  await page.click('.status-pill[data-ta="1"]'); await settle(page, 150);
  await page.keyboard.type('bl'); await settle(page, 100);
  check('Status pill: "bl" highlights blocked and saves nothing', (await mark()) === 'blocked' && (await row('a2')).status === 'not-started', await mark());
  await page.keyboard.press('Escape'); await settle(page, 200);
  // The Export menu: typing marks an item.
  await page.click('#btnExport'); await settle(page, 150);
  await page.keyboard.type('sv'); await settle(page, 100);
  check('Export menu: typing marks the item it starts', await page.$eval('#exportMenu', m => (m.querySelector('.dd-item.active') || { dataset: {} }).dataset.fmt) === 'svg');
  await page.keyboard.press('Escape'); await settle(page, 150);
  await page.close();
}
// 0.71.1: a pick made with the MOUSE in one of the editor's lists keeps the editor open
// and holds the choice until Apply. It used to shut the editor unapplied: the click on
// the option reached the page's "clicked outside" handler after the list had closed.
async function pickFlow(browser) {
  section = 'pick by mouse';
  const X = await startServer({ project: { title: 'Pick Test', appName: 'pick-test' }, phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],
    items: [{ type: 'task', id: 'k1', name: 'Pick task', start: '2026-10-05', end: '2026-10-09', phase: 0, status: 'not-started' },
      { type: 'milestone', name: 'Pick gate', date: '2026-10-20', phase: 0, status: 'not-started' }] }, 'pick');
  const page = await openPage(browser, X.url);
  const pickWith = async (sel, value) => { await page.click(sel); await settle(page, 150); await page.click(`.cell-list .cl-opt[data-value="${value}"]`); await settle(page, 250); };
  const state = () => page.evaluate(() => ({ open: !!document.querySelector('.editor.show'), status: (document.getElementById('imStatus') || {}).value, phase: (document.getElementById('imPhase') || {}).value }));
  const msUid = (await savedPlan(X.url, { minWait: 200 })).items.find(r => r.type === 'milestone').uid;
  for (const [name, find] of [['Pick task', r => r.type === 'task'], ['Pick gate', r => r.type === 'milestone']]) {
    // A milestone is a diamond on its section's row, not a label row of its own.
    if (name === 'Pick gate') await clickMilestone(page, msUid); else await openItemEditor(page, name);
    await settle(page, 200);
    await pickWith('#imStatus', 'blocked');
    let s = await state();
    check(`${name}: clicking a status in the list keeps the editor open, holding the choice`, s.open && s.status === 'blocked', JSON.stringify(s));
    await pickWith('#imPhase', '1');
    s = await state();
    check(`${name}: and so does clicking a section`, s.open && s.phase === '1', JSON.stringify(s));
    const before = (await savedPlan(X.url, { minWait: 300 })).items.find(find);
    check(`${name}: nothing is saved before Apply`, before.status === 'not-started' && before.phase === 0, JSON.stringify(before));
    await page.click('#imApply'); await settle(page, 300);
    const after = (await savedPlan(X.url)).items.find(find);
    check(`${name}: Apply saves both picks`, after.status === 'blocked' && after.phase === 1, JSON.stringify(after));
  }
  await page.close();
}
// ── Subtasks (0.79.0, issue #5) ──
// A task holds subtasks: indented under it with a chevron and a count, folded per person, its
// subtasks' span under its bar when folded; Alt+Right and Alt+Left nest and take out; a drag
// onto a task's middle nests, a drag to a gap at the left takes out; the Table follows the
// tree in plan order and stands every row alone under a sort; a filter that matches only a
// subtask keeps its task, dimmed; deleting a task takes its subtasks, and one undo brings them
// back; Collapse all folds them too.
async function subtasksFlow(browser) {
  section = 'subtasks';
  const T = (uid, id, name, ph, extra) => ({ type: 'task', uid, id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew', status: 'not-started', ...extra });
  const X = await startServer({ project: { title: 'Subtasks Test', appName: 'sub-test', view: { mode: 'gantt', grain: 'week', milestones: 'diamonds' } }, phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],
    items: [T('00001', 'b1', 'Frame', 0), T('00002', 'b2', 'Frame bolts', 0, { parent: 'b1', start: '2026-10-12', end: '2026-10-23' }), T('00003', 'b3', 'Wire', 0, { owner: 'Ana' }),
      T('00004', 'b4', 'Wire loom', 0, { parent: '00003', owner: 'Kai', effort: 6 }), T('00005', 'b5', 'Test', 0), T('00006', 's1', 'Pack', 1), { type: 'milestone', uid: '00007', name: 'Ship gate', date: '2026-10-20', phase: 1 }, T('00008', 's2', 'Send', 1)] }, 'subtasks');
  const page = await openPage(browser, X.url);
  const P = async () => (await savedPlan(X.url, { minWait: 900 })).items;
  const parentOf = async uid => ((await P()).find(r => r.uid === uid) || {}).parent || '';
  const lrow = id => `#labels .label-row[data-id="${id}"]`;
  const rowState = id => page.$eval(lrow(id), e => ({ sub: e.classList.contains('is-sub'), depth: e.style.getPropertyValue('--depth'), kids: e.classList.contains('has-kids'), folded: e.classList.contains('kids-folded'), hidden: e.classList.contains('row-collapsed'), ctx: e.classList.contains('ctx-dim'), count: (e.querySelector('.sub-count') || {}).textContent || '' }));
  let items = await P();
  check('a data file\'s parent named by task id is read as that task\'s uid', items.find(r => r.uid === '00002').parent === '00001' && items.find(r => r.uid === '00004').parent === '00003');
  // Subtasks start folded (0.79.0, ClickUp's default, Drew's choice), and each person opens their
  // own: what one opens stays open for them across a reload and stays folded for everybody else.
  const st0 = await rowState('b2'), fr0 = await rowState('b1');
  check('subtasks start folded: the task wears the chevron and its count, its subtasks are put away', st0.hidden && fr0.folded && fr0.kids && fr0.count === '1', JSON.stringify([st0, fr0]));
  await page.click(lrow('b1') + ' .sub-tog'); await settle(page, 200);
  await page.click(lrow('b3') + ' .sub-tog'); await settle(page, 200);
  await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
  check('a task this person opened stays open for them after a reload', !(await rowState('b2')).hidden && !(await rowState('b4')).hidden);
  {
    const ctx2 = await browser.createBrowserContext();
    const other = await openPage(browser, X.url, { ctx: ctx2 });
    check('and stays folded for somebody else', await other.$eval(lrow('b1'), e => e.classList.contains('kids-folded')) && await other.$eval(lrow('b3'), e => e.classList.contains('kids-folded')));
    await other.close(); await ctx2.close();
  }
  let st = await rowState('b2'), fr = await rowState('b1');
  check('a subtask is indented under its task, and the task wears the chevron and its count', st.sub && st.depth === '1' && fr.kids && fr.count === '1', JSON.stringify([st, fr]));
  // Fold with the chevron: the subtask goes, the task's bar shows its subtasks' span.
  await page.click(lrow('b1') + ' .sub-tog'); await settle(page, 200);
  st = await rowState('b2');
  const span = await page.evaluate(() => { const e = document.querySelector('.label-row[data-id="b1"]') && [...document.querySelectorAll('.sub-span')].find(x => x.style.display !== 'none'); return e ? { title: e.dataset.title, dates: e.dataset.dates } : null; });
  check('the chevron folds the task\'s subtasks away, per person, and its bar shows their span', st.hidden && (await rowState('b1')).folded && span && /12-Oct-2026 . 23-Oct-2026/.test(span.dates), JSON.stringify([st, span]));
  check('folding is a view setting: nothing is saved', (await parentOf('00002')) === '00001' && !(await (await fetch(X.url + 'changelog')).json()).events.some(e => e.action === 'parent'));
  await page.click(lrow('b1') + ' .sub-tog'); await settle(page, 200);
  check('and unfolds them again', !(await rowState('b2')).hidden);
  // Alt+Right and Alt+Left on the selected task.
  await clickBar(page, 'b5'); await page.keyboard.press('Escape'); await settle(page, 150);
  await page.keyboard.down('Alt'); await page.keyboard.press('ArrowRight'); await page.keyboard.up('Alt'); await settle(page, 300);
  check('Alt+Right nests a task under the task above it, as its last subtask', (await parentOf('00005')) === '00003' && (await P()).map(r => r.uid).join() === '00001,00002,00003,00004,00005,00006,00007,00008');
  await page.keyboard.down('Alt'); await page.keyboard.press('ArrowLeft'); await page.keyboard.up('Alt'); await settle(page, 300);
  items = await P();
  check('Alt+Left takes it out again, to straight after its task', !items.find(r => r.uid === '00005').parent && items.map(r => r.uid).join() === '00001,00002,00003,00004,00005,00006,00007,00008', items.map(r => r.uid + '<' + (r.parent || '')).join(' '));
  await undo(page);
  check('Ctrl+Z puts it back under the task', (await parentOf('00005')) === '00003');
  await undo(page);
  check('and Ctrl+Z again takes the nest back', !(await parentOf('00005')));
  // Drag Send onto the middle of Pack: it becomes Pack's subtask; drag it out to the left: top level.
  await clearSel(page);
  const mid = async id => page.evaluate(i => { const e = document.querySelector(`#labels .label-row[data-id="${i}"] .name-text`); e.scrollIntoView({ block: 'center' }); const b = e.getBoundingClientRect(); return { x: b.left + Math.min(8, b.width / 2), y: b.top + b.height / 2, h: b.height }; }, id);
  let a = await mid('s2'), b = await mid('s1');
  await page.mouse.move(a.x, a.y); await page.mouse.down();
  for (let k = 1; k <= 8; k++) { await page.mouse.move(a.x, a.y + (b.y - a.y) * k / 8); await sleep(30); }
  const nestMark = await page.$eval(lrow('s1'), e => e.classList.contains('nest-target'));
  await page.mouse.up(); await settle(page, 300);
  check('dragging a task onto the middle of another outlines it, and the drop nests it there', nestMark && (await parentOf('00008')) === '00006');
  a = await mid('s2');
  const gate = await page.evaluate(() => { const e = [...document.querySelectorAll('#labels .label-row.ms')].find(x => x.textContent.includes('Ship gate')); const r = e.getBoundingClientRect(); return { y: r.bottom - 3 }; });
  // Straight down first: a sideways start is a date drag, as it always was.
  await page.mouse.move(a.x, a.y); await page.mouse.down(); await page.mouse.move(a.x, a.y + 12); await sleep(30);
  for (let k = 1; k <= 8; k++) { await page.mouse.move(a.x - 60 * k / 8, a.y + 12 + (gate.y - a.y - 12) * k / 8); await sleep(30); }
  const line = await page.evaluate(() => { const d = document.querySelector('#labels .drop-indicator'); return d && d.classList.contains('show') ? parseFloat(d.style.left || '0') : null; });
  await page.mouse.up(); await settle(page, 300);
  items = await P();
  check('dragging a subtask to a gap and to the left takes it out, at the level the line shows', line === 0 && !items.find(r => r.uid === '00008').parent && items.map(r => r.uid).indexOf('00008') === items.map(r => r.uid).indexOf('00007') + 1, JSON.stringify([line, items.map(r => r.uid + '<' + (r.parent || '')).join(' ')]));
  // The Table follows the tree in plan order (it opens sorted by deadline).
  await view(page, 'table');
  if (await page.$('#toPlanOrder')) { await page.click('#toPlanOrder'); await settle(page, 200); }
  const trow = id => `#tableBody tr[data-key="t:${id}"]`;
  const tstate = id => page.$eval(trow(id), tr => ({ depth: (tr.querySelector('.t-ind') || { style: { getPropertyValue: () => '' } }).style.getPropertyValue('--depth'), tog: !!tr.querySelector('button.sub-tog'), ctx: tr.classList.contains('ctx-dim'), eff: (tr.querySelector('td.c-effort') || {}).textContent || '' })).catch(() => null);
  let w = await tstate('b4'), wp = await tstate('b3');
  check('in the Table a subtask is indented under its task, which has the chevron and its subtasks\' hours', w && w.depth === '1' && wp.tog && /not est\. · 6h/.test(wp.eff), JSON.stringify([w, wp]));
  await page.click(trow('b3') + ' button.sub-tog'); await settle(page, 200);
  check('the Table\'s chevron folds them too, the same fold as the chart', !(await tstate('b4')) && (await page.evaluate(() => window.__collab.tree().find(r => r.uid === '00004').hidden)));
  await page.click(trow('b3') + ' button.sub-tog'); await settle(page, 200);
  await page.click('#tableView thead th[data-sort="name"]'); await settle(page, 200);
  check('under a sort every row stands alone, a subtask marked with its task', !!(await page.$(trow('b4') + ' .sub-of')) && !(await page.$(trow('b4') + ' .t-ind')));
  await page.click('#tableView thead th[data-sort="name"]'); await settle(page, 100); await page.click('#tableView thead th[data-sort="name"]'); await settle(page, 200);
  // A filter that matches only the subtask keeps its task, dimmed, in both lenses.
  await page.select('#ganttOwner', 'Kai'); await settle(page, 300);
  w = await tstate('b4'); wp = await tstate('b3');
  check('an owner filter that matches only a subtask keeps its task on screen, dimmed (Table)', w && wp && wp.ctx && !w.ctx, JSON.stringify([w, wp]));
  await view(page, 'gantt');
  check('and in the chart', (await rowState('b3')).ctx && !(await rowState('b3')).hidden && !(await rowState('b4')).ctx);
  await page.select('#ganttOwner', ''); await settle(page, 300);
  // Collapse all folds tasks with subtasks too; Expand all opens everything.
  await page.click('#btnCollapseAll'); await settle(page, 200);
  check('Collapse all folds every task with subtasks too', (await rowState('b1')).folded && (await rowState('b3')).folded);
  await page.click('#btnCollapseAll'); await settle(page, 200);
  check('and Expand all unfolds them', !(await rowState('b1')).folded && !(await rowState('b2')).hidden);
  // Deleting a task deletes its subtasks with it; one Ctrl+Z brings both back, nested.
  await view(page, 'table');
  await page.hover(trow('b3')); await settle(page, 150);
  await page.click(trow('b3') + ' .row-del'); await settle(page, 300);
  const toast = await page.$eval('#undoToast', e => e.textContent);
  items = await P();
  check('deleting a task takes its subtasks with it, and the toast says so', !items.some(r => r.uid === '00003' || r.uid === '00004') && /Wire and its 1 subtask/.test(toast), toast);
  await undo(page);
  items = await P();
  check('one Ctrl+Z brings the task and its subtask back, still nested', items.find(r => r.uid === '00004') && items.find(r => r.uid === '00004').parent === '00003');
  await view(page, 'gantt');
  const md = await (await fetch(X.url + 'changelog?format=md')).text();
  check('the changelog says who made what a subtask of what, and took it out', /made "Test" a subtask of "Wire"/.test(md) && /took "Test" out from under "Wire"/.test(md), md.split('\n').filter(l => /subtask|out from under/.test(l)).slice(0, 3).join(' | '));
  await page.close();
}
// ── Quick add (0.79.0, issue #6) ──
// The + on a section header opens a blank row at the end of the section; Enter adds the task
// and leaves the row open for the next; Esc, or Enter on an empty row, stops. The + on a task
// adds its subtasks the same way. New's remembered owner, status and tags and its default dates
// are the defaults. Each task is one undo entry and one changelog line. The Table has a
// + in a row's Section cell (no line under each section). Dependency mode is left first, as New leaves it.
async function quickAddFlow(browser) {
  section = 'quick add';
  const T = (id, name, ph, extra) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew', status: 'not-started', ...extra });
  const X = await startServer({ project: { title: 'Quick Add Test', appName: 'qa-test', view: { mode: 'gantt', grain: 'week', milestones: 'diamonds' } }, phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],
    items: [T('q1', 'Frame', 0), T('q2', 'Wire', 0), T('q3', 'Pack', 1), { type: 'milestone', name: 'Ship gate', date: '2026-10-20', phase: 1 }] }, 'quickadd');
  const page = await openPage(browser, X.url);
  const P = async () => (await savedPlan(X.url, { minWait: 900 })).items;
  // New remembers an owner, a status and a tag; quick add starts from them.
  await page.click('#btnNew'); await settle(page, 200);
  await page.type('#ncName', 'Seed row'); await setVal(page, '#ncOwner', 'Ana'); await setVal(page, '#ncStatus', 'in-progress');
  await page.click('#ncCreate'); await settle(page, 400);
  await page.click('#btnDeps'); await settle(page, 150);
  const hdr = await page.$$('#labels .label-row.group');
  await hdr[0].hover(); await settle(page, 150);
  const addVis = await page.$eval('#labels .label-row.group .lane-add', b => window.__uiVis(b));
  await page.click('#labels .label-row.group .lane-add'); await settle(page, 250);
  const open = await page.evaluate(() => ({ row: !!document.querySelector('#labels .qa-row'), focus: document.activeElement === document.querySelector('.qa-in'), dep: document.body.classList.contains('dep-mode') }));
  check('the + on a section header shows on hover, leaves Dependency mode and opens a blank row with the cursor in it', addVis && open.row && open.focus && !open.dep, JSON.stringify([addVis, open]));
  await page.keyboard.type('Quick one'); await page.keyboard.press('Enter'); await settle(page, 500);
  check('Enter adds the task and leaves the row open for the next one', await page.evaluate(() => !!document.querySelector('#labels .qa-row') && document.activeElement === document.querySelector('.qa-in') && document.querySelector('.qa-in').value === ''));
  await page.keyboard.type('Quick two'); await page.keyboard.press('Enter'); await settle(page, 400);
  await page.keyboard.press('Enter'); await settle(page, 200);
  check('Enter on an empty row stops', !(await page.$('#labels .qa-row')));
  let items = await P();
  const sec0 = items.filter(r => r.phase === 0).map(r => r.name);
  const q1 = items.find(r => r.name === 'Quick one');
  check('the tasks land at the end of that section, in the order typed', sec0.slice(-2).join('|') === 'Quick one|Quick two', sec0.join('|'));
  check('with New\'s remembered owner and status, and New\'s dates', q1 && q1.owner === 'Ana' && q1.status === 'in-progress' && q1.start === TODAY && q1.end === addDaysISO(TODAY, 7), JSON.stringify(q1));
  const log = (await (await fetch(X.url + 'changelog')).json()).events;
  check('each one is a changelog line of its own', log.filter(e => e.action === 'add' && /^Quick (one|two)$/.test(e.name || '') && e.via === 'quick-add').length === 2);
  await undo(page);
  items = await P();
  check('Ctrl+Z takes back the last one only', items.some(r => r.name === 'Quick one') && !items.some(r => r.name === 'Quick two'));
  // Esc stops, keeping nothing typed.
  const adds = await page.$$('#labels .label-row.group .lane-add');
  await hdr[1].hover(); await settle(page, 150); await adds[1].click(); await settle(page, 200);
  const opened = await page.evaluate(() => ({ row: !!document.querySelector('#labels .qa-row'), focus: document.activeElement === document.querySelector('.qa-in'), active: document.activeElement && (document.activeElement.className || document.activeElement.tagName) }));
  await page.keyboard.type('Never mind'); await page.keyboard.press('Escape'); await settle(page, 300);
  const left = await page.evaluate(() => ({ row: !!document.querySelector('#labels .qa-row'), val: (document.querySelector('.qa-in') || {}).value, editor: document.getElementById('editor').classList.contains('show') }));
  check('Esc stops without adding the half-typed task', opened.row && opened.focus && !left.row && !(await P()).some(r => r.name === 'Never mind'), JSON.stringify([opened, left]));
  // The + on a task adds its subtasks, after the ones it has.
  await page.hover('#labels .label-row[data-id="q1"]'); await settle(page, 150);
  await page.click('#labels .label-row[data-id="q1"] .row-add'); await settle(page, 250);
  check('the + on a task opens a blank row indented under it', await page.evaluate(() => { const q = document.querySelector('#labels .qa-row'); return !!q && q.style.getPropertyValue('--depth') === '1' && q.previousElementSibling && q.previousElementSibling.dataset.id === 'q1'; }));
  await page.keyboard.type('Sub one'); await page.keyboard.press('Enter'); await settle(page, 450);
  await page.keyboard.type('Sub two'); await page.keyboard.press('Enter'); await settle(page, 450);
  await page.keyboard.press('Escape'); await settle(page, 200);
  items = await P();
  const kids = items.filter(r => r.parent === items.find(x => x.id === 'q1').uid).map(r => r.name);
  check('Enter adds subtasks of that task, in the order typed, right under it', kids.join('|') === 'Sub one|Sub two' && items.map(r => r.name).indexOf('Sub one') === items.map(r => r.name).indexOf('Frame') + 1, JSON.stringify([kids, items.map(r => r.name)]));
  // The Table opens sorted: the + in a row's Section cell adds to that section, the blank row
  // under that row.
  await view(page, 'table');
  const sorted = !!(await page.$('#toPlanOrder'));
  await page.hover('#tableBody tr[data-key="t:q2"]'); await settle(page, 150);
  await page.click('#tableBody tr[data-key="t:q2"] .sec-add'); await settle(page, 250);
  const under = await page.evaluate(() => { const e = document.querySelector('#tableBody tr.qa-entry'); return !!e && e.previousElementSibling && e.previousElementSibling.dataset.key === 't:q2' && document.activeElement === e.querySelector('.qa-in'); });
  await page.keyboard.type('Sorted one'); await page.keyboard.press('Enter'); await settle(page, 450); await page.keyboard.press('Escape'); await settle(page, 250);
  check('in the sorted Table, the + in a row\'s Section cell opens a blank row under it and adds to that section', sorted && under && ((await P()).find(r => r.name === 'Sorted one') || {}).phase === 0);
  // In plan order too, quick add is the + on hover (0.79.0, Drew's default): no "+ Add task"
  // line under every section; the + in a row's Section cell adds to the end of that section.
  await page.click('#toPlanOrder'); await settle(page, 200);
  check('the Table has no "+ Add task" line under its sections', (await page.$$('#tableBody tr.qa-line')).length === 0);
  const key1 = await page.evaluate(() => { const r = window.__collab.rows().filter(x => x.phase === 1 && x.id); return r.length ? 't:' + r[0].id : null; });
  await page.hover(`#tableBody tr[data-key="${key1}"]`); await settle(page, 150);
  await page.click(`#tableBody tr[data-key="${key1}"] .sec-add`); await settle(page, 250);
  check('the + in a row\'s Section cell opens a blank row, the cursor in it', await page.evaluate(() => !!document.querySelector('#tableBody tr.qa-entry .qa-in') && document.activeElement === document.querySelector('#tableBody .qa-in')));
  await page.keyboard.type('Table one'); await page.keyboard.press('Enter'); await settle(page, 500);
  check('Enter adds it and keeps the row open', await page.evaluate(() => !!document.querySelector('#tableBody tr.qa-entry .qa-in') && document.activeElement === document.querySelector('#tableBody .qa-in')));
  await page.keyboard.press('Escape'); await settle(page, 250);
  items = await P();
  check('the Table\'s task lands at the end of its section', items.filter(r => r.phase === 1).map(r => r.name).slice(-1)[0] === 'Table one', items.filter(r => r.phase === 1).map(r => r.name).join('|'));
  await page.hover('#tableBody tr[data-key="t:q3"]'); await settle(page, 150);
  await page.click('#tableBody tr[data-key="t:q3"] .row-add'); await settle(page, 250);
  await page.keyboard.type('Table sub'); await page.keyboard.press('Enter'); await settle(page, 450); await page.keyboard.press('Escape'); await settle(page, 250);
  items = await P();
  check('the Table\'s + on a task adds a subtask of it', (items.find(r => r.name === 'Table sub') || {}).parent === items.find(r => r.id === 'q3').uid);
  await view(page, 'gantt');
  await page.close();
}
// 0.71.0: a LINKED provenance is fixed (a link, never a text field, in the table and the
// item editor), a plain one is still edited; and the table's count says what it hides.
async function provFlow(browser) {
  section = 'provenance';
  const T = (id, name, extra) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: 0, owner: 'Drew', ...extra });
  const LINK = { label: 'Chat thread', url: 'https://example.com/thread/1' };
  const X = await startServer({ project: { title: 'Prov Test', appName: 'prov-test' }, phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }],
    items: [T('l1', 'Linked one', { provenance: LINK }), T('p1', 'Plain one', { provenance: 'Added by Drew' }), T('n1', 'Nothing here', {})] }, 'prov');
  const page = await openPage(browser, X.url);
  await view(page, 'table');
  const cell = id => `#tableView tr[data-key="t:${id}"] td[data-field="prov"]`;
  const lc = await page.$eval(cell('l1'), td => ({ ed: td.classList.contains('editable'), locked: td.classList.contains('prov-locked'), href: (td.querySelector('a.prov-chip') || {}).href || '', text: td.textContent.trim() }));
  check('a linked provenance cell is a link and is not editable', !lc.ed && lc.locked && lc.href === LINK.url && lc.text === LINK.label, JSON.stringify(lc));
  await page.$eval(cell('l1'), td => td.dispatchEvent(new MouseEvent('click', { bubbles: true }))); await settle(page, 200);
  check('clicking that cell opens no text field', !(await page.$('.cell-edit')));
  check('a plain provenance cell stays editable', await page.$eval(cell('p1'), td => td.classList.contains('editable')));
  await page.click(cell('p1')); await settle(page, 200);
  check('and opens a text field holding its text', (await page.$eval('.cell-edit input', e => e.value).catch(() => '')) === 'Added by Drew');
  await page.keyboard.press('Escape'); await settle(page, 150);
  await view(page, 'gantt');
  await openItemEditor(page, 'Linked one');
  const ed = await page.evaluate(() => { const a = document.getElementById('imProvLink'); return { input: !!document.getElementById('imProv'), href: a ? a.href : '', text: a ? a.textContent : '' }; });
  check('the item editor shows a linked provenance as the link, with no field to retype it', !ed.input && ed.href === LINK.url && ed.text === LINK.label, JSON.stringify(ed));
  await setVal(page, '#imDesc', 'reviewed'); await page.click('#imApply'); await settle(page, 300);
  const saved = (await savedPlan(X.url)).items.find(r => r.id === 'l1') || {};
  check('applying another change leaves the provenance exactly as it was', saved.description === 'reviewed' && saved.provenance && saved.provenance.label === LINK.label && saved.provenance.url === LINK.url, JSON.stringify(saved));
  await openItemEditor(page, 'Plain one');
  check('a plain provenance is still a text field in the editor', (await page.$eval('#imProv', e => e.value).catch(() => '')) === 'Added by Drew');
  await page.keyboard.press('Escape'); await settle(page, 200);
  // The count says why rows are missing.
  await view(page, 'table');
  await page.click('#tblSearch'); await page.keyboard.type('plain'); await settle(page, 400);
  const cnt = await page.$eval('#tblCount', e => ({ t: e.textContent, title: e.title }));
  check('a filtered table\'s count says how many rows are hidden and by what', cnt.t === '1 of 3 items' && /Hidden: 2 by the search/.test(cnt.title), JSON.stringify(cnt));
  await page.click('#tblSearch', { clickCount: 3 }); await page.keyboard.press('Backspace'); await settle(page, 300);
  check('and with nothing hidden it says every row is listed', /Every task and milestone/.test(await page.$eval('#tblCount', e => e.title)));
  await page.close();
}
// The Sharing card (0.68.0). Under --no-auth the page is the operator, so it may change the
// setting; the card says that sign-in is off. The gate itself is proven server-side
// against a fake Adom identity service (test/server_test.mjs).
// 0.76.0: the plan's working calendar (issue #3). Adom works Columbus Day and Veterans
// Day; a plan says so on Setup, and every working-day count, Auto-schedule and the day
// grain's shading follow it. Extra days off work the other way.
async function calendarFlow(browser) {
  section = 'calendar';
  const X = await startServer({ project: { title: 'Calendar Test', appName: 'calendar-test' },
    phases: [{ name: 'Build', start: '2026-10', end: '2026-12' }],
    items: [
      { type: 'task', id: 'a', name: 'Design', start: '2026-10-05', end: '2026-10-09', phase: 0, owner: 'Drew' },
      // Starts before its predecessor ends, so Auto-schedule moves it to start on Friday
      // 9 October; its second workday is Columbus Day only when the plan works it.
      { type: 'task', id: 'b', name: 'Build', start: '2026-10-08', end: '2026-10-09', phase: 0, owner: 'Drew', deps: ['a'] },
      { type: 'task', id: 'c', name: 'Ship', start: '2026-11-09', end: '2026-11-30', phase: 0, owner: 'Drew' },
    ] }, 'calendar');
  const page = await openPage(browser, X.url);
  const calSaved = async () => { await sleep(1000); return ((await (await fetch(X.url + 'state')).json()).calendar) || null; };
  const card = () => page.evaluate(() => ({ state: document.getElementById('calState').textContent,
    boxes: [...document.querySelectorAll('#calHols input')].map(i => ({ n: i.dataset.hol, on: i.checked, dis: i.disabled })),
    extra: [...document.querySelectorAll('#calExtra li')].map(l => l.dataset.day), add: document.getElementById('calAdd').disabled }));
  const durWd = async () => +((await readouts(page)).dur.match(/(\d+) workday/) || [])[1];
  const DEF = workdaysO('2026-10-05', '2026-11-30');
  check('by default every federal holiday is a day off, and the readout counts without them', await durWd() === DEF, String(await durWd()));
  await view(page, 'setup');
  let c = await card();
  check('the card lists the eleven federal holidays, all ticked as days off', c.boxes.length === 11 && c.boxes.every(b => b.on && !b.dis) && c.state === '11 of 11 holidays off' && !c.extra.length, JSON.stringify(c));
  for (const n of ['Columbus Day', 'Veterans Day']) { await page.click(`#calHols input[data-hol="${n}"]`); await settle(page, 150); }
  let saved = await calSaved();
  c = await card();
  check('unticking Columbus Day and Veterans Day saves them as worked', saved && saved.workHolidays.join() === 'Columbus Day,Veterans Day' && !saved.extraDaysOff.length && c.state === '9 of 11 holidays off', JSON.stringify([saved, c.state]));
  check('and the plan has two more workdays (12 Oct and 11 Nov 2026)', await durWd() === DEF + 2, String(await durWd()));
  await page.click('#calNew', { offset: { x: 14, y: 12 } }); await page.keyboard.type('11272026', { delay: 15 }); await settle(page, 100);
  await page.click('#calAdd'); await settle(page, 200);
  saved = await calSaved();
  c = await card();
  check('an extra day off (27 Nov) is added, saved and listed', saved && saved.extraDaysOff.join() === '2026-11-27' && c.extra.join() === '2026-11-27' && c.state === '9 of 11 holidays off, 1 extra day off', JSON.stringify([saved, c]));
  check('and the plan loses that workday again', await durWd() === DEF + 1, String(await durWd()));
  await page.click('#calNew', { offset: { x: 14, y: 12 } }); await page.keyboard.type('11272026', { delay: 15 }); await page.keyboard.press('Enter'); await settle(page, 200);
  check('adding the same day twice says so and changes nothing', /already a day off/.test(await page.$eval('#calStatus', e => e.textContent)) && (await card()).extra.length === 1);
  const md = await (await fetch(X.url + 'changelog?format=md')).text();
  check('the changelog names each change', /changed the working calendar: works Columbus Day/.test(md) && /works Veterans Day/.test(md) && /added 27-Nov-2026 as a day off/.test(md), md.split('\n').filter(l => /calendar/.test(l)).join(' | '));
  // The arithmetic itself, as the scheduler sees it.
  const arith = await page.evaluate(() => { const C = window.__collab; return { after: C.addBiz('2026-10-09', 2), wd: C.workdays('2026-11-23', '2026-11-30'), off: C.offDay('2026-10-12'), vet: C.offDay('2026-11-11'), xmas: C.offDay('2026-12-25') }; });
  check('the working-day arithmetic follows it: Fri 9 Oct + 1 workday is Mon 12 Oct; Thanksgiving week has 4 workdays', arith.after === '2026-10-12' && arith.wd === 4 && !arith.off && !arith.vet && arith.xmas, JSON.stringify(arith));
  await view(page, 'gantt');
  await setGrain(page, 'day');
  const bands = await page.evaluate(() => { const ax = window.__collab.axis(), idx = ds => Math.round((Date.parse(ds + 'T00:00:00Z') - Date.parse(ax.start + 'T00:00:00Z')) / 86400000);
    const at = ds => [...document.querySelectorAll('.gl-band')].find(b => Math.round(parseFloat(b.style.left) / ax.col) === idx(ds));
    const cell = ds => document.querySelectorAll('.m-row > .m-cell')[idx(ds)];
    return { col: !!at('2026-10-12'), thx: at('2026-11-26') && at('2026-11-26').className, xtra: at('2026-11-27') && at('2026-11-27').className, xtip: cell('2026-11-27') && cell('2026-11-27').title, ctip: cell('2026-10-12') && cell('2026-10-12').title }; });
  check('on the day grain a worked holiday is not shaded, Thanksgiving and the extra day are', !bands.col && /holiday/.test(bands.thx || '') && /holiday/.test(bands.xtra || '') && /day off on this plan/.test(bands.xtip || '') && !bands.ctip, JSON.stringify(bands));
  await setGrain(page, 'week');
  await page.click('#btnAuto');
  await page.waitForFunction(() => !document.querySelector('#btnAuto.btn-busy'), { timeout: 15000 }); await settle(page, 400);
  const b = (await savedPlan(X.url)).items.find(x => x.id === 'b');
  // A successor starts on the day its predecessor ends (Fri 9 Oct) and keeps its two
  // workdays, so it ends on Columbus Day; with the holiday off it would end on Tuesday.
  check('Auto-schedule ends the two-day successor on Columbus Day, because this plan works it', b && b.start === '2026-10-09' && b.end === '2026-10-12', JSON.stringify(b));
  // Undo is the tab's own: the extra day off, then Auto-schedule, are each one Ctrl+Z.
  await view(page, 'setup');
  await page.evaluate(() => document.activeElement && document.activeElement.blur());
  await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page, 300);
  saved = await calSaved();
  check('Ctrl+Z takes the extra day off back (after undoing Auto-schedule)', saved && !saved.extraDaysOff.length && saved.workHolidays.length === 2 && await durWd() === DEF + 2, JSON.stringify(saved));
  await page.keyboard.down('Control'); await page.keyboard.down('Shift'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Shift'); await page.keyboard.up('Control'); await settle(page, 300);
  saved = await calSaved();
  check('Ctrl+Shift+Z puts it back', saved && saved.extraDaysOff.join() === '2026-11-27' && await durWd() === DEF + 1, JSON.stringify(saved));
  await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);
  await view(page, 'setup');
  c = await card();
  check('the calendar survives a reload', c.state === '9 of 11 holidays off, 1 extra day off' && await durWd() === DEF + 1, JSON.stringify(c));
  const ex = await (await fetch(X.url + 'export/json')).json();
  check('the merged plan carries it as project.calendar', ex.project && ex.project.calendar && ex.project.calendar.workHolidays.join() === 'Columbus Day,Veterans Day' && ex.project.calendar.extraDaysOff.join() === '2026-11-27', JSON.stringify(ex.project && ex.project.calendar));
  await page.click('#sharePreview'); await settle(page, 200);
  await view(page, 'setup');
  c = await card();
  check('in the team view the calendar is shown but cannot be changed', c.boxes.every(x => x.dis) && c.add && await page.$$eval('#calExtra button', bs => bs.every(x => x.disabled)), JSON.stringify(c));
  await page.click('#previewBack'); await settle(page, 200);
  await view(page, 'setup');
  await page.click('#calExtra button.cal-rm'); await settle(page, 200);
  for (const n of ['Columbus Day', 'Veterans Day']) { await page.click(`#calHols input[data-hol="${n}"]`); await settle(page, 150); }
  saved = await calSaved();
  const ex2 = await (await fetch(X.url + 'export/json')).json();
  check('back to the default: nothing worked, no extra days, and project.calendar is gone', saved && !saved.workHolidays.length && !saved.extraDaysOff.length && !('calendar' in (ex2.project || {})) && await durWd() === DEF, JSON.stringify(saved));
  await page.close();
  // A plan whose data file carries the calendar starts from it.
  const Y = await startServer({ project: { title: 'Cal File', calendar: { workHolidays: ['Columbus Day', 'Not A Holiday'], extraDaysOff: ['2026-10-16', 'soon'] } },
    phases: [{ name: 'Build', start: '2026-10', end: '2026-10' }],
    items: [{ type: 'task', id: 'a', name: 'One', start: '2026-10-05', end: '2026-10-23', phase: 0 }] }, 'calfile');
  const p2 = await openPage(browser, Y.url);
  const f = await p2.evaluate(() => ({ cal: window.__collab.cal(), wd: window.__collab.workdays('2026-10-05', '2026-10-23') }));
  check('a data file\'s project.calendar is read, with unknown names and bad dates dropped', f.cal.workHolidays.join() === 'Columbus Day' && f.cal.extraDaysOff.join() === '2026-10-16' && f.wd === 14, JSON.stringify(f));
  await p2.close();
}
async function shareFlow(browser) {
  section = 'share';
  const T = (id, name, ph) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew' });
  const X = await startServer({ project: { title: 'Share Test', appName: 'share-test', view: { mode: 'setup' } },
    phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }], items: [T('s1', 'Share one', 0)] }, 'share');
  const page = await openPage(browser, X.url);
  await view(page, 'setup'); await settle(page, 400);
  const card = () => page.evaluate(() => ({ state: document.getElementById('shareState').textContent, status: document.getElementById('shareStatus').textContent,
    mode: document.getElementById('shareMode').value, disabled: document.getElementById('shareMode').disabled, org: document.getElementById('shareOrg').style.display !== 'none', orgVal: document.getElementById('shareOrg').value }));
  let c = await card();
  check('a fresh plan is open to anyone signed in to Adom, and the card says sign-in is off here', c.state === 'anyone signed in to Adom' && c.mode === 'any' && !c.org && /Sign-in is off/.test(c.status) && /No owner is named/.test(c.status), JSON.stringify(c));
  await page.select('#shareMode', 'me'); await settle(page, 500);
  c = await card();
  const acc = await (await fetch(X.url + 'access')).json();
  check('picking Only the owner sets it on the server and the card', acc.mode === 'me' && c.state === 'only the owner' && !c.org, JSON.stringify([acc, c]));
  await page.select('#shareMode', 'org'); await settle(page, 500);
  c = await card();
  const acc2 = await (await fetch(X.url + 'access')).json();
  check('picking an organization shows the org list and shares with adom', acc2.mode === 'org' && acc2.org === 'adom' && c.org && c.orgVal === 'adom' && c.state === 'adom members', JSON.stringify([acc2, c]));
  const md = await (await fetch(X.url + 'changelog?format=md')).text();
  check('each change is in the changelog', md.includes('shared the plan with its owner only (was anyone signed in to Adom)') && md.includes('shared the plan with the adom organization (was its owner only)'), md.split('\n').filter(l => /shared/.test(l)).join(' | '));
  const keys = await page.evaluate(() => { const s = document.getElementById('shareMode'); s.focus(); return s.value; });
  await page.keyboard.press('ArrowDown'); await settle(page, 200);
  await page.keyboard.press('ArrowDown'); await settle(page, 200);
  const mid = await (await fetch(X.url + 'access')).json();
  await page.keyboard.press('Enter'); await settle(page, 500);
  const fin = await (await fetch(X.url + 'access')).json();
  check('the arrow keys walk the list without changing the setting; Enter applies', keys === 'org' && mid.mode === 'org' && fin.mode === 'any', JSON.stringify([keys, mid.mode, fin.mode]));
  await page.close();
}

// 0.74.1: picking Unassigned while scrolled down moved the names 1,150 px and not the
// bars, and they stayed apart until the next scroll. A filter or a fold keeps the two
// panes row for row, and the row the reader is looking at stays where it was. Thirty
// sections so even the folded list scrolls; nobody in the last is unassigned, so the
// filter hides its header too (a hidden header used to win the pin).
async function scrollSyncFlow(browser) {
  section = 'scroll sync';
  const owners = ['Ana', 'Bo', ''], phases = [], items = [];
  for (let p = 0; p < 30; p++) {
    phases.push({ name: `Lane ${p + 1}`, start: '2026-10', end: '2026-11' });
    for (let k = 0; k < 5; k++) {
      const d = 1 + (p * 3 + k) % 20;
      items.push({ type: 'task', id: `s${p}k${k}`, name: `Lane ${p + 1} task ${k + 1}`, phase: p, owner: p === 29 ? 'Ana' : owners[(p + k) % 3],
        start: `2026-10-${String(d).padStart(2, '0')}`, end: `2026-10-${String(d + 5).padStart(2, '0')}`, ...(k ? { deps: [`s${p}k${k - 1}`] } : {}) });
    }
  }
  const X = await startServer({ project: { title: 'Scroll Sync', appName: 'scroll-sync', view: { mode: 'gantt', grain: 'week' } }, phases, items }, 'scroll-sync');
  const page = await openPage(browser, X.url);
  // Row for row: the n-th name and the n-th bar share a top, and the panes a scrollTop.
  // `at` is where each row and header is, in px from the top of the chart pane.
  const look = () => page.evaluate(() => {
    const L = document.getElementById('labels'), T = document.getElementById('timeline');
    const ls = [...L.querySelectorAll('.label-row')], bs = [...T.querySelectorAll('.bar-row')];
    let worst = 0;
    ls.forEach((l, i) => { if (bs[i] && l.offsetHeight) worst = Math.max(worst, Math.abs(bs[i].getBoundingClientRect().top - l.getBoundingClientRect().top)); });
    const pt = T.getBoundingClientRect().top, at = {};
    ls.forEach(l => { const n = l.querySelector('.lane-title, .name-text'); if (n && l.offsetHeight) at[n.textContent.trim()] = Math.round(l.getBoundingClientRect().top - pt); });
    const pin = L.querySelector('.label-row.group.is-pinned');
    return { worst: Math.round(worst), l: L.scrollTop, t: T.scrollTop, at,
      over: (T.scrollHeight - T.clientHeight) - (L.scrollHeight - L.clientHeight),
      pin: !pin ? '' : pin.offsetHeight ? pin.querySelector('.lane-title').textContent.trim() : 'hidden',
      band: pin && pin.offsetHeight ? Math.round(pin.getBoundingClientRect().bottom - pt) : 0 };
  });
  // Scroll the named row up to just under the pinned header: the first row in view.
  const putAtTop = async name => {
    for (let i = 0; i < 4; i++) {
      const v = await look(), d = v.at[name] - v.band;
      if (v.band && !d) return v;
      await page.evaluate(d => { document.getElementById('timeline').scrollTop += d; }, d);
      await settle(page, 150);
    }
    return look();
  };
  const owner = async v => { await page.select('#ganttOwner', v); await settle(page, 250); };
  // Lane 15: tasks 1 and 4 are nobody's, task 2 is Ana's, task 3 is Bo's.
  const R = 'Lane 15 task 4', Q = 'Lane 15 task 3', P = 'Lane 15 task 1';
  let v = await putAtTop(R);
  const y = v.at[R];
  check('a row sits first in view, under its pinned section', Number.isFinite(y) && y === v.band && v.pin === 'Lane 15' && v.t > 1500, JSON.stringify({ y, band: v.band, pin: v.pin, t: v.t }));
  await owner('__unassigned__');
  v = await look();
  check('Unassigned while scrolled down: the names and the bars stay row for row', v.worst === 0 && v.l === v.t, JSON.stringify({ worst: v.worst, l: v.l, t: v.t }));
  check('the chart cannot scroll past the names (the arrow layer shrinks with the rows)', v.over <= 40, `chart reaches ${v.over}px further`);
  check('the row in view stays where it was', v.at[R] === y, `${v.at[R]} vs ${y}`);
  check('the pinned header is one on screen (a hidden section does not win it)', v.pin === 'Lane 15', v.pin);
  await owner('');
  v = await look();
  check('All owners again: row for row, the same row in the same place', v.worst === 0 && v.l === v.t && v.at[R] === y, JSON.stringify({ worst: v.worst, l: v.l, t: v.t, at: v.at[R], y }));
  v = await putAtTop(Q);
  const yq = v.at[Q];
  check('the next row sits first in view', Number.isFinite(yq) && yq === v.band, JSON.stringify({ yq, band: v.band }));
  await owner('__unassigned__');
  v = await look();
  check('a filter that hides the row in view puts the nearest shown row above it in its place', v.worst === 0 && v.at[Q] === undefined && v.at[P] === yq, JSON.stringify({ worst: v.worst, Q: v.at[Q], P: v.at[P], yq }));
  await owner('');
  v = await putAtTop(R);
  await page.click('#btnCollapseAll'); await settle(page, 250);
  v = await look();
  check('Collapse all: row for row, and the section in view takes its row\'s place', v.worst === 0 && v.l === v.t && v.at['Lane 15'] === y, JSON.stringify({ worst: v.worst, l: v.l, t: v.t, lane: v.at['Lane 15'], y }));
  await page.click('#btnCollapseAll'); await settle(page, 250);
  v = await look();
  check('Expand all: row for row, and that section stays put', v.worst === 0 && v.l === v.t && v.at['Lane 15'] === y && v.at[P] > y, JSON.stringify({ worst: v.worst, l: v.l, t: v.t, lane: v.at['Lane 15'], first: v.at[P], y }));
  // Folding the section whose header is pinned: the next header used to slide up over it,
  // hiding the chevron that opens it again. The header stays where it is drawn, so the
  // same spot, clicked again, opens it.
  v = await putAtTop(R);
  const pinTop = v.band - await page.$eval('#labels .label-row.group.is-pinned', e => e.offsetHeight);
  const chev = await page.$eval('#labels .label-row.group.is-pinned .lane-toggle', e => { const b = e.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; });
  await page.mouse.click(chev.x, chev.y); await settle(page, 250);
  v = await look();
  let shut = await page.$$eval('.lane-toggle.collapsed', e => e.length);
  check('folding the pinned section keeps its header in place, the next one below it', shut === 1 && v.worst === 0 && v.l === v.t && v.at['Lane 15'] === pinTop && v.at['Lane 16'] > pinTop, JSON.stringify({ shut, worst: v.worst, l: v.l, t: v.t, lane: v.at['Lane 15'], next: v.at['Lane 16'], pinTop }));
  await page.mouse.click(chev.x, chev.y); await settle(page, 250);
  v = await look();
  shut = await page.$$eval('.lane-toggle.collapsed', e => e.length);
  check('and the same spot clicked again opens it, rows under it', shut === 0 && v.worst === 0 && v.at['Lane 15'] === pinTop && v.at[P] > pinTop, JSON.stringify({ shut, worst: v.worst, lane: v.at['Lane 15'], first: v.at[P], pinTop }));
  // Another tab deletes the plan's first row, far above this tab's view. The live merge
  // takes it away here too, and the row this tab is looking at does not move.
  v = await putAtTop(R);
  const y2 = v.at[R];
  const B = await openPage(browser, X.url, { clear: false });
  await clickBar(B, 's0k0'); await settle(B, 200);
  await B.click('#imDelete'); await settle(B, 300);
  let gone = false;
  for (const t0 = Date.now(); !gone && Date.now() - t0 < 6000;) { await sleep(150); v = await look(); gone = v.at['Lane 1 task 1'] === undefined; }
  check('a row another tab deletes far above goes here too, live', gone && Number.isFinite(y2), JSON.stringify({ y2 }));
  check('and this tab\'s row stays where it was, the panes row for row', v.worst === 0 && v.l === v.t && v.at[R] === y2, JSON.stringify({ worst: v.worst, l: v.l, t: v.t, at: v.at[R], y2 }));
  await B.close();
  await page.close();
}

// ── Dependencies to and from milestones (0.78.0) ──
// A milestone is a gate: it waits on the work before it and the work after it waits on it.
// Until 0.78.0 it had no id, so nothing could link to it. Every way a link is made, drawn,
// scheduled, copied and taken back is walked here with the mouse, in both milestone styles.
async function milestoneDepsFlow(browser) {
  section = 'milestone deps';
  const T = (id, name, s, e, ph, extra) => ({ type: 'task', id, name, start: s, end: e, phase: ph, owner: 'Drew', status: 'not-started', ...extra });
  const X = await startServer({ project: { title: 'Gates Test', appName: 'gates-test', view: { mode: 'gantt', grain: 'day', milestones: 'diamonds' } },
    phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],
    items: [
      T('build', 'EVT build', '2026-10-05', '2026-10-09', 0),
      T('test', 'EVT test', '2026-10-12', '2026-10-16', 0, { deps: ['build'] }),
      { type: 'milestone', name: 'EVT sign-off', date: '2026-10-16', phase: 0 },
      T('tooling', 'Production tooling', '2026-10-19', '2026-10-30', 1),
      { type: 'milestone', name: 'Tooling done', date: '2026-10-30', phase: 1 },
      T('ramp', 'Ramp', '2026-11-02', '2026-11-06', 1),
    ] }, 'gates');
  const row = (pl, f) => (pl.items || []).find(f);
  let p = await savedPlan(X.url, { minWait: 200 });
  const M = row(p, r => r.name === 'EVT sign-off'), M2 = row(p, r => r.name === 'Tooling done');
  check('every milestone has an id from the server, made from its name the way a task\'s is', M.id === 'evt-sign-off' && M2.id === 'tooling-done', JSON.stringify([M.id, M2.id]));
  const page = await openPage(browser, X.url);
  const flashTxt = () => page.$eval('#saveInd', e => e.textContent);
  const depsOf = async id => (row(await savedPlan(X.url), r => r.id === id) || {}).deps || [];
  const J = v => JSON.stringify(v);
  // The arrow from `f` to `t` as drawn: where its line starts and where its head's tip is.
  const arrowAt = (f, t) => page.evaluate((f, t) => {
    const poly = document.querySelector(`polygon[data-from="${f}"][data-to="${t}"]`), path = document.querySelector(`path[data-from="${f}"][data-to="${t}"]`);
    if (!poly || !path) return null;
    const svg = path.ownerSVGElement.getBoundingClientRect(), p0 = path.getPointAtLength(0), pr = poly.getBoundingClientRect();
    return { startX: svg.left + p0.x, startY: svg.top + p0.y, tipX: pr.right, tipY: pr.top + pr.height / 2 };
  }, f, t);
  // A diamond's left and right points: a rotated square's box is exactly its points.
  const diamondOf = uid => page.evaluate(u => { const h = document.querySelector(`.ms-hit[data-msuid="${u}"]`), d = h && h.parentNode.querySelector('.ms-diamond');
    if (!d) return null; const r = d.getBoundingClientRect(); return { l: r.left, r: r.right, y: r.top + r.height / 2 }; }, uid);
  const near = (a, b, tol = 2) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= tol;
  const depMode = on => page.evaluate(want => { if (document.body.classList.contains('dep-mode') !== want) document.getElementById('btnDeps').click(); }, on).then(() => settle(page, 150));
  // Apply, once the last undo's toast has gone: a tall editor puts its buttons at the foot of
  // the window, where the toast sits and would take the click.
  const apply = async () => { await page.waitForFunction(() => !document.getElementById('undoToast').classList.contains('show'), { timeout: 8000 }).catch(() => {}); await page.click('#imApply'); await settle(page, 250); };

  // ── Dependency mode: a milestone at either end ──
  await depMode(true);
  await clickBar(page, 'test');
  check('in dependency mode a task is armed by its bar', await page.$eval('.bar[data-id="test"]', b => b.classList.contains('dep-pending')));
  await clickMilestone(page, M.uid);
  check('a milestone can be the second click: the gate now waits on the test', J(await depsOf('evt-sign-off')) === '["test"]', J(await depsOf('evt-sign-off')));
  let a = await arrowAt('test', 'evt-sign-off'), dm = await diamondOf(M.uid);
  check('its arrow ends on the diamond\'s left point', !!a && !!dm && near(a.tipX, dm.l) && near(a.tipY, dm.y), J({ a, dm }));
  await clickMilestone(page, M.uid);
  check('a milestone is armed by its diamond', await page.evaluate(u => { const h = document.querySelector(`.ms-hit[data-msuid="${u}"]`); return h.parentNode.querySelector('.ms-diamond').classList.contains('dep-pending'); }, M.uid));
  await clickBar(page, 'tooling');
  check('a milestone can be the first click: the tooling now waits on the gate', (await depsOf('tooling')).includes('evt-sign-off'), J(await depsOf('tooling')));
  a = await arrowAt('evt-sign-off', 'tooling'); dm = await diamondOf(M.uid);
  check('and its arrow leaves from the diamond\'s right point', !!a && !!dm && near(a.startX, dm.r) && near(a.startY, dm.y), J({ a, dm }));
  await clickMilestone(page, M.uid); await clickMilestone(page, M2.uid);
  check('one milestone can wait on another', (await depsOf('tooling-done')).includes('evt-sign-off'), J(await depsOf('tooling-done')));
  check('and that arrow runs from diamond to diamond', await (async () => { const x = await arrowAt('evt-sign-off', 'tooling-done'), d1 = await diamondOf(M.uid), d2 = await diamondOf(M2.uid); return !!x && near(x.startX, d1.r) && near(x.tipX, d2.l); })());
  await clickMilestone(page, M2.uid); await clickBar(page, 'test');
  check('a link that would close a loop through milestones is refused, and says so', J(await depsOf('test')) === '["build"]' && /loop/.test(await flashTxt()), await flashTxt());
  // A task that already waits on one gains a second (0.78.2). Dependency mode adds to the row's
  // own list in place, and the tab's copy of what the server holds shared that list, so the
  // page found nothing to send: it said "saved", and a reload lost the link (0.77.0 to 0.78.1).
  const pageDeps = id => page.evaluate(i => ((window.__collab.rows().find(r => r.id === i) || {}).deps || []), id);
  const reloadReady = async () => { await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); };
  await clickBar(page, 'ramp'); await clickBar(page, 'test');
  check('a task that already waits on one gains a second in dependency mode, and the server has both', J(await depsOf('test')) === '["build","ramp"]', J(await depsOf('test')));
  await reloadReady();
  check('and after a reload the page and the server both still have both', J(await pageDeps('test')) === '["build","ramp"]' && J(await depsOf('test')) === '["build","ramp"]', J([await pageDeps('test'), await depsOf('test')]));
  await depMode(true);
  await clickBar(page, 'ramp'); await clickBar(page, 'test');
  const afterOut = J(await depsOf('test'));
  await reloadReady();
  check('the same pair again takes that one out, on the server and after a reload', afterOut === '["build"]' && J(await pageDeps('test')) === '["build"]', J([afterOut, await pageDeps('test')]));
  await depMode(true);
  await clickBar(page, 'test'); await clickMilestone(page, M.uid);
  check('clicking the same pair again takes a milestone\'s link out', !(await depsOf('evt-sign-off')).includes('test'), J(await depsOf('evt-sign-off')));
  await undo(page);
  check('and Ctrl+Z puts it back, still in the mode', J(await depsOf('evt-sign-off')) === '["test"]' && await page.evaluate(() => document.body.classList.contains('dep-mode')), J(await depsOf('evt-sign-off')));
  await depMode(false);

  // ── The item editor and the Table ──
  await clickMilestone(page, M.uid);
  const ins = await page.$$eval('#imDepList .dep-rm[data-dir="in"]', e => e.map(x => x.dataset.dep)), outs = (await page.$$eval('#imDepList .dep-rm[data-dir="out"]', e => e.map(x => x.dataset.dep))).sort();
  check('a milestone\'s editor lists what it waits on and what waits on it', await editorShown(page) && J(ins) === '["test"]' && J(outs) === '["tooling","tooling-done"]', J({ ins, outs }));
  const offered = await page.$$eval('#imAddDep option', o => o.map(x => x.value).filter(Boolean));
  check('it offers tasks to wait on, and nothing that would close a loop', offered.includes('build') && offered.includes('ramp') && !offered.includes('tooling') && !offered.includes('tooling-done') && !offered.includes('evt-sign-off'), J(offered));
  await page.select('#imAddDep', 'build'); await settle(page, 150);
  await apply();
  check('Apply saves the milestone\'s new dependency', J(await depsOf('evt-sign-off')) === '["test","build"]', J(await depsOf('evt-sign-off')));
  await undo(page);
  check('and one undo takes it back', J(await depsOf('evt-sign-off')) === '["test"]', J(await depsOf('evt-sign-off')));
  await clickBar(page, 'ramp');
  const ro = await page.$$eval('#imAddDep option', o => o.map(x => ({ v: x.value, t: x.textContent })));
  check('a task\'s editor offers milestones to wait on, marked as milestones', ro.some(o => o.v === 'tooling-done' && o.t.includes('◆')) && ro.some(o => o.v === 'evt-sign-off'), J(ro));
  await page.select('#imAddDep', 'tooling-done'); await settle(page, 150);
  await apply();
  check('and a task waits on a milestone once applied', (await depsOf('ramp')).includes('tooling-done'), J(await depsOf('ramp')));
  await view(page, 'table');
  const cell = await page.$eval(`#tableView tr[data-key="m:${M.uid}"] td.c-deps`, td => td.textContent.replace(/\s+/g, ''));
  check('the Table\'s Deps cell counts a milestone\'s links both ways', cell === '←1→2', cell);
  await page.click(`#tableView tr[data-key="m:${M.uid}"] td.c-deps`); await settle(page, 200);
  check('and opens the milestone\'s editor with "waits on" focused', await editorShown(page) && await page.evaluate(() => document.activeElement && document.activeElement.id === 'imAddDep'));
  await page.keyboard.press('Escape'); await settle(page, 150);

  // ── Copy and paste: a copied gate is remapped onto the copied work ──
  await clearSel(page);
  const before = new Set((await savedPlan(X.url)).items.map(r => r.uid));
  await page.keyboard.down('Control');
  await page.click('#tableView tr[data-key="t:test"] td.c-deps'); await page.click(`#tableView tr[data-key="m:${M.uid}"] td.c-deps`);
  await page.keyboard.up('Control'); await settle(page, 100);
  await page.keyboard.down('Control'); await page.keyboard.press('KeyC'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control'); await settle(page, 600);
  p = await savedPlan(X.url);
  const born = p.items.filter(r => !before.has(r.uid)), cm = born.find(r => r.type === 'milestone'), ct = born.find(r => r.type === 'task');
  check('a copied gate and the work before it paste as a chain: the copy of the gate waits on the copy of the test', born.length === 2 && !!cm && !!ct && !!cm.id && cm.id !== 'evt-sign-off' && J(cm.deps) === J([ct.id]) && J(ct.deps) === '["build"]', J(born));
  await undo(page);
  p = await savedPlan(X.url);
  check('and Ctrl+Z takes the paste back', !p.items.some(r => !before.has(r.uid)), p.items.length);
  await clearSel(page);
  await view(page, 'gantt');

  // ── Auto-schedule and Critical treat a milestone as a gate ──
  await clickBar(page, 'test');
  await setVal(page, '#imEnd', '2026-10-21'); await apply();
  p = await savedPlan(X.url);
  check('(the test slips to end on 21 Oct)', row(p, r => r.id === 'test').end === '2026-10-21', J(row(p, r => r.id === 'test')));
  await page.click('#btnAuto'); await settle(page, 700);
  const autoSaid = await page.$eval('#undoToast', e => e.textContent);
  p = await savedPlan(X.url);
  const g = row(p, r => r.id === 'evt-sign-off'), tl = row(p, r => r.id === 'tooling'), g2 = row(p, r => r.id === 'tooling-done'), rp = row(p, r => r.id === 'ramp');
  check('Auto-schedule moves a gate to the day the slipped work before it ends', g.date === '2026-10-21', g.date + ' / ' + autoSaid);
  check('a task waiting on the gate starts on the gate\'s date, keeping its working days', tl.start === '2026-10-21' && workdaysO(tl.start, tl.end) === workdaysO('2026-10-19', '2026-10-30'), J([tl.start, tl.end]));
  check('and nothing is pulled earlier: a gate already after its work, and the work after it, stay', g2.date === '2026-10-30' && rp.start === '2026-11-02', J([g2.date, rp.start]));
  await page.click('#btnCrit'); await settle(page, 300);
  const crit = await page.evaluate(u => { const h = document.querySelector(`.ms-hit[data-msuid="${u}"]`); return { ms: h.parentNode.querySelector('.ms-diamond').classList.contains('crit-ms'), into: !!document.querySelector('path.crit[data-from="test"][data-to="evt-sign-off"]') }; }, M.uid);
  check('the critical path runs through the gate: its diamond and the arrow into it are lit', crit.ms && crit.into, J(crit));
  await page.click('#btnCrit'); await settle(page, 200);
  await undo(page);
  p = await savedPlan(X.url);
  check('one undo puts the gate and the work after it back', row(p, r => r.id === 'evt-sign-off').date === '2026-10-16' && row(p, r => r.id === 'tooling').start === '2026-10-19', J([row(p, r => r.id === 'evt-sign-off').date, row(p, r => r.id === 'tooling').start]));

  // ── The lines style: arrows meet the rule, and the cap is the pick target ──
  for (let i = 0; i < 3 && await page.evaluate(() => document.documentElement.dataset.ms) !== 'line'; i++) { await page.click('#btnMsStyle'); await settle(page, 250); }
  await page.evaluate(() => document.querySelector('.bar[data-id="test"]').scrollIntoView({ block: 'center', inline: 'center' })); await settle(page, 200);
  a = await arrowAt('test', 'evt-sign-off');
  const rule = await page.evaluate(u => { const c = document.querySelector(`.ms-cap[data-msuid="${u}"]`), l = c && c.closest('.ms-line'); return l ? l.getBoundingClientRect().left : null; }, M.uid);
  const ty = await page.evaluate(() => { const r = document.querySelector('.bar[data-id="test"]').closest('.bar-row').getBoundingClientRect(); return r.top + r.height / 2; });
  check('in the lines style an arrow into a milestone ends on its rule, level with the task it comes from', !!a && near(a.tipX, rule, 2.5) && near(a.tipY, ty), J({ a, rule, ty }));
  await depMode(true);
  await clickBar(page, 'build'); await clickMilestone(page, M2.uid);
  check('and a milestone is picked in dependency mode by the cap on its rule', (await depsOf('tooling-done')).includes('build'), J(await depsOf('tooling-done')));
  await undo(page); await depMode(false);
  for (let i = 0; i < 3 && await page.evaluate(() => document.documentElement.dataset.ms) !== 'diamond'; i++) { await page.click('#btnMsStyle'); await settle(page, 250); }

  // ── Deleting a milestone takes its links; undo brings them back ──
  await clickMilestone(page, M.uid);
  await page.click('#imDelete'); await settle(page, 400);
  p = await savedPlan(X.url);
  check('deleting a milestone takes every link to it with it', !row(p, r => r.id === 'evt-sign-off') && !(row(p, r => r.id === 'tooling').deps || []).includes('evt-sign-off') && !(row(p, r => r.id === 'tooling-done').deps || []).includes('evt-sign-off'), J(p.items.map(r => [r.id, r.deps])));
  await undo(page);
  p = await savedPlan(X.url);
  check('and Ctrl+Z brings it back with its links, both ways', J(row(p, r => r.id === 'evt-sign-off').deps) === '["test"]' && (row(p, r => r.id === 'tooling').deps || []).includes('evt-sign-off'), J(p.items.map(r => [r.id, r.deps])));

  // ── An HTML snapshot draws them, and one exported before milestones had ids links them too ──
  const html = await (await fetch(X.url + 'export/html')).text();
  const snapNew = join(X.dir, 'gates-snapshot.html'); writeFileSync(snapNew, html);
  let sp = await openPage(browser, 'file://' + snapNew);
  check('an HTML snapshot draws the arrows into and out of a milestone', await sp.evaluate(() => !!document.querySelector('path[data-from="test"][data-to="evt-sign-off"]') && !!document.querySelector('path[data-from="evt-sign-off"][data-to="tooling"]')));
  await sp.close();
  const snapOld = join(X.dir, 'gates-snapshot-old.html');
  writeFileSync(snapOld, html.replace(/,"id":"(evt-sign-off|tooling-done)"/g, ''));
  check('(the old snapshot really has no milestone ids)', !/"id":"(evt-sign-off|tooling-done)"/.test(readFileSync(snapOld, 'utf8')));
  sp = await openPage(browser, 'file://' + snapOld);
  await clickMilestone(sp, M.uid);
  check('a snapshot from before 0.78.0 gives its milestones ids as it opens, so they can be linked', await sp.$eval('#imDepsWrap', e => getComputedStyle(e).display) !== 'none'
    && await sp.evaluate(() => !!document.querySelector('path[data-from="evt-sign-off"][data-to="tooling"]')));
  await sp.close();
  await page.close();
}

// ── The row handle ──
// A strip on the left edge of every task and milestone row, in the chart's name column and
// the Table's ID cell, that selects the row and opens nothing. Drew: nowhere on a row just
// selected it, so deleting one meant opening the editor and pressing its Delete. Walked
// with the mouse and the keyboard as a person would, and each change checked on the server.
// ── Requests (0.80.0): an owner change onto somebody else is a request they answer ──
// Its own server with sign-in on (a fake Adom identity service on loopback), since the
// rest of the suite runs --no-auth, where nobody could answer a request. Two people, Drew
// (the plan's owner) and Ana, each in their own browser context.
async function requestsFlow(browser) {
  section = 'requests';
  const people = { drew: { id: 'u1', name: 'drew2', display_name: 'Drew Owens' }, ana: { id: 'u2', name: 'ana', display_name: 'Ana Lopez' } };
  const intents = new Map(); let nextWho = 'drew', seq = 0;
  const carbon = http.createServer((req, res) => {
    const send = (c, o) => { res.writeHead(c, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };
    const who = String(req.headers.authorization || '').replace(/^Bearer S-/, '');
    if (req.method === 'POST' && req.url === '/auth/intents') { const t = 'T' + (++seq); intents.set(t, nextWho); send(200, { token: t, auth_url: 'http://example.invalid/a' }); return; }
    const m = req.url.match(/^\/auth\/intents\/([^/]+)\/status$/);
    if (m) { const w = intents.get(m[1]); send(200, w ? { state: 'authenticated', session_token: 'S-' + w } : { state: 'pending' }); return; }
    const p = people[who];
    if (req.url === '/user' && p) { send(200, { id: p.id, name: p.name, display_name: p.display_name }); return; }
    if (req.url === '/user/memberships' && p) { send(200, [{ organization: { id: 'o1', name: 'adom' } }]); return; }
    send(401, { error: 'no' });
  });
  const carbonPort = await new Promise(r => carbon.listen(0, '127.0.0.1', () => r(carbon.address().port)));
  try {
    const T = (id, name, ph, x = {}) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew', status: 'not-started', ...x });
    const plan = { project: { title: 'Requests Test', appName: 'requests-test', view: { mode: 'gantt', grain: 'week' } },
      phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],
      items: [T('b1', 'Build one', 0), T('b2', 'Build two', 0), T('b3', 'Build three', 0, { owner: '' }), T('s1', 'Ship one', 1, { owner: 'Ana' }), T('v1', 'Vendor parts', 1, { owner: 'Vendor Co' }),
        { type: 'milestone', name: 'Ship gate', date: '2026-11-02', phase: 1 }] };
    const dir = mkdtempSync(join(TMP, 'requests-'));
    const file = join(dir, 'plan.json');
    writeFileSync(file, JSON.stringify(plan, null, 1));
    const port = await freePort();
    const proc = spawn(process.execPath, [join(ROOT, 'server.js'), '--data', file, '--port', String(port)],
      { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: Object.assign({}, process.env, { ADOM_CARBON_API: `http://127.0.0.1:${carbonPort}` }) });
    let log = ''; proc.stdout.on('data', d => log += d); proc.stderr.on('data', d => log += d);
    const url = `http://127.0.0.1:${port}/`;
    for (let i = 0; i < 100; i++) { try { const r = await fetch(url + 'health'); if (r.ok && log.includes(`:${port}`)) break; } catch {} await sleep(100); }
    servers.push({ proc, url, dir, file, port, log: () => log });
    const token = readFileSync(join(dir, 'plan-agent-token'), 'utf8').trim();
    await fetch(url + 'access', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ owner: 'drew2' }) });
    const signIn = async who => { nextWho = who; const st = await (await fetch(url + 'auth/start', { method: 'POST' })).json();
      const r = await fetch(url + 'auth/poll?token=' + st.token); await r.json(); return ((r.headers.get('set-cookie') || '').match(/adom_gantt_sid=([^;]+)/) || [])[1]; };
    const person = async (who, opts = {}) => { const sid = await signIn(who); const ctx = await browser.createBrowserContext();
      await ctx.setCookie({ name: 'adom_gantt_sid', value: sid, domain: '127.0.0.1', path: '/' });
      return openPage(browser, url, Object.assign({ ctx }, opts)); };
    const D = await person('drew'), A = await person('ana');
    const until = async (fn, ms = 5000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await sleep(120); } return fn(); };
    const row = async id => ((await savedPlan(url, { minWait: 300 })).items || []).find(r => r.id === id) || {};
    const reqs = async () => (await (await fetch(url + 'requests')).json()).requests || [];
    const pill = (pg, id) => pg.evaluate(id => { const r = document.querySelector(`.label-row[data-id="${id}"]`); const p = r && r.querySelector('.req-pill'); return p ? { text: p.textContent, mine: p.classList.contains('mine') } : null; }, id);
    const badge = pg => pg.$eval('#reqN', e => e.textContent);
    const ownerPill = (pg, id) => pg.evaluate(id => { const r = document.querySelector(`.label-row[data-id="${id}"]`); const p = r && r.querySelector('.owner-pill'); return p ? p.textContent : null; }, id);
    const undoKey = async pg => { await pg.keyboard.down('Control'); await pg.keyboard.press('KeyZ'); await pg.keyboard.up('Control'); await settle(pg, 300); };
    const redoKey = async pg => { await pg.keyboard.down('Control'); await pg.keyboard.down('Shift'); await pg.keyboard.press('KeyZ'); await pg.keyboard.up('Shift'); await pg.keyboard.up('Control'); await settle(pg, 300); };

    check('the Requests button is in the header for a signed-in person', await D.$eval('#btnReq', b => window.__uiVis(b)) && (await badge(D)) === '');
    // 1. Drew sets Ana as the owner in the Table: it asks her, and the row stays his.
    await D.bringToFront(); await view(D, 'table');
    await D.click('#tableView tr[data-key="t:b1"] td[data-field="owner"]'); await settle(D, 100);
    await D.keyboard.down('Control'); await D.keyboard.press('KeyA'); await D.keyboard.up('Control'); await D.keyboard.type('Ana'); await D.keyboard.press('Enter'); await settle(D, 600);
    let q = (await reqs()).find(x => x.uid && x.state === 'pending');
    check('an owner set to somebody else in the Table becomes a pending request; the row keeps its owner', (await row('b1')).owner === 'Drew' && q && q.to === 'Ana' && q.from.name === 'Drew Owens', JSON.stringify([await row('b1'), q]));
    check('the Table shows the owner and the pending pill', await D.$eval('#tableView tr[data-key="t:b1"] td[data-field="owner"]', td => td.textContent.includes('Drew') && !!td.querySelector('.req-pill') && td.querySelector('.req-pill').textContent === '→ Ana?'));
    check('the toast says it asked rather than assigned', /Asked Ana to take "Build one"/.test(await D.$eval('#saveInd', e => e.textContent)), await D.$eval('#saveInd', e => e.textContent));
    await view(D, 'gantt');
    check('the chart row shows the pending pill after the owner', (await pill(D, 'b1'))?.text === '→ Ana?' && (await ownerPill(D, 'b1')) === 'Drew', JSON.stringify(await pill(D, 'b1')));
    // 2. Ana sees it live: the badge, the pill in the accent.
    check('Ana\'s badge counts it, live', await until(async () => (await badge(A)) === '1'), await badge(A));
    check('Ana\'s row shows it as hers to answer', await until(async () => (await pill(A, 'b1'))?.mine === true), JSON.stringify(await pill(A, 'b1')));
    if (KEEP) { await D.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]); }
    // 3. Ana opens the inbox and accepts with a note.
    await A.bringToFront(); await A.click('#btnReq'); await settle(A, 200);
    check('the inbox opens with the request under "For you"', await A.$eval('#reqPanel', p => p.classList.contains('show') && p.textContent.includes('For you') && p.textContent.includes('Build one') && p.textContent.includes('Drew Owens asked you')));
    await shot(A, 'requests-inbox-dark');
    await A.type('#reqPanel .rq-in', 'yes, after Friday');
    await A.click('#reqPanel .rq-b.yes'); await settle(A, 800);
    q = (await reqs()).find(x => x.id === q.id);
    check('accept: the row is Ana\'s on the server, with her note', (await row('b1')).owner === 'Ana' && q.state === 'accepted' && q.reply === 'yes, after Friday', JSON.stringify([await row('b1'), q]));
    check('Ana\'s page shows her as the owner, with no pill, and her badge clears', await until(async () => (await ownerPill(A, 'b1')) === 'Ana' && !(await pill(A, 'b1')) && (await badge(A)) === ''));
    check('Drew\'s page shows Ana as the owner, live', await until(async () => (await ownerPill(D, 'b1')) === 'Ana' && !(await pill(D, 'b1'))), await ownerPill(D, 'b1'));
    check('Drew\'s badge counts her answer', await until(async () => (await badge(D)) === '1'), await badge(D));
    await D.bringToFront(); await D.click('#btnReq'); await settle(D, 200);
    check('Drew sees the answer under "You asked": accepted, by whom, and the note', await D.$eval('#reqPanel', p => /You asked/.test(p.textContent) && /accepted/.test(p.textContent) && p.textContent.includes('Ana Lopez') && p.textContent.includes('yes, after Friday')));
    await shot(D, 'requests-asked-dark');
    check('and seeing it clears his badge', (await badge(D)) === '');
    await D.keyboard.press('Escape'); await settle(D, 100);
    check('Escape closes the inbox', !(await D.$eval('#reqPanel', p => p.classList.contains('show'))));
    // 4. Ana takes her accept back with Ctrl+Z: pending again, and the row Drew's again.
    await A.bringToFront(); await A.mouse.click(5, 300); await undoKey(A); await settle(A, 600);
    q = (await reqs()).find(x => x.id === q.id);
    check('Ana\'s Ctrl+Z takes back her accept: the request is pending and the row is Drew\'s again', q.state === 'pending' && (await row('b1')).owner === 'Drew', JSON.stringify([q.state, (await row('b1')).owner]));
    // 5. She declines from the item editor instead.
    await openItemEditor(A, 'Build one'); await settle(A, 200);
    check('the item editor says who asked her, with Accept and Decline', await A.$eval('#imReq', e => window.__uiVis(e) && e.textContent.includes('asked you to take this') && !!e.querySelector('#imReqYes') && !!e.querySelector('#imReqNo')));
    await shot(A, 'requests-editor-dark');
    await A.click('#imReqNo'); await settle(A, 800);
    q = (await reqs()).find(x => x.id === q.id);
    check('decline: the row stays Drew\'s', q.state === 'declined' && (await row('b1')).owner === 'Drew', JSON.stringify(q));
    await A.keyboard.press('Escape');
    // 6. Drew, in the item editor: the owner and a status in one Apply, one undo for both.
    await D.bringToFront(); await openItemEditor(D, 'Build two'); await settle(D, 200);
    await D.$eval('#imOwner', e => { e.value = 'Ana'; e.dispatchEvent(new Event('input', { bubbles: true })); });
    await D.select('#imStatus', 'in-progress'); await D.click('#imApply'); await settle(D, 800);
    let b2 = await row('b2'); q = (await reqs()).find(x => x.uid === b2.uid && x.state === 'pending');
    check('the editor\'s Apply lands the status and asks Ana for the owner', b2.status === 'in-progress' && b2.owner === 'Drew' && q && q.to === 'Ana', JSON.stringify([b2, q]));
    await D.mouse.click(5, 300); await undoKey(D); await settle(D, 600);
    b2 = await row('b2');
    check('one Ctrl+Z takes back both: the status, and the request (withdrawn)', b2.status === 'not-started' && (await reqs()).find(x => x.id === q.id).state === 'withdrawn', JSON.stringify([b2.status, (await reqs()).find(x => x.id === q.id)]));
    await redoKey(D); await settle(D, 600);
    b2 = await row('b2');
    check('and Ctrl+Shift+Z puts both back: the status, and the request pending again', b2.status === 'in-progress' && (await reqs()).find(x => x.id === q.id).state === 'pending', JSON.stringify([b2.status, (await reqs()).find(x => x.id === q.id)]));
    // 7. Withdraw from the inbox.
    await D.click('#btnReq'); await settle(D, 200);
    await D.click('#reqPanel .rq-b[data-a="withdraw"]'); await settle(D, 700);
    check('Drew withdraws it from the inbox', (await reqs()).find(x => x.id === q.id).state === 'withdrawn');
    await D.keyboard.press('Escape');
    // 8. Taking a task yourself is direct, from the owner pill.
    await D.evaluate(() => document.querySelector('.label-row[data-id="b3"] .owner-pill').click()); await settle(D, 200);
    await D.keyboard.type('Drew'); await D.keyboard.press('Enter'); await settle(D, 700);
    check('setting yourself as the owner is direct, not a request', (await row('b3')).owner === 'Drew' && !(await reqs()).some(x => x.uid === (plan.items[2].uid || '') && x.state === 'pending') && !(await pill(D, 'b3')), JSON.stringify(await row('b3')));
    // 9. A new task for Ana is made unassigned and asked of her.
    await D.click('#btnNew'); await settle(D, 200);
    await D.type('#ncName', 'For Ana'); await D.$eval('#ncOwner', e => { e.value = 'Ana'; e.dispatchEvent(new Event('input', { bubbles: true })); });
    await D.click('#ncCreate'); await settle(D, 900);
    const made = ((await savedPlan(url, { minWait: 300 })).items || []).find(r => r.name === 'For Ana');
    q = made && (await reqs()).find(x => x.uid === made.uid);
    check('a new task for somebody else is made unassigned and asked of them', made && !made.owner && q && q.state === 'pending' && q.created === true, JSON.stringify([made, q]));
    check('Ana gets it live', await until(async () => (await badge(A)) === '1'), await badge(A));
    // 10. A name nobody with an account answers to: assigned directly, marked quietly.
    await D.bringToFront(); await view(D, 'gantt');
    check('an owner name with no Adom account is marked (dotted), and a known one is not', await D.evaluate(() => { const v = document.querySelector('.label-row[data-id="v1"] .owner-pill'), a = document.querySelector('.label-row[data-id="s1"] .owner-pill');
      return !!v && v.classList.contains('unlinked') && /not linked to an Adom account/.test(v.title) && !!a && !a.classList.contains('unlinked'); }));
    // 11. The People card on Setup: the owner links Vendor Co to Ana's account.
    await view(D, 'setup'); await settle(D, 400);
    check('Setup lists every owner name with its account, Vendor Co not linked', await D.$eval('#suPeople', c => window.__uiVis(c) && /Vendor Co/.test(c.textContent) && /not linked/.test(c.textContent) && /Ana Lopez/.test(c.textContent)));
    await D.evaluate(() => document.getElementById('suPeople').scrollIntoView({ block: 'center' })); await settle(D, 200);
    await shot(D, 'requests-people-dark');
    const anaAcc = await D.evaluate(() => { const o = [...document.querySelectorAll('.pp-acc[data-name="Vendor Co"] option')].find(x => /Ana Lopez/.test(x.textContent)); return o ? o.value : ''; });
    await D.select('.pp-acc[data-name="Vendor Co"]', anaAcc); await settle(D, 900);
    const pv = await (await fetch(url + 'people?name=Vendor%20Co')).json();
    check('picking an account links the name on the server', pv.lookup && pv.lookup.how === 'linked' && pv.lookup.accounts[0].name === 'Ana Lopez', JSON.stringify(pv.lookup));
    check('Ana\'s tab drops the mark live', await until(async () => A.evaluate(() => { const v = document.querySelector('.label-row[data-id="v1"] .owner-pill'); return !!v && !v.classList.contains('unlinked'); })));
    await view(D, 'gantt');
    // 12. Moving the dates of Ana's task tells her: a notice in her inbox, not a request.
    const nBefore = +(await badge(A) || 0);
    await openItemEditor(D, 'Ship one'); await settle(D, 200);
    await D.$eval('#imEnd', e => { e.value = '2026-10-16'; e.dispatchEvent(new Event('input', { bubbles: true })); e.dispatchEvent(new Event('change', { bubbles: true })); });
    await D.click('#imApply'); await settle(D, 900);
    check('the date lands at once (it is not a request)', (await row('s1')).end === '2026-10-16', JSON.stringify(await row('s1')));
    check('Ana\'s badge counts the notice, live', await until(async () => +(await badge(A) || 0) === nBefore + 1), `${nBefore} -> ${await badge(A)}`);
    await A.bringToFront(); await A.click('#btnReq'); await settle(A, 200);
    check('her inbox lists it under "Changes to your tasks", with the dates it had', await A.$eval('#reqPanel', p => /Changes to your tasks/.test(p.textContent) && /Drew Owens moved the dates of your task/.test(p.textContent) && /Ship one/.test(p.textContent) && !!p.querySelector('.rq-was')));
    await shot(A, 'requests-notice-dark');
    // 13. Ana lets her AI answer for her; the agent key then answers a request in her name.
    await A.click('#rqAi'); await settle(A, 800);
    const dl = (await (await fetch(url + 'requests', { headers: { Authorization: 'Bearer ' + token } })).json()).delegates || {};
    check('the "let my AI answer" switch is stored on the server', Object.values(dl).some(v => v && v.on && v.name === 'Ana Lopez'), JSON.stringify(dl));
    await A.keyboard.press('Escape');
    const b2r = await row('b2');
    const ask = await (await fetch(url + 'ops', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ ops: [{ op: 'req', uid: b2r.uid, to: 'Ana' }] }) })).json();
    const aid = ask.requested && ask.requested[0] && ask.requested[0].id;
    const ans = await (await fetch(url + 'ops', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ ops: [{ op: 'req.answer', id: aid, accept: true, for: 'ana' }] }) })).json();
    check('with her permission the agent accepts for her', !!aid && (await row('b2')).owner === 'Ana' && !(ans.refused || []).length, JSON.stringify([ask.requested, ans.refused]));
    check('every tab shows the row as Ana\'s', await until(async () => (await ownerPill(D, 'b2')) === 'Ana' && (await ownerPill(A, 'b2')) === 'Ana'));
    // Light theme screenshots, and the phone width.
    if (KEEP) {
      for (const pg of [D, A]) { await pg.bringToFront(); await pg.click('#btnTheme'); await settle(pg, 200); }
      await A.bringToFront(); await A.click('#btnReq'); await settle(A, 200); await shot(A, 'requests-inbox-light'); await A.keyboard.press('Escape');
      await D.bringToFront(); await view(D, 'setup'); await settle(D, 300); await D.evaluate(() => document.getElementById('suPeople').scrollIntoView({ block: 'center' })); await settle(D, 200); await shot(D, 'requests-people-light'); await view(D, 'gantt');
      await D.bringToFront(); await view(D, 'table'); await settle(D, 200); await shot(D, 'requests-table-light'); await view(D, 'gantt');
      for (const pg of [D, A]) { await pg.click('#btnTheme'); await settle(pg, 150); }
    }
    await A.bringToFront(); await A.setViewport({ width: 393, height: 852 }); await settle(A, 400);
    check('at 393px the Requests button is on screen and the inbox fits', await A.evaluate(async () => { const b = document.getElementById('btnReq'); const r = b.getBoundingClientRect(); if (!(r.width > 0 && r.left >= 0 && r.right <= innerWidth)) return false;
      b.click(); await new Promise(x => setTimeout(x, 200)); const p = document.getElementById('reqPanel').getBoundingClientRect(); return p.left >= 0 && p.right <= innerWidth; }));
    await shot(A, 'requests-393');
    await A.keyboard.press('Escape'); await A.setViewport({ width: 1440, height: 900 });
    // The changelog says it in words, as the server wrote it.
    const md = await (await fetch(url + 'changelog?format=md')).text();
    check('the changelog says the AI accepted for Ana, and that Ana was told about her moved task', /Ana Lopez's AI accepted "Build two" for them/.test(md) && /moved the dates of Ana's task "Ship one", and Ana was told/.test(md), md.split('\n').filter(l => /AI|told/.test(l)).slice(0, 4).join(' | '));
    check('the changelog has the request, the accept, the take-back and the decline', /Drew Owens asked Ana to take "Build one"/.test(md) && /Ana Lopez accepted "Build one"/.test(md) && /took back their accept/.test(md) && /Ana Lopez declined "Build one"/.test(md), md.split('\n').filter(l => /ask|accept|declin/.test(l)).slice(0, 6).join(' | '));
    check('and no line claims Drew reassigned "Build one"', !/reassigned "Build one" from Drew to Ana/.test(md));
    check('the changelog chain is whole', (await (await fetch(url + 'health')).json()).integrity.ok === true);
    await D.close(); await A.close();
  } finally { carbon.close(); }
}
async function rowSelectFlow(browser) {
  section = 'row select';
  const T = (id, name, ph, extra) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew', status: 'in-progress', ...extra });
  const LONG = 'Alpha two, a name long enough to run on past both of its pills and keep going';
  const X = await startServer({ project: { title: 'Select Test', appName: 'select-test', view: { mode: 'gantt', grain: 'week', labelWidth: 300, milestones: 'diamonds' } },
    phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }, { name: 'Later', start: '2026-10', end: '2026-11' }],
    items: [T('a1', 'Alpha one', 0), T('a2', LONG, 0, { tags: ['long-lead'] }), T('a3', 'Alpha three', 0), { type: 'milestone', name: 'Alpha gate', date: '2026-10-20', phase: 0 },
      T('b1', 'Bravo one', 1), T('b2', 'Bravo two', 1), T('c1', 'Charlie one', 2), T('c2', 'Charlie two', 2)] }, 'select');
  const page = await openPage(browser, X.url);
  const J = v => JSON.stringify(v);
  const order = async () => (await savedPlan(X.url)).items.filter(r => r.type !== 'group').map(r => r.id || r.name);
  const has = async id => (await order()).includes(id);
  // Where a row's handle is, the row brought into view first, by its name in either lens.
  const locate = (name, lens) => page.evaluate((n, lens) => {
    const rows = lens === 'table' ? [...document.querySelectorAll('#tableBody tr[data-key]')] : [...document.querySelectorAll('#labels .label-row:not(.group)')];
    const e = rows.find(x => ((lens === 'table' ? x.querySelector('td.c-name') : x.querySelector('.name-text')) || {}).textContent?.replace('◆', '').trim() === n);
    if (!e) return null;
    const h = e.querySelector('.row-sel'), r = e.getBoundingClientRect();
    const nm = lens === 'table' ? e.querySelector('.uid-link') : e.querySelector('.name-text');
    if (!h) return { rowX: r.left + r.width / 2, rowY: r.top + r.height / 2 };
    // The name's TEXT: its box reaches 3px to the left of it for the hover outline.
    let nb = null; if (nm) { if (lens === 'table') nb = nm.getBoundingClientRect(); else { const rg = document.createRange(); rg.selectNodeContents(nm); nb = rg.getBoundingClientRect(); } }
    const b = h.getBoundingClientRect();
    const top = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2);
    return { rowX: r.left + Math.min(r.width / 2, 200), rowY: r.top + r.height / 2, x: b.left + b.width / 2, y: b.top + b.height / 2, w: b.width, h: b.height, rowH: r.height,
      left: b.left - r.left, right: b.right, nextLeft: nb ? nb.left : null, onTop: top === h || h.contains(top), op: +getComputedStyle(h).opacity, tip: h.title };
  }, name, lens);
  const aim = async (name, lens = 'gantt') => {
    await page.evaluate((n, lens) => { const rows = lens === 'table' ? [...document.querySelectorAll('#tableBody tr[data-key]')] : [...document.querySelectorAll('#labels .label-row:not(.group)')];
      const e = rows.find(x => ((lens === 'table' ? x.querySelector('td.c-name') : x.querySelector('.name-text')) || {}).textContent?.replace('◆', '').trim() === n); if (e) e.scrollIntoView({ block: 'center' }); }, name, lens);
    await settle(page, 80);
    return locate(name, lens);
  };
  // Hover the row (the handle shows on hover, as for a person), then click the handle.
  const pick = async (name, mods = [], lens = 'gantt') => {
    const a = await aim(name, lens);
    if (!a || !a.w) throw new Error(`rowSelectFlow: no handle on "${name}" (${lens})`);
    await page.mouse.move(a.rowX, a.rowY); await settle(page, 60);
    for (const m of mods) await page.keyboard.down(m);
    await page.mouse.click(a.x, a.y);
    for (const m of mods) await page.keyboard.up(m);
    await settle(page, 150);
  };
  const picked = (lens = 'gantt') => page.evaluate(lens => lens === 'table'
    ? [...document.querySelectorAll('#tableBody tr.multi-sel td.c-name')].map(t => t.textContent.replace('◆', '').trim())
    : [...document.querySelectorAll('#labels .label-row.multi-sel .name-text')].map(t => t.textContent.trim()), lens);
  // Nothing opened: no editor, no rename, no cell, no list, no owner field, and the keyboard is the page's.
  const opened = () => page.evaluate(() => ({ editor: document.getElementById('editor').classList.contains('show'), rename: !!document.querySelector('.label-edit'), cell: !!document.querySelector('.cell-edit'),
    list: !!document.querySelector('.cell-list'), owner: !!document.querySelector('.pill-owner-inp'), focus: document.activeElement === document.body ? 'page' : (document.activeElement.className || document.activeElement.tagName) }));
  const nothingOpen = o => !o.editor && !o.rename && !o.cell && !o.list && !o.owner && o.focus === 'page';
  const toastTxt = () => page.$eval('#undoToast', e => e.classList.contains('show') ? e.textContent : '');
  const chip = () => page.$eval('#selCount', e => e.classList.contains('show') ? e.textContent : '');
  const esc = async (n = 1) => { for (let i = 0; i < n; i++) { await page.keyboard.press('Escape'); await settle(page, 80); } };
  const del = async () => { await page.keyboard.press('Delete'); await settle(page, 200); };

  // ── The chart ──
  await page.mouse.move(900, 860); await settle(page, 100);
  let a = await aim('Alpha one');
  check('a row\'s handle is out of sight while the row is not hovered', !!a && a.w > 0 && a.op === 0, J(a));
  await page.mouse.move(a.rowX, a.rowY); await settle(page, 200);
  a = await locate('Alpha one');
  check('hovering the row shows its handle on the left edge, full height, on top, with a tooltip', a.op === 1 && a.left <= 1 && a.w >= 10 && Math.abs(a.h - a.rowH) <= 1 && a.onTop && /Select this row/.test(a.tip), J(a));
  check('the handle sits in the row\'s own padding, clear of the name', a.nextLeft !== null && a.right <= a.nextLeft + 0.5, J(a));
  const aLong = await aim(LONG), aGate = await aim('Alpha gate');
  check('a row whose name fills the column has one too, and so does a milestone', !!aLong && aLong.w >= 10 && !!aGate && aGate.w >= 10 && aGate.right <= aGate.nextLeft + 0.5, J({ aLong, aGate }));
  await pick('Alpha one');
  let o = await opened();
  check('clicking the handle selects that row and opens nothing (no rename, no picker, no editor)', J(await picked()) === J(['Alpha one']) && nothingOpen(o), J({ sel: await picked(), o }));
  check('the selection chip counts it', /1 selected/.test(await chip()), await chip());
  await page.mouse.move(900, 860); await settle(page, 150);
  check('a selected row keeps its handle in view, in the accent', (await locate('Alpha one')).op === 1);
  await pick(LONG);
  check('a click on another row\'s handle selects it and clears the others', J(await picked()) === J([LONG]));
  await pick(LONG);
  check('clicking a selected row\'s handle keeps it selected (never a toggle)', J(await picked()) === J([LONG]));
  await pick('Alpha three', ['Control']);
  o = await opened();
  check('Ctrl+click adds a row, and opens no batch panel', J(await picked()) === J([LONG, 'Alpha three']) && nothingOpen(o), J({ sel: await picked(), o }));
  await pick('Alpha three', ['Control']);
  check('Ctrl+click again takes it out', J(await picked()) === J([LONG]));
  await pick('Alpha one');
  await pick('Bravo one', ['Shift']);
  check('Shift+click selects every row between, milestones too', J(await picked()) === J(['Alpha one', LONG, 'Alpha three', 'Alpha gate', 'Bravo one']), J(await picked()));
  await esc();
  check('one Escape clears the selection', (await picked()).length === 0 && !(await chip()));
  // A range does not reach into a folded section.
  await page.evaluate(() => [...document.querySelectorAll('#labels .label-row.group')].find(g => g.textContent.includes('Ship')).querySelector('.lane-toggle').click()); await settle(page, 200);
  await pick('Alpha one'); await pick('Charlie one', ['Shift']);
  check('a Shift range skips the rows of a folded section', J(await picked()) === J(['Alpha one', LONG, 'Alpha three', 'Alpha gate', 'Charlie one']), J(await picked()));
  await page.evaluate(() => [...document.querySelectorAll('#labels .label-row.group')].find(g => g.textContent.includes('Ship')).querySelector('.lane-toggle').click()); await settle(page, 200);
  await esc();

  // Delete twice, with the app's own question in between.
  await pick('Alpha three');
  await del();
  const armed = await page.evaluate(() => [...document.querySelectorAll('#labels .label-row.del-armed .name-text')].map(e => e.textContent));
  check('Delete once asks first: the row is marked and the toast names it', J(armed) === J(['Alpha three']) && /Press Delete again to delete .Alpha three./.test(await toastTxt()) && await has('a3'), J({ armed, t: await toastTxt() }));
  await del();
  check('Delete again deletes it, and the toast offers Ctrl+Z', !(await has('a3')) && /Deleted Alpha three/.test(await toastTxt()), J({ order: await order(), t: await toastTxt() }));
  await undo(page);
  check('Ctrl+Z brings it back, in its place', J((await order()).slice(0, 4)) === J(['a1', 'a2', 'a3', 'alpha-gate']), J(await order()));
  await pick('Bravo one'); await pick('Bravo two', ['Control']);
  await del(); await del();
  const after2 = await order();
  check('Delete twice takes a whole selection, in one go', !after2.includes('b1') && !after2.includes('b2') && /Deleted 2 rows/.test(await toastTxt()), J({ after2, t: await toastTxt() }));
  await undo(page);
  check('and one Ctrl+Z brings the whole selection back', (await has('b1')) && (await has('b2')));
  await pick('Charlie one');
  await del(); await esc();
  check('Escape withdraws the question and keeps the selection', J(await picked()) === J(['Charlie one']) && !(await page.$('#labels .label-row.del-armed')));
  await del();
  check('so the next Delete asks again rather than deleting', (await has('c1')) && !!(await page.$('#labels .label-row.del-armed')));
  await esc(2);

  // Everything else on the row still does what it did.
  a = await aim('Alpha one');
  const nameAt = await page.evaluate(() => { const e = [...document.querySelectorAll('#labels .name-text')].find(x => x.textContent === 'Alpha one'); const r = e.getBoundingClientRect(); return { x: r.left + 12, y: r.top + r.height / 2 }; });
  await page.mouse.click(nameAt.x, nameAt.y); await settle(page, 150);
  check('a click on the name still renames it in place', !!(await page.$('#labels .label-edit')));
  await esc();
  await page.evaluate(() => [...document.querySelectorAll('#labels .label-row')].find(x => x.querySelector('.name-text')?.textContent === 'Alpha one').querySelector('.status-pill').dataset.t = 'st');
  await page.click('.status-pill[data-t="st"]'); await settle(page, 150);
  check('the status pill still opens its list', !!(await page.$('.cell-list')));
  await esc();
  await page.evaluate(() => [...document.querySelectorAll('#labels .label-row')].find(x => x.querySelector('.name-text')?.textContent === 'Alpha one').querySelector('.owner-pill').dataset.t = 'ow');
  await page.click('.owner-pill[data-t="ow"]'); await settle(page, 150);
  check('the owner pill still opens the owner field', !!(await page.$('.pill-owner-inp')));
  await esc(2);
  await page.evaluate(() => { window.__lastRowLink = ''; [...document.querySelectorAll('#labels .label-row')].find(x => x.querySelector('.name-text')?.textContent === 'Alpha one').querySelector('.row-link').dataset.t = 'lk'; });
  await page.hover('.row-link[data-t="lk"]'); await page.click('.row-link[data-t="lk"]'); await settle(page, 200);
  check('the link button still copies the row\'s link', /#row=/.test(await page.evaluate(() => window.__lastRowLink || '')));
  await clickBar(page, 'a1');
  check('a click on the bar still opens the item editor', await editorShown(page));
  await esc(2);
  // The handle drags the row, up and down only.
  const datesOf = async id => { const r = (await savedPlan(X.url)).items.find(x => x.id === id); return r.start + '..' + r.end; };
  const d0 = await datesOf('a1');
  a = await aim('Alpha one');
  await page.mouse.move(a.x, a.y); await page.mouse.down();
  for (let i = 1; i <= 6; i++) { await page.mouse.move(a.x + 15 * i, a.y); await sleep(20); }
  await page.mouse.up(); await settle(page, 300);
  check('a sideways drag from the handle moves no dates', (await datesOf('a1')) === d0, await datesOf('a1'));
  a = await aim('Alpha one'); const a3 = await locate('Alpha three');
  await page.mouse.move(a.x, a.y); await page.mouse.down();
  for (let i = 1; i <= 8; i++) { await page.mouse.move(a.x, a.y + (a3.y + a3.h * 0.3 - a.y) * i / 8); await sleep(20); }
  await page.mouse.up(); await settle(page, 300);
  check('dragging the handle down moves the row', J((await order()).slice(0, 3)) === J(['a2', 'a3', 'a1']), J(await order()));
  await undo(page);
  check('(and Ctrl+Z puts it back)', J((await order()).slice(0, 3)) === J(['a1', 'a2', 'a3']), J(await order()));
  await esc(2);
  // The keyboard is the page's straight after a click on the handle.
  await pick('Charlie one');
  await page.keyboard.down('Alt'); await page.keyboard.press('ArrowDown'); await page.keyboard.up('Alt'); await settle(page, 300);
  check('right after the handle, Alt+Down moves the selected row', J((await order()).slice(-2)) === J(['c2', 'c1']), J(await order()));
  await undo(page);
  await esc(2);

  // ── The Table ──
  // In plan order (it opens sorted by deadline), where its rows can be dragged.
  await view(page, 'table');
  await page.evaluate(() => { const b = document.getElementById('toPlanOrder'); if (b && b.offsetParent) b.click(); }); await settle(page, 200);
  const tkeys = await page.$$eval('#tableBody tr[data-key]', t => t.map(x => x.dataset.key));
  check('(the Table is in plan order)', J(tkeys.filter(k => k.startsWith('t:'))) === J(['t:a1', 't:a2', 't:a3', 't:b1', 't:b2', 't:c1', 't:c2']) && tkeys[3].startsWith('m:'), J(tkeys));
  await page.mouse.move(900, 860); await settle(page, 100);
  a = await aim('Bravo one', 'table');
  check('the Table\'s handle is out of sight until its row is hovered', !!a && a.w > 0 && a.op === 0, J(a));
  await page.mouse.move(a.rowX, a.rowY); await settle(page, 200);
  a = await locate('Bravo one', 'table');
  check('hovering a Table row shows the handle at the start of the ID cell, clear of the ID link', a.op === 1 && a.onTop && a.left <= 1 && a.nextLeft !== null && a.right <= a.nextLeft + 0.5, J(a));
  check('and the ID link is still the thing under its own middle', await page.evaluate(() => { const l = [...document.querySelectorAll('#tableBody .uid-link')][4]; const r = l.getBoundingClientRect(); const t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return t === l || l.contains(t); }));
  await pick('Bravo one', [], 'table');
  o = await opened();
  check('clicking it selects the row, opens no cell, and makes it the focused row', J(await picked('table')) === J(['Bravo one']) && nothingOpen(o)
    && await page.evaluate(() => document.querySelector('#tableBody tr.row-focus td.c-name')?.textContent.trim()) === 'Bravo one', J({ sel: await picked('table'), o }));
  await pick('Charlie one', ['Control'], 'table');
  check('Ctrl+click adds a row in the Table too', J(await picked('table')) === J(['Bravo one', 'Charlie one']));
  await pick('Alpha three', ['Shift'], 'table');
  check('and Shift+click takes the range of rows the Table shows', J(await picked('table')) === J(['Alpha three', 'Alpha gate', 'Bravo one', 'Bravo two', 'Charlie one']), J(await picked('table')));
  await esc();
  check('one Escape clears the selection and the focused row', (await picked('table')).length === 0 && !(await page.$('#tableBody tr.row-focus')));
  await pick('Bravo one', [], 'table');
  await page.keyboard.press('ArrowDown'); await settle(page, 100);
  check('right after the handle the arrows walk the rows', await page.evaluate(() => document.querySelector('#tableBody tr.row-focus td.c-name')?.textContent.trim()) === 'Bravo two');
  await esc(2);
  await pick('Bravo two', [], 'table');
  await page.keyboard.press('Tab'); await settle(page, 150);
  check('and Tab opens the selected row\'s first cell', await page.evaluate(() => !!document.querySelector('#tableBody tr.row-focus .cell-edit') && document.querySelector('#tableBody tr.row-focus')?.dataset.key === 't:b2'));
  await esc(3);
  await pick('Charlie two', [], 'table');
  await del();
  check('Delete once in the Table asks first, the same as the chart', (await has('c2')) && !!(await page.$('#tableBody tr.del-armed')) && /Press Delete again to delete .Charlie two./.test(await toastTxt()), await toastTxt());
  await del();
  check('Delete again deletes it', !(await has('c2')) && /Deleted Charlie two/.test(await toastTxt()), J(await order()));
  await del();
  check('a third Delete asks about the row now focused, and deletes nothing', (await has('c1')) && !!(await page.$('#tableBody tr.del-armed')));
  await esc();
  await undo(page);
  check('Ctrl+Z brings the deleted row back', await has('c2'));
  await esc(2);
  const cell = await page.evaluate(() => { const td = [...document.querySelectorAll('#tableBody td.c-name')].find(t => t.textContent.trim() === 'Bravo one'); td.scrollIntoView({ block: 'center' }); const r = td.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
  await page.mouse.click(cell.x, cell.y); await settle(page, 150);
  check('a plain click on a cell still opens its editor', !!(await page.$('#tableBody .cell-edit')));
  await esc(3);
  // Held, the handle lifts the row like the rest of it.
  a = await aim('Bravo one', 'table'); const b2 = await locate('Bravo two', 'table');
  await page.mouse.move(a.x, a.y); await page.mouse.down(); await sleep(450);
  for (let i = 1; i <= 8; i++) { await page.mouse.move(a.x, a.y + (b2.y + b2.h * 0.3 - a.y) * i / 8); await sleep(25); }
  await page.mouse.up(); await settle(page, 300);
  const ob = await order();
  check('a held press on the Table\'s handle drags the row', ob.indexOf('b2') < ob.indexOf('b1'), J(ob));
  await undo(page);
  await page.close();
}

function cleanupSync() {
  if (cleanedUp) return; cleanedUp = true;
  for (const s of servers) { try { s.proc.kill('SIGKILL'); } catch {} }
  try { BROWSER?.process()?.kill('SIGKILL'); } catch {}
  if (!KEEP) { try { rmSync(TMP, { recursive: true, force: true }); } catch {} }
}
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => {
  console.log(`\n${sig}: stopping, killing ${servers.length} server(s) and Chrome, removing ${TMP}`);
  cleanupSync(); process.exit(130);
});
// A global watchdog: a hung page or a wedged server must not hold the run open forever.
const WATCHDOG_MS = +(process.env.UI_TEST_WATCHDOG_MS || 30 * 60 * 1000);
const watchdog = setTimeout(() => {
  console.error(`\nWATCHDOG: the suite ran past ${WATCHDOG_MS / 60000} min; aborting (last flow: ${section || '-'})`);
  console.log(`\n${pass} passed, ${fail + 1} failed`);
  cleanupSync(); process.exit(2);
}, WATCHDOG_MS);
watchdog.unref();

let exitCode = 1;
try {
  await main();
  exitCode = fail ? 1 : 0;
} catch (e) {
  console.error('\nSUITE CRASHED:', e && e.stack || e);
  fail++; failures.push('suite crashed: ' + (e && e.message));
} finally {
  for (const s of servers) { try { s.proc.kill('SIGTERM'); } catch {} }
  await sleep(200);
  cleanupSync();
  if (KEEP) console.log(`\nkept: temp state ${TMP}, screenshots ${OUT}`);
  if (UNTESTED) {
    console.log(`\n${UNTESTED.length} manifest entries are declared UNTESTED (no flow proves their effect):`);
    for (const d of UNTESTED) console.log(`  - ${entryId(d)} (${d.label}): ${d.untested}`);
  } else if (ONLY) console.log(`\n--only ${ONLY.join(',')}: manifest honesty gates and the untested count run on a full run only`);
  console.log(`\n${pass} passed, ${fail} failed${UNTESTED ? `, ${UNTESTED.length} controls untested` : ''}`);
  if (failures.length) console.log('Failures:\n  - ' + failures.join('\n  - '));
  process.exit(fail ? 1 : exitCode);
}