master
Drew Owens Publish 0.80.0 eb7d34d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>adom-gantt</title>
<link rel="icon" href="favicon.svg">
<!-- Brand faces: Familjen Grotesk for headings, Satoshi for body, JetBrains Mono
     for data. These were @font-face rules pointing at https://adom.inc/fonts/,
     which serves the .woff2 files with no Access-Control-Allow-Origin header.
     A font fetched by @font-face is always a CORS request, so every one of them
     was blocked and the app has been rendering in the fallback system stack the
     whole time. Both hosts below send ACAO on the font files themselves. The
     fallback stacks in the rules further down are unchanged and still carry the
     page if these hosts are unreachable. -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@500;600;700&amp;family=JetBrains+Mono:wght@400&amp;display=swap">
<style>
/* Satoshi is not on Google Fonts. Fontshare's CSS endpoint at api.fontshare.com
   loads but hands the browser no @font-face rules, so it is skipped and the two
   faces we use are declared here against cdn.fontshare.com, which does send
   Access-Control-Allow-Origin: *. The URLs are content-addressed and immutable;
   if the CDN ever goes away the fallback stack below carries the page, which is
   the same thing that happens today. */
@font-face { font-family: 'Satoshi'; font-weight: 400; font-style: normal; font-display: swap;
  src: url('https://cdn.fontshare.com/wf/TTX2Z3BF3P6Y5BQT3IV2VNOK6FL22KUT/7QYRJOI3JIMYHGY6CH7SOIFRQLZOLNJ6/KFIAZD4RUMEZIYV6FQ3T3GP5PDBDB6JY.woff2') format('woff2'); }
@font-face { font-family: 'Satoshi'; font-weight: 500; font-style: normal; font-display: swap;
  src: url('https://cdn.fontshare.com/wf/P2LQKHE6KA6ZP4AAGN72KDWMHH6ZH3TA/ZC32TK2P7FPS5GFTL46EU6KQJA24ZYDB/7AHDUZ4A7LFLVFUIFSARGIWCRQJHISQP.woff2') format('woff2'); }

/* Adom is a dark system: dark is the base, light is the opt-in override.
   Tokens and contrast ratios come from the adom-ui-design skill.
   --text3 is 2.3:1 and is NEVER text; it is for borders and disabled chrome. */
:root {
  --bg: #0d1117; --surface: #161b22; --elevated: #1c2128; --overlay: #21262d;
  --border: #30363d; --border-muted: #21262d;
  --text: #e6edf3; --text-body: #c9d1d9; --text2: #8b949e; --text3: #484f58;
  --accent: #00b8b1; --accent-hover: #00d4cb; --accent-bright: #00e6dc;
  /* Today is a reference line, not something you act on. Teal means interactive or
     important in this brand, and the phase ramp already colours the milestone rules,
     so today reads as neutral grey and stops competing with both. */
  --today: var(--text2);
  --pdate: #e8b64a;   /* the project's dates (0.75.0): dashed lines, hatched outside */
  --ok: #3fb950; --warn: #d29922;
  /* ALARM, and it means one thing only: this tab is not saving. It is deliberately not
     --warn, which is a status a row can hold, and not the delete red, which is about a
     thing you are pointing at. It is the colour of the one banner in the app that stays
     up until somebody deals with it. The edge and the halo are the same paired shape
     --sig and --crit use, so the banner can be ringed without hard-coding an rgba the
     light theme would have to override in three places. */
  --alarm: #f85149; --alarm-edge: rgba(248,81,73,0.45); --alarm-halo: rgba(248,81,73,0.16);
  /* The dependency-mode signal, for ONE thing: a picked dependency ARROW. In 0.39.0 it
     also ringed the armed first task of a pair; 0.40.0 took that back, because an armed
     bar is a selected bar and the app already has one way to say a bar is selected. So
     the amber now means "this arrow", and the red next to it means "this arrow is about
     to go", and neither is ever a status, a phase or an accent.

     It is two bands rather than one flat colour, and that is arithmetic rather than
     taste. An arrow route crosses lane fills AND open page on its way across the chart:
     the three lane fills sit mid-luminance (teal 0.37, purple 0.23, blue 0.39 relative)
     with a near-black page behind them, so NO single colour can clear 3:1 against both.
     To beat the lightest fill it would have to be lighter than 0.93, which is white, and
     to beat the page it would have to be far darker than that. So the stroke and the
     halo drawn under it are chosen separately, each against what it actually abuts:
     --sig is the stroke, which the page is measured against, and --sig-edge is the halo,
     which the fills are measured against. */
  --sig: #ffb02e; --sig-edge: #05080b;
  /* The critical-path signal. Drew asked for WHITE, and on this page white is the one
     colour nothing else uses: the accent is teal, the dep signal is amber, danger is
     red and the phase ramp owns teal, purple and blue. So a white halo cannot be read
     as any of them.
     On paper white is the page, so it would be invisible, and the same reasoning that
     produced --sig / --sig-edge applies: the light theme keeps the SHAPE of the signal
     (a breathing halo around the same elements) and swaps the band, to a saturated
     indigo that clears 4.5:1 against the page and is not in the phase ramp. */
  --crit: #ffffff; --crit-glow: rgba(255,255,255,0.85); --crit-halo: rgba(255,255,255,0.35);
  /* Dependency mode, said on the button that arms it. Same two-band shape as the
     critical path (a tight ring plus a soft bloom, breathing), in the ACCENT rather
     than in white, because the mode is one of this app's own controls being held down
     and not a reading of the plan. Softer than the path on purpose: the path is the
     answer to a question, the mode is a state you are working in and has to sit next
     to a header full of other buttons for as long as you are in it. */
  --dep-glow: rgba(0,184,177,0.55); --dep-halo: rgba(0,184,177,0.22);
  --on-accent: #05221f;
  --grid-line: rgba(48,54,61,0.18);
  --weekend: rgba(255,255,255,0.022);
  /* A federal holiday is a non-working day like the weekend, so it gets the same
     kind of band, warmed and lifted enough that the two read as different bands
     at a glance rather than as one shade of grey. */
  --holiday: rgba(255,176,102,0.085);
  --row-hover: rgba(255,255,255,0.04);
  /* How hard the dependency-mode row wash tints. The COLOUR is the row's own phase,
     set on the element; only the strength is a theme token, and it has to be one per
     theme because the two grounds leave the bar different amounts of room. On this
     dark page a bar is drawn at 0.82 over near-black, so a tint this side of the
     phase costs it very little and 0.14 is what makes the band unmistakable next to
     the 0.04 neutral the same row already uses for an ordinary hover. */
  --dep-hl-a: 0.14;
  /* Phase ramp: teal -> purple -> blue, then a lighter step of each.
     Used when a phase declares no explicit colour. */
  --ph1: #00b8b1; --ph2: #8c6bf7; --ph3: #64abff;
  --bar-opacity: 0.82;
  color-scheme: dark;
}
:root[data-theme="light"] {
  --bg: #ffffff; --surface: #f6f8fa; --elevated: #ffffff; --overlay: #ffffff;
  --border: #d0d7de; --border-muted: #d8dee4;
  --text: #1f2328; --text-body: #24292f; --text2: #59636e; --text3: #8c959f;
  --accent: #00857f; --accent-hover: #00a49d; --accent-bright: #00a49d; --today: var(--text2); --pdate: #b7791f;
  --ok: #1a7f37; --warn: #9a6700;
  /* The alarm on paper. #f85149 is a screen red and washes out on white, so the banner
     takes the deeper one the analytics lens already uses for a late row, which clears
     4.5:1 against the page. */
  --alarm: #b91c1c; --alarm-edge: rgba(185,28,28,0.40); --alarm-halo: rgba(185,28,28,0.12);
  /* Same signal, both bands flipped for paper: the halo is the page white, which clears
     3:1 against all three light fills, and the stroke is the deep amber, which clears
     5:1 against the page. */
  --sig: #b45309; --sig-edge: #ffffff;
  --crit: #4c1d95; --crit-glow: rgba(76,29,149,0.95); --crit-halo: rgba(76,29,149,0.40);
  /* The same bloom on paper, in the light theme's deeper accent. A glow on white has
     less room than one on near-black, so both bands come down rather than up: at the
     dark theme's alphas the bloom would read as a smudge around the button. */
  --dep-glow: rgba(0,133,127,0.42); --dep-halo: rgba(0,133,127,0.18);
  --on-accent: #ffffff;
  --grid-line: rgba(31,35,40,0.10);
  --weekend: rgba(31,35,40,0.04);
  --holiday: rgba(199,105,10,0.105);
  --row-hover: rgba(31,35,40,0.04);
  /* Lighter on paper, and it has to be: a light-theme bar is opaque and sits on white,
     so it starts with about a third of the headroom the dark theme's does, and every
     step the row takes towards the phase is a step the bar loses. Measured, 0.10 keeps
     the ramp bars within a tenth of the contrast they have against the bare page. */
  --dep-hl-a: 0.10;
  /* Deepened for paper: each clears 3:1 on white while holding its hue. */
  --ph1: #00a49d; --ph2: #8c6bf7; --ph3: #3b95ff;
  --bar-opacity: 1;
  color-scheme: light;
}
/* Printing is always the light theme, whatever the screen is set to. */
@media print {
  :root { --bg:#ffffff; --surface:#ffffff; --elevated:#ffffff; --overlay:#ffffff;
    --border:#d0d7de; --border-muted:#d8dee4;
    --text:#1f2328; --text-body:#24292f; --text2:#59636e; --text3:#8c959f;
    --accent:#00857f; --today:var(--text2); --pdate:#b7791f; --grid-line:rgba(31,35,40,0.10); --weekend:rgba(31,35,40,0.04); --holiday:rgba(199,105,10,0.105);
    --alarm:#b91c1c; --alarm-edge:rgba(185,28,28,0.40); --alarm-halo:rgba(185,28,28,0.12);
    --ph1:#00a49d; --ph2:#8c6bf7; --ph3:#3b95ff;
    --sig:#b45309; --sig-edge:#ffffff;
    --crit:#4c1d95; --crit-glow:rgba(76,29,149,0.95); --crit-halo:rgba(76,29,149,0.40);
    --dep-glow:rgba(0,133,127,0.42); --dep-halo:rgba(0,133,127,0.18);
    color-scheme: light; }
  html, body { overflow: visible !important; height: auto !important; }
  .labels-col, .timeline-col { overflow: visible !important; }
  header .actions, .save-indicator, .conflict-bar { display: none !important; }
  .print-title { display: block !important; }
  /* A button on paper is a thing nobody can press. */
  .legend-item.collapse-all { display: none !important; }
}
.print-title { display: none; padding: 10px 16px 6px; font-family: 'Familjen Grotesk', sans-serif; color: var(--text); }
.print-title .pt-name { font-size: 16px; font-weight: 600; }
.print-title .pt-sub { font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text2); margin-top: 2px; }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { font: 13px/1.4 'Satoshi', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; background: var(--bg); color: var(--text); overflow: hidden; height: 100%; }

/* ── Header ── */
header { display: flex; align-items: center; gap: 12px; padding: 0 16px; height: 44px; border-bottom: 1px solid var(--border-muted); background: var(--surface); font-family: 'Familjen Grotesk', sans-serif; flex-shrink: 0; }
header .brand { display: flex; align-items: center; gap: 8px; color: var(--accent); font-size: 13px; font-weight: 600; cursor: default; flex-shrink: 0; }
header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; }
header .brand .app-name { color: var(--accent); }
/* Bounded on purpose. It is author text of any length, and unbounded it pushed
   everything after it across the header (and off a narrow one). 30ch since 0.57.0,
   up from 20: with the bold title gone from the header this is the one place the
   plan is named, and it falls back to the project title, which runs longer than an
   appName. The full text is on the brand's tooltip. */
header .brand .app-name { max-width: 30ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
header .brand .sep { color: var(--text3); font-weight: 400; }
header .vsep { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }
header .subject { display: flex; align-items: center; gap: 8px; flex: 1 0 auto; cursor: default; }
/* 0.57.0: the bold title (.subject-name) and the dimmed subtitle (.subject-sub) are
   gone from here; the brand block names the plan and its tooltip carries both. What
   the group holds now is the plan's clock, Project Duration and Time Remaining, and
   the version. It still takes the free width so the start control and the right-hand
   actions keep the positions they have always had, but it never SHRINKS (flex: 1 0
   auto, no overflow clip): when the row is short of room fitHeader() sheds or wraps
   something else instead. */
/* Two short lines stacked in the 44px header rather than one long one: side by side
   the pair needed about 300px, and at 1440px wide that is more than the header has
   left once its controls are in, so the second figure was cut off mid-word. Stacked
   it is half that. The labels share one width so the two figures line up. */
header .span-block { display: flex; flex-direction: column; justify-content: center; gap: 1px; flex-shrink: 0; line-height: 1.25; }
header .span-stat { display: flex; align-items: baseline; gap: 6px; white-space: nowrap; }
header .span-stat .span-k { font-family: 'Satoshi', sans-serif; font-size: 10.5px; color: var(--text2); min-width: 7.9em; }
header .span-stat .span-v { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--text); }
/* A plan that is over is still a fact worth stating, but not in the same voice as a
   countdown that is running. */
header .span-stat[data-state="ended"] .span-v,
header .span-stat[data-state="empty"] .span-v { color: var(--text3); }
header .span-stat .span-n { font-family: 'Satoshi', sans-serif; font-size: 10px; color: var(--text3); }
/* Work outside the project's dates (0.75.0): the readouts take the dashed lines' amber. */
header .span-stat[data-outside="1"] .span-v { color: var(--pdate); }
header .span-stat .span-n:empty { display: none; }
/* The version sits UNDER the app name (0.64.2), small and dim, on every lens at every
   width. It used to ride beside the readouts, where it was the first thing the measured
   header fit shed, so on the Gantt lens at most widths it was not there at all. Stacked
   under the name it costs no width, so it is never shed. */
header .brand .brand-txt { display: flex; flex-direction: column; line-height: 1.1; min-width: 0; }
header .brand .app-ver { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; font-weight: 400; color: var(--text3); white-space: nowrap; letter-spacing: 0.02em; }
header .start-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; font-family: 'Satoshi', sans-serif; }
header .grain-sel { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 6px; cursor: pointer; outline: none; }
header .grain-sel:hover, header .grain-sel:focus { border-color: var(--accent); color: var(--text); }
header .days-toggle { display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none; }
header .days-toggle input { appearance: none; width: 13px; height: 13px; border: 1px solid var(--border); border-radius: 3px; background: var(--bg); cursor: pointer; position: relative; flex-shrink: 0; }
header .days-toggle input:checked { background: var(--accent); border-color: var(--accent); }
header .days-toggle input:checked::after { content: ''; position: absolute; left: 3.5px; top: 0.5px; width: 4px; height: 8px; border: solid var(--on-accent); border-width: 0 2px 2px 0; transform: rotate(45deg); }
header .days-toggle span { color: var(--text2); font-size: 11px; }
header .days-toggle input:checked ~ span { color: var(--accent); }

/* Custom date picker: the native <input type=month> popup is browser chrome, so it
   cannot be themed or relabelled. This replaces it. */
header .actions { display: flex; align-items: center; gap: 4px; margin-left: auto; flex-shrink: 0; }
header .act-btn { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: background 0.15s, color 0.15s; display: flex; align-items: center; gap: 4px; }
header .act-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); }
header .act-btn svg { width: 14px; height: 14px; }
/* ── Dependency mode is ON ──
   Until 0.47.0 the mode announced itself with a sentence in a bar under the legend and
   the button that armed it did not change at all: the .on class was set on it and no
   rule read it. The bar is gone (its sentence is the button's tooltip now, where the
   question "what does this do" is actually asked), and the button is where the state
   lives, which is also where the click that leaves the mode is.
   Same two-band breath the critical path uses, on the same 2.6s, so the two live
   states in this app move alike; the accent rather than the path's white, and softer,
   because this one sits in the header for as long as the user is adding links. It is
   NOT the .on treatment .plan-ctl gives Critical (a filled swap to --crit): a glow can
   sit on top of the button's own hover without either one hiding the other. */
header .act-btn.on#btnDeps { color: var(--accent); border-color: var(--accent); background: var(--elevated); animation: depOn 2.6s ease-in-out infinite; }
@keyframes depOn {
  0%, 100% { box-shadow: 0 0 0 1px var(--dep-halo), 0 0 5px 0 var(--dep-halo); }
  50%      { box-shadow: 0 0 0 1px var(--dep-glow), 0 0 12px 2px var(--dep-glow); }
}
/* Held at the bright end of the same breath, the way every other live state here is. */
@media (prefers-reduced-motion: reduce) {
  header .act-btn.on#btnDeps { animation: none; box-shadow: 0 0 0 1px var(--dep-glow), 0 0 10px 1px var(--dep-glow); }
}
/* ── A button that is RUNNING something: .btn-busy ──
   Generic on purpose. Auto-schedule is the only button wearing it in 0.48.0, but the
   class and its two JS calls (startBusy / endBusy) know nothing about scheduling, so
   the next long-running button in this app adopts the same feedback by adding a class.

   It is an OUTLINE, not a fill. The button's face, its label, its icon and its own
   hover are all untouched; what moves is a ring drawn OUTSIDE the border, in the Adom
   teal -> blue -> purple, panning out and back. A fill would have said "this button is
   now a different button"; a ring says "this button is busy" and leaves the thing you
   pressed looking like the thing you pressed.

   The ring is an absolutely positioned span inset -2px on a relatively positioned
   button, so it is out of flow and costs the header no space: nothing beside it moves
   when the ring appears and nothing moves back when it goes. The RING shape (rather
   than a filled rounded rectangle behind the button) is the mask-composite trick:
   paint the gradient over the whole box, subtract the content box, and the 2px padding
   band is what is left. border-image cannot be animated per-stop and box-shadow cannot
   carry a gradient at all, which is why neither is used here.

   inset is measured from the button's PADDING box, so -2px lands the band across the
   button's own 1px border and 1px beyond it. That is deliberate: the button's outline
   lights up, rather than a second ring appearing outside an outline that is still
   there. Measured at 1px of overhang on every side, which is also why the ring can
   never overlap a neighbour: the header's gap is 6px.

   The colours are the phase ramp, --ph1/--ph3/--ph2, which IS the Adom brand ramp and
   is already per-theme: teal, blue, purple, deepened on paper. No new tokens, and both
   themes are covered by the ones that already switch. The strip is twice the ring's
   width and reads teal -> blue -> purple -> blue -> teal, so it is a palindrome and the
   pan turns round without a seam.

   WHY A TRANSFORM AND NOT background-position. This is the whole reason the ring is a
   span with a child rather than one pseudo-element. The work these buttons kick off is
   synchronous, and on a 160-row plan the auto-schedule pass is a single 2.4 SECOND task
   on the main thread. Anything animated on the main thread, background-position
   included, is frozen for every millisecond of that, which is precisely the moment the
   user most needs to be told the app is working and not dead. A transform on an
   otherwise static layer is run by the COMPOSITOR, which is a different thread and
   keeps painting frames while script blocks. So the ring is a clipped window (the span,
   which carries the mask and never moves) over a strip (the child, which only ever
   translates), and it keeps panning right through the pass.

   1.6s round trip, ease-in-out, and the JS floor is the same 1.6s, so the shortest run
   anyone can see is exactly one whole pan out and back, ending where it started. */
header .act-btn.btn-busy { position: relative; }
header .act-btn.btn-busy > .busy-ring {
  position: absolute; inset: -2px; border-radius: 8px; padding: 2px;
  pointer-events: none;
  -webkit-mask: linear-gradient(#000 0 0) content-box no-repeat, linear-gradient(#000 0 0) no-repeat;
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box no-repeat, linear-gradient(#000 0 0) no-repeat;
  mask-composite: exclude;
}
header .act-btn.btn-busy > .busy-ring::before {
  content: ''; position: absolute; top: 0; bottom: 0; left: -50%; width: 200%;
  background: linear-gradient(90deg, var(--ph1), var(--ph3), var(--ph2), var(--ph3), var(--ph1));
  animation: busyPan 1.6s ease-in-out infinite;
  will-change: transform;
}
/* +25% of a strip twice the ring's width puts the strip's first half in the window,
   which is the brand ramp read forwards: teal, blue, purple. -25% puts the second half
   there, which is the same ramp read back. So the pan is the gradient sweeping across
   the outline and returning, and every frame of it holds all three colours. */
@keyframes busyPan {
  0%, 100% { transform: translateX(25%); }
  50%      { transform: translateX(-25%); }
}
/* A busy button is disabled against a second press, but it must not also be FADED:
   .plan-ctl dims a disabled control to 0.4, and opacity takes the ring down with the
   button. Busy and unavailable are different states and only one of them is greyed. */
header .plan-ctl .act-btn[disabled].btn-busy { opacity: 1; }
/* Reduced motion keeps the ring and drops the travel, for the same duration: held at
   the +25% end, which is the Adom ramp laid across the outline left to right, teal to
   purple. Still unmistakably "running", with nothing moving. */
@media (prefers-reduced-motion: reduce) {
  header .act-btn.btn-busy > .busy-ring::before { animation: none; transform: translateX(25%); }
}
/* Their labels change length ("Lines"/"Diamonds", "Light"/"Dark"). .actions is
   right-aligned, so a width change there drags everything left of it, including
   the Fit button. Pin them to their widest label. */
/* The single primary action in the chrome, and the only teal fill. Sits second
   from the right of the right-aligned group, behind a fixed-width control, so
   its position is as stable as the last element's. */
header .new-btn { display: flex; align-items: center; gap: 6px; background: var(--accent); color: var(--on-accent); border: 1px solid var(--accent); border-radius: 6px; font-family: 'Satoshi', sans-serif; font-size: 11px; font-weight: 600; padding: 4px 12px; cursor: pointer; flex-shrink: 0; white-space: nowrap; transition: background 0.15s; }
header .new-btn:hover { background: var(--accent-bright); border-color: var(--accent-bright); }
header .new-btn svg { width: 13px; height: 13px; }
header #btnMsStyle { min-width: 96px; justify-content: center; }
/* Critical is NOT pinned. 0.49.0 pinned it to 158px for the reason the milestone button
   is pinned: it named its state in the label, the three names were different widths, and
   .actions to the right of it is right-aligned, so an unpinned button dragged its
   neighbours sideways on every press. 0.50.0 made it a two-state toggle whose label never
   changes, so there is nothing left to reserve room for and 158px was simply a hole in
   the header. It sizes off its own content like every other .act-btn now. */
header #btnTheme { min-width: 56px; justify-content: center; }
/* Below this the header is out of room and the project's own name starts to
   truncate, which is the one thing in here that must not. Shed chrome instead:
   the buttons keep their icons and their tooltips, the unit count goes (the axis
   already shows the span), and the "starts" label goes (its button says a date).
   With the labels gone the buttons are all one width, so nothing shifts. */
/* ── Fitting the header to its content (0.57.0) ──
   Until 0.56.0 this was a ladder of guessed pixel breakpoints (1620px: drop the button
   words), and the flexible title in the middle absorbed whatever the guesses got
   wrong by shrinking. With the title gone the flexible thing was the Duration block,
   and a review measured it clipped or hidden from 1181 to about 1390px and 1621 to
   about 1745px, worse with a long brand, and the right-hand controls off the edge just
   above the old 1180px wrap. Any fixed breakpoint goes stale the day a button is added.

   So fitHeader() MEASURES. It lays the row out with nothing shed, and if the content
   is wider than the header it sheds one step at a time and measures again, taking the
   first state that fits: the version (it is on the brand tooltip), then the button
   words (the old 1620px set; every button keeps its icon and tooltip), then the long
   labels ("Duration", "Remaining"), and last it wraps onto a second row, with the words
   back whenever they cost no extra row. The readouts never shrink (.subject is flex: 1 0 auto), so they are never the
   thing that gives. The classes below are the only switches; no rule here names a width. */
/* shed-ver no longer hides anything: the version moved under the app name (0.64.2). The
   step is kept so the fit's state names do not change under anything that reads them. */
header.shed-labels .who-name,
header.shed-labels .act-btn .btn-label,
header.shed-labels #msStyleLabel { display: none; }
header.shed-labels .who { max-width: none; }
/* Icon-only, but still pinned: the milestone glyph swaps between a bar and a diamond,
   which are not the same width, and .actions is right-aligned so that alone dragged
   the Fit button sideways. Theme keeps its word rather than becoming a blank button. */
header.shed-labels #btnMsStyle { min-width: 36px; }
header.shed-keys .span-stat .span-k .k-long { display: none; }
header.shed-keys .span-stat .span-k { min-width: 5.6em; }
/* Wrapped: the groups flow onto as many rows as they need, still right-aligned by
   .actions' margin-left: auto, and a group wider than the whole header (the actions on
   a phone) wraps inside itself. Nothing measures the header's height: the chart below
   is a flex child that takes what is left, and every popup is placed from its button. */
header.is-wrapped { flex-wrap: wrap; height: auto; min-height: 44px; padding: 6px 16px; row-gap: 6px; }
header.is-wrapped .vsep { display: none; }
header.is-wrapped .actions { flex-wrap: wrap; justify-content: flex-end; row-gap: 6px; flex-shrink: 1; min-width: 0; }
/* A phone: a little less gutter, and the Duration block sits at its own width rather
   than stretching across a row of its own. */
@media (max-width: 700px) {
  header.is-wrapped { padding: 6px 12px; column-gap: 8px; }
  header.is-wrapped .subject { flex: 0 0 auto; }
}

/* ── Legend ── */
/* It WRAPS, since 0.57.0. Until then it was one line always (nowrap, overflow hidden),
   which was fine for a three-lane plan and silently cut a twenty-lane one off at the
   pane edge: the chips past the edge could not be seen or clicked, so those lanes
   could not be filtered at all. Now it takes as many lines as the chips need.
   Nothing below it measured its height: the chart and the table are flex children
   that take whatever is left, their sticky axis and headers stick inside their own
   scroll boxes, and the timeline's ResizeObserver re-runs the layout when a resize,
   a text-size step or a lens switch changes how many lines it wraps to.
   row-gap is smaller than the chip padding so a wrapped legend reads as one block
   rather than as a stack of toolbars. The table's filters still join the same line,
   right-aligned by their margin-left: auto, so they sit at the end of the last row. */
.legend { display: flex; gap: 2px 4px; padding: 6px 16px; border-bottom: 1px solid var(--border-muted); background: var(--surface); align-items: center; flex-wrap: wrap; flex-shrink: 0; min-width: 0; }
/* Collapse all leads the line: it acts on the same sections the chips name. A button,
   outlined, so it does not read as one more toggleable chip. Its chevron points the
   way the lane chevrons do, down while any lane is open, right once all are shut. */
.legend-item.collapse-all { background: none; border: 1px solid var(--border); color: var(--text2); padding: 2px 9px 2px 6px; margin-right: 6px; gap: 4px; font-family: 'Satoshi', sans-serif; }
.legend-item.collapse-all:hover { color: var(--text); border-color: var(--accent); background: var(--row-hover); }
.legend-item.collapse-all svg { width: calc(12px * var(--fs)); height: calc(12px * var(--fs)); flex-shrink: 0; transition: transform 0.15s; }
.legend-item.collapse-all.is-all svg { transform: rotate(-90deg); }
.legend-item.collapse-all[disabled] { opacity: 0.4; cursor: default; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text2); font-family: 'Satoshi', sans-serif; padding: 3px 10px; border-radius: 6px; cursor: pointer; transition: opacity 0.15s, background 0.15s; user-select: none; }
.legend-item:hover { background: var(--row-hover); }
.legend-item.off { opacity: 0.3; }
.legend-item.static { cursor: default; }
.legend-item.static:hover { background: none; }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
.legend-diamond { width: 7px; height: 7px; transform: rotate(45deg); background: var(--text); border: 1px solid var(--text2); flex-shrink: 0; }
.legend-line { width: 12px; height: 2px; background: var(--today); border-radius: 1px; flex-shrink: 0; }

/* ── Chart ── */
.chart-wrap { display: flex; flex: 1; overflow: hidden; min-height: 0; }
/* position:relative matters more than it looks. The drop indicator is absolutely
   positioned with left:0;right:0, and without a positioned ancestor here it was
   laid out against a container further up: it spanned far past the label column
   (a stray teal line across the app) and its top was measured in a different
   coordinate space than the cursor maths, which is what made it lag the pointer.
   Now the rows and the indicator share this element as their offset parent. */
.labels-col { position: relative; width: var(--label-w, clamp(150px, 24vw, 272px)); flex-shrink: 0; border-right: 1px solid var(--border); overflow: hidden; background: var(--bg); }
/* Drag handle sitting on the divider. Wider than the 1px border so it is grabbable,
   and it keeps its own cursor so the row underneath still behaves normally. */
.col-resizer { width: 7px; margin-left: -4px; margin-right: -3px; flex-shrink: 0; cursor: col-resize; position: relative; z-index: 30; }
.col-resizer::after { content: ''; position: absolute; inset: 0 3px; background: transparent; transition: background 0.15s; }
.col-resizer:hover::after, .col-resizer.dragging::after { background: var(--accent); }
body.col-resizing { cursor: col-resize; user-select: none; }
/* overscroll-behavior contains the rubber-band at the ends: the bounce is a paint
   transform the scrollTop mirror cannot see, so without this a trackpad overscroll
   past the bottom springs the bars while the label column (overflow:hidden) holds
   still, and the rows visibly drift apart. Contained, the timeline stops dead at its
   limit and stays locked to the names. The JS clamp in syncLabelScroll handles the
   scrollTop-driven half of the same invariant. */
.timeline-col { flex: 1; overflow: auto; scrollbar-width: none; overscroll-behavior: contain; }
.timeline-col::-webkit-scrollbar { height: 0; width: 0; }
/* Scroll anchoring off in both panes (0.74.1). The browser anchors each pane by itself,
   so a filter that hid rows above the view moved the names and not the bars. keepPlace()
   holds the reader's row instead, in both panes at once. */
.labels-col, .timeline-col { overflow-anchor: none; }
.tailroom { flex-shrink: 0; pointer-events: none; }
@media print { .tailroom { display: none; } }
.q-row, .m-row { display: flex; }
.q-row { height: 22px; } .m-row { height: 24px; }
.q-cell { display: flex; align-items: center; justify-content: center; height: 100%; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); border-bottom: 1px solid var(--border-muted); border-right: 1px solid var(--border-muted); }
.m-cell.weekend { color: var(--text3); }
/* The header cell is the one part of a non-working column a pointer can actually
   reach: the bands themselves sit in the grid layer under the bars and are
   pointer-events:none, so the title that names the holiday lives here. */
.m-cell.holiday { color: var(--text2); background: var(--holiday); cursor: help; }
.m-cell { display: flex; align-items: center; justify-content: center; height: 100%; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); border-bottom: 1px solid var(--border); border-right: 1px solid var(--border-muted); }
.label-h-q { height: 22px; border-bottom: 1px solid var(--border-muted); }
.label-h-m { height: 24px; border-bottom: 1px solid var(--border); }

/* ── Rows ── */
.label-row { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; height: 34px; display: flex; align-items: center; padding: 0 12px; border-bottom: 1px solid rgba(48,54,61,0.15); font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; transition: opacity 0.15s; }
/* No background fade (0.75.1): every mark another person's selection put on a row, and
   took off it, ran it for every frame of 150 ms, a full restyle of a 30,000-node page each
   frame, and two thirds of what a watching tab paid for presence. Hover is instant now. */
.label-row:hover { background: var(--row-hover); }
.label-row.group { font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; color: var(--text2); background: var(--surface); height: 28px; letter-spacing: 0.02em; }
.label-row.ms { color: var(--text2); font-size: 11px; font-style: italic; }
.label-row.dim { opacity: 0.15; }
.label-row.multi-sel { background: rgba(0,184,176,0.06); }
.tag { display: inline-block; font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 1px 5px; border-radius: 4px; margin-left: 6px; vertical-align: middle; }
.label-row .status-pill { cursor: pointer; }
.label-row .status-pill:hover { box-shadow: inset 0 0 0 1px currentColor; filter: brightness(1.12); }
/* The owner pill sits immediately right of the status pill and is the same kind of
   object: a mono chip that is also the control that edits the thing it shows. It
   borrows the .tag box so the two chips line up exactly rather than agreeing by
   coincidence. An unowned row still gets a chip, quieter and italic, because a row
   with nobody on it is a thing worth seeing and worth being able to click. */
.label-row .owner-pill { cursor: pointer; background: rgba(139,148,158,0.10); color: var(--text2); max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.label-row .owner-pill:hover { box-shadow: inset 0 0 0 1px currentColor; filter: brightness(1.12); }
/* "NO OWNER" (0.62.0): dim capitals where the name would be, since a row nobody owns is
   the state most worth seeing, and the chip is the place to click to fix it. */
.label-row .owner-pill.none { font-style: normal; opacity: 0.55; letter-spacing: 0.04em; font-size: 0.86em; }
/* ── Requests (0.80.0): an owner change onto somebody else is a request they answer ──
   The pending pill sits right after the owner pill and is the same mono chip, dashed, so it
   reads as "not yet": "-> Ana?". The person asked sees theirs in the accent, as something to
   answer. The inbox is a tray button by the signed-in chip, with a count. */
.tag.req-pill { cursor: pointer; background: transparent; color: var(--text2); border: 1px dashed var(--text2); padding: 0 5px; max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag.req-pill.mine { color: var(--accent); border-color: var(--accent); }
.tag.req-pill:hover { filter: brightness(1.15); }
#tableView td.c-owner .req-pill { margin-left: 6px; }
.req-btn { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 28px; padding: 0; border-radius: 7px; border: 1px solid transparent; background: none; color: var(--text2); cursor: pointer; flex-shrink: 0; }
.req-btn:hover { border-color: var(--border); color: var(--text); }
.req-btn svg { width: 17px; height: 17px; }
.req-btn .req-n { position: absolute; top: -3px; right: -4px; min-width: 15px; height: 15px; padding: 0 4px; box-sizing: border-box; border-radius: 999px; background: var(--accent); color: var(--bg); font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; line-height: 15px; text-align: center; }
.req-btn .req-n:empty { display: none; }
.req-panel { position: fixed; z-index: 99998; width: min(380px, calc(100vw - 16px)); max-height: min(70vh, 560px); overflow: auto; background: var(--elevated); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.45); display: none; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text-body); }
.req-panel.show { display: block; }
.req-panel h3 { margin: 0; padding: 10px 14px 6px; font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; letter-spacing: 0.02em; color: var(--text2); text-transform: uppercase; }
.req-panel .rq-head { display: flex; align-items: baseline; justify-content: space-between; padding: 12px 14px 4px; border-bottom: 1px solid var(--border-muted); }
.req-panel .rq-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 15px; font-weight: 600; color: var(--text); }
.req-panel .rq-off { padding: 10px 14px; color: var(--text2); }
.req-panel .rq-item { padding: 8px 14px 10px; border-top: 1px solid var(--border-muted); }
.req-panel .rq-item:first-of-type { border-top: 0; }
.req-panel .rq-row { color: var(--text); font-weight: 600; cursor: pointer; text-decoration: none; }
.req-panel .rq-row:hover { color: var(--accent); }
.req-panel .rq-meta { color: var(--text2); margin-top: 2px; }
.req-panel .rq-note { margin-top: 4px; color: var(--text-body); font-style: italic; }
.req-panel .rq-state { display: inline-block; font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 1px 5px; border-radius: 4px; margin-left: 6px; border: 1px solid currentColor; }
.req-panel .rq-state.pending { color: var(--text2); border-style: dashed; }
.req-panel .rq-state.accepted { color: var(--ok); }
.req-panel .rq-state.declined { color: var(--alarm); }
.req-panel .rq-state.withdrawn, .req-panel .rq-state.closed { color: var(--text2); }
.req-panel .rq-act { display: flex; gap: 6px; margin-top: 7px; align-items: center; }
.req-panel .rq-act input { flex: 1; min-width: 0; height: 26px; box-sizing: border-box; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface); color: var(--text); font: inherit; }
.req-panel .rq-act input:focus { outline: none; border-color: var(--accent); }
.req-panel .rq-b { height: 26px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--overlay); color: var(--text); font: inherit; font-weight: 600; cursor: pointer; white-space: nowrap; }
.req-panel .rq-b:hover { border-color: var(--text2); }
.req-panel .rq-b.yes { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.req-panel .rq-b.yes:hover { filter: brightness(1.1); }
.req-panel .rq-none { padding: 4px 14px 10px; color: var(--text2); }
.im-req { margin-top: 6px; padding: 6px 8px; border: 1px dashed var(--border); border-radius: 6px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11.5px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.im-req.mine { border-color: var(--accent); color: var(--text-body); }
.im-req .rq-b { height: 22px; padding: 0 8px; border-radius: 5px; border: 1px solid var(--border); background: var(--overlay); color: var(--text); font: inherit; font-weight: 600; cursor: pointer; }
.im-req .rq-b.yes { background: var(--accent); border-color: var(--accent); color: var(--bg); }
/* Names not linked to an Adom account (0.80.0): a dotted underline, quiet, so they get linked. */
.label-row .owner-pill.unlinked, #tableView td.c-owner.unlinked { text-decoration: underline dotted; text-underline-offset: 2px; }
.req-panel .rq-ai { display: block; padding: 10px 14px 12px; border-top: 1px solid var(--border-muted); color: var(--text-body); cursor: pointer; }
.req-panel .rq-ai input { margin: 0 6px 0 0; vertical-align: -2px; }
.req-panel .rq-ai .rq-meta { display: block; margin: 2px 0 0 20px; }
.req-panel .rq-mv { margin-top: 3px; color: var(--text2); }
.req-panel .rq-mv .rq-row { font-weight: 500; }
.req-panel .rq-was { text-decoration: line-through; opacity: 0.8; }
.pp-list { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1.3fr); gap: 6px 12px; align-items: center; margin-top: 8px; }
.pp-list .pp-h { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.04em; }
.pp-list .pp-nm { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pp-list .pp-n { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); text-align: right; }
.pp-list select { min-width: 0; width: 100%; }
.pp-list .pp-why { grid-column: 1 / -1; margin: -3px 0 3px; font-size: 11px; color: var(--text2); }
.pp-list .pp-why.warn { color: var(--warn); }
/* The name text is its own hit target now: only the glyphs open the editor, and the
   empty space after them selects the row. The margin cancels the padding so the box
   hugs the text without nudging the row sideways; the hover outline is the affordance
   that says "this is editable", subtle and grey, gone the moment the pointer leaves. */
.label-row:not(.group) .name-text { cursor: text; border-radius: 3px; padding: 1px 3px; margin: 0 -3px; }
.label-row:not(.group) .name-text:hover { box-shadow: inset 0 0 0 1px var(--border); }
.tag-not-started { background: rgba(139,148,158,0.10); color: var(--text2); }
.tag-in-progress { background: rgba(0,184,177,0.14); color: var(--accent); }
.tag-blocked { background: rgba(210,153,34,0.15); color: var(--warn); }
.tag-complete { background: rgba(63,185,80,0.14); color: var(--ok); }
/* Cancelled sits in the muted family beside not-started, because both are "no work is
   happening here" and neither should pull the eye the way blocked and complete do. It
   is told apart from not-started by the line through it rather than by a colour of its
   own: a fifth hue in the tag row would be one more thing to learn, and a strikethrough
   already means "this is off" to everybody. Same two tokens in both themes, so the pair
   stays a pair on paper. */
.tag-cancelled { background: rgba(139,148,158,0.10); color: var(--text3); text-decoration: line-through; text-decoration-thickness: 1px; }
/* A status that came in from a CSV and is none of the five renders as itself,
   quietly. Better than pretending it is one of ours or dropping it. */
.tag-other { background: rgba(139,148,158,0.10); color: var(--text2); font-style: italic; }

.bar-row { height: 34px; position: relative; border-bottom: 1px solid rgba(48,54,61,0.15); transition: opacity 0.15s; }
.bar-row.group { height: 28px; background: var(--surface); }
.bar-row.dim { opacity: 0.15; }
/* CANCELLED, on the chart. The filter dim is 0.15 because a filtered-out row is
   context you are looking past; a cancelled row is part of the plan you are reading,
   so it is quieted and not nearly erased. The two are deliberately different numbers
   and never the same one.
   The bar's opacity is a FRACTION of the theme's own bar opacity rather than a fixed
   number, which is what keeps the two themes at the same relative step: dark bars start
   at 0.82 and land at 0.34, light bars are opaque on white and land at 0.42, and a light
   bar needs that extra because it has white underneath it instead of near-black.
   The LABEL row stays at 0.62, well above the bar, because the promise is that the name
   is still readable: quieting a row must never cost you the ability to find it. */
.bar.cancelled { opacity: calc(var(--bar-opacity, 0.82) * 0.42); }
.ms-diamond.cancelled, .ms-line.cancelled { opacity: 0.45; }
.label-row.cancelled { opacity: 0.62; }
.data-table tr.cancelled { opacity: 0.62; }
.bar { position: absolute; top: 7px; height: 20px; border-radius: 6px; opacity: var(--bar-opacity, 0.82); cursor: pointer; transition: opacity 0.15s; }
.bar:hover { opacity: 1; }
/* A selected bar, and the armed FIRST task of a dependency pair, are the SAME outline,
   deliberately, in one rule so they cannot drift apart. 0.39.0 gave the armed source
   its own two-band amber ring and 0.40.0 took it back: arming a task in dependency mode
   IS selecting it, the app already has one way to say a bar is selected, and a second
   vocabulary for the same idea costs more than the extra contrast it buys. A bar that
   is both selected and armed therefore has nothing to reconcile.

   Opacity goes back to 1 because the outline is drawn on the element, so an outline
   painted through 0.82 would arrive at less contrast than it was chosen for. */
.bar.selected, .bar.dep-pending { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; }
.bar-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; color: var(--on-accent); pointer-events: none; overflow: hidden; white-space: nowrap; padding: 0 6px; }
:root[data-theme="dark"] .bar-note { color: rgba(6,20,24,0.82); }
.bar-note-edit { position: absolute; inset: 0; width: 100%; height: 100%; background: var(--bg); border: 1px solid var(--accent); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-align: center; outline: none; z-index: 20; }
.bar-striped { background-image: repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(0,0,0,0.12) 4px, rgba(0,0,0,0.12) 8px); }
.bar .drag-handle { position: absolute; top: 0; width: 8px; height: 100%; cursor: ew-resize; z-index: 2; }
.bar .drag-handle.left { left: -2px; border-radius: 6px 0 0 6px; }
.bar .drag-handle.right { right: -2px; border-radius: 0 6px 6px 0; }
.bar .drag-handle:hover { background: var(--row-hover); }
.ms-diamond { position: absolute; top: 11px; width: 10px; height: 10px; transform: rotate(45deg) translateX(-50%); background: var(--text); border: 2px solid var(--bg); z-index: 5; pointer-events: none; transition: box-shadow 0.15s; }
/* Transparent hover strip: carries the tooltip in both milestone styles, so the
   full-height line never has to intercept pointer events over the whole chart. */
/* A halo round a 10px diamond, so a small target is still an easy one. It lives INSIDE
   the milestone's own bar row and spans only that row's height, so however wide it
   gets it can never reach a bar on the row above or below. */
.ms-hit { position: absolute; top: 0; bottom: 0; width: 22px; transform: translateX(-50%); z-index: 6; cursor: pointer; }
.ms-hit:hover ~ .ms-diamond, .ms-row:hover .ms-diamond { box-shadow: 0 0 10px rgba(230,237,243,0.3); }
/* Line style: a dashed vertical rule spanning the whole chart at the milestone date. */
/* The dashed rule is drawn by the pseudo-element, not by the container, and the
   container carries neither an opacity nor a z-index. Both of those mattered and
   both were the same one declaration: `opacity` on .ms-line made the tag's own
   background 75% transparent so neighbouring rules showed through the text, AND it
   created a stacking context that trapped the tag inside its own rule's layer, so a
   rule belonging to a milestone drawn later in the DOM painted straight over a tag
   drawn earlier. With the container transparent to stacking, the rules and the tags
   sit in one shared context and a z-index actually means something between them. */
.ms-line { position: absolute; top: 0; bottom: 0; width: 0; pointer-events: none; }
.ms-line::before { content: ''; position: absolute; top: 0; bottom: 0; left: 0; border-left: 1.5px dashed currentColor; opacity: 0.75; z-index: 4; }
.ms-line .ms-cap:hover { background: var(--elevated); filter: brightness(1.25); }
.ms-line .ms-cap { position: absolute; z-index: 7; opacity: 1; top: 2px; left: 0; transform: translateX(-50%); pointer-events: auto; cursor: pointer; font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1.25; color: inherit; background: var(--bg); padding: 2px 4px; border-radius: 3px; border: 1px solid currentColor; white-space: normal; overflow-wrap: anywhere; text-align: center; max-width: 132px; width: max-content; }
/* A milestone whose phase is filtered out (phaseVis) or hidden by the global
   Milestone toggle washes out like a filtered task bar (.dim, opacity 0.15). The
   opacity goes on the cap and the dashed rule, never on .ms-line itself, for the
   stacking reason spelled out above. A COLLAPSED phase hides its milestone outright
   via .row-collapsed; this is only the dim state. */
/* pointer-events go with the opacity. A cap washed out to 0.15 is not a cap you can
   read, and it floats over OTHER rows' bars rather than sitting in its own, so leaving
   it clickable would mean a ghost taking a click meant for the bar showing through it.
   A dim BAR keeps its clicks, deliberately: it is still in its own row and nothing is
   underneath it to take them from. */
.ms-line.ms-dim .ms-cap { opacity: 0.15; pointer-events: none; }
.ms-line.ms-dim::before { opacity: 0.15; }
/* Each style hides the half it does not draw. "both" names neither, so it hides
   nothing and the dashed rule, its cap and the diamond are all on screen together.
   The diamond keeps its own colour in every style: the phase is carried by the line
   and the cap outline (currentColor on .ms-line), and a phase-coloured diamond on top
   of a phase-coloured rule would say the same thing twice and lose the marker. */
:root[data-ms="diamond"] .ms-line { display: none; }
:root[data-ms="line"] .ms-diamond { display: none; }
/* In line style the dashed rule and its tag carry the milestone, so the grid row is
   redundant. Both the label and bar row go, together, or the columns fall out of step. */
:root[data-ms="line"] .label-row.ms, :root[data-ms="line"] .bar-row.ms-row { display: none; }
:root[data-ms="line"] .ms-hit { display: none; }
.label-row.ms::before { content: '\25C6\00a0'; }
:root[data-ms="line"] .label-row.ms::before { content: '\00a6\00a0'; }
:root[data-ms="both"] .label-row.ms::before { content: '\00a6\25C6\00a0'; }
.today-line { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--today); z-index: 5; pointer-events: none; }
/* The line itself stays pointer-events:none. A full-height hit strip would sit
   over every bar it crosses and eat their clicks, which is a worse trade than a
   tooltip. The tag is the marker's affordance, so it carries the tooltip, with a
   little more padding to make it a comfortable target. */
.today-tag { position: absolute; top: calc(var(--group-h) + 4px); transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--today); background: var(--bg); padding: 2px 6px; border-radius: 3px; border: 1px solid var(--border); pointer-events: auto; cursor: default; z-index: 9; white-space: nowrap; }
.today-tag:hover { border-color: var(--today); }
.pdate-line { position: absolute; top: 0; bottom: 0; width: 0; border-left: 2px dashed var(--pdate); transform: translateX(-1px); z-index: 5; pointer-events: none; }
.pdate-tag { position: absolute; top: calc(var(--group-h) + 4px); transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--pdate); background: var(--bg); padding: 2px 6px; border-radius: 3px; border: 1px dashed var(--pdate); pointer-events: auto; cursor: default; z-index: 9; white-space: nowrap; }
/* The time outside the project's dates, hatched over the bars, so a bar that runs past the
   end is hatched where it does. Under the arrows and the today line, never in the way. */
.pdate-out { position: absolute; top: 0; bottom: 0; z-index: 4; pointer-events: none; background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--pdate) 10%, transparent) 0 5px, transparent 5px 13px); }
.dep-svg { position: absolute; top: 0; left: 0; pointer-events: none; z-index: 4; overflow: visible; }
/* --dep-stroke is set per edge from the SOURCE row's phase, contrast-corrected against
   the page (see depStroke). The fallback is the accent, which is what every arrow used
   to be, so an edge whose colour could not be resolved looks exactly like 0.51.0 rather
   than looking broken. Read through a variable rather than written as an inline stroke
   on purpose: the three states below have to keep beating it. */
.dep-svg path { fill: none; stroke: var(--dep-stroke, var(--accent)); stroke-width: 1.5; opacity: 0.5; transition: opacity 0.15s; }
.dep-svg polygon { fill: var(--dep-stroke, var(--accent)); opacity: 0.5; transition: opacity 0.15s; }
.dep-svg.hidden path, .dep-svg.hidden polygon { opacity: 0; }
/* CANCELLED, on the arrows. 0.53.0 takes cancelled work out of the scheduling graph, so
   an arrow into or out of it is a link the schedule no longer walks. The link is still
   DRAWN, because deleting it from the picture would lose the reason the row is in the
   plan, and it is dimmed by the SAME 0.42 factor .bar.cancelled takes: 0.5 * 0.42 =
   0.21, so the arrow recedes exactly as far as the bar it points at and the two cannot
   drift apart. EITHER end being cancelled dims the edge, since one live end does not
   revive a link to work nobody will do.
   Placed here, above the crit, pick and armed rules, so all three still win: a cancelled
   row can no longer be on the critical path at all, but an arrow you have PICKED or
   ARMED is a thing you are doing and has to stay legible while you do it. */
.dep-svg path.cancelled, .dep-svg polygon.cancelled { opacity: 0.21; }
/* == The critical path ==
   The longest run of work in the plan, breathing. Everything here is ADDITIVE: the glow is
   carried on box-shadow and filter, which no other state in this app uses on these
   elements, so a bar can be critical AND selected AND armed at once and each of the
   three still says what it says. The two dependency-mode treatments below deliberately
   come AFTER these rules, at the same specificity, so a picked or armed arrow keeps its
   amber or its red: the mode is a thing you are doing and the path is a thing you are
   reading, and the one you are doing wins.
   Nothing here touches pointer-events, geometry or layout, so hit-testing is exactly
   what it was: a glowing bar is dragged, resized and picked on the same pixels. */
/* Held, not breathing (0.75.1). Through 0.75.0 every one of these pulsed forever: a
   drop-shadow FILTER on each arrow of an arrow layer as tall as the plan, stroke widths
   and box-shadows, re-rendered every frame. With the path on, an idle tab kept one CPU
   core at 100% for as long as it was open. The treatment is the bright end of that pulse,
   the one reduced-motion readers already had, so the path reads exactly as strongly. */
.dep-svg path.crit { stroke: var(--crit); opacity: 1; stroke-width: 2.2; filter: drop-shadow(0 0 3px var(--crit-glow)); }
.dep-svg polygon.crit { fill: var(--crit); opacity: 1; filter: drop-shadow(0 0 3px var(--crit-glow)); }
.bar.crit { box-shadow: 0 0 0 1.5px var(--crit), 0 0 10px 3px var(--crit-glow); }
.ms-diamond.crit-ms { box-shadow: 0 0 0 2px var(--crit), 0 0 10px 3px var(--crit-glow); }
/* The rule and its cap are one milestone in the other two styles, so both carry it. */
.ms-line.crit-ms::before { border-left-color: var(--crit); opacity: 1; filter: drop-shadow(0 0 3px var(--crit-glow)); }
.ms-line.crit-ms .ms-cap { border-color: var(--crit); color: var(--crit); box-shadow: 0 0 9px 2px var(--crit-glow); }
/* A milestone armed as the first end of a pair in dependency mode (0.78.0) wears the ring an
   armed bar wears, in the accent, on its diamond and on the cap of its rule. */
.ms-diamond.dep-pending { box-shadow: 0 0 0 2px var(--accent), 0 0 8px 2px var(--accent); }
.ms-line .ms-cap.dep-pending { outline: 2px solid var(--accent); outline-offset: 1px; }
/* The table's half of the same answer: the row that is on the path, marked on its left
   edge, where the focused-row marker already lives so the two read as one column of
   signals rather than two competing washes. */
.data-table tbody tr.crit-row td:first-child { box-shadow: inset 3px 0 0 var(--crit); }
/* A PICKED dependency, in dependency mode only. The whole layer stays
   pointer-events:none in every mode (see depEdgeAt: picking an arrow is a hit test
   against the drawn route, not a hit target laid over the plan), so nothing here can
   take a click from the bars underneath. Two bands, for the reason set out at the token:
   the stroke is --sig against the page, and the drop-shadow halo is --sig-edge for the
   stretches where a route crosses a bar. Since 0.40.0 this is the ONLY thing wearing the
   signal colour, so amber on the chart means "arrow" and nothing else, and the red below
   is the one step further that means "arrow about to go". */
.dep-svg path.sel { stroke: var(--sig); stroke-width: 3; opacity: 1;
  filter: drop-shadow(0 0 1.5px var(--sig-edge)) drop-shadow(0 0 1.5px var(--sig-edge)); }
.dep-svg polygon.sel { fill: var(--sig); opacity: 1; }
/* Armed for removal, in the red this app already uses for a delete about to happen. */
.dep-svg path.del-armed { stroke: #f85149; stroke-width: 3; opacity: 1; filter: none; }
.dep-svg polygon.del-armed { fill: #f85149; opacity: 1; }
/* The name cell fades its background over 150ms for the ordinary hover, and the chart
   band does not, so left alone the two halves of one row would light at different times
   and the join the wash below is FOR would be the thing the eye missed. In the mode the
   name cell drops the background half of that transition and keeps the opacity half,
   which a filtered row still needs. It also means a fast sweep leaves no comet trail of
   rows fading out behind the pointer: one row is lit, and only one. */
body.dep-mode .label-row { transition: opacity 0.15s; }
/* The row under the pointer, in dependency mode and nowhere else. The mode asks for a
   click on a particular BAR, and a 20px bar in a 34px row has its name a whole pane
   away, so the name cell and the chart band light TOGETHER and the eye can join the
   two without moving the pointer.

   The wash is the row's own phase, not a neutral: in this mode the phase is how the
   chart is read, and a grey band would say "some row" where this says "this row, in
   this lane". Only the triple is set on the element (by setDepHover, from the same
   phaseColor the bar is filled with, so an explicit lane colour and a ramp slot both
   arrive as numbers); the ALPHA stays a theme token, which is what lets a theme
   switch retint every row without JS and lets the two themes be tuned apart.

   It is a background, and the armed source's ring is an OUTLINE drawn on the bar, so
   the ring is above the wash by construction rather than by a z-index. */
body.dep-mode .label-row.dep-hover,
body.dep-mode .bar-row.dep-hover { background: rgb(var(--dep-hl-rgb) / var(--dep-hl-a)); }
/* No resize edges in the mode. The two handles are 8px each and overhang their bar by
   2px at either end, so on a one-day bar they ARE the bar: the ew-resize cursor and the
   hover wash sat exactly where the pick target is and hid the thing being aimed at, and
   at that width there was no middle left to aim at instead. They are an affordance for a
   gesture the mode has always refused anyway (wireBarDrags has returned on depMode since
   the mode existed), so in the mode they are a picture of something that cannot happen.

   display:none rather than a wash of opacity or a pointer-events override because it is
   the one declaration that takes them out of PAINT and out of HIT-TESTING together:
   elementFromPoint at either extreme edge of a bar returns the bar itself, there is no
   resize cursor to read, and the whole face is one uninterrupted dependency target. The
   2px of overhang stops being clickable with them, which is the pick target becoming
   exactly the bar and not slightly more than it.

   Nothing here is stateful and nothing measures a handle, so leaving the mode restores
   the hover behaviour exactly, with no JS to run and nothing to put back. */
body.dep-mode .bar .drag-handle { display: none; }

/* ── Tooltip ── */
.tip { display: none; position: fixed; z-index: 99999; background: rgba(22,27,34,0.92); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(0,184,177,0.2); border-radius: 10px; padding: 10px 14px; max-width: 340px; box-shadow: 0 12px 40px rgba(0,0,0,0.55); pointer-events: none; font-family: 'Satoshi', sans-serif; font-size: 12px; line-height: 1.5; color: var(--text); }
.tip.show { display: block; }
.tip .tip-title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 13px; color: var(--accent); margin-bottom: 4px; }
.tip .tip-dates { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); margin-bottom: 6px; }
.tip .tip-body { color: var(--text2); }
.tip .tip-prov { margin-top: 6px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); }

/* ── The edit panel: ONE layout for every box that edits a task (0.79.0) ──
   The item editor (a task or a milestone, opened from the chart or from the Table), the
   New panel and the batch panel all render into #editor, and they are all built by one
   function (edShell), so they read as one object and cannot drift apart again:

     the HEAD: the grab strip and the TITLE, and the title is a title: a step larger than
       anything else in the panel, in the heading face. It used to be 13px over 11px
       labels, which at a glance read as one more label;
     the BODY: a six-column grid of fields. A field is full width, a half or a third, its
       label on the first row of its cell and its control on the second (a subgrid), so
       two fields side by side start their controls on one line however their labels
       wrap. On a panel too narrow for two fields side by side (a phone at a large text
       size) every field takes the full width;
     the FOOT: the buttons. They never scroll away and nothing sits on them: when the
       panel is taller than the window the BODY scrolls inside it.

   Every size is a multiple of --fs, the app's own text size (the A 120% A control in the
   header), the way the chart and the Table are, so the panel grows with the app instead
   of staying at 100% beside it.

   WIDTH: 400px at 100%, and never wider than the window less 8px a side. It used to take
   the width of its widest content, and the "waits on" list's longest task name made it
   915px wide on a 1366px window at 120%. HEIGHT: never past the window, and never into
   the band along the bottom edge where the selection chip ("N selected, Copy IDs"), the
   toasts and the save indicator live (--ed-bottom), so none of them can cover a button,
   which the chip once did to Apply on a tall editor. */
.editor {
  --ed-pad: calc(14px * var(--fs));
  --ed-gap: calc(10px * var(--fs));
  --ed-row: calc(8px * var(--fs));
  --ed-ctl-h: calc(28px * var(--fs));
  --ed-bottom: 60px;
  position: fixed; z-index: 99998; display: none; flex-direction: column;
  width: min(calc(400px * var(--fs)), calc(100vw - 16px));
  max-height: calc(100vh - 8px - var(--ed-bottom));
  max-height: calc(100dvh - 8px - var(--ed-bottom));
  background: var(--elevated); border: 1px solid var(--border); border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.5);
  font-family: 'Satoshi', sans-serif; font-size: calc(12px * var(--fs)); color: var(--text);
}
.editor.show { display: flex; }
/* While the save-blocked banner is up the toasts step up out of its way (to 74px), so the
   band the panel keeps clear of steps up with them. */
:root[data-save="blocked"] .editor { --ed-bottom: 120px; }
.editor .drag-bar { flex: none; height: calc(14px * var(--fs)); cursor: grab; display: flex; align-items: center; justify-content: center; border-radius: 8px 8px 0 0; user-select: none; -webkit-user-select: none; }
.editor .drag-bar::after { content: ''; width: calc(32px * var(--fs)); height: 3px; border-radius: 2px; background: var(--border); }
/* The head is part of the grab strip: wireEditorDrag takes a press on either, so a panel is
   moved by its title the way a window is. */
.editor .ed-head { flex: none; padding: calc(2px * var(--fs)) var(--ed-pad) calc(8px * var(--fs)); cursor: grab; user-select: none; -webkit-user-select: none; }
.editor .drag-bar:active, .editor .ed-head:active { cursor: grabbing; }
.editor .ed-title { font-family: 'Familjen Grotesk', sans-serif; font-size: calc(16px * var(--fs)); line-height: 1.25; font-weight: 600; color: var(--accent); overflow-wrap: anywhere; }
.editor .ed-sub { margin-top: calc(3px * var(--fs)); font-size: calc(11px * var(--fs)); line-height: 1.4; color: var(--text2); }
.editor .ed-body { flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding: 0 var(--ed-pad); container-type: inline-size; }
.editor .ed-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); column-gap: var(--ed-gap); row-gap: 0; align-items: start; }
.editor .ed-f { grid-column: 1 / -1; grid-row: span 2; display: grid; grid-template-rows: subgrid; min-width: 0; margin-bottom: var(--ed-row); }
.editor .ed-f.half { grid-column: span 3; }
.editor .ed-f.third { grid-column: span 2; }
.editor .ed-f.twothirds { grid-column: span 4; }
/* A group that behaves differently from the fields above it (the dependency list; the
   batch panel's shifts, and its tags and effort, which act at once rather than on Apply). */
.editor .ed-f.sep { border-top: 1px solid var(--border-muted); padding-top: var(--ed-row); }
.editor .ed-f > label { align-self: end; }
.editor .ed-c { min-width: 0; }
.editor .ed-note { grid-column: 1 / -1; margin: calc(-3px * var(--fs)) 0 var(--ed-row); font-size: calc(10.5px * var(--fs)); line-height: 1.4; color: var(--text2); }
/* 24em of the body's own type is 288px at 100%: under it, two dates side by side would no
   longer fit their fields, so every field takes the whole row. */
@container (max-width: 24em) {
  .editor .ed-f.half, .editor .ed-f.third, .editor .ed-f.twothirds { grid-column: 1 / -1; }
}
/* While the panel is being dragged nothing on the page should be selecting text: the
   pointer is moving a window, not sweeping a paragraph. Scoped to the drag so the
   inputs inside the panel keep normal selection the rest of the time. */
body.ed-dragging { user-select: none; -webkit-user-select: none; }
.editor label { display: block; font-family: 'JetBrains Mono', monospace; font-size: calc(11px * var(--fs)); line-height: 1.35; color: var(--text2); margin-bottom: calc(3px * var(--fs)); }
.editor .de-hint { font-family: 'Satoshi', sans-serif; font-size: calc(10px * var(--fs)); color: var(--text2); }
/* Every control one height, so a row of them lines up, and the width of its cell. */
.editor input, .editor select, .editor textarea { display: block; width: 100%; min-width: 0; height: var(--ed-ctl-h); background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: calc(12px * var(--fs)); padding: 0 calc(8px * var(--fs)); outline: none; }
.editor select { font-family: 'Satoshi', sans-serif; }
.editor select:disabled { opacity: 0.7; cursor: not-allowed; }
/* A text that can run long wraps (0.79.0): one line high when it fits, as many as it
   needs when it does not, grown by edGrow as it is typed and when the panel opens. */
.editor textarea { height: auto; min-height: var(--ed-ctl-h); padding: calc(5px * var(--fs)) calc(8px * var(--fs)); line-height: calc(16px * var(--fs)); resize: none; overflow: hidden; white-space: pre-wrap; overflow-wrap: anywhere; }
.editor input:focus, .editor select:focus, .editor textarea:focus { border-color: var(--accent); }
.editor .ed-err { margin-bottom: calc(8px * var(--fs)); color: var(--alarm); font-size: calc(11px * var(--fs)); line-height: 1.4; }
.editor .ed-foot { flex: none; padding: calc(10px * var(--fs)) var(--ed-pad) calc(12px * var(--fs)); border-top: 1px solid var(--border-muted); }
.editor .de-actions { display: flex; flex-wrap: wrap; align-items: center; gap: calc(8px * var(--fs)); }
.editor .de-btn { height: var(--ed-ctl-h); padding: 0 calc(14px * var(--fs)); font-size: calc(12px * var(--fs)); white-space: nowrap; }
/* A field and a button on one line: the batch panel's tag and effort. */
.editor .ed-inline { display: flex; align-items: center; gap: calc(8px * var(--fs)); }
.editor .ed-inline > input { flex: 1 1 auto; width: auto; }
.editor .ed-inline > .de-btn { flex: none; }
.editor .bt-chips { display: flex; flex-wrap: wrap; gap: calc(4px * var(--fs)); margin-top: calc(6px * var(--fs)); }
.editor .bt-chips .sec-tag small { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: calc(9px * var(--fs)); }
.editor .bt-none { margin-top: calc(4px * var(--fs)); font-size: calc(10.5px * var(--fs)); line-height: 1.4; color: var(--text3); }
.editor .sec-tag { font-size: calc(11px * var(--fs)); }
.editor .tb-x, .editor .bt-x { font-size: calc(13px * var(--fs)); }
.bt-x { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 3px; border-radius: 3px; font-family: inherit; }
.bt-x:hover { color: #f85149; background: var(--row-hover); }
/* A row's tag box (0.60.0): the Setup page's chip editor in a field's clothes. The chips
   are the same .sec-tag pills; the box is one bordered field with the input inside it. */
.tag-box { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-height: 28px; padding: 2px 5px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); box-sizing: border-box; width: 100%; min-width: 0; cursor: text; }
.tag-box:focus-within { border-color: var(--accent); }
.tag-box .tb-chips { display: contents; }
.tag-box .sec-tag { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.tag-box input.tb-in { flex: 1 1 80px; min-width: 60px; width: auto; border: none; background: transparent; padding: 2px; outline: none; box-shadow: none; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 11px; min-height: 0; }
.tag-box input.tb-in::placeholder { color: var(--text3); }
.editor .tag-box { min-height: var(--ed-ctl-h); gap: calc(4px * var(--fs)); padding: calc(2px * var(--fs)) calc(5px * var(--fs)); }
.editor .tag-box input.tb-in { display: block; flex: 1 1 calc(80px * var(--fs)); width: auto; min-width: calc(60px * var(--fs)); height: auto; padding: 2px; font-size: calc(11px * var(--fs)); }
.tb-x { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 3px; border-radius: 3px; font-family: inherit; }
.tb-x:hover { color: #f85149; background: var(--row-hover); }
/* In a table cell the box is laid over the cell like every cell editor, and may grow DOWN
   past it (a row with five tags is taller than one line) without moving the column. */
.data-table .cell-edit.tags-edit { bottom: auto; min-height: calc(100% - 2px); min-width: 200px; right: auto; left: 3px; width: max(100% - 6px, 200px); }
.data-table .cell-edit.tags-edit .tag-box { border-color: var(--accent); min-height: 100%; box-shadow: 0 4px 14px rgba(0,0,0,0.25); }
.data-table .cell-edit.tags-edit input.tb-in.cell-input { border: none; padding: 2px; flex: 1 1 70px; width: auto; }
/* The Tags column: own tags as pills, the section's after them, fainter, so the rule the
   legend filter uses (own plus section) is visible in the one place rows are listed. */
.data-table td.c-tags { white-space: nowrap; }
/* The pills are cut inside their own span, NOT by the cell: a cell that clipped would clip
   the chip editor laid over it too, and that editor is allowed to grow past the cell. */
.data-table td.c-tags .tags-trunc { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.data-table .row-tag { display: inline-block; font-family: 'Satoshi', sans-serif; font-size: calc(10.5px * var(--fs)); line-height: 1.4; padding: 0 5px; margin-right: 3px; border: 1px solid var(--border); border-radius: 4px; color: var(--text2); vertical-align: middle; }
.data-table .row-tag.inh { border-style: dashed; color: var(--text3); }
.de-btn { border: none; border-radius: 6px; padding: 6px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; font-weight: 500; cursor: pointer; transition: background 0.15s; }
.de-btn.primary { background: var(--accent); color: var(--bg); }
.de-btn.primary:hover { background: var(--accent-bright); }
.de-btn.secondary { background: transparent; color: var(--text2); border: 1px solid var(--border); }
.de-btn.secondary:hover { background: var(--row-hover); }
/* Destructive, and deliberately not next to Apply: margin-left:auto pushes it to the
   far end of the action row, so the button reached for by habit is never the one that
   takes a row out of the plan. Quiet until the pointer is on it, then the same red the
   table's row delete has used since 0.19.0. */
.de-btn.danger { background: transparent; color: var(--text3); border: 1px solid var(--border); margin-left: auto; }
.de-btn.danger:hover { color: #f85149; border-color: rgba(248,81,73,0.45); background: rgba(248,81,73,0.10); }
/* Armed for deletion: the rows the next Delete would take, marked in both columns and
   on a milestone's dashed rule, so the count in the prompt is never the only evidence
   of what is about to go. */
.label-row.del-armed, .bar-row.del-armed { background: rgba(248,81,73,0.13); box-shadow: inset 2px 0 0 #f85149; }
.ms-line.del-armed .ms-cap { border-color: #f85149; color: #f85149; }
/* The dependency list in the item editor: what this row waits on (needs) and what waits
   on it (blocks). A long name wraps rather than being cut, and a long list is part of the
   body, which scrolls, rather than a second scroller inside it. */
.editor .dep-list { list-style: none; }
.editor .dep-list:not(:empty) { margin-bottom: calc(6px * var(--fs)); }
.editor .dep-list li { display: flex; align-items: baseline; gap: calc(6px * var(--fs)); padding: calc(2px * var(--fs)) 0; font-size: calc(11px * var(--fs)); line-height: 1.4; color: var(--text2); }
.editor .dep-list .dep-dir { flex: none; min-width: calc(54px * var(--fs)); font-family: 'JetBrains Mono', monospace; font-size: calc(9px * var(--fs)); color: var(--text2); }
.editor .dep-list .dep-dir.in { color: var(--accent); }
.editor .dep-list .dep-nm { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
.editor .dep-list .dep-rm { flex: none; align-self: flex-start; font-size: calc(14px * var(--fs)); line-height: 1.2; padding: 0 calc(4px * var(--fs)); border-radius: 4px; color: var(--text2); cursor: pointer; transition: color 0.15s; }
.editor .dep-list .dep-rm:hover, .editor .dep-list .dep-rm:focus-visible { color: #f85149; }
.editor .dep-list .dep-none { color: var(--text3); }
/* A linked provenance is the link, not a field (0.71.0). It wraps like the fields do. */
.editor .prov-fixed { display: inline-block; max-width: 100%; vertical-align: top; white-space: normal; overflow-wrap: anywhere; font-family: 'JetBrains Mono', monospace; font-size: calc(11px * var(--fs)); line-height: 1.5; padding: calc(3px * var(--fs)) calc(10px * var(--fs)); border-radius: calc(12px * var(--fs)); border: 1px solid rgba(0,184,177,0.35); color: var(--accent); text-decoration: none; }
.editor .prov-fixed:hover { border-color: var(--accent); background: rgba(0,184,177,0.08); text-decoration: underline; }

/* ── Dropdown menu ── */
.dropdown { position: fixed; z-index: 99997; background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 4px 0; box-shadow: 0 8px 24px rgba(0,0,0,0.5); display: none; min-width: 180px; }
.dropdown.show { display: block; }
.dropdown .dd-item { padding: 6px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text2); cursor: pointer; transition: background 0.15s; display: flex; align-items: center; gap: 8px; }
.dropdown .dd-item:hover { background: rgba(255,255,255,0.04); color: var(--text); }
/* The keyboard's mark on a menu item (0.69.0): the pointer's highlight, with the accent bar
   every list of ours uses for the walked-to entry. */
.dropdown .dd-item.active { background: rgba(255,255,255,0.04); color: var(--text); box-shadow: inset 2px 0 0 var(--accent); }
.dropdown .dd-sep { height: 1px; background: var(--border-muted); margin: 4px 0; }
.dropdown .dd-label { padding: 4px 14px; font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text2); }

/* ── Toasts ── */
/* The accent is a splash, not a fill: a teal left edge and a teal-tinted shadow, with
   the body text on --text so it reads as a message rather than a teal wall. It rises
   a few pixels as it fades in, which is enough to catch the eye without bouncing. */
.undo-toast { position: fixed; bottom: 16px; left: 50%; transform: translate(-50%, 8px); background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; padding: 9px 16px; font-size: 12px; color: var(--text); box-shadow: 0 8px 24px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,184,177,0.16), 0 2px 14px rgba(0,184,177,0.12); z-index: 99998; opacity: 0; transition: opacity 0.18s ease-out, transform 0.18s ease-out; pointer-events: none; }
.undo-toast.show { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
.undo-toast kbd { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); }
/* A plan that could not be fully rendered says so, and keeps saying so: a silently
   shorter plan is worse than an ugly bar. */
.data-warn { display: none; position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); max-width: 640px; background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; padding: 10px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text-body); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 99999; }
.data-warn.show { display: block; }
.data-warn .dw-title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; color: var(--text); margin-bottom: 4px; }
.data-warn .dw-rows { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); margin-top: 6px; word-break: break-word; }
.data-warn .dw-close { position: absolute; top: 6px; right: 10px; background: none; border: none; color: var(--text2); font-size: 14px; cursor: pointer; line-height: 1; }
.data-warn .dw-close:hover { color: var(--text); }
@media print { .data-warn { display: none !important; } }
/* What a ClickUp import changed and what it could not bring across. Same place and the
   same make as the undated-rows card; taller, so its body scrolls under a fixed head. */
.import-report { display: none; flex-direction: column; position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); width: min(640px, calc(100vw - 32px)); max-height: min(70vh, 560px); box-sizing: border-box; background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; font-family: 'Satoshi', sans-serif; font-size: 12px; line-height: 1.45; color: var(--text-body); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 99999; overflow: hidden; }
.import-report.show { display: flex; }
.import-report .ir-head { display: flex; align-items: center; gap: 8px; padding: 10px 10px 4px 14px; flex: 0 0 auto; }
.import-report .ir-title { flex: 1 1 auto; font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 13px; color: var(--text); }
.import-report .ir-close { flex: 0 0 auto; background: none; border: none; color: var(--text2); font-size: 16px; cursor: pointer; line-height: 1; padding: 2px 4px; }
.import-report .ir-close:hover { color: var(--text); }
.import-report .ir-body { overflow-y: auto; padding: 0 14px 12px 14px; min-height: 0; }
.import-report h4 { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text2); margin: 10px 0 3px; }
.import-report ul { margin: 0; padding-left: 16px; }
.import-report li { margin: 2px 0; overflow-wrap: anywhere; }
.import-report .ir-dim { color: var(--text2); }
.import-report .ir-cu { font-family: 'JetBrains Mono', monospace; font-size: 11px; }
/* The importer has not been compared with a real ClickUp project yet: said once, quietly. */
.import-report .ir-note { margin-top: 6px; color: var(--text2); font-style: italic; }
/* The status mapping is short entries, so they wrap side by side rather than a line each. */
.import-report ul.ir-st { display: flex; flex-wrap: wrap; gap: 2px 18px; list-style: none; padding-left: 0; }
.import-report ul.ir-st li { margin: 0; }
@media print { .import-report { display: none !important; } }
/* Was bare 10px --text2 text on the page ground, which is most of why these went
   unnoticed. It is a pill now, with the same teal left edge as the toast. */
.save-indicator { position: fixed; bottom: 16px; right: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text); background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 7px; padding: 6px 11px; box-shadow: 0 6px 18px rgba(0,0,0,0.42), 0 0 0 1px rgba(0,184,177,0.14); max-width: 44vw; z-index: 99998; opacity: 0; transform: translateY(8px); transition: opacity 0.18s ease-out, transform 0.18s ease-out; pointer-events: none; }
.save-indicator.show { opacity: 1; transform: translateY(0); pointer-events: auto; }
/* Not idle, not saved: NOT SAVING. The indicator is a toast for everything else and a
   STATE for this one thing, so while the banner is up it is pinned in the alarm colour
   and anything that flashes over it hands it straight back when it fades. */
.save-indicator.err { color: var(--alarm); border-color: var(--alarm-edge); border-left-color: var(--alarm); box-shadow: 0 6px 18px rgba(0,0,0,0.42), 0 0 0 1px var(--alarm-halo); }

/* ── The save-blocked banner ── */
/* THIS IS NOT A TOAST, and that is the whole of 0.54.0. 0.28.0 rendered the conflict
   message into #undoToast, which meant the next undo, row delete, dependency delete or
   auto-schedule overwrote its text and armed showToast's hide timer on top of it, and
   saveBlocked stopped any later 409 from putting it back. A tab could therefore sit for
   seven minutes refusing to save and saying nothing, which is exactly what happened to a
   real plan on 2026-08-21. Its own element, no timer on it anywhere, and only a
   resolution takes it down.

   Same place the old one occupied, bottom centre, so nothing has moved for anyone who
   has seen it before. The toast and the indicator both step up out of its way while it is
   there, since two things stacked at bottom:16px would sit on top of each other, and it
   is one line tall so the chart underneath it is as readable as it is under a toast. */
.conflict-bar { position: fixed; bottom: 16px; left: 50%; transform: translate(-50%, 8px); display: none; align-items: center; gap: 8px; max-width: min(900px, calc(100vw - 32px)); background: var(--elevated); border: 1px solid var(--alarm-edge); border-left: 3px solid var(--alarm); border-radius: 8px; padding: 9px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text); box-shadow: 0 8px 24px rgba(0,0,0,0.5), 0 0 0 1px var(--alarm-halo), 0 2px 14px var(--alarm-halo); z-index: 100000; opacity: 0; transition: opacity 0.18s ease-out, transform 0.18s ease-out; }
.conflict-bar.show { display: flex; opacity: 1; transform: translate(-50%, 0); }
.conflict-bar .cb-msg { flex: 1 1 auto; min-width: 0; color: var(--text-body); }
.conflict-bar .cb-lead { color: var(--alarm); font-weight: 600; }
.conflict-bar .de-btn { flex: 0 0 auto; padding: 4px 11px; }
/* Everything else that lives along the bottom edge steps up while it is there, and it
   outranks all of them, because a banner about work not being saved is the most important
   thing on the screen for as long as it is true. */
:root[data-save="blocked"] .undo-toast,
:root[data-save="blocked"] .save-indicator,
:root[data-save="blocked"] .data-warn,
:root[data-save="blocked"] .import-report { bottom: 74px; }
/* The version is chrome: present for orientation, never competing with the plan. */
header .brand .app-ver { color: var(--text3); }
.sel-count { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 7px; padding: 6px 11px; box-shadow: 0 6px 18px rgba(0,0,0,0.42); z-index: 99998; opacity: 0; transform: translateY(8px); transition: opacity 0.18s ease-out, transform 0.18s ease-out; pointer-events: none; cursor: pointer; }
.sel-count.show { opacity: 1; transform: translateY(0); pointer-events: auto; }
.sel-count:hover { border-color: var(--accent); color: var(--accent-bright); }
/* Fixed width for the count so the chip cannot change size as the number does. */
.sel-count .n { display: inline-block; min-width: 2.2em; text-align: right; }
.id-box { position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%); z-index: 99999; width: min(560px,86vw); height: 130px; background: var(--elevated); color: var(--text); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; padding: 9px 11px; font-family: 'JetBrains Mono', monospace; font-size: 11px; box-shadow: 0 10px 30px rgba(0,0,0,0.55); }

/* ── View toggle: the same plan as a chart or as a table ──
   The primary control in the header, so it is bigger and heavier than the action
   buttons. Its position is anchored to the brand block on its left, which never
   changes width, so nothing downstream appearing or disappearing can move it. */
.view-toggle { display: flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; flex-shrink: 0; background: var(--bg); }
.view-toggle .vt-btn { display: flex; align-items: center; gap: 6px; background: none; border: none; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 12px; line-height: 1; padding: 6px 13px; cursor: pointer; transition: background 0.15s, color 0.15s; white-space: nowrap; }
.view-toggle .vt-btn svg { width: 14px; height: 14px; flex-shrink: 0; }
.view-toggle .vt-btn:hover { background: rgba(255,255,255,0.05); color: var(--text); }
/* Active carries four differences: fill, text colour, weight and an accent rule.
   It is deliberately NOT teal-filled any more: a filled teal control is the
   primary action, and there is exactly one of those in the chrome (New). */
.view-toggle .vt-btn.on { background: var(--elevated); color: var(--accent); font-weight: 600; box-shadow: inset 0 -2px 0 var(--accent); }

/* ── Table view ──
   A second lens over the same items. The chart answers "when"; the table answers
   "whose, and by when". Both read and write the same rows, so an edit in one is
   already true in the other. */
.table-view { display: none; flex: 1; min-height: 0; flex-direction: column; background: var(--bg); }
:root[data-view="table"] .table-view { display: flex; }
:root[data-view="table"] .chart-wrap { display: none; }
/* The start control, the grain picker and the milestone style are chart chrome:
   nothing in the table changes when they do. */
:root[data-view="table"] header .start-ctl,
:root[data-view="table"] #btnMsStyle,
:root[data-view="table"] .legend-item.gantt-only { display: none; }
/* Now a group inside the legend line, not a band of its own. */
.table-tools { display: flex; align-items: center; gap: 8px; flex-shrink: 0; flex-wrap: nowrap; margin-left: auto; padding-left: 12px; }
/* Who else has the plan open (0.66.0): at the right end of the legend, after the table's
   filters when they are there. Quiet: initials in small discs, a count. */
.presence { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; padding-left: 10px; flex-shrink: 0; }
:root[data-view="table"] .presence { margin-left: 8px; }
.presence-chip { width: 20px; height: 20px; border-radius: 50%; background: var(--surface2); border: 1px solid var(--border); color: var(--text2); font-family: 'Familjen Grotesk', sans-serif; font-size: 9.5px; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; letter-spacing: 0.02em; }
.presence-chip[data-you="1"] { border-color: var(--accent); color: var(--accent); }
.presence-n { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); margin-left: 3px; white-space: nowrap; }
@media print { .presence { display: none; } }
:root[data-view="gantt"] .table-tools { display: none; }
.legend-item { flex-shrink: 0; }
/* Below this the phase chips give up their words before any control wraps: the
   colour and the group headers already say which lane is which. */
@media (max-width: 1150px) {
  .legend-item.phase-chip span.chip-label { display: none; }
  .legend-item.phase-chip { padding: 3px 6px; }
  .table-tools input[type="search"] { width: 130px; }
}
/* A phone. The legend wraps now (0.57.0), and the table's filters are one more thing
   in it that can be wider than the screen, so they wrap as well instead of pushing
   the page sideways: owner and status on one line, the search and the count below. */
@media (max-width: 700px) {
  .table-tools { flex-wrap: wrap; flex-shrink: 1; min-width: 0; margin-left: 0; padding-left: 0; row-gap: 4px; }
}
.table-tools label { font-family: 'Satoshi', sans-serif; font-size: 11px; color: var(--text2); }
.table-tools select, .table-tools input[type="search"] { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 8px; outline: none; }
.table-tools select:hover, .table-tools select:focus,
.table-tools input[type="search"]:hover, .table-tools input[type="search"]:focus { border-color: var(--accent); color: var(--text); }
.table-tools input[type="search"] { width: 190px; }
.table-tools .tt-btn { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: background 0.15s, color 0.15s; }
.table-tools .tt-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); }
.table-count { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); }
/* The table scrolls inside its own box, so a wide plan never pushes the page sideways. */
.table-scroll { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; }
/* The gutter is reserved whether or not there is anything to scroll. Without it a
   filter that shortens the list takes the vertical scrollbar away, the pane gets
   15px wider, and every proportional column grows by a fraction: a width change
   with no cause the reader can see, which is the same complaint in a smaller size. */
table.data-table { table-layout: fixed; border-collapse: separate; border-spacing: 0; width: 100%; min-width: 900px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text-body); }
.data-table thead th { position: sticky; top: 0; z-index: 2; background: var(--surface); border-bottom: 1px solid var(--border); font-family: 'Familjen Grotesk', sans-serif; font-size: 11px; font-weight: 600; color: var(--text2); text-align: left; padding: 8px 10px; white-space: nowrap; user-select: none; }
.data-table thead th.sortable { cursor: pointer; }
.data-table thead th.sortable:hover { color: var(--text); }
.data-table thead th.sorted { color: var(--accent); }
.data-table thead th .sort-caret { font-size: 8px; margin-left: 5px; }
.data-table tbody td { border-bottom: 1px solid rgba(48,54,61,0.15); padding: 5px 10px; vertical-align: middle; }
.data-table tbody tr:hover { background: var(--row-hover); }
.data-table td.c-name { color: var(--text); }
.data-table tr.is-ms td.c-name { color: var(--text2); font-style: italic; }
.data-table td.c-name .ms-mark { color: var(--text2); font-style: normal; margin-right: 4px; }
.data-table td.c-owner, .data-table td.c-date { font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; }
.data-table td.c-phase { color: var(--text-body); white-space: nowrap; }
.data-table td.editable { cursor: text; }
.data-table td.editable:hover { box-shadow: inset 0 0 0 1px var(--border); border-radius: 4px; }
.data-table td.empty { color: var(--text2); }
.data-table .trunc { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.data-table td.c-notes .trunc { color: var(--text-body); }
.data-table td.c-notes.empty .trunc { color: var(--text2); }
.data-table .phase-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 7px; vertical-align: middle; }
/* The editor is laid OVER the cell, not put inside it. Replacing the cell's
   content made the column resize to whatever was being typed, and a select is as
   wide as its widest option, so the table jumped on every click. An absolutely
   positioned overlay is out of flow, so the column cannot feel it; min-width:0
   stops the control having opinions of its own. */
.data-table td { position: relative; }
.data-table .cell-edit { position: absolute; inset: 1px 3px; z-index: 6; display: flex; align-items: stretch; }
.data-table .cell-input { width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box; background: var(--bg); border: 1px solid var(--accent); border-radius: 4px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 3px 6px; outline: none; text-overflow: ellipsis; }
.data-table select.cell-input { font-family: 'Satoshi', sans-serif; }
/* ── Drag to reorder ──
   Arming is a click-and-hold, so every existing click (open a cell editor, hit
   the delete button, sort a header) still lands untouched. Once armed the row
   lifts and a drop indicator says where it will land. */
.table-scroll { position: relative; }
.data-table tbody tr.row-lifted { opacity: 0.45; }
.data-table tbody tr.row-lifted td { background: rgba(0,184,177,0.06); }
body.row-dragging { cursor: grabbing; user-select: none; }
body.row-dragging .data-table tbody tr:hover { background: none; }
.row-drop { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); border-radius: 1px; box-shadow: 0 0 6px rgba(0,184,176,0.5); z-index: 8; display: none; pointer-events: none; }
.row-drop.show { display: block; }
.row-drop::before { content: ''; position: absolute; left: 0; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
/* The lane a drop would land in, called out while dragging across it. */
.data-table tbody tr.lane-target td.c-phase { box-shadow: inset 2px 0 0 var(--accent); }
/* The focused row: the row whose editor you touched last, and the row Alt+Up and
   Alt+Down move. An inset shadow rather than a border or a pseudo-element with a
   width, so the indicator cannot shift a single pixel of the row it marks. */

/* == Analytics ==
   A third lens over the same rows. The chart answers "when", the table answers "whose
   and by when", and this one answers "how much, and is any of it in trouble". It is
   READ-ONLY on purpose: every number here is derived, and a derived number with an
   editor on it invites the reader to change the answer rather than the question.

   It is also whole-plan by construction, which is why the legend goes away with it: a
   total that quietly excluded a filtered lane would be a wrong total presented as a
   right one. The filters belong to the two lenses that show rows.

   No chart library. The bars here are one div with a percentage width, which is the
   whole of what a share bar is, and it prints, themes and scales with the text for
   free because it is made of the same tokens as everything else. */
.analytics-view { display: none; flex: 1; min-height: 0; flex-direction: column; background: var(--bg); }
:root[data-view="analytics"] .analytics-view { display: flex; }
:root[data-view="analytics"] .chart-wrap,
:root[data-view="analytics"] .legend,
:root[data-view="analytics"] header .start-ctl,
:root[data-view="analytics"] #btnMsStyle,
:root[data-view="analytics"] #btnCrit,
:root[data-view="analytics"] #btnAuto,
:root[data-view="setup"] .chart-wrap,
:root[data-view="setup"] .legend,
:root[data-view="setup"] header .start-ctl,
:root[data-view="setup"] #btnMsStyle,
:root[data-view="setup"] #btnCrit,
:root[data-view="setup"] #btnAuto { display: none; }
.an-scroll { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; padding: 16px 18px 40px; }
.an-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; align-items: start; max-width: 1600px; }
.an-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 13px 15px 15px; min-width: 0; }
.an-card.wide { grid-column: 1 / -1; }
.an-card h3 { font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; color: var(--accent); margin: 0 0 3px; letter-spacing: 0.01em; }
.an-card .an-sub { font-family: 'Satoshi', sans-serif; font-size: 11px; color: var(--text2); margin: 0 0 11px; }
.an-hero { display: flex; flex-wrap: wrap; gap: 22px; align-items: flex-end; }
.an-stat { min-width: 0; }
.an-stat .v { font-family: 'JetBrains Mono', monospace; font-size: 25px; font-weight: 700; color: var(--text); line-height: 1.05; white-space: nowrap; }
.an-stat .v small { font-size: 12px; font-weight: 400; color: var(--text2); margin-left: 3px; }
.an-stat .k { font-family: 'Satoshi', sans-serif; font-size: 11px; color: var(--text2); margin-top: 3px; }
table.an-table { width: 100%; border-collapse: separate; border-spacing: 0; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text-body); table-layout: fixed; }
table.an-table th { font-family: 'Familjen Grotesk', sans-serif; font-size: 10px; font-weight: 600; color: var(--text2); text-align: left; padding: 0 8px 5px 0; white-space: nowrap; border-bottom: 1px solid var(--border); }
/* The gap on the LEFT is what keeps two adjacent number columns from touching when a
   header ("not started") is wider than the numbers under it. */
table.an-table th.n, table.an-table td.n { text-align: right; padding: 0 8px 5px 14px; font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; }
table.an-table td.n { padding: 4px 8px 4px 14px; }
table.an-table td { padding: 4px 8px 4px 0; border-bottom: 1px solid rgba(48,54,61,0.15); vertical-align: middle; }
table.an-table tr:last-child td { border-bottom: none; }
table.an-table td.name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
table.an-table td.muted, table.an-table .muted { color: var(--text2); }
table.an-table tr.total td { border-top: 1px solid var(--border); border-bottom: none; padding-top: 6px; font-weight: 600; color: var(--text); }
/* A full-width card gives its first column the slack, so a long task name has room to
   be read instead of the number columns drifting apart across a metre of empty card. */
.an-card.wide table.an-table th:first-child { width: 34%; }
/* The per-person table carries nine columns, five of them narrow counts, so it gives
   the name less of the card than a four-column one can afford to: 34% of a full-width
   card for the owner would squeeze the counts into each other. */
.an-card.wide table.an-table.people th:first-child { width: 20%; }
/* The share bar: the row's own cell, filled to its fraction. Painted UNDER the text
   rather than beside it, so a long name never steals the bar's width. */
.an-bar { position: relative; height: 6px; border-radius: 3px; background: var(--border-muted); overflow: hidden; min-width: 40px; }
.an-bar > i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent); border-radius: 3px; display: block; }
.an-chip { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 7px; vertical-align: -1px; }
.an-empty { font-family: 'Satoshi', sans-serif; font-size: 11px; color: var(--text2); padding: 3px 0; }
.an-warn { color: var(--warn); }
.an-tag { padding: 0 6px; }
.an-late { color: #f85149; }
:root[data-theme="light"] .an-late { color: #b91c1c; }
.an-foot { font-family: 'Satoshi', sans-serif; font-size: 10px; color: var(--text3); margin-top: 9px; }
@media print {
  .an-scroll { overflow: visible !important; padding: 0; }
  .an-card { break-inside: avoid; border-color: #d0d7de; }
  .an-grid { grid-template-columns: 1fr 1fr; }
}

/* == The plan-level controls ==
   Auto-schedule and the critical path are statements about the WHOLE plan rather than
   about a row, so they sit with Fit, next to the lens switch, and not in the actions
   group at the far end with import and export. They are their own bordered group for
   the same reason the start control is: a reader can tell at a glance which buttons
   change the plan and which change the view. */
.plan-ctl { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
:root[data-view="analytics"] .plan-ctl,
:root[data-view="setup"] .plan-ctl { display: none; }
.plan-ctl .act-btn[disabled] { opacity: 0.4; cursor: default; }
.plan-ctl .act-btn[disabled]:hover { background: none; border-color: var(--border); color: var(--text2); }
.plan-ctl .act-btn.on { background: var(--elevated); color: var(--crit); border-color: var(--crit); }
:root[data-theme="light"] .plan-ctl .act-btn.on { color: var(--crit); }
/* One selection model, two lenses. A selected row carries the same faint teal wash
   the chart's label rows use; the focused row keeps its accent bar on the left edge.
   They are different questions ("is this row in the set" and "which row do the keys
   act on") so they are different marks and they stack without fighting. */
.data-table tbody tr.multi-sel > td { background: rgba(0,184,177,0.10); }
.data-table tbody tr.multi-sel:hover > td { background: rgba(0,184,177,0.14); }
/* No text selection can start from a modified click anywhere in the grid. The open
   editor's own input is exempt, because selecting the text you are editing is the
   point of it. */
body.tbl-picking, body.tbl-picking .data-table { user-select: none; }
body.tbl-picking .cell-input { user-select: text; }
.data-table tbody tr.row-focus > td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
.data-table tbody tr.row-focus > td { background: rgba(0,184,177,0.045); }
/* Delete pressed once: the rows the second press would take, the chart's armed red, over
   the selection and focus tints. */
.data-table tbody tr.del-armed > td, .data-table tbody tr.del-armed:hover > td { background: rgba(248,81,73,0.13); }
.data-table tbody tr.del-armed > td:first-child { box-shadow: inset 2px 0 0 #f85149; }
/* Plan order is the only order you can drag in, so say so rather than letting
   the row silently refuse. */
.data-table tbody tr.no-drag { cursor: default; }
.sort-lock-note { font-family: 'Satoshi', sans-serif; font-size: 10px; color: var(--text2); margin-left: 10px; white-space: nowrap; }
.sort-lock-note button { background: none; border: none; color: var(--accent); font: inherit; cursor: pointer; padding: 0; text-decoration: underline; }

.data-table .table-empty { padding: 22px 12px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 12px; }
/* Provenance: a short chip, because the useful part is usually one token ("POR",
   a channel name) and the full text is on the tooltip. A chip with a link looks
   like a link; one without stays quiet chrome. */
.data-table td.c-prov { white-space: nowrap; }
.data-table .prov-chip { display: inline-block; max-width: calc(150px * var(--fs)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; font-family: 'JetBrains Mono', monospace; font-size: calc(9px * var(--fs)); line-height: 1.5; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--border); color: var(--text2); text-decoration: none; }
a.prov-chip { border-color: rgba(0,184,177,0.35); color: var(--accent); }
a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08); text-decoration: underline; }
.data-table td.c-prov.editable:hover { box-shadow: inset 0 0 0 1px var(--border); border-radius: 4px; }
/* A LINKED provenance is fixed (0.71.0): the link opens its source and its text is never
   retyped, in the table or in the item editor. A plain one stays editable. */
.data-table td.c-prov.prov-locked { cursor: default; }
/* Delete: quiet until you are on the row, destructive-red only on the button
   itself, and never wider than the glyph so it cannot push the other columns. */
.data-table th.th-del { width: 30px; }
.data-table td.c-del { width: 30px; padding: 0 6px 0 0; text-align: right; }
.data-table .row-del { background: none; border: none; color: var(--text3); font-size: 15px; line-height: 1; padding: 2px 4px; border-radius: 4px; cursor: pointer; opacity: 0; transition: opacity 0.12s, color 0.12s, background 0.12s; }
.data-table tbody tr:hover .row-del { opacity: 1; }
.data-table .row-del:hover { color: #f85149; background: rgba(248,81,73,0.10); }
.data-table .row-del:focus-visible { opacity: 1; outline: 1px solid var(--accent); }
.undo-toast kbd { white-space: nowrap; }
@media print {
  .table-scroll { overflow: visible !important; }
  .table-tools { display: none !important; }
  table.data-table { min-width: 0; }
}

/* ── Collapse a lane ──
   Collapsing hides rows, not time: the axis is a function of dates and does not
   move. Both columns hide the same rows or they drift apart as you scroll. */
.label-row.row-collapsed, .bar-row.row-collapsed, .ms-line.row-collapsed { display: none !important; }
.label-row.group { position: relative; padding-right: 4px; }
.label-row.group .lane-name { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; display: flex; align-items: baseline; }
.label-row.group .lane-title { flex: 0 1 auto; min-width: 2.5em; overflow: hidden; text-overflow: ellipsis; }
/* The aside starts from nothing and grows into what is left, up to its own width, so it is
   the only thing that gives way. A shrink ratio would take a sliver from the name too, and
   a sliver is enough to put an ellipsis on it. */
.label-row.group .lane-lead { flex: 1 1 0; max-width: max-content; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.label-row.group .lane-tags { flex: 0 0 auto; }
/* ── Setup: the plan's own furniture, on a page of its own (0.58.0) ──
   Until 0.57.1 the sections were managed from a modal behind a header button. Drew
   asked for them to have their own page instead, and a page is the better home for
   more than tidiness: it has the room to show what a modal could not (each section's
   row count and the dates its rows span), and it is where the section tags go next,
   so everything that describes the plan's STRUCTURE rather than its work lives in
   one place. It is the fourth lens, built the way Analytics is: a scroll of cards in
   the same tokens, the legend and the chart-only controls gone, because there is no
   chart here for them to act on.

   The row markup and class names are the modal's, kept on purpose: .sec-row, the
   data-a actions and the inline editors are the same code paths they always were,
   now in a card instead of a dialog. */
.setup-view { display: none; flex: 1; min-height: 0; flex-direction: column; background: var(--bg); }
:root[data-view="setup"] .setup-view { display: flex; }
.su-scroll { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; padding: 16px 18px 40px; }
.su-stack { display: flex; flex-direction: column; gap: 14px; max-width: 980px; }
.su-card .su-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.su-card .su-head h3 { margin: 0 0 3px; }
.su-card .su-n { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); }
/* The Google Chat card (0.67.0). */
.chat-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 6px 0 4px; }
.chat-row label { font-size: 11px; color: var(--text2); }
.chat-row select { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'Satoshi', sans-serif; font-size: 12px; padding: 4px 8px; max-width: 100%; }
.chat-row select:disabled, .chat-row button:disabled { opacity: 0.45; cursor: default; }
.chat-status { font-size: 11.5px; color: var(--text2); line-height: 1.5; }
.chat-status .bad { color: var(--alarm); }
#chatState.on { color: var(--accent); }
/* The Sharing card and the owner's webhook field (0.68.0). */
#shareState.on { color: var(--accent); }
.chat-row input[type="text"] { flex: 1 1 260px; min-width: 0; max-width: 520px; }
/* Project dates (0.75.0) */
.pd-row input[type="date"] { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'JetBrains Mono', monospace; font-size: 12px; padding: 4px 8px; color-scheme: dark; }
:root[data-theme="light"] .pd-row input[type="date"] { color-scheme: light; }
.pd-row input[type="date"]:focus { outline: none; border-color: var(--accent); }
.pd-row input[type="date"]:disabled { opacity: 0.6; cursor: default; }
.pd-row label + input { margin-right: 10px; }
.pd-out { list-style: none; margin: 6px 0 0; padding: 0; font-size: 12px; }
.pd-out li { padding: 3px 0; border-top: 1px solid var(--border); display: flex; gap: 8px; align-items: baseline; }
.pd-out a { color: var(--text); text-decoration: none; }
.pd-out a:hover { color: var(--accent); text-decoration: underline; }
.pd-out .pd-why { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--pdate); margin-left: auto; white-space: nowrap; }
.pd-out .pd-more { color: var(--text2); }
/* Working calendar (0.76.0) */
.cal-hols { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 2px 14px; margin: 4px 0 8px; }
.cal-hols label { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-body); padding: 3px 0; cursor: pointer; }
.cal-hols label.worked { color: var(--text2); }
.cal-hols input { accent-color: var(--accent); margin: 0; }
.cal-hols input:disabled, .cal-hols input:disabled + span { cursor: default; }
.cal-hols .cal-d { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); margin-left: auto; white-space: nowrap; }
.cal-extra { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.cal-extra li { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); background: var(--holiday); border-radius: 6px; padding: 2px 4px 2px 8px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-body); }
.cal-extra button { background: none; border: 0; color: var(--text2); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 3px; }
.cal-extra button:hover { color: var(--text); }
.cal-extra button:disabled { display: none; }
.sec-add { display: flex; gap: 8px; align-items: center; margin: 0 0 10px; }
.sec-add input[type="text"] { flex: 1; min-width: 0; max-width: 360px; }
.setup-view input[type="text"], .setup-view select { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 8px; font-family: inherit; font-size: 12px; }
.setup-view input[type="text"]:focus, .setup-view select:focus { outline: none; border-color: var(--accent); }
/* A header row over the list, in the analytics table's type, so the two numbers on
   each row say what they are once rather than on every row. */
.sec-cols { display: flex; align-items: center; gap: 10px; padding: 0 8px 5px; border-bottom: 1px solid var(--border); font-family: 'Familjen Grotesk', sans-serif; font-size: 10px; font-weight: 600; color: var(--text2); }
.sec-cols .c-name { padding-left: var(--sec-indent); }
.sec-cols .sec-count, .sec-cols .sec-dates { font-family: inherit; font-size: inherit; color: inherit; }
.sec-cols .c-name { flex: 1 1 200px; }
.setup-view { --sec-indent: 47px; }
.sec-list { padding: 4px 0 0; position: relative; }
/* 0.61.0: the whole row is the drag handle (anything on it that is not a field or a
   button), the grip at its left says so, and a focused row moves with Alt+Up/Down. */
.sec-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; padding: 6px 8px; border-radius: 7px; border: 1px solid transparent; cursor: grab; position: relative; }
.sec-row:focus { outline: none; }
.sec-row:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
.sec-grip { width: 12px; height: 15px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--text3); opacity: 0.6; touch-action: none; }
.sec-row:hover .sec-grip, .sec-row:focus-visible .sec-grip { opacity: 1; color: var(--text2); }
.sec-list.sec-drag-on, .sec-list.sec-drag-on * { cursor: grabbing !important; user-select: none; }
.sec-row.sec-dragging, .tag-row.sec-dragging { opacity: 0.4; }
.sec-drop { position: absolute; left: 6px; right: 6px; height: 2px; margin-top: -1px; background: var(--accent); border-radius: 2px; pointer-events: none; z-index: 2; }
.sec-drop::before { content: ''; position: absolute; left: -4px; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.sec-row + .sec-row { border-top-color: rgba(48,54,61,0.15); border-top-left-radius: 0; border-top-right-radius: 0; }
.sec-row:hover { background: var(--row-hover); }
.sec-row.sec-editing { border-color: var(--border); background: var(--elevated); border-radius: 7px; }
.sec-row.sec-flash, .tag-row.sec-flash { animation: secFlash 1.4s ease-out; }
@keyframes secFlash { from { background: rgba(0,184,177,0.18); } to { background: transparent; } }
.sec-sw { width: 15px; height: 15px; border-radius: 4px; border: 1px solid var(--border); flex-shrink: 0; cursor: pointer; padding: 0; }
.sec-name { flex: 1 1 200px; min-width: 0; font-size: 12.5px; color: var(--text); overflow: hidden; white-space: nowrap; display: flex; align-items: baseline; gap: 4px; }
/* Click to edit (0.61.0): the name and the aside are fields. A faint box on hover says
   so; the space after them on the row is still the drag handle. */
.sec-nm-t, .sec-ld-t { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: text; border-radius: 4px; padding: 1px 4px; border: 1px solid transparent; }
.sec-nm-t { margin-left: -5px; }
.sec-ld-t { color: var(--text2); font-size: 0.86em; flex-shrink: 2; }
.sec-nm-t:hover, .sec-ld-t:hover { border-color: var(--border); background: var(--surface); }
.sec-nm-t:focus-visible, .sec-ld-t:focus-visible { outline: none; border-color: var(--accent); }
/* A section with no owner or aside says so, always, in dim capitals (0.62.0). It was a
   placeholder that only showed on hover, and a missing owner is worth seeing at rest. */
.sec-ld-t.empty { color: var(--text3); font-style: normal; letter-spacing: 0.04em; opacity: 0.75; }
.sec-row:hover .sec-ld-t.empty, .sec-row:focus-within .sec-ld-t.empty, .sec-row:focus .sec-ld-t.empty { opacity: 1; }
.setup-view .sec-name input[type="text"] { flex: 1 1 140px; min-width: 0; font-size: 12px; padding: 1px 6px; }
.setup-view .sec-name input.sec-nm { margin-left: -5px; }
.setup-view input[type="text"].bad { border-color: #f85149; }
.sec-count, .sec-dates { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); flex-shrink: 0; text-align: right; white-space: nowrap; }
.sec-count { width: 58px; }
.sec-dates { width: 196px; }
.sec-dates.none { color: var(--text3); }
.sec-acts { display: flex; gap: 2px; flex-shrink: 0; }
/* The column header's invisible copy of the four actions, so "Dates" sits over the
   dates whatever width the font gives the buttons. Not buttons, so nothing can focus
   or click them. */
.sec-ghost { border: 1px solid transparent; padding: 3px 7px; font-size: 11px; }
.sec-btn { background: none; border: 1px solid transparent; color: var(--text2); cursor: pointer; border-radius: 5px; padding: 3px 7px; font-size: 11px; font-family: inherit; }
.sec-btn:hover { background: var(--surface); color: var(--text); border-color: var(--border-muted); }
.sec-btn[disabled] { opacity: 0.35; cursor: default; }
.sec-btn[disabled]:hover { background: none; border-color: transparent; color: var(--text2); }
.sec-btn.danger:hover { color: #f85149; }
.sec-pal { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; padding: 6px 2px 2px var(--sec-indent); }
.sec-chip { width: 18px; height: 18px; border-radius: 5px; border: 2px solid transparent; cursor: pointer; padding: 0; }
.sec-chip.on { border-color: var(--text); }
.sec-msg { flex: 1 1 100%; color: #f85149; font-size: 11px; padding-top: 2px; }
.sec-row > .sec-msg { padding-left: var(--sec-indent); }
.sec-err { color: #f85149; font-size: 11px; margin: -4px 0 8px; }
/* Tags (0.59.0). One chip shape everywhere a tag is shown: a quiet outlined pill in the
   text colours, never a section colour. From 0.61.0 a tag may have a colour of its own:
   then every chip and pill of it (the [data-tc] rule below) takes a low-alpha fill of the
   colour and its ink (the colour walked to 3:1 against the page) for text and border. */
.sec-tag, .lane-tag { display: inline-flex; align-items: center; gap: 2px; border: 1px solid var(--border); border-radius: 4px; color: var(--text2); font-family: 'Satoshi', sans-serif; white-space: nowrap; line-height: 1.35; }
.sec-tag { font-size: 11px; padding: 0 2px 0 6px; background: var(--surface); }
.tag-row .sec-tag { padding: 0 6px; }
/* A coloured tag (0.61.0), on every chip and pill shape at once. */
.sec-tag[data-tc], .lane-tag[data-tc], .data-table .row-tag[data-tc] { color: var(--tag-ink); border-color: color-mix(in srgb, var(--tag-ink) 55%, transparent); background: var(--tag-fill); }
.data-table .row-tag.inh[data-tc] { background: transparent; border-color: color-mix(in srgb, var(--tag-ink) 45%, transparent); }
.sec-tag[data-tc] .sec-tag-x, .sec-tag[data-tc] .tb-x, .sec-tag[data-tc] .bt-x, .sec-tag[data-tc] small { color: inherit; opacity: 0.75; }
.label-row .row-name .row-tags .rt[data-tc] { color: var(--tag-ink); }
.legend-item.tag-chip[data-tc] { color: var(--tag-ink); border-color: color-mix(in srgb, var(--tag-ink) 55%, transparent); background: var(--tag-fill); }
.legend-item.tag-chip[data-tc] .tc-hash, .legend-item.tag-chip[data-tc] .tc-n { color: inherit; opacity: 0.75; }
.legend-item.tag-chip[data-tc]:hover { border-color: var(--tag-ink); }
.legend-item.tag-chip.on[data-tc] { color: var(--text); border-color: var(--tag-ink); background: color-mix(in srgb, var(--tag-ink) 26%, transparent); }
.legend-item.tag-chip.on[data-tc] .tc-hash, .legend-item.tag-chip.on[data-tc] .tc-n { color: var(--tag-ink); opacity: 1; }
.tag-nm-t { cursor: text; }
.tag-nm-t:hover { border-color: var(--text3); }
.tag-nm-t[data-tc]:hover { border-color: var(--tag-ink); }
.tag-nm-t:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.tag-sw { width: 15px; height: 15px; border-radius: 4px; border: 1px solid var(--border); flex-shrink: 0; cursor: pointer; padding: 0; background: none; }
.tag-sw.none { border-style: dashed; border-color: var(--text3); }
.tag-sw:hover { border-color: var(--text2); }
.tag-pal { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; width: 100%; padding: 6px 2px 2px 25px; }
.tag-pal-sep { width: 1px; height: 16px; background: var(--border); margin: 0 3px; }
.tag-pc { width: 18px; height: 18px; border-radius: 5px; border: 2px solid transparent; cursor: pointer; padding: 0; }
.tag-pc.on { border-color: var(--text); }
.tag-pal .tag-hex { width: 88px; margin-left: 6px; }
.tag-pal .tag-btn[disabled] { opacity: 0.4; cursor: default; }
.tag-pal .sec-msg { padding-left: 0; }
.tag-n.unused { color: var(--text3); }
.tag-add { margin-top: 2px; }
.sec-tags { order: 8; width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 0 0 0 var(--sec-indent); margin-top: -2px; min-width: 0; }
.sec-tag-x { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 3px; border-radius: 3px; font-family: inherit; }
.sec-tag-x:hover { color: #f85149; background: var(--row-hover); }
.setup-view input[type="text"].sec-tag-in { width: 92px; min-width: 0; font-size: 11px; line-height: 1.35; padding: 0 6px; border: 1px dashed transparent; border-radius: 4px; background: transparent; color: var(--text2); }
.sec-row:hover input[type="text"].sec-tag-in { border-color: var(--border); }
.setup-view input[type="text"].sec-tag-in:focus { width: 180px; border-style: solid; border-color: var(--accent); background: var(--bg); color: var(--text); }
.setup-view input[type="text"].sec-tag-in[disabled] { width: auto; flex: 1 1 200px; opacity: 0.6; }
.sec-tag-in::placeholder { color: var(--text3); }
.tag-prefix { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0 0 10px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: 7px; font-size: 11.5px; color: var(--text2); }
.tag-prefix .tp-say { flex: 1 1 240px; min-width: 0; }
.tag-prefix .de-btn { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-list { padding: 2px 0 0; position: relative; }
.tag-list.sec-drag-on, .tag-list.sec-drag-on * { cursor: grabbing !important; user-select: none; }
.tag-row:hover .sec-grip, .tag-row:focus-visible .sec-grip { opacity: 1; color: var(--text2); }
.tag-row:focus-visible { outline: none; border-color: var(--accent); }
.tag-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; padding: 5px 8px; border-radius: 7px; border: 1px solid transparent; }
.tag-row + .tag-row { border-top-color: rgba(48,54,61,0.15); border-top-left-radius: 0; border-top-right-radius: 0; }
.tag-row:hover { background: var(--row-hover); }
.tag-row.tag-editing { border-color: var(--border); background: var(--elevated); border-radius: 7px; }
.tag-name { flex: 1 1 160px; min-width: 0; overflow: hidden; }
.tag-n { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); white-space: nowrap; }
.tag-acts { display: flex; gap: 2px; flex-shrink: 0; }
.tag-btn { background: none; border: 1px solid transparent; color: var(--text2); cursor: pointer; border-radius: 5px; padding: 3px 7px; font-size: 11px; font-family: inherit; }
.tag-btn:hover { background: var(--surface); color: var(--text); border-color: var(--border-muted); }
.tag-btn.danger:hover { color: #f85149; }
.tag-name .tag-nm { width: 100%; max-width: 260px; min-width: 0; font-size: 11.5px; padding: 1px 6px; }
.tag-empty { font-size: 11.5px; color: var(--text3); padding: 4px 8px; }
/* In the chart: after the aside, smaller, and quieter still. */
.lane-tags { display: inline-flex; gap: 3px; margin-left: 7px; vertical-align: 1px; }
.lane-tag { font-size: 0.76em; font-weight: 400; padding: 0 5px; color: var(--text3); }
/* The legend's tag chips lead the line, before the sections they stand in front of.
   Outlined and hash-marked rather than dotted, so they never read as a section. */
.legend-item.tag-chip, .legend-item.sec-toggle, .legend-item.tag-clear { background: none; border: 1px solid var(--border); font-family: 'Satoshi', sans-serif; color: var(--text2); padding: 2px 8px; gap: 4px; }
.legend-item.tag-chip .tc-hash { color: var(--text3); }
.legend-item.tag-chip .tc-n { font-family: 'JetBrains Mono', monospace; font-size: 0.9em; color: var(--text3); }
.legend-item.tag-chip:hover, .legend-item.sec-toggle:hover, .legend-item.tag-clear:hover { color: var(--text); border-color: var(--accent); background: var(--row-hover); }
.legend-item.tag-chip.on { color: var(--text); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
.legend-item.tag-chip.on .tc-hash, .legend-item.tag-chip.on .tc-n { color: var(--accent); }
.legend-item.sec-toggle { border-style: dashed; }
.legend-item.sec-toggle svg { width: calc(11px * var(--fs)); height: calc(11px * var(--fs)); flex-shrink: 0; transition: transform 0.15s; }
.legend-item.sec-toggle.open svg { transform: rotate(180deg); }
.legend-item.owner-pick, .legend-item.overdue-chip { background: none; border: 1px solid var(--border); font-family: 'Satoshi', sans-serif; color: var(--text2); padding: 2px 8px; gap: 6px; }
.legend-item.owner-pick select { background: transparent; color: var(--text); border: none; font: inherit; padding: 0; cursor: pointer; max-width: 16em; }
.legend-item.owner-pick select option { background: var(--surface); color: var(--text); }
.legend-item.owner-pick.on { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
.legend-item.overdue-chip .od-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--alarm); flex-shrink: 0; }
.legend-item.overdue-chip .od-n { font-family: 'JetBrains Mono', monospace; font-size: 0.9em; color: var(--alarm); }
.legend-item.overdue-chip:hover, .legend-item.owner-pick:hover { color: var(--text); border-color: var(--accent); background: var(--row-hover); }
.legend-item.overdue-chip .od-ms { font-family: 'JetBrains Mono', monospace; font-size: 0.9em; color: var(--alarm); padding-left: 6px; border-left: 1px solid var(--border); }
.legend-item.overdue-chip.on { color: var(--text); border-color: var(--alarm-edge); background: var(--alarm-halo); }
.legend-item.overdue-chip.none .od-dot { background: var(--text3); }
.legend-item.overdue-chip.none .od-n { color: var(--text3); }
/* The Current chip (0.70.0): what has started and is not done, the working set for a
   review. In the accent, not the alarm: it is a lens, not a warning. */
.legend-item.current-chip { background: none; border: 1px solid var(--border); font-family: 'Satoshi', sans-serif; color: var(--text2); padding: 2px 8px; gap: 6px; }
.legend-item.current-chip .cu-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.legend-item.current-chip .cu-n, .legend-item.current-chip .cu-ms { font-family: 'JetBrains Mono', monospace; font-size: 0.9em; color: var(--accent); }
.legend-item.current-chip .cu-ms { padding-left: 6px; border-left: 1px solid var(--border); }
.legend-item.current-chip:hover { color: var(--text); border-color: var(--accent); background: var(--row-hover); }
.legend-item.current-chip.on { color: var(--text); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
.legend-item.current-chip.none .cu-dot { background: var(--text3); }
.legend-item.current-chip.none .cu-n, .legend-item.current-chip.none .cu-ms { color: var(--text3); }
/* An overdue row (0.64.0): a red edge on its label and a red ring on its bar, so it shows
   without the filter on. Derived each render, never stored. */
.label-row.is-overdue { box-shadow: inset 3px 0 0 var(--alarm); }
.bar-row.is-overdue .bar { outline: 2px solid var(--alarm); outline-offset: 1px; }
.bar-row.is-overdue .ms-diamond { background: var(--alarm); }
.legend-item.tag-clear { border-color: transparent; color: var(--text2); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; }
/* A phone: the two numbers drop under the name, indented to line up with it, and the
   row actions follow them on the same line, so a 20-section plan is still one short
   row per section rather than a column of wrapped buttons. The header row goes,
   since the numbers are no longer in columns for it to label. */
@media (max-width: 640px) {
  .su-scroll { padding: 12px 10px 32px; }
  .sec-cols { display: none; }
  .setup-view { --sec-indent: 22px; }
  .sec-name { flex-basis: calc(100% - 52px); }
  .sec-count { width: auto; margin-left: var(--sec-indent); text-align: left; }
  .sec-dates { width: auto; text-align: left; }
  .sec-acts { margin-left: auto; }
  .sec-add input[type="text"] { max-width: none; }
}
.lane-lead { color: var(--text3); font-weight: 400; font-style: normal; margin-left: 7px; font-size: 0.86em; }
.lane-toggle { display: flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text2); cursor: pointer; padding: 2px 4px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: calc(9px * var(--fs)); flex-shrink: 0; }
.lane-toggle:hover { background: var(--row-hover); color: var(--text); }
.lane-toggle svg { width: 11px; height: 11px; transition: transform 0.15s; }
.lane-toggle.collapsed svg { transform: rotate(-90deg); }
.lane-toggle .lane-count { color: var(--text3); }
.lane-toggle.collapsed .lane-count { color: var(--accent); }
/* ── Subtasks (0.79.0, issue #5) ──
   A subtask is indented by its depth. Once a plan has any, every row keeps a slot for the
   chevron that folds a task's subtasks, so names line up; a plan without them looks as it did. */
.label-row.is-sub { padding-left: calc(12px + var(--depth, 0) * 18px * var(--fs)); }
.sub-tog { display: none; flex: none; align-items: center; justify-content: center; width: 16px; height: 16px; margin: 0 3px 0 -5px; padding: 0; background: none; border: none; border-radius: 3px; color: var(--text3); cursor: pointer; visibility: hidden; }
#labels.has-nest .label-row:not(.group) .sub-tog { display: inline-flex; }
.label-row.has-kids .sub-tog { visibility: visible; }
.sub-tog:hover { background: var(--row-hover); color: var(--text); }
.sub-tog svg { width: 11px; height: 11px; transition: transform 0.15s; }
.label-row.kids-folded .sub-tog { color: var(--accent); }
.label-row.kids-folded .sub-tog svg { transform: rotate(-90deg); }
.sub-count { display: inline-flex; align-items: center; gap: 2px; flex: none; margin: 0 2px 0 6px; font-family: 'JetBrains Mono', monospace; font-size: calc(9.5px * var(--fs)); color: var(--text3); }
.sub-count svg { width: 10px; height: 10px; }
.label-row.ctx-dim, .bar-row.ctx-dim, .data-table tr.ctx-dim td { opacity: 0.42; }
/* A folded task's subtasks, earliest start to latest end, under its own bar. */
.sub-span { position: absolute; bottom: 2px; height: 5px; border: 1.5px solid var(--text2); border-top: none; border-radius: 0 0 3px 3px; opacity: 0.75; pointer-events: none; }
/* Quick add (0.79.0, issue #6): a + beside a section's chevron, and on a task for a subtask. */
.lane-add, .row-add { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; color: var(--text2); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.12s, color 0.12s, border-color 0.12s; flex: none; }
.lane-add svg, .row-add svg { width: 12px; height: 12px; }
.label-row.group .lane-add { margin-right: 2px; }
.label-row.group:hover .lane-add, .lane-add:focus-visible { opacity: 1; pointer-events: auto; }
.label-row .row-add { position: absolute; right: 52px; top: 50%; transform: translateY(-50%); z-index: 12; }
.label-row:hover .row-add, .row-add:focus-visible { opacity: 1; pointer-events: auto; }
.label-row:hover .peer-chips { right: 76px; }
.label-row.depth-max .row-add { display: none; }
/* A drag over the middle of a task: the rows go under it. */
.label-row.nest-target, .bar-row.nest-target { box-shadow: inset 0 0 0 2px var(--accent); background: color-mix(in srgb, var(--accent) 9%, transparent); }
.data-table tr.nest-target td { background: color-mix(in srgb, var(--accent) 12%, transparent); box-shadow: inset 0 2px 0 var(--accent), inset 0 -2px 0 var(--accent); }
.lane-add:hover, .row-add:hover { color: var(--accent); border-color: var(--accent); }
/* The blank row a quick add opens: as tall as a row, in both panes, so the chart stays aligned. */
.qa-row { height: var(--row-h); display: flex; align-items: center; gap: 6px; padding: 0 12px 0 calc(12px + var(--depth, 0) * 18px * var(--fs)); border-bottom: 1px solid rgba(48,54,61,0.15); background: color-mix(in srgb, var(--accent) 6%, transparent); }
.qa-bar { height: var(--row-h); border-bottom: 1px solid rgba(48,54,61,0.15); background: color-mix(in srgb, var(--accent) 4%, transparent); }
.qa-plus { display: inline-flex; color: var(--accent); flex: none; }
.qa-plus svg { width: 12px; height: 12px; }
.qa-in { flex: 1; min-width: 0; height: calc(var(--row-h) - 10px); background: var(--bg); color: var(--text); border: 1px solid var(--accent); border-radius: 4px; padding: 0 8px; font-family: 'Satoshi', sans-serif; font-size: calc(12px * var(--fs)); outline: none; }
.qa-in::placeholder { color: var(--text3); }
.data-table tr.qa-line td { padding: 0 8px; height: 22px; border-bottom: none; }
.data-table .qa-add { background: none; border: none; color: var(--text3); font-family: 'Satoshi', sans-serif; font-size: calc(11px * var(--fs)); cursor: pointer; padding: 2px 6px; border-radius: 4px; }
.data-table .qa-add:hover { color: var(--accent); background: var(--row-hover); }
.data-table tr.qa-entry td { padding: 3px 8px; }
.data-table tr.qa-entry .qa-in { width: 100%; max-width: 520px; height: 24px; }
/* The Table's tree, in plan order: the chevron, the indent, the + for a subtask on hover. */
.data-table td.c-name { position: relative; }
.data-table .t-ind { display: inline-block; width: calc(var(--depth, 0) * 16px * var(--fs)); flex: none; }
.data-table .t-name { display: flex; align-items: center; min-width: 0; }
.data-table .sub-tog { display: inline-flex; visibility: visible; margin: 0 3px 0 0; }
.data-table .sub-tog.sub-none { visibility: hidden; }
.data-table tr.kids-folded .sub-tog svg { transform: rotate(-90deg); }
.data-table tr.kids-folded .sub-tog { color: var(--accent); }
.data-table .row-add { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); }
.data-table tr:hover .row-add, .data-table .row-add:focus-visible { opacity: 1; pointer-events: auto; }
/* The Section cell's + adds a task to that section, from any row, sorted or not. */
.data-table td.c-phase { position: relative; }
.data-table .sec-add { display: flex; align-items: center; justify-content: center; position: absolute; right: 4px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; color: var(--text2); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.12s; }
.data-table .sec-add svg { width: 11px; height: 11px; }
.data-table tr:hover .sec-add, .data-table .sec-add:focus-visible { opacity: 1; pointer-events: auto; }
.data-table .sec-add:hover { color: var(--accent); border-color: var(--accent); }
.data-table .sub-of { color: var(--text3); margin-right: 4px; }
.data-table .eff-roll { color: var(--text3); }

/* Editing a name straight in the label column. Fixed to the row's own box so the
   name column never resizes, same rule the table cells follow. */
.label-edit { position: absolute; inset: 3px 6px; width: auto; background: var(--bg); border: 1px solid var(--accent); border-radius: 4px; color: var(--text); font-family: 'Satoshi', sans-serif; font-size: calc(12px * var(--fs)); padding: 0 6px; outline: none; z-index: 12; box-sizing: border-box; }
.label-row { position: relative; }
/* A row keeps its buttons and chips to itself (0.74.1): the link button (z 11) and the
   initials (z 10) sat above the pinned section header (z 9) as their row slid under
   it, showed through it, and took the click meant for its chevron. */
.label-row:not(.group) { z-index: 0; }
/* Appears only under the cursor, absolutely positioned so it never moves the
   name text or changes the row's height. */
.row-edit { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; color: var(--text2); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.12s, color 0.12s, border-color 0.12s; z-index: 11; }
.row-edit svg { width: 12px; height: 12px; }
/* Copy a link to the row (0.73.0): always there, quiet until pointed at, so a link for Chat
   is one click on any row. It takes the pencil's reserved slot at the end of the row; the
   pencil appears beside it on hover, over the row's tail (its tags, the part that gives way). */
.row-link { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; background: none; border: 1px solid transparent; border-radius: 4px; color: var(--text3); cursor: pointer; opacity: 0.55; z-index: 11; transition: opacity 0.12s, color 0.12s, border-color 0.12s; }
.row-link svg { width: 12px; height: 12px; }
.label-row:hover .row-link, .row-link:focus-visible { opacity: 1; }
.row-link:hover { color: var(--accent); border-color: var(--accent); background: var(--surface); }
.row-link.copied, .uid-link.copied { color: var(--accent); border-color: var(--accent); }
.label-row .row-edit { right: 28px; }
/* The row handle: a slim strip on the left edge of every task and milestone row, in the
   chart's name column and the Table's ID cell. A click on it SELECTS the row and does
   nothing else (no rename, no picker, no editor), so Delete twice, Ctrl+C, Alt+Arrow and
   the batch keys have a row to act on. Ctrl or Cmd adds or drops it, Shift takes the
   range. Its six dots are the handle ClickUp, Notion and Asana put at the start of a row,
   and like theirs it drags the row too. Hidden until the row is hovered, so a plan at rest
   looks as it did; a selected row keeps its handle, in the accent. It sits in the row's
   existing left padding, so no name moves. Not a checkbox, on purpose: a box at the start
   of a task reads as "mark it done", and a checkbox adds to the set where this click
   replaces it. */
.row-sel { position: absolute; left: 0; top: 0; bottom: 0; width: 12px; display: flex; align-items: center; justify-content: center; padding: 0; margin: 0; background: none; border: none; border-radius: 0 4px 4px 0; color: var(--text3); cursor: pointer; opacity: 0; z-index: 11; transition: opacity 0.12s, color 0.12s, background-color 0.12s; }
.row-sel::before { content: ''; width: 6px; height: 10px; flex: none; background-color: currentColor;
  -webkit-mask: var(--grip-dots) center / 6px 10px no-repeat; mask: var(--grip-dots) center / 6px 10px no-repeat; }
/* The six dots as a mask on one pseudo-element rather than an SVG in every row: a plan has
   hundreds of rows, and the Table rebuilds each row's markup on every live change. */
:root { --grip-dots: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 6 10'%3E%3Cg fill='%23000'%3E%3Ccircle cx='1' cy='1' r='1'/%3E%3Ccircle cx='5' cy='1' r='1'/%3E%3Ccircle cx='1' cy='5' r='1'/%3E%3Ccircle cx='5' cy='5' r='1'/%3E%3Ccircle cx='1' cy='9' r='1'/%3E%3Ccircle cx='5' cy='9' r='1'/%3E%3C/g%3E%3C/svg%3E"); }
.label-row:hover .row-sel, .data-table tbody tr:hover .row-sel, .row-sel:focus-visible { opacity: 1; }
.row-sel:hover { color: var(--accent); background-color: color-mix(in srgb, var(--accent) 16%, transparent); }
.label-row.multi-sel .row-sel, .data-table tbody tr.multi-sel .row-sel { opacity: 1; color: var(--accent); }
/* Dependency mode owns every click on the chart, and hides the bars' handles too. */
body.dep-mode .row-sel { display: none; }
/* No hover on a touch screen, so the handle stays faintly in view there. Both features,
   because a headless browser also reports no hover (with no pointer at all). */
@media (hover: none) and (pointer: coarse) { .row-sel { opacity: 0.5; } }
/* The subtasks chevron (branch subtasks) starts where the row's padding ends, beside the
   handle's strip rather than 5px into it, so the two never share a pixel to click. */
.label-row .sub-tog { margin-left: 0; }
@media print { .row-sel { display: none !important; } }
.data-table td.c-uid { position: relative; white-space: nowrap; padding-left: 16px; padding-right: 4px; }
/* Under the sticky header (z-index 2) as the rows scroll beneath it: a Table cell is no
   stacking context of its own, unlike a chart row. */
.data-table .row-sel { width: 14px; z-index: 1; }
.data-table .uid-link { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px solid transparent; border-radius: 999px; padding: 1px 6px; font-family: 'JetBrains Mono', monospace; font-size: calc(10px * var(--fs)); color: var(--text2); cursor: pointer; }
.data-table .uid-link svg { width: 11px; height: 11px; opacity: 0.7; }
.data-table .uid-link:hover { color: var(--accent); border-color: var(--accent); }
/* The row a shared link pointed at, for a moment. */
@keyframes linkPulse { 0%, 45% { background: color-mix(in srgb, var(--accent) 24%, transparent); } 100% { background: transparent; } }
.label-row.linked, .bar-row.linked, .data-table tr.linked td { animation: linkPulse 2.6s ease-out; }
/* The team's view (0.73.0): Analytics and the plan's Sharing and Chat settings are the
   owner's. The owner can preview the team's view from the Sharing card. */
:root[data-role="member"] .vt-btn[data-view="analytics"], :root[data-role="member"] #suShare, :root[data-role="member"] #suChat { display: none !important; }
/* Auto-schedule and the critical path are the owner's too (0.74.0): one click re-times
   everybody's rows. */
:root[data-role="member"] #btnAuto, :root[data-role="member"] #btnCrit { display: none !important; }
/* Who is on which row (0.74.0): another person's selected or open rows carry their colour
   and a small initials circle, on the chart and in the table. */
.label-row.peer-focus { background: color-mix(in srgb, var(--peer) 12%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--peer) 70%, transparent); }
.bar-row.peer-focus .bar { box-shadow: 0 0 0 2px var(--peer); }
.data-table tr.peer-focus td { background: color-mix(in srgb, var(--peer) 10%, transparent); }
.peer-chips { display: inline-flex; gap: 2px; vertical-align: middle; pointer-events: auto; }
.label-row .peer-chips { position: absolute; right: 52px; top: 50%; transform: translateY(-50%); z-index: 10; }
.peer-chip { width: 16px; height: 16px; border-radius: 50%; background: var(--peer); color: #0d1117; font-family: 'Familjen Grotesk', sans-serif; font-weight: 700; font-size: 8px; line-height: 16px; text-align: center; overflow: hidden; box-shadow: 0 0 0 1.5px var(--bg); flex-shrink: 0; }
.peer-chip img { width: 100%; height: 100%; object-fit: cover; display: block; }
#memberPreview { position: fixed; top: 0; left: 50%; transform: translateX(-50%); z-index: 100002; display: flex; align-items: center; gap: 10px; padding: 6px 12px; background: var(--elevated); border: 1px solid var(--accent); border-top: none; border-radius: 0 0 8px 8px; font-size: 12px; color: var(--text); box-shadow: 0 6px 18px rgba(0,0,0,0.35); }
.label-row:hover .row-edit { opacity: 1; pointer-events: auto; }
.row-edit:hover { color: var(--accent); border-color: var(--accent); }
.row-edit:focus-visible { opacity: 1; pointer-events: auto; outline: 1px solid var(--accent); }
/* A flex row rather than a block of inline spans, so the two pills are never the part
   that gets cut off. As a block, a long name plus a status pill plus an owner pill
   simply overflowed and `overflow:hidden` took a bite out of whichever came last,
   which from 0.36.0 is the owner pill: a CONTROL, clipped to the point of being
   unclickable. Flexed, the pills are flex:none and the name is the flexible part, so a
   long name ellipsises and both chips stay whole and hittable at any label width. */
.label-row .row-name { display: flex; align-items: center; min-width: 0; overflow: hidden; white-space: nowrap; padding-right: 22px; }
.label-row .row-name .name-text { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.label-row .row-name .tag { flex: none; }
/* A row's own tags (0.60.0): quiet text after the owner, and the FIRST thing to give way.
   A flex basis of 0 means the tags ask for no room at all and only GROW into what the name
   and the pills leave over, so the name keeps its whole width until the tags have none.
   (flex-shrink 1000 against the name's 1, the first try, still handed the name a sub-pixel
   share of the squeeze, which was enough to trip its ellipsis: 0.60.0 review.) */
.label-row .row-name .row-tags { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; font-family: 'JetBrains Mono', monospace; font-size: calc(9.5px * var(--fs)); color: var(--text3); }
/* The gap before the tags is INSIDE their box, so at zero width it goes with them; a margin
   would still take its 6px from the name. */
.label-row .row-name .row-tags::before { content: '\00a0\00a0'; }
/* ---- resizable table columns ---- */
.data-table th { position: relative; }
.col-grip { position: absolute; top: 0; right: 0; width: 8px; height: 100%; cursor: col-resize; z-index: 3; }
.col-grip::after { content: ''; position: absolute; inset: 4px 3px; background: transparent; transition: background 0.15s; }
.col-grip:hover::after, .col-grip.dragging::after { background: var(--accent); }
body.col-sizing { cursor: col-resize; user-select: none; }

/* The choice list for a select cell. It is OUR dom, not the browser's popup, which
   is the whole point: a native popup is browser chrome and it swallows the first
   Tab to close itself, so leaving a dropdown cost a keypress that leaving a text
   field did not. Fixed position and appended to the body, because the cells clip
   their own overflow under the fixed table layout. */
.cell-list { position: fixed; z-index: 100001; min-width: 130px; max-height: min(calc(232px * var(--fs)), 60vh); overflow-y: auto;
  background: var(--surface); border: 1px solid var(--border); border-radius: 7px; padding: 4px;
  box-shadow: 0 8px 22px rgba(0,0,0,0.34); font-family: 'Satoshi', sans-serif; font-size: calc(12px * var(--fs));
  max-width: calc(100vw - 12px); }
/* The type follows the app's text size like the fields the list belongs to, and a long
   entry (a task name in "waits on") wraps inside the window rather than running past it. */
.cell-list .cl-opt { padding: calc(5px * var(--fs)) calc(9px * var(--fs)); border-radius: 5px; color: var(--text-body); cursor: pointer; white-space: normal; overflow-wrap: anywhere; }
.cell-list .cl-opt:hover, .cell-list .cl-opt.active { background: var(--surface2); color: var(--text); }
.cell-list .cl-opt.active { box-shadow: inset 2px 0 0 var(--accent); }
/* The owner pill's field (0.66.1): THE owner picker, laid over the pill it edits. */
.pill-owner-inp { position: fixed; z-index: 100000; height: 22px; box-sizing: border-box; padding: 2px 7px; border-radius: 5px; border: 1px solid var(--accent); background: var(--surface); color: var(--text); font-family: 'Satoshi', sans-serif; font-size: 12px; outline: none; box-shadow: 0 6px 18px rgba(0,0,0,0.35); }
.cell-list .cl-opt.on { background: rgba(0,184,177,0.14); color: var(--accent); font-weight: 600; }
/* The owner list is the choice list plus a way in for a name that is not on it yet.
   The rule above the field says "the list ends here, this is the other option". */
.cell-list.owner-list { min-width: 172px; }
/* (0.66.1: the owner list's type-in row is gone; the pill opens the owner field.) */

/* The Adom identity card, compressed rendering: avatar plus real name. Fixed
   maximum width so the chip can never move anything else in the header as a name
   changes length. Monochrome, no emoji, one teal accent, per the identity-card
   and brand rules. */
.who { display: flex; align-items: center; gap: 7px; padding: 3px 8px 3px 3px; border-radius: 999px;
       border: 1px solid transparent; cursor: pointer; max-width: 190px; flex-shrink: 0; }
.who:hover { border-color: var(--border); background: var(--surface2); }
.who-av { width: 24px; height: 24px; border-radius: 50%; flex-shrink: 0; object-fit: cover; display: block;
          background: linear-gradient(135deg, var(--accent) 0%, #5b7fd4 100%); color: #05201f;
          font-family: 'Familjen Grotesk', sans-serif; font-size: 10px; font-weight: 700; line-height: 24px;
          text-align: center; letter-spacing: 0.02em; }
.who-name { font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 500; color: var(--text2);
            white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.who-card { padding: 12px 14px; display: flex; gap: 11px; align-items: flex-start; border-bottom: 1px solid var(--border); }
.who-card .who-av { width: 34px; height: 34px; font-size: 13px; line-height: 34px; }
.who-card-txt { min-width: 0; }
.who-real { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600; color: var(--text);
            white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.who-sub { font-size: 11px; color: var(--text2); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* The grid is fixed from the first paint, so every cell clips at its column and
   nothing in the body can reach a width. .fixed-cols now only means "the user has
   set widths of their own", which is what switches the table from the proportional
   defaults to pixels. */
.data-table td, .data-table th { overflow: hidden; }
/* ...except the Tags cell, whose chip editor may grow past it (the pills clip in their own
   span instead). A clipping cell is also a scroll container, and focusing the editor's input
   scrolled the CELL's content under it, so the editor opened shifted up and left. */
.data-table td.c-tags { overflow: visible; }
.data-table thead th { text-overflow: ellipsis; }

.drop-indicator { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); z-index: 20; pointer-events: none; border-radius: 1px; box-shadow: 0 0 6px rgba(0,184,176,0.4); display: none; }
.drop-indicator.show { display: block; }
.label-row.dragging, .bar-row.dragging { opacity: 0.4; }
.label-row[data-id] { cursor: default; }
.labels-col, .timeline-col { -webkit-user-select: none; user-select: none; }
.app { display: flex; flex-direction: column; height: 100vh; }
input[type="file"] { display: none; }

/* ── Text scale ──────────────────────────────────────────────────────────────
   One multiplier drives every size that carries text, and the row geometry that
   has to grow with it, so a bigger typeface does not overflow its row. Sizes stay
   in px and are simply multiplied, which leaves all the existing layout maths
   alone: the JS measures offsetHeight/offsetWidth rather than assuming 34.
   These rules come last on purpose, so they win over the originals above. */
:root {
  --fs: 1;
  --row-h: calc(34px * var(--fs));
  --group-h: calc(28px * var(--fs));
  --bar-h: calc(20px * var(--fs));
  --ms-d: calc(10px * var(--fs));
}
.labels-col { width: var(--label-w, calc(clamp(150px, 24vw, 272px) * var(--fs))); }
.label-row { height: var(--row-h); font-size: calc(12px * var(--fs)); }
.label-row.group { height: var(--group-h); font-size: calc(12px * var(--fs)); }
.label-row.ms { font-size: calc(11px * var(--fs)); }
.tag { font-size: calc(9px * var(--fs)); }
.bar-row { height: var(--row-h); }
.bar-row.group { height: var(--group-h); }
.bar { top: calc((var(--row-h) - var(--bar-h)) / 2); height: var(--bar-h); }
.bar-note, .bar-note-edit { font-size: calc(10px * var(--fs)); }
.q-row, .label-h-q { height: calc(22px * var(--fs)); }
.m-row, .label-h-m { height: calc(24px * var(--fs)); }
.q-cell, .m-cell { font-size: calc(10px * var(--fs)); }
.ms-diamond { width: var(--ms-d); height: var(--ms-d); top: calc((var(--row-h) - var(--ms-d)) / 2); }
.ms-line .ms-cap { font-size: calc(9px * var(--fs)); max-width: calc(132px * var(--fs)); }
.today-tag { font-size: calc(9px * var(--fs)); }
.pdate-tag { font-size: calc(9px * var(--fs)); }
.legend-item { font-size: calc(11px * var(--fs)); }
.tip { font-size: calc(12px * var(--fs)); }
.tip .tip-title { font-size: calc(13px * var(--fs)); }
.tip .tip-dates { font-size: calc(10px * var(--fs)); }
.tip .tip-prov { font-size: calc(10px * var(--fs)); }
.tip .tip-body { font-size: calc(12px * var(--fs)); }
table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * var(--fs)); }
.data-table thead th { font-size: calc(11px * var(--fs)); }
.data-table td.c-owner, .data-table td.c-date { font-size: calc(11px * var(--fs)); }
.data-table .cell-input { font-size: calc(11px * var(--fs)); }

/* The scale control itself never scales: it is chrome, and a control that grew
   as you pressed it would walk away from the pointer. */
/* Last child of the right-aligned .actions group, which is the only position in
   that group that cannot move: its right edge is the header's right padding, and
   everything that hides or changes label (milestone style, theme, the button
   labels under 1460px) is upstream of it. */
.fs-ctl { display: flex; align-items: stretch; height: 24px; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; flex-shrink: 0; background: var(--bg); }
.fs-ctl button { background: none; border: none; color: var(--text2); cursor: pointer; padding: 0; transition: background 0.15s, color 0.15s; display: flex; align-items: center; justify-content: center; }
.fs-ctl button:hover { background: rgba(255,255,255,0.05); color: var(--text); }
.fs-ctl .fs-btn { width: 26px; }
.fs-ctl .fs-btn svg { width: 15px; height: 15px; }
.fs-ctl .fs-btn.fs-big svg { width: 19px; height: 19px; }
.fs-ctl button:disabled { color: var(--text3); cursor: default; background: none; }
/* Fixed width: the readout goes "85%" to "100%" and a reflow here would nudge
   everything downstream of it. */
.fs-ctl .fs-pct { min-width: 46px; font-family: 'JetBrains Mono', monospace; font-size: 10px; border-left: 1px solid var(--border); border-right: 1px solid var(--border); }
.fs-ctl .fs-pct.off-default { color: var(--accent); }

/* ── Sticky axis and section headers (0.31.0) ──────────────────────────────
   The whole gantt body is one synced vertical scroll (the timeline scrolls, the
   label column mirrors its scrollTop), so position:sticky pins the same rows in
   both columns at the same offsets and they stay row-aligned. The axis is sticky
   at the very top; each phase header is sticky just beneath it, and a run of
   sticky siblings gives the iOS behaviour for free: the next section's header
   pushes the current one up as it reaches the top.
   The axis is sticky on TOP only, so a horizontal scroll still slides it sideways
   with the bars and the dates stay over their columns. */
.q-row, .m-row, .label-h-q, .label-h-m { position: sticky; z-index: 10; background: var(--bg); }
.q-row, .label-h-q { top: 0; }
.m-row, .label-h-m { top: calc(22px * var(--fs)); }
.label-row.group, .bar-row.group {
  position: sticky; top: calc(46px * var(--fs)); z-index: 9;
  /* opaque already (var(--surface)); the shadow lifts a pinned header off the
     rows sliding under it without a hard line that would double the row border. */
}
:root[data-view="gantt"] .label-row.group.is-pinned,
:root[data-view="gantt"] .bar-row.group.is-pinned { box-shadow: 0 3px 6px -3px rgba(0,0,0,0.35); }
/* Lifted only inside one synchronous read (unpinnedRead), never painted. Static, not
   relative: relative would keep the sticky's top and shift the header by it. */
.unpinned > .bar-row.group { position: static !important; }
</style>
</head>
<body>
<div class="app">
  <!-- Paper only. The header names the plan once, in the brand block, and puts the title
       and subtitle on its tooltip; a tooltip does not print and appName is often a slug,
       so a printed chart would otherwise not say which plan it is (0.57.0, review). -->
  <div class="print-title" id="printTitle"></div>
  <header id="hdr"></header>
  <div class="legend" id="legend"></div>
  <!-- The dependency-mode info bar used to sit here, between the legend and the chart.
       It was an unstyled block that was empty out of the mode and a sentence in it, so
       entering the mode pushed the whole chart down by a line and leaving it pulled the
       chart back up. Since 0.47.0 the sentence is the Deps button's tooltip and the
       state is the button's glow, so nothing between the legend and the chart moves. -->
  <div class="chart-wrap">
    <div class="labels-col" id="labels"></div>
    <div class="col-resizer" id="colResizer" title="Drag to resize the name column. Double-click to fit the longest name."></div>
    <div class="timeline-col" id="timeline"></div>
  </div>
  <div class="table-view" id="tableView">
    <div class="table-tools" id="tableTools">
      <label for="tblStatus">status</label>
      <!-- The five options are FILLED IN FROM STATUS_ORDER at startup (see
           buildStatusFilter), not written out here. They were written out here until
           0.56.0, which is how this one select came to be the only status surface in the
           app that did not follow the list: reordering the vocabulary moved the panels,
           the pill list, the sort and both analytics cards and left this dropdown behind.
           Only the blank stays in the markup, because "All" is the filter's own idea and
           not one of the five. -->
      <select id="tblStatus">
        <option value="">All</option>
      </select>
      <!-- The Clear button stood here until 0.55.0. It reset all three filters at once,
           which is a button for a state the row already shows you how to leave: the two
           dropdowns have All at the top of them and the text box empties by deleting the
           text in it. One control fewer in a row that is already the busiest strip in the
           app, and nothing that was reachable stopped being reachable. -->
      <input type="search" id="tblSearch" placeholder="Filter by text" aria-label="Filter rows by text">
      <span class="sort-lock-note" id="sortLockNote" style="display:none"></span>
      <span class="table-count" id="tblCount"></span>
      <button class="tt-btn" id="btnCopyIds" style="display:none" title="Copy the selected items' permanent IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button>
    </div>
    <div class="table-scroll">
      <div class="row-drop" id="rowDrop"></div>
      <table class="data-table">
        <thead><tr id="tableHead">
          <th class="sortable" data-sort="uid" title="The row's permanent ID. Click one to copy a link to that row, to paste in Chat">ID</th>
          <th class="sortable" data-sort="name">Task</th>
          <th class="sortable" data-sort="phase">Phase</th>
          <th class="sortable" data-sort="owner">Owner</th>
          <th class="sortable" data-sort="start">Start</th>
          <th class="sortable" data-sort="deadline">Deadline</th>
          <th class="sortable" data-sort="status">Status</th>
          <th class="sortable" data-sort="effort" title="Estimated work in hours. 2d is two 8-hour days, 1w a 40-hour week. Empty means not estimated, and it adds nothing to the Analytics totals">Effort</th>
          <th class="sortable" data-sort="deps" title="Dependencies: what this row needs, and what waits on it. Click a cell to edit them">Deps</th>
          <th class="sortable" data-sort="notes">Notes</th>
          <th class="sortable" data-sort="tags" title="The row's own tags, then its section's (dashed). The legend's tag filter reads both. Click a cell to edit the row's own">Tags</th>
          <th class="sortable" data-sort="prov" title="Where this item came from">Provenance</th>
          <th class="th-del" aria-label="Delete"></th>
        </tr></thead>
        <tbody id="tableBody"></tbody>
      </table>
    </div>
  </div>
  <div class="analytics-view" id="analyticsView">
    <div class="an-scroll" id="anScroll"></div>
  </div>
  <!-- The Setup lens. Static markup like the other three, so an exported snapshot has
       it too; the section list inside it is rebuilt from phases[] by renderSections. -->
  <div class="setup-view" id="setupView">
    <div class="su-scroll" id="suScroll"><div class="su-stack">
      <div class="an-card su-card" id="suDates">
        <div class="su-head"><h3>Project dates</h3><span class="su-n" id="pdSpan"></span></div>
        <p class="an-sub">When the project starts and when it has to be done. The header's Duration and Time Remaining count from these, and the chart marks both with a dashed line and hatches the time outside them, so work that runs past the end is plain to see. Only the plan's owner can change them.</p>
        <div class="chat-row pd-row">
          <label for="pdStart">Start</label><input type="date" id="pdStart">
          <label for="pdEnd">End</label><input type="date" id="pdEnd">
        </div>
        <div class="chat-status" id="pdStatus"></div>
        <ul class="pd-out" id="pdOut"></ul>
      </div>
      <div class="an-card su-card" id="suCal">
        <div class="su-head"><h3>Working calendar</h3><span class="su-n" id="calState"></span></div>
        <p class="an-sub">The days this plan counts as work. Weekends are always off. Untick a federal holiday your team works, and add the weekdays the whole team is off, like a shutdown. Auto-schedule, Critical, Analytics, the header's workdays and the day grain's shading all follow this. Only the plan's owner can change it.</p>
        <div class="cal-hols" id="calHols" role="group" aria-label="Federal holidays this plan takes off"></div>
        <div class="chat-row pd-row cal-add">
          <label for="calNew">Extra day off</label><input type="date" id="calNew">
          <button class="de-btn secondary" id="calAdd" type="button">Add day off</button>
        </div>
        <div class="chat-status" id="calStatus"></div>
        <ul class="cal-extra" id="calExtra"></ul>
      </div>
      <div class="an-card su-card" id="suTags">
        <div class="su-head"><h3>Tags</h3><span class="su-n" id="tagTotal"></span></div>
        <p class="an-sub">Short labels on sections and rows, for finding and filtering them: choose one in the legend to see only the rows that carry it, on themselves or through their section. Make one here, or type it on a section below, on a row in its editor, or in the table's Tags column. Click a swatch to give a tag a colour, click a name to rename it everywhere.</p>
        <div class="sec-add tag-add">
          <input type="text" id="tagNew" placeholder="New tag" maxlength="32" autocomplete="off" aria-label="New tag">
          <button class="de-btn secondary" id="tagAdd">Add tag</button>
        </div>
        <div class="sec-err tag-err" style="display:none"></div>
        <div class="tag-prefix" id="tagPrefix" style="display:none"></div>
        <div class="tag-list" role="list"></div>
      </div>
      <div class="an-card su-card" id="suSections">
        <div class="su-head"><h3>Sections</h3><span class="su-n" id="secTotal"></span></div>
        <p class="an-sub">The lanes of this plan, in the order the chart and the table show them. Drag a row to move it; click a name or an aside to edit it. Every change here is one Ctrl+Z.</p>
        <div class="sec-add">
          <input type="text" id="secNew" placeholder="New section name" maxlength="60" aria-label="New section name">
          <button class="de-btn primary" id="secAdd">Add section</button>
        </div>
        <div class="sec-err" style="display:none"></div>
        <div class="sec-cols" aria-hidden="true"><span class="c-name">Section</span><span class="sec-count">Rows</span><span class="sec-dates">Dates</span><span class="sec-acts" style="visibility:hidden"><span class="sec-ghost">Remove</span></span></div>
        <div class="sec-list" role="list"></div>
      </div>
      <div class="an-card su-card" id="suPeople" style="display:none">
        <div class="su-head"><h3>People</h3><span class="su-n" id="ppState"></span></div>
        <p class="an-sub">Every owner name on this plan and the Adom account it belongs to. A task put on a linked person is asked of them first, and moving the dates of their tasks tells them; a name with no account is just assigned, and is marked with a dotted underline. Names match accounts by display name, username or first name; pick one here when that guesses wrong, or when two people share a first name. Only the plan's owner can change these.</p>
        <div class="pp-list" id="ppList"></div>
      </div>
      <div class="an-card su-card" id="suShare">
        <div class="su-head"><h3>Sharing</h3><span class="su-n" id="shareState"></span></div>
        <p class="an-sub">Who can open this plan. Everyone it lets in can change it, and every change is in the changelog under their name. Only the plan's owner can change this.</p>
        <div class="chat-row">
          <label for="shareMode">Open to</label>
          <select id="shareMode" aria-label="Who can open this plan">
            <option value="me">Only the owner</option>
            <option value="org">Members of an organization</option>
            <option value="any">Anyone signed in to Adom</option>
          </select>
          <select id="shareOrg" aria-label="Which organization" style="display:none"></select>
        </div>
        <div class="chat-status" id="shareStatus"></div>
        <div class="chat-row"><button type="button" class="de-btn secondary" id="sharePreview" title="See the plan the way your team sees it, until you switch back">See what the team sees</button></div>
      </div>
      <div class="an-card su-card" id="suChat">
        <div class="su-head"><h3>Google Chat</h3><span class="su-n" id="chatState"></span></div>
        <p class="an-sub">Changes to this plan are posted to a Google Chat space as digests: grouped by person, in one thread per day. The space's webhook is a credential: only the plan's owner can set it, and once set it is never shown again.</p>
        <div class="chat-row">
          <label for="chatCadence">Post</label>
          <select id="chatCadence" aria-label="How often changes are posted to Google Chat">
            <option value="live">A couple of minutes after each burst of editing</option>
            <option value="hourly">Once an hour</option>
            <option value="daily">Once a day, at 5 pm Central</option>
            <option value="off">Off</option>
          </select>
          <button type="button" class="de-btn secondary" id="chatTest">Send a test message</button>
        </div>
        <div class="chat-row" id="chatHookRow" style="display:none">
          <input type="text" id="chatHook" placeholder="Paste the space's incoming webhook URL" autocomplete="off" spellcheck="false" aria-label="Google Chat incoming webhook URL">
          <button type="button" class="de-btn secondary" id="chatHookSave">Set webhook</button>
          <button type="button" class="de-btn secondary" id="chatHookClear" style="display:none">Remove webhook</button>
        </div>
        <div class="chat-status" id="chatStatus"></div>
      </div>
    </div></div>
  </div>
</div>
<datalist id="ownerList"></datalist>
<datalist id="tagList"></datalist>
<div class="tip" id="tip"></div>
<div class="editor" id="editor"></div>
<div class="dropdown" id="exportMenu"></div>
<div class="req-panel" id="reqPanel" role="dialog" aria-label="Requests"></div>
<div class="dropdown" id="importMenu"></div>
<div class="undo-toast" id="undoToast">Undid change &middot; <kbd>Ctrl+Z</kbd></div>
<div class="conflict-bar" id="conflictBar" role="alert" aria-live="assertive"></div>
<div class="data-warn" id="dataWarn"></div>
<div class="import-report" id="importReport" role="dialog" aria-label="What the import changed"></div>
<div class="save-indicator" id="saveInd">saved</div>
<div class="sel-count" id="selCount"></div>
<input type="file" id="importFile" accept=".json,.csv,.xml">

<script>
(async function() {

// Each grain needs its own minimum cell width, sized to the widest label it draws
// ("31" vs "Mar" vs "Q1" vs "2026"). Below it the timeline scrolls instead of squashing.
const GRAINS = {
  day:     { label:'Days',     min:18 },
  week:    { label:'Weeks',    min:26 },
  month:   { label:'Months',   min:34 },
  quarter: { label:'Quarters', min:48 },
  year:    { label:'Years',    min:56 },
};
let COL = 66;         // month column width, recomputed from the viewport in fitCOL()
const $ = id => document.getElementById(id);
const labelsEl = $('labels'), timelineEl = $('timeline'), tipEl = $('tip');
const editorEl = $('editor'), undoToast = $('undoToast'), saveInd = $('saveInd');
const conflictBar = $('conflictBar');
const selCountEl = $('selCount'), exportMenu = $('exportMenu'), importMenu = $('importMenu');
const importFileEl = $('importFile');

// ── Load data ──
const projData = await fetch('data').then(r => r.json());
const { project, phases, items } = projData;
// A data file may name a row's parent by task id; the page holds uids, as the server's merge
// does (0.79.0). Before baseRowOf, so a catch-up rebuilds the same value.
{ const uidSet=new Set(), byId=new Map();
  items.forEach(r=>{ if(r&&r.uid)uidSet.add(r.uid); if(r&&r.id&&r.uid&&!byId.has(r.id))byId.set(r.id,r.uid); });
  items.forEach(r=>{ if(r&&typeof r.parent==='string'&&r.parent&&!uidSet.has(r.parent)&&byId.has(r.parent))r.parent=byId.get(r.parent); }); }
// Every data-file row as the file has it, before the sidecar touches it (0.77.1): a catch-up
// rebuilds each row as the server has it from this and the server's overrides.
const baseRowOf = new Map();
items.forEach(r => { if (r && r.uid) baseRowOf.set(r.uid, JSON.parse(JSON.stringify(r))); });

// ── Tags (0.59.0) ──
//
// Short strings on a section (phases[].tags) and, from 0.60.0, on a row (items[].tags).
// ONE normaliser, the server's normalizeTags line for line, because a list the page
// shows has to be the list the server will store: trim, drop empties and anything that
// is not a string, turn `;` (the CSV separator) into a space, de-duplicate without regard to case (the first spelling wins), cap
// each tag at 32 characters and the list at 12. An array in gives an array out, possibly
// empty; anything else gives undefined, and a malformed value is dropped, never an error.
// A tag MAY carry a colour (0.61.0), kept in the registry below and set on the Setup page.
// Until 0.61.0 tags had none, so colour on the chart meant the section only; a tag's
// colour is drawn on its chip or pill alone, never on a bar, so a bar still means its section.
const TAG_MAX_LEN = 32, TAG_MAX_COUNT = 12;
function normalizeTags(v) {
  if (!Array.isArray(v)) return undefined;
  const out = [], seen = new Set();
  for (const t of v) {
    if (typeof t !== 'string') continue;
    // `;` is the CSV's tag separator, so it cannot be IN a tag: a `x;y` tag would come
    // back from a CSV round trip as two. It becomes a space (0.60.0 review).
    const s = t.replace(/;/g, ' ').trim().slice(0, TAG_MAX_LEN).trim();
    if (!s || seen.has(s.toLowerCase())) continue;
    seen.add(s.toLowerCase());
    out.push(s);
    if (out.length >= TAG_MAX_COUNT) break;
  }
  return out;
}
// The data file's rule: a non-empty list or no key at all. A snapshot or a data file
// written by hand goes through here too, so the page never holds a list the server
// would have rewritten.
function tidyTags(o){
  if(!o||typeof o!=='object'||!('tags' in o))return;
  const t=normalizeTags(o.tags);
  if(t&&t.length)o.tags=t; else delete o.tags;
}
phases.forEach(tidyTags);
items.forEach(tidyTags);
// ── Subtasks (0.79.0): a row's parent, and its place among its siblings ──
//
// Two fields on a row. `parent` is the uid of the task it sits under ('' or absent: top level
// in its section). `pos` is its place among its siblings: a fractional index key, a string
// that sorts between its two neighbours' keys. A move, a nest or an un-nest is then a change
// to ONE row's own fields, the last writer wins them, and there is no shared list for two
// tabs to disagree about. This is how Figma's multiplayer keeps its layer tree (a parent link
// and a position on each child), and ClickUp keeps an order index on each task. The flat
// `order` is derived from these and still written, for pages and servers that predate them.
//
// The keys are David Greenspan's fractional indexing (public domain, as published in the
// `fractional-indexing` package): base 62, an integer part whose first character says how
// long it is, then a fraction with no trailing zero. "a0" is the first key; appending at the
// end only increments the integer part, so keys stay short. Kept line for line with the
// page's copy, like normalizeTags.
const POS_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const POS_MAX = 200;
// ClickUp's default for nested subtasks: a top-level task, then up to three levels under it.
const TREE_DEPTH_MAX = 3;
function fiMidpoint(a, b) {
  const D = POS_DIGITS, zero = D[0];
  if (b !== null && a >= b) throw new Error(a + ' >= ' + b);
  if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) throw new Error('trailing zero');
  if (b) {
    let n = 0;
    while ((a[n] || zero) === b[n]) n++;
    if (n > 0) return b.slice(0, n) + fiMidpoint(a.slice(n), b.slice(n));
  }
  const da = a ? D.indexOf(a[0]) : 0, db = b !== null ? D.indexOf(b[0]) : D.length;
  if (db - da > 1) return D[Math.round(0.5 * (da + db))];
  if (b && b.length > 1) return b.slice(0, 1);
  return D[da] + fiMidpoint(a.slice(1), null);
}
function fiIntLen(h) {
  if (h >= 'a' && h <= 'z') return h.charCodeAt(0) - 97 + 2;
  if (h >= 'A' && h <= 'Z') return 90 - h.charCodeAt(0) + 2;
  throw new Error('invalid key head ' + h);
}
function fiIntPart(k) { const n = fiIntLen(k[0]); if (n > k.length) throw new Error('invalid key ' + k); return k.slice(0, n); }
function fiCheck(k) {
  if (k === 'A' + POS_DIGITS[0].repeat(26)) throw new Error('invalid key ' + k);
  const i = fiIntPart(k);
  if (k.slice(i.length).slice(-1) === POS_DIGITS[0]) throw new Error('invalid key ' + k);
}
function fiStep(x, up) {
  const D = POS_DIGITS;
  if (x.length !== fiIntLen(x[0])) throw new Error('invalid integer ' + x);
  const head = x[0], digs = x.slice(1).split('');
  let carry = true;
  for (let i = digs.length - 1; carry && i >= 0; i--) {
    const d = D.indexOf(digs[i]) + (up ? 1 : -1);
    if (up ? d === D.length : d === -1) digs[i] = up ? D[0] : D.slice(-1);
    else { digs[i] = D[d]; carry = false; }
  }
  if (!carry) return head + digs.join('');
  if (up) {
    if (head === 'Z') return 'a' + D[0];
    if (head === 'z') return null;
    const h = String.fromCharCode(head.charCodeAt(0) + 1);
    if (h > 'a') digs.push(D[0]); else digs.pop();
    return h + digs.join('');
  }
  if (head === 'a') return 'Z' + D.slice(-1);
  if (head === 'A') return null;
  const h = String.fromCharCode(head.charCodeAt(0) - 1);
  if (h < 'Z') digs.push(D.slice(-1)); else digs.pop();
  return h + digs.join('');
}
function fiBetween(a, b) {
  if (a !== null) fiCheck(a);
  if (b !== null) fiCheck(b);
  if (a !== null && b !== null && a >= b) throw new Error(a + ' >= ' + b);
  if (a === null) {
    if (b === null) return 'a' + POS_DIGITS[0];
    const ib = fiIntPart(b), fb = b.slice(ib.length);
    if (ib === 'A' + POS_DIGITS[0].repeat(26)) return ib + fiMidpoint('', fb);
    if (ib < b) return ib;
    const res = fiStep(ib, false);
    if (res === null) throw new Error('cannot decrement any more');
    return res;
  }
  if (b === null) {
    const ia = fiIntPart(a), fa = a.slice(ia.length), i = fiStep(ia, true);
    return i === null ? ia + fiMidpoint(fa, null) : i;
  }
  const ia = fiIntPart(a), fa = a.slice(ia.length), ib = fiIntPart(b), fb = b.slice(ib.length);
  if (ia === ib) return ia + fiMidpoint(fa, fb);
  const i = fiStep(ia, true);
  if (i === null) throw new Error('cannot increment any more');
  return i < b ? i : ia + fiMidpoint(fa, null);
}
function posOk(k) {
  if (typeof k !== 'string' || !k || k.length > POS_MAX || !/^[A-Za-z][0-9A-Za-z]*$/.test(k)) return false;
  try { fiCheck(k); return true; } catch (e) { return false; }
}
// A key between two others, either of which may be missing (the start, the end) or not a
// key at all. Two neighbours in the wrong order (two tabs gave rows the same place a moment
// ago, and the server has not separated them yet) give a key just after the first.
function keyBetween(a, b) {
  a = posOk(a) ? a : null; b = posOk(b) ? b : null;
  if (a !== null && b !== null && a >= b) b = null;
  try { return fiBetween(a, b); } catch (e) { try { return fiBetween(a, null); } catch (e2) { return fiBetween(null, null); } }
}
// n keys in order between two others, spread so none grows longer than it has to.
function keysBetween(a, b, n) {
  a = posOk(a) ? a : null; b = posOk(b) ? b : null;
  if (a !== null && b !== null && a >= b) b = null;
  if (n <= 0) return [];
  if (n === 1) return [keyBetween(a, b)];
  const out = [];
  if (b === null) { let c = a; for (let i = 0; i < n; i++) { c = keyBetween(c, null); out.push(c); } return out; }
  if (a === null) { let c = b; for (let i = 0; i < n; i++) { c = keyBetween(null, c); out.push(c); } return out.reverse(); }
  const mid = Math.floor(n / 2), c = keyBetween(a, b);
  return keysBetween(a, c, mid).concat([c], keysBetween(c, b, n - mid - 1));
}
// Siblings in their order: by key, a row with no key after every row with one (in the order
// it was given), and two equal keys by uid, so every tab and the server agree.
function sibCmp(a, b) {
  const x = posOk(a.pos) ? a.pos : '', y = posOk(b.pos) ? b.pos : '';
  if (x && y && x !== y) return x < y ? -1 : 1;
  if (x && !y) return -1;
  if (y && !x) return 1;
  if (!x && !y && a._i !== b._i) return a._i - b._i;
  return a.uid < b.uid ? -1 : a.uid > b.uid ? 1 : 0;
}
// The tree, from the rows' own fields. `rows`: {uid, type, phase, parent, pos}, in the order to
// fall back on for rows with no key. A parent that is missing, a milestone, the row itself or
// part of a loop is ignored (a loop is cut at its smallest uid), so any input gives a tree.
// Returns the flat order (sections in list order; each top-level row followed by its subtree,
// depth first), each row's effective parent, depth and section (its top-level ancestor's).
function treeOf(rows, nSections) {
  const by = new Map(), list = [];
  rows.forEach((r, i) => { if (r && r.uid && !by.has(r.uid)) { const o = { uid: r.uid, type: r.type, phase: r.phase, parent: r.parent, pos: r.pos, _i: i }; by.set(r.uid, o); list.push(o); } });
  const par = new Map();
  list.forEach(o => { const p = o.parent; par.set(o.uid, typeof p === 'string' && p && p !== o.uid && by.has(p) && by.get(p).type !== 'milestone' ? p : ''); });
  const seen = new Map();
  list.map(o => o.uid).sort().forEach(u => {
    if (seen.get(u) === 2) return;
    const path = [];
    let x = u;
    while (x && seen.get(x) !== 2) {
      if (seen.get(x) === 1) { const loop = path.slice(path.indexOf(x)); par.set(loop.slice().sort()[0], ''); break; }
      seen.set(x, 1); path.push(x); x = par.get(x);
    }
    path.forEach(y => seen.set(y, 2));
  });
  const kids = new Map(), tops = [];
  list.forEach(o => { const p = par.get(o.uid); if (p) { if (!kids.has(p)) kids.set(p, []); kids.get(p).push(o); } else tops.push(o); });
  kids.forEach(l => l.sort(sibCmp));
  const depth = new Map(), phaseOf = new Map(), order = [];
  const walk = (o, d, ph) => { depth.set(o.uid, d); phaseOf.set(o.uid, ph); order.push(o.uid); (kids.get(o.uid) || []).forEach(k => walk(k, d + 1, ph)); };
  const buckets = Array.from({ length: Math.max(0, nSections) }, () => []), rest = [];
  tops.forEach(o => (Number.isInteger(o.phase) && o.phase >= 0 && o.phase < buckets.length ? buckets[o.phase] : rest).push(o));
  buckets.forEach(b => b.sort(sibCmp).forEach(o => walk(o, 0, o.phase)));
  rest.sort(sibCmp).forEach(o => walk(o, 0, o.phase));
  const kidsOut = new Map(); kids.forEach((l, p) => kidsOut.set(p, l.map(o => o.uid)));
  return { order, parentOf: par, depth, phaseOf, kids: kidsOut };
}
// How many levels sit under a row (0 for a row with no subtasks).
function treeHeight(t, u) { let h = 0; (t.kids.get(u) || []).forEach(k => { h = Math.max(h, 1 + treeHeight(t, k)); }); return h; }
// Is `u` inside the subtree of `anc` (anc itself excluded)?
function treeUnder(t, u, anc) { let x = t.parentOf.get(u), n = 0; while (x && n++ < 10000) { if (x === anc) return true; x = t.parentOf.get(x); } return false; }
// Effort (0.63.0): a task's estimated work in HOURS, the server's normalizeEffort line for
// line. A bar is the window the work happens in, not what it costs: through 0.62 Analytics
// counted every bar as one person full time for each working day of it, and a plan of 154
// eight-week windows came out at 38,000 hours. A task with no effort is "not estimated"
// and adds nothing to any total. 0 is an estimate (no work), not the same as blank.
const EFFORT_MAX=100000;
function normalizeEffort(v){
  if(typeof v==='string'){ if(!v.trim())return undefined; v=Number(v.trim()); }
  if(typeof v!=='number'||!isFinite(v)||v<0||v>EFFORT_MAX)return undefined;
  return Math.round(v*10)/10;
}
function tidyEffort(o){
  if(!o||typeof o!=='object'||!('effort' in o))return;
  const e=normalizeEffort(o.effort);
  if(e===undefined||o.type==='milestone')delete o.effort; else o.effort=e;
}
items.forEach(tidyEffort);
function effortOf(r){ return r&&r.type!=='milestone'&&typeof r.effort==='number'?r.effort:null; }
function setEffort(r,v){ if(v===null||v===undefined)delete r.effort; else r.effort=v; }
// What a person types: "16", "16h", "2d" (two 8-hour days), "1.5w" (a 40-hour week).
// '' is null (not estimated); anything unreadable is NaN, and the caller refuses it.
function parseEffort(str){
  const t=String(str==null?'':str).trim().toLowerCase();
  if(!t)return null;
  const m=t.match(/^(\d+(?:\.\d+)?|\.\d+)\s*(h|hr|hrs|hours?|d|days?|w|wks?|weeks?)?$/);
  if(!m)return NaN;
  const u=m[2]||'h', k=u[0]==='d'?8:u[0]==='w'?40:1;
  const v=normalizeEffort(parseFloat(m[1])*k);
  return v===undefined?NaN:v;
}
function fmtEffort(v){ return v===null||v===undefined?'':(Math.round(v*10)/10).toLocaleString()+'h'; }
const EFFORT_HELP='Hours of work, like 16, or 2d for two 8-hour days, or 1w for a 40-hour week. Leave it empty if nobody has estimated it.';

// ── The tag registry (0.61.0) ──
//
// project.tags: [{name, color?}], the server's normalizeTagRegistry line for line. It is
// where a tag keeps its colour and where a tag nothing carries yet lives, so "New tag" on
// the Setup page has somewhere to put one. A tag on a section or a row that the registry
// does not list is still a tag: the registry adds to the plan's tags, it never gates them.
// A colour is lowercase #rrggbb or absent. `_k` is a client-only identity (like phaseKey),
// so a rename is seen by the changelog diff as a rename and not a delete plus a create; it
// never reaches the disk.
const TAG_REG_MAX=200;
function normTagColor(v){
  if(typeof v!=='string')return '';
  let c=v.trim().toLowerCase(); if(!c)return '';
  if(c[0]!=='#')c='#'+c;
  if(/^#[0-9a-f]{3}$/.test(c))c='#'+c[1]+c[1]+c[2]+c[2]+c[3]+c[3];
  return /^#[0-9a-f]{6}$/.test(c)?c:'';
}
function normalizeTagRegistry(v){
  if(!Array.isArray(v))return undefined;
  const out=[], seen=new Set();
  for(const e of v){
    const raw=typeof e==='string'?e:(e&&typeof e==='object'?e.name:undefined);
    if(typeof raw!=='string')continue;
    const s=raw.replace(/;/g,' ').trim().slice(0,TAG_MAX_LEN).trim();
    if(!s||seen.has(s.toLowerCase()))continue;
    seen.add(s.toLowerCase());
    const o={name:s};
    const c=e&&typeof e==='object'?normTagColor(e.color):'';
    if(c)o.color=c;
    out.push(o);
    if(out.length>=TAG_REG_MAX)break;
  }
  return out;
}
let regKeySeq=0;
const regEntry=e=>Object.assign({},e,{_k:e._k||('t'+(++regKeySeq))});
let tagReg=(normalizeTagRegistry(project&&project.tags)||[]).map(regEntry);
if(project&&'tags' in project){ if(tagReg.length)project.tags=tagReg.map(e=>{ const o={name:e.name}; if(e.color)o.color=e.color; return o; }); else delete project.tags; }
// Does the sidecar carry the registry? Once anything has touched it (or the sidecar already
// had one), yes, `[]` included, because to the server an absent `tags` means "keep the data
// file's" and an emptied registry has to say so.
let regInSidecar=false;

// The row's key, everywhere: in the saved order, in the deletion list, in every
// override, and in the changelog. It is the uid, which is a number the server
// assigned once and that nothing can change.
//
// The two fallbacks below it are the previous two answers to this question, kept
// only for a plan that has not been through the uid assignment. `id` is a slug of
// the row's ORIGINAL name, and `#row<n>` is the row's index in the data file,
// which renumbers the moment anything is inserted above it. That renumbering is
// the bug this version exists to end: it silently re-pointed every saved override
// below an insertion, twice, and the standing workaround was a rule people had to
// remember (append, never insert). A server on this version stamps every row
// before it serves a request, so in practice neither fallback is ever reached.
function assignRowKeys(){
items.forEach((r,i)=>{ if(!r.uid&&!r.id) r._key='#row'+i; });
}
function rowOrderKey(r){ return r.uid || r.id || r._key; }
// A snapshot exported before 0.78.0 carries milestones with no id, so nothing could depend on
// them. It has no server to give them one and it is the only writer of its own copy, so it
// gives them one as it opens, the way the server does: a slug of the name, unique in the
// plan. After the row keys, so no key changes. A live plan's ids come from the server.
function giveSnapshotMilestoneIds(){
  if(!SNAPSHOT)return;
  items.forEach(r=>{ if(r.type==='milestone'&&!r.id)r.id=freshId(r.name); });
}
// Rows the user has deleted, by stable key. Deletion has to be an explicit
// signal rather than an absence: applyOrder is all-or-nothing precisely so a
// stale key can never silently shrink the plan, so "this row is gone on purpose"
// and "this key no longer resolves" must not look the same.
const deletedKeys = new Set();


// Five states. The old vocabulary is aliased on READ, everywhere, so stored data
// (the data file, an example, a sidecar the user is mid-edit in) keeps working
// untouched and only starts writing the new words when it is next saved.
//
// CANCELLED is last on purpose, and it is not a synonym for complete. Complete is
// work that happened; cancelled is work that will not. The plan keeps the row,
// because deleting it would lose why it was ever there and what depended on it, but
// its HOURS stop counting: a cancelled task is not effort anybody owes. Its own
// dates and its own hours are left intact on the row, so the display, the undo and
// the export are unchanged and it is only the analytics accumulators that skip it.
//
// THE ORDER IS THE LIFECYCLE, and 0.56.0 put it back in that order: not started, in
// progress, complete, blocked, cancelled. The first three are the road a row travels
// when nothing goes wrong, so they read in the order they happen; blocked and
// cancelled are the two ways off that road and sit after it. Complete and blocked
// were the other way round until now, which put the ENDING of the happy path after
// one of its exits and made every dropdown, every analytics column and the status
// sort read as though blocked came before finishing.
//
// This list is the single source of that order. It drives the option order in the
// item panel and the create panel, the collation the table's status column sorts by
// (rowSortVal returns the INDEX, so a reorder here is a reorder there), the status
// rows on the analytics status card and the status columns on the per-person card.
// Membership is unchanged, so no stored value means anything different than it did.
const STATUS_ORDER=['not-started','in-progress','complete','blocked','cancelled'];
const STATUS_ALIAS={
  'live':'in-progress','in progress':'in-progress','in_progress':'in-progress',
  'planned':'not-started','not started':'not-started','notstarted':'not-started',
  'done':'complete','completed':'complete',
  // American spelling, and the two phrasings a tracker exports it as.
  'canceled':'cancelled','cancel':'cancelled','abandoned':'cancelled',
  "won't do":'cancelled','wont do':'cancelled','wontdo':'cancelled','dropped':'cancelled',
};
function statusKey(v){
  const t=String(v==null?'':v).trim().toLowerCase();
  if(!t)return '';
  if(STATUS_ORDER.includes(t))return t;
  return STATUS_ALIAS[t]||t;      // unknown values survive as themselves
}
function statusOf(r){ return statusKey(r&&r.status); }
const statusTag={'not-started':'tag-not-started','in-progress':'tag-in-progress',blocked:'tag-blocked',complete:'tag-complete',cancelled:'tag-cancelled'};
const statusLabel={'not-started':'not started','in-progress':'in progress',blocked:'blocked',complete:'complete',cancelled:'cancelled'};
// Work that will not happen. Asked everywhere a number is being added up, so the one
// rule lives in one place: a cancelled row keeps its dates and its own hours and is
// simply not counted into anybody's total.
function isCancelled(r){ return statusOf(r)==='cancelled'; }
// The hours a task adds to the Analytics totals: its Effort estimate (0.63.0), or nothing
// when it has none or is cancelled. Through 0.62 this was the bar's working days x 8.
function effortHoursOf(r){ return isCancelled(r)?0:(effortOf(r)||0); }
// A live task nobody has estimated yet: counted, so the totals say how complete they are.
function unestimated(r){ return !isMs(r)&&!isCancelled(r)&&!!r.start&&!!r.end&&effortOf(r)===null; }
function statusTagFor(k){ return statusTag[k]||'tag-other'; }
function statusTextFor(k){ return statusLabel[k]||k; }
// Rows the user created. The app never rewrites the data file, so a new row has
// nowhere to live but the sidecar, and it has to be merged back by both the
// client and the server or an export would not contain it.
const addedItems = [];
// A created row needs a key that cannot collide with a data-file key now OR
// after the data file grows. #row<n> is the data file's namespace, so created
// rows get their own: #new-<base36 time>-<counter>. A created TASK keys on its
// id anyway; this matters for created milestones, which have none.
let newKeyN = 0;
function freshKey(){ return '#new-'+Date.now().toString(36)+'-'+(newKeyN++); }
// ── uid, the durable handle ──
//
// A task id is a slug of the name the row had when it was made, so it reads as a
// lie the moment the row is renamed. A group or a milestone has no id at all and
// rides `#row<n>`, its original index in the data file, which renumbers whenever
// the file is restructured. `uid` is a number, it means nothing, and nothing that
// happens to the row can change it, which is exactly what a batch edit needs to
// quote back.
//
// It is NEVER minted here. The server owns the counter and hands numbers out one
// at a time from POST /uid, so two tabs creating a row in the same second cannot
// invent the same one. If the call fails the create is refused and says so: a
// row you have to add again is a smaller problem than two rows sharing a handle.
const UID_WIDTH=5;
function fmtUid(n){ return String(n).padStart(UID_WIDTH,'0'); }
// A snapshot is a single file with no server behind it and nothing else writing
// to it, so it is the one place a number can be made locally without a race. It
// carries on from the counter the plan was exported with.
let snapUidN=null;
function snapshotUid(){
  if(snapUidN===null){ const n=Math.floor(Number(project&&project.nextUid)); snapUidN=(Number.isFinite(n)&&n>0)?n:1; }
  return fmtUid(snapUidN++);
}
async function allocUid(){
  if(SNAPSHOT)return snapshotUid();
  const r=await fetch('uid',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
  if(!r.ok)throw new Error('the server answered '+r.status);
  const j=await r.json();
  if(!j||!j.ok||!j.uid)throw new Error((j&&j.error)||'no ID came back');
  return j.uid;
}
function slugify(t){ return String(t||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,40)||'item'; }
function freshId(name){
  const base=slugify(name); let id=base, n=2;
  while(items.some(r=>r.id===id))id=base+'-'+(n++);
  return id;
}

// A row with no usable date used to take the whole chart down with it: parseISO
// split an undefined during the build, the exception escaped, and one bad row
// blanked all 72 good ones with no way back except hand-editing the data file.
// Drop the row, keep the plan, and say so out loud.
function isDateStr(s){ return typeof s==='string' && /^\d{4}-\d{1,2}(-\d{1,2})?$/.test(s); }
function rowDatesOk(r){
  if(!r||r.type==='group')return true;
  if(r.type==='milestone')return isDateStr(r.date);
  return isDateStr(r.start)&&isDateStr(r.end);
}
// Keys are assigned above, over the FULL data-file array, before anything is
// dropped, so the surviving rows keep the keys the server computes for them.
const UNDATED=items.filter(r=>!rowDatesOk(r));
function dropUndatedRows(){
if(UNDATED.length){ for(let i=items.length-1;i>=0;i--){ if(!rowDatesOk(items[i]))items.splice(i,1); } }
document.title = `${project.title} · adom-gantt`;
}

// ── View preferences (theme, milestone style, start-date override) ──
const PREF_KEY = 'adom-gantt:prefs';
// A snapshot opens as its author curated it, so it ignores whatever the viewer happens
// to have in localStorage from another chart. The export flips this to true.
// One exception since 0.57.0, the text size: see READER_PREFS below.
const SNAPSHOT = false;
// Substituted by the server from package.json, so the header cannot drift from
// the released version. Opening this file straight off disk leaves the token in
// place, which is why it is checked before being shown.
const RAW_VERSION = '__APP_VERSION__';
// The signed-in identity, substituted by the server from the session the same way
// the version is. Opening this file straight off disk leaves the placeholder, which
// is a string, so it is shape-checked rather than trusted.
const RAW_USER = "__ADOM_USER__";
const ADOM_USER = (RAW_USER && typeof RAW_USER === 'object') ? RAW_USER : null;
const USER_NAME = ADOM_USER ? (ADOM_USER.displayName || ADOM_USER.username || '') : '';
const APP_VERSION = /^[0-9]/.test(RAW_VERSION) ? RAW_VERSION : '';

// project.view is the author's intended default view. It applies everywhere the chart
// is served fresh: the HTML export, the server render, and any browser with no saved
// preference. In the live app a preference the user has actually set still wins.
const VIEW_LIST = ['gantt','table','analytics','setup'];
function viewDefaults(){
  const v = (project && project.view) || {};
  const d = {};
  if (v.grain && ['day','week','month','quarter','year'].includes(v.grain)) d.granularity = v.grain;
  // "both" is spelled out because it is the only one of the three that is neither a
  // line nor a diamond; anything else the author wrote still falls back to diamonds.
  if (v.milestones) d.msStyle = /^both$/i.test(v.milestones) ? 'both'
                             : /^lines?$/i.test(v.milestones) ? 'line' : 'diamond';
  if (v.today !== undefined) d.showToday = !!v.today;
  if (v.theme === 'light' || v.theme === 'dark') d.theme = v.theme;
  if (v.labelWidth) d.labelW = Math.max(120, Math.min(760, +v.labelWidth || 0)) || undefined;
  // Which lens the plan opens in. An owner-and-deadline plan can be authored to
  // open as the table, the same way a chart can be authored to open in days. Any
  // lens, since 0.58.0: through 0.57.1 this only knew the first two, so a plan
  // authored to open on its totals silently opened on the chart instead.
  if (VIEW_LIST.includes(v.mode)) d.view = v.mode;
  return d;
}
// Prefs that describe the READER rather than the plan. A snapshot still opens at its
// author's grain, theme, lens and collapse state, but the size of the text is about
// the eyes looking at it, so it is the one thing a snapshot takes from, and gives back
// to, the reader's own storage.
//
// Why this exists (0.57.0). Drew set a larger text size and lost it on every refresh.
// Two causes, both reproduced in Chrome before anything here was changed:
//  1. In an exported snapshot savedPrefs() returned {} outright, so A+ wrote fs to
//     localStorage and the next load never read it back. 120% became 100% on EVERY
//     refresh, which is the report exactly.
//  2. In the live app the key is one per browser origin, shared by every tab, and
//     savePrefs() writes the WHOLE prefs object. A second tab that was opened before
//     the size changed still held the old fs, and the next time it saved anything at
//     all (a grain change, a collapse, the theme) it wrote that old size back over the
//     new one. The refresh then read the stale value. The storage listener in
//     wireFontScale() keeps every open tab's fs current, so no tab holds a stale one.
const READER_PREFS = ['fs'];
function storedPrefs(){
  // An array is an object too, and a hand-edited or foreign value that parsed to one
  // would take the merge below silently (keys set on an array vanish from its JSON).
  try { const o = JSON.parse(localStorage.getItem(PREF_KEY)); return (o && typeof o === 'object' && !Array.isArray(o)) ? o : {}; } catch { return {}; }
}
// A snapshot on a page that SANDBOXES it (0.58.0). The wiki serves an uploaded HTML
// file under `Content-Security-Policy: sandbox` with no allow-same-origin, which is the
// right call for user content on a site with sessions: the page gets an OPAQUE origin.
// Storage works for as long as the page lives, and every reload is a new opaque origin
// with new, empty storage, so a text size set there was gone on the next refresh. That is
// exactly what Drew saw on wiki.adom.inc, on a 0.57.1 snapshot, after the storage fix.
// So a snapshot keeps its reader prefs in the URL FRAGMENT as well (`#fs=1.2`, written
// with replaceState so nothing navigates and no history entry is made). A reload keeps
// the fragment, and so do a bookmark and a shared link. Storage still wins where it
// works: a reader with a size of their own keeps it when somebody sends them a link
// carrying the sender's. The fragment is the answer only where storage has none.
function fragmentPrefs(){
  const out = {};
  if (!SNAPSHOT) return out;
  try {
    const h = String(location.hash || '').replace(/^#/, '');
    h.split('&').forEach(kv => {
      const i = kv.indexOf('='); if (i < 0) return;
      const k = decodeURIComponent(kv.slice(0, i)), v = decodeURIComponent(kv.slice(i + 1));
      if (READER_PREFS.indexOf(k) < 0) return;
      if (k === 'fs') { const f = parseFloat(v); if (isFinite(f)) out.fs = f; }
    });
  } catch {}
  return out;
}
function writeFragmentPrefs(){
  if (!SNAPSHOT) return;
  try {
    const parts = [];
    READER_PREFS.forEach(k => { if (prefs[k] !== undefined) parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(String(prefs[k]))); });
    const hash = parts.length ? '#' + parts.join('&') : '';
    if ((location.hash || '') === hash) return;
    if (history && history.replaceState) history.replaceState(null, '', location.pathname + location.search + hash);
    else location.hash = hash;
  } catch {}
}
function savedPrefs(){
  const all = storedPrefs();
  if (!SNAPSHOT) return all;
  const out = {};
  READER_PREFS.forEach(k => { if (all[k] !== undefined) out[k] = all[k]; });
  return Object.assign(fragmentPrefs(), out);
}
// Seven steps, 85% to 150%. Discrete so the readout is a round number and the
// step buttons have somewhere definite to land.
const FS_STEPS = [0.85, 0.925, 1, 1.1, 1.2, 1.35, 1.5];
const prefs = Object.assign(
  { theme: 'dark', msStyle: 'line', granularity: 'month', showToday: true, view: 'gantt', fs: 1, collapsed: [],
    critMode: 'off', tagFilter: [], legendSecs: false },
  viewDefaults(),
  savedPrefs()
);
// A hand-edited or stale localStorage value must not put the app at 40%.
function clampFontScalePref(){
if (FS_STEPS.indexOf(prefs.fs) < 0) prefs.fs = 1;
}
// The three milestone styles, in the order the button cycles them. A hand-edited or
// stale localStorage value lands on the first one rather than on a mode with no CSS.
const MS_STYLES = ['line', 'diamond', 'both'];
function clampMsStylePref(){
if (MS_STYLES.indexOf(prefs.msStyle) < 0) prefs.msStyle = 'line';
}
// The two critical-path states, and they are a PREFERENCE for exactly the reason the
// milestone style is one: it is a way of looking at the plan rather than a change to it,
// so a reader who left the longest path lit should find it lit when they come back.
//
// 0.49.0 shipped THREE: off, one path for the whole plan, one path per section. 0.50.0
// drops the plan-wide one. On a real plan it answers a question nobody was asking, because
// a plan is read section by section and the plan-wide winner is usually one section's chain
// with the rest of the chart dark; the per-section answer contains it and says more. With
// one live state left there is nothing for a label to name, so the button is a plain toggle
// and its .on treatment carries the whole of the state.
//
// MIGRATION. A browser that used the plan-wide mode has 'global' in its saved prefs, and
// that reader had the glow ON, so they get the glow: 'global' lands on 'phase' rather than
// on off, which is the state closest to the one they chose. Anything else hand-edited or
// stale still lands on off, the same clamp this has always had.
const CRIT_MODES = ['off', 'phase'];
function clampCritModePref(){
if (prefs.critMode === 'global') prefs.critMode = 'phase';
if (CRIT_MODES.indexOf(prefs.critMode) < 0) prefs.critMode = 'off';
}
// Is anything lit at all. Everything that used to test the old `critOn` flag asks THIS,
// so there is still exactly one answer to "is the glow up" and the mode itself is the
// only state there is.
// The team cannot see the Critical button (0.74.0), so a mode saved before it was hidden
// would stay on with no way to turn it off (0.75.1): for them it is off.
function critOn(){ return prefs.critMode !== 'off' && document.documentElement.dataset.role !== 'member'; }
// Which halves the current style is showing. Everything that measures or lays out the
// dashed rule and its cap asks this rather than testing for 'line', because 'both'
// shows them too and a test for one name would have left the caps unplaced there.
function msShowsLine(){ return prefs.msStyle !== 'diamond'; }
// A snapshot writes back only the reader prefs, merged into whatever is stored. It
// used to write everything, which it then never read, and on a shared origin (every
// file:// snapshot is one) that overwrote the live app's grain, collapse set and lens
// with whatever the snapshot's author had chosen.
// MERGE ON WRITE (0.57.0, after review). savePrefs used to write the whole prefs
// object, so any open tab overwrote every pref with whatever it happened to hold: the
// text size (cause 2 in READER_PREFS) but equally the collapse set, the theme and the
// grain. A review reproduced it with the collapse set: Collapse all in one tab, toggle
// the theme in another, refresh the first, every lane open again.
//
// Now a save writes only the keys THIS tab changed since it last read or wrote them,
// into a fresh read of what is stored, so a newer value another tab wrote under any
// other key survives. prefBase holds, per key, the JSON this tab last read or wrote.
// A snapshot is the same with the keys limited to the reader prefs.
// Everything as loaded counts as already saved; only what changes from here is written.
// Re-taken at the end of startup (startApp), so the clamps and the in-memory critical
// path drop there are corrections for this page, not choices to write back. prefs is
// declared above this line, so the initialiser can read it.
function prefsAsBase(){ const b={}; Object.keys(prefs).forEach(k=>{ b[k]=JSON.stringify(prefs[k]); }); return b; }
let prefBase=prefsAsBase();
function rebasePrefs(){ prefBase=prefsAsBase(); }
// Two things a review of the merge found. The baseline moves only AFTER the write has
// landed: moved before it, a write the browser refused (quota, blocked storage) was
// marked as saved and never tried again, which lost a text size exactly the way this
// release set out to stop. And a store that is empty or unreadable (cleared, or garbage
// from somewhere else) holds nothing newer to protect, so this tab writes everything it
// has rather than one key, and a refresh opens the way the tab looked.
function prefStoreUsable(){
  try { const o = JSON.parse(localStorage.getItem(PREF_KEY)); return !!o && typeof o === 'object' && !Array.isArray(o); } catch { return false; }
}
function savePrefs(){
  try {
    const whole = !prefStoreUsable();
    const all = storedPrefs();
    const next = {};
    let dirty = false;
    Object.keys(prefs).forEach(k => {
      if (SNAPSHOT && READER_PREFS.indexOf(k) < 0) return;
      const j = JSON.stringify(prefs[k]);
      if (j === undefined) return;
      if (j === prefBase[k] && !whole) return;
      if (j !== prefBase[k]) dirty = true;
      all[k] = prefs[k]; next[k] = j;
    });
    if (!dirty) return;
    writeFragmentPrefs();
    localStorage.setItem(PREF_KEY, JSON.stringify(all));
    Object.assign(prefBase, next);
  } catch {}
}
// A stored value the clamps had to correct (a size that is not a step, a milestone
// style with no CSS, the retired plan-wide critical mode) is corrected in storage too,
// once, at startup. Only a value that is INVALID is rewritten: a valid one another tab
// wrote is newer than this page and is left alone, and so is the critical mode this
// page dropped in memory because its plan has no dependencies.
function healStoredPrefs(){
  try {
    if (!prefStoreUsable()) return;
    const all = storedPrefs();
    let dirty = false;
    const fix = (k, ok, v) => { if (all[k] !== undefined && !ok(all[k])) { all[k] = v; dirty = true; } };
    fix('fs', v => FS_STEPS.indexOf(v) >= 0, FS_STEPS.indexOf(prefs.fs) >= 0 ? prefs.fs : 1);
    if (!SNAPSHOT) {
      fix('msStyle', v => MS_STYLES.indexOf(v) >= 0, 'line');
      fix('critMode', v => CRIT_MODES.indexOf(v) >= 0, all.critMode === 'global' ? 'phase' : 'off');
    }
    if (dirty) localStorage.setItem(PREF_KEY, JSON.stringify(all));
  } catch {}
}
function applyTheme(){ document.documentElement.setAttribute('data-theme', prefs.theme); }
function applyStartupPrefs(){
applyTheme();
// The critical-path mode is on the root the same way the theme and the milestone style
// are, so the state is readable from the document rather than only from a closure.
document.documentElement.setAttribute('data-crit', prefs.critMode);
// Both set before the chart is built: the view so a plan that opens as a table
// never measures a hidden timeline, the scale so the first paint is already at
// the size the user chose and nothing visibly resettles. The header buttons do
// not exist yet on either of these first calls.
applyView();
applyFontScale();
}

// ── Collapse a lane ──
//
// View state, kept in prefs beside theme and grain: it is how one person is
// looking at the plan, not a fact about the plan. If it should ever be shared it
// gets promoted to the sidecar, and the UI says so.
const collapsed=new Set((prefs.collapsed||[]).filter(n=>typeof n==='number'));
let transientCollapse=null;          // set while a lane is being dragged
function isCollapsed(ph){ return collapsed.has(ph); }
function laneRows(ph){ return items.filter(r=>r.type!=='group'&&r.phase===ph); }
function applyCollapse(){
  const ks=tagFilterKeys();
  // Subtasks (0.79.0). A folded task hides everything under it. Under a filter, a task that
  // does not match but has a subtask that does stays on screen, dimmed, so the subtask is not
  // shown out of context; it is not counted (ClickUp's assignee filter brings the parent up
  // the same way).
  const foldedBy=new Set(), below=new Set(), vf=viewFilterOn();
  if(collapsedRows.size)tree.order.forEach(k=>{ const p=tree.parentOf.get(k); if(p&&(collapsedRows.has(p)||foldedBy.has(p)))foldedBy.add(k); });
  if(vf)for(let i=tree.order.length-1;i>=0;i--){ const k=tree.order[i], x=treeRows.get(k), p=tree.parentOf.get(k); if(x&&p&&(below.has(k)||rowShown(x,ks)))below.add(p); }
  items.forEach(r=>{
    if(r.type==='group'){
      // A lane the tag filter leaves out goes whole, header included, through the same
      // class and flag a collapsed row uses, so every piece of geometry that already
      // skips a collapsed row (the arrows, the caps, the tail room) skips it too.
      const out=!phaseShown(r.phase,ks);
      r._collapsed=out;
      if(r._lDiv)r._lDiv.classList.toggle('row-collapsed',out);
      if(r._bDiv)r._bDiv.classList.toggle('row-collapsed',out);
      const on=isCollapsed(r.phase);
      if(r._laneBtn){
        r._laneBtn.classList.toggle('collapsed',on);
        const all=laneRows(r.phase), n=all.length;
        // With a tag filter hiding some of this lane's rows, the count says so ("2 of 3"),
        // or a header over two rows reading 3 looks like a row went missing (0.60.0 review).
        const shown=viewFilterOn()?all.filter(x=>rowShown(x,ks)).length:n;
        r._laneBtn.querySelector('.lane-count').textContent=shown<n?shown+' of '+n:String(n);
        r._laneBtn.title=(on?'Show':'Hide')+' the '+(shown<n?shown+' row(s) the tag filter shows, of '+n+',':n+' row(s)')+' in this lane. This is your view only, it does not change the plan.';
        r._laneBtn.setAttribute('aria-expanded',on?'false':'true');
      }
      return;
    }
    const k=rowTreeKey(r), shown=rowShown(r,ks), ctx=!shown&&below.has(k);
    const hide=isCollapsed(r.phase)||foldedBy.has(k)||(!shown&&!ctx);
    r._collapsed=hide; r._ctx=ctx;
    if(r._lDiv)r._lDiv.classList.toggle('ctx-dim',ctx);
    if(r._bDiv)r._bDiv.classList.toggle('ctx-dim',ctx);
    const od=isOverdue(r);
    if(r._lDiv)r._lDiv.classList.toggle('is-overdue',od);
    if(r._bDiv)r._bDiv.classList.toggle('is-overdue',od);
    if(r._lDiv)r._lDiv.classList.toggle('row-collapsed',hide);
    if(r._bDiv)r._bDiv.classList.toggle('row-collapsed',hide);
    // the dashed rule belongs to the milestone, so it goes with it
    if(r._msLine)r._msLine.classList.toggle('row-collapsed',hide);
  });
  syncCollapseAllBtn();
  paintSpans();
}
// ── Collapse or expand every lane at once (0.57.0) ──
//
// The same collapse a lane's chevron does, applied to all of them: the same Set, the
// same prefs.collapsed, the same applyCollapse, so the result persists exactly as a
// hand-collapsed lane does and the table (which reads isCollapsed) agrees with the
// chart. It is a view setting, like the chevron, so it is not an undo step.
//
// The mixed state has one answer: if ANY lane that has rows is open, the button
// collapses everything; only when every such lane is already shut does it expand
// them all. So one press from any state gives a tidy outline, and a second press
// always gets the whole plan back. A lane with no rows has nothing to hide and does
// not get a vote, or an empty section would pin the button on "Collapse all".
const collapseAllBtn=document.createElement('button');
// Only the lanes on screen vote: with a tag filter on, "Collapse all" is about the
// sections the reader is looking at, and the hidden ones must not pin it on either label.
function lanesWithRows(){ const ks=tagFilterKeys(); return phases.map((p,i)=>i).filter(i=>phaseShown(i,ks)&&laneRows(i).some(r=>rowShown(r,ks))); }
function allLanesCollapsed(){ const l=lanesWithRows(); return l.length>0&&l.every(isCollapsed); }
function syncCollapseAllBtn(){
  if(!collapseAllBtn.isConnected)return;
  const lanes=lanesWithRows(), all=allLanesCollapsed();
  collapseAllBtn.classList.toggle('is-all',all);
  collapseAllBtn.querySelector('.ca-label').textContent=all?'Expand all':'Collapse all';
  collapseAllBtn.setAttribute('aria-pressed',all?'true':'false');
  collapseAllBtn.disabled=!lanes.length;
  collapseAllBtn.title=!lanes.length?'No section has any rows to collapse yet.'
    :all?`Expand all ${plural(lanes.length,'section')}. This is your view only, it does not change the plan.`
    :`Collapse all ${plural(lanes.length,'section')} to their headers. This is your view only, it does not change the plan.`;
}
function setAllCollapsed(on){
  const mark=placeMark();
  // With a tag filter on, "all" is the sections on screen: the hidden ones keep whatever
  // they were, so clearing the filter does not reveal lanes folded by a press that never
  // showed them.
  if(viewFilterOn())phases.forEach((p,i)=>{ if(phaseShown(i)){ if(on)collapsed.add(i); else collapsed.delete(i); } });
  else { collapsed.clear(); if(on)phases.forEach((p,i)=>collapsed.add(i)); }
  prefs.collapsed=[...collapsed];
  // Tasks with subtasks fold and unfold with them (0.79.0), so Expand all shows every row.
  collapsedRows.clear(); if(on)items.forEach(r=>{ if(!isDerivedGroup(r)&&treeHasKids(r))collapsedRows.add(rowTreeKey(r)); });
  saveFolds();
  refreshTreeDom();
  applyCollapse();
  layoutMsCaps();sizeTailroom();drawDeps();
  keepPlace(mark);
  renderTable();
  depHoverResync();
}
function buildLegendCollapseAll(){
  collapseAllBtn.type='button';
  collapseAllBtn.id='btnCollapseAll';
  collapseAllBtn.className='legend-item collapse-all';
  collapseAllBtn.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 9l5 5 5-5"/></svg><span class="ca-label">Collapse all</span>';
  collapseAllBtn.addEventListener('click',()=>setAllCollapsed(!allLanesCollapsed()));
  legendEl.appendChild(collapseAllBtn);
  syncCollapseAllBtn();
}
function setCollapsed(ph,on,persist){
  if(on)collapsed.add(ph); else collapsed.delete(ph);
  if(persist!==false){ prefs.collapsed=[...collapsed]; savePrefs(); }
  applyCollapse();
  layoutMsCaps();sizeTailroom();drawDeps();
  if(prefs.view==='gantt')updatePinnedHeader();
}
// Fold or unfold one task's subtasks (0.79.0), holding the task where it is drawn, the way a
// section's chevron does.
function toggleRowFold(r){
  if(Math.abs(labelsEl.scrollTop-timelineEl.scrollTop)>0.5)timelineEl.scrollTop=labelsEl.scrollTop;
  const y=prefs.view==='gantt'&&r._lDiv?r._lDiv.getBoundingClientRect().top:null;
  setRowFolded(r,!collapsedRows.has(rowTreeKey(r)));
  refreshTreeDom(); applyCollapse(); layoutMsCaps(); sizeTailroom(); drawDeps();
  if(prefs.view==='gantt')updatePinnedHeader();
  if(y!==null)keepPlace({r,i:items.indexOf(r),y:y-timelineEl.getBoundingClientRect().top});
  if(prefs.view==='table')renderTable();
  depHoverResync();
}
// Alt+Right puts a row under the row just above it among its siblings, as that task's last
// subtask; Alt+Left takes it out, to straight after the task it was under (0.79.0). ClickUp's
// Tab and Shift+Tab; here Tab walks fields, so it is Alt with the arrows, beside Alt+Up and
// Alt+Down. Returns why it could not, or ''.
function indentPlace(r,dir){
  if(!r||isDerivedGroup(r))return 'no row';
  if(dir>0){
    const p=sibNext(r,-1);
    if(!p)return 'nothing above it to go under';
    if(p.type==='milestone')return 'a milestone cannot hold subtasks';
    if(!canNestUnder([r],p))return 'subtasks go '+TREE_DEPTH_MAX+' levels deep at most';
    placeRowsAt([r],p,p.phase,lastKid(p,new Set([r])));
    if(collapsedRows.has(rowTreeKey(p)))setRowFolded(p,false);
    return '';
  }
  const pk=treeParent(r); if(!pk)return 'already a top-level row';
  const p=treeRows.get(pk), gp=p&&treeParent(p)?treeRows.get(treeParent(p)):null;
  if(!p)return 'no row';
  placeRowsAt([r],gp,p.phase,p);
  return '';
}
function indentRow(r,dir){ return indentRows([r],dir); }
function indentRows(rows,dir){
  const snap=placeSnap(rows);
  // Into the row above: top to bottom, so each lands after the one before it. Out: bottom to
  // top, so each lands straight after the parent and above the ones that went before it.
  const seq=dir>0?rows.slice():rows.slice().reverse();
  let why='', done=0;
  for(const r of seq){ const w=indentPlace(r,dir); if(w){ why=why||w; continue; } done++; placeHeaders(); }
  if(!done){ flash(saveInd,why,3200); return false; }
  pushUndo(snap);
  layoutRows(); renderTable();
  if(prefs.view==='gantt')refreshSelVisuals();
  moveHint=rows.length===1?'"'+(rows[0].name||'a row')+'"':rows.length+' rows';
  scheduleSave(dir>0?'indent':'outdent');
  return true;
}
function indentFocusedRow(dir){
  const table=prefs.view==='table';
  if(table&&!manualOrder()){ flash(saveInd,'sorted, so rows cannot be nested here. Back to plan order to nest them',3200); return; }
  const rows=selected.size>1?treeRoots(selectedRows()):[table?rowItem(focusedKey):soleSelectedRow()].filter(Boolean);
  if(!rows.length){ flash(saveInd,table?'click a cell first, then Alt+Right and Alt+Left nest that row':'select a task first'); return; }
  indentRows(rows,dir);
  if(table&&rows.length===1)setFocusedRow(rowKey(rows[0]));
}
function wireLaneToggle(){
labelsEl.addEventListener('click',e=>{
  // Subtasks (0.79.0): the chevron that folds a task's subtasks, and the two quick adds.
  const tog=e.target.closest('button.sub-tog');
  if(tog){ e.stopPropagation(); const r=items.find(x=>x._lDiv===tog.closest('.label-row')); if(r&&treeHasKids(r))toggleRowFold(r); return; }
  const ra=e.target.closest('.row-add');
  if(ra){ e.stopPropagation(); const r=items.find(x=>x._lDiv===ra.closest('.label-row')); if(r)openQuickAdd({parent:r}); return; }
  const la=e.target.closest('.lane-add');
  if(la){ e.stopPropagation(); const g=items.find(x=>x._lDiv===la.closest('.label-row')); if(g)openQuickAdd({phase:g.phase}); return; }
  const btn=e.target.closest('.lane-toggle');
  if(!btn)return;
  e.stopPropagation();
  const g=items.find(r=>r._laneBtn===btn);
  if(g){
    // The chevron stays under the pointer (0.74.1). Folding the section whose header is
    // pinned slid the next header up over it, and the chevron that would open it again
    // was gone. The header is held where it is drawn, so it lands at its own place.
    // The names may have just scrolled on their own (focus, a find) and the click landed
    // where they are, so the bars catch up before anything is measured.
    if(Math.abs(labelsEl.scrollTop-timelineEl.scrollTop)>0.5)timelineEl.scrollTop=labelsEl.scrollTop;
    const y=prefs.view==='gantt'&&g._lDiv?g._lDiv.getBoundingClientRect().top:null;
    setCollapsed(g.phase,!isCollapsed(g.phase));
    if(y!==null)keepPlace({r:g,i:items.indexOf(g),y:y-timelineEl.getBoundingClientRect().top});
  }
  // A lane folding away above the pointer slides a different row under it without
  // moving it, which is the same stale-row problem a scroll causes and the same answer.
  depHoverResync();
});
}

// ── Date range ──
// The timeline is derived from the data every time, so an edit that moves the first or
// last date reshapes the axis for free.
function isoDay(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }
function isoMonth(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; }
// Undateable rows are dropped at load, so this should never see junk. It is
// defensive anyway: a throw in here used to blank the entire chart.
function parseISO(ds){ const[y,m,d]=String(ds==null?'':ds).split('-').map(Number); return new Date(y||1970,(m||1)-1,d||1); }

const PHASE_MONTH_RE=/^\d{4}-\d{2}/;
function phaseHasRange(p){ return !!p&&PHASE_MONTH_RE.test(String(p.start||''))&&PHASE_MONTH_RE.test(String(p.end||'')); }
function scanRange(){
  let minD='9999-12-31', maxD='0000-01-01';
  items.forEach(r=>{ if(r.start&&r.start<minD)minD=r.start; if(r.end&&r.end>maxD)maxD=r.end;
                     if(r.date&&r.date<minD)minD=r.date; if(r.date&&r.date>maxD)maxD=r.date; });
  // A section's own month range widens the axis only when it HAS one. A section made in
  // Setup is stored with start and end blank, and '' + '-01' sorts before every real
  // date, so until 0.58.0 one new section put the axis at 1970 after a reload and every
  // bar off screen. Blank or malformed means "no range of its own": what the section
  // spans is where its rows are, which the rows already count above. The server's
  // readers (SVG, CSV) already skip such a phase the same way.
  phases.forEach(p=>{ if(!phaseHasRange(p))return;
                      const ps=p.start+'-01'; const[ey,em]=p.end.split('-').map(Number);
                      const pe=`${ey}-${String(em).padStart(2,'0')}-28`;
                      if(ps<minD)minD=ps; if(pe>maxD)maxD=pe; });
  // The project's dates are on the axis too, so both dashed lines are always on it.
  if(planDates.start&&planDates.start<minD)minD=planDates.start;
  if(planDates.end&&planDates.end>maxD)maxD=planDates.end;
  return {minD,maxD};
}
// Earliest real task/milestone date, which is what the start control edits.
function planStart(){
  let m=null;
  items.forEach(r=>{ const d=r.start||r.date; if(d&&(!m||d<m))m=d; });
  return m;
}
// ── Project dates (0.75.0) ──
// The project's own start and end, set by the owner on Setup: the window the work has
// to fit in. The header's Duration and Remaining count from them, and the chart draws a
// dashed line at each with the time outside them hatched, so a row that runs past the
// end is plain to see. Blank is "not set", and that end of the readouts falls back to
// the rows. The data file may carry them as project.start / project.end; the sidecar's
// `dates` wins, the way its tag registry does.
const cleanDay=v=>typeof v==='string'&&/^\d{4}-\d{2}-\d{2}$/.test(v)&&!isNaN(parseISO(v).getTime())?v:'';
let planDates={start:cleanDay(project&&project.start),end:cleanDay(project&&project.end)};
let datesInSidecar=false;
// Every change to the dates comes through here: the Setup card, undo and redo. The axis
// is rebuilt because the dates are on it, which also moves the lines and the readouts.
function setPlanDates(start,end){
  planDates={start:cleanDay(start),end:cleanDay(end)}; datesInSidecar=true;
  rebuildTimeline();
  renderDatesCard();
}
function commitPlanDates(start,end){
  const st=$('pdStatus');
  if(start&&end&&start>end){
    if(st)st.innerHTML='<span class="bad">The end has to be on or after the start.</span>';
    renderDatesCard(true);
    return;
  }
  if(start===planDates.start&&end===planDates.end)return;
  pushUndo({type:'pdates',start:planDates.start,end:planDates.end});
  setPlanDates(start,end);
  scheduleSave('setup');
}
// The Setup card: the two dates, the span they make, and the rows that fall outside them,
// each a link that opens the row on the chart. Read-only for the team.
function renderDatesCard(keepStatus){
  const s=$('pdStart'), e=$('pdEnd');
  if(!s||!e)return;
  const owner=document.documentElement.dataset.role!=='member';
  if(document.activeElement!==s)s.value=planDates.start;
  if(document.activeElement!==e)e.value=planDates.end;
  s.disabled=e.disabled=!owner;
  s.title=e.title=owner?'':"Only the plan's owner can change the project dates.";
  const {start,end}=planDates, sp=$('pdSpan');
  if(sp)sp.textContent=start&&end?fmtSpanWd(dayNum(end)-dayNum(start)+1,bizBetween(start,end)):'';
  const out=rowsOutsideDates(), st=$('pdStatus'), list=$('pdOut');
  if(st&&!keepStatus){
    st.textContent=!start&&!end?"Not set: the header counts from the plan's first and last rows."
      :!start?"No start set: Duration counts from the plan's first row."
      :!end?"No end set: the header counts to the plan's last row."
      :out.length?`${plural(out.length,'row')} ${out.length===1?'falls':'fall'} outside these dates:`
      :'Every row falls inside these dates.';
  }
  if(!list)return;
  const shown=out.slice(0,12);
  list.innerHTML=shown.map(r=>{
    const a=r.type==='milestone'?r.date:r.start, b=r.type==='milestone'?r.date:r.end;
    const early=start&&dayNum(spanDay(a,false))<dayNum(start);
    const why=early?(r.type==='milestone'?'on ':'starts ')+fmt(spanDay(a,false)):(r.type==='milestone'?'on ':'ends ')+fmt(spanDay(b,true));
    return `<li><a href="#row=${encodeURIComponent(r.uid||'')}">${esc(r.name||'')}</a><span class="pd-why">${why}</span></li>`;
  }).join('')+(out.length>shown.length?`<li class="pd-more">and ${out.length-shown.length} more</li>`:'');
}
function wireDatesCard(){
  const s=$('pdStart'), e=$('pdEnd');
  if(!s||!e)return;
  // A date field fires change while the year is still being typed (0002, 0020, 0202,
  // 2026), so a field that is not blank and not a plausible year yet commits nothing.
  const sane=v=>v===''||(cleanDay(v)&&+v.slice(0,4)>=1970&&+v.slice(0,4)<=2200);
  const go=()=>{ if(document.documentElement.dataset.role==='member')return;
    if(!sane(s.value)||!sane(e.value))return;
    commitPlanDates(cleanDay(s.value),cleanDay(e.value)); };
  s.addEventListener('change',go); e.addEventListener('change',go);
  renderDatesCard();
}
// ── Working calendar card (0.76.0) ──
// Every change to the calendar comes through here: the card, undo and redo. The day
// sets are rebuilt from it, the axis is redrawn (its shading and every working-day count
// follow it), and Critical and Analytics read it again.
function setPlanCal(cal){
  planCal=cleanCal(cal)||{workHolidays:[],extraDaysOff:[]}; calInSidecar=true;
  PLAN_OFF_CACHE.clear();
  rebuildTimeline();
  renderCalCard();
  renderDatesCard();
}
function commitPlanCal(cal){
  const next=cleanCal(cal);
  if(!next||(next.workHolidays.join()===planCal.workHolidays.join()&&next.extraDaysOff.join()===planCal.extraDaysOff.join()))return;
  pushUndo({type:'pcal',cal:{workHolidays:planCal.workHolidays.slice(),extraDaysOff:planCal.extraDaysOff.slice()}});
  setPlanCal(next);
  scheduleSave('setup');
}
// The year the card shows each holiday's date in: the project's, else this one.
function calYear(){ const d=planDates.start||planDates.end; return d?+d.slice(0,4):new Date().getFullYear(); }
function renderCalCard(keepStatus){
  const box=$('calHols'), list=$('calExtra');
  if(!box||!list)return;
  const owner=document.documentElement.dataset.role!=='member', y=calYear();
  const when=n=>{ let d=''; fedHolidays(y).forEach((nm,ds)=>{ if(nm===n&&!d)d=ds; });
    if(!d)fedHolidays(y+1).forEach((nm,ds)=>{ if(nm===n&&!d)d=ds; }); return d; };
  box.innerHTML=FED_HOLIDAY_NAMES.map(n=>{
    const off=!planCal.workHolidays.includes(n), d=when(n);
    return `<label class="${off?'':'worked'}" title="${off?'A day off on this plan. Untick it if your team works it.':'Worked on this plan, a normal working day.'}"><input type="checkbox" class="cal-hol" data-hol="${esc(n)}"${off?' checked':''}${owner?'':' disabled'}><span>${esc(n)}</span><span class="cal-d">${d?fmt(d):''}</span></label>`;
  }).join('');
  list.innerHTML=planCal.extraDaysOff.map(d=>`<li data-day="${d}">${fmt(d)}${isWeekend(d)?' (a weekend)':''}<button type="button" class="cal-rm" data-day="${d}" aria-label="Remove ${fmt(d)}" title="Remove this day off"${owner?'':' disabled'}>&times;</button></li>`).join('');
  const nw=$('calNew'), add=$('calAdd');
  if(nw)nw.disabled=!owner; if(add)add.disabled=!owner;
  if(nw)nw.title=add.title=owner?'':"Only the plan's owner can change the working calendar.";
  const worked=planCal.workHolidays.length, extra=planCal.extraDaysOff.length;
  const cs=$('calState');
  if(cs)cs.textContent=`${11-worked} of 11 holidays off`+(extra?`, ${plural(extra,'extra day')} off`:'');
  const st=$('calStatus');
  if(st&&!keepStatus)st.textContent=extra?'':'No extra days off.';
}
function wireCalCard(){
  const box=$('calHols'), list=$('calExtra'), nw=$('calNew'), add=$('calAdd');
  if(!box||!list||!nw||!add)return;
  const owner=()=>document.documentElement.dataset.role!=='member';
  const cur=()=>({workHolidays:planCal.workHolidays.slice(),extraDaysOff:planCal.extraDaysOff.slice()});
  box.addEventListener('change',e=>{
    const cb=e.target.closest('input[data-hol]'); if(!cb||!owner())return;
    const c=cur(), n=cb.dataset.hol;
    c.workHolidays=cb.checked?c.workHolidays.filter(x=>x!==n):c.workHolidays.concat(n);
    commitPlanCal(c);
  });
  list.addEventListener('click',e=>{
    const b=e.target.closest('button.cal-rm'); if(!b||!owner())return;
    const c=cur(); c.extraDaysOff=c.extraDaysOff.filter(d=>d!==b.dataset.day);
    commitPlanCal(c);
  });
  const go=()=>{
    if(!owner())return;
    const d=cleanDay(nw.value), st=$('calStatus');
    if(!d||+d.slice(0,4)<1970||+d.slice(0,4)>2200){ if(st)st.innerHTML='<span class="bad">Pick a date first.</span>'; return; }
    if(planCal.extraDaysOff.includes(d)){ if(st)st.textContent=fmt(d)+' is already a day off.'; return; }
    const c=cur(); c.extraDaysOff=c.extraDaysOff.concat(d);
    nw.value='';
    commitPlanCal(c);
    if(st)st.textContent=isWeekend(d)?fmt(d)+' is a weekend, which is already off.':fedHolidays(+d.slice(0,4)).has(d)?fmt(d)+' is '+fedHolidays(+d.slice(0,4)).get(d)+', added as a day off of its own.':'';
  };
  add.addEventListener('click',go);
  nw.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); go(); } });
  renderCalCard();
}
// Live rows that start before the project start or end after its end.
function rowsOutsideDates(){
  const {start,end}=planDates;
  if(!start&&!end)return [];
  return items.filter(r=>{
    if(r.type==='group'||isCancelled(r))return false;
    const a=r.type==='milestone'?r.date:r.start, b=r.type==='milestone'?r.date:r.end;
    if(!isDateStr(a)||!isDateStr(b))return false;
    return (start&&dayNum(spanDay(a,false))<dayNum(start))||(end&&dayNum(spanDay(b,true))>dayNum(end));
  });
}

let START=new Date(), UNITS=0;   // UNITS = months, or days when granularity is 'day'
function addD(d,n){const x=new Date(d.getFullYear(),d.getMonth(),d.getDate());x.setDate(x.getDate()+n);return x;}
// The axis a given grain would produce, without touching the live one. Fit needs
// to ask "how wide would Days be?" about a grain it has not switched to yet.
function rangeFor(g){
  const {minD,maxD}=scanRange();
  const a=parseISO(minD), b=parseISO(maxD);
  if(g==='day'){       const s=addD(a,-3);
                       return {start:s, units:Math.max(14, dayDiff(s,b)+8)}; }
  if(g==='week'){      const s=new Date(a.getFullYear(),a.getMonth(),a.getDate()); s.setDate(s.getDate()-s.getDay()-7);
                       return {start:s, units:Math.max(8, Math.ceil(dayDiff(s,b)/7)+2)}; }
  if(g==='month'){     const s=new Date(a.getFullYear(),a.getMonth(),1);
                       return {start:s, units:Math.max(2, mDiff(s,b)+2)}; }
  if(g==='quarter'){   const s=new Date(a.getFullYear(),Math.floor(a.getMonth()/3)*3,1);
                       return {start:s, units:Math.max(2, Math.ceil((mDiff(s,b)+1)/3)+1)}; }
  const s=new Date(a.getFullYear(),0,1);
  return {start:s, units:Math.max(2, (b.getFullYear()-s.getFullYear())+2)};
}
function computeRange(){ const r=rangeFor(G()); START=r.start; UNITS=r.units; }
// W is the timeline's pixel width and cannot be computed until computeRange() has
// set UNITS, so it is declared here and assigned inside the init step. That
// declare-then-assign shape is the rule for anything order-dependent now: the
// declaration is a binding, the ordering lives in init().
let W = 0;
function initTimeRange(){
computeRange();
W = UNITS*COL;
}

// Fit the timeline to whatever width the pane actually has. Wide window: months
// stretch so the chart fills it. Narrow window or a very long plan: months stop
// at MIN_COL and the timeline scrolls horizontally instead of squashing.
function fitCOL(){
  // -1 leaves room for the closing grid line, which sits at x=W and is 1px wide
  const avail = (timelineEl.clientWidth || (window.innerWidth - 272)) - 1;
  COL = Math.max((GRAINS[G()]||GRAINS.month).min, Math.floor((avail / UNITS) * 100) / 100);
  W = UNITS * COL;
}

function mDiff(a,b){return(b.getFullYear()-a.getFullYear())*12+b.getMonth()-a.getMonth();}
function dayDiff(a,b){return Math.round((new Date(b.getFullYear(),b.getMonth(),b.getDate())-new Date(a.getFullYear(),a.getMonth(),a.getDate()))/86400000);}
// Function declarations, not const arrows: computeRange() runs at module level before
// this point in the file, and a const would still be in its temporal dead zone.
function G(){ return GRAINS[prefs.granularity] ? prefs.granularity : 'month'; }
function isDayMode(){ return G()==='day'; }
// Months per unit for the calendar-based grains; day and week are handled separately
// because their length in months is not constant.
const MONTHS_PER = { month:1, quarter:3, year:12 };

function toX(ds){
  const d=parseISO(ds), g=G();
  if(g==='day')  return dayDiff(START,d)*COL;
  if(g==='week') return dayDiff(START,d)/7*COL;
  const dd=+(ds.split('-')[2]||1);
  const mf=mDiff(START,d)+(dd-1)/30;
  return mf/MONTHS_PER[g]*COL;
}
function xToDate(x){
  const g=G(), u=x/COL;
  if(g==='day')  return isoDay(addD(START,Math.round(u)));
  if(g==='week') return isoDay(addD(START,Math.round(u*7)));
  const mf=u*MONTHS_PER[g], mo=Math.floor(mf), df=mf-mo;
  const d=new Date(START.getFullYear(),START.getMonth()+mo,1);
  const day=Math.max(1,Math.min(28,Math.round(df*30)+1));
  return`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`;
}
// ── Snapping to the visible grid ──
//
// A dragged bar lands on the lines the axis is actually drawing: day boundaries at
// the day grain, week starts at the week grain, month, quarter and year firsts at
// the coarser ones. That is one rule rather than five, because the grid, the axis
// cells and the bar geometry are all the same function of COL: unit i sits at
// exactly i*COL px and at exactly unitDate(i), so a snap is a rounded index and
// nothing else. Rounding is what makes the preview jump as the cursor crosses each
// midpoint rather than at the boundary itself.
function snapIndex(x){ return Math.max(0,Math.min(UNITS,Math.round(x/COL))); }
function snapDateAt(i){ return isoDay(unitDate(i)); }
function snapX(x){ const i=snapIndex(x); return {i, x:i*COL, date:snapDateAt(i)}; }
// The calendar date at the left edge of unit i, and the two header labels for it.
function unitDate(i){
  const g=G();
  if(g==='day')     return addD(START,i);
  if(g==='week')    return addD(START,i*7);
  if(g==='month')   return new Date(START.getFullYear(),START.getMonth()+i,1);
  if(g==='quarter') return new Date(START.getFullYear(),START.getMonth()+i*3,1);
  return new Date(START.getFullYear()+i,0,1);
}
function topLabel(d){
  const g=G();
  if(g==='day'||g==='week') return `${mNames[d.getMonth()]} ${d.getFullYear()}`;
  if(g==='month')           return `Q${Math.floor(d.getMonth()/3)+1} ${d.getFullYear()}`;
  if(g==='quarter')         return `${d.getFullYear()}`;
  return `${Math.floor(d.getFullYear()/10)*10}s`;
}
function botLabel(d){
  const g=G();
  if(g==='day'||g==='week') return String(d.getDate());
  if(g==='month')           return mNames[d.getMonth()];
  if(g==='quarter')         return 'Q'+(Math.floor(d.getMonth()/3)+1);
  return String(d.getFullYear());
}
// The one date formatter. Every surface a person reads goes through it, so the
// format is decided in one place: 17-Aug-2026, zero-padded day, capitalised
// three-letter month. A year-month value has no day, so it renders Aug 2026.
// Machine formats are deliberately NOT this: the data file, the sidecar, the
// changelog payloads and CSV all stay ISO, because they are interchange.
const MN=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmt(ds){
  if(!ds)return '';
  const[y,mo,d]=String(ds).split('-').map(Number);
  if(!y||!mo)return String(ds);
  return d?`${String(d).padStart(2,'0')}-${MN[mo-1]}-${y}`:`${MN[mo-1]} ${y}`;
}
function addDays(ds, n){const[y,mo,d]=ds.split('-').map(Number);const dt=new Date(y,mo-1,d);dt.setDate(dt.getDate()+n);return`${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`;}

// ── Business days ──
//
// Every date in this app is a `yyyy-mm-dd` STRING, and it means that calendar day
// in the reader's plan and not an instant. So the arithmetic below never builds a
// local Date and asks it what day it is: a local Date is an instant, it lands at
// midnight in whatever zone the browser happens to be in, and one DST boundary or
// one container running UTC-behind is all it takes for a Friday to report itself
// as a Saturday. `dayNum` goes through Date.UTC, which is pure arithmetic on the
// three numbers, and every other function here is built on that one.
//
// Day 0 of the UTC epoch is 1970-01-01, a Thursday, so (dayNum + 4) % 7 is the
// day of the week with 0 for Sunday, the same convention getDay() uses.
function dayNum(ds){const[y,m,d]=String(ds).split('-').map(Number);return Math.round(Date.UTC(y,m-1,d)/86400000);}
function dowOf(ds){return ((dayNum(ds)%7)+7+4)%7;}
function isWeekend(ds){const w=dowOf(ds);return w===0||w===6;}

// ── US federal holidays ──
//
// The eleven statutory holidays of 5 U.S.C. 6103, as OBSERVED, which is the only
// form worth computing here: a plan is scheduled against the days people are at
// work, and nobody is at work on the Friday the Fourth of July was moved to. Six
// are already pinned to a Monday or a Thursday and never move; the other five are
// fixed calendar dates and follow the OPM rule, a Saturday observed on the Friday
// before and a Sunday on the Monday after.
//
// Because every observed date is a weekday by construction, the holiday set and the
// weekend cannot overlap, which is what lets isOffDay below be a plain OR and lets
// bizBetween subtract a flat count without asking whether it already skipped one.
//
// The shift crosses the year boundary in BOTH directions and both are real: New
// Year's Day 2028 is a Saturday, so it is observed on Friday 31 December 2027, and
// New Year's Day 2023 was a Sunday, observed on Monday 2 January. fedHolidays(y)
// therefore builds the candidates for y AND the New Year of y+1, then keeps only
// what actually lands in y, so every observed day belongs to exactly one year's set
// no matter which way it moved. A calendar year can hold ten, eleven or twelve of
// them for that reason, and 2022 really did hold ten.
function pad2(n){return String(n).padStart(2,'0');}
// The nth given weekday of a month, 1-based, in the getDay() convention: 0 Sunday,
// 1 Monday, 4 Thursday.
function nthDow(y,m,dow,n){
  const first=`${y}-${pad2(m)}-01`;
  return addDays(first,((dow-dowOf(first))+7)%7+(n-1)*7);
}
// The last given weekday of a month, counted back from its final day, so May with
// five Mondays gives the fifth and May with four gives the fourth.
function lastDow(y,m,dow){
  const last=`${y}-${pad2(m)}-${pad2(new Date(y,m,0).getDate())}`;
  return addDays(last,-(((dowOf(last)-dow)+7)%7));
}
function observedDay(ds){
  const w=dowOf(ds);
  return w===6?addDays(ds,-1):w===0?addDays(ds,1):ds;
}
// One Map per year, date string to holiday name, built once and kept. A plan spans
// a handful of years, so this is a handful of Maps for the life of the tab.
const HOLIDAY_CACHE=new Map();
function fedHolidays(y){
  let s=HOLIDAY_CACHE.get(y);
  if(s)return s;
  const fx=(m,d)=>observedDay(`${y}-${pad2(m)}-${pad2(d)}`);
  s=new Map();
  [["New Year's Day",fx(1,1)],
   ['Martin Luther King, Jr. Day',nthDow(y,1,1,3)],
   ["Washington's Birthday",nthDow(y,2,1,3)],
   ['Memorial Day',lastDow(y,5,1)],
   ['Juneteenth National Independence Day',fx(6,19)],
   ['Independence Day',fx(7,4)],
   ['Labor Day',nthDow(y,9,1,1)],
   ['Columbus Day',nthDow(y,10,1,2)],
   ['Veterans Day',fx(11,11)],
   ['Thanksgiving Day',nthDow(y,11,4,4)],
   ['Christmas Day',fx(12,25)],
   // Next year's New Year, which is observed on 31 December of THIS year whenever it
   // falls on a Saturday. In every other year the filter below drops it again.
   ["New Year's Day",observedDay(`${y+1}-01-01`)]
  ].forEach(([n,d])=>{ if(+d.slice(0,4)===y)s.set(d,n); });
  HOLIDAY_CACHE.set(y,s);
  return s;
}
// ── The plan's working calendar (0.76.0) ──
//
// Every organisation keeps a different set of those holidays: Adom works Columbus Day
// and Veterans Day. So a plan says which of the eleven it works (`workHolidays`, by the
// names above) and which extra weekdays nobody works (`extraDaysOff`: a shutdown, a
// company holiday). Both live in project.calendar in the data file and are set by the
// owner on Setup; the sidecar's win, like the project dates. Absent is the old rule,
// every federal holiday off and nothing else.
const FED_HOLIDAY_NAMES=["New Year's Day",'Martin Luther King, Jr. Day',"Washington's Birthday",'Memorial Day',
  'Juneteenth National Independence Day','Independence Day','Labor Day','Columbus Day','Veterans Day',
  'Thanksgiving Day','Christmas Day'];
const EXTRA_DAY_OFF='Day off';
function cleanCal(v){
  if(!v||typeof v!=='object'||Array.isArray(v))return null;
  const wh=Array.isArray(v.workHolidays)?v.workHolidays:[], xd=Array.isArray(v.extraDaysOff)?v.extraDaysOff:[];
  return {workHolidays:FED_HOLIDAY_NAMES.filter(n=>wh.includes(n)),
          extraDaysOff:[...new Set(xd.map(cleanDay).filter(Boolean))].sort()};
}
let planCal=cleanCal(project&&project.calendar)||{workHolidays:[],extraDaysOff:[]};
let calInSidecar=false;
// One Map per year of the days THIS plan has off on weekdays: the federal holidays it
// does not work, then its own extra days. An extra day on a weekend is already off and
// is left out, so the set and the weekend still never overlap and bizBetween's flat
// subtraction below stays right. Cleared whenever the calendar changes.
const PLAN_OFF_CACHE=new Map();
function planOffDays(y){
  let s=PLAN_OFF_CACHE.get(y);
  if(s)return s;
  s=new Map();
  fedHolidays(y).forEach((n,d)=>{ if(!planCal.workHolidays.includes(n))s.set(d,n); });
  planCal.extraDaysOff.forEach(d=>{ if(+d.slice(0,4)===y&&!isWeekend(d)&&!s.has(d))s.set(d,EXTRA_DAY_OFF); });
  PLAN_OFF_CACHE.set(y,s);
  return s;
}
function calSig(){ return planCal.workHolidays.join(',')+'/'+planCal.extraDaysOff.join(','); }
function isHoliday(ds){ return planOffDays(+String(ds).slice(0,4)).has(String(ds)); }
function holidayName(ds){ return planOffDays(+String(ds).slice(0,4)).get(String(ds))||''; }
// What a shaded day column says when it is hovered.
function offDayTitle(ds){
  const n=holidayName(ds);
  return !n?'':n===EXTRA_DAY_OFF?"A day off on this plan's calendar, a non-working day":n+' (US federal holiday, a non-working day)';
}
// A day nobody works: the weekend or one of the plan's days off. Everything below
// this line asks THIS and not isWeekend, which is what makes a holiday behave
// exactly like a Saturday everywhere in the app at once.
function isOffDay(ds){ return isWeekend(ds)||isHoliday(ds); }

// The next working day ON or after this one, and its mirror. Both are identity on a
// working day, so calling them on a date that is already fine costs one modulo and
// one Map hit. A plan's extra days off can make a long run of them (a two-week
// shutdown), so the guard allows a year of closed days before it gives up.
function nextBiz(ds){let s=ds,g=0;while(isOffDay(s)&&g++<370)s=addDays(s,1);return s;}
function prevBiz(ds){let s=ds,g=0;while(isOffDay(s)&&g++<370)s=addDays(s,-1);return s;}
// Working days in [a,b], counting both ends, which is how a person reads a task
// that starts and ends on the same Monday: one day, not zero. Whole weeks are
// five days each and only the remainder is walked, so a ten-year span costs the
// same six comparisons a ten-day one does; the holidays inside the span are then
// handed back, one Map per year touched.
function bizBetween(a,b){
  const A=dayNum(a),B=dayNum(b);
  if(B<A)return 0;
  const total=B-A+1,weeks=Math.floor(total/7);
  let c=weeks*5;
  for(let i=weeks*7;i<total;i++){const w=((A+i)%7+7+4)%7;if(w!==0&&w!==6)c++;}
  return c-holidaysBetween(a,b);
}
// The plan's weekday days off in [a,b]. Every one of them is a weekday, so each is a
// day the weekday count above has already added and now has to give back.
function holidaysBetween(a,b){
  const A=dayNum(a),B=dayNum(b);
  if(B<A)return 0;
  let n=0;
  for(let y=+String(a).slice(0,4);y<=+String(b).slice(0,4);y++)
    planOffDays(y).forEach((_,d)=>{const k=dayNum(d);if(k>=A&&k<=B)n++;});
  return n;
}
// n working days forward from a working day, counting `ds` as the first, so
// addBiz(d,1) === d and addBiz(Friday,2) === the following Monday.
function addBiz(ds,n){
  let s=nextBiz(ds),k=Math.max(1,Math.round(n));
  while(k>1){s=addDays(s,1);if(!isOffDay(s))k--;}
  return s;
}

// ── Header with import/export buttons ──
// The brand block is the one place the plan is named in the header (0.57.0). A plan
// that sets appName shows that; one that does not shows its own title, because a
// header reading "adom-gantt" names the tool and leaves the reader to guess the plan.
function brandName(){ return project.appName||project.title||'adom-gantt'; }
// The title and subtitle stopped being painted into the header, so the tooltip is
// where they are still read in full: the plan, what it is, and the tool.
function brandTip(){
  const t=project.title||'', parts=[];
  if(project.appName&&t&&t!==project.appName)parts.push(project.appName+': '+t);
  else parts.push(t||project.appName||'adom-gantt');
  if(project.subtitle)parts.push(project.subtitle);
  parts.push('adom-gantt'+(APP_VERSION?' v'+APP_VERSION:''));
  return parts.join('\n');
}
function buildHeader(){
$('printTitle').innerHTML = `<div class="pt-name">${esc(project.title||project.appName||'')}</div>${project.subtitle?`<div class="pt-sub">${esc(project.subtitle)}</div>`:''}`;
$('hdr').innerHTML = `
  <div class="brand" title="${esc(brandTip())}"><svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/><rect x="5" y="9" width="12" height="2" rx="1"/><rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/><rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/></svg>
    <span class="brand-txt"><span class="app-name">${esc(brandName())}</span>${APP_VERSION?`<span class="app-ver" title="adom-gantt ${esc(APP_VERSION)}">v${esc(APP_VERSION)}</span>`:''}</span></div>
  <div class="vsep"></div>
  <!-- The lens switch is the primary control, so it sits first in the row, anchored
       to the brand block, which never changes width. It used to live inside .actions,
       which is right-aligned: every chart-only button that hid in table view made
       that group narrower and shoved the toggle sideways between clicks. -->
  <div class="view-toggle" id="viewToggle" role="tablist" title="Four lenses over the same plan: the chart, the owner and deadline table, the totals, or the plan's setup">
    <button class="vt-btn" data-view="gantt" role="tab">
      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="5" width="13" height="3" rx="1.5"/><rect x="7" y="10.5" width="14" height="3" rx="1.5"/><rect x="5" y="16" width="10" height="3" rx="1.5"/></svg>Gantt</button>
    <button class="vt-btn" data-view="table" role="tab">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9.5h18M3 15h18M10 9.5V20"/></svg>Table</button>
    <button class="vt-btn" data-view="analytics" role="tab">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>Analytics</button>
    <!-- 0.58.0: the fourth lens. The plan's structure, which the header's Sections
         button used to open in a modal. The icon is the one that button carried. -->
    <button class="vt-btn" data-view="setup" role="tab">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h13"/></svg>Setup</button>
  </div>
  <div class="vsep"></div>
  <!-- 0.57.0: the plan is named ONCE, in the brand block. Until 0.56.0 this group
       repeated the project title in bold and the subtitle dimmed after it, so the same
       plan was named twice in 400px of header. What is left here is the plan's clock:
       how long it runs and how much of it is left, both from the real first and last
       dates (syncPlanSpan), so neither moves when the grain picker does. The title and
       subtitle are still data and still on the brand's tooltip and in document.title. -->
  <div class="subject"><div class="span-block">
    <span class="span-stat" id="spanDur"><span class="span-k"><span class="k-long">Project </span>Duration:</span><span class="span-v" id="spanDurV"></span></span>
    <span class="span-stat" id="spanLeft"><span class="span-k"><span class="k-long">Time </span>Remaining:</span><span class="span-v" id="spanLeftV"></span><span class="span-n" id="spanLeftN"></span></span>
  </div></div>
  <div class="start-ctl">
    <select id="grainSel" class="grain-sel" title="Timeline cell size">
      <option value="day">Days</option><option value="week">Weeks</option>
      <option value="month">Months</option><option value="quarter">Quarters</option>
      <option value="year">Years</option>
    </select>
    <button class="act-btn" id="btnFit" title="Fit the whole plan into the pane: the finest cell size that shows every date without scrolling sideways">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 5v14M21 5v14"/><path d="M7 12h10"/><path d="M10 9l-3 3 3 3M14 9l3 3-3 3"/></svg>Fit</button>
    <!-- The tooltip IS the info bar, moved. Same sentence, on the control it describes,
         so it is there before the mode is entered (what does this button do) as well as
         during it (what am I meant to click now). Shift+D is not repeated here; it is on
         the keyboard-shortcuts pulldown with every other one. -->
    <button class="act-btn" id="btnDeps" title="Adding dependencies. Click the task or milestone that comes first, then the one that waits on it. Click an arrow to pick it. Esc leaves the mode.">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5" cy="6" r="2"/><circle cx="19" cy="18" r="2"/><path d="M7 6h6a3 3 0 0 1 3 3v7"/><path d="M14 14l2 3 2-3"/></svg><span class="btn-label">Deps</span></button>
  </div>
  <div class="plan-ctl">
    <button class="act-btn" id="btnAuto" title="Align dependencies by pushing later tasks out so each one starts when the work it waits on finishes, skipping weekends and the plan&#39;s days off. Never pulls work earlier, so intentional lead-time gaps are preserved and cancelled work is left out">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h7M4 12h11M4 17h6"/><path d="M17 14l3 3-3 3"/><path d="M20 17h-6"/></svg><span class="btn-label">Auto-schedule</span></button>
    <button class="act-btn" id="btnCrit" title="Critical path: light up the longest run of work inside each section. A dependency that crosses sections is left out." aria-pressed="false">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18l5-5 4 3 7-8"/><circle cx="4" cy="18" r="1.6"/><circle cx="9" cy="13" r="1.6"/><circle cx="13" cy="16" r="1.6"/><circle cx="20" cy="8" r="1.6"/></svg><span class="btn-label" id="critLabel">Critical</span></button>
  </div>
  <div class="actions">
    <button class="act-btn" id="btnMsStyle" title="Milestone style, cycled: dashed lines with tags, diamonds on their own row, or both at once">
      <span id="msStyleIcon"></span><span id="msStyleLabel"></span></button>
    <button class="act-btn" id="btnTheme" title="Toggle light and dark"><span id="themeLabel"></span></button>
    <button class="act-btn" id="btnImport" title="Import a plan: JSON, CSV, MS Project XML, Jira CSV or ClickUp"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg><span class="btn-label">Import</span></button>
    <button class="act-btn" id="btnExport" title="Export this plan: JSON, CSV, SVG or a self-contained HTML snapshot"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21V9m0 0l4 4m-4-4l-4 4M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2"/></svg><span class="btn-label">Export</span></button>
    <button class="act-btn" id="btnHelp" style="font-family:'Familjen Grotesk',sans-serif;font-size:14px;font-weight:600;padding:4px 9px">?</button>
    <button class="new-btn" id="btnNew" title="New task &middot; Shift+N">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>New</button>
    <div class="vsep"></div>
    <div class="fs-ctl" id="fsCtl">
      <button class="fs-btn" id="fsDown" title="Smaller text"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><text x="12" y="17" text-anchor="middle" font-family="Familjen Grotesk,sans-serif" font-size="14" font-weight="600">A</text></svg></button>
      <button class="fs-pct" id="fsPct" title="Text size. Click to go back to 100%"></button>
      <button class="fs-btn fs-big" id="fsUp" title="Larger text"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><text x="12" y="19" text-anchor="middle" font-family="Familjen Grotesk,sans-serif" font-size="20" font-weight="600">A</text></svg></button>
    </div>${ADOM_USER?`
    <div class="vsep"></div>
    <button class="req-btn" id="btnReq" type="button" title="Requests: tasks people have asked you to take, and the ones you asked of others" aria-label="Requests"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 13h5l1.5 3h5L16 13h5"/><path d="M5.5 5h13L21 13v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6z"/></svg><span class="req-n" id="reqN"></span></button>
    <div class="who" id="whoChip" title="Signed in as ${esc(ADOM_USER.displayName)}${ADOM_USER.username?', @'+esc(ADOM_USER.username):''}${ADOM_USER.email?', '+esc(ADOM_USER.email):''}">
      ${avatarHtml(ADOM_USER,'who-av')}<span class="who-name">${esc(ADOM_USER.displayName)}</span>
    </div>`:''}
  </div>`;
applyView();   // again, now that the toggle buttons exist
fitHeader();
}

// ── Fit the header to its content ── (the CSS side, and why, is above .legend)
//
// Each step is a set of classes; the first set under which the row's content is no
// wider than the row wins. Measured synchronously, class on, read, class off, inside
// one task, so nothing flickers: the browser paints only the state that was kept.
// "Fits" is two questions, because the readouts must be WHOLE and not merely present:
// nothing in the header runs past its right edge (scrollWidth, which counts content
// that overflows a box that does not clip), and each readout is as wide as its text.
const HDR_STEPS=[[],['shed-ver'],['shed-ver','shed-labels'],['shed-ver','shed-labels','shed-keys']];
// Wrapped, there are two candidates, and width cannot choose between them: a wrapping
// row always "fits". HEIGHT chooses. The button words come back when they cost no
// extra row, and stay off when they would (a phone, where they add a row or two).
const HDR_WRAP_WORDS=['shed-ver','is-wrapped'], HDR_WRAP_ICONS=['shed-ver','shed-labels','is-wrapped'];
const HDR_ALL=['shed-ver','shed-labels','shed-keys','is-wrapped'];
function headerFits(h){
  if(h.scrollWidth>h.clientWidth+0.5)return false;
  const hr=h.getBoundingClientRect();
  for(const el of h.children){ const r=el.getBoundingClientRect(); if(r.width&&(r.right>hr.right+0.5||r.left<hr.left-0.5))return false; }
  for(const st of h.querySelectorAll('.span-stat')){ if(st.scrollWidth>st.clientWidth+0.5)return false; }
  return true;
}
let hdrFitKey='';
function fitHeader(){
  const h=$('hdr'); if(!h||!h.firstElementChild)return;
  let pick=null;
  for(const step of HDR_STEPS){
    h.classList.remove(...HDR_ALL); h.classList.add(...step);
    if(headerFits(h)){ pick=step; break; }
  }
  if(!pick){
    h.classList.remove(...HDR_ALL); h.classList.add(...HDR_WRAP_ICONS);
    const iconsH=h.offsetHeight;
    h.classList.remove(...HDR_ALL); h.classList.add(...HDR_WRAP_WORDS);
    pick=(headerFits(h)&&h.offsetHeight<=iconsH+0.5)?HDR_WRAP_WORDS:HDR_WRAP_ICONS;
  }
  h.classList.remove(...HDR_ALL); h.classList.add(...pick);
  hdrFitKey=pick.join(' ');
}
// Anything that can change what the row holds or how wide it is re-runs the fit: the
// window, a lens switch that hides the chart-only controls, a label that changes
// length (the readouts, the milestone style, the theme), the web fonts arriving.
// The observer watches the groups, not the header, so wrapping (which changes the
// header's height) does not wake it; a group whose size a step changed wakes it once,
// the fit reaches the same answer, and nothing changes again, so it settles.
let hdrFitRaf=0;
function scheduleFitHeader(){ if(hdrFitRaf)return; hdrFitRaf=requestAnimationFrame(()=>{ hdrFitRaf=0; fitHeader(); }); }
function wireHeaderFit(){
  window.addEventListener('resize',scheduleFitHeader);
  if(window.ResizeObserver){
    const ro=new ResizeObserver(scheduleFitHeader);
    [...$('hdr').children].forEach(el=>{ if(!el.classList.contains('subject'))ro.observe(el); });
    const sb=$('hdr').querySelector('.span-block'); if(sb)ro.observe(sb);
  }
  if(document.fonts&&document.fonts.ready)document.fonts.ready.then(scheduleFitHeader);
}

// The brand has three hues, so the ramp cycles them rather than inventing lighter
// or darker fourths. On a dark ground a second teal has to be lighter to be told
// apart, and a pale teal reads as off-brand. Sections are separated by the grey
// banding and the group header instead, so a repeat three rows down is not confusing.
// Returning the CSS var (not a resolved hex) is what lets a theme switch recolour
// every bar without rebuilding the chart.
// The avatar is the identity, and it never renders as a blank circle: no url, or a
// url that fails to load, falls back to initials on the brand gradient.
function avatarHtml(u,cls){
  const ini=esc(u.initials||'?');
  if(u.avatarUrl)return `<img class="${cls}" src="${esc(u.avatarUrl)}" alt="" referrerpolicy="no-referrer" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'${cls}',textContent:'${ini}'}))">`;
  return `<span class="${cls}">${ini}</span>`;
}
function phaseColor(i){
  const p = phases[i];
  // An explicit colour is theme-INVARIANT by design: a lane someone deliberately
  // painted keeps that exact colour in light, dark and print, because they chose
  // a colour and not a slot in a ramp. A lane with no colour of its own follows
  // the theme ramp, which is what makes the default palette adapt for free.
  return (p && p.color) ? p.color : `var(--ph${(i % 3) + 1})`;
}
// ── Sections ───────────────────────────────────────────────────────────────
//
// `phases` is the single source of truth for what sections the plan has. A group
// header row is a DERIVED render artifact: exactly one per phase, in phases[]
// order, synthesised at load and never stored. Before this, headers were seeded
// display items in the data file with no connection to phases at all, so the four
// sections added in August had no header, renaming a header renamed nothing, and
// legend order and chart order could disagree (the 0.12.0 note).
//
// Phase IDENTITY is `phase.uid`, from the same counter every row uses. `item.phase`
// stays an INDEX in stored form, which keeps the data file and every export
// readable and unchanged in shape, but the index is a storage encoding and not the
// identity: exactly one function reorders phases, and it remaps every item.phase
// atomically. That is what keeps the index from becoming the next `#row`.
function phaseUid(i){ const p=phases[i]; return p&&p.uid?p.uid:('#ph'+i); }
function headerUid(i){ return '#hdr-'+phaseUid(i); }
function isDerivedGroup(r){ return !!(r&&r.type==='group'&&r._derived); }
// Lift a legacy data file's group rows into the phases they were describing, then
// drop them as rows. A label like "Phase 1 - Build (Jan-Apr 2026)" carries a name
// and often an aside; the aside becomes `lead`, which renders as secondary text.
function leadFromLabel(raw,name){
  const r=String(raw||'').trim(), nm=String(name||'').trim();
  if(r.toLowerCase().startsWith(nm.toLowerCase())){
    const rest=r.slice(nm.length).trim();
    if(rest.startsWith(','))return rest.slice(1).trim();
    const m=rest.match(/^\((.*)\)$/);
    return m?m[1].trim():rest;
  }
  return r.toLowerCase()===nm.toLowerCase()?'':r;
}
function absorbLegacyGroups(){
  const groups=items.filter(r=>r.type==='group'&&!r._derived);
  groups.forEach(g=>{
    const p=phases[g.phase];
    if(!p)return;
    const raw=groupLabel(g.label||'');
    // "Workcells, the long pole (Adonis)" -> name "Workcells", lead "the long pole (Adonis)"
    // "Logistics (Dan)"                    -> name "Logistics", lead "Dan"
    if(!p.lead)p.lead=leadFromLabel(raw,p.name);
  });
  for(let i=items.length-1;i>=0;i--) if(items[i].type==='group'&&!items[i]._derived) items.splice(i,1);
  return groups.length;
}
// One header per phase, always, including a phase with nothing in it: an empty
// section that shows its own header with a count of 0 is what makes a section the
// user just made feel real. They are in-memory only and never saved.
function buildDerivedHeaders(){
  // Reuse the header object a phase already has, DOM and all. Rebuilding them all
  // from scratch at runtime left every header without a `_lDiv`, and the next
  // reflow tried to appendChild(undefined): headers only ever got their DOM from
  // the initial buildRows pass, so a section created after load had none.
  const old=items.filter(isDerivedGroup);
  const byPhaseUid=new Map();
  old.forEach(h=>{ if(h._phUid)byPhaseUid.set(h._phUid,h); });
  for(let i=items.length-1;i>=0;i--) if(isDerivedGroup(items[i])) items.splice(i,1);
  const kept=new Set();
  phases.forEach((p,i)=>{
    const key=p.uid||('#ph'+i);
    let h=byPhaseUid.get(key);
    if(h){ h.phase=i; h.label=p.name; h.lead=p.lead||''; h.uid=headerUid(i); }
    else h={type:'group',phase:i,label:p.name,lead:p.lead||'',uid:headerUid(i),_derived:true,_phUid:key};
    h._phUid=key;
    kept.add(h);
    items.push(h);
    if(rowsBuilt&&!h._lDiv)buildRowDom(h,items.length-1);
  });
  // a header whose phase is gone takes its DOM with it
  old.forEach(h=>{ if(!kept.has(h)){
    if(h._lDiv&&h._lDiv.parentNode)h._lDiv.remove();
    if(h._bDiv&&h._bDiv.parentNode)h._bDiv.remove();
  }});
}
// ── The row tree (0.79.0) ──
//
// Derived from the rows' own fields whenever the rows are laid out: the parent links the tree
// can hold (treeOf drops a milestone parent, a loop, a parent that is not in the plan), each
// row's depth, every task's subtasks in order, and a subtask's section, which is always its
// top-level ancestor's. A subtask's own `phase` is written from it here, so everything that
// groups rows by section (the headers, the legend, the filters, the colours) needs nothing new.
let tree={order:[],parentOf:new Map(),depth:new Map(),phaseOf:new Map(),kids:new Map()}, treeRows=new Map();
function rowTreeKey(r){ return r.uid||rowOrderKey(r); }
function syncTree(){
  const rows=items.filter(r=>r&&r.type!=='group');
  treeRows=new Map(rows.map(r=>[rowTreeKey(r),r]));
  tree=treeOf(rows.map(r=>({uid:rowTreeKey(r),type:r.type,phase:r.phase,parent:r.parent,pos:r.pos})),phases.length);
  rows.forEach(r=>{ const k=rowTreeKey(r);
    if(tree.parentOf.get(k)){ const ph=tree.phaseOf.get(k); if(Number.isInteger(ph)&&ph>=0&&ph<phases.length&&r.phase!==ph)r.phase=ph; } });
  return tree;
}
const treeParent=r=>r?tree.parentOf.get(rowTreeKey(r))||'':'';
const treeDepth=r=>r?tree.depth.get(rowTreeKey(r))||0:0;
const treeKids=r=>r?(tree.kids.get(rowTreeKey(r))||[]).map(k=>treeRows.get(k)).filter(Boolean):[];
const treeHasKids=r=>!!r&&(tree.kids.get(rowTreeKey(r))||[]).length>0;
// A task can take a subtask while its own subtree leaves room under the depth limit.
const canHoldKids=r=>!!r&&r.type!=='milestone'&&!isDerivedGroup(r)&&treeDepth(r)<TREE_DEPTH_MAX;
// Every row under `r`, depth first, in plan order.
function treeDesc(r){ const out=[]; const walk=x=>treeKids(x).forEach(k=>{ out.push(k); walk(k); }); walk(r); return out; }
function treeUnderRow(r,anc){ return !!r&&!!anc&&treeUnder(tree,rowTreeKey(r),rowTreeKey(anc)); }
// Each header above its section's rows, an empty section's header after the section before
// it, and the rows in tree order: every top-level row by its key, its subtasks under it.
// A row with no key keeps the order it had (rows are listed to treeOf in their current order).
function placeHeaders(){
  const hdrs=items.filter(isDerivedGroup);
  const rest=items.filter(r=>!isDerivedGroup(r));
  syncTree();
  const byKey=new Map(); rest.forEach(r=>byKey.set(rowTreeKey(r),r));
  const bySec=phases.map(()=>[]), loose=[];
  tree.order.forEach(k=>{ const r=byKey.get(k); if(!r)return; const ph=tree.phaseOf.get(k);
    if(Number.isInteger(ph)&&ph>=0&&ph<bySec.length)bySec[ph].push(r); else loose.push(r); });
  const out=[];
  phases.forEach((p,i)=>{ const h=hdrs.find(x=>x.phase===i); if(h)out.push(h); bySec[i].forEach(r=>out.push(r)); });
  // a row whose section is out of range, or that the tree could not list, would otherwise
  // vanish, which is the one way this could lose work: keep it at the end rather than drop it
  const seen=new Set(out);
  loose.concat(rest).forEach(r=>{ if(!seen.has(r)){ out.push(r); seen.add(r); } });
  items.length=0; items.push(...out);
}
// The tree on the rows (0.79.0): each row indented by its depth, a task with subtasks wearing
// the chevron that folds them and how many it has, a folded task its subtasks' span. Writes
// only what changed, so a layout that moved nothing costs nothing. The label column keeps a
// slot for the chevron on every row only once the plan has any subtasks at all, so a plan
// without them looks exactly as it did.
function refreshTreeDom(){
  if(!rowsBuilt)return;
  let nest=false;
  items.forEach(r=>{
    if(isDerivedGroup(r)||!r._lDiv)return;
    const d=treeDepth(r), kids=treeHasKids(r);
    if(d||kids)nest=true;
    if(r._lDiv.__depth!==d){ r._lDiv.__depth=d; r._lDiv.style.setProperty('--depth',String(d)); r._lDiv.classList.toggle('is-sub',d>0); if(r._bDiv)r._bDiv.classList.toggle('is-sub',d>0); }
    r._lDiv.classList.toggle('has-kids',kids);
    r._lDiv.classList.toggle('depth-max',d>=TREE_DEPTH_MAX);
    r._lDiv.classList.toggle('kids-folded',kids&&collapsedRows.has(rowTreeKey(r)));
    const n=kids?treeDesc(r).length:0;
    if(r._subTog){ const f=collapsedRows.has(rowTreeKey(r));
      const t=kids?(f?'Show':'Hide')+' its '+plural(n,'subtask')+'. This is your view only, it does not change the plan.':'';
      if(r._subTog.title!==t)r._subTog.title=t; r._subTog.setAttribute('aria-expanded',f?'false':'true'); }
    // The count after the name is part of the name's markup: restated only when it changed.
    if(r.id&&r._lDiv.__kids!==n){ r._lDiv.__kids=n; refreshLabelRow(r); }
  });
  labelsEl.classList.toggle('has-nest',nest);
}
// Folded tasks, per person, like a folded section: how one person is looking at the plan.
// Subtasks start folded (0.79.0, ClickUp's default): what this browser keeps is the tasks its
// person OPENED, and every other task with subtasks reads as folded, a new one included, so a
// teammate's new subtasks never spread out under somebody else's work. The class answers as
// the set of folded tasks the rest of the page asks about.
const openRows=new Set((prefs.openRows||[]).filter(u=>typeof u==='string'));
class FoldedRows {
  has(k){ return !openRows.has(k); }
  add(k){ openRows.delete(k); return this; }
  delete(k){ const was=!openRows.has(k); openRows.add(k); return was; }
  // "Nothing folded": every task that has subtasks now is opened.
  clear(){ items.forEach(r=>{ if(!isDerivedGroup(r)&&treeHasKids(r))openRows.add(rowTreeKey(r)); }); }
  // Never empty while folding is the default; callers use it only to skip work.
  get size(){ return 1; }
  *[Symbol.iterator](){ for(const r of items){ if(!isDerivedGroup(r)&&treeHasKids(r)){ const k=rowTreeKey(r); if(!openRows.has(k))yield k; } } }
}
const collapsedRows=new FoldedRows();
function saveFolds(){ prefs.openRows=[...openRows]; delete prefs.collapsedRows; savePrefs(); }
function setRowFolded(r,on,persist){
  const k=rowTreeKey(r); if(!k)return;
  if(on)collapsedRows.add(k); else collapsedRows.delete(k);
  if(persist!==false)saveFolds();
}
// The span of a folded task's subtasks, earliest start to latest end, drawn under its own
// bar, so the chart still reads right with the detail put away (issue #5).
function paintSpans(){
  if(!rowsBuilt||prefs.view!=='gantt')return;
  items.forEach(r=>{
    if(!r._bDiv||isDerivedGroup(r))return;
    const on=collapsedRows.has(rowTreeKey(r))&&treeHasKids(r)&&!r._collapsed;
    let el=r._spanEl;
    if(!on){ if(el&&el.style.display!=='none')el.style.display='none'; return; }
    let a=null,b=null;
    treeDesc(r).forEach(x=>{ const s0=x.type==='milestone'?x.date:x.start, e0=x.type==='milestone'?x.date:x.end;
      if(isDateStr(s0)&&(!a||s0<a))a=s0; if(isDateStr(e0)&&(!b||e0>b))b=e0; });
    if(!a||!b){ if(el)el.style.display='none'; return; }
    if(!el){ el=document.createElement('div'); el.className='sub-span'; r._bDiv.appendChild(el); r._spanEl=el; }
    const x1=toX(a),x2=toX(b);
    setStyleIfChanged(el,'display',''); setStyleIfChanged(el,'left',x1+'px'); setStyleIfChanged(el,'width',Math.max(x2-x1,6)+'px');
    el.dataset.title='Subtasks of '+(r.name||'this task'); el.dataset.dates=fmt(a)+' → '+fmt(b);
  });
}

// ── State ──
const phaseVis = phases.map(()=>true);
let msVis=true, depsVis=true;
const selected = new Set();
const barEls = {};
let depSvg;

// ── Undo ──
const undoStack = [];
// Redo only ever replays what undo just took back. It never invents history, so
// any new edit empties it: that is the whole contract, and it lives in pushUndo
// rather than at each of the nine places that record an edit, because the tenth
// one added later would have forgotten.
const redoStack = [];
// Declared here, beside pushUndo, rather than beside the functions that set them, so
// they exist before anything can push. See withOneUndo and phaseKey.
let undoGroup=null, phaseKeySeq=0;
// Edits this page made, counted (0.78.1), and the count at which computeOps last found
// nothing to send. Declared here for the same reason: pushUndo bumps it.
let localEditN=0, cleanAtN=-1;
function markLocalEdit(){ localEditN++; }
// True while a live change that only reorders rows is applied (0.78.1): rows then move only
// inside their own sections, so no section header moves, no section changes height and no
// arrow comes or goes, and the work those would need is left out (reflowRows, drawDeps,
// keepPlaceRows).
let orderOnlyMerge=false;
function pushUndo(sn){
  markLocalEdit();
  if(undoGroup){ undoGroup.push(sn); return; }   // collected by withOneUndo
  sealUndo();
  // Where the plan's outside changes stood when this was done, so its undo can tell a field
  // this person set from one somebody else changed after them (0.77.0).
  if(sn.seq===undefined)sn.seq=syncSeq;
  undoStack.push(sn);
  if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);
  redoStack.length=0;
}
// A row move's undo puts back only the rows THIS move moved, so it needs the order just
// after the move as well as the one before it. Taken the moment anything else happens: the
// next edit, the next save, the next change from outside.
function sealUndo(){
  const top=undoStack[undoStack.length-1];
  if(!top||top.sealed)return;
  top.sealed=true;
  if(top.type==='order'||top.type==='phase'){ if(!top.after)top.after=liveOrder(); return; }
  // A field edit keeps the row as it left it too, so its undo knows which fields IT changed
  // and does not report a field somebody else changed that it never touched.
  const seal=st=>{ if(st.type==='edit'){ const r=items.find(d=>d.id===st.id); if(r)st.after=rawRow(r); } else if(st.type==='ms'&&isLive(st.r))st.after=rawRow(st.r); };
  if(top.type==='multi')(top.steps||[]).forEach(seal); else seal(top);
}
function rawRow(r){ const o={}; Object.keys(RAW).forEach(f=>{ o[f]=cloneV(RAW[f][0](r)); }); return o; }
// ── Undo is yours alone (0.77.0) ──
// As in Google Docs: Ctrl+Z takes back what this person did, and nothing anybody else did
// since. A field somebody else changed after them is left as it is; in a list (dependencies,
// tags) only the items this person added or took out go back. The toast names what was left.
const undoKept=[];
const RAW={
  s:[r=>r.start,(r,v)=>{r.start=v;}], e:[r=>r.end,(r,v)=>{r.end=v;}], date:[r=>r.date,(r,v)=>{r.date=v;}],
  deps:[r=>r.deps||[],(r,v)=>{r.deps=Array.isArray(v)?v.slice():v;}],
  note:[r=>r.note||'',(r,v)=>{r.note=v;}],
  owner:[r=>r.owner,(r,v)=>setOpt(r,'owner',v)], status:[r=>r.status,(r,v)=>setOpt(r,'status',v)],
  desc:[r=>r.description,(r,v)=>setOpt(r,'description',v)], prov:[r=>r.provenance,(r,v)=>setProv(r,v)],
  name:[r=>r.name,(r,v)=>{r.name=v;}], tags:[r=>tagsSnap(r),(r,v)=>setRowTags(r,v)],
  effort:[r=>effortOf(r),(r,v)=>setEffort(r,v)], phase:[r=>r.phase,(r,v)=>{r.phase=v;}],
  parent:[r=>r.parent||'',(r,v)=>{ if(v)r.parent=v; else delete r.parent; }], pos:[r=>r.pos||'',(r,v)=>{ if(posOk(v))r.pos=v; else delete r.pos; }],
};
const rawEq=(f,a,b)=>ROW_SETS[f]?ovEq(f,a,b)&&listOf(a).join('\u0001')===listOf(b).join('\u0001'):ovEq(f,a,b);
// Put one field back to `before`, unless somebody else changed it after `seq`. `after` is the
// row as the edit left it, when known: a field the edit did not change is not reported.
function restoreField(r,f,before,seq,after){
  const [get,set]=RAW[f], cur=get(r);
  if(rawEq(f,cur,before))return false;
  const mine=!after||!(f in after)||!rawEq(f,after[f],before);
  if(ROW_SETS[f]&&r.uid){
    const k=setKey(f), B=listOf(before), C=listOf(cur), Bs=new Set(B.map(k)), Cs=new Set(C.map(k));
    const theirs=x=>(remoteSeq.get(r.uid+'|'+f+'|'+k(x))||0)>seq;
    let kept=0;
    const next=C.filter(x=>{ if(Bs.has(k(x)))return true; if(theirs(x)){ kept++; return true; } return false; });
    B.forEach(x=>{ if(Cs.has(k(x)))return; if(theirs(x)){ kept++; return; } next.push(x); });
    if(kept&&mine)undoKept.push({r,f,who:remoteWho.get(r.uid+'|'+f)||''});
    if(rawEq(f,next,cur))return false;
    set(r,f==='tags'&&before===undefined&&!next.length?undefined:next);
    return true;
  }
  if(r.uid&&(remoteSeq.get(r.uid+'|'+f)||0)>seq){ if(mine)undoKept.push({r,f,who:remoteWho.get(r.uid+'|'+f)||''}); return false; }
  set(r,cloneV(before));
  return true;
}
// The rows one move moved, put back next to the rows they followed, on the order as it is
// now: other people's moves since stay where they put them.
function selectiveOrder(before,after,live){
  const liveSet=new Set(live);
  const B=(before||[]).filter(k=>liveSet.has(k)), A=(after||live).filter(k=>liveSet.has(k));
  const res=live.slice();
  orderMoves(A,B).forEach(m=>moveInOrder(res,m.uid,m.after));
  return res;
}
const UNDO_TOAST_DEFAULT='Undid change &middot; <kbd>Ctrl+Z</kbd>';
let undoTimer = null;
// The snapshot carries owner, status and description too, so a table edit is one
// Ctrl+Z like every other edit rather than a second class of change.
// ONE gesture, ONE undo entry (0.57.1). This used to push an entry per row, so a batch
// Apply over five tasks, or a drag of five selected bars, took five Ctrl+Z to take back
// and the first of them restored one row and left the other four where they had been
// moved. A single row is still a plain edit entry, exactly as before.
// `tags` (0.60.0) is the row's own list as it stands, absent or [] included, so the item
// editor's Apply over name, owner AND tags is still one entry. tagsSnap copies it.
const tagsSnap=r=>Array.isArray(r.tags)?r.tags.slice():undefined;
// A milestone's own entry: its date and its words, and since 0.78.0 its dependencies. It has
// an id now too, so a snapshot taken by id (Dependency mode, the editor's "blocks" list) lands
// here rather than in a task's entry, which would put back a start and an end it never had.
function msSnap(r){ return {type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,tags:tagsSnap(r),deps:r.deps?[...r.deps]:[]}; }
function editSnapshot(r){ if(r.type==='milestone')return msSnap(r); return {type:'edit',id:r.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name,tags:tagsSnap(r),effort:effortOf(r)}; }
function snapshotMany(ids){
  const steps=ids.map(id=>items.find(d=>d.id===id)).filter(Boolean).map(editSnapshot);
  if(steps.length)pushUndo(steps.length===1?steps[0]:{type:'multi',steps});
}
function snapshot(id){snapshotMany([id]);}
// One undo entry covering many rows, for bulk operations like a plan shift.
function snapshotGroup(ids){
  const entries=[];
  // Tasks only: every milestone's date is in `ms` below, and a milestone has an id since
  // 0.78.0, so a caller passing every id in the plan would otherwise list it twice.
  ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r&&r.type!=='milestone')entries.push({id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||''});});
  const ms=items.filter(r=>r.type==='milestone');
  pushUndo({type:'group',entries,ms:ms.map(r=>({r,date:r.date})),phases:phases.map(p=>({p,start:p.start,end:p.end}))});
}
// Legacy label and name keys are still accepted, so an order saved before row
// keys became stable still restores.
function orderKeyMap(){
  const m=new Map();
  items.forEach(r=>m.set(rowOrderKey(r),r));
  items.forEach(r=>{const k=r.label||r.name;if(k&&!m.has(k))m.set(k,r);});
  return m;
}
// All or nothing. This used to push whatever resolved and drop the rest, so one
// stale key shrank the plan and the shrunken plan was then saved over the real one.
// Re-append every row in items order. The dashed milestone rules are painted
// above the rows, so they go back on top afterwards or a reorder buries them.
function reflowRows(){
  // When both panes already show the rows in this order (every live merge that only
  // changed fields, which still rebuilds the section list), only the indices are restated:
  // re-appending ~1000 nodes and re-reading every header's position was ~300 ms a merge,
  // twice, for nothing (0.75.1).
  const same=paneOrderMatches();
  items.forEach((r,i)=>{r._itemIdx=i;if(r.id&&barEls[r.id])barEls[r.id].idx=i;});
  if(!same){
    // Only the rows whose place changed are moved (0.78.1). Somebody else's drag used to
    // re-append every row in both panes, about 1,000 nodes, so every open page restyled
    // and laid out the whole chart again: ~200 ms a moved row. The rows on a longest run
    // already in order stay put; each other one goes in front of its new neighbour. A pane
    // that is not in the shape this expects is rebuilt the old way, which always works.
    const rules=items.filter(r=>r.type==='milestone'&&r._msLine).map(r=>r._msLine);
    const ok=placeInOrder(labelsEl,items.map(r=>r._lDiv),el=>el.classList.contains('label-row'),isQaEl)>=0
      &&placeInOrder(bars,items.map(r=>r._bDiv),el=>el.classList.contains('bar-row'),isQaEl)>=0
      &&placeInOrder(bars,rules,el=>el.classList.contains('ms-line'))>=0
      &&paneOrderMatches();
    if(!ok){
      items.forEach(r=>{labelsEl.appendChild(r._lDiv);bars.appendChild(r._bDiv);});
      rules.forEach(el=>bars.appendChild(el));
    }
  }
  // An order-only merge changes no row's section, so no colour (0.78.1).
  if(!orderOnlyMerge)recolorByPhase();
  refreshTreeDom();
  placeQuickAdd();
  if(same)return;
  keepTailLast();
  // No header moved in an order-only merge, so which one is pinned cannot have changed; asking
  // forced a layout of the whole chart on every page for each row somebody moved (0.78.1).
  if(!orderOnlyMerge)updatePinnedHeader();
}
// The fewest moves that put a pane's rows in the given order (0.78.1): the rows on a longest
// run already in order stay where they are, and every other one is inserted in front of its
// new neighbour, so one row moved by somebody else is one node moved here. The rows must sit
// together in the pane (the tail room, the rules or the arrow layer before or after them, not
// between); -1 when they do not, or when the pane holds a row the list does not, and the
// caller rebuilds the pane instead. Returns how many nodes it moved. `skip` names nodes that
// may sit anywhere among the rows and are placed by their owner afterwards (0.79.0: the
// quick-add row, which follows the row it was opened from).
function placeInOrder(parent,want,isRow,skip){
  const pos=new Map(); want.forEach((el,i)=>{ if(el)pos.set(el,i); });
  const seq=[]; let started=false, ended=false, endRef=null;
  for(const el of parent.children){
    if(skip&&skip(el))continue;
    if(isRow(el)){
      if(ended)return -1;
      const p=pos.get(el); if(p===undefined)return -1;
      seq.push(p); started=true;
    } else if(started&&!ended){ ended=true; endRef=el; }
  }
  // Longest increasing run of the wanted positions, in DOM order (patience sort).
  const tails=[], tailAt=[], prev=new Array(seq.length);
  for(let i=0;i<seq.length;i++){
    const v=seq[i]; let lo=0, hi=tails.length;
    while(lo<hi){ const mid=(lo+hi)>>1; if(tails[mid]<v)lo=mid+1; else hi=mid; }
    tails[lo]=v; tailAt[lo]=i; prev[i]=lo>0?tailAt[lo-1]:-1;
  }
  const keep=new Set();
  for(let i=tails.length?tailAt[tails.length-1]:-1;i>=0;i=prev[i])keep.add(seq[i]);
  let ref=endRef, moved=0;
  for(let i=want.length-1;i>=0;i--){
    const el=want[i]; if(!el)continue;
    if(!keep.has(i)&&(el.parentNode!==parent||el.nextSibling!==ref)){ parent.insertBefore(el,ref); moved++; }
    ref=el;
  }
  return moved;
}
function paneOrderMatches(){
  // Rows in items order, and nothing out of place around them: no row after the names'
  // tail room (a new row is appended at the very end), and no bar row after a milestone
  // rule (the rules are kept above the rows).
  let i=0, pastTail=false;
  for(const el of labelsEl.children){
    if(el===labelTail){ pastTail=true; continue; }
    if(!el.classList.contains('label-row'))continue;
    if(pastTail)return false;
    const r=items[i++]; if(!r||r._lDiv!==el)return false;
  }
  if(i!==items.length)return false;
  let j=0, pastRule=false;
  for(const el of bars.children){
    if(el.classList.contains('ms-line')){ pastRule=true; continue; }
    if(!el.classList.contains('bar-row'))continue;
    if(pastRule)return false;
    const r=items[j++]; if(!r||r._bDiv!==el)return false;
  }
  return j===items.length;
}
// Bar fill and milestone-rule colour are a PURE FUNCTION of the row's current
// phase, nothing else. Before this they were set imperatively at build time and
// patched at a handful of phase-change sites, so any path that changed a phase
// without also touching the inline style left the bar showing its old lane's
// colour: a task with a data-file phase of 0 and a sidecar override of 5 was built
// teal-slot and never recoloured, which is the mixed-colour Drone lane. phaseColor
// returns an explicit p.color unchanged (theme-invariant by design) or the
// var(--phN) ramp slot (which the theme recolours for free), so both kinds land
// on the destination lane's effective colour.
function recolorByPhase(){
  items.forEach(r=>{
    if(isDerivedGroup&&isDerivedGroup(r))return;
    const c=phaseColor(r.phase);
    if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=c;
    if(r._msLine)r._msLine.style.color=c;
  });
}
function refreshChartGeometry(){
  if(prefs.view!=='gantt')return;
  paintSpans(); layoutMsCaps(); sizeTailroom(); drawDeps();
}
// The saved order lists the plan's ROWS. Headers are derived, so they are not in
// it and are re-placed from the phases afterwards; that is what guarantees legend
// order and chart order can never disagree again.
function applyOrder(order){
  if(!Array.isArray(order))return false;
  const byKey=orderKeyMap();
  const real=items.filter(r=>!isDerivedGroup(r));
  const next=order.map(k=>byKey.get(k)).filter(r=>r&&!isDerivedGroup(r));
  if(next.length!==real.length||new Set(next).size!==real.length)return false;
  const hdrs=items.filter(isDerivedGroup);
  const before=items.slice();
  items.length=0;items.push(...next,...hdrs);
  placeHeaders();
  // The same order the panes already show (every live merge that only changed fields):
  // nothing to re-append, no layout to force (0.75.1).
  if(before.length===items.length&&before.every((r,i)=>r===items[i]))return true;
  if(orderOnlyMerge)noteDepSpan(before);
  reflowRows();
  drawDeps();
  return true;
}
// In an order-only merge, the rows between the first and the last place that changed: every
// row outside them has the same rows above it, so the arrows' measurements of it still hold.
function noteDepSpan(before){
  let lo=0, hi=items.length-1;
  while(lo<items.length&&before[lo]===items[lo])lo++;
  while(hi>=0&&before[hi]===items[hi])hi--;
  depSpan=depSpan?[Math.min(depSpan[0],lo),Math.max(depSpan[1],hi)]:[lo,hi];
}

// Deleting is a data change, not a hide: the row leaves items, its DOM goes, its
// bar entry goes, and every dependency pointing at it goes too, or the arrows,
// the pickers and the exports would all keep naming something that is no longer
// in the plan. Everything needed to put it back is returned.
function removeRow(r,silent){
  const idx=items.indexOf(r);
  if(idx<0)return null;
  const droppedDeps=[];
  if(r.id)items.forEach(o=>{
    if(o!==r&&Array.isArray(o.deps)&&o.deps.includes(r.id)){
      droppedDeps.push({id:o.id,dep:r.id});
      o.deps=o.deps.filter(d=>d!==r.id);
    }
  });
  items.splice(idx,1);
  if(r._lDiv&&r._lDiv.parentNode)r._lDiv.remove();
  if(r._bDiv&&r._bDiv.parentNode)r._bDiv.remove();
  if(r._msLine&&r._msLine.parentNode)r._msLine.remove();
  if(r.id)delete barEls[r.id];
  // A row that only ever existed in the sidecar just goes. Recording it as
  // deleted as well would leave an added+deleted pair sitting there for good.
  const ai=addedItems.indexOf(r);
  if(ai>=0)addedItems.splice(ai,1);
  else deletedKeys.add(rowOrderKey(r));
  r._wasAdded=ai>=0;
  // A deletion on the server's say-so is kept whole, so another tab's undo of it can put
  // it back here without a reload.
  if(silent)remoteRemoved.set(rowOrderKey(r),{r,idx});
  items.forEach((x,i)=>{x._itemIdx=i;if(x.id&&barEls[x.id])barEls[x.id].idx=i;});
  if(!silent){keepTailLast();applyFilter();refreshChartGeometry();}
  return {idx,droppedDeps};
}
function restoreRow(r,idx,droppedDeps){
  remoteRemoved.delete(rowOrderKey(r));
  if(r._wasAdded){ if(!addedItems.includes(r))addedItems.push(r); }
  else deletedKeys.delete(rowOrderKey(r));
  items.splice(Math.max(0,Math.min(idx,items.length)),0,r);
  (droppedDeps||[]).forEach(d=>{const o=items.find(x=>x.id===d.id);
    if(o){o.deps=o.deps||[];if(!o.deps.includes(d.dep))o.deps.push(d.dep);}});
  if(r.id&&r._bDiv){const b=r._bDiv.querySelector('.bar[data-id]');if(b)barEls[r.id]={bar:b,idx,row:r._bDiv};}
  if(rowBatch){ rowBatchDirty=true; return; }
  placeHeaders();reflowRows();applyFilter();refreshChartGeometry();
}
// Put a fully-formed item into the plan: give it a key if it has none, build the
// same DOM the data-file rows got, and drop it into its lane.
function insertRow(r,silent){
  if(!r.uid&&!r._key&&!r.id)r._key=freshKey();
  items.push(r);
  buildRowDom(r,items.length-1);
  if(r.type==='milestone'&&r._msLine)bars.appendChild(r._msLine);
  homeRow(r);
  if(!silent){applyFilter();refreshChartGeometry();}
  return r;
}
// The same build, dropped at a CHOSEN index rather than at the end of its lane.
// Paste is the only caller: pasting below a selected row means below THAT row, not
// at the bottom of the lane it happens to be in, and homeRow can only ever say the
// bottom. The row is built at the end (buildRowDom appends, and it needs a real
// index for barEls) and then moved, which is exactly what homeRow does too, so the
// two paths differ in one line and not in how a row comes into existence.
function insertRowAt(r,idx,silent){
  // Since 0.79.0 a row's place is its own (parent and key), set by the caller; `idx` only
  // seeds where a row with no key yet sits until it gets one.
  if(!r.uid&&!r._key&&!r.id)r._key=freshKey();
  items.push(r);
  buildRowDom(r,items.length-1);
  if(r.type==='milestone'&&r._msLine)bars.appendChild(r._msLine);
  const cur=items.indexOf(r);
  items.splice(cur,1);
  items.splice(Math.max(0,Math.min(idx,items.length)),0,r);
  homeRow(r);
  if(!silent){applyFilter();refreshChartGeometry();}
  return r;
}
// Place a row at the end of its phase's block. Shared by creation and by a phase
// move, so a new row lands exactly where a moved one would.
function homeRow(r){
  // A row with no key yet goes after its siblings (0.79.0): the end of its section, or the
  // last subtask of its parent. One with a key already has its place.
  if(!posOk(r.pos)&&!isDerivedGroup(r)){
    const par=r.parent?(treeRows.get(r.parent)||rowByUid(r.parent)):null;
    const sib=siblingsIn(par,r.phase,new Set([r])).filter(x=>posOk(x.pos));
    r.pos=keyBetween(sib.length?sib[sib.length-1].pos:null,null);
  }
  if(rowBatch){ rowBatchDirty=true; return; }
  placeHeaders();
  reflowRows();
}
// ── Placing rows (0.79.0) ──
// The rows that sit beside each other under `parent` (a row, or null for the top level of
// section `phase`), in plan order, less the ones in `skip`.
function siblingsIn(parent,phase,skip){
  const pk=parent?rowTreeKey(parent):'';
  return items.filter(x=>!isDerivedGroup(x)&&x.type!=='group'&&!(skip&&skip.has(x))&&(pk?treeParent(x)===pk:(!treeParent(x)&&x.phase===phase)));
}
// `rows`, in this order, put straight after `after` (one of the siblings, or null: first) under
// `parent` in section `phase`. Each gets a key between that sibling's and the next one's, so
// the move is a change to these rows' own fields and nothing else. A subtask's section is its
// parent's; the layout writes it.
function placeRowsAt(rows,parent,phase,after){
  const set=new Set(rows), sib=siblingsIn(parent,phase,set);
  let i=after?sib.indexOf(after):-1;
  if(after&&i<0)i=sib.length-1;
  const keys=keysBetween(i>=0?sib[i].pos:null,sib[i+1]?sib[i+1].pos:null,rows.length);
  rows.forEach((r,j)=>{ if(parent){ r.parent=rowTreeKey(parent); r.phase=parent.phase; } else { delete r.parent; r.phase=phase; } r.pos=keys[j]; });
}
// The places of some rows, for one undo entry: putting them back is putting these back.
function placeSnap(rows){ return {type:'place',steps:rows.filter(Boolean).map(r=>({r,parent:r.parent||'',pos:r.pos||'',phase:r.phase}))}; }
// The rows of a selection that move as a unit: each one with no selected row above it in the
// tree, in plan order. Its subtasks go with it.
function treeRoots(rows){
  const set=new Set(rows);
  return rows.filter(r=>{ let p=treeParent(r); while(p){ const x=treeRows.get(p); if(x&&set.has(x))return false; p=tree.parentOf.get(p); } return true; })
    .sort((a,b)=>items.indexOf(a)-items.indexOf(b));
}
// The sibling just before or after `r` in its group, or null.
function sibNext(r,dir){
  const par=treeParent(r)?treeRows.get(treeParent(r)):null, sib=siblingsIn(par,r.phase,null), i=sib.indexOf(r);
  return i<0?null:sib[i+dir]||null;
}
function lastKid(r,skip){ const k=treeKids(r).filter(x=>!(skip&&skip.has(x))); return k.length?k[k.length-1]:null; }
// Can `rows` (and their subtasks) go under `parent`? Not a milestone, not into their own
// subtree, and no deeper than the limit.
function canNestUnder(rows,parent){
  if(!parent)return true;
  if(parent.type==='milestone'||isDerivedGroup(parent))return false;
  return rows.every(r=>r!==parent&&!treeUnderRow(parent,r)&&treeDepth(parent)+1+treeHeight(tree,rowTreeKey(r))<=TREE_DEPTH_MAX);
}
// After a change of places: lay the rows out again, and everything that hangs off them.
function layoutRows(){ placeHeaders(); reflowRows(); applyFilter(); refreshChartGeometry(); }
// Many rows placed at once (the saved rows at startup, a live merge's new rows) reflow the
// two panes ONCE at the end (0.75.1). Each one used to re-append all ~500 rows and read
// every header's position, a full layout per row: 23 s of a 32 s startup on a real plan.
let rowBatch=0, rowBatchDirty=false;
function inRowBatch(fn){
  rowBatch++;
  try{ fn(); }
  finally{ rowBatch--; if(!rowBatch&&rowBatchDirty){ rowBatchDirty=false; const before=orderOnlyMerge?items.slice():null; placeHeaders(); if(before)noteDepSpan(before); reflowRows(); } }
}

// A group header is a row like any other, so moving a task to another phase has
// to move it under that phase's header. Changing only item.phase would leave it
// sitting visually in the lane it came from, which is worse than not moving it.
function moveRowToPhase(r,np){
  // To the end of that section as a top-level row (0.79.0), its subtasks with it. A subtask
  // moved this way leaves its parent: a subtask always sits in its parent's section.
  const sib=siblingsIn(null,np,new Set([r]));
  delete r.parent; r.phase=np; r.pos=keyBetween(sib.length?sib[sib.length-1].pos:null,null);
  homeRow(r);
  if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(np);
  if(r._msLine)r._msLine.style.color=phaseColor(np);
  applyFilter();refreshChartGeometry();
}

// ── Moving a row with the keyboard ──
//
// Alt+Up and Alt+Down move one row one slot: the focused row in the table, the
// selected task in the chart. Alt, because the arrows on their own belong to
// whatever the user is typing into, and because nothing else in the app or the
// browser chrome claims that chord here.

// A collapsed lane hides its rows, so a row moved into one used to vanish in the
// middle of a press and every press after that was a guess at where it had got to.
// The lane OPENS at the moment the moving row actually crosses into it.
//
// That is a different question from the five-row scroll buffer, and deliberately so:
// the buffer is about what is near the EDGE OF THE PANE, this is about where the row
// has LANDED. A row travelling towards a collapsed lane opens nothing, however close
// it gets; a row that enters one opens it, on that keystroke and no earlier. A lane
// merely stepped over on the way (an empty one, which a single press does skip)
// stays shut, because nothing landed in it.
//
// A real expansion, the same state the header toggle writes and stored in prefs
// beside it, so it survives the keys coming up. Undoing the move does NOT shut it
// again: what the reader is looking at is not part of the plan, and the undo of a
// move owns the plan.
function openLandingLanes(rows){
  let opened=false;
  rows.forEach(r=>{ if(r&&isCollapsed(r.phase)){ collapsed.delete(r.phase); opened=true; } });
  if(!opened)return false;
  prefs.collapsed=[...collapsed]; savePrefs();
  applyCollapse();
  return true;
}
// Plan order: a row steps past its neighbour, taking its subtasks with it, and the
// neighbour's subtasks are stepped over whole (0.79.0). A subtask moves among its own
// siblings and stops at the first and the last (Alt+Left takes it out). A top-level row at
// the edge of its section steps into the next section that has rows, as it always has:
// going down it lands at the top of that section, going up at the bottom.
function planNeighbourMove(r,dir){
  const par=treeParent(r)?treeRows.get(treeParent(r)):null;
  const sib=siblingsIn(par,r.phase,null), i=sib.indexOf(r);
  if(i<0)return 'edge';
  const j=i+dir;
  if(j>=0&&j<sib.length){ placeRowsAt([r],par,r.phase,dir>0?sib[j]:(j>0?sib[j-1]:null)); return 'ok'; }
  if(par)return 'sub-edge';
  for(let p=r.phase+dir;p>=0&&p<phases.length;p+=dir){
    const s2=siblingsIn(null,p,new Set([r]));
    if(!s2.length)continue;
    placeRowsAt([r],null,p,dir>0?null:s2[s2.length-1]);
    return 'ok';
  }
  return 'edge';
}
// Under a column sort the rows on screen are not the rows in the plan, so a move
// that could go anywhere would either lie about the sort or silently re-sort the
// plan. Movement is bounded to the run of rows the sort considers TIED with this
// one: under an owner sort an Adonis task moves within the Adonis block and
// nowhere else, the visible order stays exactly what the sort says it is, and
// what actually changes is the tie-break, which is plan position, which persists.
function sortedNeighbourMove(r,dir){
  const rows=tableRows();
  const vi=rows.indexOf(r);
  if(vi<0)return 'gone';
  const key=sortValueOf(r,sortKey);
  let a=vi,b=vi;
  while(a>0&&sortValueOf(rows[a-1],sortKey)===key)a--;
  while(b<rows.length-1&&sortValueOf(rows[b+1],sortKey)===key)b++;
  if(a===b)return 'alone';
  const ti=vi+dir;
  if(ti<a||ti>b)return 'edge';
  // The comparator breaks ties on plan position ascending, in both sort
  // directions, so landing after the neighbour is what puts the row after it.
  // Plan position is a row's place among its siblings (0.79.0), so only two rows that sit
  // side by side under one parent, or at the top of one section, can trade places here.
  const n=rows[ti];
  if((treeParent(n)||'')!==(treeParent(r)||'')||(!treeParent(r)&&n.phase!==r.phase))return 'apart';
  const par=treeParent(r)?treeRows.get(treeParent(r)):null, sib=siblingsIn(par,r.phase,new Set([r])), k=sib.indexOf(n);
  placeRowsAt([r],par,r.phase,dir>0?n:(k>0?sib[k-1]:null));
  return 'ok';
}
// The chart has one selection model and it is the one this uses. More than one
// row selected is a different gesture (a batch move) and is not this one.
function soleSelectedRow(){
  if(selected.size!==1)return null;
  return rowByUid([...selected][0]);
}
// A held arrow key is one intent, not eight. Undo stays per press, because a
// keystroke is the unit you want back, but the changelog waits for the burst to
// finish and then records the net move as one readable line.
let keyMoveTimer=null;
function scheduleKeyMoveSave(){
  clearTimeout(keyMoveTimer);
  keyMoveTimer=setTimeout(()=>{keyMoveTimer=null;scheduleSave('key-move');},600);
}
function flushKeyMove(){
  if(!keyMoveTimer)return;
  clearTimeout(keyMoveTimer);keyMoveTimer=null;
  scheduleSave('key-move');
}
// Move a whole selection one slot, as a group. Each selected row steps one place,
// so the gaps between them survive: a non-contiguous selection stays non-contiguous
// rather than compressing into a block on the way. Order of operations matters and
// is the whole trick: going down, the bottom-most selected row moves first, so it is
// never stepping over a sibling that has not moved yet; going up, the topmost does.
//
// Tried on the rows' places first. If ANY selected row has nowhere to go, the press does
// nothing at all rather than moving the ones that can and quietly squashing the
// selection against the end of the plan. Since 0.79.0 the rows that move are the
// selection's top rows in the tree, each with its subtasks.
function planGroupMove(rows,dir){
  const before=rows.map(r=>({r,parent:r.parent,pos:r.pos,phase:r.phase}));
  const seq=dir>0?rows.slice().reverse():rows.slice();
  for(const r of seq){
    const res=planNeighbourMove(r,dir);
    if(res!=='ok'){ before.forEach(b=>{ if(b.parent)b.r.parent=b.parent; else delete b.r.parent; if(b.pos)b.r.pos=b.pos; else delete b.r.pos; b.r.phase=b.phase; }); placeHeaders(); return false; }
    placeHeaders();
  }
  return true;
}
// ── Grouping a scattered selection ──
//
// Ctrl+Click builds a selection with GAPS in it: rows taken from up and down the
// plan with unselected rows left between them. Stepping that a slot at a time keeps
// the gaps, which is right for a set the reader wants to stay spread out and wrong
// for the far commoner intent, which is to bring them together. So the FIRST
// Alt+Arrow press on a gapped selection GROUPS it: the selected rows close up into
// one contiguous block at the topmost selected row's slot, in the order they already
// had, and the rows that were between them close up below it.
//
// That press does not also step the block. Anchoring the block on the topmost
// selected row and then moving it one place in the same press would leave it
// anchored one row off the thing it was aligned to, would make Alt+Up mean something
// different from Alt+Down at the moment the block forms, and would put two changes
// under one Ctrl+Z. One press, one visible effect, one undo. The block is contiguous
// afterwards, so the NEXT press moves it as one, which is exactly what a contiguous
// selection has always done, through exactly the same code.
//
// No flag is kept and none is wanted: the SHAPE of the selection is the state. Scatter
// it again with another Ctrl+Click and the next press groups it again, close it up by
// hand and the next press moves it.
function gappedSelection(rows){
  // Measured over real rows only. A section header between two selected rows is not
  // a gap: it is not a slot anything can occupy, and a selection either side of one
  // is contiguous and has always moved as a block.
  // A selected row's subtasks travel with it, so they are not gaps either (0.79.0).
  const under=new Set(); rows.forEach(r=>treeDesc(r).forEach(x=>under.add(x)));
  const real=items.filter(r=>r.type!=='group'&&!under.has(r));
  const pos=rows.map(r=>real.indexOf(r)).sort((a,b)=>a-b);
  if(pos.length<2||pos[0]<0)return false;
  return pos[pos.length-1]-pos[0]!==pos.length-1;
}
// The selected rows go to the topmost one's slot, in plan order; everything else
// keeps the order it had and closes up. Rows pulled up beside the topmost row join its
// section (and, since 0.79.0, its parent) and recolour.
function groupSelectedRows(rows,dir){
  // The selected rows close up under the topmost one, as its next siblings, in plan order,
  // each with its subtasks (0.79.0): the topmost row's parent and section are where they land.
  const top=rows[0], par=treeParent(top)?treeRows.get(treeParent(top)):null, rest=rows.slice(1);
  if(par&&!canNestUnder(rest,par)){ flash(saveInd,'these rows cannot all sit under "'+(par.name||'that task')+'": too deep',3200); return true; }
  const snap=placeSnap(rows);
  placeRowsAt(rest,par,top.phase,top);
  pushUndo(snap);
  placeHeaders();
  openLandingLanes(rows);
  applyCollapse();reflowRows();applyFilter();refreshChartGeometry();renderTable();
  refreshSelVisuals();
  followMovedRows(rows,dir);
  moveHint=rows.length+' rows grouped together';
  flash(saveInd,'Grouped '+rows.length+' tasks, the arrows now move the block',3200);
  scheduleKeyMoveSave();
  return true;
}
function moveSelectedRows(dir){
  const sel=selectedRows();
  if(sel.length<2)return false;
  const rows=treeRoots(sel);
  if(rows.length<2){ moveOneRow(rows[0],dir,false); return true; }
  if(gappedSelection(rows))return groupSelectedRows(rows,dir);
  // One undo entry for the whole press, every row's place in it.
  const snap=placeSnap(rows);
  if(!planGroupMove(rows,dir)){ flash(saveInd,'the end of the plan'); return true; }
  pushUndo(snap);
  openLandingLanes(rows);                   // a block that has entered a shut lane opens it
  applyCollapse();reflowRows();applyFilter();refreshChartGeometry();renderTable();
  refreshSelVisuals();                      // the selection survives, and still looks it
  followMovedRows(rows,dir);
  moveHint=rows.length+' rows';
  scheduleKeyMoveSave();
  return true;
}
// ── Following a moved row ───────────────────────────────────────────────────
//
// Alt+Up and Alt+Down move a row through the plan, and until now the pane stayed
// where it was, so a row moved far enough simply left the screen and the next press
// was a guess. The pane now follows it, and it follows with a MARGIN: the row is
// kept five rows clear of the edge it is travelling towards, so the reader can see
// what it is about to move past rather than only where it has got to.
//
// Only when it has to. The scroll is the difference between where the row is and
// the nearest position that satisfies the margin, so a row moving about the middle
// of the pane does not move the pane at all, and a held key that walks a row down
// thirty places scrolls exactly as far as those thirty places needed.
//
// Instant, not smooth, and that is the point rather than a shortcut: a held Alt+Down
// repeats faster than a smooth scroll settles, so a smooth one would fall behind the
// row it is supposed to be following and then overshoot when the key came up.
const ROW_BUFFER=5;
// The usable top of the chart pane is NOT the pane's top edge: the time axis is
// sticky there, and a section header pins under it. Both are opaque, so a row level
// with either of them is a row the reader cannot see.
function chartViewBand(){
  const pane=timelineEl.getBoundingClientRect();
  let top=pane.top+46*(prefs.fs||1);
  if(pinnedGroup&&pinnedGroup._bDiv){
    const g=pinnedGroup._bDiv.getBoundingClientRect();
    if(g.bottom>top&&g.top<pane.bottom)top=g.bottom;
  }
  return {top,bottom:pane.bottom};
}
// How far to scroll so `box` sits clear of the edge it is heading for by `pad`,
// without ever pushing the row itself out of the band on the other side. Positive
// means scroll down. One function for both lenses, because the question is the same
// one in each and only the band and the scroller differ.
function followDelta(box,band,pad,dir){
  let d=0;
  if(dir>0){
    if(box.bottom+pad>band.bottom)d=box.bottom+pad-band.bottom;
    d=Math.min(d,box.top-band.top);
  }else{
    if(box.top-pad<band.top)d=box.top-pad-band.top;
    d=Math.max(d,box.bottom-band.bottom);
  }
  return d;
}
function followChartRow(r,dir){
  if(prefs.view!=='gantt'||!r||!r._bDiv)return 0;
  const el=r._bDiv;
  if(el.classList.contains('row-collapsed')||!el.offsetParent)return 0;
  const d=followDelta(el.getBoundingClientRect(),chartViewBand(),ROW_BUFFER*(el.offsetHeight||34),dir);
  if(!d)return 0;
  timelineEl.scrollTop+=d;
  // The two panes are one scroll (0.34.0), so the label column is brought along in
  // the same tick rather than left to the scroll event, which fires after the next
  // keypress of a held repeat has already been handled.
  syncLabelScroll();
  updatePinnedHeader();
  return d;
}
function followTableRow(tr,dir){
  const sc=document.querySelector('.table-scroll');
  if(!sc||!tr)return 0;
  const pane=sc.getBoundingClientRect();
  const band={top:Math.max(pane.top,tableHead.getBoundingClientRect().bottom),bottom:pane.bottom};
  const d=followDelta(tr.getBoundingClientRect(),band,ROW_BUFFER*(tr.offsetHeight||26),dir);
  if(d)sc.scrollTop+=d;
  return d;
}
// One entry point, so the two move paths cannot follow the row differently. `rows`
// is the moving selection; the one that matters is the LEADING row in the direction
// of travel, because that is the edge the reader is about to lose sight of.
function followMovedRows(rows,dir){
  const live=rows.filter(Boolean);
  if(!live.length)return 0;
  const lead=live.reduce((a,b)=>((items.indexOf(b)>items.indexOf(a))===(dir>0)?b:a));
  if(prefs.view==='gantt')return followChartRow(lead,dir);
  const tr=visibleRowEls().find(t=>t.dataset.key===rowKey(lead));
  return followTableRow(tr||focusedRowEl(),dir);
}
function moveFocusedRow(dir){
  const table=prefs.view==='table';
  // More than one row selected moves the lot, as a group, in either lens: it is one
  // selection and one machinery. Under an active column sort a GROUP move is refused
  // rather than guessed at. A single row under a sort is bounded to the run of rows
  // the sort considers tied with it (0.17.0), and composing that rule across a set,
  // where each row has its own run and the runs move as the rows do, has no answer
  // that stays truthful to the sort. Dragging is refused under a sort for the same
  // reason, so this is the behaviour already established rather than a new rule.
  if(selected.size>1){
    if(table&&!manualOrder()){
      flash(saveInd,'sorted, so a group cannot be moved. Back to plan order to reorder',3200);
      return;
    }
    if(moveSelectedRows(dir))return;
  }
  const r=table?rowItem(focusedKey):soleSelectedRow();
  if(!r){
    flash(saveInd, table
      ? 'click a cell first, then Alt+Up and Alt+Down move that row'
      : 'select a task first');
    return;
  }
  moveOneRow(r,dir,table);
}
function moveOneRow(r,dir,table){
  const sorted=table&&!manualOrder();
  const snap=placeSnap([r]);
  const res=sorted?sortedNeighbourMove(r,dir):planNeighbourMove(r,dir);
  if(res!=='ok'){
    if(res==='alone')flash(saveInd,'no other row is tied with this one under this sort');
    else if(res==='apart')flash(saveInd,'under a sort, rows only trade places with a row beside them in the plan',3200);
    else if(res==='sub-edge'){ const p=treeRows.get(treeParent(r)); flash(saveInd,`the ${dir>0?'last':'first'} subtask of "${p?p.name:'its task'}". Alt+Left takes it out`,3200); }
    else if(res==='edge')flash(saveInd,sorted?'the end of the tied group':'the end of the plan');
    return;
  }
  pushUndo(snap);
  placeHeaders();
  openLandingLanes([r]);                    // it landed in a shut lane, so the lane opens
  reflowRows();applyFilter();refreshChartGeometry();renderTable();
  // A milestone's row key is its position among the milestones, so it can change
  // under the row that carries it. Re-read it rather than keep a stale one.
  if(table)setFocusedRow(rowKey(r));
  followMovedRows([r],dir);
  moveHint='"'+(r.name||'a row')+'"';
  scheduleKeyMoveSave();
}
// Apply a snapshot, and return the snapshot that puts things back. Undo and redo
// are the same operation pointed in opposite directions, so there is exactly one
// implementation of "restore this state" and the two directions cannot drift
// apart the way two hand-written halves would. Every branch captures its own
// inverse from the CURRENT state before it overwrites it, except create and
// delete, which are each other's inverse and take it from what removeRow returns.
// Returns null when the snapshot no longer resolves, which is the old early
// return: a stale entry does nothing rather than half-applying.
function applySnapshot(sn,seqIn){
  const seq=sn.seq!==undefined?sn.seq:(seqIn!==undefined?seqIn:Infinity);
  // A request (0.80.0): made, answered or withdrawn, and taken back the same way.
  if(sn.type==='req')return applyReqSnapshot(sn);
  // Project dates (0.75.0): the inverse is the dates as they stand.
  if(sn.type==='pdates'){
    const inv={type:'pdates',start:planDates.start,end:planDates.end};
    setPlanDates(sn.start,sn.end);
    return inv;
  }
  // The working calendar (0.76.0), the same way.
  if(sn.type==='pcal'){
    const inv={type:'pcal',cal:{workHolidays:planCal.workHolidays.slice(),extraDaysOff:planCal.extraDaysOff.slice()}};
    setPlanCal(sn.cal);
    return inv;
  }
  // Rows' places (0.79.0): a move, a nest, an un-nest. Each row goes back to the place it had,
  // as one value, unless somebody else has moved it since: then theirs stands and the toast
  // says so. Its subtasks go with it.
  if(sn.type==='place'){
    const inv={type:'place',steps:[]};
    (sn.steps||[]).forEach(st=>{
      const r=st.r; if(!isLive(r))return;
      inv.steps.push({r,parent:r.parent||'',pos:r.pos||'',phase:r.phase});
      if(r.uid&&(remoteSeq.get(r.uid+'|place')||0)>seq){ undoKept.push({r,f:'place',who:remoteWho.get(r.uid+'|place')||''}); return; }
      if(st.parent)r.parent=st.parent; else delete r.parent;
      if(posOk(st.pos))r.pos=st.pos; else delete r.pos;
      if(Number.isInteger(st.phase)&&st.phase>=0&&st.phase<phases.length)r.phase=st.phase;
    });
    placeHeaders();
    openLandingLanes(inv.steps.map(x=>x.r));
    applyCollapse();reflowRows();applyFilter();refreshChartGeometry();
    return inv;
  }
  if(sn.type==='order'){
    const live=liveOrder();
    const inv={type:'order',order:live,
      phases:(sn.phases||[]).map(pc=>({r:pc.r,from:pc.r.phase}))};
    (sn.phases||[]).forEach(pc=>{
      if(!isLive(pc.r)||!restoreField(pc.r,'phase',pc.from,seq))return;
      if(pc.r.id&&barEls[pc.r.id])barEls[pc.r.id].bar.style.background=phaseColor(pc.r.phase);
      if(pc.r._msLine)pc.r._msLine.style.color=phaseColor(pc.r.phase);
    });
    inv.after=selectiveOrder(sn.order,sn.after,live);
    applyOrder(inv.after);
    if(sn.phases&&sn.phases.length){applyCollapse();applyFilter();refreshChartGeometry();}
    return inv;
  }
  // A list of snapshots applied as one. The inverse is the inverses in reverse, so
  // undoing a batch and redoing it both land exactly where they started.
  if(sn.type==='phase.state')return restorePhaseState(sn);
  // Section tags: [phaseKey, tags] pairs, the lists as they were. Found by the section's
  // client identity, never by index or name, so a reorder or a rename in between does
  // not send the tags to the wrong section. A section that is gone is skipped.
  if(sn.type==='phase.tags'){
    const inv={type:'phase.tags',list:[]};
    (sn.list||[]).forEach(([k,t])=>{ const j=phaseIndexOfKey(k); if(j<0)return;
      const p=phases[j]; inv.list.push([k,(p.tags||[]).slice()]);
      if(t&&t.length)p.tags=t.slice(); else delete p.tags; });
    // Row lists (0.60.0), by the row OBJECT, as the create and delete entries hold it: a
    // row deleted since is not in the plan and is skipped, and one put back by an undo of
    // that delete is the same object, so its tags resolve again.
    if(sn.rows){ inv.rows=[];
      sn.rows.forEach(([r,t])=>{ if(!items.includes(r))return;
        inv.rows.push([r,Array.isArray(r.tags)?r.tags.slice():undefined]);
        setRowTags(r,t); refreshRowTags(r); }); }
    if(sn.reg){ inv.reg=regSnap(); tagReg=sn.reg.map(e=>Object.assign({},e)); }
    if(!inv.list.length&&!(inv.rows&&inv.rows.length)&&!sn.reg)return null;
    // A rename moved the reader's filter with it; its undo and redo move it back and on.
    if(sn.filter){ inv.filter=prefs.tagFilter.slice(); prefs.tagFilter=sn.filter.slice(); savePrefs(); }
    afterTagsChange();
    return inv;
  }
  // The tag registry as it was (0.61.0): a new tag, a colour.
  if(sn.type==='tag.reg'){
    const inv={type:'tag.reg',reg:regSnap()};
    tagReg=sn.reg.map(e=>Object.assign({},e));
    regInSidecar=true;
    afterTagsChange();
    return inv;
  }
  if(sn.type==='phase.rename'){
    const p=phases[sn.i]; if(!p)return null;
    const inv={type:'phase.rename',i:sn.i,name:p.name};
    p.name=sn.name; refreshSections();
    return inv;
  }
  if(sn.type==='phase.order'){
    // Keyed by phaseKey, never by name. Until 0.58.0 this was p.uid||p.name, and a plan
    // whose sections carry no uid (the reference launch plan has none) and two sections with the
    // same name resolved both keys to the FIRST of them: phases[] came back holding one
    // object twice, a section was gone and its rows merged into the other, and it saved.
    const inv={type:'phase.order',order:phases.map(phaseKey)};
    const want=sn.order;
    const arr=want.map(k=>phases.find(p=>phaseKey(p)===k)).filter(Boolean);
    if(arr.length!==phases.length||new Set(arr).size!==arr.length)return null;
    const map=new Map();
    arr.forEach((p,ni)=>map.set(phases.indexOf(p),ni));
    applyPhaseOrder(arr,map);
    return inv;
  }
  if(sn.type==='multi'){
    const inv=[];
    (sn.steps||[]).forEach(st=>{ const i=applySnapshot(st,seq); if(i)inv.unshift(i); });
    return {type:'multi',steps:inv};
  }
  if(sn.type==='delete'){
    // Somebody else put it back already: there is nothing to restore.
    if(isLive(sn.r))return {type:'create',r:sn.r};
    restoreRow(sn.r,sn.idx,sn.droppedDeps);
    // Putting the row back is undone by taking it out again, and it is the SAME
    // object, so its id and its stable key survive the round trip and everything
    // keyed on them still resolves.
    return {type:'create',r:sn.r};
  }
  if(sn.type==='create'){
    if(!isLive(sn.r))return {type:'multi',steps:[]};
    // A row somebody else has worked on since this person made it stays: taking it out
    // would take their work with it.
    if(sn.r.uid&&(remoteRowSeq.get(sn.r.uid)||0)>seq){ undoKept.push({r:sn.r,f:'row',who:remoteWho.get(sn.r.uid+'|row')||''}); return {type:'multi',steps:[]}; }
    const info=removeRow(sn.r); renderTable();
    return {type:'delete',r:sn.r,idx:info?info.idx:items.length,droppedDeps:info?info.droppedDeps:[]};
  }
  if(sn.type==='phase'){
    if(!isLive(sn.r))return null;
    const live=liveOrder();
    const inv={type:'phase',r:sn.r,phase:sn.r.phase,order:live};
    if(restoreField(sn.r,'phase',sn.phase,seq)){
      if(sn.r.id&&barEls[sn.r.id])barEls[sn.r.id].bar.style.background=phaseColor(sn.r.phase);
      if(sn.r._msLine)sn.r._msLine.style.color=phaseColor(sn.r.phase);
    }
    inv.after=selectiveOrder(sn.order,sn.after,live);
    applyOrder(inv.after);applyFilter();refreshChartGeometry();
    return inv;
  }
  if(sn.type==='ms'){
    const r=sn.r;
    if(!isLive(r))return null;
    const inv=msSnap(r);
    const tagsBefore=ownTags(r).slice();
    const A=sn.after;
    restoreField(r,'date',sn.date,seq,A);
    if(sn.name!==undefined)restoreField(r,'name',sn.name,seq,A);
    restoreField(r,'owner',sn.owner,seq,A); restoreField(r,'status',sn.status,seq,A); restoreField(r,'desc',sn.desc,seq,A);
    if('prov' in sn)restoreField(r,'prov',sn.prov,seq,A);
    if('tags' in sn)restoreField(r,'tags',sn.tags,seq,A);
    // An entry from before 0.78.0 carries no dependencies and leaves them as they are.
    if('deps' in sn)restoreField(r,'deps',Array.isArray(sn.deps)?sn.deps:[],seq,A);
    const tagsMoved=!sameTags(ownTags(r),tagsBefore);
    refreshMsChrome(r);updateMilestone(r);
    if(tagsMoved)afterTagsChange();
    return inv;
  }
  if(sn.type==='group'){
    // one Ctrl+Z undoes the whole shift, not 26 tasks one at a time, and one
    // Ctrl+Shift+Z puts the whole shift back
    const inv={type:'group',
      entries:sn.entries.map(en=>{const r=items.find(d=>d.id===en.id);
        return r?{id:en.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||''}:Object.assign({},en);}),
      ms:(sn.ms||[]).map(m=>({r:m.r,date:m.r.date})),
      phases:(sn.phases||[]).map(x=>({p:x.p,start:x.p.start,end:x.p.end}))};
    sn.entries.forEach(en=>{const r=items.find(d=>d.id===en.id);if(!r)return;
      restoreField(r,'s',en.s,seq); restoreField(r,'e',en.e,seq); restoreField(r,'deps',en.deps,seq); restoreField(r,'note',en.note,seq);});
    (sn.ms||[]).forEach(m=>{ if(isLive(m.r))restoreField(m.r,'date',m.date,seq); });
    (sn.phases||[]).forEach(x=>{x.p.start=x.start;x.p.end=x.end;});
    rebuildTimeline();updateBars();
    return inv;
  }
  const r=items.find(d=>d.id===sn.id);
  if(!r)return null;
  const inv={type:'edit',id:sn.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',
             owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name,tags:tagsSnap(r),effort:effortOf(r)};
  const tagsBefore=ownTags(r).slice();
  const A=sn.after;
  if('tags' in sn)restoreField(r,'tags',sn.tags,seq,A);
  if('effort' in sn)restoreField(r,'effort',sn.effort,seq,A);
  restoreField(r,'s',sn.s,seq,A); restoreField(r,'e',sn.e,seq,A); restoreField(r,'deps',Array.isArray(sn.deps)?sn.deps:[],seq,A);
  if(sn.note!==undefined)restoreField(r,'note',sn.note,seq,A);
  if('owner' in sn)restoreField(r,'owner',sn.owner,seq,A);
  if('status' in sn)restoreField(r,'status',sn.status,seq,A);
  if('desc' in sn)restoreField(r,'desc',sn.desc,seq,A);
  if('prov' in sn)restoreField(r,'prov',sn.prov,seq,A);
  if(sn.name!==undefined)restoreField(r,'name',sn.name,seq,A);
  const tagsMoved=!sameTags(ownTags(r),tagsBefore);
  updateBar(sn.id);refreshLabelRow(r);refreshChartGeometry();
  if(tagsMoved)afterTagsChange();
  return inv;
}
// One step in either direction. The entry comes off one stack and its inverse
// goes onto the other, which is what makes undo, redo, undo land exactly where
// undo did. Note that neither of these goes through pushUndo: time travel is the
// one thing that must NOT clear the other stack.
let timeTraveling=false;
function timeTravel(from,to,via,label,chord){
  if(!from.length)return false;
  const sn=from.pop();
  const fPre=prefs.tagFilter.slice();
  timeTraveling=true;
  undoKept.length=0;
  let inv;
  try{
    inv=applySnapshot(sn);
    if(inv===null)return false;
    inv.seq=syncSeq;
    // The row as this step left it, for the step back (see sealUndo).
    const seal=st=>{ if(st.type==='edit'){ const r=items.find(d=>d.id===st.id); if(r)st.after=rawRow(r); } else if(st.type==='ms'&&isLive(st.r))st.after=rawRow(st.r); };
    if(inv.type==='multi')(inv.steps||[]).forEach(seal); else seal(inv);
    inv.sealed=true;
    to.push(inv);
    // The reader's tag filter as the entry remembered it (a delete that pruned it, say).
    if(sn.viewFilter){ prefs.tagFilter=sn.viewFilter.slice(); savePrefs(); afterTagsChange(); }
    // and any prune this step causes is remembered on its inverse, so redo then undo again
    // lands on the same view
    if(tagsReady&&tagSigNow()!==tagSig)afterTagsChange();
    if(!inv.filter&&!inv.viewFilter&&JSON.stringify(fPre)!==JSON.stringify(prefs.tagFilter))inv.viewFilter=fPre;
  } finally { timeTraveling=false; }
  if(to.length>100)to.splice(0,to.length-100);
  if(prefs.view==='table')renderTable();
  undoToast.innerHTML=label+keptNote()+' &middot; <kbd>'+chord+'</kbd>';
  // 3000, was 1800. It names a keystroke, so it needs long enough to read AND act.
  showToast(undoToast,3000,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});
  // Tagged so the changelog says which direction it was. The markdown renderer
  // already puts the via in brackets on every line, so this reads as
  // "user reassigned X from A to B (redo)" with no server change.
  scheduleSave(via);
  return true;
}
// What an undo or redo left alone because somebody else changed it after this person.
function keptNote(){
  if(!undoKept.length)return '';
  const k=undoKept[0], who=k.who||'someone else';
  const what=k.f==='row'?`kept "${k.r.name}", which ${who} changed after you made it`:k.f==='place'?`kept "${k.r.name}" where ${who} moved it after you`:`kept ${who}'s newer ${FIELD_WORD[k.f]||k.f} on "${k.r.name}"`;
  return ' &middot; '+esc(what)+(undoKept.length>1?' and '+(undoKept.length-1)+' more':'');
}
function undo(){ return timeTravel(undoStack,redoStack,'undo','Undid change','Ctrl+Z'); }
function redo(){ return timeTravel(redoStack,undoStack,'redo','Redid change','Ctrl+Shift+Z'); }
// Is anything at all in progress? A chord that opens a panel must never fire in
// the middle of something else, and "something else" includes any field anywhere,
// not just this app's own editors.
function isTypingTarget(el){
  if(!el)return false;
  if(el.isContentEditable)return true;
  const t=(el.tagName||'').toUpperCase();
  return t==='INPUT'||t==='TEXTAREA'||t==='SELECT';
}
// The field types Escape blurs. A SELECT is deliberately not one of them: its Escape
// belongs to the list that is open over it, native or ours. A date field IS one, and the
// blur is all that changes there, so the arrow keys and typing still walk its segments.
const ESC_TEXT_TYPES={text:1,search:1,number:1,tel:1,url:1,email:1,password:1,
  date:1,month:1,week:1,time:1,'datetime-local':1};
// The fields whose Escape is already spoken for. See the listener in wireGlobalKeys.
// The quick-add row (0.79.0) owns its Escape too: it stops adding, it does not just let go of the field.
const ESC_OWNED='.label-edit,.bar-note-edit,.cell-input,.pill-owner-inp,.id-box,.sec-row,.tag-row,#editor,.qa-in';
function isEscBlurTarget(el){
  if(!el||!el.tagName)return false;
  const t=el.tagName.toUpperCase();
  if(t==='TEXTAREA')return true;
  if(t!=='INPUT')return false;
  return !!ESC_TEXT_TYPES[(el.type||'text').toLowerCase()];
}
function appIsIdle(){
  if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;
  if(editorEl.classList.contains('show'))return false;
  if(isTypingTarget(document.activeElement))return false;
  return true;
}
// The gate the two clipboard chords use, and appIsIdle() with ONE clause taken out:
// the open panel.
//
// It has to be. A plain click on a bar SELECTS the task and opens its editor, and that
// is the only way to select a single row in the chart, so "nothing is open" and "a row
// is selected" are mutually exclusive in that lens. Gating Ctrl+V on appIsIdle() would
// mean it never fired in the chart at all, which is the lens the feature exists for,
// and Ctrl+C has never asked the question either (it has answered from behind an open
// panel since 0.44.0). Every other clause is kept, and the one that matters most is
// kept in both: a field with the caret in it owns its own copy and its own paste.
// The row chords (Alt+Up/Down to move a row, Ctrl+C and Ctrl+V to copy and paste rows)
// act on rows, so they answer only in a lens that shows rows. Until 0.58.0 they also
// answered on Analytics and Setup, where a selection left over from the chart moved or
// pasted rows nobody could see, with no feedback at all.
function rowsLens(){ return prefs.view==='gantt'||prefs.view==='table'; }
function clipboardReady(){
  if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;
  if(isTypingTarget(document.activeElement))return false;
  return true;
}
function wireGlobalKeys(){
// An armed Delete is a question, and everything except the answer withdraws it. Both
// listeners capture, so they run before whatever else the key or the click is for and
// the armed state can never survive into an action the user took instead.
document.addEventListener('keydown',e=>{
  if(!delArm&&!depArm)return;
  if(e.key==='Delete')return;                 // the answer; handled in the main handler
  disarmDelete();
  disarmDepDelete();
  // Escape's whole job here was to call off the delete, so it stops here. Letting it
  // fall through would also drop the selection, which is the thing the user is still
  // working with and did not ask to lose.
  if(e.key==='Escape'){e.preventDefault();e.stopPropagation();}
},true);
document.addEventListener('mousedown',()=>{ if(delArm)disarmDelete(); if(depArm)disarmDepDelete(); },true);
// Escape gets you out of a field. One rule, in one place.
//
// Escape pressed with the caret in a plain text field takes the FOCUS out of the field
// and does nothing else. Until 0.55.0 the table's filter box was a dead end for the
// keyboard: it stops its own keydown, which is right (the page's chords must stay out of
// a field someone is typing in), but nothing ever blurred it, so after typing a filter
// every global key in the app was gone until the user reached for the mouse.
//
// IT DOES NOT CLEAR THE FIELD. A filter someone typed is work, and a key that silently
// wipes it is a key nobody presses twice. Emptying the box is what clears the filter.
// preventDefault is load-bearing for exactly that reason: a search input clears ITSELF
// on Escape in WebKit-derived browsers, and the text is the one thing this must not touch.
//
// WHATEVER ALREADY OWNED THE KEY KEEPS IT. Several fields have a real Escape of their own,
// and every one of them already ends with the field removed or explicitly blurred, so the
// focus is never stranded either way. They are listed here rather than patched one by one,
// so this stays the single place the whole rule is written down:
//
//   .label-edit     the chart's inline name editor    Escape cancels the edit
//   .bar-note-edit  the note typed onto a bar         Escape cancels the edit
//   .cell-input     a table cell editor               Escape cancels the edit
//   .pill-owner-inp the owner pill's field            Escape closes its list, then the field
//   .id-box         the copy-IDs fallback box         Escape dismisses the box
//   .sec-row        a Setup page row editor           Escape cancels the row's edit
//   .tag-row        a Setup page tag's rename box     Escape cancels the rename
//   #editor         every panel field                 Escape closes the panel
//
// CAPTURE, and it stops the event dead. One Escape is one action: blurring the filter box
// must not ALSO exit dependency mode or close whatever is open behind it, and stopping the
// event here is what guarantees the app-level handler below never sees the same keypress.
// The next Escape, with nothing focused, does exactly what it always did.
document.addEventListener('keydown',e=>{
  if(e.key!=='Escape'||e.altKey||e.ctrlKey||e.metaKey)return;
  // A live drag or reorder owns Escape outright, whatever happens to hold focus.
  if(rowDrag||laneDrag||dragState||(reorderState&&reorderState.active))return;
  const el=document.activeElement;
  if(!isEscBlurTarget(el))return;
  if(el.closest&&el.closest(ESC_OWNED))return;
  e.preventDefault(); e.stopPropagation();
  try{ el.blur(); }catch(_){}
},true);
document.addEventListener('keydown',e=>{
  // Undo and redo live in the same handler and are therefore guarded identically,
  // which is the point: whatever stops Ctrl+Z from firing stops Ctrl+Shift+Z too.
  // Every text surface in this app stops its own keydown, so inside a cell editor,
  // the create panel or the filter box the FIELD's native undo and redo win and
  // this never runs.
  if((e.ctrlKey||e.metaKey)&&(e.key==='z'||e.key==='Z')){
    e.preventDefault();
    if(e.shiftKey)redo(); else undo();
    return;
  }
  // Ctrl+Y is the Windows redo chord. Nothing in this app claims it and no browser
  // binds it, so it is one clause for a keystroke half our users will reach for.
  if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&(e.key==='y'||e.key==='Y')){
    e.preventDefault(); redo(); return;
  }
  // Alt+Up and Alt+Down MOVE a row. Checked first, so the two arrow meanings never
  // race: with Alt the plan changes, without it only the selection does.
  if(e.altKey&&!e.ctrlKey&&!e.metaKey&&(e.key==='ArrowUp'||e.key==='ArrowDown')){
    if(isTypingTarget(document.activeElement))return;
    if(!rowsLens())return;   // Analytics and Setup show no rows to move
    e.preventDefault();
    moveFocusedRow(e.key==='ArrowDown'?1:-1);
    return;
  }
  // Alt+Right and Alt+Left nest a row under the one above it and take it out again (0.79.0).
  // Only with a row to act on: otherwise the browser keeps its own Back and Forward.
  if(e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey&&(e.key==='ArrowRight'||e.key==='ArrowLeft')){
    if(isTypingTarget(document.activeElement))return;
    if(!rowsLens())return;
    if(!selected.size&&!(prefs.view==='table'&&focusedKey))return;
    e.preventDefault();
    indentFocusedRow(e.key==='ArrowRight'?1:-1);
    return;
  }
  // Plain Up and Down move the SELECTION, the way a spreadsheet does. The
  // preventDefault is the point of the feature: the arrows are for choosing a row,
  // not for nudging a scrollbar, and the pane only moves when the row you chose is
  // off the edge. Only in the table: the chart's selection is a multi-select set
  // with its own anchor and modifier rules, and giving it arrows is a different
  // feature with different questions, so it keeps the browser's scrolling.
  if(!e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey&&(e.key==='ArrowUp'||e.key==='ArrowDown')){
    if(!tableNavReady())return;      // an open editor, a panel or a drag wins
    e.preventDefault();
    moveRowFocus(e.key==='ArrowDown'?1:-1);
    return;
  }
  // Delete, then Delete again, removes the selected rows, in both lenses: the first press
  // marks them and says what would go, the second does it (chartDeleteKey). In the Table,
  // with nothing selected, it is the focused row's. The row's own Delete button takes the
  // same path, so it is one undo, one toast, one changelog event and one sidecar deletion
  // signal. Focus moves to the row that slid up into its place, so a cleanup run is
  // Delete twice, Delete twice. Backspace is deliberately NOT bound: it is a fat-finger
  // away from Delete and browsers spent a decade navigating back on it. Only when no
  // field is live, the same gate the arrows use, so Delete inside an editor edits text.
  if(e.key==='Delete'&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){
    // A picked ARROW answers first of all: it is the smallest thing on screen the key
    // could mean, and while one is picked the mode has already taken the selection
    // away, so nothing else is competing for it.
    if(depDeleteReady()){ depDeleteKey(e); return; }
    // The chart lens answers Delete too, on the second press. Checked before the
    // table gate because the two are mutually exclusive by view and this one has to
    // see the key first or tableNavReady would return and eat it.
    if(chartDeleteReady()){ chartDeleteKey(e); return; }
    if(!tableNavReady())return;
    tableDeleteKey(e);
    return;
  }
  // Tab with a row selected but NO editor open used to fall straight through to the
  // browser's own focus walk, which lands on the first tabbable thing inside the
  // table: a provenance link several rows up, ringed and miles from what the user
  // was looking at. Once a row is selected, Tab means "walk this row's fields",
  // which is exactly what it already means once an editor is open. Shift+Tab starts
  // from the last field so the two directions stay symmetric.
  //
  // Only when nothing else holds focus. If the user has tabbed onto a header
  // control they are walking the page's chrome on purpose and that is legitimate
  // keyboard access, so it is left alone; Esc drops the row focus and hands plain
  // tabbing back.
  if(e.key==='Tab'&&!e.altKey&&!e.ctrlKey&&!e.metaKey){
    if(!tableNavReady())return;
    const ae=document.activeElement;
    if(ae&&ae!==document.body&&ae!==document.documentElement)return;
    const tr=focusedRowEl();
    if(!tr)return;                       // no row selected: the page's tab order is right
    const cells=[...tr.querySelectorAll('td.editable[data-field]')];
    if(!cells.length)return;
    e.preventDefault();
    startCellEdit(e.shiftKey?cells[cells.length-1]:cells[0]);
    return;
  }
  // Enter on the focused row edits its name, which is the field you almost always
  // want and the one the chart's label click already opens.
  if(e.key==='Enter'&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){
    if(!tableNavReady())return;
    const tr=focusedRowEl();
    const td=tr&&tr.querySelector('td.c-name.editable');
    if(!td)return;
    e.preventDefault();
    startCellEdit(td);
    return;
  }
  // Shift+D turns dependency mode on and off, on the same gate as Shift+N: a
  // capital D typed into a task name is a capital D.
  if(e.shiftKey&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&(e.key==='D'||e.key==='d')){
    if(!appIsIdle())return;
    if(prefs.view!=='gantt'){ flash(saveInd,'dependencies are drawn on the chart',2400); return; }
    e.preventDefault();toggleDepMode();
    return;
  }
  // Shift+N opens the create panel, and only when the app is genuinely idle: a
  // capital N typed into a task name is a capital N and nothing else.
  if(e.shiftKey&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&(e.key==='N'||e.key==='n')){
    if(!appIsIdle())return;
    e.preventDefault();closeMenus();openCreateEditor();
    return;
  }
  // Ctrl+C copies the selected ROWS, but only when it cannot possibly be someone's
  // real copy: nothing focused that a person could be typing in, no live text
  // selection anywhere on the page, no cell editor open, and something selected.
  // If any of those is true the browser's own copy is left completely alone.
  //
  // 0.56.0 widened what it copies, and widened nothing about the gate: the same four
  // questions, in the same order. The rows go on the app's own clipboard, ready for
  // Ctrl+V, and the id text still goes on the OS clipboard exactly as before, because
  // that is the hand-off to an AI chat and paste does not replace it.
  if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&!e.altKey&&(e.key==='c'||e.key==='C')){
    if(!selected.size)return;
    if(!rowsLens())return;
    if(!clipboardReady())return;
    let live=''; try{ live=String(window.getSelection?window.getSelection().toString():''); }catch(_){}
    if(live)return;
    e.preventDefault(); copySelectedRows();
    return;
  }
  // Ctrl+V pastes them, on exactly the gate Ctrl+C answers on, which is the point: a
  // chord that can copy has to be able to paste from the same place. A Ctrl+V typed
  // into ANY field anywhere, into a cell editor, into the create panel or in the
  // middle of a drag is that field's paste and never the app's. There is no
  // text-selection clause here, because a paste does not read the selection and
  // having some text highlighted is not a reason to refuse to duplicate a task. With
  // an empty clipboard it says so rather than doing nothing, and the browser's own
  // paste is left alone in every case the gate refuses.
  if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&!e.altKey&&(e.key==='v'||e.key==='V')){
    if(!clipboardReady())return;
    if(!rowsLens())return;
    e.preventDefault(); pasteRows();
    return;
  }
  if(e.key!=='Escape')return;
  // Innermost first. A native select or datalist popup swallows its own Escape
  // before this ever runs, which is exactly the first rung of the stack.
  if(reorderState&&reorderState.active){
    cancelReorder();
    return;
  }
  if(rowDrag||laneDrag){ endRowDrag(false); endLaneDrag(false); return; }
  // An open section row editor on the Setup page. Its text fields take their own
  // Escape before this runs; this is the rung for when the focus is on one of its
  // buttons or its destination list instead, so Escape still means "cancel the row".
  if(prefs.view==='setup'&&secRowOpen()){ renderSections(); return; }
  // Innermost first here too: a picked arrow, then the half-made dependency, then the
  // mode that is making them. An ARMED arrow is inside all of them and never reaches
  // here at all, because the capturing withdrawal listener above takes that Escape.
  if(depMode&&depSel){ setDepSel(null); return; }
  if(depMode&&depPending){ setDepPending(null); return; }
  if(depMode){ setDepMode(false); return; }
  // The chart's own inline name editor. It was missing from this stack entirely, so
  // an editor that had lost focus could not be dismissed by any key at all.
  if(closeLabelEditors(false)) return;
  if(tblEditing){ tblEditing.cancel(); return; }
  if(editorEl.classList.contains('show')){ closeEditor(); return; }
  // The row focus is the outermost thing the table owns, so it goes after the
  // field and after any panel, and before the chart's own selection.
  // Only while the table is the lens looking at it. A row focus left over from an
  // earlier table visit is invisible in the chart, and it used to swallow the Escape
  // that was meant for the chart's selection.
  // Skipped while a set is selected: the focused row is one OF the selection, so
  // dropping it on its own would look like the key did nothing while two rows stayed
  // lit. With a set up, Escape takes the set and the focus together. The same when the
  // focused row IS the one selected row, which is what the row handle and a Ctrl+click
  // leave: one Escape clears what they picked.
  const fr=focusedKey?rowItem(focusedKey):null;
  const focusPicked=!!(fr&&fr.uid&&selected.has(fr.uid));
  if(focusedKey&&prefs.view==='table'&&selected.size<2&&!focusPicked){ setFocusedRow(null); return; }
  clearSelection(); setFocusedRow(null); closeMenus();
});
}

// ── Persistence ──
let saveTimer=null;
// Which revision of the sidecar this tab is working from. The server bumps it on
// every save and refuses a save whose base is stale, so the second writer is told
// rather than the first writer being erased. Null means "we have not seen one", and
// the check is skipped, which is exactly how an older build behaves.
let baseRev=null;
let saveBlocked=false;
// ── Live tabs (0.66.0) ──
//
// This tab's id, so the server can tell it which announcements are its own.
const CLIENT_ID=Math.random().toString(36).slice(2,10)+Date.now().toString(36).slice(-4);
// ── Field-level sync (0.77.0) ──
//
// How Google Docs keeps several people in one document, at the grain of a plan. A tab does
// not send its copy of the whole plan: it sends what it CHANGED (this field of this row,
// this row placed after that one, this tag added to that row), and the server applies
// every change in the order it arrives and announces it, with the revision it made, to
// every open tab including the sender. So the last write wins per FIELD, never per row;
// dependency and tag lists merge item by item; and every tab ends on exactly what the
// server holds.
//
// `shadow` is the server's plan as this tab last heard it. What this tab has changed and
// the server does not have yet is the difference between the page and the shadow, so
// nothing has to be marked and nothing can be forgotten: computeOps() reads it off. A
// change from someone else moves the shadow and, where this tab has not changed the same
// thing itself, the page. Where it has, this tab's value stays: its change reaches the
// server later and wins there too, which is what keeps the two in step.
//
// Rows this tab removed on the SERVER's say-so (startup or a merge), kept whole so that an
// undo of that deletion in another tab can put them back here without a reload.
const remoteRemoved=new Map();
const shadow={rows:new Map(),dead:new Map(),order:[],phases:'',phasesV:null,reg:'',regV:null,dates:'',datesV:null,cal:'',calV:null};
// The one batch on its way to the server: what it carried, field by field.
let inflight=null, batchN=0;
// Which fields somebody else changed in this tab, and when, as a sequence number, so an
// undo can tell a field it set from one somebody changed after it (see restoreField).
let syncSeq=0;
const remoteSeq=new Map(), remoteWho=new Map(), remoteRowSeq=new Map();
// Fields this tab set itself a moment ago, so a person whose value is replaced by somebody
// else's straight after is told: the only true conflict, the same field at the same time.
const myRecent=new Map(), RECENT_MS=120000;
const syncNotices=[];
const ROW_F=['s','e','date','deps','note','owner','status','desc','prov','tags','effort','name','phase','parent','pos'];
// A row's place (0.79.0): its parent, its key among its siblings and, for a top-level row, its
// section. One value: sent together, taken or kept together (see rowDiff and applyRowOp).
const PLACE_F=['parent','pos','phase'];
// Two copies of a row's place differ: its parent, its key, or the section of a top-level row
// (a subtask's section is its parent's, never a value of its own).
function placeDiffOf(a,b){ return !ovEq('parent',a.parent,b.parent)||!ovEq('pos',a.pos,b.pos)||(!a.parent&&!ovEq('phase',a.phase,b.phase)); }
const ROW_SETS={deps:true,tags:true};
const FIELD_WORD={s:'start',e:'deadline',date:'date',deps:'dependencies',note:'bar label',owner:'owner',status:'status',desc:'description',prov:'provenance',tags:'tags',effort:'effort',name:'name',phase:'section',parent:'place',pos:'place',place:'place'};
// Every row this tab has held, by uid, so one that comes back is the same object (its DOM,
// and every undo entry that names it, still resolve).
const allRows=new Map();
// The data file's rows, as opposed to the ones people created: the server keeps the two
// apart (a deleted data row is listed, a deleted created row is dropped).
const dataUids=new Set();
// A row as the sidecar has always stored it. The server keeps exactly this per row.
function rowOv(r){
  const o={uid:r.uid,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),name:r.name,phase:r.phase};
  // Dependencies on every row: a milestone waits on work too since 0.78.0. A list of its own
  // (0.78.2), as the tags have: Dependency mode adds to and removes from the row's list in
  // place, and a copy that shared it changed with it, so the page found nothing to send and
  // said "saved" for a link the server never got. Every version from 0.77.0 lost them so.
  o.deps=(r.deps||[]).slice();
  if(r.type==='milestone')o.date=r.date;
  else { o.s=r.start; o.e=r.end; o.note=r.note||''; }
  // A row's own tags (0.60.0 edits them). Written only when the row has a list, so a
  // row that never had one says nothing and the data file's value stands; an edit
  // that clears a row's tags leaves `[]` on it, which is the sidecar's "cleared".
  if(Array.isArray(r.tags))o.tags=r.tags.slice();
  // Effort (0.63.0) on every task, null when it has none: null is the sidecar's
  // "cleared", as '' is for the owner.
  if(r.type!=='milestone')o.effort=effortOf(r);
  // Its place (0.79.0): '' for a top-level row and a row with no key yet.
  o.parent=r.parent||''; o.pos=r.pos||'';
  return o;
}
const listOf=v=>Array.isArray(v)?v:[];
const setKey=f=>f==='tags'?(x=>String(x).toLowerCase()):(x=>x);
function ovEq(f,a,b){
  if(ROW_SETS[f]){ const A=listOf(a),B=listOf(b); if(A.length!==B.length)return false; const k=setKey(f),h=new Set(A.map(k)); return B.every(x=>h.has(k(x))); }
  if(f==='prov')return JSON.stringify(a===undefined||a===null?'':a)===JSON.stringify(b===undefined||b===null?'':b);
  if(f==='effort')return (a===undefined?null:a)===(b===undefined?null:b);
  return (a===undefined||a===null?'':a)===(b===undefined||b===null?'':b);
}
const cloneV=v=>Array.isArray(v)?v.slice():(v&&typeof v==='object'?Object.assign({},v):v);
// One field written into the page's model, by its sidecar name.
function setOvField(r,f,v){
  if(f==='s')r.start=v; else if(f==='e')r.end=v; else if(f==='date')r.date=v;
  else if(f==='deps')r.deps=listOf(v).slice();
  else if(f==='note')r.note=v||'';
  else if(f==='owner')setOpt(r,'owner',v);
  else if(f==='status')setOpt(r,'status',v);
  else if(f==='desc')setOpt(r,'description',v);
  else if(f==='prov')setProv(r,v);
  else if(f==='tags')setRowTags(r,normalizeTags(listOf(v)));
  else if(f==='effort'){ if(r.type!=='milestone'){ if(v===null||v===undefined)delete r.effort; else { const ef=normalizeEffort(v); if(ef!==undefined)r.effort=ef; } } }
  else if(f==='name'){ if(v)r.name=v; }
  else if(f==='phase'){ if(Number.isInteger(v)&&v>=0&&v<phases.length)r.phase=v; }
  else if(f==='parent'){ if(typeof v==='string'&&v)r.parent=v; else delete r.parent; }
  else if(f==='pos'){ if(posOk(v))r.pos=v; else delete r.pos; }
}
function isLive(r){ return !!r&&(items[r._itemIdx]===r||items.includes(r)); }
function liveRowOf(uid){ const r=allRows.get(uid); return isLive(r)?r:null; }
function liveOrder(){ return items.filter(r=>!isDerivedGroup(r)).map(rowOrderKey); }
function phasesOut(){
  return phases.map((p,i)=>{const o={i,name:p.name,start:p.start,end:p.end};
    if(p.uid)o.uid=p.uid; if(p.lead)o.lead=p.lead; if(p.color)o.color=p.color;
    o.tags=(p.tags||[]).slice(); return o;});
}
const phasesSigOf=l=>JSON.stringify(listOf(l).map(p=>[p.name,p.start||'',p.end||'',p.uid||'',p.lead||'',p.color||'',listOf(p.tags)]));
const regSigOf=l=>JSON.stringify(listOf(l).map(e=>[e.name,e.color||'']));
const datesSigOf=d=>d?(d.start||'')+'|'+(d.end||''):'';
const calSigOf=c=>c?JSON.stringify([listOf(c.workHolidays),listOf(c.extraDaysOff)]):'';
// The four plan-wide settings as this page has them. One the page never touched reads as
// the shadow's, so it is never sent: absent means the data file's, on both sides.
function curStruct(){
  return {phases:phasesSigOf(phasesOut()),
    reg:regInSidecar?regSigOf(regOut()):shadow.reg,
    dates:datesInSidecar?datesSigOf(planDates):shadow.dates,
    cal:calInSidecar?calSigOf(planCal):shadow.cal};
}
// The baseline, taken once the plan is loaded and merged with the sidecar: at that moment
// the page and the server agree by definition.
function initShadow(){
  shadow.rows.clear(); shadow.dead.clear(); dataUids.clear();
  items.forEach(r=>{ if(!r.uid||isDerivedGroup(r))return; allRows.set(r.uid,r); shadow.rows.set(r.uid,rowOv(r)); if(!addedItems.includes(r))dataUids.add(r.uid); });
  remoteRemoved.forEach((info,k)=>{ allRows.set(k,info.r); shadow.dead.set(k,rowOv(info.r)); if(!info.r._wasAdded)dataUids.add(k); });
  shadow.order=liveOrder();
  const c=curStruct();
  shadow.phases=c.phases; shadow.phasesV=phasesOut();
  shadow.reg=regInSidecar?c.reg:''; shadow.regV=regInSidecar?regOut():null;
  shadow.dates=datesInSidecar?c.dates:''; shadow.datesV=datesInSidecar?{start:planDates.start,end:planDates.end}:null;
  shadow.cal=calInSidecar?c.cal:''; shadow.calV=calInSidecar?JSON.parse(JSON.stringify(planCal)):null;
}
// What changed in one row, as one op: the plain fields with their new values, the two
// lists as what was added and what was taken out.
function rowDiff(ov,sh){
  let set=null,add=null,del=null,ord=null;
  // No tags of its own is the same as an empty list: an undo back to "none" takes the key
  // off the row, and that is still a change to send.
  if(!('tags' in ov))ov=Object.assign({},ov,{tags:[]});
  // A row's place is one value (0.79.0): any part of it changed sends all of it, so the last
  // writer wins the whole place (Figma keeps a layer's parent and position in one property for
  // the same reason). A subtask's section is never sent: it is its parent's, kept by the server.
  const child=!!ov.parent;
  if(!ovEq('parent',ov.parent,sh.parent)||!ovEq('pos',ov.pos,sh.pos)||(!child&&!ovEq('phase',ov.phase,sh.phase))){
    set={parent:ov.parent||'',pos:ov.pos||''}; if(!child)set.phase=ov.phase;
  }
  for(const f of ROW_F){
    if(!(f in ov)||PLACE_F.includes(f))continue;
    if(ROW_SETS[f]){
      const k=setKey(f), A=listOf(ov[f]), B=listOf(sh[f]), As=new Set(A.map(k)), Bs=new Set(B.map(k));
      const plus=A.filter(x=>!Bs.has(k(x))), minus=B.filter(x=>!As.has(k(x)));
      if(plus.length)(add||(add={}))[f]=plus;
      if(minus.length)(del||(del={}))[f]=minus;
      // The whole list too, for its order: the server keeps this tab's items where this tab
      // has them (a tag renamed in place stays in place).
      if(plus.length||minus.length)(ord||(ord={}))[f]=A.slice();
      continue;
    }
    if(!ovEq(f,ov[f],sh[f]))(set||(set={}))[f]=cloneV(ov[f]);
  }
  if(!set&&!add&&!del)return null;
  const o={op:'row',uid:ov.uid};
  if(set)o.set=set; if(add)o.add=add; if(del)o.del=del; if(ord)o.ord=ord;
  return o;
}
// A created row, whole, in the sidecar's `added` shape.
function addedOut(r){
  const o={};
  ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','provenance','tags','effort','parent','pos'].forEach(k=>{if(r[k]!==undefined)o[k]=cloneV(r[k]);});
  return o;
}
// A created row's id, as the server settled it (two people making a row of the same name
// in the same second). Dependencies name rows by id, a task's or a milestone's, so they follow.
function renameRowId(r,id){
  const old=r.id;
  if(!old||!id||old===id)return;
  items.forEach(o=>{ if(Array.isArray(o.deps)&&o.deps.includes(old))o.deps=o.deps.map(d=>d===old?id:d); });
  if(barEls[old]){ barEls[id]=barEls[old]; delete barEls[old]; if(barEls[id].bar)barEls[id].bar.dataset.id=id; }
  // A task's name cell carries its id; a milestone's carries none, which is how the label
  // column tells the two apart, so it is left without one.
  if(r._lDiv&&r.type!=='milestone')r._lDiv.dataset.id=id;
  if(depPending===old)depPending=id;
  if(selected.has(old)){ selected.delete(old); selected.add(id); }
  r.id=id;
}
// Place `uid` straight after `after` (null: at the top), as the server does.
function moveInOrder(order,uid,after){
  const i=order.indexOf(uid);
  if(i<0)return false;
  if(after!==null&&(after===uid||!order.includes(after)))return false;
  order.splice(i,1);
  order.splice(after===null?0:order.indexOf(after)+1,0,uid);
  return true;
}
// The order as this page lays it out (placeHeaders): each section's rows together, sections
// in list order, rows in the given order inside a section, a row with no known section last.
// Orders are compared in this form (0.77.1), so a list that differs only in being grouped
// another way is never taken for rows somebody moved. Two pages that grouped the server's
// flat list differently each sent "moves" to fix it, and undid each other's for an hour.
function groupOrder(keys){
  const byKey=orderKeyMap(), buckets=phases.map(()=>[]), rest=[];
  keys.forEach(k=>{ const r=byKey.get(k), p=r?r.phase:undefined;
    if(Number.isInteger(p)&&p>=0&&p<buckets.length)buckets[p].push(k); else rest.push(k); });
  return [].concat(...buckets,rest);
}
// The fewest rows to move to turn `base` into `target`, each as "after its new neighbour".
// The rows that stay are the longest run already in the right relative order (a longest
// increasing subsequence of their old positions); everything else moved, and so did any
// row `base` does not have yet.
function orderMoves(base,target){
  const pos=new Map(); base.forEach((k,i)=>pos.set(k,i));
  const seq=[]; target.forEach((k,i)=>{ if(pos.has(k))seq.push(i); });
  const tails=[],tailAt=[],prev=new Array(seq.length);
  seq.forEach((ti,j)=>{
    const v=pos.get(target[ti]); let lo=0,hi=tails.length;
    while(lo<hi){ const m=(lo+hi)>>1; if(tails[m]<v)lo=m+1; else hi=m; }
    tails[lo]=v; tailAt[lo]=j; prev[j]=lo>0?tailAt[lo-1]:-1;
  });
  const keep=new Set();
  for(let j=tails.length?tailAt[tails.length-1]:-1;j>=0;j=prev[j])keep.add(target[seq[j]]);
  const moves=[];
  target.forEach((k,i)=>{ if(!keep.has(k))moves.push({op:'move',uid:k,after:i?target[i-1]:null}); });
  return moves;
}
// Everything this tab has that the server does not, as ops, in the order the server needs
// them: new rows first (a field or a move can name them), then deletions, fields, moves,
// and the plan-wide settings.
function computeOps(){
  const adds=[],dels=[],rows=[],tail=[];
  const live=new Set();
  items.forEach(r=>{
    if(!r.uid||isDerivedGroup(r))return;
    live.add(r.uid); allRows.set(r.uid,r);
    const ov=rowOv(r), sh=shadow.rows.get(r.uid);
    if(sh){ const d=rowDiff(ov,sh); if(d)rows.push(d); return; }
    adds.push({op:'add',row:addedOut(r)});
    const dead=shadow.dead.get(r.uid);
    if(dead){ const d=rowDiff(ov,dead); if(d)rows.push(d); }
  });
  shadow.rows.forEach((_,u)=>{ if(!live.has(u))dels.push({op:'del',uid:u}); });
  // No moves (0.79.0): a row's place is its own fields, sent with the rest of them above.
  const moves=[];
  const c=curStruct();
  if(c.phases!==shadow.phases)tail.push({op:'phases',phases:phasesOut()});
  if(regInSidecar&&c.reg!==shadow.reg)tail.push({op:'reg',tags:regOut()});
  if(datesInSidecar&&c.dates!==shadow.dates)tail.push({op:'dates',dates:{start:planDates.start,end:planDates.end}});
  if(calInSidecar&&c.cal!==shadow.cal)tail.push({op:'cal',calendar:JSON.parse(JSON.stringify(planCal))});
  return adds.concat(dels,rows,moves,tail);
}
function pendingCount(){ return computeOps().length; }
// How many changelog-worthy edits have been made since the last save that LANDED. It is
// the number the banner quotes back before a reload throws them away, so a user is never
// asked to discard "your changes" without being told how many of them there are.
let unsavedCount=0;
// Changelog lines waiting for the save they belong to.
//
// THE LOG AND THE SIDECAR HAVE TO TELL THE SAME STORY. Through 0.53.0 the /log post went
// out the instant an edit was made and was completely independent of whether /save was
// working, so a tab whose saves were being refused kept writing "moved X to Y" lines for
// edits the sidecar never took. That split is not a cosmetic problem: the changelog
// advancing is precisely what made the app look alive on 2026-08-21 while nothing was
// landing, and it left the only durable record of seven minutes of work as a list of
// things that had not happened.
//
// The /log post is therefore no longer a side channel. It is CHAINED to the save: lines
// queue here and go out, in order, behind the save that carries them, so the changelog
// can never run ahead of the state it describes. Only queueing while already blocked
// would have left exactly one edit leaking, the one whose 800ms debounce is what
// discovers the conflict in the first place, and one is enough to make the log wrong.
//
// Two things drain the queue and one drops it. A save that lands flushes it. A tab being
// closed flushes it alongside the beacon that saves the state, so the 0.30.0-era fix for
// the other direction (a tab closed inside the debounce window) still holds in both. And
// a reload that discards the edits drops their lines with them, because at that point the
// edits really did not happen.
let logQueue=[];
const LOG_QUEUE_MAX=2000;    // bounded, because a blocked tab can be left open all day
// Milestones carry no id, so an array of tasks alone loses their dates on reload and
// they snap back to the data file while the tasks stay shifted. Persist both.
// An empty value means "no value", not the empty string, so clearing an owner in
// the table leaves the item as clean as it was in the data file.
function setOpt(r,k,v){ if(v===undefined||v===null||v==='')delete r[k]; else r[k]=v; }
// provenance is an object or a string, so it needs its own setter rather than
// setOpt's empty-means-absent rule applied to a truthy object.
function setProv(r,v){ if(v===undefined||v===null||v===''||(typeof v==='object'&&!v.label&&!v.url))delete r.provenance; else r.provenance=v; }
// v3 adds owner, status and description. The fields are additive: a v2 sidecar
// written before the table view existed still loads, and a v2 reader ignores
// what it does not know.
// v4. One override list keyed by uid, where v3 had a task list keyed by slug id
// and a separate milestone list carrying a key, an index AND a name because none
// of the three could be trusted on its own. Older sidecars are translated by the
// server on the way in, so this side only ever reads and writes v4.
function getState(){
  return {
    v: 4,
    items: items.filter(r=>r.uid&&!isDerivedGroup(r)).map(rowOv),
    // The whole section list, not just date patches by index. Sections are data
    // the user edits now (rename, reorder, and soon colour and lead), so the
    // sidecar has to carry what they are, not only when they run. `i` stays for a
    // reader written against the older shape.
    // `tags` is written on EVERY entry, `[]` included. This page owns the whole list,
    // and to the server an entry with no `tags` key means "keep the data file's", which
    // is right for a 0.58.0 client that knows nothing of tags and wrong for this one: a
    // section whose last tag was removed here has to say so.
    phases: phases.map((p,i)=>{const o={i,name:p.name,start:p.start,end:p.end};
      if(p.uid)o.uid=p.uid; if(p.lead)o.lead=p.lead; if(p.color)o.color=p.color;
      o.tags=(p.tags||[]).slice(); return o;}),
    // The tag registry (0.61.0), only once something has touched it: absent tells the
    // server the data file's registry stands, so an untouched plan's sidecar says nothing.
    ...(regInSidecar?{tags:regOut()}:{}),
    // Project dates (0.75.0), once they are anyone's but the data file's.
    ...(datesInSidecar?{dates:{start:planDates.start,end:planDates.end}}:{}),
    // The working calendar (0.76.0), the same way.
    ...(calInSidecar?{calendar:{workHolidays:planCal.workHolidays.slice(),extraDaysOff:planCal.extraDaysOff.slice()}}:{}),
    order: items.filter(r=>!isDerivedGroup(r)).map(rowOrderKey),
    deleted: [...deletedKeys],
    added: addedItems.map(r=>{const o={};
      // `uid` is in this list because it is not optional. A created row lives only
      // in the sidecar, so a field missing from this whitelist is a field that
      // does not survive the next save: leaving uid out would have quietly
      // stripped the durable id off every row the user ever created, and in v4 it
      // is also the key every other part of the file uses to name that row.
      ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','striped','provenance','tags','effort','parent','pos'].forEach(k=>{if(r[k]!==undefined)o[k]=r[k];});
      return o;}),
  };
}
// v4 only. Everything older is translated to v4 by the server before it reaches
// here, which is why the four-rung "find the milestone by its key, or its name,
// or its index, or its name again" ladder that used to live at this spot is gone:
// with a uid there is one lookup and it either finds the row or there is no row.
// The sidecar's phase list, adopted. Returns true when the sections actually changed.
function adoptPhases(sph){
  let phasesRebuilt=false;
  if(Array.isArray(sph)&&sph.length&&sph.some(p=>p&&p.name!==undefined)){
    // Tags are newer than this shape. An entry with no `tags` key was written by a client
    // that predates them, and it inherits its section's tags from the data file, found by
    // uid and then by name, exactly as the server's merge does; `[]` clears them.
    const basePh=phases.slice();
    const baseTags=p=>{ const b=(p.uid&&basePh.find(x=>x&&x.uid===p.uid))||basePh.find(x=>x&&x.name===p.name);
      return b&&Array.isArray(b.tags)?b.tags:undefined; };
    const rebuilt=sph.map(p=>{
      const o={name:p.name,start:p.start||'',end:p.end||''};
      if(p.uid)o.uid=p.uid;
      if(p.lead)o.lead=p.lead;
      if(p.color)o.color=p.color;
      const t=Array.isArray(p.tags)?normalizeTags(p.tags):baseTags(p);
      if(t&&t.length)o.tags=t.slice();
      return o;
    });
    // The same sections the page already has (every live merge of a field edit, since
    // every save carries the whole list): nothing to rebuild (0.75.1). A rebuild here
    // re-derived every header, reflowed both panes and re-filtered on every save anyone
    // made, and reset the reader's own dimmed sections in the legend to visible.
    const phSig=p=>JSON.stringify([p.name,p.start||'',p.end||'',p.uid||'',p.lead||'',p.color||'',p.tags||[]]);
    const unchanged=rebuilt.length===phases.length&&rebuilt.every((p,i)=>phSig(p)===phSig(phases[i]));
    if(!unchanged){
      // A section dimmed in this tab's legend stays dimmed, found by uid, then by name.
      const wasVis=new Map(); phases.forEach((p,i)=>{ wasVis.set('u:'+(p.uid||''),phaseVis[i]); wasVis.set('n:'+p.name,phaseVis[i]); });
      phases.length=0; phases.push(...rebuilt);
      phaseVis.length=0; phases.forEach(p=>{ const v=p.uid&&wasVis.has('u:'+p.uid)?wasVis.get('u:'+p.uid):wasVis.has('n:'+p.name)?wasVis.get('n:'+p.name):true; phaseVis.push(v!==false); });
      // Relabel/rehome the derived headers to the rebuilt list and drop any whose phase
      // is gone — that DOM removal is what clears the phantom empty lane. Safe here:
      // the chart was built in init() (rowsBuilt), and buildDerivedHeaders reuses a
      // header's DOM by phase uid, so nothing already on screen is orphaned by mistake.
      if(rowsBuilt)buildDerivedHeaders();
      phasesRebuilt=true;
    }
  } else {
    (sph||[]).forEach(sv=>{ const p=sv&&phases[sv.i]; if(p){ if(sv.start)p.start=sv.start; if(sv.end)p.end=sv.end;
      if(Array.isArray(sv.tags)){ const t=normalizeTags(sv.tags); if(t.length)p.tags=t; else delete p.tags; } } });
  }
  return phasesRebuilt;
}
// One row's sidecar override, applied to the row, exactly as the server's merge does. Shared
// by the startup merge and by a full catch-up (pullOnce), which rebuilds a row the server
// put back from the copy this tab still holds.
function applyOverrideRow(r,sv,phaseN){
  if(sv.s)r.start=sv.s;
  if(sv.e)r.end=sv.e;
  if(sv.date)r.date=sv.date;
  if(sv.deps)r.deps=sv.deps;
  if(sv.note!==undefined)r.note=sv.note;
  if(sv.owner!==undefined)setOpt(r,'owner',sv.owner);
  if(sv.status!==undefined)setOpt(r,'status',sv.status);
  if(sv.desc!==undefined)setOpt(r,'description',sv.desc);
  if(sv.prov!==undefined)setProv(r,sv.prov);
  // Absent: the data file's tags stand. [] : the user cleared them. As the server merges.
  if(Array.isArray(sv.tags)){ const t=normalizeTags(sv.tags); r.tags=t; }
  // Effort: absent, the data file's stands; null, cleared; a number, set. As the server.
  if('effort' in sv&&r.type!=='milestone'){ if(sv.effort===null)delete r.effort; else { const ef=normalizeEffort(sv.effort); if(ef!==undefined)r.effort=ef; } }
  if(sv.name)r.name=sv.name;
  if(Number.isInteger(sv.phase)&&sv.phase>=0&&sv.phase<phaseN)r.phase=sv.phase;
  // Its place (0.79.0), as the server's merge applies it: '' makes it a top-level row.
  if(sv.parent!==undefined){ if(typeof sv.parent==='string'&&sv.parent)r.parent=sv.parent; else delete r.parent; }
  if(sv.pos!==undefined){ if(posOk(sv.pos))r.pos=sv.pos; else delete r.pos; }
  return r;
}
function applyState(state){
  if(!state||Array.isArray(state))return;
  // Created rows first: the overrides, the deletions and the order all name
  // them, so they have to exist before any of those are applied.
  if(Array.isArray(state.added)){
    const have=new Set(items.map(r=>r.uid).filter(Boolean));
    inRowBatch(()=>state.added.forEach(a=>{
      if(!a||typeof a!=='object')return;
      if(a.uid&&have.has(a.uid))return;
      const r=Object.assign({},a);
      if(r.uid)have.add(r.uid);
      addedItems.push(r);
      insertRow(r,true);
    }));
  }
  const byUid=new Map();
  items.forEach(r=>{ if(r.uid)byUid.set(r.uid,r); });
  // The tag registry (0.61.0): absent, the data file's stands; a list, even [], replaces it.
  if(Array.isArray(state.tags)){ tagReg=(normalizeTagRegistry(state.tags)||[]).map(regEntry); regInSidecar=true; }
  // Project dates (0.75.0): absent, the data file's stand; present, they replace them.
  if(state.dates&&typeof state.dates==='object'&&!Array.isArray(state.dates)){
    planDates={start:cleanDay(state.dates.start),end:cleanDay(state.dates.end)}; datesInSidecar=true;
  }
  // The working calendar (0.76.0): absent, the data file's stands.
  { const c=cleanCal(state.calendar);
    if(c){ if(c.workHolidays.join()!==planCal.workHolidays.join()||c.extraDaysOff.join()!==planCal.extraDaysOff.join()){ planCal=c; PLAN_OFF_CACHE.clear(); } calInSidecar=true; } }
  // How many sections the plan will have once this sidecar is applied. The row overrides
  // are applied BEFORE the section list is rebuilt below, so checking a row's saved phase
  // against phases[] here checked it against the DATA FILE's list. A row whose section
  // sat past the end of that list (any section added since, or one moved up past a new
  // one) failed the check, silently kept its data-file index and landed in whichever
  // section now held that index: reorder a new section and reload, and the rows of the
  // section it passed moved into it. Present since sections became editable (0.57.1 had
  // it too); the server's merge was right all along, so only the page was wrong.
  const sphFull=Array.isArray(state.phases)&&state.phases.length&&state.phases.some(p=>p&&p.name!==undefined);
  const phaseN=sphFull?state.phases.length:phases.length;
  (state.items||[]).forEach(sv=>{
    if(!sv||!sv.uid)return;
    const r=byUid.get(sv.uid);if(r)applyOverrideRow(r,sv,phaseN);
  });
  // The sidecar's phase list, applied EXACTLY as the server's mergedData does, so
  // the client renders what /export/json says. Two shapes. A sidecar written since
  // sections became editable carries the FULL list, every entry named, and it OWNS
  // the section list: it is rebuilt in place so a rename, recolour, reorder, create
  // or delete made in the section editor survives a reload (the render at the end of
  // this function re-derives the headers, the legend and the row grouping from it).
  // An older sidecar (v3/v4 date-only) carries just {i,start,end} patches by index
  // and is applied against the data-file phases, exactly as before — the example
  // files and any pre-editable-sections sidecar keep loading unchanged.
  const phasesRebuilt=adoptPhases(state.phases);
  // Before the order, always: the saved order was written after these rows were
  // removed, so applying it first would compare against the wrong length and be
  // rejected by the anti-shrink guard.
  if(Array.isArray(state.deleted)){
    state.deleted.forEach(k=>{ const r=items.find(x=>rowOrderKey(x)===k); if(r)removeRow(r,true); });
  }
  // Against the ROW count, not items.length: headers are derived and are not in
  // the saved order, so comparing with them included made this guard reject every
  // saved order outright and silently leave the plan in data-file order.
  const rowCount=items.filter(r=>!isDerivedGroup(r)).length;
  if(Array.isArray(state.order)&&state.order.length===rowCount)applyOrder(state.order);
  // A rebuilt phase list changed the sections, their names, leads, order and colours,
  // so re-derive both lenses from phases[], the same tail every section mutation and
  // restorePhaseState run: placeHeaders/reflowRows regroup and recolour the rows (the
  // saved order above may already have, which is idempotent), and refreshSections
  // rewrites each header's name and lead in the DOM (buildDerivedHeaders only reused
  // the header object, not its text) and rebuilds the legend and the table. applyFilter
  // restores lane visibility. Geometry (axis, caps, deps) is left to startApp, which
  // rebuilds the timeline right after this returns.
  if(phasesRebuilt&&rowsBuilt){ placeHeaders(); reflowRows(); refreshSections(); applyFilter(); }
  // The rows' parents and keys came with the overrides (0.79.0): lay the rows out from them.
  else if(rowsBuilt){ placeHeaders(); reflowRows(); }
}
// ── Changelog ──
//
// Rather than instrument each edit path by hand (which drifts the moment a new
// one is added), we snapshot the semantic state and diff it at every save. One
// hook, and it cannot disagree with the data. `via` just labels how the change
// was made, for readability in the log.

// Group labels often carry a trailing date range, e.g. "Phase 1 - Design (Jan-Apr 2026)".
// The axis already states the dates, and the text goes stale the moment the plan is
// shifted, so drop it. Narrow on purpose: it only strips a parenthetical that starts
// with a month abbreviation and carries a 4-digit year, so "(EVT)" or "(Year 1-2)" stay.
const GROUP_DATE_RE = /\s*\((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[^)]*\d{4}[^)]*\)\s*$/;
function groupLabel(t){ return (t||'').replace(GROUP_DATE_RE,''); }

// Set by a drag just before it saves, so the reorder event can say what actually
// moved instead of listing keys. Consumed once.
let moveHint=null;
function nameOf(id){const r=items.find(d=>d.id===id);return r?r.name:id;}
function phaseNameAt(i){const p=phases[i];return p?p.name:String(i);}
function planStartOf(snap){let m=null;Object.values(snap.byId).forEach(v=>{if(v.start&&(!m||v.start<m))m=v.start;});return m;}
function snapTask(r){ return {start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:statusOf(r),desc:r.description||'',prov:provSig(r),provL:provLabel(r),name:r.name||'',phase:r.phase,tags:ownTags(r).slice(),eff:effortOf(r),parent:treeParent(r)}; }
function snapMs(r){ return {name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),phase:r.phase,tags:ownTags(r).slice(),deps:(r.deps||[]).slice(),id:r.id||'',parent:treeParent(r)}; }
// The task a row sits under, by name, for the changelog (0.79.0).
function parentEv(a,b){ const nm=u=>{ if(!u)return ''; const x=allRows.get(u)||rowByUid(u); return x?x.name||'':''; }; return {from:{parent:a||'',name:nm(a)},to:{parent:b||'',name:nm(b)}}; }
// Somebody else's field edits, written into the baseline so the next diff does not log
// them as this person's. Whole rows only; anything that moved the plan's shape takes a
// fresh snapshot instead.
function patchSnap(rows){
  if(!lastSnap)return;
  rows.forEach(r=>{ if(!isLive(r))return;
    if(r.id&&lastSnap.byId[r.id])lastSnap.byId[r.id]=snapTask(r);
    else if(r.type==='milestone'&&lastSnap.ms[rowOrderKey(r)])lastSnap.ms[rowOrderKey(r)]=snapMs(r); });
}
function snapSemantic(){
  const byId={};
  // Tasks. A milestone has an id since 0.78.0 and is still diffed below, by its row key.
  items.forEach(r=>{if(r.id&&r.type!=='milestone')byId[r.id]=snapTask(r);});
  // Keyed by the stable row key, not by the name. Keying by name made a rename
  // look like one milestone vanishing and another appearing, so it logged nothing
  // (both sides early-return on an unseen name) and instead surfaced as a phantom
  // "reorder". It also made one of any same-named pair invisible to the diff.
  const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[rowOrderKey(r)]=snapMs(r);});
  // Section tags, keyed by the section's client identity (phaseKey), so a rename or a
  // reorder between two snapshots is not read as a tag change. A section that exists on
  // one side only (created, removed) has its own event already and adds none here.
  const ph={};phases.forEach((p,i)=>{ph[phaseKey(p)]={i,name:p.name,tags:(p.tags||[]).slice()};});
  // The tag registry (0.61.0), by entry identity, so a rename reads as one.
  const reg={};tagReg.forEach(e=>{reg[e._k]={name:e.name,color:e.color||''};});
  return {byId,ms,ph,reg,dates:{start:planDates.start,end:planDates.end},cal:{workHolidays:planCal.workHolidays.slice(),extraDaysOff:planCal.extraDaysOff.slice()},order:items.map(rowOrderKey)};
}
// The baseline the changelog diffs against. It has to be taken after the plan is
// loaded and normalised, which is an ordering fact, so it is an init step rather
// than a declaration initialiser.
let lastSnap=null;
function captureBaselineSnapshot(){
lastSnap=snapSemantic();
}
function diffAndLog(via){
  sealUndo();
  const cur=snapSemantic(),evs=[];
  // Project dates (0.75.0): one line, both ends, the way the server describes it.
  if(lastSnap.dates&&(lastSnap.dates.start!==cur.dates.start||lastSnap.dates.end!==cur.dates.end))
    evs.push({action:'plan.dates',from:Object.assign({},lastSnap.dates),to:Object.assign({},cur.dates)});
  // The working calendar (0.76.0): one line naming what changed.
  if(lastSnap.cal&&(lastSnap.cal.workHolidays.join()!==cur.cal.workHolidays.join()||lastSnap.cal.extraDaysOff.join()!==cur.cal.extraDaysOff.join()))
    evs.push({action:'plan.calendar',from:lastSnap.cal,to:cur.cal});
  Object.keys(cur.byId).forEach(id=>{
    const a=lastSnap.byId[id],b=cur.byId[id];if(!a)return;
    if(a.start!==b.start||a.end!==b.end){
      evs.push({action:'dates',id,name:nameOf(id),
        from:{start:a.start,end:a.end},to:{start:b.start,end:b.end},
        shift:{start:daysBetween(a.start,b.start),end:daysBetween(a.end,b.end)}});
    }
    if((a.note||'')!==(b.note||'')) evs.push({action:'note',id,name:nameOf(id),from:{note:a.note||''},to:{note:b.note||''}});
    if((a.owner||'')!==(b.owner||'')) evs.push({action:'owner',id,name:nameOf(id),from:{owner:a.owner||''},to:{owner:b.owner||''}});
    if((a.status||'')!==(b.status||'')) evs.push({action:'status',id,name:nameOf(id),from:{status:statusTextFor(a.status)},to:{status:statusTextFor(b.status)}});
    if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',id,name:nameOf(id),from:{desc:a.desc||''},to:{desc:b.desc||''}});
    if((a.prov||'')!==(b.prov||'')) evs.push({action:'provenance',id,name:nameOf(id),from:{label:a.provL||''},to:{label:b.provL||''}});
    if((a.name||'')!==(b.name||'')) evs.push({action:'rename',id,name:b.name||'',from:{name:a.name||''},to:{name:b.name||''}});
    // A subtask's section follows its top-level task's, which says so itself (0.79.0).
    if((a.parent||'')!==(b.parent||'')) evs.push(Object.assign({action:'parent',id,name:nameOf(id)},parentEv(a.parent,b.parent)));
    if(a.phase!==b.phase&&!b.parent) evs.push({action:'phase',id,name:nameOf(id),from:{phase:phaseNameAt(a.phase)},to:{phase:phaseNameAt(b.phase)}});
    // A row's own tags (0.60.0), the row side of the server's `tags` action. Absent and []
    // both diff as none: the sidecar keeps the difference, the changelog need not.
    if(!sameTags(a.tags,b.tags)) evs.push({action:'tags',id,name:nameOf(id),from:{tags:a.tags||[]},to:{tags:b.tags}});
    if((a.eff===undefined?null:a.eff)!==b.eff) evs.push({action:'effort',id,name:nameOf(id),from:{effort:a.eff===undefined?null:a.eff},to:{effort:b.eff}});
    b.deps.filter(d=>!a.deps.includes(d)).forEach(d=>evs.push({action:'dep.add',id,name:nameOf(id),dep:d,depName:nameOf(d)}));
    a.deps.filter(d=>!b.deps.includes(d)).forEach(d=>evs.push({action:'dep.remove',id,name:nameOf(id),dep:d,depName:nameOf(d)}));
  });
  // A row that was in the last snapshot and is not in this one was deleted, and
  // the reverse is an undo putting it back. The field-by-field loop above cannot
  // see either, because it only walks rows that exist in both.
  Object.keys(lastSnap.byId).forEach(id=>{
    if(cur.byId[id])return;
    evs.push({action:'delete',id,name:lastSnap.byId[id].name||id});
  });
  Object.keys(cur.byId).forEach(id=>{
    if(lastSnap.byId[id])return;
    const r=items.find(x=>x.id===id);
    const born=r&&addedItems.includes(r);
    evs.push({action:born?'add':'restore',id,name:cur.byId[id].name||id,
              kind:born?(r.type==='milestone'?'milestone':'task'):undefined});
    // A row created WITH tags (the New panel) says so in its own line, via `create`, so a
    // reader of the log sees where a tag came from. A paste is left as its one summary line.
    if(born&&via!=='paste'&&cur.byId[id].tags.length)
      evs.push({action:'tags',id,name:cur.byId[id].name||id,from:{tags:[]},to:{tags:cur.byId[id].tags}});
    if(born&&via!=='paste'&&cur.byId[id].eff!==null)
      evs.push({action:'effort',id,name:cur.byId[id].name||id,from:{effort:null},to:{effort:cur.byId[id].eff}});
  });
  // A plan shift moves every task by the same delta. Logging 26 identical rows
  // buries the one fact a reader wants, so collapse it into a single event.
  const dateEvs=evs.filter(e=>e.action==='dates');
  if(dateEvs.length>3){
    const d0=dateEvs[0].shift;
    if(dateEvs.every(e=>e.shift.start===d0.start&&e.shift.end===d0.end)){
      const kept=evs.filter(e=>e.action!=='dates');
      evs.length=0;
      evs.push({action:'plan.shift',count:dateEvs.length,shift:d0,
                from:{start:planStartOf(lastSnap)},to:{start:planStart()}},...kept);
    }
  }
  // milestones: keyed by the stable row key, which they have always had (an id only since 0.78.0)
  const curMs=cur.ms,oldMs=lastSnap.ms||{};
  Object.keys(oldMs).forEach(k=>{ if(!curMs[k])evs.push({action:'delete',ms:true,key:k,name:oldMs[k].name||k}); });
  Object.keys(curMs).forEach(k=>{ if(!oldMs[k]){
    const r=items.find(x=>x.type==='milestone'&&rowOrderKey(x)===k);
    const born=r&&addedItems.includes(r);
    evs.push({action:born?'add':'restore',ms:true,key:k,name:curMs[k].name||k,kind:born?'milestone':undefined});
    if(born&&via!=='paste'&&curMs[k].tags.length)
      evs.push({action:'tags',ms:true,key:k,name:curMs[k].name||k,from:{tags:[]},to:{tags:curMs[k].tags}});
  }});
  Object.keys(curMs).forEach(k=>{
    const a=oldMs[k],b=curMs[k];
    if(!a)return;
    const nm=b.name;
    if(a.name!==b.name)
      evs.push({action:'milestone.rename',ms:true,key:k,name:nm,from:{name:a.name},to:{name:b.name}});
    if(a.date!==b.date&&a.date&&b.date)
      evs.push({action:'milestone',ms:true,key:k,name:nm,from:{date:a.date},to:{date:b.date},
                shift:{days:daysBetween(a.date,b.date)}});
    if((a.owner||'')!==(b.owner||'')) evs.push({action:'owner',ms:true,key:k,name:nm,from:{owner:a.owner||''},to:{owner:b.owner||''}});
    if((a.status||'')!==(b.status||'')) evs.push({action:'status',ms:true,key:k,name:nm,from:{status:a.status||''},to:{status:b.status||''}});
    if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',ms:true,key:k,name:nm,from:{desc:a.desc||''},to:{desc:b.desc||''}});
    if((a.prov||'')!==(b.prov||'')) evs.push({action:'provenance',ms:true,key:k,name:nm,from:{label:a.provL||''},to:{label:b.provL||''}});
    if((a.parent||'')!==(b.parent||'')) evs.push(Object.assign({action:'parent',ms:true,key:k,name:nm},parentEv(a.parent,b.parent)));
    if(a.phase!==b.phase&&!b.parent) evs.push({action:'phase',ms:true,key:k,name:nm,from:{phase:phaseNameAt(a.phase)},to:{phase:phaseNameAt(b.phase)}});
    if(!sameTags(a.tags,b.tags)) evs.push({action:'tags',ms:true,key:k,name:nm,from:{tags:a.tags||[]},to:{tags:b.tags}});
    // What a gate waits on (0.78.0), in the same two events a task's dependencies make.
    const ad=a.deps||[], bd=b.deps||[], mid=b.id||undefined;
    bd.filter(d=>!ad.includes(d)).forEach(d=>evs.push({action:'dep.add',ms:true,key:k,id:mid,name:nm,dep:d,depName:nameOf(d)}));
    ad.filter(d=>!bd.includes(d)).forEach(d=>evs.push({action:'dep.remove',ms:true,key:k,id:mid,name:nm,dep:d,depName:nameOf(d)}));
  });
  // A section's tags (0.59.0). Diffed here rather than posted by the Setup page, so an
  // undo and a redo of a tag edit are logged too, as every other undo is, and so one
  // gesture that touches many sections (rename a tag everywhere, the prefix button) goes
  // out as ONE batch behind its save.
  const curPh=cur.ph||{},oldPh=lastSnap.ph||{};
  Object.keys(curPh).forEach(k=>{
    const a=oldPh[k],b=curPh[k]; if(!a)return;
    if(JSON.stringify(a.tags)!==JSON.stringify(b.tags))
      evs.push({action:'phase.tags',phase:b.i,name:b.name,from:{tags:a.tags},to:{tags:b.tags}});
  });
  // The tag registry (0.61.0): tag.create, tag.rename, tag.color, tag.delete. An entry made
  // only to hold a colour for a tag the plan already carries is a colour, not a new tag.
  const curR=cur.reg||{},oldR=lastSnap.reg||{};
  const carried=new Set(usedTags().map(e=>e.key));
  // Since 0.62.0 any registry edit lists EVERY tag (regMaterialize), so that the order can
  // be dragged. An entry for a tag the plan carries, with no colour, is bookkeeping: it
  // coming or going is not something anybody did, and it is not logged. A carried tag's
  // entry going while it held a colour is the colour being cleared.
  Object.keys(oldR).forEach(k=>{ if(curR[k])return; const o=oldR[k];
    if(!carried.has(tagKey(o.name)))evs.push({action:'tag.delete',tag:o.name,from:{color:o.color}});
    else if(o.color)evs.push({action:'tag.color',tag:o.name,from:{color:o.color},to:{color:''}}); });
  Object.keys(curR).forEach(k=>{
    const a=oldR[k],b=curR[k];
    if(!a){
      if(b.color&&carried.has(tagKey(b.name)))evs.push({action:'tag.color',tag:b.name,from:{color:''},to:{color:b.color}});
      else if(b.color||!carried.has(tagKey(b.name)))evs.push({action:'tag.create',tag:b.name,to:{color:b.color}});
      return;
    }
    if(a.name!==b.name)evs.push({action:'tag.rename',tag:b.name,from:{name:a.name},to:{name:b.name}});
    if(a.color!==b.color)evs.push({action:'tag.color',tag:b.name,from:{color:a.color},to:{color:b.color}});
  });
  const before=lastSnap.order,after=cur.order;
  // Only a real reorder. Adding or removing a row changes this list, and so does
  // moving one to another lane, and both of those already say so themselves.
  const explained=evs.some(e=>e.action==='phase'||e.action==='parent');
  if(!explained&&before.length===after.length&&JSON.stringify(before)!==JSON.stringify(after)){
    const moved=after.filter((v,i)=>before[i]!==v);
    evs.push({action:'reorder',order:after,moved:moved.slice(0,8),hint:moveHint||undefined});
  }
  // An auto-schedule pass moves every task by a DIFFERENT amount, on purpose, so the
  // plan-shift collapse cannot see it. It is still one press of one button and one
  // Ctrl+Z, and thirty date lines would bury that, so it collapses on the `via`
  // instead of on the shape of the deltas. It runs LAST, after the milestone diff,
  // because a milestone moved off a weekend is part of the same press and its event
  // is not produced until then. The rows it moved are named, up to a readable number
  // of them, so the log still says WHAT moved and not only how many.
  if(via==='auto-schedule'){
    const moved=evs.filter(e=>e.action==='dates'||e.action==='milestone');
    if(moved.length){
      const kept=evs.filter(e=>e.action!=='dates'&&e.action!=='milestone');
      evs.length=0;
      evs.push({action:'auto-schedule',count:moved.length,
                moved:moved.slice(0,12).map(e=>e.name||e.id||e.key)},...kept);
    }
  }
  // A paste is ONE action and one Ctrl+Z, so it is one line in the log too. Five
  // "added a new task" lines in a row say nothing about the fact that they are five
  // copies of one chain made in one keystroke, and a reader trying to reconstruct
  // what a person did would have to infer it from the timestamps. Collapsed on the
  // `via` for the same reason auto-schedule is: the shape of the events cannot tell
  // a paste from five separate creates, only the gesture can.
  //
  // It runs LAST, after the milestone diff, because a pasted milestone's `add` is not
  // produced until then and it belongs in the same count. Rows are NAMED, up to a
  // readable number of them, so the log still says what was pasted.
  if(via==='paste'){
    const born=evs.filter(e=>e.action==='add');
    if(born.length){
      const kept=evs.filter(e=>e.action!=='add');
      evs.length=0;
      evs.push({action:'paste',count:born.length,
                names:born.slice(0,12).map(e=>e.name||e.id||e.key),
                hint:born.length+' task'+(born.length===1?'':'s')+' pasted'},...kept);
    }
  }
  lastSnap=cur;
  moveHint=null;
  if(!evs.length)return;
  if(SNAPSHOT)return;   // a snapshot has no server to record against
  // Every event carries the row's uid, stamped in one place rather than at the
  // twenty spots above that build one, so a new kind of event cannot be added
  // without it. `id` and `name` stay exactly as they were: the log is append-only
  // and a reader written against the old fields has to keep working forever.
  const stamp=e=>{
    const o={...e,via};
    if(o.uid===undefined){
      if(o.ms&&o.key)o.uid=o.key;
      else if(o.id){ const r=items.find(x=>x.id===o.id); if(r&&r.uid)o.uid=r.uid; }
    }
    return o;
  };
  postLog(evs.map(stamp));
}
// The one door to the changelog, so the chaining rule cannot be bypassed by a caller that
// did not know about it. See logQueue above for why nothing goes out ahead of its save.
function postLog(events){
  if(!events.length)return;
  unsavedCount+=events.length;
  logQueue.push(...events);
  if(logQueue.length>LOG_QUEUE_MAX)logQueue.splice(0,logQueue.length-LOG_QUEUE_MAX);
  refreshConflictCount();
}
function logBody(events){ return JSON.stringify({actor:'user',events}); }
// Called only after a save has landed, so the lines arrive behind the state they describe.
function flushLogQueue(){
  if(!logQueue.length)return;
  const q=logQueue; logQueue=[];
  fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},body:logBody(q)})
    .catch(()=>{});
}
// Tags can change under an edit that is not a tag edit: a delete takes a tagged row with
// it, an undo of a create, a paste, a row moved into a tagged section (its effective tags
// change). So every save compares the plan's tag picture with the last one drawn, and a
// difference repaints what reads it: the legend chips and counts, the filter (a tag gone
// from the plan leaves it), the suggestions. Cheap: a plan has tens of tags at most.
let tagSig='';
function tagSigNow(){ return JSON.stringify(planTags().map(e=>[e.tag,e.n,e.items,e.color])); }
// An edit can make a row overdue or settle it (a date, a status) and can move the owner
// counts, so the two chips and the row marks are re-read after each one. Only re-read:
// a row that stops matching stays on screen until the filter is next touched, rather
// than vanishing from under the hand that just edited it.
function refreshViewChips(){
  if(!overdueChip.isConnected)return;
  syncOverdueChip(); syncCurrentChip(); syncOwnerPick();
  items.forEach(r=>{ if(isGroup(r))return; const od=isOverdue(r);
    if(r._lDiv)r._lDiv.classList.toggle('is-overdue',od); if(r._bDiv)r._bDiv.classList.toggle('is-overdue',od); });
}
// How long a burst of edits gathers before it goes out (0.77.0, was 800 ms): a batch is a
// few fields now, not the whole plan, so the team sees an edit sooner.
const SAVE_DEBOUNCE_MS=400;
function scheduleSave(via){markLocalEdit();refreshViewChips();if(tagsReady&&tagSigNow()!==tagSig)afterTagsChange();diffAndLog(via||'edit');syncPlanSpan();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();else if(prefs.view==='setup')renderSections(true);clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},SAVE_DEBOUNCE_MS);}
// Both writes wait: the sidecar for its 800ms debounce and, since 0.54.0, the changelog
// line for the save it belongs to. A tab closed inside that window would leave the edit in
// neither, so anything still pending goes out synchronously when the page is being put
// away, the state first and its lines behind it. sendBeacon is the only send guaranteed to
// outlive the document; keepalive fetch is the fallback.
function flushSave(){
  // A burst of Alt+Arrow presses holds its changelog line back for 600ms. If the
  // tab is put away inside that window the line still has to go, or the log and
  // the sidecar would disagree about a move that definitely happened.
  flushKeyMove();
  if(!saveTimer)return;
  clearTimeout(saveTimer);saveTimer=null;
  if(SNAPSHOT)return;
  // What this tab has that the server does not, as ops (0.77.0). A batch on its way is in
  // it too, which is harmless: an op says what a field IS, so landing twice changes nothing.
  const ops=computeOps().concat(reqOut.splice(0));
  if(ops.length){
    const body=JSON.stringify({client:CLIENT_ID,batch:CLIENT_ID+'.'+(++batchN),ops});
    let sent=false;
    try{
      if(navigator.sendBeacon)sent=navigator.sendBeacon('ops',new Blob([body],{type:'application/json'}));
    }catch(e){}
    if(!sent){
      try{fetch('ops',{method:'POST',headers:{'Content-Type':'application/json'},body,keepalive:true});}catch(e){}
    }
  }
  flushLogBeacon();
}
// The queue, on the way out of the document. Same two mechanisms as the state above, and
// second in line behind it, so the log still never precedes the state it describes.
function flushLogBeacon(){
  if(!logQueue.length)return;
  const q=logQueue; logQueue=[];
  const body=logBody(q);
  try{
    if(navigator.sendBeacon&&navigator.sendBeacon('log',new Blob([body],{type:'application/json'})))return;
  }catch(e){}
  try{fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},body,keepalive:true});}catch(e){}
}
function wireSaveFlush(){
window.addEventListener('pagehide',flushSave);
// pagehide does not fire when a mobile browser backgrounds the tab and later
// discards it, so the hidden transition flushes too.
document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='hidden')flushSave();});
}
// A snapshot is a file, not a server. The export used to try to rewrite this
// function with a regex that could never match its own source; the flag it
// already sets is enough.
// A BLOCKED TAB KEEPS TRYING. Through 0.53.0 this returned early on saveBlocked, which
// meant the first 409 was the last request the tab ever sent: the banner could never be
// re-raised, and there was no path by which a conflict could resolve on its own. The
// attempt costs one refused POST per 800ms of editing, the server answers it out of a
// single readFileSync, and in exchange the banner is telling the live truth and a save
// that starts working again clears it without the user doing anything.
let saveInFlight=false, saveRetry=null;
// The one save path (0.77.0). What this tab has that the server does not goes out as ops,
// one batch at a time: the next waits for this one's answer, so the server sees this tab's
// changes in the order they were made. Never refused for being stale; a refusal now means
// the person may not write, or the server could not be reached.
function saveToFile(){
  if(SNAPSHOT){flash(saveInd,'snapshot mode');return;}
  if(inflight||syncBusy){ clearTimeout(saveTimer); saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},250); return; }
  clearTimeout(saveRetry); saveRetry=null;
  sealUndo();
  // Request ops (0.80.0) ride after the rows, so a request for a row made in the same
  // batch finds it on the server.
  const at=localEditN, rq=reqOut.splice(0), ops=computeOps().concat(rq);
  if(!ops.length){ cleanAtN=at; if(unsavedCount||logQueue.length)onSaveOk('saved'); return; }
  const b={batch:CLIENT_ID+'.'+(++batchN),ops,rev:null,vals:new Map(),fields:new Map(),adds:new Set(),dels:new Set(),order:null,struct:curStruct(),sent:{}};
  ops.forEach(op=>{
    if(op.op==='row'||op.op==='add'){
      const uid=op.op==='add'?op.row.uid:op.uid, r=allRows.get(uid);
      if(!r)return;
      b.vals.set(uid,rowOv(r));
      const fs=b.fields.get(uid)||new Set();
      if(op.op==='add'){ b.adds.add(uid); ROW_F.forEach(f=>fs.add(f)); }
      else [op.set,op.add,op.del].forEach(o=>{ if(o)Object.keys(o).forEach(f=>fs.add(f)); });
      b.fields.set(uid,fs);
    } else if(op.op==='del')b.dels.add(op.uid);
    else if(op.op==='phases'||op.op==='reg'||op.op==='dates'||op.op==='cal')b.sent[op.op]=true;
  });
  if(ops.some(o=>o.op==='move'||o.op==='order'||o.op==='add'||o.op==='del'))b.order=liveOrder();
  inflight=b; saveInFlight=true;
  fetch('ops',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({client:CLIENT_ID,batch:b.batch,ops})})
    .then(async r=>{
      saveInFlight=false;
      const d=await r.json().catch(()=>({}));
      if(!r.ok){
        // Requests not taken go again with the next try, unless the person may not write.
        if(rq.length&&r.status!==403&&r.status!==401)reqOut.unshift(...rq);
        if(inflight===b)inflight=null;
        if(r.status===403)onSaveBlocked('This plan is not shared with you any more, so the server refused the save.');
        else if(r.status===401)onSaveBlocked('Your sign-in has ended, so the server refused the save. Export your version, then reload to sign in again.');
        else { onSaveBlocked('The server refused the save ('+r.status+').'); retrySaveSoon(); }
        return;
      }
      onSaveOk('saved');
      rqSettle(rq,d);
      if(inflight!==b)return;          // a catch-up settled it already
      const out=Array.isArray(d.ops)?d.ops:[];
      b.rev=typeof d.stateRev==='number'?d.stateRev:null;
      // Nothing taken (every op refused, or none changed anything): no revision was made
      // and none will be announced, so it settles here.
      if(!out.length||b.rev===null){ syncQ(()=>{ if(inflight===b){ inflight=null; applyOps([],{settle:b}); afterSync(); } }); return; }
      syncQ(()=>{
        if(inflight!==b)return;
        if(b.rev<=(baseRev||0)){ inflight=null; applyOps([],{settle:b}); afterSync(); return; }
        // No stream to hear it on: this answer is the announcement, when it is the next one.
        if((!live||live.readyState!==1)&&b.rev===(baseRev||0)+1){ inflight=null; applyOps(out,{mine:b,settle:b}); baseRev=b.rev; afterSync(); }
      });
      // The announcement normally lands before this answer or just after it. If it has not
      // in a few seconds the stream is behind or gone, and a catch-up settles everything.
      setTimeout(()=>{ if(inflight===b)pullAndMerge().catch(()=>{}); },3000);
    })
    .catch(()=>{ saveInFlight=false; if(rq.length)reqOut.unshift(...rq); if(inflight===b)inflight=null; onSaveBlocked('The server could not be reached.'); retrySaveSoon(); });
}
// A save that failed for want of a server is tried again on its own. Ops are safe to send
// twice: each says what a field is, which item goes in or out of a list, or which row a row
// follows, so one that lands twice changes nothing the second time.
function retrySaveSoon(){ clearTimeout(saveRetry); saveRetry=setTimeout(()=>{ saveRetry=null; if(!saveTimer)saveToFile(); },5000); }
// One thing at a time touches the page's copy of the plan from outside: an announcement, a
// catch-up, a batch's answer. They queue here, so they apply in the order they arrived.
let syncChain=Promise.resolve(), syncBusy=false, pullQueued=null, pullCount=0, liveMerging=false;
function syncQ(fn){
  const p=syncChain.then(async()=>{ syncBusy=true; try{ return await fn(); } finally{ syncBusy=false; } });
  syncChain=p.catch(()=>{});
  return p;
}
// A full catch-up, for when the announcements cannot be trusted to have arrived: a gap in
// the revisions, a reconnect, a save made by something that does not send ops (an older
// tab, a script writing the whole state). One at a time; a request made while one waits
// is the same request.
function pullAndMerge(){
  if(pullQueued)return pullQueued;
  pullQueued=syncQ(async()=>{ pullQueued=null; pullCount++; await pullOnce(); });
  return pullQueued;
}
async function pullOnce(force){
  if(SNAPSHOT)return;
  const r=await fetch('state'); if(!r.ok)throw new Error('state '+r.status);
  const st=await r.json();
  const rev=st&&typeof st.stateRev==='number'?st.stateRev:0;
  const late=inflight&&inflight.rev!==null&&inflight.rev<=rev;
  if(!st||st.empty){ baseRev=Math.max(baseRev||0,rev); return; }
  if(rev<=(baseRev||0)&&!late&&!force)return;
  applyOps(stateOps(st),{actor:liveLastActor||'Someone'});
  baseRev=Math.max(baseRev||0,rev);
  // This tab's batch is already in what came back: whatever it sent and has not changed
  // since takes the server's value, which is the batch's, or a later one's.
  if(inflight&&inflight.rev!==null&&inflight.rev<=baseRev){ const b=inflight; inflight=null; applyOps([],{settle:b}); }
  liveMerges++;
  // A catch-up replaces this tab's copy of the server's plan wholesale, so whatever the page
  // holds that the server does not shows only now, whatever this tab did or did not do: the
  // comparison runs (0.78.1).
  cleanAtN=-1;
  afterSync();
}
// The server's whole state as the ops that would turn the shadow into it, so a catch-up is
// applied by exactly the code that applies an announcement, with the same rules about what
// this tab has changed and not sent.
function stateOps(st){
  const ops=[];
  const gone=new Set(Array.isArray(st.deleted)?st.deleted:[]);
  const added=new Map(); (Array.isArray(st.added)?st.added:[]).forEach(a=>{ if(a&&a.uid)added.set(a.uid,a); });
  const entries=new Map(); (Array.isArray(st.items)?st.items:[]).forEach(o=>{ if(o&&o.uid)entries.set(o.uid,o); });
  const live=new Set();
  dataUids.forEach(u=>{ if(!gone.has(u))live.add(u); });
  added.forEach((_,u)=>{ if(!gone.has(u))live.add(u); });
  shadow.rows.forEach((_,u)=>{ if(!live.has(u))ops.push({op:'del',uid:u}); });
  const phaseN=Array.isArray(st.phases)&&st.phases.length&&st.phases.some(p=>p&&p.name!==undefined)?st.phases.length:phases.length;
  // Dependencies on a deleted row go with it, exactly as the server's merge drops them.
  const goneIds=new Set(); gone.forEach(u=>{ const b=added.get(u)||baseRowOf.get(u)||allRows.get(u); if(b&&b.id)goneIds.add(b.id); });
  live.forEach(u=>{
    if(shadow.rows.has(u)){
      // The server's WHOLE row, not only its overrides (0.77.1). A whole-plan save can change
      // a created row's own fields (its section, on 2026-11-03) and leave no override behind;
      // a page that read only the overrides never heard of it, and kept the row where it was.
      const base=added.get(u)||baseRowOf.get(u); if(!base)return;
      const row=Object.assign({},base); delete row._key;
      const sv=entries.get(u); if(sv)applyOverrideRow(row,sv,phaseN);
      const set=rowOv(row); delete set.uid;
      if(!('tags' in set))set.tags=[];
      if(Array.isArray(set.deps))set.deps=set.deps.filter(d=>!goneIds.has(d));
      ops.push({op:'row',uid:u,set});
      return;
    }
    const base=added.get(u)||allRows.get(u);
    if(!base)return;
    const row=Object.assign({},base); delete row._lDiv; delete row._bDiv; delete row._msLine;
    const sv=entries.get(u); if(sv)applyOverrideRow(row,sv,phaseN);
    ops.push({op:'add',uid:u,row:addedOut(row)});
  });
  if(Array.isArray(st.order))ops.push({op:'order',order:st.order});
  if(Array.isArray(st.phases)&&st.phases.length&&st.phases.some(p=>p&&p.name!==undefined))ops.push({op:'phases',phases:st.phases});
  if(Array.isArray(st.tags))ops.push({op:'reg',tags:st.tags});
  if(st.dates&&typeof st.dates==='object'&&!Array.isArray(st.dates))ops.push({op:'dates',dates:st.dates});
  if(st.calendar&&typeof st.calendar==='object'&&!Array.isArray(st.calendar))ops.push({op:'cal',calendar:st.calendar});
  // The requests whole (0.80.0): a catch-up replaces the list.
  ops.push({op:'reqs',list:Array.isArray(st.requests)?st.requests:[],people:st.people,notices:Array.isArray(st.notices)?st.notices:[],delegates:st.delegates||{}});
  return ops;
}
// After the page caught up: whatever it still has that the server does not goes out soon,
// and a tab that was blocked for want of a server is unblocked by having reached it.
function afterSync(){
  if(saveBlocked&&/could not be reached|refused the save \((5\d\d|0)\)/.test(blockedWhy))clearConflict('back in sync');
  // Only when this page did something since computeOps last found nothing (0.78.1). What
  // the page has unsent is still read off the page and the shadow, never kept in a list;
  // the count only says whether it is worth looking. Every save asked for, every undo step
  // and every gesture on the page counts. A page nobody is touching compared all ~500 rows
  // after every change anybody made, about 5 ms each, three times a second in a meeting.
  if(saveTimer||inflight||(localEditN===cleanAtN&&!reqOut.length))return;
  const at=localEditN;
  if(computeOps().length||reqOut.length)saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},150);
  else cleanAtN=at;
}
// A gesture counts before anything it does: a key held for a move saves 600 ms later, and an
// announcement landing in between must still look.
['pointerdown','keydown','input','change','drop','paste','cut'].forEach(t=>document.addEventListener(t,markLocalEdit,true));
// An announcement: a batch the server applied, with the revision it made. The next one in
// line is applied as it stands; this tab's own batch coming back is its confirmation; a
// gap, or a save that carried no ops, means a catch-up.
function onRev(m){
  if(typeof m.stateRev!=='number')return;
  if(m.by!==CLIENT_ID&&m.actor)liveLastActor=m.actor;
  syncQ(async()=>{
    if(m.stateRev<=(baseRev||0))return;
    if(!Array.isArray(m.ops)||m.stateRev!==(baseRev||0)+1){ pullCount++; await pullOnce(); return; }
    const b=inflight&&m.by===CLIENT_ID&&m.batch===inflight.batch?inflight:null;
    if(b){ inflight=null; applyOps(m.ops,{mine:b,settle:b}); }
    else { applyOps(m.ops,{actor:m.actor||'Someone',self:m.by===CLIENT_ID}); liveMerges++; }
    baseRev=m.stateRev;
    afterSync();
  }).catch(()=>{});
}
// ── Requests (0.80.0): adding and moving tasks to other people ──
//
// Drew: "we need to add a request flow for adding/moving tasks to other people, which both
// humans and AI should honor." Setting a row's owner to somebody else does not put it on
// their plate: it asks them, and they accept or decline with an optional note, the pattern
// Linear's triage, Asana's approvals and Jira's approvals share. The SERVER decides (see
// reqEngine in server.js), so a tab of any version and the agent key all go through it;
// this page only shows it and asks first, so nothing flickers. Taking a task yourself,
// clearing an owner, dates, sections and status stay direct.
//
// The list lives in the sidecar (`requests`, and `people`: the names people have accepted
// under) and arrives with the plan, in every announcement (`req`, `people` ops) and in every
// catch-up. Which answers this person has already seen is a convenience of this browser.
const REQ={on:false,list:new Map(),people:{},youOwner:false,accounts:[],delegates:{},notices:new Map()};
const reqOut=[];
const RQ_SEEN_KEY='adom-gantt:reqSeen:'+location.pathname;
let rqSeen=new Set();
try{ rqSeen=new Set(JSON.parse(localStorage.getItem(RQ_SEEN_KEY)||'[]')); }catch(e){}
function rqSaveSeen(){ try{ localStorage.setItem(RQ_SEEN_KEY,JSON.stringify([...rqSeen].slice(-300))); }catch(e){} }
const rqNorm=s=>String(s==null?'':s).trim().replace(/^@/,'').replace(/\s+/g,' ').toLowerCase();
const rqClean=s=>String(s==null?'':s).trim().replace(/\s+/g,' ').slice(0,200);
function rqMyId(){ return ADOM_USER?'u:'+(ADOM_USER.id||ADOM_USER.username||ADOM_USER.displayName):''; }
// Who an owner name is: the same rule as the server's resolveName. A link the plan's owner
// made on Setup (or an accept made) decides; otherwise the accounts of the people who have
// signed in to this plan whose display name or username it is, else whose first name it is.
// `how`: linked, match, first, shared (two people share that first name), none (no account:
// assigned directly, and marked), off (the owner said it has no account).
function rqKeys(a){
  const ex=new Set(); [a.name,a.username].forEach(v=>{ const x=rqNorm(v); if(x)ex.add(x); });
  const f=rqNorm(a.name).split(' ')[0];
  return {ex,first:f.length>=2?f:''};
}
function rqResolve(name){
  const n=rqNorm(name); if(!n)return {ids:[],how:'none'};
  const l=REQ.people&&REQ.people[n];
  if(l&&typeof l==='object'){ if(l.none)return {ids:[],how:'off'}; if(l.id)return {ids:[l.id],how:'linked'}; }
  const acc=REQ.accounts.slice();
  // This person is an account whether or not the list has caught up with them.
  if(ADOM_USER&&!acc.some(a=>a.id===rqMyId()))acc.push({id:rqMyId(),name:ADOM_USER.displayName||'',username:ADOM_USER.username||''});
  const exact=acc.filter(a=>rqKeys(a).ex.has(n)).map(a=>a.id);
  if(ADOM_USER&&!exact.includes(rqMyId())){ const e=String(ADOM_USER.email||'').toLowerCase(); if(e&&(rqNorm(e)===n||rqNorm(e.split('@')[0])===n))exact.push(rqMyId()); }
  if(exact.length)return {ids:exact,how:exact.length>1?'shared':'match'};
  const first=acc.filter(a=>rqKeys(a).first===n).map(a=>a.id);
  if(first.length)return {ids:first,how:first.length>1?'shared':'first'};
  return {ids:[],how:'none'};
}
function rqIsMe(name){ return !!ADOM_USER&&rqResolve(name).ids.includes(rqMyId()); }
// A name nobody with an Adom account answers to: assigned directly, and marked quietly so
// the plan's owner links it (Drew, 0.80.0).
function rqUnlinked(name){ if(!rqOn()||!rqClean(name))return false; const h=rqResolve(name).how; return h==='none'||h==='off'; }
function rqOn(){ return REQ.on&&!!ADOM_USER&&!SNAPSHOT; }
// Would setting this row's owner to `v` be a request rather than a change?
function rqAsks(r,v){
  if(!rqOn()||!r)return false;
  const to=rqClean(v);
  if(!to||rqNorm(to)===rqNorm(r.owner))return false;
  // Nobody to ask behind the name: assigned directly.
  const res=rqResolve(to);
  return res.ids.length>0&&!res.ids.includes(rqMyId());
}
function rqPending(uid){ for(const q of REQ.list.values())if(q&&q.uid===uid&&q.state==='pending')return q; return null; }
function rqForMe(q){ return !!q&&q.state==='pending'&&rqIsMe(q.to); }
function rqMine(q){ return !!q&&!!q.from&&q.from.id===rqMyId(); }
const rqNewId=()=>'rq-'+Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,8);
// A request op goes out with the next batch, after the rows (a new row comes first).
function rqSend(op){ reqOut.push(op); markLocalEdit(); clearTimeout(saveTimer); saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},120); }
// Ask `to` to take row `r`. The row keeps its owner; the pill shows at once.
function rqAsk(r,to,opts){
  opts=opts||{}; to=rqClean(to);
  if(!r||!r.uid||!to)return null;
  const open=rqPending(r.uid);
  if(open&&rqNorm(open.to)===rqNorm(to)){ flash(saveInd,`${open.to} has already been asked to take "${r.name}"`,4000); return open; }
  const q={id:rqNewId(),uid:r.uid,to,was:r.owner||'',from:{id:rqMyId(),name:USER_NAME||'you'},at:new Date().toISOString(),state:'pending',_local:true};
  if(opts.created)q.created=true;
  if(opts.note)q.note=String(opts.note).slice(0,500);
  if(open){ open.state='closed'; open.why='replaced by a newer request'; }
  REQ.list.set(q.id,q);
  rqSend({op:'req',uid:r.uid,to,id:q.id,note:q.note,created:opts.created===true});
  // One Ctrl+Z takes the request back (it is withdrawn); joined to the edit that made it
  // when there was one, so the editor's Apply is still one undo.
  if(!opts.noUndo){
    const step={type:'req',do:'withdraw',id:q.id};
    const top=undoStack[undoStack.length-1];
    if(opts.join&&top){ undoStack.pop(); pushUndo({type:'multi',steps:[top,step]}); }
    else pushUndo(step);
  }
  rqRefresh([r.uid]);
  flash(saveInd,`Asked ${to} to take "${r.name}". It is ${to}'s when ${to} accepts.`,5000);
  return q;
}
// Answer, withdraw or take back an answer. Shown at once; the server's word follows.
function rqDo(id,what,note,noUndo){
  const q=REQ.list.get(id); if(!q)return;
  const prev=q.state;
  if(what==='accept'||what==='decline'){
    if(prev!=='pending')return;
    q.state=what==='accept'?'accepted':'declined'; q.by={id:rqMyId(),name:USER_NAME}; q.done=new Date().toISOString();
    if(note)q.reply=String(note).slice(0,500); else delete q.reply;
    rqSend({op:'req.answer',id,accept:what==='accept',note:note||undefined});
    rqSeen.add(id); rqSaveSeen();
  } else if(what==='withdraw'){
    if(prev!=='pending')return;
    q.state='withdrawn'; q.by={id:rqMyId(),name:USER_NAME}; q.done=new Date().toISOString();
    rqSend({op:'req.withdraw',id});
  } else if(what==='reopen'){
    if(prev==='pending')return;
    q.state='pending'; delete q.by; delete q.done; delete q.reply;
    rqSend({op:'req.reopen',id});
  }
  if(!noUndo)pushUndo(rqInverse(id,what,prev,note));
  rqRefresh([q.uid]);
}
function rqInverse(id,what,prev,note){
  if(what==='reopen')return {type:'req',do:prev==='accepted'?'accept':prev==='declined'?'decline':'withdraw',id,note:note||''};
  return {type:'req',do:'reopen',id,prev:what==='accept'?'accepted':what==='decline'?'declined':'withdrawn',note:note||''};
}
// An undo entry for a request: do it again (or take it back), and hand back the opposite.
function applyReqSnapshot(sn){
  const q=REQ.list.get(sn.id);
  const note=sn.note||(q&&q.reply)||'';
  if(q)rqDo(sn.id,sn.do,note,true); else rqSend(sn.do==='withdraw'?{op:'req.withdraw',id:sn.id}:sn.do==='reopen'?{op:'req.reopen',id:sn.id}:{op:'req.answer',id:sn.id,accept:sn.do==='accept',note:note||undefined});
  return sn.do==='reopen'?rqInverse(sn.id,'reopen',sn.prev||'withdrawn',note):rqInverse(sn.id,sn.do,'pending',note);
}
// A request from the server: the whole of it, as stored. Tells this person when somebody
// asks them, and when somebody answers what they asked.
function rqUpsert(req,quiet){
  if(!req||typeof req!=='object'||!req.id)return;
  const old=REQ.list.get(req.id);
  REQ.list.set(req.id,req);
  if(quiet)return;
  const r=liveRowOf(req.uid), nm=r?r.name:'a task';
  if(req.state==='pending'&&rqIsMe(req.to)&&!rqMine(req)&&(!old||old.state!=='pending'))
    flash(saveInd,`${req.from&&req.from.name||'Someone'} asked you to take "${nm}". Open Requests to answer.`,8000);
  else if(rqMine(req)&&old&&old.state==='pending'&&(req.state==='accepted'||req.state==='declined')&&req.by&&req.by.id!==rqMyId())
    flash(saveInd,`${req.by.name} ${req.state} "${nm}"${req.reply?': "'+req.reply+'"':''}`,8000);
}
// A notice (0.80.0): somebody moved the dates of tasks this person owns. One per batch per
// person, kept up to date for ten minutes from the same hand. Told once, on arrival.
function rqNoticeForMe(n){ return !!n&&Array.isArray(n.ids)&&n.ids.includes(rqMyId())&&!(n.from&&n.from.id===rqMyId()); }
function rqNotice(n,quiet){
  if(!n||!n.id)return;
  const old=REQ.notices.get(n.id);
  REQ.notices.set(n.id,n);
  if(old&&old.upd!==n.upd)rqSeen.delete(n.id);
  if(quiet||!rqNoticeForMe(n))return;
  const r0=n.rows&&n.rows[0];
  flash(saveInd,n.count>1?`${n.from&&n.from.name||'Someone'} moved the dates of ${n.count} of your tasks. Requests shows which.`:`${n.from&&n.from.name||'Someone'} moved the dates of your task "${r0?r0.name:''}"`,7000);
}
// The whole list, as a catch-up or the first load has it. Requests this page has made and
// not sent yet are kept.
function rqLoad(list,people,notices,delegates){
  if(notices!==undefined){ REQ.notices.clear(); (Array.isArray(notices)?notices:[]).forEach(n=>{ if(n&&n.id)REQ.notices.set(n.id,n); }); }
  if(delegates!==undefined)REQ.delegates=delegates&&typeof delegates==='object'&&!Array.isArray(delegates)?delegates:{};
  const keep=[...REQ.list.values()].filter(q=>q&&q._local&&reqOut.some(o=>o.id===q.id));
  REQ.list.clear();
  (Array.isArray(list)?list:[]).forEach(q=>{ if(q&&q.id)REQ.list.set(q.id,q); });
  keep.forEach(q=>{ if(!REQ.list.has(q.id))REQ.list.set(q.id,q); });
  REQ.people=people&&typeof people==='object'&&!Array.isArray(people)?people:{};
}
// What the server did with this page's request ops: one it refused is taken off the page,
// and the person is told why.
function rqSettle(sent,d){
  if(!sent||!sent.length)return;
  const back=new Set((Array.isArray(d&&d.ops)?d.ops:[]).filter(o=>o&&o.op==='req'&&o.req).map(o=>o.req.id));
  const why=(Array.isArray(d&&d.refused)?d.refused:[]).filter(x=>/^req/.test(x));
  const uids=[];
  sent.forEach(op=>{ if(op.op!=='req'||!op.id||back.has(op.id))return; const q=REQ.list.get(op.id);
    if(q&&q._local){ REQ.list.delete(op.id); uids.push(q.uid); } });
  if(why.length)flash(saveInd,'The server did not take that request: '+why[0],7000);
  else{
    // The save's own "saved" would otherwise be the last word: say what was done.
    const made=sent.filter(op=>op.op==='req'&&back.has(op.id)).map(op=>REQ.list.get(op.id)).filter(Boolean);
    if(made.length===1){ const q=made[0], r=liveRowOf(q.uid); flash(saveInd,`Asked ${q.to} to take "${r?r.name:'it'}". It is ${q.to}'s when ${q.to} accepts.`,6000); }
    else if(made.length>1)flash(saveInd,`Asked ${made.length} people to take tasks. Each is theirs when they accept.`,6000);
  }
  if(uids.length||why.length)rqRefresh(uids);
}
// The pill on a row: "-> Ana?", in the accent for the person asked.
function reqPillHtml(r){
  if(!r||!r.uid||!REQ.list.size)return '';
  const q=rqPending(r.uid); if(!q)return '';
  const mine=rqIsMe(q.to);
  const t=`${q.from&&q.from.name||'Someone'} asked ${q.to} to take this, ${agoText(q.at)||'just now'}${q.note?': "'+q.note+'"':''}. ${mine?'Click to answer':'Waiting on '+q.to}`;
  return `<span class="tag req-pill${mine?' mine':''}" data-req="${esc(q.id)}" title="${esc(t)}">→ ${esc(q.to)}?</span>`;
}
// Repaint what shows requests: the rows named, the badge, the open panel and editor.
function rqRefresh(uids){
  (uids||[]).forEach(u=>{ const r=liveRowOf(u); if(!r)return;
    if(r.type==='milestone')refreshMsChrome(r); else refreshLabelRow(r);
    if(tableBody&&tableRowsCache){ tableForceKeys.add(rowKey(r)); } });
  if(prefs.view==='table'&&(uids||[]).length){ const rows=uids.map(liveRowOf).filter(Boolean); if(!patchTableRows(rows,new Set(['owner'])))renderTable(); }
  rqBadge();
  if(reqPanel.classList.contains('show'))rqRenderPanel();
  if(edSubject&&(uids||[]).includes(edSubject.uid))rqEditorLine(edSubject);
}
function rqCounts(){
  let forMe=0, answered=0, moved=0;
  REQ.list.forEach(q=>{ if(rqForMe(q))forMe++;
    else if(rqMine(q)&&(q.state==='accepted'||q.state==='declined')&&q.by&&q.by.id!==rqMyId()&&!rqSeen.has(q.id))answered++; });
  REQ.notices.forEach(n=>{ if(rqNoticeForMe(n)&&!rqSeen.has(n.id))moved++; });
  return {forMe,answered,moved};
}
function rqBadge(){
  const b=$('btnReq'), n=$('reqN'); if(!b||!n)return;
  b.style.display=rqOn()||REQ.list.size?'':'none';
  const c=rqCounts(), t=c.forMe+c.answered+c.moved;
  n.textContent=t?String(t):'';
  b.title=(c.forMe?`${c.forMe} request${c.forMe===1?'':'s'} waiting on you`:'No requests waiting on you')+(c.answered?`, ${c.answered} answer${c.answered===1?'':'s'} to yours`:'')+(c.moved?`, ${c.moved} change${c.moved===1?'':'s'} to your tasks`:'')+'. Tasks people ask you to take, the ones you asked of others, and dates moved on yours';
}
const reqPanel=$('reqPanel');
function rqItemHtml(q,kind){
  const r=liveRowOf(q.uid), nm=r?r.name:'(a row that is gone)';
  const who=q.from&&q.from.name||'Someone';
  const st=`<span class="rq-state ${esc(q.state)}">${esc(q.state)}</span>`;
  let meta, act='';
  if(kind==='for'){
    meta=`${esc(who)} asked you${q.was?' (it is '+esc(q.was)+'\'s now)':q.created?' (a new task)':''}, ${esc(agoText(q.at))}`;
    act=`<div class="rq-act"><input type="text" class="rq-in" maxlength="500" placeholder="a note (optional)" data-req="${esc(q.id)}"><button class="rq-b yes" data-a="accept" data-req="${esc(q.id)}">Accept</button><button class="rq-b" data-a="decline" data-req="${esc(q.id)}">Decline</button></div>`;
  } else if(kind==='asked'){
    meta=`you asked ${esc(q.to)}, ${esc(agoText(q.at))}${q.by&&q.state!=='pending'?' · '+esc(q.state)+' by '+esc(q.by.for?q.by.for.name+'\'s AI':q.by.name)+' '+esc(agoText(q.done)):''}${q.why?' ('+esc(q.why)+')':''}`;
    if(q.state==='pending')act=`<div class="rq-act"><button class="rq-b" data-a="withdraw" data-req="${esc(q.id)}">Withdraw</button></div>`;
  } else {
    meta=`${esc(who)} asked ${esc(q.to)}, ${esc(agoText(q.at))}`;
    act=`<div class="rq-act"><input type="text" class="rq-in" maxlength="500" placeholder="a note (optional)" data-req="${esc(q.id)}"><button class="rq-b yes" data-a="accept" data-req="${esc(q.id)}" title="Answer for ${esc(q.to)}, as the plan's owner">Accept for them</button><button class="rq-b" data-a="decline" data-req="${esc(q.id)}">Decline</button><button class="rq-b" data-a="withdraw" data-req="${esc(q.id)}">Withdraw</button></div>`;
  }
  const notes=(q.note?`<div class="rq-note">"${esc(q.note)}"</div>`:'')+(q.reply?`<div class="rq-note">${esc(q.by?q.by.name:'')}: "${esc(q.reply)}"</div>`:'');
  return `<div class="rq-item" data-req="${esc(q.id)}"><a class="rq-row" data-uid="${esc(q.uid)}" href="#row=${esc(q.uid)}">${esc(nm)}</a>${kind==='asked'?st:''}<div class="rq-meta">${meta}</div>${notes}${act}</div>`;
}
function rqNoticeHtml(n){
  const rows=(n.rows||[]).slice(0,6), more=Math.max(0,(n.count||rows.length)-rows.length);
  const d=v=>v?(v.date?fmt(v.date):(v.s?fmt(v.s):'')+' to '+(v.e?fmt(v.e):'')):'';
  return `<div class="rq-item rq-ntc" data-notice="${esc(n.id)}"><div class="rq-meta">${esc(n.from&&n.from.name||'Someone')} moved the dates of ${n.count>1?esc(String(n.count))+' of your tasks':'your task'}, ${esc(agoText(n.upd||n.at))}</div>`
    +rows.map(x=>`<div class="rq-mv"><a class="rq-row" data-uid="${esc(x.uid)}" href="#row=${esc(x.uid)}">${esc(x.name||'#'+x.uid)}</a> <div class="rq-meta">now ${esc(d(x.now))}, was <span class="rq-was">${esc(d(x.was))}</span></div></div>`).join('')
    +(more?`<div class="rq-meta">and ${more} more</div>`:'')+'</div>';
}
function rqRenderPanel(){
  const all=[...REQ.list.values()].sort((a,b)=>String(b.at).localeCompare(String(a.at)));
  const forMe=all.filter(rqForMe);
  const asked=all.filter(q=>rqMine(q)&&q.state!=='closed').slice(0,30);
  const others=REQ.youOwner?all.filter(q=>q.state==='pending'&&!rqIsMe(q.to)&&!rqMine(q)):[];
  // Keep a note being typed across a repaint.
  const drafts={}; reqPanel.querySelectorAll('.rq-in').forEach(i=>{ if(i.value)drafts[i.dataset.req]=i.value; });
  const sec=(t,list,kind,none)=>`<h3>${t}</h3>`+(list.length?list.map(q=>rqItemHtml(q,kind)).join(''):`<div class="rq-none">${none}</div>`);
  const moved=[...REQ.notices.values()].filter(rqNoticeForMe).sort((a,b)=>String(b.upd||b.at).localeCompare(String(a.upd||a.at))).slice(0,20);
  const aiOn=!!(REQ.delegates[rqMyId()]&&REQ.delegates[rqMyId()].on);
  reqPanel.innerHTML=`<div class="rq-head"><span class="rq-title">Requests</span></div>`
    +(rqOn()?'':`<div class="rq-off">Requests are off on this plan: an owner change applies at once.</div>`)
    +sec('For you',forMe,'for','Nothing waiting on you.')
    +`<h3>Changes to your tasks</h3>`+(moved.length?moved.map(rqNoticeHtml).join(''):'<div class="rq-none">Nobody has moved your dates lately.</div>')
    +sec('You asked',asked,'asked','You have not asked anyone to take a task.')
    +(REQ.youOwner?sec('Waiting on others',others,'other','Nothing else is waiting.'):'')
    +`<label class="rq-ai"><input type="checkbox" id="rqAi"${aiOn?' checked':''}> Let my AI answer requests for me <span class="rq-meta">An AI with this plan's agent key may then accept or decline in your name; the history says it was your AI.</span></label>`;
  reqPanel.querySelectorAll('.rq-in').forEach(i=>{ if(drafts[i.dataset.req])i.value=drafts[i.dataset.req]; });
}
function rqPlacePanel(){
  const b=$('btnReq'); if(!b)return;
  const r=b.getBoundingClientRect(), w=reqPanel.offsetWidth||380;
  reqPanel.style.left=Math.max(8,Math.min(r.right-w,window.innerWidth-w-8))+'px';
  reqPanel.style.top=(r.bottom+6)+'px';
}
function openReqPanel(focusId){
  closeMenus();
  rqRenderPanel();
  reqPanel.classList.add('show');
  rqPlacePanel();
  // Answers to what this person asked count as seen once the panel shows them.
  REQ.list.forEach(q=>{ if(rqMine(q)&&q.state!=='pending')rqSeen.add(q.id); });
  REQ.notices.forEach(n=>{ if(rqNoticeForMe(n))rqSeen.add(n.id); }); rqSaveSeen(); rqBadge();
  if(focusId){ const it=reqPanel.querySelector(`.rq-item[data-req="${CSS.escape(focusId)}"]`); if(it){ it.scrollIntoView({block:'nearest'}); const i=it.querySelector('.rq-in'); if(i)i.focus(); } }
}
function closeReqPanel(){ reqPanel.classList.remove('show'); }
function wireRequests(){
  const b=$('btnReq');
  if(b)b.addEventListener('click',e=>{ e.stopPropagation(); if(reqPanel.classList.contains('show'))closeReqPanel(); else openReqPanel(); });
  reqPanel.addEventListener('click',e=>{
    e.stopPropagation();
    const a=e.target.closest('.rq-row');
    if(a){ e.preventDefault(); closeReqPanel(); location.hash='row='+a.dataset.uid; openRowLink(); return; }
    if(e.target.id==='rqAi'){ rqSend({op:'ai.answers',on:e.target.checked}); REQ.delegates=Object.assign({},REQ.delegates); if(e.target.checked)REQ.delegates[rqMyId()]={on:true}; else delete REQ.delegates[rqMyId()];
      flash(saveInd,e.target.checked?'Your AI may now answer requests for you; the history will say it was your AI':'Your AI no longer answers requests for you',5000); return; }
    const btn=e.target.closest('.rq-b'); if(!btn)return;
    const id=btn.dataset.req, inp=reqPanel.querySelector(`.rq-in[data-req="${CSS.escape(id)}"]`);
    rqDo(id,btn.dataset.a,inp?inp.value.trim():'');
  });
  reqPanel.addEventListener('keydown',e=>{
    e.stopPropagation();
    if(e.key==='Escape'){ e.preventDefault(); closeReqPanel(); if(b)b.focus(); }
    else if(e.key==='Enter'&&e.target.classList.contains('rq-in')){ e.preventDefault(); rqDo(e.target.dataset.req,'accept',e.target.value.trim()); }
  });
  document.addEventListener('click',e=>{ if(reqPanel.classList.contains('show')&&!reqPanel.contains(e.target)&&e.target!==b)closeReqPanel(); });
  window.addEventListener('resize',()=>{ if(reqPanel.classList.contains('show'))rqPlacePanel(); });
  rqBadge();
}
// What the server says about requests on this plan, once at load.
async function rqFetch(){
  if(SNAPSHOT||!ADOM_USER)return;
  try{
    const r=await fetch('requests'); if(!r.ok)return;
    const d=await r.json();
    REQ.on=!!d.on; REQ.youOwner=!!d.youAreOwner;
    if(Array.isArray(d.accounts))REQ.accounts=d.accounts;
    if(!REQ.list.size&&Array.isArray(d.requests))rqLoad(d.requests.map(q=>{ const o=Object.assign({},q); delete o.row; return o; }),d.people,d.notices,d.delegates);
    else { if(!REQ.notices.size&&Array.isArray(d.notices))d.notices.forEach(n=>{ if(n&&n.id)REQ.notices.set(n.id,n); }); if(d.delegates&&typeof d.delegates==='object')REQ.delegates=d.delegates; }
    // The owner pills and cells mark names nobody answers to, now that the accounts are known.
    items.forEach(r=>{ if(!isGroup(r)&&r.uid&&rqClean(r.owner)){ if(r.type==='milestone')refreshMsChrome(r); else refreshLabelRow(r); } });
    if(prefs.view==='table')renderTable();
    if(prefs.view==='setup')renderPeopleCard();
    rqRefresh([...new Set([...REQ.list.values()].map(q=>q.uid))]);
  }catch(e){}
}
// The item editor's line about a request on its row: who asked whom, and the buttons that
// answer or withdraw it. Empty when there is none.
function rqEditorLine(r){
  const el=$('imReq'); if(!el)return;
  const q=r&&rqPending(r.uid);
  if(!q){ el.style.display='none'; el.innerHTML=''; return; }
  const mine=rqIsMe(q.to), asker=rqMine(q);
  el.classList.add('im-req'); el.classList.toggle('mine',mine);
  el.style.display='';
  el.innerHTML=`<span>${mine?esc(q.from&&q.from.name||'Someone')+' asked you to take this':'Waiting on '+esc(q.to)+(asker?' (you asked)':' (asked by '+esc(q.from&&q.from.name||'someone')+')')}${q.note?': "'+esc(q.note)+'"':''}</span>`
    +(mine?`<button class="rq-b yes" id="imReqYes" type="button">Accept</button><button class="rq-b" id="imReqNo" type="button">Decline</button>`:'')
    +((asker||REQ.youOwner)&&!mine?`<button class="rq-b" id="imReqWd" type="button">Withdraw</button>`:'');
  const on=(id,what)=>{ const x=$(id); if(x)x.addEventListener('click',e=>{ e.preventDefault(); e.stopPropagation(); rqDo(q.id,what,''); }); };
  on('imReqYes','accept'); on('imReqNo','decline'); on('imReqWd','withdraw');
}
// ── Applying ops to the page ──
// One batch, applied to the page and repainted once. `mine` is this tab's own batch coming
// back (its values are compared with what it SENT, so a field changed again since is kept);
// `settle` hands back whatever that batch sent and the server did not take.
function applyOps(ops,ctx){
  ctx=ctx||{};
  sealUndo();
  const T={rows:new Set(),fields:new Set(),fresh:new Set(),structure:false,order:false,phases:false,reg:false,dates:false,cal:false};
  // A batch that only reorders rows cannot move the plan's span, so the two whole-plan
  // scans that compare it before and after are skipped (0.78.1), and the reader's place is
  // read off the rows alone (placeMarkRows). Since 0.79.0 a move is the row's own key, so a
  // batch whose rows change nothing but their keys is one of these too.
  const orderOnly=!ctx.settle&&Array.isArray(ops)&&ops.length>0&&ops.every(o=>o&&(o.op==='order'||keyOnlyOp(o)));
  const mark=orderOnly?placeMarkRows():placeMark();
  const range0=prefs.view==='gantt'&&!orderOnly?JSON.stringify(scanRange()):null;
  liveMerging=true; orderOnlyMerge=orderOnly;
  try{
    withTable(()=>withDeps(()=>{
      inRowBatch(()=>{
        // Sections first, then rows, then the order (0.77.1): a row moved into a section that
        // is new in the same batch was refused when the row came first, and the page then
        // held the row's old section as an unsent edit of its own and sent it back.
        const rank=o=>(o.op==='phases'||o.op==='reg'||o.op==='dates'||o.op==='cal')?0:o.op==='order'?2:1;
        (ops||[]).filter(op=>op&&typeof op==='object').map((op,i)=>[op,i]).sort((a,b)=>rank(a[0])-rank(b[0])||a[1]-b[1]).forEach(([op])=>{
          // One op that cannot be applied must not take the rest of the batch with it; the
          // page fetches the whole plan instead of carrying on half-applied.
          try{ applyOne(op,ctx.mine||null,ctx.self?'':ctx.actor||'Someone',T); }
          catch(e){ T.resync='op'; try{ console.warn('adom-gantt: could not apply a change ('+op.op+'), fetching the plan:',e); }catch(_){} }
        });
        if(ctx.settle)settleSent(ctx.settle,T);
      });
      // The server's order is derived from the places this batch carried (0.79.0). A page with
      // nothing of its own on the way that lays its rows out otherwise has missed a change.
      if(T.orderCheck&&!inflight&&!T.resync){
        const lo=liveOrder(), have=new Set(lo), F=T.orderCheck.filter(k=>have.has(k)), want=new Set(F);
        if(F.join('\n')!==lo.filter(k=>want.has(k)).join('\n')&&!computeOps().length)T.resync='order';
      }
      repaintAfter(T,range0);
    }));
    if(T.structure)keepPlace(mark);
    else if(T.order)orderOnly?keepPlaceRows(mark):keepPlace(mark);
    if(edSubject&&(T.rows.has(edSubject)||T.structure||T.order))syncEditorDates(edSubject);
  }finally{ liveMerging=false; orderOnlyMerge=false; }
  if(T.structure||T.phases||T.reg||T.dates||T.cal)lastSnap=snapSemantic();
  else{
    if(T.rows.size)patchSnap(T.rows);
    // The changelog's baseline takes the new order and nothing else (0.78.1).
    if(T.order&&lastSnap)lastSnap.order=items.map(rowOrderKey);
  }
  if(T.resync)requestResync(T.resync);
  if(T.reqs)rqRefresh([...T.reqs]);
  if(T.people&&prefs.view==='setup')renderPeopleCard();
  return T;
}
// A row op that changes nothing but the row's key among its siblings (0.79.0): the same parent
// and, for a top-level row, the same section. Applied, it moves the row inside its section and
// changes nothing else about it or any other row.
function keyOnlyOp(o){
  if(!o||o.op!=='row'||!o.set||typeof o.set!=='object'||o.add||o.del)return false;
  const r=liveRowOf(o.uid); if(!r)return false;
  for(const k of Object.keys(o.set)){
    if(k==='pos')continue;
    if(k==='parent'&&(o.set.parent||'')===(r.parent||''))continue;
    if(k==='phase'&&o.set.phase===r.phase)continue;
    return false;
  }
  return 'pos' in o.set;
}
// A full catch-up on purpose (0.77.1), for a page that found it disagrees with the server in a
// way no announcement will put right: a row in another section than the server has it, a
// value it could not take, an op it could not apply. What this page changed and has not sent
// is kept, as in every catch-up. At most one every ten seconds.
let resyncAt=0, resyncCount=0;
function requestResync(why){
  const now=Date.now(); if(now-resyncAt<10000)return; resyncAt=now; resyncCount++;
  syncQ(()=>pullOnce(true)).catch(()=>{});
}
function applyOne(op,mine,who,T){
  if(op.op==='row')return applyRowOp(op,mine,who,T);
  // Requests (0.80.0): one request whole, the names people answer to, or (a catch-up) all.
  if(op.op==='req'){ if(op.req&&op.req.id){ rqUpsert(op.req,!who); (T.reqs||(T.reqs=new Set())).add(op.req.uid); } return; }
  if(op.op==='people'){ REQ.people=op.people&&typeof op.people==='object'?op.people:{}; T.reqs=T.reqs||new Set(); items.forEach(r=>{ if(rqPending(r.uid)||rqClean(r.owner))T.reqs.add(r.uid); }); T.people=true; return; }
  if(op.op==='notice'){ if(op.notice&&op.notice.id){ rqNotice(op.notice,!who); T.reqs=T.reqs||new Set(); } return; }
  if(op.op==='delegates'){ REQ.delegates=op.delegates&&typeof op.delegates==='object'?op.delegates:{}; T.reqs=T.reqs||new Set(); return; }
  if(op.op==='reqs'){ const before=new Set([...REQ.list.values()].filter(q=>q.state==='pending').map(q=>q.uid)); rqLoad(op.list,op.people,op.notices,op.delegates);
    T.reqs=T.reqs||new Set(); before.forEach(u=>T.reqs.add(u)); REQ.list.forEach(q=>{ if(q.state==='pending')T.reqs.add(q.uid); }); return; }
  if(op.op==='add')return applyAddOp(op,mine,who,T);
  if(op.op==='del')return applyDelOp(op,mine,who,T);
  if(op.op==='order'){ if(Array.isArray(op.order)){ shadow.order=op.order.slice(); T.orderCheck=op.order; } return; }
  const flying=k=>!mine&&!!inflight&&!!inflight.sent[k];
  const sentDiff=(k,cur)=>mine&&mine.sent[k]?cur!==mine.struct[k]:false;
  if(op.op==='phases'){
    if(!Array.isArray(op.phases))return;
    const sig=phasesSigOf(op.phases), old=shadow.phases;
    shadow.phases=sig; shadow.phasesV=op.phases;
    const cur=phasesSigOf(phasesOut());
    const pending=mine?sentDiff('phases',cur):(flying('phases')||cur!==old);
    if(pending||cur===sig)return;
    if(adoptPhases(op.phases)){ T.phases=true; T.structure=true; }
    return;
  }
  if(op.op==='reg'){
    if(!Array.isArray(op.tags))return;
    const sig=regSigOf(op.tags), old=shadow.reg;
    shadow.reg=sig; shadow.regV=op.tags;
    const cur=regInSidecar?regSigOf(regOut()):old;
    const pending=mine?sentDiff('reg',cur):(flying('reg')||cur!==old);
    if(pending)return;
    regInSidecar=true;
    if(cur===sig)return;
    tagReg=(normalizeTagRegistry(op.tags)||[]).map(regEntry); T.reg=true;
    return;
  }
  if(op.op==='dates'){
    const d={start:cleanDay(op.dates&&op.dates.start),end:cleanDay(op.dates&&op.dates.end)}, sig=datesSigOf(d), old=shadow.dates;
    shadow.dates=sig; shadow.datesV=d;
    const cur=datesInSidecar?datesSigOf(planDates):old;
    const pending=mine?sentDiff('dates',cur):(flying('dates')||cur!==old);
    if(pending)return;
    datesInSidecar=true;
    if(cur===sig)return;
    planDates=d; T.dates=true;
    return;
  }
  if(op.op==='cal'){
    const c=cleanCal(op.calendar); if(!c)return;
    const sig=calSigOf(c), old=shadow.cal;
    shadow.cal=sig; shadow.calV=c;
    const cur=calInSidecar?calSigOf(planCal):old;
    const pending=mine?sentDiff('cal',cur):(flying('cal')||cur!==old);
    if(pending)return;
    calInSidecar=true;
    if(cur===sig)return;
    planCal=c; PLAN_OFF_CACHE.clear(); T.cal=true;
  }
}
// A field from the server. Where this tab has its own change to the same field (sent and
// not yet answered, or not sent yet), its value stays: it reaches the server after this one
// and wins there. A list merges item by item: this tab's own additions and removals are
// replayed on the server's list.
function applyRowOp(op,mine,who,T){
  const uid=op.uid;
  if(!uid||!op.set||typeof op.set!=='object')return;
  let sh=shadow.rows.get(uid)||shadow.dead.get(uid);
  const r=liveRowOf(uid);
  if(!sh){ if(!r)return; sh=rowOv(r); shadow.rows.set(uid,sh); }
  const cur=r?rowOv(r):null;
  // A row with no tags of its own carries no `tags` key, and is still a row tags can land on.
  if(cur&&!('tags' in cur))cur.tags=[];
  const sent=mine?mine.vals.get(uid):null, sentF=mine?mine.fields.get(uid):null;
  const flying=!mine&&inflight?inflight.fields.get(uid):null;
  // A row's place is one value (0.79.0): where this tab has its own change to any part of it,
  // on its way or not sent yet, the whole place stays as this tab has it; it reaches the server
  // after this one and wins there. A subtask's section is its parent's, never this tab's change.
  const placeDiff=placeDiffOf;
  let placePend=false;
  if(cur&&PLACE_F.some(f=>f in op.set)){
    const oldPlace={parent:sh.parent,pos:sh.pos,phase:sh.phase};
    if(mine)placePend=!!(sent&&sentF&&PLACE_F.some(f=>sentF.has(f)))&&placeDiff(cur,sent);
    else placePend=!!(flying&&PLACE_F.some(f=>flying.has(f)))||placeDiff(cur,oldPlace);
  }
  for(const f of Object.keys(op.set)){
    if(!ROW_F.includes(f))continue;
    const v=op.set[f], old=sh[f];
    sh[f]=cloneV(v);
    if(!r||!(f in cur))continue;
    const mineHere=!!(sent&&sentF&&sentF.has(f));
    let next;
    if(PLACE_F.includes(f))next=placePend?cur[f]:v;
    else if(ROW_SETS[f]){
      // An item this tab has touched (added or removed since the server's copy, in a batch on
      // its way or since) is where this tab has it; every other item is where the server has
      // it. Without the batch on its way, an item added and then taken out again before the
      // server answered would come back from the server's copy.
      const k=setKey(f), C=listOf(cur[f]), Cs=new Set(C.map(k)), touched=new Set();
      const differ=(a,b)=>{ const As=new Set(listOf(a).map(k)), Bs=new Set(listOf(b).map(k)); listOf(a).forEach(x=>{ if(!Bs.has(k(x)))touched.add(k(x)); }); listOf(b).forEach(x=>{ if(!As.has(k(x)))touched.add(k(x)); }); };
      if(mineHere)differ(C,sent[f]);
      else { differ(C,old); const fl=flying&&flying.has(f)&&inflight.vals.get(uid); if(fl){ differ(C,fl[f]); differ(fl[f],old); } }
      next=listOf(v).filter(x=>!touched.has(k(x)));
      C.forEach(x=>{ if(touched.has(k(x))&&!next.some(y=>k(y)===k(x)))next.push(x); });
      // This tab's own items keep the order this tab has them in, as the server will.
      if(C.some(x=>touched.has(k(x)))){ const L=C.map(k), at=x=>L.indexOf(k(x)); const placed=next.filter(x=>at(x)>=0).sort((a,b)=>at(a)-at(b)); let j=0; next=next.map(x=>at(x)>=0?placed[j++]:x); }
      if(!mine){
        const Ns=new Set(next.map(k));
        C.forEach(x=>{ if(!Ns.has(k(x)))remoteSeq.set(uid+'|'+f+'|'+k(x),++syncSeq); });
        next.forEach(x=>{ if(!Cs.has(k(x)))remoteSeq.set(uid+'|'+f+'|'+k(x),++syncSeq); });
      }
    } else {
      const pending=mineHere?!ovEq(f,cur[f],sent[f]):((flying&&flying.has(f))||!ovEq(f,cur[f],old));
      next=pending?cur[f]:v;
      if(!mine&&!pending&&who)noticeIfMine(r,f,v,who);
    }
    if(mineHere&&!ROW_SETS[f])myRecent.set(uid+'|'+f,{v:cloneV(v),t:Date.now()});
    if(ovEq(f,next,cur[f])){
      // The same set in another order: take the server's, so every tab lists it alike.
      if(ROW_SETS[f]&&listOf(next).join('\u0001')!==listOf(cur[f]).join('\u0001')){ setOvField(r,f,cloneV(next)); cur[f]=cloneV(next); T.rows.add(r); T.fields.add(f); }
      continue;
    }
    setOvField(r,f,cloneV(next));
    // A section this page cannot take yet: its copy of the server's value stays what the row
    // shows, so the row is never taken for an edit of this page's own (sending it back would
    // undo the move for everybody), and the page fetches the plan to catch up.
    if(f==='phase'&&r.phase!==next){ sh[f]=old; T.resync='section'; continue; }
    cur[f]=cloneV(next);
    if(!mine){ remoteSeq.set(uid+'|'+f,++syncSeq); remoteWho.set(uid+'|'+f,who); remoteWho.set(uid+'|row',who); remoteRowSeq.set(uid,syncSeq);
      if(PLACE_F.includes(f)){ remoteSeq.set(uid+'|place',syncSeq); remoteWho.set(uid+'|place',who); } }
    T.rows.add(r); T.fields.add(f);
    // The rows are laid out again from their places once the batch is in. In a batch that only
    // changes rows' keys (keyOnlyOp) that is a reorder inside sections and nothing else.
    if(PLACE_F.includes(f)){ T.place=true; if(orderOnlyMerge)T.order=true; else T.structure=true; rowBatchDirty=true; }
  }
}
// The same field, changed by somebody else a moment after this person set it: they are
// told, with the value they had, so nothing is lost without a word.
function noticeIfMine(r,f,v,who){
  const k=r.uid+'|'+f, m=myRecent.get(k);
  if(!m||Date.now()-m.t>RECENT_MS||ovEq(f,v,m.v))return;
  myRecent.delete(k);
  const show=x=>x===null||x===undefined||x===''?'nothing':(f==='s'||f==='e'||f==='date')?fmt(x):f==='phase'?phaseNameAt(x):f==='prov'?(typeof x==='object'?(x.label||x.url||''):String(x)):String(x);
  syncNotices.push({uid:r.uid,f,who,to:cloneV(v),was:cloneV(m.v),t:Date.now()});
  if(syncNotices.length>50)syncNotices.shift();
  flash(saveInd,`${who} changed the ${FIELD_WORD[f]||f} of "${r.name}" to ${show(v)} just now (you had ${show(m.v)})`,9000);
}
function applyAddOp(op,mine,who,T){
  const row=op.row&&typeof op.row==='object'?op.row:null;
  const uid=op.uid||(row&&row.uid);
  if(!uid)return;
  const local=liveRowOf(uid);
  const obj=local||allRows.get(uid)||(remoteRemoved.get(uid)||{}).r||null;
  if(!row&&!obj)return;
  shadow.dead.delete(uid);
  shadow.rows.set(uid,rowOv(row?Object.assign({},obj||{},row):obj));
  if(mine&&mine.adds.has(uid)){
    // This tab's own new row, confirmed. Its task id is the server's: two people making a
    // row of the same name in the same second do not get the same id.
    if(local&&row&&row.id&&local.id&&row.id!==local.id){ renameRowId(local,row.id); T.rows.add(local); T.structure=true; }
    // And its place is the server's (0.79.0): two people adding a row at the end of one
    // section in the same second give both the same key, and the server parts them. Unless
    // this tab has moved it since, which goes out next and wins.
    if(local&&row){ const sent=mine.vals.get(uid), now=rowOv(local);
      if(sent&&!placeDiffOf(now,sent)){ const want={parent:row.parent||'',pos:row.pos||'',phase:Number.isInteger(row.phase)?row.phase:now.phase}; let ch=false;
        PLACE_F.forEach(f=>{ if(!ovEq(f,want[f],now[f])){ setOvField(local,f,cloneV(want[f])); ch=true; } });
        if(ch){ T.rows.add(local); T.place=true; T.structure=true; rowBatchDirty=true; } } }
    return;
  }
  if(local)return;
  // This tab's own deletion of it is on its way, and lands after this.
  if(inflight&&inflight.dels.has(uid))return;
  T.structure=true; T.fresh.add(uid);
  if(remoteRemoved.has(uid)){ const info=remoteRemoved.get(uid); restoreRow(info.r,Math.min(info.idx,items.length),[]); }
  else if(obj){ restoreRow(obj,items.length,[]); }
  else { const n=Object.assign({},row); addedItems.push(n); insertRow(n,true); allRows.set(uid,n); }
  const r=liveRowOf(uid);
  if(!r)return;
  if(row){ const want=rowOv(row), have=rowOv(r); if(!('tags' in have))have.tags=[];
    ROW_F.forEach(f=>{ if(f in want&&f in have&&!ovEq(f,want[f],have[f]))setOvField(r,f,cloneV(want[f])); }); }
  T.rows.add(r);
}
function applyDelOp(op,mine,who,T){
  const uid=op.uid;
  if(!uid)return;
  const sh=shadow.rows.get(uid);
  if(sh){ shadow.rows.delete(uid); shadow.dead.set(uid,sh); }
  if(mine&&mine.dels.has(uid))return;
  const r=liveRowOf(uid);
  if(!r)return;
  // A row this tab has changed and not saved outlives somebody else's deletion of it: its
  // next save puts it back with the change, and the person is told.
  const changed=(inflight&&inflight.fields.has(uid))||(sh&&rowDiff(rowOv(r),sh));
  if(changed){
    if(who){ syncNotices.push({uid,f:'deleted',who,t:Date.now()});
      flash(saveInd,`${who} deleted "${r.name}" while you were changing it; your change keeps it`,9000); }
    return;
  }
  selected.delete(r.id);
  removeRow(r,true);
  T.structure=true;
}
// What a batch sent and the server did not take, where this tab has not changed it since,
// goes back to the server's value; otherwise it would be sent again with every save.
function settleSent(b,T){
  b.fields.forEach((fs,uid)=>{
    const r=liveRowOf(uid), sh=shadow.rows.get(uid), sent=b.vals.get(uid);
    if(b.adds.has(uid)&&!sh&&!shadow.dead.has(uid)&&r){
      removeRow(r,true); T.structure=true;
      flash(saveInd,`the server did not take the new row "${r.name}"`,6000);
      return;
    }
    if(!r||!sh||!sent)return;
    const cur=rowOv(r);
    if(!('tags' in cur))cur.tags=[];
    fs.forEach(f=>{
      if(!(f in cur)||!(f in sh&&sh[f]!==undefined||f==='tags'))return;
      if(ovEq(f,cur[f],sent[f])&&!ovEq(f,cur[f],sh[f])){ setOvField(r,f,cloneV(sh[f])); T.rows.add(r); T.fields.add(f);
        if(PLACE_F.includes(f)){ T.place=true; T.structure=true; rowBatchDirty=true; } }
    });
  });
  const c=curStruct();
  if(b.sent.phases&&c.phases===b.struct.phases&&c.phases!==shadow.phases&&shadow.phasesV){ if(adoptPhases(shadow.phasesV)){ T.phases=true; T.structure=true; } }
  if(b.sent.reg&&c.reg===b.struct.reg&&c.reg!==shadow.reg){ tagReg=(normalizeTagRegistry(shadow.regV||[])||[]).map(regEntry); T.reg=true; }
  if(b.sent.dates&&c.dates===b.struct.dates&&c.dates!==shadow.dates){ planDates=shadow.datesV?Object.assign({},shadow.datesV):{start:cleanDay(project&&project.start),end:cleanDay(project&&project.end)}; T.dates=true; }
  if(b.sent.cal&&c.cal===b.struct.cal&&c.cal!==shadow.cal){ planCal=cleanCal(shadow.calV)||cleanCal(project&&project.calendar)||{workHolidays:[],extraDaysOff:[]}; PLAN_OFF_CACHE.clear(); T.cal=true; }
}
// The rows a batch touched, repainted once; the lanes, the legend and the axis only when
// the batch changed the plan's shape or its span.
function repaintAfter(T,range0){
  const rows=[...T.rows].filter(isLive);
  if(T.structure||T.phases){
    items.forEach(r=>{ if(r.type==='milestone')refreshMsChrome(r); else if(r.id)refreshLabelRow(r); });
    Object.keys(barEls).forEach(paintBar);
    recolorByPhase();
    if(T.phases)refreshSections();
    rebuildLegend(); applyCollapse(); applyFilter();
  } else rows.forEach(r=>{
    if(r.type==='milestone'){ refreshMsChrome(r); paintMilestone(r); if(r._msLine)r._msLine.style.color=phaseColor(r.phase); }
    else if(r.id){ paintBar(r.id); refreshLabelRow(r); if(T.fields.has('phase')&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(r.phase); }
  });
  if(tagsReady&&(T.reg||T.fields.has('tags')||T.structure||T.phases)&&(T.reg||tagSigNow()!==tagSig))afterTagsChange();
  if(T.dates||T.cal){ renderDatesCard(); renderCalCard(); }
  const geo=T.structure||T.phases||T.dates||T.cal||['s','e','date','deps','name','phase'].some(f=>T.fields.has(f));
  if(prefs.view==='gantt'){
    // range0 is null when the batch could not have moved the plan's span (0.78.1).
    if(T.structure||T.phases||T.dates||T.cal||(range0!==null&&JSON.stringify(scanRange())!==range0))rebuildTimeline();
    else if(geo)refreshChartGeometry();
  } else if(geo)timelineStale=true;
  // Not while a cell is being typed into: the repaint would close that editor under the
  // person's hands. The model is current either way, and finishing the cell repaints.
  if(!tblEditing&&prefs.view==='table'&&(rows.length||T.structure||T.phases||T.order)){
    if(T.structure||T.phases||!patchTableRows(rows,T.fields))renderTable();
  }
  if(prefs.view==='analytics')renderAnalytics(); else if(prefs.view==='setup'&&(T.structure||T.phases||T.order||T.reg||T.dates||T.cal||rows.length))renderSections(true);
  if(rows.length||T.structure||T.dates||T.cal){ syncPlanSpan(); refreshViewChips(); }
  syncCollapseAllBtn(); syncPlanCtl();
  if(critOn()&&(geo||T.fields.has('status')))refreshCritical();
}
// The stream of announcements from the server, and the presence it carries.
let live=null, livePeople=[], liveTabs=0, liveYou=null, liveMerges=0, liveLastActor='';
// Once a minute, and whenever the tab comes back into view, the tab tells the server it is
// still open (0.71.0). The server forgets a stream it has not heard from in a couple of
// minutes, so the count in the legend is tabs that are open, not sockets a proxy kept
// after the tab went away. If the server has forgotten THIS tab (the laptop slept), the
// stream is opened again.
const LIVE_PING_MS=60000;
let livePing=null;
function pingLive(){
  if(SNAPSHOT||!live)return;
  const wasOpen=live.readyState===1;
  fetch('live/ping?cid='+encodeURIComponent(CLIENT_ID),{method:'POST'}).then(r=>r.ok?r.json():null).then(d=>{
    if(d&&d.known===false&&wasOpen&&live){ stopLive(); startLive(); }
  }).catch(()=>{});
}
function startLive(){
  if(SNAPSHOT||live||!window.EventSource)return;
  const es=new EventSource('live?cid='+encodeURIComponent(CLIENT_ID));
  live=es;
  if(!livePing){
    livePing=setInterval(pingLive,LIVE_PING_MS);
    document.addEventListener('visibilitychange',()=>{ if(!document.hidden)pingLive(); });
  }
  es.onmessage=ev=>{
    let m; try{ m=JSON.parse(ev.data); }catch(e){ return; }
    if(m.type==='hello'){ liveYou=m.you||null; if(typeof m.stateRev==='number'&&m.stateRev>(baseRev||0))pullAndMerge().catch(()=>{}); return; }
    if(m.type==='presence'){ livePeople=m.people||[]; liveTabs=m.tabs||0; liveFocus=Array.isArray(m.focus)?m.focus:[]; renderPresence(); schedulePeerPaint(); return; }
    if(m.type==='rev'){ onRev(m); return; }
    // The owner turned requests on or off (0.80.0).
    if(m.type==='accounts'){ if(Array.isArray(m.accounts)){ REQ.accounts=m.accounts; if(prefs.view==='setup')renderPeopleCard(); } return; }
    if(m.type==='requests'){ REQ.on=!!m.on; rqRefresh([]); flash(saveInd,`${m.actor||'The owner'} turned requests ${m.on?'on: an owner change onto somebody else now asks them first':'off: an owner change applies at once'}`,6000); return; }
    // The owner changed the sharing and this person is no longer on it (0.68.0). The
    // stream is closed by the server; the bar offers the export before anything is lost.
    if(m.type==='denied'){ stopLive(); onSaveBlocked('The owner changed who this plan is shared with, and it is not shared with you any more.'); return; }
    if(m.type==='reload'){
      flash(saveInd,(m.actor||'Someone')+' imported a plan, reloading',3000);
      setTimeout(()=>location.reload(),1200);
    }
  };
  es.onerror=()=>{ /* the browser reconnects on its own; presence is re-announced then */ };
}
function stopLive(){ if(live){ live.close(); live=null; } }
// Who has the plan open, at the right end of the legend. Nothing when this tab is alone:
// a chip saying "you" on every plan would be noise. One chip per person (initials, the
// name on hover, a count when they have several tabs), and the number of people.
// ── Who is on which row (0.74.0) ──
// Each tab tells the server which rows it has selected, open in the editor or focused in the
// table (POST /live/focus, a quarter second after it changes). Every other tab marks those
// rows in that person's colour with their initials (their picture when Adom has one), on the
// chart and in the table, so people can see what the others are in before they edit it.
// Your own tabs are left out.
let liveFocus=[], sentFocus='', focusTimer=null;
const PEER_COLORS=['#00b8b1','#8c6bf7','#e8b64a','#ef6f6c','#4ea8de','#7bc96f','#f28fbe','#c9a26b'];
function peerColor(key){ let h=0; for(const ch of String(key||''))h=(h*31+ch.charCodeAt(0))>>>0; return PEER_COLORS[h%PEER_COLORS.length]; }
function myFocusUids(){
  const s=new Set([...selected].filter(Boolean));
  if(edSubject&&edSubject.uid)s.add(edSubject.uid);
  if(typeof focusedKey!=='undefined'&&focusedKey){ const r=rowItem(focusedKey); if(r&&r.uid)s.add(r.uid); }
  return [...s].slice(0,25);
}
function scheduleFocus(){
  if(SNAPSHOT||!live)return;
  clearTimeout(focusTimer);
  focusTimer=setTimeout(()=>{
    const u=myFocusUids(), sig=JSON.stringify(u);
    if(sig===sentFocus)return; sentFocus=sig;
    fetch('live/focus?cid='+encodeURIComponent(CLIENT_ID),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({uids:u})}).catch(()=>{});
  },250);
}
function peerChipHtml(p){
  const inner=p.avatar?`<img src="${esc(p.avatar)}" alt="" onerror="this.remove()">`:'';
  return `<span class="peer-chip" style="--peer:${p.color}" title="${esc(p.name)} is on this row">${inner||esc(p.initials||'?')}</span>`;
}
// Only what changed is touched (0.75.1). Each focus message used to strip every mark and chip
// from the page and put them all back, which restyled every marked row (and the table) for one
// person moving to another row: a watching tab spent a quarter of a core on it while five
// people clicked around. Each marked element now carries what it shows (data-peer-sig), so
// an unchanged mark is left alone, a replaced element (a re-rendered table row) is marked
// afresh, and only rows whose mark changed are restyled.
const peerMarked=new Set();
// Others' marks repaint at most four times a second, the last state always painted (0.75.1):
// a room of people clicking around sends a dozen announcements a second, and a mark a
// quarter second late tells nobody anything different.
const PEER_PAINT_MS=250;
let peerPaintAt=0, peerPaintTimer=null;
function schedulePeerPaint(){
  if(peerPaintTimer)return;
  const wait=peerPaintAt+PEER_PAINT_MS-Date.now();
  if(wait<=0){ peerPaintAt=Date.now(); paintPeerFocus(); return; }
  peerPaintTimer=setTimeout(()=>{ peerPaintTimer=null; peerPaintAt=Date.now(); paintPeerFocus(); },wait);
}
function paintPeerFocus(){
  const byUid=new Map();
  liveFocus.forEach(f=>{
    if(f.cid===CLIENT_ID)return;
    if(f.id&&liveYou&&liveYou.id&&f.id===liveYou.id)return;
    const p={name:f.name,initials:f.initials,avatar:f.avatar,color:peerColor(f.id||f.name)};
    (f.uids||[]).forEach(u=>{ const a=byUid.get(u)||[]; if(!a.some(x=>x.name===p.name))a.push(p); byUid.set(u,a); });
  });
  const want=new Map();
  if(byUid.size){
    const rows=new Map(items.filter(r=>!isGroup(r)&&r.uid).map(r=>[r.uid,r]));
    // The table only while it is on screen: switching to it renders it, and that paints.
    const trs=tableBody&&prefs.view==='table'?new Map([...tableBody.querySelectorAll('tr[data-key]')].map(t=>[t.dataset.key,t])):null;
    byUid.forEach((people,u)=>{
      const r=rows.get(u); if(!r)return;
      const shown=people.slice(0,3), color=people[0].color;
      const sig=color+'|'+shown.map(p=>p.name+'/'+(p.avatar||p.initials||'')).join(';');
      const chips='<span class="peer-chips">'+shown.map(peerChipHtml).join('')+'</span>';
      if(r._lDiv)want.set(r._lDiv,{sig,color,chips,into:r._lDiv});
      if(r._bDiv)want.set(r._bDiv,{sig,color});
      const tr=trs&&trs.get(rowKey(r));
      if(tr)want.set(tr,{sig,color,chips,into:tr.querySelector('td.c-uid')});
    });
  }
  for(const el of [...peerMarked]){ if(!want.has(el)){ unmarkPeer(el); peerMarked.delete(el); } }
  want.forEach((w,el)=>{
    if(el.dataset.peerSig===w.sig)return;
    unmarkPeer(el);
    el.classList.add('peer-focus'); el.style.setProperty('--peer',w.color);
    if(w.chips&&w.into)w.into.insertAdjacentHTML('beforeend',w.chips);
    el.dataset.peerSig=w.sig;
    peerMarked.add(el);
  });
}
function unmarkPeer(el){
  if(!el.dataset.peerSig&&!el.classList.contains('peer-focus'))return;
  el.classList.remove('peer-focus'); el.style.removeProperty('--peer');
  el.querySelectorAll('.peer-chips').forEach(c=>c.remove());
  delete el.dataset.peerSig;
}
// Redrawn only when who is here changes: a focus message carries the same people (0.75.1).
let presenceSig='';
function renderPresence(){
  const leg=$('legend'); if(!leg)return;
  const sig=JSON.stringify([liveTabs,livePeople.map(p=>[p.name,p.initials,p.n]),liveYou&&liveYou.name]);
  if(sig===presenceSig&&($('presence')||liveTabs<2||!livePeople.length))return;
  presenceSig=sig;
  const old=$('presence'); if(old)old.remove();
  if(liveTabs<2||!livePeople.length)return;
  const el=document.createElement('span'); el.className='presence'; el.id='presence';
  const names=livePeople.map(p=>p.name+(p.n>1?' ('+p.n+' tabs)':'')).join(', ');
  el.title='Open right now, in '+liveTabs+' tabs: '+names+'. What is saved in any of them shows up here live.';
  el.innerHTML=livePeople.map(p=>`<span class="presence-chip"${liveYou&&p.name===liveYou.name?' data-you="1"':''} title="${esc(p.name)}${p.n>1?' ('+p.n+' tabs)':''}">${esc(p.initials||'?')}</span>`).join('')
    +`<span class="presence-n">${liveTabs===1?'1 tab':liveTabs+' tabs'}</span>`;
  leg.appendChild(el);
}
// For the suite: pause and resume this tab's stream, and read what it knows.
window.__req={list:()=>[...REQ.list.values()].map(q=>Object.assign({},q)),counts:()=>rqCounts(),on:()=>REQ.on,out:()=>reqOut.length};
window.__collab={pause:stopLive,resume:startLive,people:()=>livePeople.slice(),merges:()=>liveMerges,pulls:()=>pullCount,dirty:()=>computeOps().map(o=>o.uid||(o.row&&o.row.uid)||o.op),rev:()=>baseRev,pending:pendingCount,notices:()=>syncNotices.slice(),kept:()=>undoKept.map(k=>({uid:k.r&&k.r.uid,f:k.f,who:k.who})),inflight:()=>!!inflight,
  // For the collaboration suite (0.75.1): the plan as this tab holds it, row by row, to compare with the server's.
  rows:()=>items.filter(r=>r.uid&&!isDerivedGroup(r)).map(r=>({uid:r.uid,name:r.name||'',owner:r.owner||'',status:r.status||'',start:r.start||'',end:r.end||'',date:r.date||'',phase:r.phase,deps:(r.deps||[]).slice().sort(),desc:r.description||'',
    note:r.note||'',tags:ownTags(r).map(t=>t.toLowerCase()).sort(),effort:effortOf(r),id:r.id||'',parent:treeParent(r),pos:r.pos||''})),
  // The suite's hands (0.77.0): the same edits a person makes, one undo entry each, without
  // the clicks, so a run can make hundreds of them across several tabs.
  order:()=>liveOrder(),
  set:(uid,f,v)=>{ const r=liveRowOf(uid); if(!r||!ROW_F.includes(f))return false;
    if(r.type==='milestone')snapshotMs(r); else snapshot(r.id);
    setOvField(r,f,cloneV(v));
    if(r.type==='milestone'){ refreshMsChrome(r); updateMilestone(r); } else { updateBar(r.id); refreshLabelRow(r); }
    if(f==='tags')afterTagsChange();
    scheduleSave('edit'); return true; },
  // A move is a change of the row's place (0.79.0): straight after `after`, as its sibling, or
  // first among its own siblings when `after` is null.
  move:(uid,after)=>{ const r=liveRowOf(uid), a=after?liveRowOf(after):null;
    if(!r||(after&&!a)||(a&&a.phase!==r.phase)||a===r)return false;
    const par=a?(treeParent(a)?treeRows.get(treeParent(a)):null):(treeParent(r)?treeRows.get(treeParent(r)):null);
    if(par&&!canNestUnder([r],par))return false;
    const snap=placeSnap([r]); placeRowsAt([r],par,r.phase,a); pushUndo(snap); layoutRows(); renderTable(); scheduleSave('drag'); return true; },
  // Subtasks (0.79.0): put a row under a task (as its last subtask), or take it out (it lands
  // straight after its parent), the way the keyboard and a drag do.
  nest:(uid,puid)=>{ const r=liveRowOf(uid), p=liveRowOf(puid); if(!r||!p||!canNestUnder([r],p))return false;
    const snap=placeSnap([r]); placeRowsAt([r],p,p.phase,lastKid(p,new Set([r]))); pushUndo(snap); layoutRows(); renderTable(); scheduleSave('nest'); return true; },
  unnest:uid=>{ const r=liveRowOf(uid); if(!r||!treeParent(r))return false; return indentRow(r,-1); },
  indent:(uid,dir)=>{ const r=liveRowOf(uid); return r?indentRow(r,dir):false; },
  tree:()=>items.filter(r=>r.uid&&!isDerivedGroup(r)).map(r=>({uid:r.uid,parent:treeParent(r),pos:r.pos||'',depth:treeDepth(r),phase:r.phase,hidden:!!r._collapsed,ctx:!!r._ctx})),
  fold:(uid,on)=>{ const r=liveRowOf(uid); if(!r||!treeHasKids(r))return false; if(collapsedRows.has(rowTreeKey(r))!==!!on)toggleRowFold(r); return true; },
  del:uid=>{ const r=liveRowOf(uid); if(!r)return false; deleteRow(r); return true; },
  undo:()=>undo(), redo:()=>redo(),
  dates:()=>Object.assign({},planDates), cal:()=>JSON.parse(JSON.stringify(planCal)),
  addBiz:(d,n)=>addBiz(d,n), workdays:(a,b)=>bizBetween(a,b), offDay:d=>isOffDay(d), axis:()=>({start:isoDay(START),col:COL}), busy:()=>!!pullQueued||syncBusy||!!inflight||saveInFlight||!!saveTimer,
  // A page that missed a change, for the suite (0.77.1): these rows put in another section on
  // the page AND in its copy of the server's values, which is what a lost announcement leaves
  // behind. Nothing it sends or hears afterwards would show it; only a catch-up puts it right.
  diverge:(uids,ph)=>{ let n=0; uids.forEach(u=>{ const r=liveRowOf(u), sh=shadow.rows.get(u); if(!r||!sh)return; r.phase=ph; sh.phase=ph; n++; }); placeHeaders(); reflowRows(); drawDeps(); return n; },
  resyncs:()=>resyncCount};
// One save landed. Anything the banner was waiting for is now settled, and the queued
// changelog lines go out BEHIND the state they describe, never in front of it.
function onSaveOk(msg){
  unsavedCount=0;
  if(saveBlocked)clearConflict('saved, the plan is back in sync');
  else flash(saveInd,msg);
  flushLogQueue();
}
// This tab's writes are being refused. Usually because someone else wrote to the plan
// while we were editing it; occasionally because the server said no for another reason.
// We do not merge and we do not overwrite anyone silently. What changed in 0.54.0 is not
// the policy, it is the VISIBILITY of it: the message is now a banner of its own that
// stays up until the situation is resolved, and the save indicator goes into an error
// state and stays there. A user must never again be able to keep working for seven
// minutes in a tab that is quietly saving nothing.
//
// `why` is either the string 'conflict' or a sentence about what else went wrong.
let blockedWhy='conflict';
function onSaveBlocked(why){
  saveBlocked=true;
  blockedWhy=why;
  clearTimeout(undoTimer);
  showConflictBar();
}
// The count is quoted back before a reload discards the work, so it is worth being exact.
function unsavedNote(){
  if(!unsavedCount)return 'Nothing is waiting to be saved right now.';
  return unsavedCount===1?'1 change in this tab has not been saved.'
                         :unsavedCount+' changes in this tab have not been saved.';
}
function refreshConflictCount(){
  const n=$('cbCount');
  if(n)n.textContent=unsavedNote();
}
function showConflictBar(){
  document.documentElement.setAttribute('data-save','blocked');
  edReclamp();      // the bottom band an open panel keeps clear of grows with the banner
  showSaveErr();
  // Already up. Do NOT rebuild it: a rebuild every 800ms would disarm the confirm button
  // under the user's second click and would throw away the reload warning they are
  // reading. Only the count moves.
  if(conflictBar.classList.contains('show')){ refreshConflictCount(); return; }
  renderConflictMain();
  conflictBar.classList.add('show');
}
// Three ways out, and the safe one is always available. Export my version writes the
// IN-MEMORY plan to a file, so nothing is ever trapped behind the banner; that is the
// 0.28.0 client-state export and it is unchanged. Save mine anyway force-writes over the
// other change, behind a confirm click. Reload latest takes the server's version, and
// says what that costs before it does it.
function renderConflictMain(){
  const lead=blockedWhy==='conflict'?'This plan changed somewhere else.':esc(blockedWhy);
  conflictBar.innerHTML='<span class="cb-msg"><span class="cb-lead">Your changes are not saving.</span> '+
    lead+' <span id="cbCount">'+esc(unsavedNote())+'</span></span>'+
    '<button type="button" id="cbExport" class="de-btn secondary">Export my version</button>'+
    '<button type="button" id="cbForce" class="de-btn secondary">Try again</button>'+
    '<button type="button" id="cbReload" class="de-btn primary">Reload latest</button>';
  $('cbExport').addEventListener('click',exportMine);
  // Warn first, always, when there is something to lose. A tab with nothing unsaved is
  // stale rather than dirty and reloading it costs nothing, so it just reloads.
  $('cbReload').addEventListener('click',()=>{
    if(unsavedCount>0)renderConflictReloadWarning(); else location.reload();
  });
  // Since 0.77.0 a save carries only this tab's own changes, so trying again cannot
  // overwrite anyone and needs no second click.
  $('cbForce').addEventListener('click',forceSaveMine);
}
// What a reload actually costs, said before it happens, with the export offered in the
// same row so keeping the work is never more steps than losing it.
function renderConflictReloadWarning(){
  conflictBar.innerHTML='<span class="cb-msg"><span class="cb-lead">Reloading discards your work.</span> '+
    'Reload latest replaces what is on your screen with the version on the server. '+
    esc(unsavedNote())+' Export first if you want to keep it.</span>'+
    '<button type="button" id="cbExport" class="de-btn secondary">Export my version</button>'+
    '<button type="button" id="cbBack" class="de-btn secondary">Keep editing</button>'+
    '<button type="button" id="cbGo" class="de-btn primary">Discard and reload</button>';
  $('cbExport').addEventListener('click',exportMine);
  $('cbBack').addEventListener('click',renderConflictMain);
  $('cbGo').addEventListener('click',()=>{
    // The queued changelog lines describe the very edits about to be thrown away, so they
    // go with them. Posting them on the way out would leave the log claiming edits that
    // no sidecar ever held, which is the split this release exists to close.
    logQueue.length=0; unsavedCount=0;
    location.reload();
  });
}
function exportMine(){ doExport('json'); flash(saveInd,'exported your version',4000); }
// Any of the three ways out, plus the fourth: a save that simply started working again.
function clearConflict(msg){
  saveBlocked=false;
  blockedWhy='conflict';
  conflictBar.classList.remove('show');
  conflictBar.innerHTML='';
  document.documentElement.removeAttribute('data-save');
  edReclamp();
  unpinSaveErr();
  if(msg)flash(saveInd,msg,4000);
}
// Not idle, not saved: NOT SAVING, and it stays that way. See the .save-indicator.err
// rule for why this one message is a state rather than a toast.
function showSaveErr(){
  clearTimeout(saveInd.__t);
  saveInd.__transient=false;
  saveInd.textContent='not saving, this plan changed elsewhere';
  saveInd.classList.add('show','err');
}
function unpinSaveErr(){
  saveInd.__transient=true;
  saveInd.classList.remove('err');
}
// Try the save again now. Since 0.77.0 there is nothing to overwrite: a save carries only
// this tab's own changes, field by field, so the button just stops the wait.
function forceSaveMine(){
  clearTimeout(saveRetry); saveRetry=null;
  clearTimeout(saveTimer); saveTimer=null;
  saveToFile();
}
// Every toast's timer lives here so hover can pause it. Gone-before-you-noticed was
// half duration and half that a toast you are reading keeps counting down; pointing
// at one now stops the clock until you look away, which is what every toast in every
// other app does.
const TOAST_MS=2800;          // general confirmations, was a 1500ms default
// 0.59.0: pointing AT it, not the toast arriving under a pointer that was already
// there. A verifier saw an "Undid change" toast stay up across several later screens:
// Ctrl+Z was pressed with the pointer resting where the toast then appeared, Chrome sent
// mouseenter for the new box under the still pointer, the clock stopped, and a pointer
// that never moves never leaves. So the pause needs the pointer to have MOVED since the
// toast went up, and a paused toast still goes after TOAST_PAUSE_MAX in any case.
const TOAST_PAUSE_MAX=15000;
let lastPtr=null;
document.addEventListener('mousemove',e=>{ lastPtr=e.clientX+','+e.clientY; },{capture:true,passive:true});
function showToast(el,ms,onDone){
  clearTimeout(el.__t);
  el.__ms=ms||TOAST_MS; el.__onDone=onDone||null; el.__paused=false; el.__transient=true;
  el.__ptrAt=lastPtr;
  if(!el.__hoverWired){
    el.__hoverWired=true;
    el.addEventListener('mousemove',e=>{
      if(el.__paused||!el.classList.contains('show'))return;
      if(el.__ptrAt===e.clientX+','+e.clientY)return;   // the pointer has not moved since the toast appeared
      clearTimeout(el.__t); el.__paused=true;
      if(el.__transient!==false)el.__t=setTimeout(el.__hide,TOAST_PAUSE_MAX);
    });
    // A pinned message is not a toast and must not be given a hide timer by the pointer
    // merely passing over it. Through 0.53.0 the conflict banner lived in this element and
    // was shown by hand rather than through showToast, so __ms still belonged to whatever
    // toast came before it and one hover was enough to schedule its disappearance.
    el.addEventListener('mouseleave',()=>{ if(el.__paused&&el.classList.contains('show')){ el.__paused=false; clearTimeout(el.__t); if(el.__transient!==false)el.__t=setTimeout(el.__hide,el.__ms); } });
    // Whatever flashed over the save indicator, the not-saving state is what it goes back
    // to while saves are still being refused.
    el.__hide=()=>{ el.classList.remove('show'); if(el.__onDone)el.__onDone(); if(el===saveInd&&saveBlocked)showSaveErr(); };
  }
  el.classList.add('show');
  el.__t=setTimeout(el.__hide,el.__ms);
}
function flash(el,text,ms){ el.textContent=text; showToast(el,ms); }

// ── Legend ──
const legendEl = $('legend');
function phaseChip(ph,i,label){
  const el=document.createElement('div');el.className='legend-item phase-chip';
  const tg=ph.tags&&ph.tags.length?'\nTags: '+ph.tags.join(', '):'';
  el.title=(ph.name||'')+(ph.lead?', '+ph.lead:'')+tg;
  el.innerHTML=`<div class="legend-dot" style="background:${esc(phaseColor(i))}"></div><span class="chip-label">${esc(label||ph.name)}</span>`;
  if(!phaseVis[i])el.classList.add('off');
  el.addEventListener('click',()=>{phaseVis[i]=!phaseVis[i];el.classList.toggle('off',!phaseVis[i]);applyFilter();syncSecToggle();});
  return el;
}
function buildLegendPhases(){ rebuildLegend(); }

// ── Tags: the model, the filter, the legend (0.59.0) ──
//
// A tag is compared without regard to case everywhere (the normaliser already keeps one
// spelling per section), and shown in the spelling the plan uses first.
const tagKey=t=>String(t||'').trim().toLowerCase();
function phaseTagsOf(r){ const p=r&&phases[r.phase]; return (p&&p.tags)||[]; }
// A row's OWN tags (0.60.0): items[].tags, the list the row carries itself. Absent and
// [] both read as none here; the difference matters only to the sidecar (see getState).
function ownTags(r){ return (r&&Array.isArray(r.tags))?r.tags:[]; }
// A row's EFFECTIVE tags: its own, then its section's, one spelling per key (the row's own
// spelling first). This is the list the legend filter and the Analytics per-tag card read,
// so a task tagged "long-lead" in an untagged section is found by that tag, and every row
// of a section tagged "Tech" is found by "Tech" without carrying it itself.
function effTags(r){
  const out=[], seen=new Set();
  [...ownTags(r),...phaseTagsOf(r)].forEach(t=>{ const k=tagKey(t); if(!seen.has(k)){ seen.add(k); out.push(t); } });
  return out;
}
// Put a row's own list back exactly: an array (possibly empty, the sidecar's "cleared")
// or no key at all. Undo and redo go through here so absent and [] survive the round trip.
function setRowTags(r,v){ if(Array.isArray(v))r.tags=v.slice(); else delete r.tags; }
// The registry (0.61.0), read by key. regOut is its on-disk shape, without the `_k`.
function regOut(){ return tagReg.map(e=>{ const o={name:e.name}; if(e.color)o.color=e.color; return o; }); }
function regFind(t){ const k=tagKey(t); return tagReg.find(e=>tagKey(e.name)===k)||null; }
function regSnap(){ return tagReg.map(e=>Object.assign({},e)); }
function regSig(){ return JSON.stringify(tagReg.map(e=>[e._k,e.name,e.color||''])); }
function tagColor(t){ const e=regFind(t); return (e&&e.color)||''; }
// Every tag in the plan, in the order it first appears going down the sections, with
// how many sections carry it (`n`, which the legend fold and the prefix logic rely on
// meaning sections), how many rows carry it on their own (`rows`, 0.60.0), and how many
// rows it finds through the filter, own or inherited (`items`). First-appearance order
// and not A to Z, because on a plan whose sections are grouped ("Launch · ...", then
// "Tech · ...") that IS the plan's order. Within a section, its own tags come before the
// tags its rows add.
function planTags(){
  const m=new Map();
  const ent=t=>{ const k=tagKey(t); if(!m.has(k))m.set(k,{tag:t,key:k,n:0,idx:[],rows:0,items:0}); return m.get(k); };
  const rows=items.filter(r=>!isGroup(r));
  phases.forEach((p,i)=>{
    (p.tags||[]).forEach(t=>{ const e=ent(t); e.n++; e.idx.push(i); });
    rows.forEach(r=>{ if(r.phase===i)ownTags(r).forEach(t=>ent(t).rows++); });
  });
  // a row whose section index is out of range still has its own tags counted
  rows.forEach(r=>{ if(!phases[r.phase])ownTags(r).forEach(t=>ent(t).rows++); });
  rows.forEach(r=>effTags(r).forEach(t=>{ const e=m.get(tagKey(t)); if(e)e.items++; }));
  // Then the registry's tags that nothing carries yet (0.61.0), in registry order, so a tag
  // made with "New tag" is listed and suggested before anything wears it. `used` is what the
  // legend, the filter and the Analytics card go by: an unused tag would filter to nothing.
  tagReg.forEach(r=>{ const k=tagKey(r.name); if(!m.has(k))m.set(k,{tag:r.name,key:k,n:0,idx:[],rows:0,items:0}); });
  // ORDER (0.62.0). A tag the registry lists sits where the registry puts it, which is
  // where the reader dragged it on Setup; tags it does not list follow, in the order they
  // first appear. Any edit to the registry first lists every tag in the order shown
  // (regMaterialize), so making or colouring one tag never shuffles the rest.
  const at=new Map(); tagReg.forEach((r,i)=>{ const k=tagKey(r.name); if(!at.has(k))at.set(k,i); });
  const out=[...m.values()].map((e,i)=>({e,i})).sort((a,b)=>{
    const x=at.has(a.e.key)?at.get(a.e.key):Infinity, y=at.has(b.e.key)?at.get(b.e.key):Infinity;
    return x!==y?x-y:a.i-b.i;
  }).map(o=>o.e);
  out.forEach(e=>{ e.color=tagColor(e.key); e.used=!!(e.n||e.rows); });
  return out;
}
// Every tag the plan has, written into the registry in the order they are shown now.
function regMaterialize(){ planTags().forEach(e=>{ if(!regFind(e.tag))tagReg.push(regEntry({name:e.tag})); }); }
function usedTags(){ return planTags().filter(e=>e.used); }
// "2 sections, 3 rows", leaving out a zero, for the Setup card and the chip titles.
function tagCountText(e){
  const parts=[]; if(e.n)parts.push(plural(e.n,'section')); if(e.rows)parts.push(plural(e.rows,'row'));
  return parts.join(', ')||'0 sections';
}
// The filter is VIEW state, one person's, kept in prefs beside the collapse set: which
// tags they are looking at is not a fact about the plan. Stored in the plan's spelling,
// compared by key. A tag the plan no longer has drops out (pruneTagFilter), so a filter
// can never be left on that hides everything for a reason nobody can see.
function clampTagFilterPref(){
if(!Array.isArray(prefs.tagFilter))prefs.tagFilter=[];
prefs.tagFilter=prefs.tagFilter.filter(t=>typeof t==='string'&&t.trim());
prefs.legendSecs=prefs.legendSecs===true;
// Owner and Overdue (0.64.0) are view state like the tags. The owner used to be the
// table's own (prefs.tableOwner); it is one choice for both lenses now, so a person
// picked on the chart is the person the table shows, and an old table pick carries over.
if(typeof prefs.ownerFilter!=='string')prefs.ownerFilter=typeof prefs.tableOwner==='string'?prefs.tableOwner:'';
delete prefs.tableOwner;
prefs.overdueOnly=prefs.overdueOnly===true;
prefs.currentOnly=prefs.currentOnly===true;
}
let tagsReady=false;             // set once the sidecar is in: before that the data file's tags are not the plan's
function tagFilterKeys(){ return new Set(prefs.tagFilter.map(tagKey)); }
function tagFilterOn(){ return prefs.tagFilter.length>0; }
// THE FILTER RULE (0.60.0), in two predicates the chart and the table both use:
//
//   rowShown(r)    a row is on screen when ANY of its effective tags (its own plus its
//                  section's) is one of the chosen tags. So a row in a chosen section is
//                  shown even with no tags of its own, a row tagged "long-lead" in an
//                  untagged section is shown by "long-lead", and a row whose own tags do
//                  not match, in a section that does not match, is hidden.
//   phaseShown(i)  a section's HEADER is on screen when the section itself matches, or
//                  when at least one of its rows does. A header shown only for some of
//                  its rows shows those rows and no others.
//
// With no tag chosen both are true for everything. `ks` may be passed in by a caller
// that asks for many rows at once, so the chosen set is built once.
// OWNER AND OVERDUE (0.64.0) narrow the same two predicates, ANDed with the tags:
//
//   ownerPass(r)   with an owner chosen, a row shows only when it is theirs; the
//                  Unassigned choice shows the rows nobody owns, which on a young plan
//                  is most of it, so it has to be reachable rather than hidden.
//   isOverdue(r)   its deadline (a task's end, a milestone's date) is before today and
//                  its status does not settle it: complete means it happened, cancelled
//                  means it was called off. A blank status is still owed. The same rule
//                  as the Analytics card, through the same statusOf, so "completed",
//                  "done" and "canceled" all count as settled.
//
// Overdue is DERIVED, never stored: it is re-read from the dates and statuses on every
// render, so it cannot go stale, cannot be edited away by hand, and computing it never
// writes to the plan (a write would make every other open tab stop saving).
const OWNER_NONE='__unassigned__';   // the same sentinel the table's owner select uses
function ownerFilterOn(){ return !!prefs.ownerFilter; }
function ownerPass(r){
  if(!prefs.ownerFilter)return true;
  const o=(r.owner||'').trim();
  return prefs.ownerFilter===OWNER_NONE?!o:o===prefs.ownerFilter;
}
function isOverdue(r){
  if(!r||r.type==='group')return false;
  const dl=r.type==='milestone'?(r.date||''):(r.end||'');
  if(!isDateStr(dl))return false;
  const st=statusOf(r);
  // A month-only deadline ("2026-10") is due at the END of its month (0.70.0: before, it
  // read as no date at all and such a row could never be overdue).
  return st!=='complete'&&st!=='cancelled'&&dayNum(spanDay(dl,true))<dayNum(anToday());
}
function overdueRows(){ return items.filter(isOverdue); }
// CURRENT (0.70.0): the working set for a review. A row is current when it has STARTED
// (a task's start, a milestone's date: today or earlier) and its status does not settle
// it, the same settled set as Overdue (complete, cancelled). So it is the work under way
// plus the work that should be: a task still "not started" a week after its start date
// is exactly what a review wants on screen. Future rows and finished ones drop out. Every
// overdue row is current, so the two chips together show the overdue ones. Derived like
// Overdue, never stored; a month-only start counts from the first of its month.
function isCurrent(r){
  if(!r||r.type==='group')return false;
  const s=r.type==='milestone'?r.date:r.start;
  if(!isDateStr(s))return false;
  const st=statusOf(r);
  return st!=='complete'&&st!=='cancelled'&&dayNum(spanDay(s,false))<=dayNum(anToday());
}
function currentRows(){ return items.filter(isCurrent); }
// Is ANY view filter hiding rows? Callers that used to ask only about tags ("2 of 5" on
// a lane, Collapse all acting on what is on screen) ask this now.
function viewFilterOn(){ return tagFilterOn()||ownerFilterOn()||prefs.overdueOnly||prefs.currentOnly; }
function rowShown(r,ks){
  if(!ownerPass(r))return false;
  if(prefs.overdueOnly&&!isOverdue(r))return false;
  if(prefs.currentOnly&&!isCurrent(r))return false;
  if(!prefs.tagFilter.length)return true;
  ks=ks||tagFilterKeys();
  return effTags(r).some(t=>ks.has(tagKey(t)));
}
function phaseShown(i,ks){
  // Owner and Overdue choose ROWS, never a section: under either, a header shows only
  // over rows it still has on screen.
  if(ownerFilterOn()||prefs.overdueOnly||prefs.currentOnly)return items.some(r=>r.phase===i&&!isGroup(r)&&rowShown(r,ks));
  if(!prefs.tagFilter.length)return true;
  const p=phases[i]; if(!p)return true;
  ks=ks||tagFilterKeys();
  if((p.tags||[]).some(t=>ks.has(tagKey(t))))return true;
  return items.some(r=>r.phase===i&&!isGroup(r)&&ownTags(r).some(t=>ks.has(tagKey(t))));
}
function pruneTagFilter(){
  if(!tagsReady||!prefs.tagFilter.length)return false;
  const have=new Map(usedTags().map(e=>[e.key,e.tag]));
  const next=[]; prefs.tagFilter.forEach(t=>{ const sp=have.get(tagKey(t)); if(sp&&!next.some(x=>tagKey(x)===tagKey(sp)))next.push(sp); });
  if(JSON.stringify(next)===JSON.stringify(prefs.tagFilter))return false;
  prefs.tagFilter=next; savePrefs();
  return true;
}
// Everything a change in what is shown has to move: the rows, the arrows, the caps, the
// table, the legend's own chips. The same tail Collapse all runs.
function applyTagView(){
  const mark=placeMark();
  rebuildLegend();
  applyFilter();
  layoutMsCaps();sizeTailroom();drawDeps();
  keepPlace(mark);
  depHoverResync();
}
function setTagFilter(list){
  prefs.tagFilter=list; savePrefs();
  applyTagView();
}
function toggleTagFilter(tag){
  const k=tagKey(tag), cur=prefs.tagFilter;
  setTagFilter(cur.some(t=>tagKey(t)===k)?cur.filter(t=>tagKey(t)!==k):[...cur,tag]);
}
// The legend with tags in the plan. Twenty section chips are a wall; four tag chips are
// a choice. So when the plan has tags, the tag chips lead, and the section chips stand
// BEHIND them: with no tag chosen they fold away behind one "20 sections" chip (a click
// opens them, and that is remembered), and with tags chosen the chips of exactly the
// sections on screen come back, a handful, straight after the tags that chose them.
// A plan with no tags has no tag chips and no fold: its legend is what it always was.
const secToggle=document.createElement('button'), tagClear=document.createElement('button'), tagToggle=document.createElement('button');
// Tags fold like the sections (0.73.0). A plan with more than TAG_FOLD_AT tags starts folded;
// a chosen tag's chip always stays out, so a filter is never hidden behind the fold.
const TAG_FOLD_AT=8;
function tagsOpen(n){ return typeof prefs.legendTags==='boolean'?prefs.legendTags:n<=TAG_FOLD_AT; }
function syncTagToggle(n,chosen){
  const open=tagsOpen(n);
  tagToggle.classList.toggle('open',open);
  tagToggle.setAttribute('aria-expanded',open?'true':'false');
  tagToggle.querySelector('.st-label').textContent=open?'Fewer tags':plural(n,'tag')+(chosen?' · '+chosen+' on':'');
  tagToggle.title=open?'Fold the tag chips away behind one chip.':'Show a chip for each of the '+plural(n,'tag')+', to filter by them.';
}
// Owner and Overdue lead the legend, before the tags (0.64.0). The owner pick is a select
// because a plan has a dozen people, not four; it is the chart's copy of the table's own
// select, so it hides in the table view, where that one sits in the toolbar.
const ownerPick=document.createElement('div'), ownerPickSel=document.createElement('select'), overdueChip=document.createElement('button'), currentChip=document.createElement('button');
function wireLegendOwner(){
// Both lenses (0.77.1). The Table had a filter of its own at the far end of the line, so the
// chart's one seemed to vanish on the way into the Table; it is the same choice, in one place.
ownerPick.className='legend-item owner-pick';
ownerPick.innerHTML='<span class="op-label">Owner</span>';
ownerPickSel.id='ganttOwner'; ownerPickSel.setAttribute('aria-label','Owner filter');
ownerPickSel.title="Show only one person's rows, or the ones nobody owns yet. Your view only, it does not change the plan.";
ownerPick.appendChild(ownerPickSel);
wireAppSelect(ownerPickSel);
ownerPickSel.addEventListener('apppick',()=>setOwnerFilter(ownerPickSel.value));
overdueChip.type='button'; overdueChip.id='overdueChip'; overdueChip.className='legend-item overdue-chip';
overdueChip.addEventListener('click',()=>{ prefs.overdueOnly=!prefs.overdueOnly; savePrefs(); applyTagView(); });
currentChip.type='button'; currentChip.id='currentChip'; currentChip.className='legend-item current-chip';
currentChip.addEventListener('click',()=>{ prefs.currentOnly=!prefs.currentOnly; savePrefs(); applyTagView(); });
}
function setOwnerFilter(v){
  prefs.ownerFilter=v||''; savePrefs();
  applyTagView();
}
// Options carry counts, so "Unassigned (146)" says how much of the plan nobody owns.
function ownerOptionsHTML(){
  const n=new Map(); let none=0;
  items.forEach(r=>{ if(isGroup(r))return; const o=(r.owner||'').trim(); if(o)n.set(o,(n.get(o)||0)+1); else none++; });
  const names=[...n.keys()].sort((a,b)=>a.localeCompare(b));
  return {names,html:'<option value="">All owners</option>'
    +names.map(o=>`<option value="${esc(o)}">${esc(o)} (${n.get(o)})</option>`).join('')
    +`<option value="${OWNER_NONE}">Unassigned (${none})</option>`};
}
function syncOwnerPick(){
  const {names,html}=ownerOptionsHTML();
  // An owner who no longer has a row drops out of the filter, the way a vanished tag
  // does, so nobody is left looking at an empty chart for a reason they cannot see.
  if(prefs.ownerFilter&&prefs.ownerFilter!==OWNER_NONE&&!names.includes(prefs.ownerFilter)){ prefs.ownerFilter=''; savePrefs(); }
  // Only when the list itself changed: rewriting it under an open list or a keyboard walk
  // would move the highlight for nothing (0.77.1).
  if(ownerPickSel._html!==html){ ownerPickSel.innerHTML=html; ownerPickSel._html=html; }
  if(ownerPickSel.value!==prefs.ownerFilter)ownerPickSel.value=prefs.ownerFilter;
  ownerPick.classList.toggle('on',ownerFilterOn());
}
// Tasks and milestones are counted apart. On a real plan most milestones carry no status
// at all, so as their dates pass they pile up here with no work having slipped; one count
// would let them drown the late tasks. Two counts keep both signals, and say which is which.
function syncOverdueChip(){
  const od=overdueRows(), ms=od.filter(r=>r.type==='milestone').length, tk=od.length-ms, on=prefs.overdueOnly;
  overdueChip.classList.toggle('on',on); overdueChip.classList.toggle('none',!od.length);
  overdueChip.setAttribute('aria-pressed',on?'true':'false');
  overdueChip.dataset.tasks=tk; overdueChip.dataset.ms=ms;
  overdueChip.innerHTML='<span class="od-dot" aria-hidden="true"></span><span class="od-label">Overdue</span><span class="od-n">'+tk+'</span>'
    +(ms?'<span class="od-ms" aria-label="'+plural(ms,'milestone')+'">\u25C6 '+ms+'</span>':'');
  overdueChip.title=(on?'Show every row again. ':'Show only what is overdue: ')
    +plural(tk,'task')+(ms?' and '+plural(ms,'milestone'):'')+' with a deadline before today ('+fmt(anToday())+') that is not complete or cancelled. '
    +(ms?'A milestone with no status counts as not reached: mark it complete once it is. ':'')
    +'Worked out from the dates and statuses each time the page draws, and again at midnight; nothing is written to the plan.';
}
// The same two counts as Overdue, for the same reason.
function syncCurrentChip(){
  const cu=currentRows(), ms=cu.filter(r=>r.type==='milestone').length, tk=cu.length-ms, on=prefs.currentOnly;
  currentChip.classList.toggle('on',on); currentChip.classList.toggle('none',!cu.length);
  currentChip.setAttribute('aria-pressed',on?'true':'false');
  currentChip.dataset.tasks=tk; currentChip.dataset.ms=ms;
  currentChip.innerHTML='<span class="cu-dot" aria-hidden="true"></span><span class="cu-label">Current</span><span class="cu-n">'+tk+'</span>'
    +(ms?'<span class="cu-ms" aria-label="'+plural(ms,'milestone')+'">\u25C6 '+ms+'</span>':'');
  currentChip.title=(on?'Show every row again. ':'Show only what is current: ')
    +plural(tk,'task')+(ms?' and '+plural(ms,'milestone'):'')+' that started on or before today ('+fmt(anToday())+') and are not complete or cancelled. '
    +'Future and finished rows are hidden; with Overdue on too, only the overdue ones. Your view only, it does not change the plan.';
}
// The page can be left open overnight. When the date turns, what is overdue changes with
// nobody touching the plan, so the chip and the marks are re-read then, and whenever the
// tab comes back into view.
let overdueDay='';
function rescanOverdue(){
  const t=anToday(); if(t===overdueDay)return; overdueDay=t;
  applyTagView();
}
function wireOverdueClock(){
overdueDay=anToday();
setInterval(rescanOverdue,5*60*1000);
document.addEventListener('visibilitychange',()=>{ if(!document.hidden)rescanOverdue(); });
}
function wireLegendTags(){
secToggle.type='button'; secToggle.id='legendSecs'; secToggle.className='legend-item sec-toggle';
secToggle.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg><span class="st-label"></span>';
secToggle.addEventListener('click',()=>{ prefs.legendSecs=!prefs.legendSecs; savePrefs(); rebuildLegend(); });
tagToggle.type='button'; tagToggle.id='legendTags'; tagToggle.className='legend-item sec-toggle tag-toggle';
tagToggle.innerHTML=secToggle.innerHTML;
tagToggle.addEventListener('click',()=>{ prefs.legendTags=!tagsOpen(usedTags().length); savePrefs(); rebuildLegend(); });
tagClear.type='button'; tagClear.id='tagClear'; tagClear.className='legend-item tag-clear';
tagClear.textContent='Clear tags';
tagClear.title='Show every section and row again. Only your view changes, not the plan.';
tagClear.addEventListener('click',()=>setTagFilter([]));
}
function tagChip(e,on){
  const el=document.createElement('button'); el.type='button';
  el.className='legend-item tag-chip'+(on?' on':''); el.dataset.tag=e.key;
  el.setAttribute('aria-pressed',on?'true':'false');
  // The number is the rows the chip would show: every row that carries the tag itself or
  // sits in a section that does (0.60.0). Before rows had tags it counted sections, and
  // with row tags that number stopped answering "how much will I see".
  el.innerHTML=`<span class="tc-hash" aria-hidden="true">#</span><span class="tc-label">${esc(e.tag)}</span><span class="tc-n">${e.items}</span>`;
  paintTag(el,e.tag);
  const where=[e.n?plural(e.n,'section')+': '+e.idx.map(i=>phases[i].name).join(', '):'',e.rows?plural(e.rows,'row')+' tagged on their own':''].filter(Boolean).join('; ');
  el.title=(on?'Stop filtering by ':'Show only the rows tagged ')+e.tag+', '+plural(e.items,'row')+' ('+where+'). Choose more than one to see any of them. Your view only, it does not change the plan.';
  el.addEventListener('click',()=>toggleTagFilter(e.tag));
  return el;
}
// Does the fold hide a section chip that is switched off? Then the fold says so, or a
// section would be washed out on the chart with nothing in the legend owning up to it.
function syncSecToggle(){
  if(!secToggle.isConnected)return;
  const n=phases.length, off=phaseVis.filter(v=>!v).length, open=!!prefs.legendSecs;
  secToggle.classList.toggle('open',open);
  secToggle.setAttribute('aria-expanded',open?'true':'false');
  secToggle.querySelector('.st-label').textContent=(open?'Fewer':plural(n,'section'))+(!open&&off?' · '+off+' off':'');
  secToggle.title=open?'Fold the section chips away behind the tags again.'
    :'Show a chip for each of the '+plural(n,'section')+', to switch sections on and off one at a time.';
}
function rebuildLegend(){
  legendEl.querySelectorAll('.phase-chip,.tag-chip').forEach(el=>el.remove());
  [secToggle,tagClear,tagToggle].forEach(el=>{ if(el.isConnected)el.remove(); });
  // The owner filter and the two chips stay put (0.77.1). They were taken out and put back
  // on every rebuild, and a rebuild follows any change to the plan's order, so somebody
  // else's drag closed the owner list in the hand of whoever had it open.
  let at=collapseAllBtn.isConnected?collapseAllBtn.nextSibling:legendEl.firstChild;
  [ownerPick,currentChip,overdueChip].forEach(el=>{ if(el!==at)legendEl.insertBefore(el,at); at=el.nextSibling; });
  syncOwnerPick(); syncCurrentChip(); syncOverdueChip();
  const first=at;
  const put=el=>legendEl.insertBefore(el,first);
  const tags=usedTags(), ks=tagFilterKeys(), filtering=tags.length>0&&tagFilterOn();
  if(tags.length){ syncTagToggle(tags.length,tags.filter(e=>ks.has(e.key)).length); put(tagToggle); }
  const tOpen=tagsOpen(tags.length);
  tags.forEach(e=>{ if(tOpen||ks.has(e.key))put(tagChip(e,ks.has(e.key))); });
  if(filtering)put(tagClear);
  const folded=tags.length>0&&!filtering&&!prefs.legendSecs;
  if(tags.length&&!filtering)put(secToggle);
  // Behind a chosen tag, "Tech · Laser workcells" says "Tech" twice: the tag chip in
  // front of it already does. So a section whose name prefix IS a chosen tag shows the
  // rest of its name; its tooltip keeps the whole of it.
  const short=ph=>{ if(!filtering)return ''; const pre=namePrefix(ph.name);
    return pre&&ks.has(tagKey(pre))?String(ph.name).replace(PREFIX_RE_CUT,''):''; };
  if(!folded)phases.forEach((ph,i)=>{ if(phaseShown(i,ks))put(phaseChip(ph,i,short(ph))); });
  syncSecToggle();
  syncCollapseAllBtn();
  renderPresence();
}
// A key for the second band colour, and nothing more: the holidays are not something
// a reader can switch off, because the schedule underneath them is computed with them
// in it. It is static for that reason, and it appears only at the grain where the
// bands themselves are drawn, which holidayLegendGrain() is told by buildGridLines.
const holL=document.createElement('div');
function buildLegendHoliday(){
holL.className='legend-item static gantt-only';
holL.innerHTML='<div class="legend-dot" style="background:var(--holiday);box-shadow:inset 0 0 0 1px var(--border)"></div>Holiday';
holL.title="The plan's days off (Setup, Working calendar): the US federal holidays it takes and any extra days. They are shaded like the weekend and count as non-working days everywhere in the plan. Hover a shaded date above the chart to see which one it is.";
legendEl.appendChild(holL);
}
function holidayLegendGrain(dayGrain){ holL.style.display=dayGrain?'':'none'; }
const msL=document.createElement('div');
function buildLegendMilestone(){
msL.className='legend-item';msL.innerHTML='<div class="legend-diamond"></div>Milestone';msL.addEventListener('click',()=>{msVis=!msVis;msL.classList.toggle('off',!msVis);applyFilter();});legendEl.appendChild(msL);
}
const depL=document.createElement('div');
function buildLegendDeps(){
depL.className='legend-item gantt-only';depL.innerHTML='<svg width="14" height="10" style="flex-shrink:0"><path d="M0,5C4,5 10,5 14,5" stroke="var(--accent)" stroke-width="1.5" fill="none" opacity="0.6"/><polygon points="14,5 10,3 10,7" fill="var(--accent)" opacity="0.6"/></svg>Dependencies';
depL.addEventListener('click',()=>{depsVis=!depsVis;depL.classList.toggle('off',!depsVis);depSvg.classList.toggle('hidden',!depsVis);});legendEl.appendChild(depL);
}
// Today toggles from the legend like Milestone and Dependencies, rather than adding
// separate chrome: the legend is already where this app hides and shows chart elements.
const todayL=document.createElement('div');
function buildLegendToday(){
todayL.className='legend-item gantt-only';
todayL.innerHTML='<div class="legend-line"></div>Today';
refreshTodayTip(todayL);
todayL.addEventListener('mouseover',()=>refreshTodayTip(todayL));
todayL.addEventListener('click',()=>{
  prefs.showToday=!prefs.showToday;savePrefs();
  todayL.classList.toggle('off',!prefs.showToday);
  positionToday();positionDates();
});
legendEl.appendChild(todayL);
buildLegendHoliday();
// The table's filters join the legend line rather than owning a second band.
// Done here, after the chips, so they sit to the right of them.
legendEl.appendChild($('tableTools'));
}

// ── Build chart ──
function fitTimelineToPane(){
fitCOL();
}
const gridEls=[], msLineEls=[]; let todayLineEl=null, todayTagEl=null;
function mountQHeaderSpacer(){
labelsEl.appendChild(Object.assign(document.createElement('div'),{className:'label-h-q'}));
}
const qRow=document.createElement('div');
function mountQRow(){
qRow.className='q-row';
labelsEl.appendChild(Object.assign(document.createElement('div'),{className:'label-h-m'}));
}
const mRow=document.createElement('div');
function mountMRow(){
mRow.className='m-row';
}
const mNames=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
let qs=[];

const bars=document.createElement('div');
function sizeBarsPane(){
bars.style.cssText=`position:relative;width:${W}px;`;
}
// The grid lives in its own layer so changing the start date can rebuild it without
// disturbing the rows (and their bound handlers). Phases are carried by the bar colour
// and the group header, so there is no background band.
const gridLayer=document.createElement('div');
function mountGridLayer(){
gridLayer.style.cssText='position:absolute;inset:0;pointer-events:none;';bars.appendChild(gridLayer);
}

// Quarter and month header cells, rebuilt whenever START or UNITS changes.
// The axis and the grid are a pure function of these, so an unchanged axis is not rebuilt
// (0.75.1): rebuildTimeline and relayout each rebuilt both, twice per live merge, which
// threw away and re-laid out every header cell and grid line for nothing.
function axisKey(){ return [START&&START.getTime(),UNITS,COL,G(),isoDay(new Date()),calSig()].join('|'); }
let headersKey='', gridKey='';
function buildTimeHeaders(){
  const k=axisKey(); if(k===headersKey&&qRow.firstChild)return; headersKey=k;
  qRow.innerHTML='';mRow.innerHTML='';qs=[];
  const g=G();
  let cq=null;
  for(let i=0;i<UNITS;i++){
    const l=topLabel(unitDate(i));
    if(!cq||cq.l!==l){if(cq)qs.push(cq);cq={l,n:1};}else cq.n++;
  }
  if(cq)qs.push(cq);
  qRow.style.width=W+'px';
  qs.forEach(q=>{const c=document.createElement('div');c.className='q-cell';c.style.width=(q.n*COL)+'px';c.textContent=q.l;qRow.appendChild(c);});
  mRow.style.width=W+'px';
  for(let i=0;i<UNITS;i++){
    const d=unitDate(i);
    const c=document.createElement('div');c.className='m-cell';c.style.width=COL+'px';
    if(g==='day'&&(d.getDay()===0||d.getDay()===6))c.classList.add('weekend');
    // The holiday's name goes on the header cell rather than on the band, because the
    // band is under the bars and takes no pointer. One hover answers "why is that
    // column shaded, and why did Auto-schedule step over it".
    if(g==='day'){
      const hn=offDayTitle(isoDay(d));
      if(hn){c.classList.add('holiday');c.title=hn;}
    }
    c.textContent=botLabel(d);
    mRow.appendChild(c);
  }
}
function buildGridLines(){
  const k=axisKey(); if(k===gridKey&&gridLayer.firstChild)return; gridKey=k;
  gridLayer.innerHTML='';gridEls.length=0;
  const g=G();
  holidayLegendGrain(g==='day');
  if(g==='day'){
    // A rule per day is noise: month boundaries only, with every NON-WORKING column
    // shaded. Weekends and federal holidays get the same kind of band in two
    // deliberately different colours, so the reader can see at a glance both that a
    // column is closed and which kind of closed it is.
    //
    // Bands are laid down before the boundary rules and in a separate pass, so a
    // holiday that falls on the first of a month gets both. New Year's Day always
    // does, and shading it is the whole point.
    for(let i=0;i<UNITS;i++){
      const d=addD(START,i),ds=isoDay(d);
      const hn=holidayName(ds);
      if(!hn&&!(d.getDay()===0||d.getDay()===6))continue;
      const l=document.createElement('div');
      l.className='gl-band '+(hn?'holiday':'weekend');
      l.style.cssText=`position:absolute;top:0;bottom:0;left:${i*COL}px;width:${COL}px;background:var(--${hn?'holiday':'weekend'});pointer-events:none;`;
      // The band takes no pointer, so this title is for anything reading the DOM
      // rather than for a hover; the header cell above it carries the one a reader
      // can actually reach.
      if(hn)l.title=offDayTitle(ds);
      gridLayer.appendChild(l);gridEls.push(l);
    }
    for(let i=0;i<=UNITS;i++){
      const d=addD(START,i);
      if(d.getDate()===1||i===0||i===UNITS){
        const l=document.createElement('div');
        l.style.cssText=`position:absolute;top:0;bottom:0;left:${i*COL}px;width:1px;background:var(--grid-line);pointer-events:none;`;
        gridLayer.appendChild(l);gridEls.push(l);
      }
    }
    return;
  }
  for(let i=0;i<=UNITS;i++){
    const l=document.createElement('div');
    l.style.cssText=`position:absolute;top:0;bottom:0;left:${i*COL}px;width:1px;background:var(--grid-line);pointer-events:none;`;
    gridLayer.appendChild(l);gridEls.push(l);
  }
}
function buildTimeAxis(){
buildTimeHeaders();buildGridLines();
depSvg=document.createElementNS('http://www.w3.org/2000/svg','svg');depSvg.classList.add('dep-svg');depSvg.setAttribute('width',W);depSvg.setAttribute('height','1');bars.appendChild(depSvg);
}



// The row's own way into the full editor. A single click on the label edits the
// name (which the user wanted kept), so the modal needed a target of its own
// rather than a double-click nobody could reach past the instant editor.
// Group rows keep their chevron and get no Edit button: a lane header's only
// field is its name, and that is the phase, which is a different thing to edit.
// ── A link to one row (0.73.0) ──
// Every task and milestone has a permanent ID (its uid, "#00042"), and a link to it is the
// plan's address plus #row=<uid>-<name>, the name only there so a bare link still says what
// it points at. Copying puts the BARE LINK on the clipboard as text (0.74.0: the text used to
// lead with "#00042 Name:", and pasted into an address bar that is a search, not a link), and
// a named link for anything that takes HTML. Opening the link (signing in first if needed:
// the sign-in page keeps the fragment and reloads to it) shows the row on the chart.
function rowSlug(r){ return String(r.name||r.label||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,48).replace(/-+$/,''); }
function rowLink(r){ const sl=rowSlug(r); return location.origin+location.pathname+'#row='+encodeURIComponent(r.uid||'')+(sl?'-'+sl:''); }
function rowRef(r){ return '#'+(r.uid||'?')+' '+(r.name||r.label||''); }
function copyRowLink(r,el){
  if(!r||!r.uid){ flash(saveInd,'this row has no permanent ID yet'); return; }
  const url=rowLink(r), txt=url;
  window.__lastRowLink=txt;   // for the suite: headless Chrome does not show its clipboard
  const done=()=>{ flash(saveInd,'Copied a link to '+rowRef(r),2800); if(el){ el.classList.add('copied'); setTimeout(()=>el.classList.remove('copied'),1200); } };
  try{
    if(navigator.clipboard&&navigator.clipboard.write&&window.ClipboardItem){
      const html='<a href="'+esc(url)+'">'+esc(rowRef(r))+'</a>';
      navigator.clipboard.write([new ClipboardItem({'text/plain':new Blob([txt],{type:'text/plain'}),'text/html':new Blob([html],{type:'text/html'})})])
        .then(done).catch(()=>writeIdText(txt,done,false));
      return;
    }
  }catch(e){}
  writeIdText(txt,done,false);
}
function makeLinkBtn(r){
  const b=document.createElement('button');
  b.type='button';b.className='row-link';b.title='Copy a link to this row, to paste in Chat';
  b.setAttribute('aria-label','Copy a link to this row');
  b.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg>';
  b.addEventListener('mousedown',e=>e.stopPropagation());
  b.addEventListener('dblclick',e=>e.stopPropagation());
  b.addEventListener('click',e=>{e.stopPropagation();e.preventDefault();hideTip();copyRowLink(r,b);});
  return b;
}
// Opening a link: #row=<uid> in the address, at load or pasted into an open tab.
function linkedUid(){ const m=String(location.hash||'').match(/(?:^#|&)row=([0-9A-Za-z_]+)/); return m?m[1]:''; }
function openRowLink(){
  const u=linkedUid(); if(!u)return;
  try{ const h=String(location.hash).replace(/^#/,'').split('&').filter(kv=>kv&&!/^row=/.test(kv)).join('&');
    history.replaceState(null,'',location.pathname+location.search+(h?'#'+h:'')); }catch(e){}
  const r=items.find(x=>!isGroup(x)&&x.uid===u);
  if(!r){ flash(saveInd,'There is no row #'+u+' in this plan (it may have been deleted)',4500); return; }
  // A link always lands on the chart, with the row on screen: its section opened, and any
  // view filter that would hide it switched off (0.74.0, Drew: "brought to that task in
  // gantt view and see the row selected/highlighted").
  if(prefs.view!=='gantt')setView('gantt');
  if(r.phase!==undefined&&isCollapsed(r.phase))setCollapsed(r.phase,false);
  // A subtask inside a folded task: the tasks above it unfold (0.79.0).
  { let p=treeParent(r), opened=false; while(p){ if(collapsedRows.has(p)){ collapsedRows.delete(p); opened=true; } p=tree.parentOf.get(p); }
    if(opened){ saveFolds(); refreshTreeDom(); applyCollapse(); } }
  const hidden=(r.phase!==undefined&&!phaseVis[r.phase])||!rowShown(r)||(isMs(r)&&!msVis);
  if(hidden){
    if(r.phase!==undefined)phaseVis[r.phase]=true;
    if(isMs(r)&&!msVis){ msVis=true; msL.classList.remove('off'); }
    prefs.ownerFilter=''; prefs.tagFilter=[]; prefs.currentOnly=false; prefs.overdueOnly=false; savePrefs();
    applyTagView();
  }
  selectSole(r);
  const pulse=el=>{ if(!el)return; el.classList.remove('linked'); void el.offsetWidth; el.classList.add('linked'); setTimeout(()=>el.classList.remove('linked'),2800); };
  if(prefs.view==='table'){
    setFocusedRow(rowKey(r)); renderTable();
    const tr=[...tableBody.querySelectorAll('tr[data-key]')].find(t=>t.dataset.key===rowKey(r));
    if(tr){ tr.scrollIntoView({block:'center'}); pulse(tr); }
  }else{
    const target=isMs(r)?document.querySelector('.ms-hit[data-msuid="'+u+'"]'):(barEls[r.id]||r._lDiv);
    if(target&&target.scrollIntoView)target.scrollIntoView({block:'center',inline:'center'});
    pulse(r._lDiv); pulse(r._bDiv);
  }
  flash(saveInd,rowRef(r)+(hidden?': your filters were hiding it, so they are cleared':''),5000);
}
function makeEditBtn(r){
  const b=document.createElement('button');
  b.type='button';b.className='row-edit';b.title='Edit all fields';
  b.setAttribute('aria-label','Edit all fields');
  b.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h4L19 9a2.1 2.1 0 0 0-3-3L5 17v3z"/></svg>';
  b.addEventListener('mousedown',e=>e.stopPropagation());
  b.addEventListener('dblclick',e=>e.stopPropagation());
  b.addEventListener('click',e=>{e.stopPropagation();e.preventDefault();hideTip();openItemModal(r);});
  return b;
}

// The inner HTML of a task or milestone name span: the name wrapped in its own
// .name-text hit target, plus the status pill for a task. One place builds it so the
// build loop, the inline-edit restore and the table-to-chart status sync can never
// draw the name three subtly different ways. Groups are not routed through here; a
// lane header keeps its own .lane-name.
// A row's own tags ride last, after the owner, as quiet "#tag" text rather than chips:
// they are the first thing to give way when the column is narrow (see .row-tags), and
// text cut by an ellipsis still reads, where a chip cut in half does not. The name never
// yields to them. Section tags are not repeated here; the section header carries those.
function rowTagsHtml(r){
  const t=ownTags(r); if(!t.length)return '';
  return `<span class="row-tags" title="${esc('Tags: '+t.join(', '))}">${t.map(x=>`<span class="rt"${tagAttrs(x)}>#${esc(x)}</span>`).join(' ')}</span>`;
}
function nameInner(r){
  const nt='<span class="name-text">'+esc(r.name||'')+'</span>';
  if(r.type==='milestone')return nt+reqPillHtml(r)+rowTagsHtml(r);
  const sk=statusOf(r);
  const tg=sk?`<span class="tag status-pill ${statusTagFor(sk)}" title="Click to change status">${esc(statusTextFor(sk))}</span>`:'';
  // The owner rides immediately right of the status, the same kind of chip answering
  // the other question a row raises: not just how it is going, but whose it is. A row
  // with nobody on it still gets a chip, quieter, because "unassigned" is the state
  // most worth seeing at a glance and it has to be clickable to be fixable.
  const ow=(r.owner||'').trim();
  const ul=ow&&rqUnlinked(ow);
  const op=`<span class="tag owner-pill${ow?'':' none'}${ul?' unlinked':''}" title="${ow?(ul?esc(ow)+' is not linked to an Adom account, so nobody is told about work put on them. The plan\'s owner links names on Setup. ':'')+'Click to change owner':'Nobody owns this yet. Click to set an owner'}">${esc(ow||'NO OWNER')}</span>`;
  // A request somebody made for this row, right after its owner (0.80.0).
  return nt+subCountHtml(r)+tg+op+reqPillHtml(r)+rowTagsHtml(r);
}
// A task's hours with its subtasks' (0.79.0), as ClickUp rolls a time estimate up: the
// task's own estimate plus every subtask's, cancelled work left out. Shown beside its own in
// the Table; Analytics counts every task's own hours once, so nothing is counted twice.
function subEffort(r){ let h=0,n=0; treeDesc(r).forEach(x=>{ if(x.type==='milestone'||isCancelled(x))return; const e=effortOf(x); if(e!==null){ h+=e; n++; } }); return n?h:null; }
function effortRollHtml(r){
  const sub=treeHasKids(r)?subEffort(r):null; if(sub===null)return '';
  const own=isCancelled(r)?0:(effortOf(r)||0), tot=Math.round((own+sub)*10)/10;
  return `<span class="eff-roll" title="${esc(fmtEffort(tot)+' with its subtasks: '+(effortOf(r)===null?'none of its own':fmtEffort(own)+' of its own')+' and '+fmtEffort(Math.round(sub*10)/10)+' in its subtasks')}"> &middot; ${esc(fmtEffort(tot))}</span>`;
}
// How many subtasks a task has, after its name (0.79.0), the way ClickUp marks a parent.
const PLUS_SVG='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
function subCountHtml(r){
  if(!treeHasKids(r))return '';
  const n=treeDesc(r).length, k=(tree.kids.get(rowTreeKey(r))||[]).length;
  return `<span class="sub-count" title="${esc(plural(n,'subtask')+(n>k?', '+k+' directly under it':''))}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4v9a3 3 0 0 0 3 3h9"/><path d="M15 13l3 3-3 3"/></svg>${n}</span>`;
}

// Extracted from the initial build loop so a row created at runtime is built by
// exactly the same code as a row from the data file, rather than a second
// almost-identical path that drifts.
function buildRowDom(r,idx){
  const lDiv=document.createElement('div'),bDiv=document.createElement('div');
  if(r.type==='group'){
    lDiv.className='label-row group';
    const nm=document.createElement('span');nm.className='lane-name';
    const dp=isDerivedGroup(r)&&phases[r.phase];
    fillLaneName(nm,isDerivedGroup(r)?(dp?dp.name:r.label):groupLabel(r.label),dp?(dp.lead||''):'',dp?dp.tags:null);
    const btn=document.createElement('button');btn.className='lane-toggle';btn.type='button';
    btn.innerHTML='<span class="lane-count"></span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>';
    // Quick add (0.79.0, issue #6): a task at the end of this section, typed in place.
    const add=document.createElement('button');add.className='lane-add';add.type='button';add.title='Add a task to this section';
    add.setAttribute('aria-label','Add a task to this section');add.innerHTML=PLUS_SVG;
    lDiv.appendChild(nm);lDiv.appendChild(add);lDiv.appendChild(btn);
    r._laneBtn=btn;
    bDiv.className='bar-row group';
  }
  else if(r.type==='milestone'){
    lDiv.className='label-row ms';
    lDiv.appendChild(Object.assign(document.createElement('span'),{className:'sub-tog sub-none'}));
    lDiv.appendChild(Object.assign(document.createElement('span'),{className:'row-name'}));
    lDiv.appendChild(makeEditBtn(r));
    lDiv.appendChild(makeLinkBtn(r));
    r._nameSpan=lDiv.querySelector('.row-name');
    setHtmlIfChanged(r._nameSpan,nameInner(r));
    bDiv.className='bar-row ms-row';
    const x=toX(r.date);
    const hit=document.createElement('div');hit.className='ms-hit';hit.style.left=x+'px';
    hit.dataset.msuid=r.uid||'';
    hit.dataset.title=r.name;hit.dataset.dates=fmt(r.date);hit.dataset.body=r.description||'';hit.dataset.prov=provLabel(r);hit.dataset.tags=ownTags(r).join(', ');
    const d=document.createElement('div');d.className='ms-diamond';d.style.left=x+'px';
    bDiv.appendChild(hit);bDiv.appendChild(d);
    // The dashed rule lives on the chart container, not the row, so it can span
    // the full height. Colour follows the milestone's phase.
    const ln=document.createElement('div');ln.className='ms-line';ln.style.left=x+'px';
    ln.style.color=phaseColor(r.phase);
    const cap=document.createElement('div');cap.className='ms-cap';cap.textContent=r.name.replace(/^M(\d+):\s*/,'M$1 ');
    cap.dataset.msuid=r.uid||'';
    cap.dataset.title=r.name;cap.dataset.dates=fmt(r.date);cap.dataset.body=r.description||'';cap.dataset.prov=provLabel(r);cap.dataset.tags=ownTags(r).join(', ');
    ln.appendChild(cap);msLineEls.push(ln);
    r._msEl=d;r._msHit=hit;r._msLine=ln;
  }
  else{lDiv.className='label-row';lDiv.dataset.id=r.id;
    // Subtasks (0.79.0): the chevron that folds this task's subtasks, in a slot every row
    // keeps once the plan has any, so names line up; and the + that adds one, on hover.
    const tog=document.createElement('button');tog.type='button';tog.className='sub-tog';tog.setAttribute('aria-label','Fold or unfold the subtasks');
    tog.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>';
    lDiv.appendChild(tog); r._subTog=tog;
    lDiv.appendChild(Object.assign(document.createElement('span'),{className:'row-name'}));
    const add=document.createElement('button');add.type='button';add.className='row-add';add.title='Add a subtask';
    add.setAttribute('aria-label','Add a subtask');add.innerHTML=PLUS_SVG;
    lDiv.appendChild(add);
    lDiv.appendChild(makeEditBtn(r));
    lDiv.appendChild(makeLinkBtn(r));
    r._nameSpan=lDiv.querySelector('.row-name');
    setHtmlIfChanged(r._nameSpan,nameInner(r));
    bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.prov=provLabel(r);b.dataset.tags=ownTags(r).join(', ');b.dataset.id=r.id;
    const nt=document.createElement('div');nt.className='bar-note';nt.textContent=r.note||'';b.appendChild(nt);r._noteEl=nt;
    b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};}
  // The handle that selects the row and nothing else (see selectFromHandle). A lane header
  // cannot be selected, so it has none.
  if(r.type!=='group')lDiv.appendChild(makeSelBtn());
  r._lDiv=lDiv;r._bDiv=bDiv;r._itemIdx=idx;labelsEl.appendChild(lDiv);bars.appendChild(bDiv);
  // Last, because everything it marks has to exist first: both row divs are assigned on
  // the line above and the bar went into barEls a few lines before that. Here rather
  // than in buildRows, because insertRow builds a NEW row through this function alone,
  // so a row created already cancelled has to come out of the panel looking it.
  syncCancelMark(r);
}
let rowsBuilt=false;
function buildRows(){
items.forEach((r,idx)=>buildRowDom(r,idx));
rowsBuilt=true;

// Appended after the rows so the dashed rules sit above the phase bands.
msLineEls.forEach(ln=>bars.appendChild(ln));
applyCollapse();
document.documentElement.setAttribute('data-ms', prefs.msStyle);
if(prefs.showToday===false)todayL.classList.add('off');
}

const today=new Date();
function todayX(){return toX(isoDay(today));}
// Uses the app's own tooltip, which renders through textContent nodes.
function refreshTodayTip(el){ if(!el)return; el.dataset.title='Today'; el.dataset.dates=fmt(isoDay(new Date())); }
function positionToday(){
  const tx=todayX(), on=prefs.showToday!==false && tx>0 && tx<W;
  if(todayLineEl){todayLineEl.style.left=tx+'px';todayLineEl.style.display=on?'':'none';}
  if(todayTagEl){todayTagEl.style.left=tx+'px';todayTagEl.style.display=on?'':'none';}
}
function mountTodayLine(){
{const tl=document.createElement('div');tl.className='today-line';bars.appendChild(tl);todayLineEl=tl;
 const tt=document.createElement('div');tt.className='today-tag';tt.textContent='today';bars.appendChild(tt);todayTagEl=tt;
 // Recomputed on hover, not at build: a plan left open overnight would otherwise
 // still say yesterday. The marker's own position is a separate question and
 // still comes from the load-time date, as it always has.
 refreshTodayTip(tt);
 tt.addEventListener('mouseover',()=>refreshTodayTip(tt));
 positionToday();}
mountDateLines();
timelineEl.appendChild(qRow);timelineEl.appendChild(mRow);timelineEl.appendChild(bars);
}
// The project's dates on the chart (0.75.0): a dashed line at each, a tag on it in the
// band "today" uses, and the time outside them hatched. The end line sits where a bar
// that ends on that day ends, so a bar that touches it is on time and one past it is not.
let dateEls=null;
function mountDateLines(){
  const el=(cls,txt)=>{ const d=document.createElement('div'); d.className=cls; if(txt)d.textContent=txt; bars.appendChild(d); return d; };
  dateEls={before:el('pdate-out pdate-before'),after:el('pdate-out pdate-after'),
    sLine:el('pdate-line pdate-start'),eLine:el('pdate-line pdate-end'),
    sTag:el('pdate-tag pdate-start-tag','start'),eTag:el('pdate-tag pdate-end-tag','end')};
  positionDates();
}
function positionDates(){
  if(!dateEls)return;
  const {start,end}=planDates;
  const xs=start?toX(start):null, xe=end?toX(end):null;
  const place=(line,tag,x,ds,what)=>{
    const on=x!==null&&x>=0&&x<=W;
    line.style.display=tag.style.display=on?'':'none';
    if(!on)return;
    line.style.left=tag.style.left=x+'px';
    tag.dataset.title='Project '+what; tag.dataset.dates=fmt(ds);
    tag.dataset.body='Set on Setup, under Project dates.';
  };
  place(dateEls.sLine,dateEls.sTag,xs,start,'start');
  place(dateEls.eLine,dateEls.eTag,xe,end,'end');
  const b=dateEls.before, a=dateEls.after;
  b.style.display=xs!==null&&xs>0?'':'none';
  if(xs!==null){ b.style.left='0px'; b.style.width=Math.min(xs,W)+'px'; }
  a.style.display=xe!==null&&xe<W?'':'none';
  if(xe!==null){ const l=Math.max(0,xe); a.style.left=l+'px'; a.style.width=Math.max(0,W-l)+'px'; }
}
const barTail=document.createElement('div');
function mountBarTailroom(){
barTail.className='tailroom';timelineEl.appendChild(barTail);
}
const labelTail=document.createElement('div');
// The names and the bars are one scroll: the timeline is the real scroller and the
// label column mirrors its scrollTop. The timeline can be TALLER than the labels,
// though, because a dependency arrow's SVG canvas can reach a little below the last
// row, so an overscroll on the timeline (wheel, drag, PageDown/End, past the end)
// would slide the bars down past the point the labels can follow and the rows would
// drift out of alignment. Clamp the timeline to the distance the labels can actually
// travel first, then mirror, so the two panes are locked row-for-row at the end of
// the list and past it. Setting scrollTop here re-fires scroll, but the clamp is
// idempotent once reached, so it settles in one extra tick rather than looping.
function syncLabelScroll(){
  const maxL=labelsEl.scrollHeight-labelsEl.clientHeight;
  if(timelineEl.scrollTop>maxL)timelineEl.scrollTop=maxL;
  labelsEl.scrollTop=timelineEl.scrollTop;
}
// ── Holding the reader's place (0.74.1) ──
// A change in which rows show (a filter, Collapse all) keeps the row the reader is
// looking at where it was on screen. When the change hides that row, the nearest row
// above it that still shows takes its spot, which after a Collapse all is its own
// section's header, so the reader stays in the section they were in. Both panes move
// together, through the timeline. The browser's own scroll anchoring is off in both
// (CSS): it anchored each pane by itself, and picking Unassigned while scrolled down
// moved the names and not the bars, leaving them 1,150 px apart until the next scroll.
// A pinned header reports where it is pinned, not where it lives (see capBandBase), so
// the headers' sticky is lifted for the read, all at once, and put back in the same tick
// before any paint.
function unpinnedRead(fn){
  bars.classList.add('unpinned');
  try{ return fn(); }finally{ bars.classList.remove('unpinned'); }
}
function placeMark(){
  // At the very top there is nothing above to keep in place: the reader stays at the
  // top, where the first section starts, and a fold or a filter reads from there.
  if(prefs.view!=='gantt'||timelineEl.scrollTop<1)return null;
  // Read before the lift: the band ends under the header that is pinned now.
  const paneTop=timelineEl.getBoundingClientRect().top, band=chartViewBand().top;
  return unpinnedRead(()=>{
    for(let i=0;i<items.length;i++){
      const r=items[i];
      if(!r._bDiv||r._collapsed)continue;
      const b=r._bDiv.getBoundingClientRect();
      if(b.bottom>band+1)return {r,i,y:b.top-paneTop};
    }
    return null;
  });
}
// The reader's place for a change that only reorders rows (0.78.1). The rows move only inside
// their own sections then, so no header moves and none can be the place to keep: the rows are
// read where they stand, with the pinned headers left pinned, which spares the two restyles of
// the whole chart that unpinnedRead costs per change. Gives what placeMark gives otherwise: the
// first row on screen whose bottom is below the band, or nothing when placeMark would have
// stopped on a header, which an order change cannot move.
function placeMarkRows(){
  if(prefs.view!=='gantt'||timelineEl.scrollTop<1)return null;
  const paneTop=timelineEl.getBoundingClientRect().top, band=chartViewBand().top;
  let prev=null;
  for(let i=0;i<items.length;i++){
    const r=items[i];
    if(!r._bDiv||r._collapsed)continue;
    if(isDerivedGroup(r)){ prev=r; continue; }
    const b=r._bDiv.getBoundingClientRect();
    if(!b.height)continue;
    if(b.bottom>band+1)return prev&&isDerivedGroup(prev)&&b.top>band+1?null:{r,i,y:b.top-paneTop};
    prev=r;
  }
  return null;
}
// And keeping it: only when that row moved, which it can only have done inside its section.
function keepPlaceRows(mark){
  if(!mark||prefs.view!=='gantt'||!mark.r._bDiv)return;
  const d=mark.r._bDiv.getBoundingClientRect().top-timelineEl.getBoundingClientRect().top-mark.y;
  if(Math.abs(d)<0.5)return;
  timelineEl.scrollTop+=d;
  syncLabelScroll(); updatePinnedHeader();
}
function keepPlace(mark){
  if(prefs.view!=='gantt')return;
  const shows=x=>x._bDiv&&!x._collapsed;
  // A row another person deleted is gone from items: its old index stands in for it.
  let i=mark?items.indexOf(mark.r):-1;
  const gone=mark&&i<0;
  if(gone)i=Math.min(mark.i,items.length);
  const r=!mark?null:!gone&&shows(mark.r)?mark.r:(items.slice(0,i).reverse().find(shows)||items.slice(gone?i:i+1).find(shows));
  if(r){
    const paneTop=timelineEl.getBoundingClientRect().top;
    timelineEl.scrollTop+=unpinnedRead(()=>r._bDiv.getBoundingClientRect().top)-paneTop-mark.y;
  }
  syncLabelScroll(); updatePinnedHeader();
}
function mountLabelTailroom(){
labelTail.className='tailroom';labelsEl.appendChild(labelTail);
timelineEl.addEventListener('scroll',()=>{syncLabelScroll();updatePinnedHeader();});
// The names can be scrolled on their own: focus moving to a control out of view (Tab
// to a lane chevron, a rename field) scrolls its nearest scroller, which is this column,
// and the bars stayed where they were. The timeline follows them instead (0.74.1); its
// own scroll then mirrors back, already equal, and stops there.
labelsEl.addEventListener('scroll',()=>{ if(Math.abs(labelsEl.scrollTop-timelineEl.scrollTop)>0.5)timelineEl.scrollTop=labelsEl.scrollTop; });
}
// Which section header is currently stuck under the axis. Pure CSS does the
// pinning and the swap; this only adds the class the drop-shadow hangs on, so a
// pinned header lifts off the rows sliding beneath it. Cheap: one pass over the
// group rows on scroll.
let pinnedGroup=null;
function updatePinnedHeader(){
  if(prefs.view!=='gantt')return;
  const axis=46*(prefs.fs||1);
  const top=timelineEl.scrollTop;
  const groups=items.filter(isDerivedGroup);
  let active=null;
  // A header a filter hides reads offsetTop 0, which is "above the axis" at any scroll,
  // so it would win the pin while not on screen (0.74.1).
  groups.forEach(g=>{ if(g._lDiv && !g._collapsed && (g._lDiv.offsetTop-top)<=axis+0.5) active=g; });
  if(active!==pinnedGroup){
    groups.forEach(g=>{ const on=g===active;
      if(g._lDiv)g._lDiv.classList.toggle('is-pinned',on);
      if(g._bDiv)g._bDiv.classList.toggle('is-pinned',on); });
    pinnedGroup=active;
  }
  maskTopBand(active);
}
// The milestone caps and the today tag live in a band at the top of the timeline.
// A cap that has scrolled up INTO the pinned axis+header band would otherwise show
// a clipped sliver of its (sometimes two-line) label under the overlay. Hide any
// whose top has crossed the pinned band's bottom edge; they reappear, whole, the
// moment they are fully below it again. This is the clean-edge version of "content
// scrolls up and disappears under the pinned header".
function maskTopBand(active){
  const boundary=(active&&active._bDiv)?active._bDiv.getBoundingClientRect().bottom
                 :timelineEl.getBoundingClientRect().top+46*(prefs.fs||1);
  if(msShowsLine()){
    items.forEach(r=>{ if(r.type!=='milestone'||!r._msLine)return;
      const cap=r._msLine.querySelector('.ms-cap'); if(!cap)return;
      cap.style.visibility=(cap.getBoundingClientRect().top>=boundary-1)?'':'hidden'; });
  }
  if(todayTagEl&&todayTagEl.style.display!=='none'){
    todayTagEl.style.visibility=(todayTagEl.getBoundingClientRect().top>=boundary-1)?'':'hidden';
  }
  if(dateEls)[dateEls.sTag,dateEls.eTag].forEach(t=>{ if(t.style.display!=='none')t.style.visibility=(t.getBoundingClientRect().top>=boundary-1)?'':'hidden'; });
}
function wireChartWheel(){
  const wrap=timelineEl.closest('.chart-wrap')||timelineEl.parentElement;
  if(!wrap)return;
  wrap.addEventListener('wheel',e=>{
    // A dropdown or menu that opens over the chart and scrolls itself keeps its own
    // wheel; nothing like that lives inside .chart-wrap today, but the guard is cheap.
    if(e.target.closest('.cell-list, .dropdown, .sec-list'))return;
    // Line and page deltas become pixels, so a mouse wheel and a trackpad move the
    // plan by a comparable amount and the maths is uniform.
    const unitY=e.deltaMode===1?16:(e.deltaMode===2?timelineEl.clientHeight:1);
    const unitX=e.deltaMode===1?16:(e.deltaMode===2?timelineEl.clientWidth:1);
    let dx=e.deltaX*unitX, dy=e.deltaY*unitY;
    // Shift with a vertical wheel is the standard "scroll sideways" gesture.
    if(e.shiftKey&&dx===0){ dx=dy; dy=0; }
    if(dy)timelineEl.scrollTop+=dy;
    if(dx)timelineEl.scrollLeft+=dx;
    // Sync the label column HERE rather than lean on the timeline's own scroll
    // event: a programmatic scrollTop fires that event late (or not at all in some
    // engines), so the labels have to be pulled along in the same tick the wheel is
    // handled or they lag a frame behind the bars. syncLabelScroll also clamps the
    // overscroll so the bars can never outrun the names past the end of the list.
    syncLabelScroll();
    updatePinnedHeader();
    // Own the gesture: the label column has no scroll of its own, and the page
    // behind must never rubber-band because the pointer was over a header.
    e.preventDefault();
  },{passive:false});
}

// ── Responsive relayout ──
// Everything on the timeline is positioned in px derived from COL, so a resize
// means recomputing COL and repositioning, not rebuilding (handlers stay bound).
function relayout(){
  fitCOL();
  buildTimeHeaders();
  buildGridLines();
  bars.style.width=W+'px';
  if(depSvg)depSvg.setAttribute('width',W);
  items.forEach(r=>{
    if(r.type==='milestone'&&r._msEl){
      const x=toX(r.date);
      r._msEl.style.left=x+'px';
      if(r._msHit){r._msHit.style.left=x+'px';r._msHit.dataset.dates=fmt(r.date);}
      if(r._msLine){
        r._msLine.style.left=x+'px';
        const cap=r._msLine.querySelector('.ms-cap');
        if(cap){cap.dataset.dates=fmt(r.date);cap.dataset.title=r.name;cap.dataset.body=r.description||'';}
      }
    }
    else if(r.id&&barEls[r.id]){const b=barEls[r.id].bar,x1=toX(r.start),x2=toX(r.end);b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';}
  });
  positionToday();positionDates();
  paintSpans();
  layoutMsCaps();
  sizeTailroom();
  drawDeps();
}

// Room to scroll past the last row, so the bottom of the plan can sit centred in
// the pane instead of being pinned to the bottom edge. Spacers rather than padding
// on the chart container, or the today line and the dashed milestone rules would
// stretch down through the empty tail. Both columns get the same height or they
// drift apart as you scroll.
// Re-appending the rows (a reorder, or applyOrder on load) moves them AFTER the
// label column's tail spacer, which then pushes every label down and detaches the
// labels from their bars. Put the spacer back at the end whenever rows move.
// Wrapping narrows a long milestone title but cannot stop two adjacent ones from
// colliding, so caps that still overlap drop to the next row. Measured left-to-right
// in date order, each cap takes the first row it fits on.
// The pinned section header, opaque and sticky since 0.31.0, sits in the same top
// band the milestone caps live in. So the caps band starts BELOW it: at rest, a
// cap under the first section's header is no longer hidden by it, and a cap that
// scrolls up still slides under the header (it is z-below) rather than being
// clipped in place.
//
// The band anchors to the FIRST section that actually shows content, not blindly to
// the first header (0.34.0). When the top section(s) are collapsed or empty their
// headers stack up with nothing under them, and a band pinned below the first header
// would be tucked behind the second or third one. So the anchor skips any leading
// collapsed or empty section and lands on the topmost expanded, non-empty one; the
// band sits just below THAT header and travels down as the user collapses the top of
// the plan. phaseVis is deliberately ignored: a filtered section is only washed out,
// its rows still occupy their space, so the geometry does not move for it.
function capBandAnchor(){
  const groups=items.filter(isDerivedGroup);
  for(const g of groups){
    if(isCollapsed(g.phase))continue;
    const has=items.some(r=>r.type!=='group'&&r.phase===g.phase&&!isCollapsed(r.phase));
    if(has)return g;
  }
  return groups[0]||null;
}
// Content-y (relative to the bars pane, where the caps and today tag are absolutely
// positioned) just below the anchor header. It tracks the font scale because it is
// read from a real row rather than a constant.
// The anchor header is position:sticky, and in Blink offsetTop on a sticky element
// reports where it is PINNED, not where it lives: scrolled 300px down, the top
// header's offsetTop reads 300 instead of 0. Every caller that laid the band out
// while the plan was scrolled therefore anchored it to the viewport, and the caps
// stayed at that stale offset from then on. Toggling the milestone style was the way
// to see it, because that path lays the band out and nothing about it returns the
// plan to the top first. Suppress the sticky for the one measurement and the row
// gives up its real layout position; it is put back in the same tick, before any
// paint, so nothing flickers.
function capBandBase(){
  const g=capBandAnchor();
  if(g&&g._bDiv){
    const el=g._bDiv, was=el.style.position;
    el.style.position='static';
    const top=el.offsetTop+el.offsetHeight;
    el.style.position=was;
    return Math.round(top)+4;
  }
  return Math.round(28*(prefs.fs||1))+4;
}
// ── The band gets its own space ──
//
// The caps and the today tag float in a band that starts just under the anchor header,
// which is where the FIRST bar rows are. So a cap, which is an opaque box with
// pointer-events on it at z-index 7, lay on top of a task bar and took the clicks
// meant for it, its resize handles included. 0.37.0 recorded that as a known quirk and
// worked around it in the suites; 0.38.0 fixed where the band anchors but not that it
// overlapped.
//
// Deciding who wins a shared pixel is the wrong question, because the cap really is
// painted there: whatever the answer, one of the two is being clicked through
// something the user can see on top of it. So the band is given room instead. The
// anchor header carries a margin-bottom the height of the band, in BOTH columns or
// they drift apart by exactly that much, and every row below now begins under the band
// rather than behind it. capBandBase reads offsetTop + offsetHeight, and a
// margin-BOTTOM changes neither, so the caps do not move and there is no second pass:
// only the plan below them moves down.
//
// The reserve is cleared off the old anchor whenever the anchor changes, which is what
// collapsing the top section does.
let capBandOwner=null;
function clearCapBandReserve(g){
  if(!g)return;
  if(g._lDiv)g._lDiv.style.marginBottom='';
  if(g._bDiv)g._bDiv.style.marginBottom='';
  if(capBandOwner===g)capBandOwner=null;
}
function setCapBandReserve(anchor,px){
  if(capBandOwner&&capBandOwner!==anchor)clearCapBandReserve(capBandOwner);
  if(!anchor||px<=0){ clearCapBandReserve(anchor); return; }
  if(anchor._lDiv)anchor._lDiv.style.marginBottom=px+'px';
  if(anchor._bDiv)anchor._bDiv.style.marginBottom=px+'px';
  capBandOwner=anchor;
}
// capBandBase() already puts the band 4px below the header, so that 4 is part of what
// has to be reserved; the second 4 is clear air between the bottom of the band and the
// first bar row, so a cap and a bar are never merely touching.
const CAP_BAND_GAP=4;
function todayTagInBand(){
  return !!(todayTagEl&&todayTagEl.style.display!=='none'&&prefs.showToday!==false);
}
function layoutMsCaps(){
  const anchor=capBandAnchor();
  // A reserve left on a FORMER anchor is above the new one, so it is part of where the
  // new one is. It therefore has to go before the base is measured, not after: measure
  // first and clear second and the caps are laid out against a position the clear then
  // takes away from under them, which is a whole band's height of stale offset.
  // Collapsing the top section is the gesture that moves the anchor, so that is where
  // it showed. The reserve on the anchor ITSELF is a margin-BOTTOM and cannot move the
  // anchor, which is what makes one pass enough.
  if(capBandOwner&&capBandOwner!==anchor)clearCapBandReserve(capBandOwner);
  const base=capBandBase();
  // The today tag shares the band, so it rides the anchor too, in both milestone
  // styles. Keeping it level with the caps means a collapsed top section can never
  // strand it behind a header either.
  if(todayTagEl)todayTagEl.style.top=base+'px';
  if(!msShowsLine()){
    // No caps in this style, but the today tag is still in the band and takes clicks
    // by exactly the same mechanism, so it is reserved for by exactly the same rule.
    setCapBandReserve(anchor,todayTagInBand()?CAP_BAND_GAP+(todayTagEl.offsetHeight||14)+CAP_BAND_GAP:0);
    return;
  }
  const caps=[];
  items.filter(r=>r.type==='milestone'&&r._msLine&&!r._collapsed).forEach(r=>{
    const cap=r._msLine.querySelector('.ms-cap');
    if(cap){cap.style.top=base+'px';caps.push({cap,x:toX(r.date)});}
  });
  caps.sort((a,b)=>a.x-b.x);
  const GAP=6;
  // Occupied spans per row, not just a right edge, so a cap can sit in a gap to the
  // LEFT of something already placed. Row 0 starts pre-occupied by the today tag,
  // which shares this band and would otherwise be overlapped.
  const rows=[[]];
  if(todayTagInBand()){
    const tw=todayTagEl.offsetWidth||34, tx=todayX();
    rows[0].push([tx-tw/2-GAP, tx+tw/2+GAP]);
  }
  const fits=(row,l,r)=>row.every(([a,b])=>r<a||l>b);
  // Uniform row pitch set by the tallest tag: a wrapped (two-line) tag on an upper
  // row can never be ridden into by a shorter tag on the row below.
  let rowH=14;
  caps.forEach(c=>{rowH=Math.max(rowH,c.cap.offsetHeight||14);});
  rowH+=3;
  caps.forEach(c=>{
    const w=c.cap.offsetWidth||80;
    const l=c.x-w/2-GAP, r=c.x+w/2+GAP;
    let n=0;
    while(true){
      if(!rows[n])rows[n]=[];
      if(fits(rows[n],l,r))break;
      n++;
    }
    rows[n].push([l,r]);
    c.cap.style.top=(base+n*rowH)+'px';
  });
  // The band is as deep as the last row it used plus the tallest thing standing on
  // that row. rowH already carries the 3px pitch padding, so the last row is measured
  // rather than assumed: a two-line cap on the bottom row must not hang into the plan.
  const lastTop=(rows.length-1)*rowH;
  let tall=todayTagInBand()&&rows.length===1?(todayTagEl.offsetHeight||14):0;
  caps.forEach(c=>{
    const n=Math.round((parseFloat(c.cap.style.top)-base)/rowH);
    if(n===rows.length-1)tall=Math.max(tall,c.cap.offsetHeight||14);
  });
  setCapBandReserve(anchor,CAP_BAND_GAP+lastTop+(tall||rowH)+CAP_BAND_GAP);
}

function keepTailLast(){
  // Already last is left alone (0.78.1): appending a node that is in place still takes it
  // out and puts it back, which restyles it and lays the pane out again.
  if(typeof labelTail!=='undefined'&&labelTail&&labelTail.parentNode&&labelsEl.lastElementChild!==labelTail) labelsEl.appendChild(labelTail);
  if(typeof barTail!=='undefined'&&barTail&&barTail.parentNode&&timelineEl.lastElementChild!==barTail) timelineEl.appendChild(barTail);
}
function sizeTailroom(){
  const pane=timelineEl.clientHeight||0;
  const vis=items.filter(r=>r._bDiv&&!r._collapsed);
  const lastRow=vis.length?vis[vis.length-1]._bDiv:null;
  const rowH=lastRow?lastRow.offsetHeight:34;
  const pad=Math.max(0,Math.round(pane/2-rowH));
  barTail.style.height=pad+'px';
  labelTail.style.height=pad+'px';
}
// The echo guard's baseline. A measurement is order-dependent by definition, so it
// is taken in wireRelayoutObservers() rather than here, where it would now read a
// pane the chart had not been put into yet.
let rlTimer=null, lastPaneW=0, lastPaneH=0;
function scheduleRelayout(){clearTimeout(rlTimer);rlTimer=setTimeout(()=>{
  // A hidden chart measures zero, which would collapse every column and the
  // milestone cap layout. Switching back to the chart relays out explicitly.
  if(prefs.view!=='gantt')return;
  const w=timelineEl.clientWidth, h=timelineEl.clientHeight;
  if(w===lastPaneW && h===lastPaneH) return;  // guard: ignore observer echo from our own writes
  lastPaneW=w; lastPaneH=h;
  // A width change reflows the whole timeline; a height-only change still has to
  // resize the tailroom, or scrolling past the last row breaks after a vertical drag.
  if(w!==lastRelayoutW){ lastRelayoutW=w; if(timelineStale){ timelineStale=false; rebuildTimeline(); } else relayout(); }
  else if(timelineStale){ timelineStale=false; rebuildTimeline(); }
  else { sizeTailroom(); }
  // One more pass next frame: if the pane was still settling when we measured,
  // the tail would otherwise stay stale until the next resize.
  requestAnimationFrame(()=>{ lastPaneH=timelineEl.clientHeight; sizeTailroom(); });
},80);}
let lastRelayoutW=0;
function wireRelayoutObservers(){
lastPaneW=timelineEl.clientWidth; lastPaneH=timelineEl.clientHeight;
lastRelayoutW=timelineEl.clientWidth;
window.addEventListener('resize',scheduleRelayout);
if(window.ResizeObserver)new ResizeObserver(scheduleRelayout).observe(timelineEl);
// Web-font load changes tag widths and heights after the first measure, so re-run the
// milestone cap collision layout once fonts settle, or tags can overlap on a cold load.
if(document.fonts&&document.fonts.ready)document.fonts.ready.then(()=>{if(msShowsLine()&&prefs.view==='gantt')layoutMsCaps();});
}


// ── Filter ──
// The legend's phase and milestone toggles are a statement about the plan, not
// about the chart, so the table honours them too and the two lenses agree.
// The lane counts on the chevrons are a fact about the plan, so they are refreshed
// wherever the plan's membership can change. applyCollapse() is the thing that
// writes them, and applyFilter() is the choke point every structural change already
// goes through (add, delete, restore, phase move, drag, keyboard move), so putting
// it here covers all of them at once instead of at seven call sites, one of which a
// later round would have forgotten.
function applyFilter(){applyCollapse();items.forEach(r=>{let vis=true;if(r.phase!==undefined&&!phaseVis[r.phase])vis=false;if(r.type==='milestone'&&!msVis)vis=false;r._lDiv.classList.toggle('dim',!vis);r._bDiv.classList.toggle('dim',!vis);
  // The line-mode cap lives on _msLine, not _bDiv, so the dim wash has to be carried
  // to it by hand or a milestone filtered out by phaseVis (or the global Milestone
  // toggle) would leave its tag at full strength in the top band. Collapse is handled
  // separately: applyCollapse hides _msLine outright with .row-collapsed.
  if(r._msLine)r._msLine.classList.toggle('ms-dim',!vis);});drawDeps();
  // Rows came or went: the two panes are one scroll, so they settle together (0.74.1).
  if(prefs.view==='gantt'){ syncLabelScroll(); updatePinnedHeader(); }
  if(prefs.view==='table')renderTable();}

// ── Dependencies ──
function sb0(entry){ return entry.row || entry.bar.closest('.bar-row'); }
// An arrow leaves a bar the way the work does: out of its END, sideways, and it
// arrives at the next one's START, sideways again. It never drops straight into the
// target. Two bars that end and start on nearly the same day used to put x1 and x2
// within a pixel of each other; both control points of the single cubic landed on
// that same x and the curve collapsed into a vertical line falling out of one bar
// into the other, with nothing in it to say which end was the cause and which the
// effect.
//
// So the route is always a Z with rounded elbows: a horizontal run out of the source,
// a vertical drop, and a horizontal run into the target's left edge, where the
// arrowhead sits. DEP_STUB is the shortest either horizontal run may be, which is
// what keeps the two ends readable as horizontal however close the bars are.
const DEP_STUB=16, DEP_R=10;
// How much clear air a route keeps between itself and a bar body, and how far past a
// bar's edge a nudged drop lands. Three pixels is wider than the stroke, so the line
// never kisses an edge, and it is far less than the empty band above and below every
// bar, so a lane between two rows is still a lane.
const DEP_PAD=3, DEP_GAP=2;
// A polyline with its corners rounded off. Quadratic corners, each radius clamped to
// half of the two segments it joins, so a tight elbow degrades to a square one
// instead of overshooting into the segment past it. The first and last segments are
// left straight, and that is what makes the tangents at the two ends horizontal.
function roundedPath(pts,rad){
  let d='M'+pts[0][0]+','+pts[0][1];
  for(let i=1;i<pts.length-1;i++){
    const px=pts[i-1][0],py=pts[i-1][1],cx=pts[i][0],cy=pts[i][1],nx=pts[i+1][0],ny=pts[i+1][1];
    const d1=Math.hypot(cx-px,cy-py)||1, d2=Math.hypot(nx-cx,ny-cy)||1;
    const r=Math.min(rad,d1/2,d2/2);
    d+=' L'+(cx+(px-cx)/d1*r)+','+(cy+(py-cy)/d1*r)
      +' Q'+cx+','+cy+' '+(cx+(nx-cx)/d2*r)+','+(cy+(ny-cy)/d2*r);
  }
  return d+' L'+pts[pts.length-1][0]+','+pts[pts.length-1][1];
}
// The route from the end of one bar to the start of the next, in either direction.
//   room between them : out, down, in. The drop stays near the middle, so a long
//                       span still reads as the wide sweep it always did.
//   too close, or the target starts BEFORE the source ends : out, down into a lane
//                       between the two rows, back past the target's start, down
//                       again, in. The hook is what replaces the old vertical line.
// Either way the first segment leaves the source horizontally and the last enters the
// target horizontally from x < x2, so the arrowhead always points right into the
// bar's start edge.
//
// Until 0.41.0 both shapes were geometry alone. They knew where the two bars were and
// nothing at all about the ones in between, so a drop could fall straight through the
// body of an unrelated task and a hook's middle run could lie along one, which reads
// as if that bar were part of the dependency.
//
// The chart is rows, and that is the whole trick. Every row carries at most one bar,
// with the same empty band above and below it, so:
//   a horizontal run along a row BOUNDARY is clear of every bar by construction;
//   a horizontal run at a row's own centre can only meet that row's own bar, which is
//   the source behind the exit or the target ahead of the entry;
//   so the only thing left to choose is the x of each vertical drop.
// The two shapes are therefore exactly the ones 0.38.0 shipped. What is new is that a
// drop moves to the nearest clear channel when the default x would cross a bar, and
// the hook's middle run is snapped to a row boundary instead of landing wherever the
// midpoint of the two rows happened to fall.

// A milestone's diamond as it is drawn, in the coordinates the arrows use, or null when it
// has no box (the lines style draws no diamond; a collapsed row has none). It is a rotated
// square, so its box is not the box CSS laid out: the 45 degree turn happens about the
// centre and the translateX(-50%) rides along with it. Its points are `half` either side
// of the centre.
function msDiamondBox(r){
  const bd=r&&r._bDiv, d=r&&r._msEl;
  if(!bd||!bd.offsetHeight||!d||!d.offsetWidth)return null;
  const w=d.offsetWidth,h=d.offsetHeight,off=w*Math.SQRT1_2/2,half=Math.hypot(w,h)/2;
  return {cx:d.offsetLeft+w/2-off, cy:bd.offsetTop+d.offsetTop+h/2-off, half};
}
// Where an arrow meets a row, now that a milestone is an end of one too (0.78.0), in the
// layer's coordinates, or null when the row is not on screen to be pointed at.
//   a task       out of its bar's end and into its start, at the middle of its row
//   a milestone  out of the diamond's right point and into its left one
//   lines style  a milestone has no row there, only its dashed rule down the whole chart,
//                so the arrow meets the RULE at the height of the row at its other end
//                (`rule`, the caller picks the y), and between two milestones it runs
//                from one cap to the next, in the band at the top where the caps are.
function depEnd(r){
  if(!r||r._collapsed)return null;
  if(r.type==='milestone'){
    if(prefs.msStyle==='line'){
      const ln=r._msLine, cap=ln&&ln.querySelector('.ms-cap');
      if(!ln||!ln.isConnected||!cap||!cap.offsetHeight)return null;
      const x=ln.offsetLeft, cl=x+cap.offsetLeft-cap.offsetWidth/2, cy=ln.offsetTop+cap.offsetTop+cap.offsetHeight/2;
      return {rule:x,capIn:[cl,cy],capOut:[cl+cap.offsetWidth,cy]};
    }
    const m=msDiamondBox(r); if(!m)return null;
    return {out:[m.cx+m.half,m.cy],in:[m.cx-m.half,m.cy]};
  }
  const be=r.id&&barEls[r.id]; if(!be)return null;
  const rec=depRowCache&&depRowCache.rows.get(r);
  if(rec&&rec.h){ const y=rec.top+rec.h/2; return {out:[rec.bx1,y],in:[rec.bx0,y]}; }
  const row=sb0(be), b=be.bar, y=row.offsetTop+row.offsetHeight/2;
  return {out:[b.offsetLeft+b.offsetWidth,y],in:[b.offsetLeft,y]};
}
// The two ends of one arrow, from what comes first (s) to what waits on it (t).
function depEndsOf(s,t){
  if(s.rule!==undefined&&t.rule!==undefined)return [s.capOut,t.capIn];
  if(s.rule!==undefined)return [[s.rule,t.in[1]],t.in];
  if(t.rule!==undefined)return [s.out,[t.rule,s.out[1]]];
  return [s.out,t.in];
}
// Every bar body on screen, as a rectangle in the coordinates the arrows are drawn in:
// x off the bar's own offsets, y off its ROW's, which is what the endpoints below are
// already measured with. A collapsed or hidden row has no box and is no obstacle; a
// dimmed one is still on screen, so it still is.
function depRects(){
  const obs=[], lanes=[]; let gap=Infinity;
  // Each row measured once into a record the arrows read their ends from too (0.78.1). A
  // merge that only reorders rows re-measures only the rows between the first and last place
  // that changed (depSpan): every row outside them has the same rows above it, so it stands
  // where it stood. The rest of the chart's geometry is checked unchanged first (depGeoSig).
  const sig=depGeoSig(), reuse=orderOnlyMerge&&depSpan&&depRowCache&&depRowCache.sig===sig;
  const next=new Map();
  items.forEach((r,i)=>{
    let rec=reuse&&(i<depSpan[0]||i>depSpan[1])?depRowCache.rows.get(r):undefined;
    if(rec===undefined)rec=depRowRec(r);
    next.set(r,rec);
    if(!rec||!rec.h)return;
    // The boundary below this row, which is where a horizontal run is free.
    lanes.push(rec.top+rec.h);
    gap=Math.min(gap,rec.gap);
    if(rec.obs)obs.push(rec.obs);
  });
  depRowCache={sig,rows:next}; depSpan=null;
  // Never claim more clearance than the layout has. The row height and the bar height
  // both scale with the text size, and so does the band between them.
  return {obs,lanes,pad:Math.max(0.5,Math.min(DEP_PAD,gap===Infinity?DEP_PAD:gap*0.4))};
}
// One row as the routing sees it: where it is, how tall, its bar's two ends, and the box its
// bar or diamond takes as an obstacle (none for a row that is not on screen).
let depRowCache=null, depSpan=null;
function depRowRec(r){
  const bd=r._bDiv; if(!bd)return null;
  const top=bd.offsetTop, h=bd.offsetHeight, rec={top,h,bx0:0,bx1:0,obs:null,gap:Infinity};
  if(r.id&&barEls[r.id]){
    const b=barEls[r.id].bar, bw=b.offsetWidth, bl=b.offsetLeft;
    rec.bx0=bl; rec.bx1=bl+bw;
    if(!h||!bw)return rec;
    const bt=b.offsetTop, bh=b.offsetHeight;
    rec.gap=Math.min(bt,h-bt-bh);
    rec.obs={id:r.id,k:r,x0:bl,x1:bl+bw,y0:top+bt,y1:top+bt+bh};
    return rec;
  }
  if(!h)return rec;
  // A milestone diamond is a rotated square, so its box is not the box CSS laid out:
  // the 45 degree turn happens about the centre and the translateX(-50%) rides along
  // with it. Small, but a route through one still reads as a mistake.
  const d=r._msEl; if(!d||!d.offsetWidth)return rec;
  const w=d.offsetWidth,dh=d.offsetHeight,off=w*Math.SQRT1_2/2,half=Math.hypot(w,dh)/2;
  const cx=d.offsetLeft+w/2-off, cy=top+d.offsetTop+dh/2-off;
  // Its own arrows (0.78.0) leave and arrive at its points, so it carries its id like a bar does.
  rec.obs={id:r.id||null,k:r,x0:cx-half,x1:cx+half,y0:cy-half,y1:cy+half};
  return rec;
}
// Everything besides the order that moves rows or bars: the width, the text size, the milestone
// style, the folded sections, which rows are dimmed, how many rows there are. The kept records
// are used only while it is unchanged.
function depGeoSig(){
  let dim=0; items.forEach((r,i)=>{ if(r._bDiv&&r._bDiv.classList.contains('dim'))dim=(dim*31+i)%1000000007; });
  return [W,prefs.fs||1,document.documentElement.dataset.ms||'',items.length,[...collapsed].sort().join(','),[...collapsedRows].sort().join(','),dim].join('|');
}
// Everything the routing needs, measured once per redraw rather than once per arrow. The
// rows are walked exactly once here: this runs on every mousemove of a bar drag, so the
// DOM is read in one pass and every arrow then works off the numbers.
function depCtx(){
  const m=depRects();
  return {obs:m.obs,pad:m.pad,lanes:m.lanes,xMin:2,xMax:Math.max(2,W-2)};
}
// The route's own two bars are not obstacles at the edges it leaves and arrives on, so
// they are measured without the padding: the exit starts ON the source's right edge and
// the entry ends ON the target's left one. Cross either body anywhere else and it counts.
function depPadOf(o,self,pad){ return self&&o.id&&(o.id===self.from||o.id===self.to)?0:pad; }
// The x spans a vertical run between two y's may not pass through, merged so the search
// walks a handful of intervals rather than every bar.
function depSpans(obs,self,pad,ya,yb){
  const lo=Math.min(ya,yb),hi=Math.max(ya,yb),sp=[];
  for(const o of obs){const q=depPadOf(o,self,pad);
    if(o.y1+q<=lo||o.y0-q>=hi)continue;
    sp.push([o.x0-q,o.x1+q]);}
  sp.sort((a,b)=>a[0]-b[0]);
  const m=[];
  for(const s of sp){const t=m[m.length-1];
    if(t&&s[0]<=t[1]){if(s[1]>t[1])t[1]=s[1];}else m.push([s[0],s[1]]);}
  return m;
}
// The x nearest to `want` inside [lo,hi] that no span covers, or null when the whole
// range is covered. Only the edges of the spans and the two ends are worth trying, and
// a tie goes to the smaller x, so the same layout always routes the same way.
function depChannel(spans,want,lo,hi){
  if(hi<lo)return null;
  const on=v=>spans.some(s=>v>s[0]&&v<s[1]);
  const x=Math.min(Math.max(want,lo),hi);
  if(!on(x))return x;
  let best=null,bd=Infinity;
  const cand=[lo,hi];
  for(const s of spans){cand.push(s[0]-DEP_GAP,s[1]+DEP_GAP);}
  for(const c of cand){
    if(c<lo||c>hi||on(c))continue;
    const d=Math.abs(c-x);
    if(d<bd-1e-9||(best!==null&&d<bd+1e-9&&c<best)){bd=d;best=c;}
  }
  return best;
}
// How much of one segment lies inside a bar. Zero is the whole point of the routing;
// the number itself is only consulted when a plan is dense enough that nothing is
// clear, and then the least-bad route is drawn rather than none at all.
function depHit(obs,self,pad,ax,ay,bx,by){
  const xlo=Math.min(ax,bx),xhi=Math.max(ax,bx),ylo=Math.min(ay,by),yhi=Math.max(ay,by);
  let n=0;
  for(const o of obs){const q=depPadOf(o,self,pad);
    const lx=o.x0-q,hx=o.x1+q,ly=o.y0-q,hy=o.y1+q;
    const ox=Math.min(xhi,hx)-Math.max(xlo,lx), oy=Math.min(yhi,hy)-Math.max(ylo,ly);
    // A run with no width is still inside a bar when its x is. The overlap of two
    // intervals, one of which is a single point, is never POSITIVE, so an axis the
    // segment does not extend along has to be asked the other question.
    const onX=xhi>xlo?ox>0:(xlo>lx&&xlo<hx);
    const onY=yhi>ylo?oy>0:(ylo>ly&&ylo<hy);
    if(onX&&onY)n+=Math.max(ox,oy,0.5);}
  return n;
}
// The corner points of the route, in order. The first candidate tried is the 0.38.0
// one, so an arrow with nothing in its way is drawn exactly where this app has drawn
// it since 0.38.0 and a redraw that changes nothing changes nothing.
function depPts(x1,y1,x2,y2,ctx,self){
  const xa=x1+DEP_STUB, xb=x2-DEP_STUB, pad=ctx.pad;
  // Both ends on one row, running backwards: a milestone's rule in the lines style meets an
  // arrow at the height of its other end (0.78.0), and a task that starts before the gate it
  // waits on has its start on the wrong side of the rule. No lane lies between the two ends
  // to hook through, so the hook goes through the boundary below the row.
  if(Math.abs(y2-y1)<0.5&&xb<xa){
    let yb=Infinity; for(const y of ctx.lanes)if(y>y1+0.5&&y<yb)yb=y;
    if(yb!==Infinity){ const back=Math.min(xb,xa-DEP_STUB); return [[x1,y1],[xa,y1],[xa,yb],[back,yb],[back,y2],[x2,y2]]; }
  }
  const lo=Math.min(y1,y2),hi=Math.max(y1,y2);
  // Every corner of every route below sits at y1, y2, or a lane between them, so a bar
  // outside that band cannot be in the way of any of them. Throwing those away first is
  // what keeps this affordable on a plan of a hundred and sixty rows: an arrow between
  // two neighbouring rows then measures itself against two bars rather than all of them.
  const obs=ctx.obs.filter(o=>o.y1+pad>lo&&o.y0-pad<hi);
  const want=Math.min(Math.max(x1+(x2-x1)*0.5,xa),xb);
  const tried=[];
  const cost=pts=>{let n=0;for(let i=1;i<pts.length;i++)n+=depHit(obs,self,pad,pts[i-1][0],pts[i-1][1],pts[i][0],pts[i][1]);return n;};
  const len=pts=>{let n=0;for(let i=1;i<pts.length;i++)n+=Math.abs(pts[i][0]-pts[i-1][0])+Math.abs(pts[i][1]-pts[i-1][1]);return n;};
  const keep=pts=>{const c=cost(pts);tried.push({pts,c,l:len(pts)});return c===0;};
  if(xb>=xa){
    // A Z that crosses nothing is not merely good enough, it is the SHORTEST route there
    // is: it never turns back, so it walks the bare distance between the two bars. Both
    // of these therefore end the search, and the first of them is the 0.38.0 route
    // itself, which is why an uncluttered plan looks exactly as it did.
    if(keep([[x1,y1],[want,y1],[want,y2],[x2,y2]]))return tried[0].pts;
    const xd=depChannel(depSpans(obs,self,pad,y1,y2),want,xa,xb);
    if(xd!==null&&keep([[x1,y1],[xd,y1],[xd,y2],[x2,y2]]))return tried[tried.length-1].pts;
  }
  // The middle run goes in a lane BETWEEN the two rows, where no bar can be. The lane
  // nearest the halfway point is tried first because that is where 0.38.0 put it and
  // for two neighbouring rows it is the very same y; the ones hugging the source and
  // the target are the ways out when the drop to it has nowhere clear to fall.
  const lanes=ctx.lanes.filter(y=>y>lo+0.5&&y<hi-0.5);
  const picks=[];
  if(lanes.length){
    const mid=(y1+y2)/2;
    let near=lanes[0];
    for(const y of lanes)if(Math.abs(y-mid)<Math.abs(near-mid))near=y;
    for(const y of [near,y2>y1?lanes[0]:lanes[lanes.length-1],y2>y1?lanes[lanes.length-1]:lanes[0]])
      if(!picks.includes(y))picks.push(y);
  }
  const S=(ym,xE,xM)=>{if(xE!==null&&xM!==null)keep([[x1,y1],[xE,y1],[xE,ym],[xM,ym],[xM,y2],[x2,y2]]);};
  for(const ym of picks){
    // The two halves of the drop, measured once and used by both shapes below.
    const st=depSpans(obs,self,pad,y1,ym), sb=depSpans(obs,self,pad,ym,y2);
    // Forwards: out, down to the lane, along it, down again, in. Both drops stay in the
    // gap between the two bars, so this is the Z with its drop broken in two.
    if(xb>=xa){
      const xE=depChannel(st,xa,xa,xb);
      S(ym,xE,xE===null?null:depChannel(sb,xb,xE,xb));
    }
    // The hook. Its exit may travel further RIGHT than the target's start and its return
    // further LEFT than the source's end, which is the only way round a bar that lies
    // across the whole span between the two rows. It is the shape this app has drawn
    // since 0.38.0 whenever a target starts before its source ends, so nothing new
    // appears on screen here; it is only offered to more pairs than it used to be.
    const xE=depChannel(st,xa,xa,Math.max(xa,ctx.xMax));
    const cap=Math.min(xb,(xE===null?xa:xE)-DEP_STUB);
    S(ym,xE,xE===null?null:depChannel(sb,cap,Math.min(ctx.xMin,cap),cap));
  }
  // The shortest of the routes that cross nothing, and if none of them does, the
  // least-bad by how much bar it crosses, so a hopelessly dense plan still gets a short
  // honest line rather than a loop around the whole chart. If no shape produced a
  // candidate at all, the 0.38.0 route, which always produces one.
  if(!tried.length){
    const back=Math.min(xb,xa-DEP_STUB), ym=(y1+y2)/2;
    return xb>=xa?[[x1,y1],[want,y1],[want,y2],[x2,y2]]
                 :[[x1,y1],[xa,y1],[xa,ym],[back,ym],[back,y2],[x2,y2]];
  }
  tried.sort((a,b)=>a.c-b.c||a.l-b.l);
  return tried[0].pts;
}
function depPath(x1,y1,x2,y2,ctx,self){
  return roundedPath(depPts(x1,y1,x2,y2,ctx||depCtx(),self),DEP_R);
}
// ── Arrow colour ───────────────────────────────────────────────────────────
//
// An arrow is painted in the SOURCE row's phase colour, so where a chain comes FROM
// is readable without tracing it back. The tail and not the head, deliberately: an
// arrow here answers "what is this waiting on", and the answer is the thing at the
// tail. Every arrow used to be the same teal, which said only "there is a dependency"
// and made a plan with forty of them a thicket.
//
// A phase colour was chosen to be a 20px-tall FILL. A 1.5px stroke is a different job
// with about a twentieth of the area, so a colour that reads perfectly as a bar can
// disappear as a line. The three ramp colours are safe by construction, because they
// are picked against the page in each theme. An EXPLICIT lane colour is not: it is
// theme-invariant by design (0.30.0), so somebody who paints a lane navy has painted
// an invisible arrow on the dark theme without meaning to, and somebody who paints one
// pale yellow has done the same on paper.
//
// So the stroke is DERIVED and never merely used. The formula, stated once and
// implemented immediately below:
//
//   1. resolve the phase colour to r,g,b, through the same cssRgbTriple the hover wash
//      uses, so a var(--phN) and an explicit hex both come out as three numbers
//   2. take the WCAG relative luminance of it and of the page, --bg
//   3. if their contrast ratio is at or above FLOOR the colour is used unchanged
//   4. otherwise hold HUE and SATURATION and walk LIGHTNESS away from the page in 2%
//      steps, re-measuring each step, until the ratio clears the floor or lightness
//      runs out of room
//
// FLOOR is 3.0, the WCAG 2.1 minimum for a graphical object, which is what a line is.
// Text would be 4.5 and would push half the ramp to pastel for no gain; the arrow only
// has to be findable, and the name beside it is what carries the meaning.
//
// Holding hue is what makes step 4 a repair rather than a substitution. A navy lane
// gets a lighter blue arrow that is still recognisably that lane, instead of falling
// back to the one teal that used to paint every arrow and told you nothing. Walking
// AWAY from the page means the direction is decided by the theme rather than fixed:
// the same navy is lifted on the dark theme and left alone on paper.
const DEP_STROKE_FLOOR=3.0;
const depStrokeMemo=new Map();
function srgbLin(c){ c/=255; return c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4); }
function relLum(t){ return 0.2126*srgbLin(t[0])+0.7152*srgbLin(t[1])+0.0722*srgbLin(t[2]); }
function contrastOf(a,b){ const hi=Math.max(a,b),lo=Math.min(a,b); return (hi+0.05)/(lo+0.05); }
function rgbToHsl(r,g,b){
  r/=255;g/=255;b/=255;
  const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn,l=(mx+mn)/2;
  if(!d)return [0,0,l];
  const s=l>0.5?d/(2-mx-mn):d/(mx+mn);
  let h;
  if(mx===r)h=((g-b)/d+(g<b?6:0));
  else if(mx===g)h=(b-r)/d+2;
  else h=(r-g)/d+4;
  return [h/6,s,l];
}
function hslToRgb(h,s,l){
  if(!s){const v=Math.round(l*255);return [v,v,v];}
  const q=l<0.5?l*(1+s):l+s-l*s, p=2*l-q;
  const f=t=>{ t=(t+1)%1;
    if(t<1/6)return p+(q-p)*6*t;
    if(t<1/2)return q;
    if(t<2/3)return p+(q-p)*(2/3-t)*6;
    return p; };
  return [Math.round(f(h+1/3)*255),Math.round(f(h)*255),Math.round(f(h-1/3)*255)];
}
function tripleOf(css){ const s=cssRgbTriple(css); return s?s.split(' ').map(Number):null; }
function depStroke(css){
  const key=(document.documentElement.getAttribute('data-theme')||'')+'|'+String(css||'');
  if(depStrokeMemo.has(key))return depStrokeMemo.get(key);
  let out=null;
  const t=tripleOf(css), bg=tripleOf('var(--bg)');
  if(t&&bg)out='rgb('+contrastWalk(t,bg,DEP_STROKE_FLOOR).join(',')+')';
  depStrokeMemo.set(key,out);
  return out;
}
// Steps 3 and 4 of the formula above, on its own so a tag chip (0.61.0) walks a colour
// exactly the way an arrow does: hue and saturation held, lightness walked away from
// `bg` in 2% steps until the ratio clears `floor`.
function contrastWalk(t,bg,floor){
  const lb=relLum(bg);
  if(contrastOf(relLum(t),lb)>=floor)return t;
  // Away from the page: on a dark page lightness goes up, on a light one it goes
  // down. A step of 2% is fine enough that the lift is never more than it has to
  // be and coarse enough that the walk is at most fifty iterations, once, memoised.
  const [h,s,l0]=rgbToHsl(t[0],t[1],t[2]);
  const up=lb<0.5, step=up?0.02:-0.02;
  let best=t;
  for(let l=l0+step; up?l<=1.0001:l>=-0.0001; l+=step){
    const c=hslToRgb(h,s,Math.max(0,Math.min(1,l)));
    best=c;
    if(contrastOf(relLum(c),lb)>=floor)break;
  }
  return best;
}
// ── A tag's colour on its chip (0.61.0) ──
//
// A tag with a colour is drawn in it wherever it is a chip or a pill: a low-alpha FILL of
// the colour itself, and the text and border in its INK, which is the colour walked by
// contrastWalk until it clears 3:1 (the arrows' floor) against what the text actually sits
// on, the fill over the page. So a pale amber reads on paper and a deep indigo reads on the
// dark theme, and the hue is still the one that was picked. Measured per theme and memoised
// like the arrows; the theme button repaints every coloured chip (repaintTagInks). A tag
// with no colour carries no data-tc and looks exactly as it did before colours existed.
const TAG_FILL_ALPHA=0.14, TAG_INK_FLOOR=3.0;
const tagInkMemo=new Map();
function tagPaint(hex){
  const key=(document.documentElement.getAttribute('data-theme')||'')+'|'+hex;
  if(tagInkMemo.has(key))return tagInkMemo.get(key);
  let out=null;
  const t=tripleOf(hex), bg=tripleOf('var(--bg)');
  if(t&&bg){
    const under=bg.map((b,i)=>Math.round(b+(t[i]-b)*TAG_FILL_ALPHA));
    out={ink:'rgb('+contrastWalk(t,under,TAG_INK_FLOOR).join(',')+')',fill:'rgba('+t.join(',')+','+TAG_FILL_ALPHA+')'};
  }
  tagInkMemo.set(key,out);
  return out;
}
function applyTagPaint(el,hex){
  const p=hex&&tagPaint(hex);
  if(!p){ delete el.dataset.tc; el.style.removeProperty('--tag-ink'); el.style.removeProperty('--tag-fill'); return; }
  el.dataset.tc=hex; el.style.setProperty('--tag-ink',p.ink); el.style.setProperty('--tag-fill',p.fill);
}
// For DOM built node by node, and for markup built as a string (the attributes to splice in).
function paintTag(el,t){ applyTagPaint(el,tagColor(t)); }
function tagAttrs(t){
  const c=tagColor(t), p=c&&tagPaint(c);
  return p?` data-tc="${c}" style="--tag-ink:${p.ink};--tag-fill:${p.fill}"`:'';
}
function repaintTagInks(){ document.querySelectorAll('[data-tc]').forEach(el=>applyTagPaint(el,el.dataset.tc)); }
// The colour an arrow out of this row should be. A row with no phase (which nothing
// arrow-bearing has today) falls back to nothing, and nothing means the CSS default,
// which is still the accent.
function depStrokeFor(r){ return r&&r.phase!==undefined?depStroke(phaseColor(r.phase)):null; }
function paintDepEdge(el,stroke){
  // A custom property and NOT a style.stroke. An inline stroke would beat every class
  // on the element, and the three states that override the base colour are all
  // classes: the picked arrow's amber, the armed-delete red and the critical path's
  // white. Setting the property leaves the base rule to read it and leaves each of
  // those rules to win over the base rule exactly as it did before, so the precedence
  // this round inherits from 0.39.0, 0.42.0 and 0.49.0 is untouched.
  if(stroke)el.style.setProperty('--dep-stroke',stroke);
  else el.style.removeProperty('--dep-stroke');
}
// Repaint the arrows ALREADY on screen from the phases as they stand now. drawDeps
// paints on the way past, but recolouring a lane and moving a row into another lane
// both change an arrow's colour without moving a single pixel of geometry, so there
// would be nothing to trigger a redraw and the arrow would keep the old lane's colour
// until the next resize. This is a walk of the paths already in the layer setting one
// custom property each, which is cheap enough to hang off every one of those paths.
function recolorDepArrows(){
  if(!depSvg)return;
  const byId={};
  items.forEach(r=>{ if(r.id)byId[r.id]=r; });
  depSvg.querySelectorAll('[data-from]').forEach(el=>{
    paintDepEdge(el,depStrokeFor(byId[el.getAttribute('data-from')]));
  });
}
// Which arrows point at, or come out of, cancelled work. A full walk of the layer
// rather than a toggle driven from the row that changed, and for the same reason
// recolorDepArrows is: the answer is a question about BOTH ends of each edge, so
// un-cancelling one row must not brighten an arrow whose other end is still cancelled.
// One class, so the stylesheet holds the whole treatment and this holds none of it.
function paintCancelledDeps(){
  if(!depSvg)return;
  const byId={};
  items.forEach(r=>{ if(r.id)byId[r.id]=r; });
  const dead=id=>{const r=byId[id];return !!(r&&isCancelled(r));};
  depSvg.querySelectorAll('[data-from]').forEach(el=>{
    el.classList.toggle('cancelled',dead(el.getAttribute('data-from'))||dead(el.getAttribute('data-to')));
  });
}
// Many geometry changes at once (a live merge, startup) draw the arrows once, at the end,
// instead of once per change (0.75.1).
let depDefer=0, depRedrawOwed=false;
function withDeps(fn){
  depDefer++;
  try{ return fn(); }
  finally{ depDefer--; if(!depDefer&&depRedrawOwed){ depRedrawOwed=false; drawDeps(); } }
}
// The chart's geometry while another lens is on screen (0.75.1): there is nothing to measure
// there (the panes are hidden), so the work is owed, and the switch back pays it once.
let timelineStale=false;
// The arrows on screen, by the dependency each one draws (0.78.1), so a redraw reuses them.
const depEls=new Map();
// What the last redraw routed around: every obstacle by its row, the lanes, the clearance.
let depLast=null;
function drawDeps(){
  if(depDefer){ depRedrawOwed=true; return; }
  if(prefs.view!=='gantt'){ timelineStale=true; return; }
  // The layer lives inside the bars, so bars.scrollHeight below counts the layer's own
  // old height: it could grow and never shrink, and after a filter the chart stayed as
  // tall as the whole plan while the names shrank to the rows on screen (0.74.1).
  // Not in an order-only merge (0.78.1): the rows only trade places, so the chart's height
  // stands, and shrinking the layer to measure it would lay the chart out twice more.
  const keepH=orderOnlyMerge;
  if(!keepH)depSvg.setAttribute('height','1');
  // Every measurement first, then every node, built off the page and added at once
  // (0.75.1): the old loop read a bar's position right after writing the previous arrow,
  // which forced a fresh layout per read, hundreds per redraw.
  const ctx=depCtx(), edges=[];
  // Either end may be a task or a milestone (0.78.0), so the rows are found by id here
  // rather than through the bars, which only tasks have.
  const rowById=new Map(); items.forEach(x=>{ if(x.id&&x.type!=='group'&&!rowById.has(x.id))rowById.set(x.id,x); });
  const ends=new Map(), endOf=x=>{ if(!ends.has(x))ends.set(x,depEnd(x)); return ends.get(x); };
  items.forEach(r=>{if(!r.deps||!r.deps.length||!r.id||r.type==='group'||!r._bDiv)return;
    r.deps.forEach(depId=>{const sItem=rowById.get(depId);
      if(!sItem||sItem===r||!sItem._bDiv)return;
      if(r._bDiv.classList.contains('dim')||sItem._bDiv.classList.contains('dim'))return;
      // a collapsed row has no position on screen, so an arrow to it would be
      // drawn from nowhere to nowhere
      if(r._collapsed||sItem._collapsed)return;
      // Measure the row rather than assuming 17, which was half of a 34px row and
      // put every arrow in the wrong place the moment the text scale moved it. A task's
      // end comes from depRects' record of its row (0.78.1), read once per redraw, not per arrow.
      const S=endOf(sItem),T=endOf(r);
      if(!S||!T)return;
      const [[x1,y1],[x2,y2]]=depEndsOf(S,T);
      edges.push({depId,to:r.id,sItem,x1,y1,x2,y2});
    });});
  // Each arrow is one path and one head, kept from the last redraw while its dependency is
  // still drawn: only a route, a head or a colour that changed is written (0.78.1). Every
  // redraw used to throw the layer away and build all ~500 nodes again, on every open page,
  // for every row somebody moved.
  // A route depends on its two ends and on the obstacles and lanes in its own band, nothing
  // else (depPts). So it is worked out again only when an end moved or an obstacle that
  // changed since the last redraw, where it was or where it is, falls in that band by the
  // same test depPts uses; any change to the lanes, the clearance or the width reroutes
  // all of them (0.78.1). Every arrow re-searched every route on each redraw before.
  const lanesSig=ctx.lanes.join(','), prevD=depLast, moved=[], movedNow=[];
  let rerouteAll=!prevD||prevD.pad!==ctx.pad||prevD.xMax!==ctx.xMax||prevD.lanes!==lanesSig;
  const obsNow=new Map(); ctx.obs.forEach(o=>obsNow.set(o.k,o));
  if(!rerouteAll){
    prevD.obs.forEach((o,k)=>{ const n=obsNow.get(k);
      if(!n||n.x0!==o.x0||n.x1!==o.x1||n.y0!==o.y0||n.y1!==o.y1){ moved.push(o); if(n){ moved.push(n); movedNow.push(n); } } });
    obsNow.forEach((n,k)=>{ if(!prevD.obs.has(k)){ moved.push(n); movedNow.push(n); } });
  }
  depLast={obs:obsNow,pad:ctx.pad,xMax:ctx.xMax,lanes:lanesSig};
  const reaches=(lo,hi)=>moved.some(o=>o.y1+ctx.pad>lo&&o.y0-ctx.pad<hi);
  const keepK=new Set(), want=[]; let maxY=0, madeNew=false;
  edges.forEach(({depId,to,sItem,x1,y1,x2,y2})=>{
      // Which dependency this route IS, carried on the drawn elements. Attributes
      // rather than extra nodes: the layer is redrawn wholesale on every geometry
      // change, so a pick has to be re-applied from state afterwards, and one path
      // plus one head per edge is what every other reader of this layer counts on.
      // The SOURCE row's phase, derived once per edge and put on both halves of it, so
      // the line and the head it ends in are one colour and cannot drift apart.
      const stroke=depStrokeFor(sItem);
      let k=depId+'>'+to; while(keepK.has(k))k+='+'; keepK.add(k);
      let e=depEls.get(k);
      if(!e){ madeNew=true;
        const p=document.createElementNS('http://www.w3.org/2000/svg','path'), a=document.createElementNS('http://www.w3.org/2000/svg','polygon');
        p.setAttribute('data-from',depId);p.setAttribute('data-to',to);a.setAttribute('data-from',depId);a.setAttribute('data-to',to);
        e={p,a,d:null,pts:null,stroke:undefined}; depEls.set(k,e);
      }
      // A route that was the plain Z (the first shape depPts tries, taken whenever it crosses
      // nothing) is still the one depPts would draw when no bar that moved now lies across it:
      // taking bars away cannot block it, and nothing else about it changed.
      const self={from:depId,to};
      let fresh=rerouteAll||e.d===null||e.x1!==x1||e.y1!==y1||e.x2!==x2||e.y2!==y2;
      if(!fresh&&reaches(Math.min(y1,y2),Math.max(y1,y2))){
        const zx=e.zx;
        fresh=zx===undefined||movedNow.some(o=>depHit([o],self,ctx.pad,x1,y1,zx,y1)||depHit([o],self,ctx.pad,zx,y1,zx,y2)||depHit([o],self,ctx.pad,zx,y2,x2,y2));
      }
      let d=e.d;
      if(fresh){
        const pts=depPts(x1,y1,x2,y2,ctx,self); d=roundedPath(pts,DEP_R);
        // Remember whether it came out as the plain Z, and where its drop runs.
        const xa=x1+DEP_STUB, xb=x2-DEP_STUB, want=Math.min(Math.max(x1+(x2-x1)*0.5,xa),xb);
        e.zx=xb>=xa&&pts.length===4&&pts[0][0]===x1&&pts[0][1]===y1&&pts[1][0]===want&&pts[1][1]===y1&&pts[2][0]===want&&pts[2][1]===y2&&pts[3][0]===x2&&pts[3][1]===y2?want:undefined;
      }
      const pts=`${x2},${y2} ${x2-6},${y2-3} ${x2-6},${y2+3}`;
      e.x1=x1; e.y1=y1; e.x2=x2; e.y2=y2;
      if(e.d!==d){ e.p.setAttribute('d',d); e.d=d; }
      if(e.pts!==pts){ e.a.setAttribute('points',pts); e.pts=pts; }
      if(e.stroke!==stroke){ paintDepEdge(e.p,stroke); paintDepEdge(e.a,stroke); e.stroke=stroke; }
      want.push(e.p,e.a);
      maxY=Math.max(maxY,y1,y2);});
  depEls.forEach((e,k)=>{ if(!keepK.has(k))depEls.delete(k); });
  // Anything else in the layer goes, as the wholesale redraw always did, and the arrows
  // stand in edge order, moving only the ones out of place.
  const wantSet=new Set(want);
  [...depSvg.childNodes].forEach(n=>{ if(!wantSet.has(n))n.remove(); });
  placeInOrder(depSvg,want,()=>true);
  if(!keepH)depSvg.setAttribute('height',Math.max(maxY+40,bars.scrollHeight));
  // The pick, the armed state, the critical path and the cancelled dim are put back from the
  // state that outlives the DOM. An order-only merge changes none of them and makes no new
  // arrow, and the arrows it kept still carry theirs (0.78.1).
  if(keepH&&!madeNew)return;
  paintDepSel();
  paintCritDeps();
  paintCancelledDeps();
}
function wireDepRedraw(){
setTimeout(drawDeps,100);new ResizeObserver(drawDeps).observe(bars);
}

// ── Selection (Ctrl+click toggle, Shift+click range, click single) ──
let selAnchor = null;
// The selection holds UIDS, not task ids, and that is what lets a milestone into
// it. Milestones were excluded for one reason only: they had no id, so there was
// nothing to put in the set. They have one now.
//
// Groups stay out. A group is a lane header rather than a row of work, it has no
// dates and no owner, and every operation the selection offers (copy the ids,
// delete, move as a block) means something different for a lane than for the
// rows inside it.
function selUid(r){ return r&&r.type!=='group'?r.uid:null; }
// The rows a Shift+click range walks, in plan order: the ones ON SCREEN, the way a file
// manager and the Table's own Shift+click take a range. A row folded away in a collapsed
// section or hidden by a filter is not between two rows anyone can see, and taking it
// silently would hand it to Delete twice, Alt+Arrow and the batch panel unseen. The two
// ends of the range are counted whatever they are.
function selKeys(...ends){return items.filter(r=>selUid(r)&&(!r._collapsed||ends.includes(r.uid))).map(r=>r.uid);}
function rowByUid(u){return items.find(r=>r.uid===u)||null;}
function clearSelection(){selected.clear();refreshSelVisuals();updateSelCount();}
function refreshSelVisuals(){
  document.querySelectorAll('.bar.selected').forEach(b=>b.classList.remove('selected'));
  document.querySelectorAll('.label-row.multi-sel').forEach(l=>l.classList.remove('multi-sel'));
  // A task gets the outline on its bar AND the wash on its label row; a milestone
  // has no bar to outline, so the label row is the whole mark. Both lenses agree,
  // because both are reading the same set.
  selected.forEach(u=>{
    const r=rowByUid(u); if(!r)return;
    if(r.id&&barEls[r.id])barEls[r.id].bar.classList.add('selected');
    if(r._lDiv)r._lDiv.classList.add('multi-sel');
  });
  markSelectedRows();
}
function doSelect(uid, e){
  if(!uid)return;
  const ctrl=e.ctrlKey||e.metaKey, shift=e.shiftKey;
  if(shift&&selAnchor){
    const ids=selKeys(selAnchor,uid), ai=ids.indexOf(selAnchor), ci=ids.indexOf(uid);
    if(ai>=0&&ci>=0){
      const lo=Math.min(ai,ci), hi=Math.max(ai,ci);
      if(!ctrl)selected.clear();
      for(let i=lo;i<=hi;i++)selected.add(ids[i]);
    }
  } else if(ctrl){
    if(selected.has(uid))selected.delete(uid); else selected.add(uid);
    selAnchor=uid;
  } else {
    const was=selected.has(uid)&&selected.size===1;
    selected.clear();
    if(!was){selected.add(uid);selAnchor=uid;}else{selAnchor=null;}
  }
  refreshSelVisuals();updateSelCount();
}
// Make this row the selection, full stop. doSelect is the CLICK path and a click
// toggles, which is right for a pointer and wrong here: after committing a rename the
// promise is "this row is selected", not "this row's selection was flipped", and a row
// that happened to be the sole selection already would have been dropped. Groups have
// no uid to select, so this is inert on a lane header.
//
// The table's focused row is set to the same row on the way through, so the two lenses
// agree about what is current and the arrow keys pick up from the row you just renamed
// rather than from wherever the focus happened to be left.
// This row IS the selection, full stop, with nothing else touched. selectOnly is this
// plus the table's focused row; the chart's click path has never moved the table's
// cursor and a bug fix is not the place to start, so it takes the half it needs.
function selectSole(r){
  const u=selUid(r); if(!u)return;
  selected.clear(); selected.add(u); selAnchor=u;
  refreshSelVisuals(); updateSelCount();
}
function selectOnly(r){
  const u=selUid(r); if(!u)return;
  selectSole(r);
  if(typeof setFocusedRow==='function'){
    const k=rowKey(r);
    if(k)setFocusedRow(k);
  }
}
// ── The row handle ──
//
// Drew: there was nowhere to click that just selects a row. In the chart a click on the
// name renames it, each pill opens its picker, the link button copies and the bar opens
// the item editor; in the Table every cell opens its editor. The one quiet spot was the
// gap after the pills, and a long name leaves no gap. So every task and milestone row
// has a handle on its left edge (styles at .row-sel), and a click on it SELECTS, by the
// rules of a spreadsheet's row header and a file manager:
//
//   click           this row, and only this row. Never a toggle: clicking a selected row
//                   keeps it, so a double-click cannot undo itself
//   Ctrl/Cmd+click  adds the row to the set, or takes it out
//   Shift+click     every row on screen from the anchor to this one
//
// The same selection as everywhere else, so Delete twice, Ctrl+C and V, Alt+Arrow and
// Copy IDs act on it, and Escape clears it. It opens NOTHING: Ctrl and Shift do not open
// the batch panel the way they do on a bar. What a click anywhere else would close, it
// closes: a rename or a cell being edited commits, a picker lets go, and a panel that was
// open (about the old selection) goes.
//
// Its mousedown is prevented (with the drag arms), so the focus never lands on the handle
// and the keyboard is the page's on the very next key: Delete, Tab, Enter, the arrows.
// That is also why the field that had the focus is let go of here, by hand. In the chart
// the plain click is selectOnly, so the Table's cursor comes along and the two lenses
// agree about the current row, as they do after a rename.
// The dots are drawn by CSS (.row-sel::before), so the markup is one empty button.
const ROW_SEL_HTML='<button type="button" class="row-sel" tabindex="-1" aria-label="Select this row" title="Select this row. Ctrl+click adds or removes it, Shift+click selects the rows in between. Drag to move it."></button>';
function makeSelBtn(){ const t=document.createElement('template'); t.innerHTML=ROW_SEL_HTML; return t.content.firstElementChild; }
function selectFromHandle(r,e,lens){
  if(!selUid(r))return;
  const k=rowKey(r);
  closeLabelEditors(true);
  const ae=document.activeElement;
  if(ae&&ae!==document.body&&ae!==document.documentElement&&typeof ae.blur==='function'){ try{ ae.blur(); }catch(_){} }
  closeMenus(); closeEditor(); hideTip();
  if(lens==='table'){ tableSelect(k,e); return; }
  if(e.ctrlKey||e.metaKey||e.shiftKey)doSelect(r.uid,e);
  else selectOnly(r);
}
// The rows the selection names, in plan order, which is the order every operation
// on a set walks them in.
function selectedRows(){return items.filter(r=>r.uid&&selected.has(r.uid));}
// What gets copied: the uid, a tab, then the name. The uid is the durable handle,
// the one string about a row that nothing can change, so it is what makes an AI
// batch edit land on the right row a week and three renames later; the name rides
// along on the same line so the paste is readable by the human who made it, and a
// tab means it drops into a spreadsheet as two columns. One row per line.
//
// The fallback chain exists for a plan that has not been through the uid
// assignment yet: a server started on this build stamps every row before it
// serves a request, but a snapshot taken from an older build has no uids in it at
// all, and handing out nothing at all would be worse than handing out the old id.
function selectedIdText(){
  return selectedRows()
    .map(r=>(r.uid||r.id||rowOrderKey(r))+'\t'+(r.name||''))
    .join('\n');
}
function showIdFallback(txt){
  const old=document.getElementById('idBox'); if(old)old.remove();
  const ta=document.createElement('textarea');
  ta.id='idBox'; ta.className='id-box'; ta.readOnly=true; ta.value=txt;
  document.body.appendChild(ta); ta.focus(); ta.select();
  const off=e=>{ if(e.target!==ta){ ta.remove(); document.removeEventListener('mousedown',off,true);
    document.removeEventListener('keydown',esc,true); } };
  const esc=e=>{ if(e.key==='Escape'){ e.stopPropagation(); ta.remove();
    document.removeEventListener('mousedown',off,true); document.removeEventListener('keydown',esc,true); } };
  document.addEventListener('mousedown',off,true);
  document.addEventListener('keydown',esc,true);
}
// The OS clipboard half, on its own so the two callers can differ in the one way
// they need to. `quiet` suppresses BOTH the toast and the on-screen fallback box:
// Ctrl+C now also fills the app's own clipboard (see copySelectedRows below), and a
// fallback textarea popping up and taking the focus would eat the Ctrl+V that is
// about to follow it. The button paths keep the loud version.
function writeIdText(txt,done,quiet){
  const ok=()=>{ if(done)done(); };
  // The clipboard API needs a secure context and the click it was called from. When
  // it is refused, the ids are put on screen in a selectable box rather than lost:
  // a copy that silently does nothing is worse than one that hands you the text.
  try{
    if(navigator.clipboard&&navigator.clipboard.writeText){
      navigator.clipboard.writeText(txt).then(ok).catch(()=>{
        if(legacyCopy(txt))ok(); else if(!quiet)showIdFallback(txt); });
      return;
    }
  }catch(e){}
  if(legacyCopy(txt))ok(); else if(!quiet)showIdFallback(txt);
}
function copySelectedIds(){
  const n=selected.size;
  if(!n){ flash(saveInd,'select some rows first'); return; }
  writeIdText(selectedIdText(),()=>flash(saveInd,'Copied '+n+' item ID'+(n===1?'':'s')),false);
}
function legacyCopy(txt){
  try{
    const ta=document.createElement('textarea');
    ta.value=txt; ta.style.cssText='position:fixed;top:-1000px;left:-1000px;opacity:0';
    document.body.appendChild(ta); ta.focus(); ta.select();
    const ok=document.execCommand&&document.execCommand('copy');
    ta.remove(); return !!ok;
  }catch(e){ return false; }
}
function syncCopyBtn(){
  const b=$('btnCopyIds');
  if(b)b.style.display=selected.size?'':'none';
}
function updateSelCount(){
  syncCopyBtn();
  scheduleFocus();
  if(selected.size){
    selCountEl.innerHTML=`<span class="n">${selected.size}</span> selected &middot; Copy IDs`;
    selCountEl.title="Copy the selected items' permanent IDs, one per line with the name, to paste into an AI chat for batch edits";
    selCountEl.classList.add('show');
  } else selCountEl.classList.remove('show');
}

// ── Copy and paste rows ──
//
// Drew duplicates chains: the same five-step crate build, once per workcell, renamed
// afterwards. Making those by hand is five trips through the create panel and then
// four dependency links drawn by eye, per copy. Ctrl+C, click, Ctrl+V is the whole
// job, and the LINKS COME WITH IT, which is the part that makes it worth having.
//
// AN IN-APP CLIPBOARD, not the OS one. Reading rows back out of the system clipboard
// would mean inventing a serialisation, guarding against pasting a plan into itself
// from another tab at a different version, and asking for a permission the browser
// only grants on a gesture it likes. None of that buys anything here: the rows never
// leave the page. So the clipboard is a variable. Ctrl+C still ALSO writes the id
// text to the OS clipboard, exactly as it has since 0.44.0, because that is the
// hand-off to an AI chat and nothing about paste replaces it.
//
// SNAPSHOTTED AT COPY TIME. A clip holds values, not references: editing an original
// after copying it does not change what comes out of the paste, and deleting the
// original does not empty the clipboard. That is what "clipboard" means everywhere
// else, and it is the only rule that survives the original being deleted.
let clipRows=[];
function clipOf(r){
  return {type:r.type==='milestone'?'milestone':'task',
    name:r.name||'', srcId:r.id||'',
    start:r.start, end:r.end, date:r.date,
    owner:r.owner, status:r.status, description:r.description, note:r.note,
    // The row's OWN tags travel with it (0.60.0). Its section's do not need to: a copy
    // pasted into another section takes that section's, which is what "effective" means.
    tags:ownTags(r).slice(), effort:effortOf(r),
    deps:Array.isArray(r.deps)?r.deps.slice():[],
    // Where it sat (0.79.0): a subtask copied with its task is pasted under the task's copy.
    srcUid:r.uid||'', srcParent:treeParent(r)};
}
// Ctrl+C. The selection is already the app's one selection model, so this works the
// same from either lens and with either kind of row in it.
function copySelectedRows(){
  const rows=selectedRows();
  if(!rows.length){ flash(saveInd,'select some rows first'); return; }
  clipRows=rows.map(clipOf);
  // Silently, and with the fallback box suppressed: the toast below is the feedback,
  // and a textarea that grabs the focus would break the paste that follows.
  writeIdText(selectedIdText(),null,true);
  flash(saveInd,'Copied '+rows.length+' task'+(rows.length===1?'':'s'));
}
// ── Where a paste lands ──
//
// TWO RULES, and which one applies is decided by whether anything is selected.
//
// 1. SOMETHING IS SELECTED: directly below it. With a set selected, below the LAST
//    of them in plan order, which is the bottom of the block the user is looking at.
//    The phase is that row's phase, so pasting under a row in another lane moves the
//    copies into that lane and recolours them.
//
// 2. NOTHING IS SELECTED: the end of the ACTIVE phase, meaning the one most in view.
//    Each lens answers "most in view" with the thing it already computes:
//
//    * gantt: the PINNED lane. `pinnedGroup` is the group header the sticky-header
//      logic currently has stuck under the axis, which is by construction the lane
//      whose rows fill the top of the pane. Scrolled above the first header nothing
//      is pinned, and the answer is the first lane in plan order.
//    * table: the phase of the TOPMOST FULLY VISIBLE ROW, meaning the first rendered
//      row whose top edge is at or below the bottom of the sticky header (a row half
//      under the header is not one you are looking at). If every row is partly cut
//      off, the first rendered row answers. An empty table answers phase 0.
//
//    Both are read at the moment of the paste, so scrolling changes the answer and
//    nothing has to be clicked to choose a lane.
function tablePhaseInView(){
  const sc=document.querySelector('.table-scroll');
  const rows=visibleRowEls();
  if(!rows.length)return null;
  let first=null;
  if(sc&&tableHead){
    const top=Math.max(sc.getBoundingClientRect().top,tableHead.getBoundingClientRect().bottom);
    first=rows.find(tr=>tr.getBoundingClientRect().top>=top-0.5)||null;
  }
  const r=rowItem((first||rows[0]).dataset.key);
  return (r&&r.phase!==undefined)?r.phase:null;
}
function phaseInView(){
  if(prefs.view==='gantt'){
    if(pinnedGroup&&pinnedGroup.phase!==undefined)return pinnedGroup.phase;
    const g=items.find(isDerivedGroup);
    return g?g.phase:0;
  }
  const p=tablePhaseInView();
  if(p!==null&&p!==undefined)return p;
  const g=items.find(isDerivedGroup);
  return g?g.phase:0;
}
// The index a row appended to the end of `ph`'s block would take. Same walk homeRow
// does, written as a position instead of as a move.
function endOfPhaseIndex(ph){
  const gi=items.findIndex(g=>g.type==='group'&&g.phase===ph);
  if(gi<0)return items.length;
  let end=gi+1;
  while(end<items.length&&items[end].type!=='group')end++;
  return end;
}
function pasteTarget(){
  const sel=selectedRows();
  if(sel.length){
    const last=sel[sel.length-1];
    return {phase:last.phase,idx:items.indexOf(last)+1,below:last};
  }
  const ph=phaseInView();
  return {phase:ph,idx:endOfPhaseIndex(ph),below:null};
}
// ── Naming a copy ──
//
// The name is KEPT. Drew duplicates a chain across workcells and renames afterwards,
// so " (copy)" on every row would be five renames of noise to undo before the five
// renames that are the actual job.
//
// The one exception is a name that would collide INSIDE THE TARGET LANE, which is
// what pasting back into the same lane always does: two rows called "Reflow profile"
// under one header cannot be told apart on the chart. Those get " 2", then " 3", and
// so on, counting past whatever is already there. A copy into a DIFFERENT lane keeps
// the name unchanged, because the lane is what tells them apart.
//
// `taken` carries the names this same paste has already placed, so pasting three
// copies of one row in one go numbers them 2, 3, 4 rather than all 2.
function copyName(base,ph,taken){
  const used=new Set(items.filter(r=>r.type!=='group'&&r.phase===ph).map(r=>r.name||''));
  taken.forEach(n=>used.add(n));
  if(!used.has(base))return base;
  let n=2;
  while(used.has(base+' '+n))n++;
  return base+' '+n;
}
// ── Ctrl+V ──
//
// Async, because a pasted row cannot exist until the server has given it a uid, and
// EVERY uid is minted before ANY row is built: a paste is one thing, so a mint that
// fails halfway must leave the plan exactly as it was rather than half a chain. The
// uids already handed out are simply not used, which costs nothing (the counter never
// goes backwards and a uid is never reused anyway) and is the same trade the create
// panel makes.
let pasting=false;
async function pasteRows(){
  if(pasting)return;
  if(!clipRows.length){ flash(saveInd,'nothing copied yet'); return; }
  const tgt=pasteTarget();
  pasting=true;
  let uids;
  try{ uids=[]; for(const c of clipRows) uids.push(await allocUid()); }
  catch(_){
    pasting=false;
    flash(saveInd,'could not allocate item IDs, nothing was pasted',4000);
    return;
  }
  pasting=false;
  // The TARGET was decided at the keystroke, which is right: it is what the user was
  // looking at and pointing to when they pressed the keys. The INDEX is re-read HERE,
  // after the mint, because the mint is a real round trip and the plan could have moved
  // under it. Both branches are re-read, not just the anchored one: an appended paste
  // wants the end of the lane as it is NOW, and the end of a lane is exactly the thing
  // another paste, a create or a drag would have moved. If the anchor row itself is gone
  // the paste falls back to the end of the lane it was in, which is the same answer the
  // no-selection rule would have given.
  const j=tgt.below?items.indexOf(tgt.below):-1;
  const idx=(j>=0)?j+1:endOfPhaseIndex(tgt.phase);
  const prov=USER_NAME?('Added by '+USER_NAME):'Added by hand';
  const taken=[];
  // Ids are RESERVED as they are handed out, not merely looked up. freshId only walks
  // `items`, and none of the clones are in it yet, so pasting two rows that slug the
  // same way would hand both of them the same id. One growing set answers for the plan
  // and for the clones made so far.
  const seen=new Set(items.map(r=>r.id).filter(Boolean));
  const reserveId=name=>{
    const base=slugify(name); let id=base,n=2;
    while(seen.has(id))id=base+'-'+(n++);
    seen.add(id); return id;
  };
  // Pass one: build every clone, and record the id it answers to so pass two can
  // remap the links onto it.
  const idMap=new Map();                       // original row id -> clone's new id
  const clones=clipRows.map((c,i)=>{
    const name=copyName(c.name,tgt.phase,taken);
    taken.push(name);
    const r={type:c.type,uid:uids[i],name,phase:tgt.phase};
    // Every clone gets its own id, a milestone's too (0.78.0), and is remapped below
    // like a task: a copied chain through a gate is a chain through the copied gate.
    r.id=reserveId(name);
    if(c.srcId)idMap.set(c.srcId,r.id);
    if(c.type==='milestone'){ r.date=c.date; }
    else{ r.start=c.start; r.end=c.end; }
    if(c.owner)r.owner=c.owner;
    if(c.status)r.status=c.status;
    if(c.description)r.description=c.description;
    if(c.note)r.note=c.note;
    if(c.tags&&c.tags.length)r.tags=c.tags.slice();
    if(c.type!=='milestone'&&c.effort!==null&&c.effort!==undefined)r.effort=c.effort;
    // Provenance is the CREATED-row convention, not the original's. These rows were
    // not produced by whatever the original cites; they were made here, now, by the
    // person pasting, and that is what the field is for.
    r.provenance=prov;
    return r;
  });
  // Pass two: the links.
  //
  // A dep that names a row INSIDE the copied set is remapped to that row's clone, so
  // a copied chain is a chain again and not five rows all hanging off the originals.
  // A dep that names anything OUTSIDE the set is kept exactly as it is, still pointing
  // at the original, because that is a real fact about the work and the copy inherits
  // it. Nothing is added in the other direction: the originals' successors are not
  // touched, so copying A->B->C and pasting it leaves D, which waited on C, waiting on
  // the ORIGINAL C and not on both.
  clipRows.forEach((c,i)=>{
    const deps=c.deps.map(d=>idMap.has(d)?idMap.get(d):d).filter(d=>d&&d!==clones[i].id);
    if(deps.length)clones[i].deps=deps;
  });
  // Their place (0.79.0): straight after the row they were pasted below, as its siblings,
  // or at the end of the section in view.
  // A row copied together with the task it sits under is pasted under that task's copy, in
  // the order copied; the rest go in as siblings.
  { const below=tgt.below&&isLive(tgt.below)?tgt.below:null;
    let par=below&&treeParent(below)?treeRows.get(treeParent(below)):null;
    const ph=below?below.phase:tgt.phase;
    const byUid=new Map(); clipRows.forEach((c,i)=>{ if(c.srcUid)byUid.set(c.srcUid,clones[i]); });
    const roots=[], kidsOf=new Map();
    clipRows.forEach((c,i)=>{ const p=c.srcParent&&byUid.get(c.srcParent);
      if(p&&p!==clones[i]&&p.type!=='milestone'){ if(!kidsOf.has(p))kidsOf.set(p,[]); kidsOf.get(p).push(clones[i]); } else roots.push(clones[i]); });
    const h=r=>{ let m=0; (kidsOf.get(r)||[]).forEach(k=>{ m=Math.max(m,1+h(k)); }); return m; };
    let after=below;
    // Too deep under the row's task: the copies go in after that task's top-level row instead.
    if(par&&treeDepth(par)+1+Math.max(0,...roots.map(h))>TREE_DEPTH_MAX){ let top=below; while(top&&treeParent(top))top=treeRows.get(treeParent(top)); par=null; after=top; }
    clones.forEach(c=>{ c.phase=ph; });
    placeRowsAt(roots,par,ph,after||(siblingsIn(null,ph,null).slice(-1)[0]||null));
    kidsOf.forEach((ks,p)=>{ const keys=keysBetween(null,null,ks.length); ks.forEach((k,j)=>{ k.parent=p.uid; k.pos=keys[j]; }); }); }
  // Into the plan, in copy order, each one below the last.
  const steps=[];
  clones.forEach((r,i)=>{
    addedItems.push(r);
    insertRowAt(r,idx+i,true);
    steps.push({type:'create',r});
  });
  // ONE undo entry for the whole paste, the way a batch delete is one. Undoing it
  // removes every clone and takes their uids out of circulation by the same door a
  // delete does (removeRow sees them in addedItems, so they leave without ever being
  // recorded as deletions); redo puts them back at the same indices.
  pushUndo({type:'multi',steps});
  // A lane the copies landed in that happens to be shut opens, the 0.44.0 rule: a
  // paste you cannot see is a paste you will do again.
  openLandingLanes(clones);
  applyFilter(); refreshChartGeometry();
  renderTable();
  rebuildTimeline();
  // The pasted set becomes THE selection, so Alt+Arrow, a drag or a Delete all apply
  // to what was just made without a click, and the pane follows it by the same
  // five-row buffer every other row-moving gesture uses.
  selected.clear();
  clones.forEach(r=>{ if(r.uid)selected.add(r.uid); });
  selAnchor=clones.length?clones[clones.length-1].uid:null;
  // The panel on screen, if there is one, is the one the COPY was opened from, and it
  // is now describing a row that is no longer the selection. Panels in this app track
  // the selection, so it goes rather than sits there naming the wrong task.
  closeEditor();
  refreshSelVisuals(); updateSelCount();
  if(prefs.view==='table')setFocusedRow(rowKey(clones[clones.length-1]));
  followMovedRows(clones,1);
  scheduleSave('paste');
  const n=clones.length;
  undoToast.innerHTML='Pasted '+n+' task'+(n===1?'':'s')+' into '+esc(phaseNameAt(tgt.phase))+' &middot; <kbd>Ctrl+Z</kbd>';
  showToast(undoToast,4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});
}

// ── Label click → select bar ──
// A click on the label edits the NAME, right there. The bar still owns the
// schedule, so clicking it opens the date and dependency editor as before: the
// label is the thing's name, the bar is its time.
//
// Multi-select modifiers keep their old meaning and never start an edit.
function wireLabelClicks(){
labelsEl.addEventListener('click',e=>{
  if(reorderState||laneDrag)return;
  // The row handle selects the row and does nothing else (selectFromHandle). Stopped here,
  // so the document's "a click outside closes the editor" handler leaves the closing to it.
  const handle=e.target.closest('.row-sel');
  if(handle){
    e.stopPropagation(); e.preventDefault();
    if(Date.now()-dragEndedAt<150)return;          // the release of a drag made from it
    const hr=items.find(x=>x._lDiv===handle.closest('.label-row'));
    if(hr)selectFromHandle(hr,e,'gantt');
    return;
  }
  // A drag that ends over a row produces a click on it, and this used to answer that
  // click by opening the name editor on whatever the pointer happened to land on.
  // Timestamped rather than a sticky flag, for the reason 0.12.0 recorded: a drag
  // that ends somewhere else never clears a boolean, and the stale flag then eats
  // the next real click.
  if(Date.now()-dragEndedAt<150)return;
  if(e.target.closest('.lane-toggle')||e.target.closest('.row-edit')||e.target.closest('.sub-tog, .row-add, .lane-add, .qa-row'))return;
  // The status pill is its own control. A plain click on it edits the status and
  // goes no further, so it never also opens the name editor or selects the row. A
  // modified click falls through to selection, the same as clicking the name.
  const pill=e.target.closest('.status-pill');
  if(pill&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){
    e.stopPropagation();
    const prow=e.target.closest('.label-row[data-id]');
    const pr=prow&&items.find(x=>x._lDiv===prow);
    if(pr&&pr.id)openStatusPill(pr,pill);
    return;
  }
  // The owner pill is the same kind of control as the status pill and gets the same
  // deal: a plain click on it edits the owner and goes no further, so it never also
  // opens the name editor or selects the row, and a modified click falls through to
  // selection. One more excluded region, exactly like the status pill, which is why
  // the gap-click and name-text behaviours below are untouched by it.
  // The request pill opens the inbox at that request (0.80.0).
  const rpill=e.target.closest('.req-pill');
  if(rpill&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){ e.stopPropagation(); openReqPanel(rpill.dataset.req); return; }
  const opill=e.target.closest('.owner-pill');
  if(opill&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){
    e.stopPropagation();
    const prow=e.target.closest('.label-row[data-id]');
    const pr=prow&&items.find(x=>x._lDiv===prow);
    if(pr&&pr.id)openOwnerPill(pr,opill);
    return;
  }
  const row=e.target.closest('.label-row[data-id], .label-row.ms, .label-row.group');
  // Empty space in the label column is a click on nothing, which is how every list
  // in the world drops a selection.
  if(!row){ if(!e.ctrlKey&&!e.metaKey&&!e.shiftKey){ closeLabelEditors(true); clearSelection(); } return; }
  const r=items.find(x=>x._lDiv===row);
  if(!r)return;
  if(e.ctrlKey||e.metaKey||e.shiftKey){
    if(!selUid(r)){ flash(saveInd,'a lane header cannot be selected'); return; }
    // The document-level "a click outside closes the editor" handler does not have
    // the label column in its allowlist, so it was seeing this very click and
    // shutting the batch editor the line below had just opened. Same shape as the
    // Deps cell in 0.22.0: any surface that OPENS the editor has to answer that
    // handler.
    e.stopPropagation();
    closeLabelEditors(true);
    doSelect(r.uid,e);
    if(selected.size>1)openBatchEditor(); else closeEditor();
    return;
  }
  // A plain click's target inside the name row is now hit-tested (0.34.0). A lane
  // header still edits its phase name on a plain click, the way it always has. On a
  // task or milestone row the glyphs of the name open the editor; the empty space
  // after them (the gap past the status pill) selects the row instead, the same
  // single-select clicking the bar or the milestone line would do. A plain click on
  // the name text also collapses any multi-select, the way clicking one item in any
  // list drops the rest; building the set stays a ctrl or shift gesture.
  if(isDerivedGroup(r)){ if(selected.size)clearSelection(); editLabelInline(r); return; }
  // A click on the name renames it IN PLACE, the caret where the click landed (0.66.1).
  // 0.65.0 opened the item editor here instead, for consistency with the bar; Drew: "I
  // should be able to edit a task name just by clicking on it ... I don't want to see
  // [the editor] when I click on a task name." The name is the one field a person edits
  // where they read it, like a Table cell. The item editor is still a click on the bar,
  // the milestone, the pencil or the Deps cell away, and a double-click on the name
  // upgrades the rename to it, carrying what was typed.
  const ntEl=e.target.closest('.name-text');
  if(ntEl){
    if(selected.size)clearSelection();
    editLabelInline(r,caretIndexAt(ntEl,e.clientX,r.name||''));
    return;
  }
  // The gap: select the row. selUid is null for anything that cannot be selected, so
  // this is inert on a header. stopPropagation for the same editor-guard reason as
  // the modified-click branch above.
  if(!selUid(r))return;
  e.stopPropagation();
  closeLabelEditors(true);
  doSelect(r.uid,e);
  if(selected.size>1)openBatchEditor(); else closeEditor();
});
// The second click of a double-click upgrades to the full editor, carrying
// whatever has been typed so far, so the fast path is never paid for by the
// slow one: the inline editor is already mounted and instant.
labelsEl.addEventListener('dblclick',e=>{
  // Two clicks on the handle are two selections of the same row, not an editor.
  if(e.target.closest('.row-sel'))return;
  const row=e.target.closest('.label-row[data-id], .label-row.ms');
  if(!row)return;
  const r=items.find(x=>x._lDiv===row);
  if(!r)return;
  e.preventDefault();e.stopPropagation();
  const live=row.querySelector('.label-edit');
  const carried=live?live.value:undefined;
  if(live)live.__cancel&&live.__cancel();
  openItemModal(r,carried);
});
}

// Exactly one of these may be open at a time. There used to be no rule at all: the
// only guard was per-row, so a second row could mount its own editor while the first
// was still on screen, and `labelEditing` tracked only the last one, so the first was
// forgotten entirely. An editor that has lost focus can never dismiss itself either,
// because Enter and Escape are handled on the input, so an orphan sat there with its
// accent border looking exactly like a stuck selection.
function closeLabelEditors(save){
  const live=[...document.querySelectorAll('.label-edit')];
  if(!live.length)return false;
  live.forEach(inp=>{ if(inp.__finish)inp.__finish(!!save); else if(inp.__cancel)inp.__cancel(); });
  document.querySelectorAll('.label-edit').forEach(el=>el.remove());   // belt and braces
  labelEditing=null;
  return true;
}
// Where in a name the pointer landed, as a caret index into the string. Opening the
// editor by clicking the text used to select the WHOLE name, so the first thing a user
// wanting to fix one word had to do was click a second time to put the highlight down
// and the caret somewhere useful. Now the caret starts where the click was. Select-all
// is still one Ctrl+A away, and a double-click still arrives at the full editor with
// the name selected, so nothing that could reach it before has lost it.
//
// The span holds the whole name and lets CSS ellipsise it, so hit-testing the span
// reads the real character under the cursor. Past the clip there is nothing to hit:
// a click on the ellipsis, or anywhere right of the text, means the end of the name.
function caretIndexAt(el,clientX,text){
  if(!el)return null;
  const box=el.getBoundingClientRect();
  const y=box.top+box.height/2;
  const clipped=el.scrollWidth-el.clientWidth>1;
  // The ellipsis is painted INSIDE the box against its right edge, so a hit there
  // would land on whichever character happens to sit under the clip rather than at
  // the end of a name the user cannot even see the rest of.
  if(clientX>=box.right-(clipped?14:2))return text.length;
  if(clientX<=box.left)return 0;
  let hit=null;
  try{
    if(document.caretPositionFromPoint){
      const c=document.caretPositionFromPoint(clientX,y);
      if(c)hit={n:c.offsetNode,o:c.offset};
    } else if(document.caretRangeFromPoint){
      const c=document.caretRangeFromPoint(clientX,y);
      if(c)hit={n:c.startContainer,o:c.startOffset};
    }
  }catch(_){}
  if(hit&&hit.n&&hit.n.nodeType===3&&el.contains(hit.n))return Math.max(0,Math.min(text.length,hit.o));
  // No hit test to be had: fall back to the fraction of the way across the text.
  const w=el.scrollWidth||box.width;
  return w>0?Math.max(0,Math.min(text.length,Math.round((clientX-box.left)/w*text.length))):0;
}
function editLabelInline(r,caret){
  const lDiv=r._lDiv;
  if(!lDiv||lDiv.querySelector('.label-edit'))return;
  closeLabelEditors(true);        // commit whatever else was open, never orphan it
  const inp=document.createElement('input');
  // A header IS its phase now, so the field edits the phase name and the rename
  // flows to the legend, the table's phase column and every phase dropdown.
  const isHdr=isDerivedGroup(r);
  inp.type='text';inp.className='label-edit';
  inp.value=isHdr?((phases[r.phase]&&phases[r.phase].name)||''):(r.name||'');
  let done=false;
  // Take the field away and give the app back its idle state. Neither used to happen
  // on the Enter, Escape or blur paths: restore() only rewrote the name SPAN, and the
  // input is a SIBLING of that span, so it survived every one of them as a live
  // overlay sitting on the row. The next click on that row then hit the
  // already-has-an-editor guard at the top of this function and did nothing at all, so
  // a row could be renamed exactly once. `labelEditing` was stranded the same way, and
  // appIsIdle() reads it, so one inline edit also quietly took the table's arrows,
  // Delete and Tab out of service for the rest of the session. Both are cleared here,
  // in the one place every exit passes through.
  const dismiss=()=>{
    if(inp.parentNode)inp.remove();
    if(labelEditing===inp)labelEditing=null;
  };
  const restore=()=>{
    dismiss();
    if(isDerivedGroup(r))refreshHeaderRow(r);
    else if(r.type==='milestone')refreshMsChrome(r); else refreshLabelRow(r);
    applyCollapse();
  };
  const finish=save=>{
    if(done)return;done=true;
    const v=inp.value.trim();
    try{inp.blur();}catch(e){}
    dismiss();
    if(isHdr){
      const p=phases[r.phase];
      if(save&&v&&p&&v!==p.name){ renamePhase(r.phase,v); } else restore();
      return;
    }
    if(save&&v&&v!==r.name){
      if(r.type==='milestone')snapshotMs(r); else snapshot(r.id);
      r.name=v;
      restore();
      if(r.type==='milestone')updateMilestone(r); else updateBar(r.id);
      refreshChartGeometry();
      scheduleSave('label-edit');
    } else restore();
  };
  inp.__cancel=()=>{done=true;restore();};
  inp.__finish=finish;
  // Enter is the whole gesture, not half of it: it commits the name, closes the field
  // and hands the row over to the keyboard still SELECTED, so a rename runs straight
  // into whatever comes next instead of ending on a row that is no longer anything.
  // Escape is unchanged and still cancels without committing.
  inp.addEventListener('keydown',e=>{e.stopPropagation();
    if(e.key==='Enter'){e.preventDefault();finish(true);selectOnly(r);}
    else if(e.key==='Escape'){e.preventDefault();finish(false);}});
  inp.addEventListener('blur',()=>finish(true));
  inp.addEventListener('mousedown',e=>e.stopPropagation());
  inp.addEventListener('click',e=>e.stopPropagation());
  inp.addEventListener('dblclick',e=>e.stopPropagation());
  lDiv.appendChild(inp);
  inp.focus();
  // A caret index means the field was opened by clicking the text, at that character.
  // Everything else (the Edit button, Shift+N, a header's phase name) still arrives
  // with the whole value selected, ready to be typed over.
  if(caret==null)inp.select();
  else{const n=Math.max(0,Math.min(inp.value.length,caret));inp.setSelectionRange(n,n);}
  labelEditing=inp;
}
let labelEditing=null;

// ── Unified pending gesture (vertical=reorder, horizontal=move) ──
let reorderState=null, gesture=null;
// Escape cancels a reorder while the button is still down. The release that follows
// still produces a click, and that click is the tail of a drag the user has already
// abandoned, so it must not act. A flag rather than a timestamp because there is no
// telling how long the button stays down; the very next mouseup clears it either way,
// so unlike the sticky didDrag it cannot outlive the gesture that set it.
let cancelledDrag=false;
const labelDropInd=document.createElement('div');
function mountLabelDropIndicator(){
labelDropInd.className='drop-indicator';labelsEl.appendChild(labelDropInd);
}
const barDropInd=document.createElement('div');
function mountBarDropIndicator(){
barDropInd.className='drop-indicator';bars.appendChild(barDropInd);
}
function rowYCenter(el){return el.offsetTop+el.offsetHeight/2;}
// One place to put the indicators away, called from every exit: drop, Escape,
// a drag that ends outside any list, and the safety net on mouseup. An indicator
// left showing is a promise about a drop that is not going to happen.
function hideDropIndicators(){
  labelDropInd.classList.remove('show');
  barDropInd.classList.remove('show');
  clearNestMark();
}

// ── Dropping rows (0.79.0) ──
//
// Where a drag lands, in either lens. `seq` is what the person can see, in plan order (the
// chart's section headers included), less the rows being dragged and their subtasks; `i` is
// the row under the pointer and `frac` how far down it the pointer is; `dx` how far the
// pointer has travelled sideways since the drag began. Onto the middle of a task: the rows
// become its last subtasks. Between two rows: a move, at the level the sideways travel picks
// among the levels that gap allows, and the drop line is indented to show it (ClickUp's
// List and Gantt drag; Notion's outline does the same with the pointer's x).
const DROP_STEP=24;
function dropAt(seq,i,frac,dx,moving){
  const rows=treeRoots(moving); if(!rows.length)return null;
  const R=seq[i]; if(!R)return null;
  if(frac>=0.25&&frac<=0.75&&!isDerivedGroup(R)&&canNestUnder(rows,R))return {kind:'nest',target:R,rows};
  const above=frac<0.5, X=above?(seq[i-1]||null):R, Y=above?R:(seq[i+1]||null);
  const hmax=Math.max(0,...rows.map(r=>treeHeight(tree,rowTreeKey(r))));
  let phase, dmin=0, dmax=0;
  // In the Table there are no section headers: the top half of a section's first row is the
  // top of that section, as it always was there.
  const edge=X&&!isDerivedGroup(X)&&Y&&!isDerivedGroup(Y)&&above&&Y.phase!==X.phase;
  if(!X||isDerivedGroup(X)||edge){ phase=edge?Y.phase:X?X.phase:(Y?Y.phase:0); }
  else {
    phase=X.phase;
    dmax=Math.min(treeDepth(X)+(canNestUnder(rows,X)?1:0),TREE_DEPTH_MAX-hmax);
    dmin=(Y&&!isDerivedGroup(Y)&&Y.phase===phase)?treeDepth(Y):0;
  }
  if(dmin>dmax)return null;
  const d=Math.max(dmin,Math.min(dmax,treeDepth(rows[0])+Math.round(dx/DROP_STEP)));
  const up=x=>x&&treeParent(x)?treeRows.get(treeParent(x))||null:null;
  let parent=null, after=null;
  if(X&&!isDerivedGroup(X)&&!edge){
    if(d>0){ let a=X; while(a&&treeDepth(a)>d-1)a=up(a); parent=a||null; }
    if(treeDepth(X)>=d){ let b=X; while(b&&treeDepth(b)>d)b=up(b); after=b||null; }
  }
  if(parent&&!canNestUnder(rows,parent))return null;
  return {kind:'gap',parent,phase:parent?parent.phase:phase,after,depth:d,X,Y,above,rows};
}
// The drop, applied: one undo entry, one save, the rows' subtasks with them.
function applyDrop(drop,via){
  if(!drop)return false;
  const rows=drop.rows, set=new Set(rows);
  const before=placeSnap(rows);
  if(drop.kind==='nest'){
    const t=drop.target;
    placeRowsAt(rows,t,t.phase,lastKid(t,set));
    if(collapsedRows.has(rowTreeKey(t)))setRowFolded(t,false);
  } else {
    // Dropped where it already is: nothing to do, and no undo entry for it.
    if(rows.length===1){ const r=rows[0], pk=drop.parent?rowTreeKey(drop.parent):'';
      if((treeParent(r)||'')===pk&&r.phase===drop.phase&&sibNext(r,-1)===drop.after)return false; }
    placeRowsAt(rows,drop.parent,drop.phase,drop.after);
  }
  pushUndo(before);
  placeHeaders();
  openLandingLanes(rows);
  applyCollapse();reflowRows();applyFilter();refreshChartGeometry();renderTable();
  moveHint=rows.length===1?'"'+(rows[0].name||'a row')+'"':rows.length+' rows';
  scheduleSave(via);
  return true;
}
function clearNestMark(){ document.querySelectorAll('.nest-target').forEach(x=>x.classList.remove('nest-target')); }
// The chart's indicator for a drop: the line between two rows, indented to the level it
// lands at, or the task it would go under, outlined.
function showChartDrop(drop){
  clearNestMark();
  if(!drop){ hideDropIndicators(); return; }
  if(drop.kind==='nest'){ hideDropIndicators(); const t=drop.target; if(t._lDiv)t._lDiv.classList.add('nest-target'); if(t._bDiv)t._bDiv.classList.add('nest-target'); return; }
  const ref=drop.above?drop.Y:drop.X; if(!ref||!ref._lDiv){ hideDropIndicators(); return; }
  const lt=drop.above?ref._lDiv.offsetTop:ref._lDiv.offsetTop+ref._lDiv.offsetHeight;
  const bt=drop.above?ref._bDiv.offsetTop:ref._bDiv.offsetTop+ref._bDiv.offsetHeight;
  labelDropInd.style.top=lt+'px'; labelDropInd.style.left=(drop.depth*18*(prefs.fs||1)+(drop.depth?8:0))+'px'; labelDropInd.classList.add('show');
  barDropInd.style.top=bt+'px'; barDropInd.classList.add('show');
}
function wireLabelDragArm(){
labelsEl.addEventListener('mousedown',e=>{
  if(e.target.closest('.status-pill, .owner-pill, .req-pill'))return;   // editing a pill, not starting a drag
  if(e.target.closest('.sub-tog, .row-add, .lane-add, .qa-row'))return;   // a fold, a quick add (0.79.0)
  const row=e.target.closest('.label-row[data-id], .label-row.ms');
  if(!row)return;
  // The row handle drags the row, up and down only: a handle moves a row, it does not
  // drag dates along a time axis that is not there. Its press keeps the focus where it
  // was and starts no text selection (selectFromHandle says why).
  const grip=!!e.target.closest('.row-sel');
  if(grip)e.preventDefault();
  // A milestone label row carries no data-id, so it could never be armed and a
  // milestone was the one row in the plan that could not be dragged into another lane
  // from the name column. It arms by uid instead, the same key its diamond uses, and
  // resolves through the same machine.
  if(!row.dataset.id){
    const mr=items.find(x=>x._lDiv===row);
    if(!mr||!mr.uid)return;
    gesture={uid:mr.uid,startX:e.clientX,startY:e.clientY,src:'mslabel',grip};
    return;
  }
  gesture={id:row.dataset.id,startX:e.clientX,startY:e.clientY,src:'label',grip};
});
}

// (bar body mousedown is handled below in the drag section)

function wireBarClicks(){
bars.addEventListener('click',e=>{
  // A drag must not also count as a click on whatever it finished over. Timestamped
  // rather than a sticky flag, the same answer the table's guard has used since
  // 0.12.0 and for the same reason: a drag that ends anywhere but this pane never
  // produces a click HERE, so a boolean set on the way out is never cleared and then
  // eats the next real click, however much later it arrives. A row reorder dragged in
  // the label column did exactly that, and the bar you clicked minutes and any number
  // of scrolls later did nothing at all.
  if(Date.now()-dragEndedAt<150)return;
  // While the mode is on, a click on the chart is a dependency click. No editor
  // opens and nothing is selected. A milestone is picked like a bar (0.78.0), by its
  // diamond or, in the lines style, by the cap on its rule.
  if(depMode){
    e.stopPropagation();
    const b=e.target.closest('.bar[data-id]');
    if(b){ setDepSel(null); depClickBar(b.dataset.id); return; }
    const mh=e.target.closest('.ms-hit, .ms-cap');
    if(mh){ const mr=rowByUid(mh.dataset.msuid); setDepSel(null); depClickBar(mr&&mr.id); return; }
    // Nothing solid under the pointer, so ask the arrows. A half-made pair and a picked
    // arrow are two answers to the same question and cannot both be live, so picking
    // one drops the other.
    const edge=depEdgeAt(e.clientX,e.clientY);
    if(edge){ setDepPending(null); setDepSel(edge); return; }
    // Genuinely empty space: both let go, which is the same thing Escape does one rung
    // at a time and the thing a click away from everything should mean.
    setDepPending(null); setDepSel(null);
    return;
  }
  const msHit=e.target.closest('.ms-hit, .ms-cap');
  if(msHit){
    const mr=rowByUid(msHit.dataset.msuid);
    // A modifier on a milestone builds the set, the same gesture as on a bar. A
    // plain click still opens its editor, because that is what a plain click on a
    // milestone has always meant and one is not a selection.
    if(mr&&mr.uid&&(e.ctrlKey||e.metaKey||e.shiftKey)){
      e.stopPropagation();
      doSelect(mr.uid,e);
      if(selected.size>1)openBatchEditor(); else closeEditor();
      return;
    }
    // A plain click SELECTS the milestone and opens its editor, which is what a plain
    // click on a task bar has always done. It used to clear the selection instead, so
    // the one row you had just aimed at was the one row that could not be selected,
    // and Alt+Up, Alt+Down and the table's cursor all read "nothing chosen". selectOnly
    // hands the same row to the table, so the two lenses agree about what is current.
    if(mr)selectOnly(mr); else clearSelection();
    { const mrow=rowByUid(msHit.dataset.msuid); if(mrow)openItemModal(mrow,undefined,{anchor:msHit.getBoundingClientRect()}); }
    e.stopPropagation(); return;
  }
  const bar=e.target.closest('.bar[data-id]');
  if(bar&&!e.target.classList.contains('drag-handle')){
    const id=bar.dataset.id;
    const br=items.find(d=>d.id===id);
    // A plain click on a bar means "open this task", and it has to mean that on the
    // FIRST click. doSelect TOGGLES, which reads right while the panel is on screen
    // (the bar dismisses what the bar opened) and wrong the moment it is gone: the
    // selection OUTLIVES the panel, so the row whose panel was just closed is still
    // the sole selection, and the next click on its bar lands as "deselect", drops
    // the selection to zero and opens nothing. That is the swallowed first click.
    // Nothing to do with the drag machinery: it cost a second click every time
    // someone reopened the task they had just closed, and scrolling only made it
    // easier to hit, because a scrolled chart puts a different bar under the same
    // spot. A modified click is set-building and keeps the toggle in every case.
    const plainClick=!(e.ctrlKey||e.metaKey||e.shiftKey);
    const panelUp=editorEl.classList.contains('show')&&selected.size===1&&soleSelectedRow()===br;
    if(plainClick&&!panelUp)selectSole(br); else doSelect(br&&br.uid, e);
    if(selected.size===1&&soleSelectedRow()===br)openItemModal(br,undefined,{anchor:bar.getBoundingClientRect()});
    else if(selected.size>1)openBatchEditor();
    else closeEditor();
    e.stopPropagation();
  }else if(!e.target.closest('.editor')&&!e.target.closest('.dropdown')){clearSelection();closeEditor();}
});
document.addEventListener('click',e=>{
  // The release that ends a drag is not a click OUTSIDE anything. A snapped bar cannot
  // track the pointer exactly, so a drag of any length usually finishes with the cursor
  // beside the bar rather than on it, and this handler was reading that as "clicked
  // away" and shutting the panel that had just spent the whole drag showing the dates.
  if(Date.now()-dragEndedAt<150)return;
  // A pick in one of our own lists is a click INSIDE whatever opened the list (0.71.1).
  // The list sits on the page body, not inside the editor, and by the time the click gets
  // here the pick has already closed it, so the option clicked is not even on the page
  // any more. Read as "clicked outside", it shut the editor with the pick unapplied.
  if(inOwnPopup(e)||!e.target.isConnected)return;
  if(!e.target.closest('.editor')&&!e.target.closest('.bar[data-id]')&&!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeEditor();});
}

// ── Sections: the only places phases[] is mutated ──────────────────────────
//
// Every one of these rebuilds the header row, the legend and the table, so a
// section change lands in both lenses at once and there is no second copy of the
// truth to drift.
function refreshHeaderRow(r){
  if(!r||!r._lDiv)return;
  const p=phases[r.phase]; if(!p)return;
  const nm=r._lDiv.querySelector('.lane-name');
  if(nm)fillLaneName(nm,p.name||'',p.lead||'',p.tags);
  r.label=p.name; r.lead=p.lead||'';
}
// A section header's tags: small quiet chips after the aside, in the text colour and
// never the section's, or in the tag's own colour when it has one (0.61.0).
// The header's name, aside and tags are three flex items, and they give way in that
// order of LEAST importance: the aside ellipsises first, then the name, and the tag chips
// never shrink. The 0.59.0 review found the chips cut off after a long aside, when all
// three were inline text in one ellipsis. The name is the header's first child, as before.
function fillLaneName(nm,name,lead,tags){
  nm.textContent='';
  const t=document.createElement('span'); t.className='lane-title'; t.textContent=name; nm.appendChild(t);
  if(lead){ const sp=document.createElement('span'); sp.className='lane-lead'; sp.textContent=lead; nm.appendChild(sp); }
  if(tags&&tags.length){
    const w=document.createElement('span'); w.className='lane-tags';
    tags.forEach(x=>{ const c=document.createElement('span'); c.className='lane-tag'; c.textContent=x; paintTag(c,x); w.appendChild(c); });
    nm.appendChild(w);
  }
}
// The suggestions every tag box offers (the Setup rows, the item editor, the New panel,
// the table's Tags cell and the batch panel): every tag in the plan, sections and rows.
function syncTagList(tags){
  const dl=$('tagList'); if(!dl)return;
  dl.innerHTML=(tags||planTags()).map(e=>`<option value="${esc(e.tag)}">`).join('');
}
// A row's own tags show in three places on the chart: the label row (after the owner),
// the bar's or diamond's tooltip, and the milestone cap's. All three are restated here.
function refreshRowTags(r){
  if(!r)return;
  const own=ownTags(r).join(', ');
  [r._msHit,r._msLine&&r._msLine.querySelector('.ms-cap'),r.id&&barEls[r.id]&&barEls[r.id].bar].forEach(el=>{ if(el)el.dataset.tags=own; });
  if(r._nameSpan)setHtmlIfChanged(r._nameSpan,nameInner(r));
}
// Any change to a section's tags lands here: the filter drops a tag the plan no longer
// has, and both lenses, the legend and the Setup page repaint from phases[].
function afterTagsChange(){
  tagSig=tagSigNow();
  // A tag that leaves the plan leaves the reader's filter (pruneTagFilter). When that was
  // caused by an edit (a delete of the only row carrying it, a row's tags cleared), the
  // edit's undo entry remembers the filter as it was, so Ctrl+Z brings the view back with
  // the tag (0.60.0 review). A rename already carries its own `filter`; time travel does
  // its own bookkeeping (see timeTravel).
  const fBefore=prefs.tagFilter.slice();
  if(pruneTagFilter()&&!timeTraveling){
    const top=undoStack[undoStack.length-1];
    if(top&&!top.filter&&!top.viewFilter)top.viewFilter=fBefore;
  }
  syncTagList();
  // A tag's colour (0.61.0) is on every row label that carries it as "#tag" text.
  items.forEach(r=>{ if(r._nameSpan&&ownTags(r).length)setHtmlIfChanged(r._nameSpan,nameInner(r)); });
  refreshSections();
  applyTagView();
}
function refreshSections(){
  pruneTagFilter();
  items.filter(isDerivedGroup).forEach(refreshHeaderRow);
  rebuildLegend();
  applyCollapse();
  // Every phase dropdown (the New panel, the table's phase cell) is built from
  // phases[] at the moment it opens, so there is nothing else to keep in step.
  if(prefs.view==='table')renderTable();
  // The Setup page is the section list, so it follows every change, including one that
  // came from Ctrl+Z or from another tab rather than from its own buttons.
  if(prefs.view==='setup')renderSections(true);
}
function renamePhase(i,name){
  const p=phases[i]; if(!p||!name||name===p.name)return false;
  if(secNameTaken(name,p))return false;   // two sections may not share a name
  const from=p.name;
  pushUndo({type:'phase.rename',i,name:from});
  p.name=name;
  refreshSections();
  logPhaseEvent({action:'phase.rename',phase:name,from:{name:from},to:{name}});
  scheduleSave('section-rename');
  return true;
}
// A phases[] reorder is the one operation that moves every item's stored phase
// index, so it is written once, here, and it remaps in a single pass: build the
// new array, build old-index -> new-index from it, then rewrite every row. The
// mapping is asserted to be a bijection before anything is applied, because a
// half-applied phase remap is the restructure hazard all over again.
// A client-only identity for a section, for the undo entries that have to find one again.
// A uid is not enough (older plans have none on their phases, and the server only mints
// them for rows) and a name is not one at all. `_k` is minted once per phase object and is
// copied along with it by Object.assign, so the copies phaseSnapshot keeps and
// restorePhaseState puts back are still the same section. It never reaches the disk: every
// writer of phases[] (the sidecar, the export) lists the fields it writes.
function phaseKey(p){ if(!p._k)p._k='k'+(++phaseKeySeq); return p._k; }
function reorderPhases(from,to){
  if(from===to||!phases[from]||to<0||to>=phases.length)return false;
  const before=phases.map(phaseKey);
  const arr=phases.slice();
  const [moved]=arr.splice(from,1);
  arr.splice(to,0,moved);
  const map=new Map();
  arr.forEach((p,ni)=>{ const oi=phases.indexOf(p); map.set(oi,ni); });
  if(map.size!==phases.length||new Set(map.values()).size!==phases.length)return false;
  pushUndo({type:'phase.order',order:before});
  applyPhaseOrder(arr,map);
  logPhaseEvent({action:'phase.reorder',phase:moved.name,from:{i:from},to:{i:to}});
  scheduleSave('section-reorder');
  return true;
}
function applyPhaseOrder(arr,map){
  phases.length=0; phases.push(...arr);
  items.forEach(r=>{ if(r.phase!==undefined&&map.has(r.phase))r.phase=map.get(r.phase); });
  // the collapse set is keyed by phase index, so it travels with the remap
  const wasCollapsed=[...collapsed];
  collapsed.clear();
  wasCollapsed.forEach(ph=>{ if(map.has(ph))collapsed.add(map.get(ph)); });
  prefs.collapsed=[...collapsed]; savePrefs();
  const vis=phaseVis.slice();
  const nv=phases.map(()=>true);
  map.forEach((ni,oi)=>{ if(vis[oi]!==undefined)nv[ni]=vis[oi]; });
  phaseVis.length=0; phaseVis.push(...nv);
  // The header objects still carry the phase indices they had BEFORE the remap, so
  // they are re-pointed at their phases (by phase uid, which did not move) before
  // the layout runs. Without this the legend reordered and the chart did not.
  buildDerivedHeaders();
  placeHeaders();
  reflowRows(); refreshSections(); applyFilter(); refreshChartGeometry();
}
// One snapshot type covers create, delete, recolour and lead, because all four
// are "the section list looked like this, and every row pointed where it pointed".
// Capturing both halves together is what makes an undo of a DELETE put the rows
// back in the section they came from rather than leaving them where they were
// reassigned to.
function phaseSnapshot(){
  phases.forEach(phaseKey);   // keyed BEFORE the copy, so the copies carry the key
  return {type:'phase.state',
    phases:phases.map(p=>Object.assign({},p)),
    at:items.filter(r=>!isDerivedGroup(r)).map(r=>[r.uid,r.phase]),
    collapsed:[...collapsed], vis:phaseVis.slice()};
}
function restorePhaseState(sn){
  const inv=phaseSnapshot();
  phases.length=0; phases.push(...sn.phases.map(p=>Object.assign({},p)));
  const m=new Map(sn.at);
  items.forEach(r=>{ if(!isDerivedGroup(r)&&m.has(r.uid))r.phase=m.get(r.uid); });
  collapsed.clear(); (sn.collapsed||[]).forEach(x=>collapsed.add(x));
  prefs.collapsed=[...collapsed]; savePrefs();
  phaseVis.length=0; phaseVis.push(...(sn.vis&&sn.vis.length===phases.length?sn.vis:phases.map(()=>true)));
  buildDerivedHeaders(); placeHeaders(); reflowRows();
  refreshSections(); applyFilter(); refreshChartGeometry();
  return inv;
}
// A section is a row-shaped thing, so it gets its id the same way a row does:
// from the server, never minted here. If the call cannot be made the section is
// not created and the modal says so, exactly as the New panel does.
async function createPhase(name,color,lead){
  const uid=await allocUid();
  const sn=phaseSnapshot();
  const p={name,start:'',end:'',uid};
  if(color)p.color=color;
  if(lead)p.lead=lead;
  phases.push(p); phaseVis.push(true);
  pushUndo(sn);
  buildDerivedHeaders(); placeHeaders(); reflowRows();
  refreshSections(); applyFilter(); refreshChartGeometry();
  logPhaseEvent({action:'phase.create',phase:name,to:{uid,color:color||'',lead:lead||''}});
  scheduleSave('section-create');
  return phases.length-1;
}
// Deleting a section never orphans a row. An empty one goes on its own; a
// populated one MUST name where its rows are going, and they are moved before the
// section is removed. Every index above the removed one shifts down by one, which
// is why the item phases, the collapse set and the legend visibility are all
// remapped here rather than anywhere else.
function deletePhase(i,reassignTo){
  const p=phases[i]; if(!p||phases.length<2)return false;
  const rows=items.filter(r=>!isDerivedGroup(r)&&r.phase===i);
  if(rows.length&&(reassignTo===undefined||reassignTo===null||reassignTo===i||!phases[reassignTo]))return false;
  const sn=phaseSnapshot();
  const name=p.name, dest=rows.length?phases[reassignTo].name:null;
  // The section's top-level rows join the destination's where they stood in the plan: ahead
  // of its own rows when the section came before it, after them when it came after, in their
  // order, their subtasks with them (0.79.0: a row's place is its key, so they get keys there).
  const tops=rows.filter(r=>!treeParent(r)), there=siblingsIn(null,reassignTo,new Set(rows));
  const keys=i<reassignTo?keysBetween(null,there.length?there[0].pos:null,tops.length):keysBetween(there.length?there[there.length-1].pos:null,null,tops.length);
  tops.forEach((r,k)=>{ r.pos=keys[k]; });
  rows.forEach(r=>{ r.phase=reassignTo; });
  const map=new Map();
  phases.forEach((_,oi)=>{ if(oi<i)map.set(oi,oi); else if(oi>i)map.set(oi,oi-1); });
  phases.splice(i,1);
  items.forEach(r=>{ if(!isDerivedGroup(r)&&map.has(r.phase))r.phase=map.get(r.phase); });
  const wasC=[...collapsed]; collapsed.clear();
  wasC.forEach(ph=>{ if(map.has(ph))collapsed.add(map.get(ph)); });
  prefs.collapsed=[...collapsed]; savePrefs();
  const vis=phaseVis.slice(); const nv=phases.map(()=>true);
  map.forEach((ni,oi)=>{ if(vis[oi]!==undefined)nv[ni]=vis[oi]; });
  phaseVis.length=0; phaseVis.push(...nv);
  pushUndo(sn);
  buildDerivedHeaders(); placeHeaders(); reflowRows();
  refreshSections(); applyFilter(); refreshChartGeometry();
  logPhaseEvent({action:'phase.delete',phase:name,count:rows.length,
                 to:dest?{section:dest}:undefined});
  scheduleSave('section-delete');
  return true;
}
function recolorPhase(i,color){
  const p=phases[i]; if(!p)return false;
  const from=p.color||'';
  if((color||'')===from)return false;
  pushUndo(phaseSnapshot());
  if(color)p.color=color; else delete p.color;
  items.forEach(r=>{
    if(r.phase!==i)return;
    if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(i);
    if(r._msLine)r._msLine.style.color=phaseColor(i);
  });
  // Every other recolour path ends in refreshChartGeometry, which redraws the arrow
  // layer and repaints it on the way past. Repainting a LANE moves no geometry at all,
  // so this is the one that has to say so itself.
  recolorDepArrows();
  refreshSections();
  logPhaseEvent({action:'phase.color',phase:p.name,from:{color:from},to:{color:color||'(theme ramp)'}});
  scheduleSave('section-color');
  return true;
}
function setPhaseLead(i,lead){
  const p=phases[i]; if(!p)return false;
  const from=p.lead||'';
  if((lead||'')===from)return false;
  pushUndo(phaseSnapshot());
  if(lead)p.lead=lead; else delete p.lead;
  refreshSections();
  logPhaseEvent({action:'phase.lead',phase:p.name,from:{lead:from},to:{lead:lead||''}});
  scheduleSave('section-lead');
  return true;
}
function logPhaseEvent(e){
  if(SNAPSHOT)return;
  try{ fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({actor:'user',events:[Object.assign({via:'sections'},e)]})}).catch(()=>{}); }catch(_){}
}

// ── Sections editor: the Setup lens ──────────────────────────────────────────
//
// The page is a view of phases[] and nothing else: every button calls one of the
// mutation functions above, then the list is rebuilt from phases[]. There is no
// second copy of the section list to fall out of step, which is the whole reason
// unification came first.
//
// 0.58.0 moved it from a modal behind the header's Sections button onto the Setup
// lens. The row code is the modal's, unchanged in what it does; what changed is the
// frame. A page is never "open" or "closed", so the modal's own Escape handler and
// its Done button went with it: Escape on the page belongs to the row editor that is
// open (see ESC_OWNED and the app-level stack), and leaving is picking another lens.
//
// A brand-safe palette, plus a constrained hex box for anything else. "Theme" is
// the absence of a colour: it hands the lane back to the --ph ramp, which is the
// only setting that adapts between light and dark. Anything explicit is the colour
// the user picked and stays that colour everywhere, on purpose.
const SEC_PALETTE=[['','Theme'],['#00b8b1','Teal'],['#8c6bf7','Purple'],['#64abff','Blue'],
  ['#3fb950','Green'],['#f0883e','Orange'],['#f85149','Red'],['#8b949e','Slate']];
const HEX_RE=/^#[0-9a-fA-F]{6}$/;
function laneCount(i){ return items.filter(r=>!isDerivedGroup(r)&&r.phase===i).length; }
// The dates a section's rows span, first to last, the same reduction the Analytics
// per-section card makes: a task counts its start and end, a milestone its date, and
// a row with no usable date adds nothing. A section's own start and end in the data
// are not used, because what the chart draws for a lane is where its rows are.
function laneSpan(i){
  let lo='',hi='';
  items.forEach(r=>{
    if(isDerivedGroup(r)||r.phase!==i)return;
    const ds=isMs(r)?[r.date]:(r.start&&r.end?[r.start,r.end]:[]);
    ds.forEach(d=>{ if(!d)return;
      if(!lo||dayNum(d)<dayNum(lo))lo=d;
      if(!hi||dayNum(d)>dayNum(hi))hi=d; });
  });
  return {from:lo,to:hi};
}
// Is a row's inline editor (rename, colour, remove) open?
function secRowOpen(){ const v=$('setupView'); return !!(v&&v.querySelector('.sec-row.sec-editing')); }
// Section names are how people, the legend, the table's phase column and every phase
// dropdown tell sections apart, so two sections may not share one. Compared trimmed and
// without regard to case, because "Fab" and "fab " are the same word to a reader. It was
// allowed until 0.58.0, and the reorder undo, which found sections by name, merged two
// same-named sections into one.
function secNameTaken(name,except){
  const k=String(name||'').trim().toLowerCase();
  return phases.some(p=>p!==except&&String(p.name||'').trim().toLowerCase()===k);
}
// The colour box takes what people actually type: with or without the #, three digits or
// six, either case. It is stored as lowercase #rrggbb, so the palette chip for the same
// colour lights up. null means it is not a colour at all.
function normHex(v){
  v=String(v||'').trim().toLowerCase(); if(!v)return '';
  if(v[0]!=='#')v='#'+v;
  if(/^#[0-9a-f]{3}$/.test(v))v='#'+v[1]+v[1]+v[2]+v[2]+v[3]+v[3];
  return HEX_RE.test(v)?v:null;
}
// Several phase mutations made by ONE gesture (a Save that renames and sets the aside)
// become one undo entry: while `undoGroup` is an array, pushUndo collects into it, and
// the collection goes on the stack as a single `multi`, which applySnapshot already
// knows how to undo and redo as a unit. The page says every change is one Ctrl+Z.
function withOneUndo(fn){
  const g=undoGroup=[];
  try{ fn(); } finally{ undoGroup=null; }
  if(g.length===1)pushUndo(g[0]);
  else if(g.length>1)pushUndo({type:'multi',steps:g.reverse()});
}
function wireSetup(){
  const v=$('setupView'); if(!v)return;
  const inp=$('secNew'), btn=$('secAdd'), err=v.querySelector('.sec-err');
  const say=t=>{ err.textContent=t; err.style.display=t?'':'none'; };
  // One add at a time. The id comes from the server, so there is a window between the
  // press and the new row; the button was disabled across it but Enter was not, and five
  // quick Enters made five sections of the same name.
  let adding=false;
  const add=async()=>{
    if(adding)return;
    const nm=inp.value.trim();
    if(!nm){ inp.focus(); return; }
    if(secNameTaken(nm)){ say('There is already a section called "'+nm+'". Pick a name no other section has.'); inp.focus(); return; }
    adding=true; btn.disabled=true;
    try{
      const i=await createPhase(nm,'','');
      inp.value=''; say(''); renderSections();
      // New sections go on the END of the list, which on a twenty-section plan is off
      // the bottom of the card, so the new row is brought into view and flashed once:
      // otherwise the only sign it worked is that the name box emptied.
      const row=v.querySelector(`.sec-row[data-i="${i}"]`);
      if(row){ row.scrollIntoView({block:'nearest'}); row.classList.add('sec-flash'); }
    }
    catch(e){ say('No id could be allocated, so no section was created ('+e.message+').'); }
    finally{ adding=false; btn.disabled=false; }
  };
  btn.addEventListener('click',add);
  inp.addEventListener('keydown',e=>{ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); add(); } });
  inp.addEventListener('input',()=>say(''));
  // New tag (0.61.0): a tag with nothing carrying it yet, kept in the registry so it is
  // listed here and suggested in every tag box. Refused when the plan already has it.
  const tIn=$('tagNew'), tBtn=$('tagAdd'), tErr=v.querySelector('.tag-err');
  const tSay=t=>{ tErr.textContent=t; tErr.style.display=t?'':'none'; };
  const tAdd=()=>{
    const nm=tIn.value.trim();
    if(!nm){ tIn.focus(); return; }
    const why=createTag(nm);
    if(why==='taken'){ tSay('There is already a tag called "'+nm.slice(0,TAG_MAX_LEN)+'".'); tIn.focus(); return; }
    if(why==='empty'){ tSay('That is not a tag. Type a word or two.'); tIn.focus(); return; }
    tIn.value=''; tSay(''); tIn.focus();
    const row=$('suTags').querySelector(`.tag-row[data-tag="${CSS.escape(tagKey(nm.replace(/;/g,' ').trim().slice(0,TAG_MAX_LEN).trim()))}"]`);
    if(row){ row.scrollIntoView({block:'nearest'}); row.classList.add('sec-flash'); }
  };
  tBtn.addEventListener('click',tAdd);
  tIn.addEventListener('keydown',e=>{ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); tAdd(); } else if(e.key==='Escape'){ e.preventDefault(); if(tIn.value)tIn.value=''; else tIn.blur(); tSay(''); } });
  tIn.addEventListener('input',()=>tSay(''));
  wireSecList(v.querySelector('.sec-list'));
  wireTagList($('suTags').querySelector('.tag-list'));
  // Escape closes whatever row editor is open on this page (a name or aside being edited,
  // Remove, the colour palette, a tag's rename box or palette), WHEREVER the focus is: in
  // one of its fields, on one of its buttons, on some other control of the page, or
  // nowhere. 0.58.0 left it to each field's own handler plus one rung of the app-level
  // stack, and a verifier found a focus that neither covered (a field outside the row,
  // which the blur-on-Escape rule took first). Capture, so no field's own Escape can come
  // first. A field that belongs to something else on screen, the New panel say, keeps its
  // own Escape.
  document.addEventListener('keydown',e=>{
    if(e.key!=='Escape'||e.altKey||e.ctrlKey||e.metaKey||prefs.view!=='setup')return;
    const a=document.activeElement;
    if(a&&a!==document.body&&!v.contains(a))return;
    const open=secRowOpen(), ren=tagEditing!==null||tagPalOpen!==null;
    if(!open&&!ren)return;
    e.preventDefault(); e.stopPropagation();
    if(ren){ tagEditing=null; tagPalOpen=null; renderTagsCard(); }
    if(open)renderSections();
    if(a&&a!==document.body&&!a.isConnected)try{ document.activeElement.blur(); }catch(_){}
  },true);
}
// ── The section list's gestures (0.61.0) ──
//
// Drag to reorder: press anywhere on a row that is not a field or a button (the grip at its
// left says so) and move. Under five pixels of movement it is a click, not a drag. While it
// moves, a bar between two rows shows where it will land; the drop is ONE reorderPhases
// call, the very path the up and down arrows used, so one undo entry, one changelog event
// (phase.reorder), and the rows' section indexes, the collapse set and the legend follow
// it. A mouse drags from anywhere on the row; a finger drags from the grip only, so a
// swipe over the list still scrolls it. Alt+Up and Alt+Down move the focused row.
//
// Click to edit: a press and release on the name or the aside opens it in place (the
// pointerup does it, from what the pointerdown recorded, so a repaint between the two,
// a blur commit in another row say, cannot eat the click).
function wireSecList(list){
  if(!list)return;
  let press=null, drop=null;
  const MOVE=5;
  const rows=()=>[...list.querySelectorAll('.sec-row')];
  const stopDrag=()=>{
    if(drop){ drop.remove(); drop=null; }
    list.classList.remove('sec-drag-on');
    list.querySelectorAll('.sec-dragging').forEach(r=>r.classList.remove('sec-dragging'));
  };
  const place=y=>{
    const rs=rows(); let slot=rs.length;
    for(let j=0;j<rs.length;j++){ const b=rs[j].getBoundingClientRect(); if(y<b.top+b.height/2){ slot=j; break; } }
    const to=slot>press.from?slot-1:slot;
    press.to=to;
    if(to===press.from){ drop.style.display='none'; return; }
    drop.style.display='';
    const lr=list.getBoundingClientRect();
    const edge=slot<rs.length?rs[slot].getBoundingClientRect().top:rs[rs.length-1].getBoundingClientRect().bottom;
    drop.style.top=(edge-lr.top-1)+'px';
    // near the edge of the scroller, scroll it, so a long list can be dragged end to end
    const sc=$('suScroll'); if(sc){ const sr=sc.getBoundingClientRect();
      if(y<sr.top+36)sc.scrollTop-=14; else if(y>sr.bottom-36)sc.scrollTop+=14; }
  };
  list.addEventListener('pointerdown',e=>{
    if(e.button!==0)return;
    const row=e.target.closest('.sec-row'); if(!row||row.parentElement!==list)return;
    const fld=e.target.closest('.sec-nm-t,.sec-ld-t');
    if(fld){ press={kind:'field',k:row.dataset.k,f:fld.dataset.f,x:e.clientX,y:e.clientY,id:e.pointerId}; return; }
    if(e.target.closest('button,input,select,textarea,a,.sec-pal,.sec-msg,.sec-tag'))return;
    if(e.pointerType!=='mouse'&&!e.target.closest('.sec-grip'))return;
    press={kind:'drag',k:row.dataset.k,from:+row.dataset.i,to:+row.dataset.i,x:e.clientX,y:e.clientY,id:e.pointerId,on:false,row};
  });
  document.addEventListener('pointermove',e=>{
    if(!press||press.kind!=='drag'||e.pointerId!==press.id)return;
    if(!press.on){
      if(Math.hypot(e.clientX-press.x,e.clientY-press.y)<MOVE)return;
      if(!press.row.isConnected){ press=null; return; }
      press.on=true;
      try{ list.setPointerCapture&&press.row.setPointerCapture(e.pointerId); }catch(_){}
      try{ window.getSelection().removeAllRanges(); }catch(_){}
      list.classList.add('sec-drag-on'); press.row.classList.add('sec-dragging');
      drop=document.createElement('div'); drop.className='sec-drop'; drop.setAttribute('aria-hidden','true'); list.appendChild(drop);
    }
    e.preventDefault();
    place(e.clientY);
  });
  const end=(e,commit)=>{
    if(!press||e.pointerId!==press.id)return;
    const p=press; press=null;
    if(p.kind==='field'){
      if(commit&&Math.hypot(e.clientX-p.x,e.clientY-p.y)<MOVE)openSecField(p.k,p.f);
      return;
    }
    if(!p.on)return;               // a click, not a drag
    stopDrag();
    const j=phaseIndexOfKey(p.k);
    if(commit&&j>=0&&p.to!==j&&p.to>=0&&p.to<phases.length&&reorderPhases(j,p.to)){
      renderSections();
      const r=rowAt(p.to); if(r){ r.classList.add('sec-flash'); try{ r.focus({preventScroll:true}); }catch(_){ r.focus(); } }
    }
  };
  document.addEventListener('pointerup',e=>end(e,true));
  document.addEventListener('pointercancel',e=>end(e,false));
  // A drag ends in a click on whatever is under it; it is not one.
  list.addEventListener('click',e=>{ if(list.classList.contains('sec-drag-on'))e.stopPropagation(); },true);
  list.addEventListener('keydown',e=>{
    const row=e.target.closest&&e.target.closest('.sec-row'); if(!row||row.parentElement!==list)return;
    const fld=e.target.closest('.sec-nm-t,.sec-ld-t');
    if(fld&&(e.key==='Enter'||e.key===' ')&&!e.altKey){ e.preventDefault(); e.stopPropagation(); openSecField(row.dataset.k,fld.dataset.f); return; }
    if(e.altKey&&!e.ctrlKey&&!e.metaKey&&(e.key==='ArrowUp'||e.key==='ArrowDown')&&!/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)){
      e.preventDefault(); e.stopPropagation();
      const i=+row.dataset.i, to=e.key==='ArrowUp'?i-1:i+1;
      if(to<0||to>=phases.length)return;
      if(reorderPhases(i,to)){ renderSections(); const r=rowAt(to); if(r)r.focus(); }
    }
  });
}
// Is a pointer pressed right now? A blur that a press caused waits for the release and its
// click before it commits, because committing repaints the list.
let ptrDown=false;
document.addEventListener('pointerdown',()=>{ ptrDown=true; },true);
document.addEventListener('pointerup',()=>{ ptrDown=false; },true);
document.addEventListener('pointercancel',()=>{ ptrDown=false; },true);
function afterPointer(fn){
  if(!ptrDown){ setTimeout(fn,0); return; }
  const f=()=>{ document.removeEventListener('pointerup',f); document.removeEventListener('pointercancel',f); setTimeout(fn,0); };
  document.addEventListener('pointerup',f); document.addEventListener('pointercancel',f);
}
// Close whatever row editor is open before another opens: an inline name or aside is
// COMMITTED (false when it refused its value and stays open, with its message), a Remove
// form or a palette is dropped, as before.
function settleSecEditors(){
  const open=$('setupView').querySelector('.sec-row.sec-editing');
  if(open&&open._secCommit)return open._secCommit()!==false;
  renderSections(); return true;
}
// Open a section's name or aside for editing, in place. Another row's open editor is
// closed first: an inline one by committing it (a refused value keeps it open and this
// does not open), a Remove form or a palette by dropping it, as before.
function openSecField(k,f){
  if(!settleSecEditors())return;
  const j=phaseIndexOfKey(k); if(j<0)return;
  openInline(rowAt(j),j,f);
}
function openInline(row,i,f,held){
  if(!row)return;
  const p=phases[i], k=phaseKey(p);
  const span=row.querySelector(f==='name'?'.sec-nm-t':'.sec-ld-t'); if(!span)return;
  row.classList.add('sec-editing','sec-inline');
  const inp=document.createElement('input'); inp.type='text';
  inp.className=f==='name'?'sec-nm':'sec-ld'; inp.maxLength=60; inp.autocomplete='off';
  inp.value=held?held.v:(f==='name'?p.name:(p.lead||''));
  // The field a NO OWNER click opens asks for the owner, since that is what the reader
  // came to set; a name or note still fits, and the aside shows whatever is typed.
  inp.placeholder=f==='name'?'Section name':'Owner, or a short note';
  inp.setAttribute('aria-label',f==='name'?'Section name':'Owner or aside beside the name');
  span.replaceWith(inp);
  // '' while open; 'done' once committed, 'cancel' on Escape, 'moved' when a repaint carried
  // the text over to a fresh box (that box takes over, and this one must do nothing more).
  let state='';
  const refuse=(t,keepFocus)=>{ inp.classList.add('bad'); secRowMsg(row,t); if(keepFocus)inp.focus(); return false; };
  const commit=keepFocus=>{
    if(state)return true;
    const j=phaseIndexOfKey(k);
    if(j<0){ state='done'; renderSections(true); return true; }
    const q=phases[j], v=inp.value.trim();
    if(f==='name'){
      if(!v)return refuse('A section needs a name.',keepFocus);
      if(v!==q.name&&secNameTaken(v,q))return refuse('There is already a section called "'+v+'". Pick a name no other section has.',keepFocus);
    }
    state='done';
    // Out of the editing state BEFORE the change repaints the list, so the repaint does not
    // carry this editor over and reopen it.
    row.classList.remove('sec-editing','sec-inline'); row._secHold=null; row._secCommit=null;
    if(f==='name'){ if(v!==q.name)renamePhase(j,v); }
    else setPhaseLead(j,v);
    renderSections(true);
    return true;
  };
  row._secCommit=()=>commit(false);
  row._secHold=()=>{ state='moved'; const foc=document.activeElement===inp;
    return {k,reopen:(r,j)=>{ openInline(r,j,f,{v:inp.value,focused:foc,sel:[inp.selectionStart,inp.selectionEnd],bad:inp.classList.contains('bad')});
      const m=row.querySelector('.sec-msg'); if(m)secRowMsg(r,m.textContent); }}; };
  inp.addEventListener('keydown',e=>{ e.stopPropagation();
    if(e.key==='Enter'){ e.preventDefault(); commit(true); }
    else if(e.key==='Escape'){ e.preventDefault(); state='cancel'; renderSections(); } });
  inp.addEventListener('input',()=>{ inp.classList.remove('bad'); const m=row.querySelector('.sec-msg'); if(m)m.remove(); });
  // Clicking away commits, after the click has landed (afterPointer), so the repaint the
  // commit causes cannot take the clicked control out from under its own click. A box a
  // repaint took out of the page, or that Escape cancelled, does nothing.
  inp.addEventListener('blur',()=>{ afterPointer(()=>{ if(!state&&inp.isConnected&&document.activeElement!==inp)commit(false); }); });
  if(held){
    if(held.bad)inp.classList.add('bad');
    if(held.focused){ inp.focus(); try{ inp.setSelectionRange(held.sel[0],held.sel[1]); }catch(_){} }
  } else { inp.focus(); inp.select(); }
}
// Rebuild the list from phases[]. With `keep`, an open row editor survives the rebuild:
// it is found again by its section's phaseKey (NOT by its index, which an undo, a redo
// or another tab's reorder can change under it), reopened on that section's new row
// with whatever had been typed into it, and dropped only if the section itself is gone.
// Every passive refresh keeps; a deliberate close (a commit, Cancel, Escape) does not.
//
// Until 0.58.0's review the editor was left alone instead, stale, and its buttons still
// carried the index they were opened with, so Ctrl+Z with a Remove open, followed by the
// Remove, deleted whichever section had moved into that slot and merged its rows.
function renderSections(keep){
  renderDatesCard();
  renderCalCard();
  const v=$('setupView'); if(!v)return;
  const list=v.querySelector('.sec-list');
  // Mid-drag the list is not repainted under the pointer; the drop repaints it.
  if(list.classList.contains('sec-drag-on'))return;
  let held=null;
  if(keep){ const ed=list.querySelector('.sec-row.sec-editing'); if(ed&&ed._secHold)held=ed._secHold(); }
  // Any inline editor not carried over is finished with: its blur must not commit later.
  list.querySelectorAll('.sec-row.sec-editing').forEach(r=>{ if(r._secHold&&!held)try{ r._secHold(); }catch(_){} });
  // A half-typed tag survives every repaint, kept or not, and so does the focus in it:
  // the list repaints on every save, including the one the tag just typed caused.
  const tagHold=holdTagInput(list);
  list.innerHTML='';
  const tot=$('secTotal'); if(tot)tot.textContent=phases.length+' section'+(phases.length===1?'':'s');
  phases.forEach((p,i)=>{
    const n=laneCount(i), sp=laneSpan(i);
    const row=document.createElement('div'); row.className='sec-row'; row.dataset.i=String(i);
    row.setAttribute('role','listitem'); row.tabIndex=0;
    row.title='Drag to move this section, or Alt+Up / Alt+Down while the row has the focus. Click the name or the aside to edit it.';
    row.innerHTML=`<span class="sec-grip" aria-hidden="true"><svg viewBox="0 0 8 14" width="8" height="14"><g fill="currentColor"><circle cx="2" cy="2" r="1.2"/><circle cx="6" cy="2" r="1.2"/><circle cx="2" cy="7" r="1.2"/><circle cx="6" cy="7" r="1.2"/><circle cx="2" cy="12" r="1.2"/><circle cx="6" cy="12" r="1.2"/></g></svg></span>`+
      `<button class="sec-sw" title="Colour" style="background:${esc(phaseColor(i))}"></button>`+
      `<span class="sec-name"><span class="sec-nm-t" role="button" tabindex="0" data-f="name" title="${esc(p.name)}. Click to rename">${esc(p.name)}</span>`+
        `<span class="sec-ld-t${p.lead?'':' empty'}" role="button" tabindex="0" data-f="lead" title="${p.lead?esc(p.lead)+'. Click to edit the owner or aside':'No owner or aside yet. Click to add one'}">${p.lead?esc(p.lead):'NO OWNER'}</span></span>`+
      `<span class="sec-count">${n} row${n===1?'':'s'}</span>`+
      (sp.from?`<span class="sec-dates">${esc(fmt(sp.from))}${sp.to!==sp.from?' to '+esc(fmt(sp.to)):''}</span>`
              :`<span class="sec-dates none">no dates</span>`)+
      `<span class="sec-acts">`+
        `<button class="sec-btn danger" data-a="del" title="Remove this section"${phases.length<2?' disabled':''}>Remove</button>`+
      `</span>`+secTagsHtml(p);
    row.dataset.k=phaseKey(p);
    list.appendChild(row);
  });
  list.querySelectorAll('.sec-row').forEach(row=>{
    const k=row.dataset.k;
    // The swatch toggles its own palette: a second click on it closes it again.
    row.querySelector('.sec-sw').addEventListener('click',()=>{
      const was=row.classList.contains('sec-editing')&&row.querySelector('.sec-pal');
      if(!settleSecEditors())return;
      const i=phaseIndexOfKey(k); if(!was&&i>=0)openSwatch(rowAt(i),i); });
    // One row editor at a time: opening a second one closes the first, and what was
    // typed into the first is dropped, the way clicking into another table cell ends
    // the one before. Two open forms on one page is two half-finished edits and no
    // way to tell which Enter saves. Switching lens, by contrast, keeps it.
    row.querySelectorAll('.sec-btn').forEach(b=>b.addEventListener('click',()=>{
      if(b.dataset.a!=='del'||!settleSecEditors())return;
      const i=phaseIndexOfKey(k); if(i>=0)openDelete(rowAt(i),i);
    }));
  });
  list.querySelectorAll('.sec-row').forEach(wireSecTags);
  if(held){
    const j=phases.findIndex(q=>phaseKey(q)===held.k);
    if(j>=0)held.reopen(rowAt(j),j);
  }
  restoreTagInput(list,tagHold);
  renderTagsCard();
}
function rowAt(i){ return $('setupView').querySelector(`.sec-row[data-i="${i}"]`); }

// ── Tags on the Setup page (0.59.0) ──
//
// Every tag edit goes through editTags: one gesture, one undo entry, one save, and one
// changelog batch, however many sections it touches. `change(p,i)` returns a section's
// new list, or nothing to leave it alone. The undo entry is the lists as they were, keyed
// by phaseKey (see applySnapshot 'phase.tags'). The changelog lines come from the diff at
// save time (diffAndLog), tagged `setup`, so an undo and a redo are logged the same way.
function sameTags(a,b){ return JSON.stringify(a||[])===JSON.stringify(b||[]); }
// `refilter` (a rename) returns the reader's tag filter as it should read after the edit.
// It is applied BEFORE the prune, which would otherwise drop the old name first (the
// 0.59.0 review caught exactly that), and the undo entry carries the filter as it was, so
// Ctrl+Z puts the filter back with the tags and redo moves it on again.
//
// Rows too, since 0.60.0: `opt.rows(r)` returns a row's new OWN list, or nothing to leave
// it alone, and the same entry records the rows' lists as they were ([row, list-or-absent]
// pairs), so "rename a tag everywhere" over sections AND rows is still one Ctrl+Z. A row
// cleared of its last tag keeps `tags: []`, which is how the sidecar says "cleared" rather
// than "never touched" (a data-file tag would otherwise come back on reload). `opt.via`
// names the gesture in the changelog: setup, table-edit, batch-editor.
function editTags(change,refilter,opt){
  opt=opt||{};
  const before=[], rowsBefore=[];
  if(change)phases.forEach((p,i)=>{
    const nx=change(p,i); if(!nx)return;
    const t=normalizeTags(nx)||[], cur=p.tags||[];
    if(sameTags(t,cur))return;
    before.push([phaseKey(p),cur.slice()]);
    if(t.length)p.tags=t; else delete p.tags;
  });
  if(opt.rows)items.forEach(r=>{
    if(isGroup(r))return;
    const nx=opt.rows(r); if(!nx)return;
    const t=normalizeTags(nx)||[];
    if(sameTags(t,ownTags(r)))return;
    rowsBefore.push([r,Array.isArray(r.tags)?r.tags.slice():undefined]);
    r.tags=t;
    refreshRowTags(r);
  });
  // The registry (0.61.0): a rename or a remove everywhere takes the tag's registry entry
  // with it, in the same undo entry, so its colour follows a rename and an undo of a remove
  // brings the colour back too.
  const regBefore=opt.reg?regSnap():null, regSig0=opt.reg?regSig():'';
  if(opt.reg)opt.reg();
  const regMoved=!!opt.reg&&regSig()!==regSig0;
  if(!before.length&&!rowsBefore.length&&!regMoved)return 0;
  const sn={type:'phase.tags',list:before};
  if(rowsBefore.length)sn.rows=rowsBefore;
  if(regMoved){ sn.reg=regBefore; regInSidecar=true; }
  if(refilter){
    const nf=refilter(prefs.tagFilter);
    if(JSON.stringify(nf)!==JSON.stringify(prefs.tagFilter)){ sn.filter=prefs.tagFilter.slice(); prefs.tagFilter=nf; savePrefs(); }
  }
  pushUndo(sn);
  afterTagsChange();
  scheduleSave(opt.via||'setup');
  return before.length+rowsBefore.length+(regMoved?1:0);
}
function addPhaseTags(k,words){
  const j=phaseIndexOfKey(k); if(j<0)return 0;
  return editTags((p,i)=>i===j?[...(p.tags||[]),...words]:null);
}
function removePhaseTag(k,tag){
  const j=phaseIndexOfKey(k); if(j<0)return 0;
  return editTags((p,i)=>i===j?(p.tags||[]).filter(t=>tagKey(t)!==tagKey(tag)):null);
}
// Rename a tag everywhere, in place in each list so its position is kept. A new name
// that one of those sections already carries merges into it (the normaliser keeps the
// first). The reader's filter follows the rename rather than silently dropping it.
function renameTagEverywhere(from,to){
  to=String(to||'').trim().slice(0,TAG_MAX_LEN).trim(); if(!to)return 0;
  const k=tagKey(from);
  // Sections and rows alike (0.60.0): "everywhere" means every list that carries it.
  const ren=list=>list.some(t=>tagKey(t)===k)?list.map(t=>tagKey(t)===k?to:t):null;
  return editTags(p=>ren(p.tags||[]),
    cur=>{ const f=[]; cur.forEach(t=>{ const v=tagKey(t)===k?to:t; if(!f.some(x=>tagKey(x)===tagKey(v)))f.push(v); }); return f; },
    {rows:r=>ren(ownTags(r)),reg:()=>{
      // The entry is renamed in place (same `_k`, so the changelog says "renamed"). Onto a
      // name the registry already lists, the two merge: the target keeps its own colour,
      // or takes this one's if it has none.
      const e=regFind(from); if(!e)return;
      const o=regFind(to);
      if(o&&o!==e){ if(!o.color&&e.color)o.color=e.color; tagReg.splice(tagReg.indexOf(e),1); }
      else e.name=to;
    }});
}
function removeTagEverywhere(tag){
  const k=tagKey(tag);
  const rm=list=>list.some(t=>tagKey(t)===k)?list.filter(t=>tagKey(t)!==k):null;
  return editTags(p=>rm(p.tags||[]),null,{rows:r=>rm(ownTags(r)),reg:()=>{ tagReg=tagReg.filter(e=>tagKey(e.name)!==k); }});
}
// The registry's own edits (0.61.0): a new tag, and a colour. One gesture, one undo entry
// ('tag.reg', the registry as it was), one save; the changelog lines come from the diff.
function editReg(fn,via){
  const before=regSnap();
  regMaterialize();
  const sig=regSig();
  fn();
  if(regSig()===sig){ tagReg=before; return false; }
  regInSidecar=true;
  pushUndo({type:'tag.reg',reg:before});
  afterTagsChange();
  scheduleSave(via||'setup');
  return true;
}
// '' when it was made, or why not: 'empty', or 'taken' (the plan already has that tag,
// carried or listed, in any case).
function createTag(name){
  const t=(normalizeTags([String(name||'')])||[])[0];
  if(!t)return 'empty';
  if(planTags().some(e=>e.key===tagKey(t)))return 'taken';
  editReg(()=>{ tagReg.push(regEntry({name:t})); });
  return '';
}
// A colour for a tag, or '' for none. A carried tag the registry does not list yet gets an
// entry in the plan's own spelling.
function setTagColor(tag,color){
  color=normTagColor(color||'');
  return editReg(()=>{
    let e=regFind(tag);
    if(!e){ if(!color)return; const sp=(planTags().find(x=>x.key===tagKey(tag))||{tag}).tag; e=regEntry({name:sp}); tagReg.push(e); }
    if(color)e.color=color; else delete e.color;
  });
}
// "Launch · Program and gates" carries its group in its name. The prefix is what comes
// before the first " · " (spaces on both sides, so a name with a dot in a word is left
// alone). The button adds it as a tag to every such section that lacks it and has room;
// it never renames anything, and it is offered only while there is something to add.
const PREFIX_RE=/^(.+?)\s+·\s+\S/, PREFIX_RE_CUT=/^(.+?)\s+·\s+/;
function namePrefix(name){ const m=String(name||'').match(PREFIX_RE); if(!m)return '';
  const t=m[1].trim(); return t.length<=TAG_MAX_LEN?t:''; }
function prefixCandidates(){
  const out=[];
  phases.forEach((p,i)=>{ const t=namePrefix(p.name); if(!t)return;
    const cur=p.tags||[]; if(cur.length>=TAG_MAX_COUNT||cur.some(x=>tagKey(x)===tagKey(t)))return;
    out.push({i,tag:t}); });
  return out;
}
function tagsFromPrefixes(){
  const c=prefixCandidates(); if(!c.length)return 0;
  const by=new Map(c.map(x=>[x.i,x.tag]));
  return editTags((p,i)=>by.has(i)?[...(p.tags||[]),by.get(i)]:null);
}
function secTagsHtml(p){
  const t=p.tags||[], full=t.length>=TAG_MAX_COUNT;
  return `<div class="sec-tags">`+t.map(x=>`<span class="sec-tag"${tagAttrs(x)}>${esc(x)}<button type="button" class="sec-tag-x" data-tag="${esc(x)}" title="Remove the tag ${esc(x)} from this section" aria-label="Remove the tag ${esc(x)}">&times;</button></span>`).join('')+
    `<input type="text" class="sec-tag-in" list="tagList" maxlength="${TAG_MAX_LEN}" autocomplete="off" placeholder="${full?'12 tags, the most a section can carry':t.length?'+ tag':'+ add a tag'}" aria-label="Add a tag to ${esc(p.name)}"${full?' disabled':''}></div>`;
}
// Type, then Enter or a comma. A pasted "a, b" adds both. The suggestions are every tag
// already in the plan (the datalist), so the same word is not spelled three ways.
function wireSecTags(row){
  const k=row.dataset.k, inp=row.querySelector('.sec-tag-in');
  row.querySelectorAll('.sec-tag-x').forEach(b=>b.addEventListener('click',()=>{ removePhaseTag(k,b.dataset.tag); }));
  if(!inp)return;
  const commit=()=>{
    const words=inp.value.split(',').map(w=>w.trim()).filter(Boolean);
    inp.value='';
    if(words.length)addPhaseTags(k,words);
  };
  inp.addEventListener('keydown',e=>{
    e.stopPropagation();
    if(e.key==='Enter'||e.key===','){ e.preventDefault(); commit(); }
    else if(e.key==='Escape'){ e.preventDefault(); if(inp.value)inp.value=''; else inp.blur(); }
  });
  // A comma that arrives by paste, or by a datalist pick followed by one, adds too.
  inp.addEventListener('input',()=>{ if(inp.value.includes(','))commit(); });
  // Clicking away commits what was typed, the way a table cell does (0.59.0 review);
  // Escape still discards it, because it empties the box first. Two blurs do NOT
  // commit: a repaint taking the box out of the page (the text is carried over to the
  // new box instead), and a press on one of this section's own chip x buttons, which
  // would otherwise repaint the row between its mousedown and its click and eat it.
  inp.addEventListener('blur',e=>{
    if(!inp.isConnected||!inp.value.trim())return;
    if(e.relatedTarget&&e.relatedTarget.closest&&e.relatedTarget.closest('.sec-tags')===inp.closest('.sec-tags'))return;
    // After the focus has landed, so the repaint this causes keeps it where it went
    // (another section's box, say) instead of losing it with the replaced node.
    setTimeout(()=>{ if(inp.isConnected&&inp.value.trim()&&document.activeElement!==inp)commit(); },0);
  });
}
function holdTagInput(list){
  const a=document.activeElement;
  const out=[];
  list.querySelectorAll('.sec-tag-in').forEach(inp=>{
    const focused=inp===a;
    if(!inp.value&&!focused)return;
    const row=inp.closest('.sec-row');
    out.push({k:row&&row.dataset.k,v:inp.value,focused,sel:focused?[inp.selectionStart,inp.selectionEnd]:null});
  });
  return out;
}
function restoreTagInput(list,held){
  (held||[]).forEach(h=>{
    const row=h.k&&list.querySelector(`.sec-row[data-k="${h.k}"]`); if(!row)return;
    const inp=row.querySelector('.sec-tag-in'); if(!inp||inp.disabled)return;
    inp.value=h.v;
    if(h.focused&&!list.querySelector('.sec-row.sec-editing')){ inp.focus(); try{ inp.setSelectionRange(h.sel[0],h.sel[1]); }catch(_){} }
  });
}
// The Tags card: every tag in the plan, how many sections and rows carry it, its colour,
// and the plan-wide edits: click a name to rename it everywhere, Remove to take it off
// everything. "New tag" adds one nothing carries yet (0.61.0). The name-prefix button
// sits on top when it has work to do.
//
// The palette (0.61.0): the three brand colours first, then six chosen to sit with them
// and with the section colours, light enough to read on the dark theme and each walked to
// a readable ink on paper by tagPaint. A hex box for anything else, and No colour.
const TAG_PALETTE=[['#00b8b1','Teal'],['#8c6bf7','Purple'],['#64abff','Blue'],
  ['#ff8a7a','Coral'],['#e8b64a','Amber'],['#5fd7a3','Mint'],['#ec7ab8','Rose'],['#6f7cff','Indigo'],['#8b9bb4','Slate']];
let tagEditing=null;             // the key of the tag whose name is being edited
let tagPalOpen=null;             // the key of the tag whose palette is open
// An open rename box on another tag is committed first, as a click away from it would.
function settleTagRename(){ const nb=$('suTags').querySelector('.tag-nm'); if(nb&&nb._go)nb._go(); }
function openTagRename(k){
  if(tagEditing===k)return;
  settleTagRename();
  tagPalOpen=null; tagEditing=k; renderTagsCard();
  const i=$('suTags').querySelector('.tag-nm'); if(i){ i.focus(); i.select(); }
}
// A click on a tag's name opens its rename box, from the pointerdown's record, so a blur
// commit in another box repainting the card between press and release cannot eat it.
// The order of the tags, which is the order of the registry (0.62.0). One gesture, one
// undo; the legend, the suggestions and the Analytics card follow, since they all read
// planTags().
function reorderTag(key,to){
  return editReg(()=>{
    const i=tagReg.findIndex(e=>tagKey(e.name)===key); if(i<0)return;
    to=Math.max(0,Math.min(tagReg.length-1,to)); if(to===i)return;
    const [e]=tagReg.splice(i,1); tagReg.splice(to,0,e);
  });
}
// A press on a tag's name opens its rename box; a press anywhere else on the row that is
// not a control, moved more than a few pixels, DRAGS the row, exactly as a section row is
// dragged (0.62.0). Alt+Up and Alt+Down move the focused row.
function wireTagList(list){
  let press=null, drop=null;
  const MOVE=5;
  const rows=()=>[...list.querySelectorAll('.tag-row')];
  const stopDrag=()=>{
    if(drop){ drop.remove(); drop=null; }
    list.classList.remove('sec-drag-on');
    list.querySelectorAll('.sec-dragging').forEach(r=>r.classList.remove('sec-dragging'));
  };
  const place=y=>{
    const rs=rows(); let slot=rs.length;
    for(let j=0;j<rs.length;j++){ const b=rs[j].getBoundingClientRect(); if(y<b.top+b.height/2){ slot=j; break; } }
    const to=slot>press.from?slot-1:slot;
    press.to=to;
    if(to===press.from){ drop.style.display='none'; return; }
    drop.style.display='';
    const lr=list.getBoundingClientRect();
    const edge=slot<rs.length?rs[slot].getBoundingClientRect().top:rs[rs.length-1].getBoundingClientRect().bottom;
    drop.style.top=(edge-lr.top-1)+'px';
    const sc=$('suScroll'); if(sc){ const sr=sc.getBoundingClientRect();
      if(y<sr.top+36)sc.scrollTop-=14; else if(y>sr.bottom-36)sc.scrollTop+=14; }
  };
  const focusRow=k=>{ const r=rows().find(x=>x.dataset.tag===k); if(r){ r.classList.add('sec-flash'); try{ r.focus({preventScroll:true}); }catch(_){ r.focus(); } } };
  list.addEventListener('pointerdown',e=>{
    if(e.button!==0)return;
    const row=e.target.closest('.tag-row'); if(!row||row.parentElement!==list){ press=null; return; }
    if(e.target.closest('.tag-nm-t')){ press={kind:'name',k:row.dataset.tag,x:e.clientX,y:e.clientY,id:e.pointerId}; return; }
    if(e.target.closest('button,input,select,textarea,a,.tag-pal,.sec-msg')){ press=null; return; }
    if(e.pointerType!=='mouse'&&!e.target.closest('.sec-grip')){ press=null; return; }
    const from=rows().indexOf(row);
    press={kind:'drag',k:row.dataset.tag,from,to:from,x:e.clientX,y:e.clientY,id:e.pointerId,on:false,row};
  });
  document.addEventListener('pointermove',e=>{
    if(!press||press.kind!=='drag'||e.pointerId!==press.id)return;
    if(!press.on){
      if(Math.hypot(e.clientX-press.x,e.clientY-press.y)<MOVE)return;
      if(!press.row.isConnected){ press=null; return; }
      press.on=true;
      try{ press.row.setPointerCapture(e.pointerId); }catch(_){}
      try{ window.getSelection().removeAllRanges(); }catch(_){}
      list.classList.add('sec-drag-on'); press.row.classList.add('sec-dragging');
      drop=document.createElement('div'); drop.className='sec-drop'; drop.setAttribute('aria-hidden','true'); list.appendChild(drop);
    }
    e.preventDefault();
    place(e.clientY);
  });
  const end=(e,commit)=>{
    if(!press||e.pointerId!==press.id)return;
    const p=press; press=null;
    if(p.kind==='name'){ if(commit&&Math.hypot(e.clientX-p.x,e.clientY-p.y)<MOVE)openTagRename(p.k); return; }
    if(!p.on)return;
    stopDrag();
    if(commit&&p.to!==p.from&&reorderTag(p.k,p.to)){ renderTagsCard(); focusRow(p.k); }
  };
  document.addEventListener('pointerup',e=>end(e,true));
  document.addEventListener('pointercancel',e=>end(e,false));
  list.addEventListener('click',e=>{ if(list.classList.contains('sec-drag-on'))e.stopPropagation(); },true);
  list.addEventListener('keydown',e=>{
    const row=e.target.closest&&e.target.closest('.tag-row'); if(!row||row.parentElement!==list)return;
    if(e.altKey&&!e.ctrlKey&&!e.metaKey&&(e.key==='ArrowUp'||e.key==='ArrowDown')&&!/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)){
      e.preventDefault(); e.stopPropagation();
      const i=rows().indexOf(row), to=e.key==='ArrowUp'?i-1:i+1;
      if(to<0||to>=rows().length)return;
      const k=row.dataset.tag;
      if(reorderTag(k,to)){ renderTagsCard(); focusRow(k); }
    }
  });
}
function renderTagsCard(){
  const card=$('suTags'); if(!card)return;
  const tags=planTags();
  syncTagList(tags);
  $('tagTotal').textContent=plural(tags.length,'tag');
  const pre=$('tagPrefix'), cand=prefixCandidates();
  if(cand.length){
    const names=[...new Map(cand.map(c=>[tagKey(c.tag),c.tag])).values()];
    pre.style.display='';
    pre.innerHTML=`<span class="tp-say">${plural(cand.length,'section')} ${cand.length===1?'is':'are'} named with a prefix, like "${esc(phases[cand[0].i].name)}".</span>`+
      `<button type="button" class="de-btn primary" id="tagPrefixBtn" title="Adds ${esc(names.join(', '))} as tags to the sections whose names start with them. Adds tags only; no section is renamed. One Ctrl+Z takes it back.">Tag them: ${esc(names.slice(0,6).join(', '))}${names.length>6?', ...':''}</button>`;
    pre.querySelector('#tagPrefixBtn').addEventListener('click',()=>tagsFromPrefixes());
  } else { pre.style.display='none'; pre.innerHTML=''; }
  const list=card.querySelector('.tag-list');
  // What was typed into an open rename or hex box survives the repaint, with the focus.
  const nmBox=list.querySelector('.tag-nm'), hexBox=list.querySelector('.tag-hex');
  const keyOf=el=>{ const r=el&&el.closest('.tag-row'); return r?r.dataset.tag:''; };
  const draft=nmBox&&keyOf(nmBox)===tagEditing?{v:nmBox.value,f:document.activeElement===nmBox}:null;
  const hexDraft=hexBox&&keyOf(hexBox)===tagPalOpen?{v:hexBox.value,f:document.activeElement===hexBox,msg:(list.querySelector('.tag-pal .sec-msg')||{}).textContent||''}:null;
  list.querySelectorAll('.tag-nm').forEach(i=>{ i._moved=true; });
  if(tagEditing&&!tags.some(e=>e.key===tagEditing))tagEditing=null;
  if(tagPalOpen&&!tags.some(e=>e.key===tagPalOpen))tagPalOpen=null;
  list.innerHTML=tags.length?'':`<div class="tag-empty">No tags yet. Make one with New tag above, type one into the box under any section below, or give a row tags in its editor or the table's Tags column${cand.length?', or tag them all from their name prefixes with the button above':''}.</div>`;
  tags.forEach(e=>{
    const row=document.createElement('div'); row.className='tag-row'; row.dataset.tag=e.key; row.setAttribute('role','listitem'); row.tabIndex=0;
    const secs=e.idx.map(i=>phases[i].name);
    const rowNames=e.rows?items.filter(r=>!isGroup(r)&&ownTags(r).some(t=>tagKey(t)===e.key)).map(r=>r.name||''):[];
    const tip=[secs.length?'Sections: '+secs.join(', '):'',rowNames.length?'Rows: '+rowNames.slice(0,12).join(', ')+(rowNames.length>12?', ...':''):''].filter(Boolean).join('\n')||'Nothing carries this tag yet. Add it to a section below, or to a row in its editor or the Table.';
    row.innerHTML=`<span class="sec-grip" aria-hidden="true" title="Drag to move this tag, or Alt+Up / Alt+Down while the row has the focus"><svg viewBox="0 0 8 14" width="8" height="14"><g fill="currentColor"><circle cx="2" cy="2" r="1.2"/><circle cx="6" cy="2" r="1.2"/><circle cx="2" cy="7" r="1.2"/><circle cx="6" cy="7" r="1.2"/><circle cx="2" cy="12" r="1.2"/><circle cx="6" cy="12" r="1.2"/></g></svg></span>`+
      `<button type="button" class="tag-sw${e.color?'':' none'}" title="${e.color?'Colour '+e.color+'. Click to change it':'No colour. Click to give this tag one'}" aria-label="Colour of the tag ${esc(e.tag)}"${e.color?` style="background:${e.color}"`:''}></button>`+
      `<span class="tag-name"><span class="sec-tag tag-nm-t" role="button" tabindex="0" title="Click to rename ${esc(e.tag)} on every section and row that carries it"${tagAttrs(e.tag)}>${esc(e.tag)}</span></span>`+
      `<span class="tag-n${e.used?'':' unused'}" title="${esc(tip)}">${esc(tagCountText(e))}</span>`+
      `<span class="tag-acts"><button type="button" class="tag-btn danger" data-a="del" title="${e.used?'Remove this tag from '+esc(tagCountText(e))+'. One Ctrl+Z puts it back.':'Remove this tag from the plan. One Ctrl+Z puts it back.'}">Remove</button></span>`;
    list.appendChild(row);
    row.querySelector('[data-a="del"]').addEventListener('click',()=>{ tagEditing=null; tagPalOpen=null; removeTagEverywhere(e.tag); });
    row.querySelector('.tag-sw').addEventListener('click',()=>{ settleTagRename(); tagEditing=null; tagPalOpen=tagPalOpen===e.key?null:e.key; renderTagsCard();
      const h=list.querySelector('.tag-hex'); if(h)h.focus(); });
    row.querySelector('.tag-nm-t').addEventListener('keydown',ev=>{ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); ev.stopPropagation(); openTagRename(e.key); } });
    if(tagEditing===e.key){
      row.classList.add('tag-editing');
      const nm=document.createElement('input'); nm.type='text'; nm.className='tag-nm'; nm.maxLength=TAG_MAX_LEN; nm.autocomplete='off';
      nm.value=draft?draft.v:e.tag; nm.setAttribute('aria-label','New name for the tag '+e.tag);
      row.querySelector('.tag-name').replaceChildren(nm);
      let done=false;
      const go=()=>{ if(done||nm._moved)return; done=true; const v=nm.value.trim(); tagEditing=null;
        if(!(v&&v!==e.tag&&renameTagEverywhere(e.tag,v)))renderTagsCard(); };
      nm._go=go;
      nm.addEventListener('keydown',ev=>{ ev.stopPropagation();
        if(ev.key==='Enter'){ ev.preventDefault(); go(); }
        else if(ev.key==='Escape'){ ev.preventDefault(); done=true; tagEditing=null; renderTagsCard(); } });
      nm.addEventListener('blur',()=>{ afterPointer(()=>{ if(!done&&!nm._moved&&nm.isConnected&&document.activeElement!==nm)go(); }); });
      if(draft&&draft.f)nm.focus();
    }
    if(tagPalOpen===e.key){
      row.classList.add('tag-editing');
      const w=document.createElement('div'); w.className='tag-pal';
      w.innerHTML=TAG_PALETTE.map(([hex,nm],i)=>(i===3?'<span class="tag-pal-sep" aria-hidden="true"></span>':'')+
          `<button type="button" class="tag-pc${e.color===hex?' on':''}" data-hex="${hex}" title="${nm} ${hex}${i<3?', a brand colour':''}" aria-label="${nm}" style="background:${hex}"></button>`).join('')+
        `<input type="text" class="tag-hex" placeholder="#rrggbb" maxlength="7" value="${esc(hexDraft?hexDraft.v:(e.color||''))}" aria-label="Colour of ${esc(e.tag)} as #rrggbb">`+
        `<button type="button" class="tag-btn" data-a="nocolor" title="Take the colour off this tag"${e.color?'':' disabled'}>No colour</button>`;
      row.appendChild(w);
      const apply=c=>{ tagPalOpen=null; if(!setTagColor(e.tag,c))renderTagsCard(); };
      w.querySelectorAll('.tag-pc').forEach(b=>b.addEventListener('click',()=>apply(b.dataset.hex)));
      w.querySelector('[data-a="nocolor"]').addEventListener('click',()=>apply(''));
      const hx=w.querySelector('.tag-hex');
      hx.addEventListener('keydown',ev=>{ ev.stopPropagation();
        if(ev.key==='Enter'){ ev.preventDefault(); const nv=normHex(hx.value);
          if(nv===null){ hx.classList.add('bad'); secRowMsg(w,'"'+hx.value.trim()+'" is not a colour. Type six hex digits, like #e8b64a, or three, like #eb4.'); return; }
          apply(nv); }
        else if(ev.key==='Escape'){ ev.preventDefault(); tagPalOpen=null; renderTagsCard(); } });
      if(hexDraft){ if(hexDraft.msg){ hx.classList.add('bad'); secRowMsg(w,hexDraft.msg); } if(hexDraft.f)hx.focus(); }
    }
  });
}
// Where a section is NOW. Every row editor resolves its section through this at the
// moment it acts, never through the index it was opened with.
function phaseIndexOfKey(k){ return phases.findIndex(q=>phaseKey(q)===k); }
// An inline message under a row editor, for the refusals (a taken name, a bad colour).
function secRowMsg(wrap,t){
  let m=wrap.querySelector('.sec-msg');
  if(!m){ m=document.createElement('div'); m.className='sec-msg'; wrap.appendChild(m); }
  m.textContent=t;
}
function openSwatch(row,i,held){
  row.classList.add('sec-editing');
  const k=phaseKey(phases[i]);
  const cur=(phases[i].color||'').toLowerCase();
  const wrap=document.createElement('div');
  wrap.className='sec-pal'; wrap.style.cssText='width:100%;order:9';
  wrap.innerHTML=SEC_PALETTE.map(([hex,nm])=>
      `<button class="sec-chip${cur===hex?' on':''}" data-hex="${hex}" title="${esc(nm)}" style="background:${hex||'var(--ph'+((i%3)+1)+')'}"></button>`).join('')+
    `<input type="text" class="sec-hex" placeholder="#rrggbb" maxlength="7" value="${esc(held?held.hex:cur)}" style="width:88px;margin-left:6px" aria-label="Colour as #rrggbb">`;
  row.appendChild(wrap);
  const hex=wrap.querySelector('.sec-hex');
  row._secHold=()=>({k,reopen:(r,j)=>openSwatch(r,j,{hex:hex.value})});
  const apply=c=>{ const j=phaseIndexOfKey(k); if(j>=0)recolorPhase(j,c); renderSections(); };
  wrap.querySelectorAll('.sec-chip').forEach(c=>c.addEventListener('click',()=>apply(c.dataset.hex)));
  hex.addEventListener('keydown',e=>{ e.stopPropagation();
    if(e.key==='Enter'){ const nv=normHex(hex.value);
      if(nv===null){ hex.style.borderColor='#f85149';
        secRowMsg(wrap,'"'+hex.value.trim()+'" is not a colour. Type six hex digits, like #3fb950, or three, like #3b5.'); return; }
      apply(nv); }
    else if(e.key==='Escape'){ renderSections(); } });
  if(!held)hex.focus();
}
function openDelete(row,i,held){
  const n=laneCount(i), k=phaseKey(phases[i]);
  row.classList.add('sec-editing');
  const wrap=document.createElement('div');
  wrap.style.cssText='display:flex;gap:6px;width:100%;order:9;padding:6px 0 0 var(--sec-indent);align-items:center;flex-wrap:wrap';
  if(!n){
    wrap.innerHTML=`<span style="font-size:11px;color:var(--text2);flex:1">Remove "${esc(phases[i].name)}"? It has no rows.</span>`+
      `<button class="de-btn primary sec-go" style="padding:2px 10px">Remove</button>`+
      `<button class="de-btn secondary sec-no" style="padding:2px 10px">Cancel</button>`;
  } else {
    // A populated section cannot just go: its rows have to land somewhere, and the
    // user says where. There is no option that discards them. The options carry the
    // destination's phaseKey, not its index, for the same reason the editor does.
    const opts=phases.map((p,j)=>j===i?'':`<option value="${esc(phaseKey(p))}">${esc(p.name)}</option>`).join('');
    wrap.innerHTML=`<span style="font-size:11px;color:var(--text2);flex:1 1 100%">`+
      `"${esc(phases[i].name)}" holds ${n} row${n===1?'':'s'}. Move ${n===1?'it':'them'} to:</span>`+
      `<select class="sec-dest" style="max-width:100%" aria-label="Move the rows to">${opts}</select>`+
      `<button class="de-btn primary sec-go" style="padding:2px 10px">Move and remove</button>`+
      `<button class="de-btn secondary sec-no" style="padding:2px 10px">Cancel</button>`;
  }
  row.appendChild(wrap);
  const sel=wrap.querySelector('.sec-dest');
  if(sel)wireAppSelect(sel);
  if(sel&&held&&held.dest&&[...sel.options].some(o=>o.value===held.dest))sel.value=held.dest;
  row._secHold=()=>({k,reopen:(r,j)=>openDelete(r,j,{dest:sel?sel.value:''})});
  wrap.querySelector('.sec-no').addEventListener('click',()=>renderSections());
  wrap.querySelector('.sec-go').addEventListener('click',()=>{
    const j=phaseIndexOfKey(k); if(j<0){ renderSections(); return; }
    // The row count is read again too: an undo can have put rows into a section that
    // was empty when the form opened, and deletePhase refuses to orphan them.
    let to;
    if(sel){ to=phaseIndexOfKey(sel.value); if(to<0){ renderSections(true); return; } }
    if(deletePhase(j,to))renderSections(); else renderSections(true);
  });
  if(!held)(sel||wrap.querySelector('.sec-no')).focus();
}

// ── Milestone editor ──
// The small milestone panel (name, date, description) is gone (0.65.0): a milestone
// click opens the item editor, which has every one of those fields.
// ── Batch editor (relative adjustments) ──
function daysBetween(a,b){const[ay,am,ad]=a.split('-').map(Number),[by,bm,bd]=b.split('-').map(Number);return Math.round((new Date(by,bm-1,bd)-new Date(ay,am-1,ad))/86400000);}

function wireBatchCopy(){ const b=$('beCopyIds'); if(b)b.addEventListener('click',e=>{e.stopPropagation();copySelectedIds();}); }
// Dates are the one thing a selection cannot do uniformly now that milestones are
// in it. A task has a start and an end; a milestone has one date and no duration,
// so "shift the end by 3 days" has no meaning for it. The panel edits the TASKS
// and says how many rows it is leaving alone, rather than inventing a rule for a
// milestone or refusing a mixed selection outright.
function openBatchEditor(){
  const rows=selectedRows();
  const tasks=rows.filter(r=>r&&r.start&&r.type!=='milestone');
  const others=rows.length-tasks.length;
  if(!tasks.length){closeEditor();return;}
  const earliest=tasks.reduce((a,r)=>r.start<a?r.start:a,tasks[0].start);
  const latest=tasks.reduce((a,r)=>r.end>a?r.end:a,tasks[0].end);

  // The layout every panel has (edShell, 0.79.0). The two dates and the three shifts land
  // on Apply; the tags and the effort under them act at once, so they are set apart.
  const shift=(label,id)=>edF(label,`<input type="number" id="${id}" value="0" step="1">`,{w:'third',for:id,hint:'days'});
  editorEl.innerHTML=edShell({
    title:`${tasks.length} task${tasks.length===1?'':'s'} selected`,
    sub:(others?`${others} milestone${others===1?'':'s'} unchanged. `:'')+'All changes are relative, each task shifts from its own dates',
    body:edF('earliest start',`<input type="date" id="bStart" value="${earliest}">`,{w:'half',for:'bStart'})
      +edF('latest end',`<input type="date" id="bEnd" value="${latest}">`,{w:'half',for:'bEnd'})
      +'<div class="ed-note">Changing a date shifts all selected by the same offset</div>'
      +shift('shift all','bShiftAll')+shift('shift start','bShiftS')+shift('shift end','bShiftE')
      +'<div class="ed-f sep" id="bTagsWrap"></div>'
      +'<div class="ed-f sep" id="bEffWrap"></div>',
    actions:'<button class="de-btn primary" id="bApply">Apply</button><button class="de-btn secondary" id="bClose">Cancel</button>'
      +'<button class="de-btn secondary" id="beCopyIds" title="Copy the selected items\' permanent IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button>'
      +`<button class="de-btn danger" id="bDelete" title="${esc(batchDeleteTitle(rows.length,others))}">Delete</button>`,
  });
  renderBatchTags();
  renderBatchEffort();
  // In the middle of the room the window leaves it, measured once it is filled in.
  showEditor((w,h)=>({x:(window.innerWidth-w)/2,y:(window.innerHeight-edBottomBand()-h)/2}));
  // Enter applies and Escape cancels, as in the item editor and the New panel. The tag and
  // effort boxes keep their own Enter (it adds the tag, it sets the effort) and Escape.
  setEditorKeys(e=>{e.stopPropagation();
    if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('bApply').click();}
    else if(e.key==='Escape'){e.preventDefault();closeEditor();}});

  $('bApply').addEventListener('click',()=>{
    const newStart=$('bStart').value, newEnd=$('bEnd').value;
    const startDelta=daysBetween(earliest,newStart);
    const endDelta=daysBetween(latest,newEnd);
    const all=parseInt($('bShiftAll').value)||0, ds=parseInt($('bShiftS').value)||0, de=parseInt($('bShiftE').value)||0;
    const totalStartShift=startDelta+all+ds, totalEndShift=endDelta+all+de;
    if(totalStartShift===0&&totalEndShift===0){closeEditor();return;}
    const ids=tasks.map(r=>r.id);
    snapshotMany(ids);
    tasks.forEach(r=>{r.start=addDays(r.start,totalStartShift);r.end=addDays(r.end,totalEndShift);paintBar(r.id);});
    drawDeps();
    scheduleSave('batch-editor');closeEditor();
  });
  $('bClose').addEventListener('click',closeEditor);
  $('bDelete').addEventListener('click',()=>deleteBatchFromPanel());
  wireBatchCopy();
}
// Effort across the selection (0.63.1). Like the tags, it acts at once rather than on
// Apply: one press sets (or, with the box empty, clears) the estimate on every selected
// TASK, as one undo entry and one changelog batch (`effort`, via batch-editor). Milestones
// carry no effort and are left alone. The line above the box says where the selection
// stands, so estimating a lane is: select it, type 4, Enter.
function renderBatchEffort(){
  const w=$('bEffWrap'); if(!w)return;
  const tasks=selectedRows().filter(r=>r&&!isGroup(r)&&!isMs(r)&&r.id);
  if(!tasks.length){ w.innerHTML=''; w.style.display='none'; return; }
  w.style.display='';
  const est=tasks.filter(r=>effortOf(r)!==null), sum=est.reduce((a,r)=>a+effortOf(r),0);
  const same=est.length===tasks.length&&est.every(r=>effortOf(r)===effortOf(est[0]));
  // A field of the panel's grid (edF's shape: the label, then one block), filled in place.
  w.innerHTML=`<label for="bEffort">effort <span class="de-hint">on every selected task, saved at once</span></label><div class="ed-c"><div class="ed-inline">`
    +`<input type="text" id="bEffort" inputmode="decimal" autocomplete="off" placeholder="hours, 2d, 1w" title="${esc(EFFORT_HELP)}" aria-label="Effort to set on every selected task" value="${same?esc(String(effortOf(est[0]))):''}">`
    +`<button type="button" class="de-btn secondary" id="bEffortSet" title="Set this effort on all ${plural(tasks.length,'selected task')}, or with the box empty, clear it. Milestones carry none. One Ctrl+Z takes it back.">Set on all</button></div>`
    +`<div class="bt-none" id="bEffSum">${est.length} of ${plural(tasks.length,'task')} estimated${est.length?', '+esc(fmtEffort(sum))+' in all':''}</div>`
    +`<div class="bt-none" id="bEffErr" style="color:var(--alarm);display:none"></div></div>`;
  const inp=$('bEffort');
  const go=()=>{
    const v=parseEffort(inp.value);
    if(Number.isNaN(v)){ const er=$('bEffErr'); er.textContent='Effort is hours, like 16, 2d or 1w; empty clears it.'; er.style.display=''; inp.focus(); return; }
    const todo=tasks.filter(r=>effortOf(r)!==v);
    if(!todo.length){ inp.focus(); return; }
    snapshotMany(todo.map(r=>r.id));
    todo.forEach(r=>setEffort(r,v));
    scheduleSave('batch-editor');
    if(prefs.view==='table')renderTable();
    renderBatchEffort();
    const i2=$('bEffort'); if(i2)i2.focus();
    flash(saveInd,(v===null?'effort cleared on ':'effort set on ')+plural(todo.length,'task'));
  };
  // Stops its own click: the repaint takes the button out of the page, and a detached
  // target reads as a click outside the panel (the same reason as the tag block above).
  $('bEffortSet').addEventListener('click',e=>{ e.stopPropagation(); go(); });
  inp.addEventListener('keydown',e=>{ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); go(); } else if(e.key==='Escape'){ e.preventDefault(); if(inp.value)inp.value=''; else inp.blur(); } });
}
// Tags across the selection (0.60.0). Unlike the dates above, a tag means the same thing
// on a milestone as on a task, so these act on EVERY selected row, milestones included,
// and they act at once rather than on Apply: each press is its own edit, one undo entry
// and one changelog batch (`tags`, via batch-editor) however many rows it touches. The
// chips list the own tags the selection carries, with how many rows carry each; the x
// takes that tag off all of them.
function renderBatchTags(){
  const w=$('bTagsWrap'); if(!w)return;
  const rows=selectedRows().filter(r=>r&&!isGroup(r));
  const m=new Map();
  rows.forEach(r=>ownTags(r).forEach(t=>{ const k=tagKey(t); if(!m.has(k))m.set(k,{t,n:0}); m.get(k).n++; }));
  w.innerHTML=`<label for="bTag">tags <span class="de-hint">on every selected row, saved at once</span></label><div class="ed-c"><div class="ed-inline">`
    +`<input type="text" id="bTag" list="tagList" maxlength="${TAG_MAX_LEN}" autocomplete="off" placeholder="a tag" aria-label="A tag to add to every selected row">`
    +`<button type="button" class="de-btn secondary" id="bTagAdd" title="Add this tag to all ${plural(rows.length,'selected row')}, milestones included. One Ctrl+Z takes it back.">Add to all</button></div>`
    +(m.size?`<div class="bt-chips">`+[...m.values()].map(e=>`<span class="sec-tag"${tagAttrs(e.t)} title="${esc(e.t)}: on ${e.n} of the ${rows.length} selected">${esc(e.t)} <small>${e.n}</small><button type="button" class="bt-x" data-tag="${esc(e.t)}" title="Remove ${esc(e.t)} from every selected row that carries it" aria-label="Remove ${esc(e.t)} from every selected row">&times;</button></span>`).join('')+'</div>'
      :`<div class="bt-none">No selected row carries a tag of its own.</div>`)+'</div>';
  const inp=$('bTag');
  const add=()=>{ const words=inp.value.split(',').map(x=>x.trim()).filter(Boolean); if(!words.length){ inp.focus(); return; }
    batchTags(r=>[...ownTags(r),...words]); };
  // Each press repaints this block, which takes the pressed button out of the page before
  // the document's click-outside check sees the click, and a detached target reads as
  // "outside": so the click stops here, as the tag box's x does.
  $('bTagAdd').addEventListener('click',e=>{ e.stopPropagation(); add(); });
  // A text field stops its own keys, so Ctrl+Z in it is the field's undo and not the plan's.
  inp.addEventListener('keydown',e=>{ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); add(); } else if(e.key==='Escape'){ e.preventDefault(); if(inp.value)inp.value=''; else inp.blur(); } });
  w.querySelectorAll('.bt-x').forEach(b=>b.addEventListener('click',e=>{ e.stopPropagation(); const k=tagKey(b.dataset.tag);
    batchTags(r=>ownTags(r).some(t=>tagKey(t)===k)?ownTags(r).filter(t=>tagKey(t)!==k):null); }));
}
function batchTags(fn){
  const set=new Set(selectedRows());
  const n=editTags(null,null,{rows:r=>set.has(r)?fn(r):null,via:'batch-editor'});
  renderBatchTags();
  const inp=$('bTag'); if(inp)inp.focus();
  if(n)flash(saveInd,'tags changed on '+plural(n,'row'));
  return n;
}
// The one sentence the batch Delete has to get right. Everything ABOVE it in this panel
// is about the dates, and the dates are a task-only edit, so the headline says how many
// milestones it is leaving alone. Delete is not a date edit: it takes the SELECTION, the
// milestones with it, which is what the same selection has always meant to the chart's
// double-Delete. The two counts would otherwise read as one, so the mixed case names its
// total out loud rather than letting "3 tasks selected" be read as "3 rows will go".
function batchDeleteTitle(total,others){
  const rows=total+(total===1?' row':' rows');
  if(!others)return 'Delete these '+rows+' from the plan. Undoable with Ctrl+Z';
  return 'Delete all '+rows+' selected, the '+others+(others===1?' milestone':' milestones')+' included. Undoable with Ctrl+Z';
}

// One milestone's place and tooltips, then the arrows (a moved diamond moves its arrows).
function updateMilestone(r){ if(paintMilestone(r))drawDeps(); }
function paintMilestone(r){
  if(!r||!r._msEl)return false;
  const x=toX(r.date);
  r._msEl.style.left=x+'px';
  // Diamond style hovers the hit strip and line style hovers the cap, so both
  // carry the tooltip and both have to be restated, not just the cap.
  if(r._msHit){r._msHit.style.left=x+'px';r._msHit.dataset.dates=fmt(r.date);r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';}
  if(r._msLine){
    r._msLine.style.left=x+'px';
    const cap=r._msLine.querySelector('.ms-cap');
    if(cap){cap.dataset.dates=fmt(r.date);cap.dataset.title=r.name;cap.dataset.body=r.description||'';}
  }
  syncEditorDates(r);
  return true;
}
function snapshotMs(r){ pushUndo(msSnap(r)); }
// Milestone chrome that is not the date: the label row, the tooltip body and the
// dashed rule's tag all restate the milestone, so they move together.
function refreshMsChrome(r){
  if(!r)return;
  syncCancelMark(r);
  if(r._nameSpan)setHtmlIfChanged(r._nameSpan,nameInner(r));
  else if(r._lDiv&&r._lDiv.textContent!==r.name)r._lDiv.textContent=r.name;
  if(r._msHit){r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';r._msHit.dataset.prov=provLabel(r);r._msHit.dataset.tags=ownTags(r).join(', ');}
  if(r._msLine){const cap=r._msLine.querySelector('.ms-cap');
    if(cap){cap.textContent=(r.name||'').replace(/^M(\d+):\s*/,'M$1 ');cap.dataset.title=r.name;cap.dataset.body=r.description||'';cap.dataset.prov=provLabel(r);cap.dataset.tags=ownTags(r).join(', ');}}
}

// The bar's tooltip lives in its data-* attributes, so restating the item means
// restating those too. This used to patch only the geometry and the dates, so a
// description restored from the sidecar (or edited in the table) left the bar
// hovering the version that was in the data file.
// One bar's position, colour and tooltip. Every arrow is redrawn after it, because a
// moved bar moves the arrows into and out of it.
function updateBar(id){ if(paintBar(id))drawDeps(); }
// The same paint for EVERY bar, and the arrows once at the end (0.75.1). Startup, a live
// merge, Auto-schedule and a group undo called updateBar per bar, so the whole arrow layer
// (every route, every layout read) was redrawn once per bar: about 480 times on a real
// plan, 15 to 25 seconds of one CPU core frozen, in every open tab, for every save anyone
// made. Six people in a meeting froze each other's laptops that way.
function updateBars(){ Object.keys(barEls).forEach(paintBar); drawDeps(); }
function paintBar(id){
  const be=barEls[id]; if(!be)return false;
  const r=(items[be.idx]&&items[be.idx].id===id)?items[be.idx]:items.find(d=>d.id===id); if(!r)return false;
  if(r._noteEl&&r._noteEl.textContent!==(r.note||''))r._noteEl.textContent=r.note||'';
  const x1=toX(r.start),x2=toX(r.end),b=be.bar;
  setStyleIfChanged(b,'left',x1+'px');setStyleIfChanged(b,'width',Math.max(x2-x1,6)+'px');setStyleIfChanged(b,'background',phaseColor(r.phase));
  setDataIfChanged(b,'dates',fmt(r.start)+' → '+fmt(r.end));setDataIfChanged(b,'title',r.name);setDataIfChanged(b,'body',r.description||'');setDataIfChanged(b,'prov',provLabel(r));setDataIfChanged(b,'tags',ownTags(r).join(', '));
  syncEditorDates(r);
  return true;
}
// The shape goes with the content: every opener declares which one it wants, so closing
// has to leave the element without any of them rather than let the last panel's aspect
// or its metrics ride along into the next one.
// ONE keydown listener on the panel at a time. Both panels used to add their own on
// every open, to an element that OUTLIVES them: .editor is permanent and only its
// innerHTML is replaced. So the listeners piled up, and after three trips through the
// edit panel an Enter pressed in the CREATE panel ran three stale edit-panel handlers,
// each reaching for an Apply button that was no longer on the page and each throwing.
// Swapping rather than adding means exactly one is live and it always belongs to the
// panel that is actually mounted. wireEditorTab's listener is not one of these: it is
// wired once at start-up, reads only the focused element, and outlives every panel on
// purpose.
let edPanelKeys=null;
function setEditorKeys(fn){
  if(edPanelKeys)editorEl.removeEventListener('keydown',edPanelKeys);
  edPanelKeys=fn||null;
  if(edPanelKeys)editorEl.addEventListener('keydown',edPanelKeys);
}
// Closes the lists the PANEL opened, and only those: a Table cell's list is opened by the
// same click that tells an open panel to close, and must survive it.
function closeEditor(){edSubject=null;scheduleFocus();setEditorKeys(null);if(cellList&&editorEl.contains(cellList.sel))closeCellList();if(ownerCombo&&editorEl.contains(ownerCombo.inp))closeOwnerCombo();editorEl.classList.remove('show','square','compact');editorEl.innerHTML='';}
function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.remove('show');if(typeof closeReqPanel==='function')closeReqPanel();}
// ── The keyboard on the menus (0.69.0) ──
// Export, Import, Help and the who-card are one kind of menu, opened from a button that
// keeps the focus. While one is open the arrows walk its items, Home and End jump to the
// ends, Enter takes the marked item, Escape closes the menu, and Tab closes it on the way
// to the next control. The mark is the same highlight the pointer gives, and the pointer
// moves it too, so the two never disagree. Help rows are text, not choices, and are
// skipped. Read on the window in the capture phase so the page's own arrow keys (row
// focus) and Escape chain never see a key the menu took.
function openMenuEl(){ return [exportMenu,importMenu].find(m=>m.classList.contains('show'))||null; }
function menuItems(m){ return [...m.querySelectorAll('.dd-item')].filter(it=>it.style.cursor!=='default'); }
function menuMark(m,i){ const its=menuItems(m); its.forEach((it,j)=>it.classList.toggle('active',j===i)); if(its[i]&&its[i].scrollIntoView)its[i].scrollIntoView({block:'nearest'}); }
window.addEventListener('keydown',e=>{
  const m=openMenuEl(); if(!m)return;
  if(e.key==='Escape'){ e.preventDefault(); e.stopImmediatePropagation(); closeMenus(); return; }
  if(e.key==='Tab'){ closeMenus(); return; }
  const its=menuItems(m); if(!its.length)return;
  const cur=its.findIndex(it=>it.classList.contains('active'));
  if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Home'||e.key==='End'){
    e.preventDefault(); e.stopImmediatePropagation();
    menuMark(m,e.key==='Home'?0:e.key==='End'?its.length-1:e.key==='ArrowDown'?(cur<0?0:Math.min(its.length-1,cur+1)):(cur<0?its.length-1:Math.max(0,cur-1)));
    return;
  }
  if(e.key==='Enter'&&cur>=0){ e.preventDefault(); e.stopImmediatePropagation(); its[cur].click(); return; }
  // Typing marks the item it starts (0.72.0), the same search as every list.
  if(e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&/\S/.test(e.key)){
    e.preventDefault(); e.stopImmediatePropagation();
    const now=performance.now(); if(now-(m._taAt||0)>TA_GAP_MS)m._ta=''; m._taAt=now; m._ta=(m._ta||'')+e.key.toLowerCase();
    const t=it=>it.textContent.trim().toLowerCase(), q=m._ta;
    let j=its.findIndex(it=>t(it).startsWith(q)); if(j<0)j=its.findIndex(it=>t(it).includes(q));
    if(j>=0)menuMark(m,j);
  }
},true);
[exportMenu,importMenu].forEach(m=>m.addEventListener('mousemove',e=>{
  const it=e.target.closest('.dd-item'); if(!it||it.style.cursor==='default')return;
  menuItems(m).forEach(x=>x.classList.toggle('active',x===it));
}));

// ── Drag (edge-resize, body-move horizontal, reorder vertical) ──
let dragState=null;

// Winding a drag up is the same few steps wherever it winds up, so both ways out share
// them. The ordinary way out is the release. The other one is dependency mode arriving
// mid-press: the mousedown guard below refuses to OPEN a resize, a move or a reorder in
// the mode, but a Shift+D pressed with the button already down used to leave whichever
// of them was open running underneath it, so the mode with no resize handles went on
// resizing. Now the mode closes what is open on its way in and the guard covers both
// halves of the same rule.
//
// A horizontal drag banks what it has done rather than throwing it away: the pointer has
// already written those dates into the model and redrawn from them, so dropping the state
// without a save would leave the page and the file disagreeing. A reorder is the opposite,
// because nothing has moved in items[] until the drop, so it is abandoned exactly the way
// Escape abandons one.
function endBarDrag(){
  if(!dragState)return;
  scheduleSave(dragState.kind==='ms'?'drag-milestone':(dragState.side==='move'?'drag-move':'drag-resize'));
  dragState=null;document.body.style.cursor='';
}
function cancelReorder(){
  if(!reorderState||!reorderState.active)return;
  reorderState.moveIds.forEach(mid=>{const r=rowByUid(mid);
    if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}});
  hideDropIndicators();document.body.style.cursor='';reorderState=null;gesture=null;cancelledDrag=true;
}

function wireBarDrags(){
bars.addEventListener('mousedown',e=>{
  if(depMode)return;                      // no edge resize, no move, no reorder arming
  const handle=e.target.closest('.drag-handle');
  const bar=handle?handle.closest('.bar[data-id]'):e.target.closest('.bar[data-id]');
  if(!bar)return;

  if(handle){
    e.preventDefault();hideTip();
    const id=bar.dataset.id, r=items.find(d=>d.id===id);
    const side=handle.classList.contains('left')?'left':'right';
    snapshot(id);
    if(r.uid&&!selected.has(r.uid)){selected.clear();selected.add(r.uid);selAnchor=r.uid;refreshSelVisuals();updateSelCount();}
    dragState={id,side,startX:e.clientX,origLeft:parseFloat(bar.style.left),origWidth:parseFloat(bar.style.width),bar,r,moveIds:[id],originals:{[id]:{left:parseFloat(bar.style.left),width:parseFloat(bar.style.width),start:r.start,end:r.end}}};
    document.body.style.cursor='ew-resize';
    return;
  }

  gesture={id:bar.dataset.id,startX:e.clientX,startY:e.clientY,src:'bar',bar};
});

// Milestones carry no id, so they sat outside the bar drag path entirely. They now go
// through the SAME pending gesture the bars use, keyed by uid instead: sideways moves
// the date, downwards moves the row, and one 5px threshold decides which. Before this
// the press opened a date drag outright, so a milestone could only ever move along the
// time axis and the diamond was the one thing on the chart you could not drag into
// another lane.
bars.addEventListener('mousedown',e=>{
  if(depMode)return;
  const hit=e.target.closest('.ms-hit, .ms-cap');
  if(!hit)return;
  const r=rowByUid(hit.dataset.msuid);
  if(!r||r.type!=='milestone')return;
  // preventDefault so a press that is still deciding cannot sweep a text selection
  // across the chart behind it.
  e.preventDefault();hideTip();
  // A cap is only ever a date drag. It sits in the band at the top of the chart, not
  // on a row, so "drag it down into the next lane" would be aiming at a place the
  // thing being dragged is not.
  gesture={uid:r.uid,startX:e.clientX,startY:e.clientY,src:hit.classList.contains('ms-cap')?'mscap':'ms'};
});

document.addEventListener('mousemove',e=>{
  // Resolve gesture → horizontal drag or vertical reorder
  if(gesture&&!dragState&&!reorderState){
    const dx=e.clientX-gesture.startX, dy=e.clientY-gesture.startY;
    if(Math.abs(dx)<5&&Math.abs(dy)<5)return;
    // From the row handle only a move up or down is a drag (the row's); sideways travel
    // alone leaves the press a click, which selects.
    if(gesture.grip&&Math.abs(dy)<5)return;
    hideTip();
    const id=gesture.id;
    // Bars are keyed by id, milestones by uid, and everything past this point wants
    // the ROW either way. A cap never resolves vertically, so its gesture is turned
    // into the horizontal one whichever way the pointer went.
    const grow=gesture.uid?rowByUid(gesture.uid):items.find(d=>d.id===id);
    if(gesture.src==='ms'||gesture.src==='mscap'||gesture.src==='mslabel'){
      if(!grow){gesture=null;return;}
      // The diamond decides by axis. A cap is horizontal only (it sits in the band, not
      // on a row) and a name in the label column is vertical only (there is no time
      // axis over there to drag a date along).
      const horizontal=gesture.src==='mscap'||(gesture.src==='ms'&&Math.abs(dx)>=Math.abs(dy));
      if(horizontal){
        snapshotMs(grow);
        dragState={kind:'ms',r:grow,startX:gesture.startX,origLeft:toX(grow.date)};
        if(grow.uid&&!selected.has(grow.uid)){selected.clear();selected.add(grow.uid);selAnchor=grow.uid;refreshSelVisuals();updateSelCount();}
        document.body.style.cursor='ew-resize';
      } else {
        reorderState={active:true,id:null,x0:gesture.startX};
        closeEditor();
        const moveIds=(grow.uid&&selected.has(grow.uid)&&selected.size>1)
          ? selectedRows().map(x=>x.uid) : [grow.uid];
        if(grow.uid&&!selected.has(grow.uid)){selected.clear();selected.add(grow.uid);selAnchor=grow.uid;refreshSelVisuals();updateSelCount();}
        reorderState.moveIds=moveIds;
        moveIds.forEach(mid=>{const r=rowByUid(mid);if(r){r._lDiv.classList.add('dragging');r._bDiv.classList.add('dragging');}});
        document.body.style.cursor='grabbing';
      }
      gesture=null;
      return;
    }

    if(Math.abs(dx)>=Math.abs(dy)&&!gesture.grip){
      // Horizontal → move bar dates
      const bar=gesture.bar||barEls[id]?.bar;
      if(!bar){gesture=null;return;}
      const r=items.find(d=>d.id===id);
      // A bar drag is a bar operation. It carries the TASKS in the selection: a
      // milestone in the set has one date and no duration, so there is no honest
      // answer to dragging its start edge, and it keeps its date. The batch panel
      // says the same thing in words.
      const moveIds=(r&&r.uid&&selected.has(r.uid)&&selected.size>1)
        ? selectedRows().filter(x=>x.id&&barEls[x.id]).map(x=>x.id) : [id];
      snapshotMany(moveIds);
      const originals={};
      // The dates go into the snapshot as well as the pixels: a body move keeps
      // its duration, and duration is a number of days, not a number of pixels.
      moveIds.forEach(mid=>{const be=barEls[mid],ri=items.find(d=>d.id===mid);
        if(be&&ri)originals[mid]={left:parseFloat(be.bar.style.left),width:parseFloat(be.bar.style.width),start:ri.start,end:ri.end};});
      dragState={id,side:'move',startX:gesture.startX,origLeft:parseFloat(bar.style.left),origWidth:parseFloat(bar.style.width),bar,r,moveIds,originals};
      if(r&&r.uid&&!selected.has(r.uid)){selected.clear();selected.add(r.uid);selAnchor=r.uid;refreshSelVisuals();updateSelCount();}
      document.body.style.cursor='grabbing';
    } else {
      // Vertical → reorder. This one IS a row move rather than a date move, so it
      // carries the whole set including any milestones in it, which is what
      // Alt+Up and Alt+Down have always done. Keyed by uid for the same reason.
      reorderState={active:true,id,x0:gesture.startX};
      closeEditor();
      const dr=items.find(d=>d.id===id);
      const moveIds=(dr&&dr.uid&&selected.has(dr.uid)&&selected.size>1)
        ? selectedRows().map(x=>x.uid) : [dr&&dr.uid].filter(Boolean);
      if(dr&&dr.uid&&!selected.has(dr.uid)){selected.clear();selected.add(dr.uid);selAnchor=dr.uid;refreshSelVisuals();updateSelCount();}
      reorderState.moveIds=moveIds;
      moveIds.forEach(mid=>{const r=rowByUid(mid);if(r){r._lDiv.classList.add('dragging');r._bDiv.classList.add('dragging');}});
      document.body.style.cursor='grabbing';
    }
    gesture=null;
  }

  // Horizontal drag
  if(dragState){
    // A resize handle and a milestone both open a dragState on mousedown, with no
    // threshold: they have one obvious axis and waiting 5px to start would feel
    // sticky. So a PRESS alone is not a drag here, and only this flag says one really
    // happened. Without it the clock would be stamped by a plain click on a milestone
    // and the click it produced would be suppressed, which is the editor never opening.
    dragState.moved=true;
    const dx=e.clientX-dragState.startX;
    if(dragState.kind==='ms'){
      // A milestone is a single date, so it lands on a grid line like any edge.
      const s=snapX(dragState.origLeft+dx);
      if(dragState.r.date!==s.date){ dragState.r.date=s.date; updateMilestone(dragState.r); }
      return;
    }
    const{id,side,origLeft,origWidth,bar,r,moveIds,originals}=dragState;
    // Geometry is redrawn FROM the dates rather than from the cursor, so what is
    // on screen mid-drag is exactly what will be committed, down to the pixel.
    const paint=(be,ri)=>{
      const x1=toX(ri.start),x2=toX(ri.end);
      be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';
      be.bar.dataset.dates=fmt(ri.start)+' → '+fmt(ri.end);
      syncEditorDates(ri);
    };
    if(side==='move'){
      // The start snaps; the DURATION is preserved, so the end lands at start plus
      // the same number of days it always had. Snapping both ends independently
      // would quietly stretch or shrink every bar the user only meant to slide.
      const base=originals[id];
      if(base){
        const s=snapX(origLeft+dx);
        const shift=dayDiff(parseISO(base.start),parseISO(s.date));
        // With more than one bar selected, the bar under the cursor is the one
        // that lands on the line and the rest move by the same number of days, so
        // the arrangement the user selected survives the move.
        moveIds.forEach(mid=>{
          const be=barEls[mid],orig=originals[mid],ri=items.find(d=>d.id===mid);
          if(!be||!orig||!ri)return;
          const ns=addDays(orig.start,shift), ne=addDays(orig.end,shift);
          if(ri.start===ns&&ri.end===ne)return;
          ri.start=ns;ri.end=ne;paint(be,ri);
        });
      }
    } else if(side==='left'){
      // The end is fixed. Step back off any boundary that would put the start on
      // or past it: one cell of the current grain is the shortest bar the visible
      // grid can express, so that is the minimum. Drop to a finer grain for less.
      let i=snapIndex(origLeft+dx);
      while(i>0&&snapDateAt(i)>=r.end)i--;
      const d=snapDateAt(i);
      if(d<r.end&&r.start!==d){ r.start=d; paint(barEls[id]||{bar},r); }
    } else {
      // The start is fixed, same rule from the other side.
      let i=snapIndex(origLeft+origWidth+dx);
      while(i<UNITS&&snapDateAt(i)<=r.start)i++;
      const d=snapDateAt(i);
      if(d>r.start&&r.end!==d){ r.end=d; paint(barEls[id]||{bar},r); }
    }
    drawDeps();
  }

  // Vertical reorder
  if(reorderState&&reorderState.active){
    const labelsRect=labelsEl.getBoundingClientRect();
    const mouseY=e.clientY-labelsRect.top+labelsEl.scrollTop;
    // The rows on screen, less the ones being dragged and their subtasks (0.79.0). Both
    // columns are measured against their own offset parent, and the cursor was converted
    // into that same space above, so the line sits on the slot boundary the pointer is
    // actually nearest.
    const moving=reorderState.moveIds.map(rowByUid).filter(Boolean);
    const skip=new Set(moving); moving.forEach(m=>treeDesc(m).forEach(x=>skip.add(x)));
    const seq=items.filter(r=>!skip.has(r)&&r._lDiv&&r._lDiv.offsetHeight>0);
    let at=-1, frac=1;
    for(let k=0;k<seq.length;k++){ const el=seq[k]._lDiv, t=el.offsetTop, h=el.offsetHeight; if(mouseY<t+h){ at=k; frac=Math.max(0,(mouseY-t)/h); break; } }
    if(at<0&&seq.length)at=seq.length-1;
    const drop=at>=0?dropAt(seq,at,frac,e.clientX-(reorderState.x0||e.clientX),moving):null;
    reorderState.drop=drop;
    showChartDrop(drop);
  }
});

document.addEventListener('mouseup',()=>{
  // Every kind of drag this handler winds up stamps the one clock, once, on the way
  // out, and every click guard reads that clock. Stamped HERE rather than inside each
  // branch below: a reorder dropped on nothing takes none of those branches, and a
  // reorder abandoned with Escape has already cleared itself, so both used to leave
  // the old sticky flag armed with nothing left to clear it. A gesture that never
  // passed the 5px threshold is a click and not a drag, and deliberately stamps
  // nothing: suppressing that one is how a plain click on a bar goes missing.
  if(dragState||reorderState||cancelledDrag)dragEndedAt=Date.now();
  cancelledDrag=false;
  gesture=null;
  if(!reorderState&&!laneDrag)hideDropIndicators();

  endBarDrag();

  if(reorderState&&reorderState.active){
    hideDropIndicators();
    reorderState.moveIds.forEach(mid=>{const r=rowByUid(mid);if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}});
    document.body.style.cursor='';
    if(reorderState.drop)applyDrop(reorderState.drop,reorderState.drop.kind==='nest'?'drag-nest':'drag-reorder');
    reorderState=null;
  }
});
}

// ── Dependency mode ──
//
// Drawing dependencies used to mean opening one bar's editor and picking from a
// dropdown of every task in the plan, one at a time. This is the other way round:
// turn the mode on, then click the task that comes FIRST and the task that waits on
// it, and keep going. Chaining is the whole point, so the mode survives each pair
// and only Esc or the button turns it off.
//
// A milestone is an end like a task (0.78.0), first click or second: a gate waits on
// the work before it, and the work after it waits on the gate. A dependency is stored
// as an id in the waiting row's `deps`, and until then a milestone had no id, so a dep
// on one could not be drawn, listed or exported and the mode refused milestones. The
// server gives every milestone an id now, made from its name the way a task's is. On
// the chart a milestone is picked by its diamond, or in the lines style by its cap.
let depMode=false, depPending=null;
// A picked ARROW, and an arrow armed for removal. Both are {from,to}: `from` is the
// row that comes first (the master) and `to` is the one that waits on it, which is
// the row whose `deps` array holds the edge. Both live only in the mode.
let depSel=null, depArm=null;
function sameDep(a,b){ return !!(a&&b&&a.from===b.from&&a.to===b.to); }
// Any task or milestone with an id. A milestone without one is a plan nothing has given
// ids to yet (a snapshot exported before 0.78.0 gets them as it opens).
function depIsEligible(r){ return !!(r && r.id && r.type!=='group'); }
// What wears the armed mark: a task's bar, a milestone's diamond and the cap on its rule.
function depMarkEls(id){
  const be=id&&barEls[id]; if(be)return [be.bar];
  const r=id&&items.find(d=>d.id===id&&d.type==='milestone');
  return r?[r._msEl,r._msLine&&r._msLine.querySelector('.ms-cap')].filter(Boolean):[];
}
// Deps are "needs" edges: r.deps lists what r waits on. Adding master to dep.deps
// makes dep wait on master, which closes a loop if master already waits on dep,
// directly or through any chain. Breadth-first so the path in the hint is the
// shortest one, which is the one a person can actually check.
function depPathTo(fromId,targetId){
  const seen=new Set([fromId]); const q=[[fromId]];
  while(q.length){
    const path=q.shift(), cur=path[path.length-1];
    const r=items.find(d=>d.id===cur);
    for(const nx of (r&&r.deps)||[]){
      if(nx===targetId)return path.concat(nx);
      if(seen.has(nx))continue;
      seen.add(nx); q.push(path.concat(nx));
    }
  }
  return null;
}
function depName(id){ const r=items.find(d=>d.id===id); return r?(r.name||id):id; }
function setDepPending(id){
  if(depPending)depMarkEls(depPending).forEach(el=>el.classList.remove('dep-pending'));
  depPending=id||null;
  if(depPending)depMarkEls(depPending).forEach(el=>el.classList.add('dep-pending'));
}
// ── Picking a dependency arrow ──
//
// Only in the mode. In the normal lens the arrows are chrome: they cross bars, names
// and empty grid, and a layer that took clicks there would steal them from the plan
// underneath. So the layer stays pointer-events:none in EVERY mode and picking an
// arrow is a hit TEST rather than a hit target: a click that landed on nothing else is
// measured against the routes the browser actually drew, and the nearest one within
// DEP_PICK px is picked. A bar therefore always wins over an arrow crossing it, which
// is the right precedence, since the bar is the thing and the arrow is only the line
// between things, and normal mode is untouched by construction rather than by a CSS
// condition that a later round could get wrong.
const DEP_PICK=9;
function depEdgeAt(clientX,clientY){
  if(!depSvg||depSvg.classList.contains('hidden'))return null;
  const box=depSvg.getBoundingClientRect();
  const x=clientX-box.left, y=clientY-box.top;
  let best=null,bd=DEP_PICK;
  depSvg.querySelectorAll('path[data-from]').forEach(el=>{
    const L=el.getTotalLength(); if(!L)return;
    // Sampled about every 6px, floored and capped so a very short route is still
    // measured properly and a very long one cannot cost more than a few hundred points.
    const n=Math.max(24,Math.min(300,Math.round(L/6)));
    for(let i=0;i<=n;i++){
      const q=el.getPointAtLength(L*i/n);
      const d=Math.hypot(q.x-x,q.y-y);
      if(d<bd){bd=d;best=el;}
    }
  });
  return best?{from:best.getAttribute('data-from'),to:best.getAttribute('data-to')}:null;
}
// The pick and the armed state are properties of the plan, not of the SVG, because
// drawDeps throws the whole layer away on every geometry change. This is what puts
// them back, and drawDeps calls it as its last act.
function paintDepSel(){
  if(!depSvg)return;
  depSvg.querySelectorAll('[data-from]').forEach(el=>{
    const d={from:el.getAttribute('data-from'),to:el.getAttribute('data-to')};
    el.classList.toggle('sel',sameDep(d,depSel));
    el.classList.toggle('del-armed',sameDep(d,depArm));
  });
}
function setDepSel(sel){
  depSel=sel||null;
  disarmDepDelete();
  paintDepSel();
}
// Everything the mode says OUTSIDE the chart. Until 0.47.0 that was a sentence in a bar
// under the legend, rewritten on every pick; the sentence is the Deps button's tooltip
// now and the state is the button's glow, so this reads depMode and nothing else. That
// is why setDepSel and setDepPending no longer call it: neither of them can change what
// it paints, and the marks they DO paint (the amber arrow, the pending bar) are on the
// chart, next to the thing being picked, where they always were.
function syncDepMode(){
  document.documentElement.setAttribute('data-dep',depMode?'on':'off');
  const btn=$('btnDeps'); if(btn)btn.classList.toggle('on',depMode);
}
function setDepMode(on){
  depMode=!!on;
  if(!depMode){setDepPending(null);setDepSel(null);clearDepHover();}
  document.body.classList.toggle('dep-mode',depMode);
  if(depMode){
    // Anything the mouse already had open goes with the old mode. A press still deciding
    // which way it went (gesture) would otherwise resolve into a resize, a move or a
    // reorder on the very next pointer move, INSIDE a mode whose mousedown guard exists to
    // stop exactly that, so it is dropped rather than allowed to resolve; a drag already
    // resolved is banked and closed. cancelledDrag is what makes the release that follows
    // read as the end of a drag rather than as a dependency pick on whatever it happens to
    // finish over, the same clock every other click guard in the file reads.
    //
    // Both of the ways INTO the mode already make this hard to reach: Shift+D is behind
    // appIsIdle, which is false while any of these is live, and the Deps button needs a
    // click of its own, which needs the button released first. That is why it is here and
    // not only at those two doors. The rule is "the mode has no resize, no move and no
    // reorder", and a rule enforced at the state change cannot be walked around by a
    // future third way in.
    if(dragState||gesture||(reorderState&&reorderState.active))cancelledDrag=true;
    endBarDrag();cancelReorder();gesture=null;
    closeEditor();closeMenus();clearSelection();
  }
  syncDepMode();
}
function toggleDepMode(){ setDepMode(!depMode); }
// ── The row under the pointer ──────────────────────────────────────────────────
//
// Armed by the mode and by nothing else. In the normal lens a row already answers
// "which one" the moment you are over its bar, because the bar is what you are aiming
// at; in this mode the target is a bar you are about to PAIR, the pointer is often
// between rows on its way somewhere, and the name that identifies the row is in the
// other pane. So the mode, and only the mode, paints the band.
//
// Purely visual: the click that adds a dependency is still the click on the bar it has
// always been, and nothing here is a hit target.
let depHoverEl=null, depHoverItem=null;
// Resolving a fill costs a getComputedStyle, so it is done once per colour per theme
// rather than once per pointer move. phaseColor hands back an explicit hex for a lane
// somebody painted (theme-invariant by design) and a var(--phN) for one that follows
// the ramp, so both forms have to come out as three numbers.
const depRgbMemo=new Map();
function cssRgbTriple(css){
  const key=(document.documentElement.getAttribute('data-theme')||'')+'|'+String(css||'');
  if(depRgbMemo.has(key))return depRgbMemo.get(key);
  let s=String(css||'').trim();
  const v=s.match(/^var\((--[\w-]+)\)$/);
  if(v)s=getComputedStyle(document.documentElement).getPropertyValue(v[1]).trim();
  let out=null,m=s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
  if(m){
    const h=m[1].length===3?m[1].replace(/./g,c=>c+c):m[1];
    out=parseInt(h.slice(0,2),16)+' '+parseInt(h.slice(2,4),16)+' '+parseInt(h.slice(4,6),16);
  }else if((m=s.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i))){
    out=Math.round(+m[1])+' '+Math.round(+m[2])+' '+Math.round(+m[3]);
  }
  depRgbMemo.set(key,out);
  return out;
}
function clearDepHover(){
  if(depHoverItem){
    [depHoverItem._lDiv,depHoverItem._bDiv].forEach(el=>{
      if(!el)return;
      el.classList.remove('dep-hover');
      el.style.removeProperty('--dep-hl-rgb');
    });
  }
  depHoverEl=null;depHoverItem=null;
}
// Exactly one row wears the wash, so painting is: take the old one off, put the new one
// on. A lane header is not a row of work and gets nothing, which is also the answer for
// empty space below the last row.
function setDepHover(r){
  clearDepHover();
  if(!r||r.type==='group')return;
  const rgb=cssRgbTriple(phaseColor(r.phase));
  if(!rgb)return;
  [r._lDiv,r._bDiv].forEach(el=>{
    if(!el)return;
    el.style.setProperty('--dep-hl-rgb',rgb);
    el.classList.add('dep-hover');
  });
  depHoverItem=r;
}
// The throttle is the identity check, not a timer: a sweep spends most of its moves
// inside the row it is already on, and those cost one closest() and one comparison and
// touch neither the DOM nor the style system. The element is remembered even when it
// resolves to nothing paintable, so hovering a lane header is just as cheap.
let depHoverX=-1, depHoverY=-1;
function depHoverMove(e){
  if(!depMode)return;
  depHoverX=e.clientX; depHoverY=e.clientY;
  const el=e.target&&e.target.closest?e.target.closest('.label-row, .bar-row'):null;
  if(el===depHoverEl)return;
  setDepHover(el?items.find(x=>x._lDiv===el||x._bDiv===el):null);
  depHoverEl=el;
}
// The rows can move under a pointer that has not moved: a scroll, or a lane collapsing
// above it. Nothing fires a mousemove for that, so the wash would sit on the row the
// pointer USED to be over while the click went to the row that slid under it, which is
// the one thing this feature exists to stop. So the question gets asked again from the
// last place the pointer was.
//
// elementsFromPoint rather than elementFromPoint: the grid bands and the arrow layer
// take no pointer events, but they are still the topmost things at that coordinate, so
// the singular call answers with a layer that has no row above it and the row underneath
// would look like empty space.
function depHoverResync(){
  if(!depMode||depHoverX<0)return;
  for(const el of document.elementsFromPoint(depHoverX,depHoverY)){
    const row=el.closest&&el.closest('.label-row, .bar-row');
    if(!row)continue;
    if(row===depHoverEl)return;
    setDepHover(items.find(x=>x._lDiv===row||x._bDiv===row));
    depHoverEl=row;
    return;
  }
  clearDepHover();
}
// The pointer has left the chart, so there is no last position inside it to resync to.
function depHoverLeave(){ depHoverX=-1; depHoverY=-1; clearDepHover(); }
// One click in the mode, on a task's bar or a milestone (0.78.0), by its id. Returns
// nothing; every refusal says why in the same place every other refusal in this app says why.
function depClickBar(id){
  const r=id?items.find(d=>d.id===id):null;
  if(!depIsEligible(r)){ flash(saveInd,'this row has no id yet, so it cannot be linked: reload the plan',3200); return; }
  if(!depPending){ setDepPending(id); return; }
  if(depPending===id){ setDepPending(null); flash(saveInd,'pick cleared'); return; }
  const masterId=depPending, depId=id;
  const dep=items.find(d=>d.id===depId);
  if((dep.deps||[]).includes(masterId)){
    // The same pair again means the opposite. Until 0.39.0 this branch said "already
    // needs" and did nothing, which left removing a link as a job for the editor only:
    // the mode could add an edge but never take one back, so the one gesture that
    // obviously means "not that" was the one gesture it refused. It removes now, down
    // the same removeDep the editor's x goes down, so there is one removal in this app
    // however it is reached.
    removeDep(masterId,depId,'dep-mode');
    setDepPending(null); return;
  }
  // The data model is a DAG and everything downstream assumes it, so a loop is
  // refused rather than stored and discovered later by something that hangs.
  const loop=depPathTo(masterId,depId);
  if(loop){
    const path=loop.map(depName).concat(depName(masterId)).join(' needs ');
    flash(saveInd,'that would make a loop: '+path,6000);
    setDepPending(null); return;
  }
  // One edit snapshot, which already carries deps, so this is one undo step and the
  // 0.20.0 inverse machinery gives redo for nothing.
  snapshot(depId);
  if(!dep.deps)dep.deps=[];
  dep.deps.push(masterId);
  updateBar(depId);
  drawDeps();
  if(prefs.view==='table')renderTable();
  flash(saveInd,'"'+depName(depId)+'" now needs "'+depName(masterId)+'"',2200);
  scheduleSave('dep-mode');
  // The pair resets and the mode stays on: the next click starts the next pair.
  setDepPending(null);
}
// ── Removing one dependency, from wherever it was asked for ──
//
// The editor's x has done this since 0.10.0. This is the same three lines, so the two
// callers leave the SAME undo entry (one edit snapshot, which already carries the deps
// array, hence one Ctrl+Z and redo for free) and the server derives the same
// dep.remove event from the same state change. Only `via` differs, so the changelog
// still says which gesture it was.
function removeDep(masterId,depId,via){
  const dep=items.find(d=>d.id===depId);
  if(!dep||!(dep.deps||[]).includes(masterId))return false;
  snapshot(depId);
  dep.deps=dep.deps.filter(d=>d!==masterId);
  // A removed edge cannot stay picked: its route is about to stop existing.
  if(sameDep(depSel,{from:masterId,to:depId}))setDepSel(null);
  updateBar(depId);
  drawDeps();
  if(prefs.view==='table')renderTable();
  undoToast.innerHTML='Removed dependency: '+esc(depName(depId))+' no longer needs '+esc(depName(masterId))+' &middot; <kbd>Ctrl+Z</kbd>';
  showToast(undoToast,4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});
  scheduleSave(via||'dep-mode');
  return true;
}
// ── Delete on a picked arrow, armed the way the chart's row Delete is ──
//
// Same shape and for the same reason: the key has no cursor to aim it, so the first
// press says what would go and marks it, and the second inside the window does it.
// Everything else withdraws the question through the two capturing listeners in
// wireGlobalKeys, which watch both arms.
function disarmDepDelete(){
  if(!depArm)return;
  clearTimeout(depArm.timer);
  depArm=null;
  paintDepSel();
  if(undoToast.__armed){ undoToast.__armed=false; undoToast.classList.remove('show'); undoToast.innerHTML=UNDO_TOAST_DEFAULT; }
}
function armDepDelete(sel){
  disarmDepDelete();
  depArm={from:sel.from,to:sel.to,timer:setTimeout(disarmDepDelete,4000)};
  paintDepSel();
  undoToast.__armed=true;
  undoToast.innerHTML='Press <kbd>Delete</kbd> again to remove this dependency';
  showToast(undoToast,4000,()=>{undoToast.__armed=false;undoToast.innerHTML=UNDO_TOAST_DEFAULT;disarmDepDelete();});
}
// Live only while an arrow is actually picked in the mode, and only when nothing else
// holds the keyboard. Same gate as the chart's row Delete, minus the selection clause:
// this one acts on the arrow, not on the rows.
function depDeleteReady(){
  if(!depMode||!depSel)return false;
  if(prefs.view!=='gantt')return false;
  if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;
  if(isTypingTarget(document.activeElement))return false;
  if(editorEl.classList.contains('show'))return false;
  return true;
}
function depDeleteKey(e){
  e.preventDefault();
  if(!sameDep(depArm,depSel)){ armDepDelete(depSel); return; }
  const from=depSel.from, to=depSel.to;
  disarmDepDelete();
  setDepSel(null);
  removeDep(from,to,'dep-arrow-delete');
}
function wireDepMode(){
$('btnDeps').addEventListener('click',e=>{e.stopPropagation();toggleDepMode();});
// One listener on the pane that holds BOTH columns, so a pointer crossing the
// resizer from names to bars is one continuous hover rather than two. The handler
// leaves immediately when the mode is off, which is what keeps the normal lens
// exactly what it was.
const wrap=timelineEl.closest('.chart-wrap')||timelineEl.parentElement;
if(wrap){
  wrap.addEventListener('mousemove',depHoverMove);
  wrap.addEventListener('mouseleave',depHoverLeave);
}
// Scroll does not bubble, so it is taken on the two scrollers themselves. Both are
// listened to rather than just the timeline, because the label column is its own
// scroller even though it mirrors.
timelineEl.addEventListener('scroll',depHoverResync);
labelsEl.addEventListener('scroll',depHoverResync);
}

// ── Bar notes ──
// Double-click the bar body to type a short label straight onto it, the way the
// NSF chart shows durations. Single-click still opens the full editor.
function editNoteInline(id){
  const r=items.find(d=>d.id===id),be=barEls[id];
  if(!r||!be)return;
  if(be.bar.querySelector('.bar-note-edit'))return;
  const inp=document.createElement('input');
  inp.className='bar-note-edit';inp.value=r.note||'';inp.maxLength=24;
  inp.addEventListener('mousedown',e=>e.stopPropagation());
  inp.addEventListener('click',e=>e.stopPropagation());
  // LATCHED, the same way the label editor and the table's cell editor are, and for a
  // reason 0.55.0's field-by-field pass over Escape turned up. Taking a FOCUSED input out
  // of the document fires its blur from INSIDE the removal, and the blur handler here is
  // commit(true). So Escape used to run commit(false), reach inp.remove(), be re-entered
  // by its own blur as commit(true), and that second pass SAVED the note the first pass
  // had just been told to throw away, snapshot, sidecar write and all. Then it removed the
  // node out from under the remove() still on the stack, which threw NotFoundError on
  // every single Escape and every single Enter. The parentNode guard could not catch it:
  // during the outer removal the node still has its parent. One latch answers both halves,
  // so Escape cancels, Enter commits once, and neither of them throws.
  let done=false;
  const commit=(save)=>{
    if(done||!inp.parentNode)return;
    done=true;
    if(save&&(r.note||'')!==inp.value){snapshot(id);r.note=inp.value;updateBar(id);scheduleSave('bar-note');}
    inp.remove();
  };
  inp.addEventListener('keydown',e=>{e.stopPropagation();
    if(e.key==='Enter'){e.preventDefault();commit(true);}
    if(e.key==='Escape'){e.preventDefault();commit(false);}});
  inp.addEventListener('blur',()=>commit(true));
  be.bar.appendChild(inp);inp.focus();inp.select();
}
// A double-click on a bar used to open a one-field label editor ON the bar. The label is
// a field of the item editor now (0.65.0), which the first click of that double-click has
// already opened, so the second click does nothing more.
function wireBarDblclick(){}

// ── Tooltip ──
let tipTimer=null;
// Built as nodes, never as markup. The title and body are user-typed (a task name,
// a note typed into the table), and reading them back out of a data-* attribute
// decodes the escaping that put them there, so an innerHTML here executed whatever
// the last person to edit the plan wrote, for everyone who opened it afterwards.
function tipLine(cls,text){const d=document.createElement('div');d.className=cls;d.textContent=text;return d;}
function showTip(el){if(dragState)return;clearTimeout(tipTimer);tipTimer=setTimeout(()=>{
  if(!el.isConnected){hideTip();return;}   // a re-render or a delete beat the delay
  const t=el.dataset.title,d=el.dataset.dates,b=el.dataset.body;
  if(!t)return;
  const pv=el.dataset.prov, tg=el.dataset.tags;
  tipEl.textContent='';
  tipEl.appendChild(tipLine('tip-title',t));
  if(d)tipEl.appendChild(tipLine('tip-dates',d));
  if(b)tipEl.appendChild(tipLine('tip-body',b));
  if(pv)tipEl.appendChild(tipLine('tip-prov','source: '+pv));
  if(tg)tipEl.appendChild(tipLine('tip-prov','tags: '+tg));
  tipEl.classList.add('show');posTipFor(el);},500);}
// Anchored to the trigger, positioned ONCE, clamped on both axes. It used to be
// placed from the pointer coordinates and re-placed on every mousemove, so it
// trailed the cursor instead of sitting beside the thing it describes.
function posTipFor(el){
  const r=el.getBoundingClientRect();
  const tw=tipEl.offsetWidth, th=tipEl.offsetHeight, M=8;
  let y=r.bottom+M;
  if(y+th>window.innerHeight-M) y=r.top-th-M;      // flip above rather than fall off
  y=Math.max(M,Math.min(y,window.innerHeight-th-M));
  const x=Math.max(M,Math.min(r.left,window.innerWidth-tw-M));
  tipEl.style.left=x+'px';tipEl.style.top=y+'px';
}
function hideTip(){clearTimeout(tipTimer);tipEl.classList.remove('show');}
function wireTooltipsAndExport(){
document.addEventListener('mouseover',e=>{const el=e.target.closest('[data-title]');if(el)showTip(el);});
document.addEventListener('mouseout',e=>{if(e.target.closest('[data-title]'))hideTip();});
// No mousemove handler on purpose: a tooltip that follows the pointer is the bug.
// It leaves on anything that could move or invalidate what it points at.
document.addEventListener('mousedown',hideTip,true);
document.addEventListener('click',hideTip,true);
window.addEventListener('scroll',hideTip,true);   // capture, so inner scrollers count
window.addEventListener('resize',hideTip);
window.addEventListener('blur',hideTip);

// ── Export menu ──
$('btnExport').addEventListener('click',e=>{
  closeMenus();const rect=e.currentTarget.getBoundingClientRect();
  exportMenu.innerHTML=`
    <div class="dd-label">export as</div>
    <div class="dd-item" data-fmt="json">JSON (native)</div>
    <div class="dd-item" data-fmt="csv">CSV</div>
    <div class="dd-item" data-fmt="svg">SVG</div>
    <div class="dd-item" data-fmt="html">HTML (self-contained)</div>`;
  exportMenu.style.left=Math.min(rect.left,window.innerWidth-200)+'px';exportMenu.style.top=(rect.bottom+4)+'px';exportMenu.classList.add('show');
  exportMenu.querySelectorAll('.dd-item').forEach(item=>{item.addEventListener('click',()=>{doExport(item.dataset.fmt);closeMenus();});});
  e.stopPropagation();
});
}

// The plan as the user sees it RIGHT NOW, in the shape /export/json returns from
// disk: project, phases, items in display order with only their real fields. The
// point is that this reads the in-memory model, not the server, so an export
// reflects unsaved edits and keeps working when a save conflict has blocked the
// sidecar. `getState()` is a delta for the server to merge; this is the whole
// merged plan, which is what an export is.
function clientMergedData(){
  const FIELDS=['type','uid','parent','pos','id','name','label','start','end','date','phase','owner','status','description','deps','note','striped','provenance','tags'];
  const clean=r=>{const o={};FIELDS.forEach(k=>{if(r[k]!==undefined)o[k]=r[k];});
    if(o.status)o.status=statusKey(o.status);        // the canonical vocabulary, as the server normalises on the way out
    tidyTags(o);                                      // the data-file rule: a non-empty list or no key
    return o;};
  // Sections travel in `phases`, which now carries name, lead and colour. Group
  // ROWS are gone from the export shape: they were never data, and a reader that
  // wants the section a row belongs to has always used `phase`.
  const phs=phases.map(p=>{const o={name:p.name,start:p.start,end:p.end};
    if(p.uid)o.uid=p.uid; if(p.lead)o.lead=p.lead; if(p.color)o.color=p.color;
    if(p.tags&&p.tags.length)o.tags=p.tags.slice(); return o;});
  const pj=Object.assign({},project); delete pj.tags;
  if(tagReg.length)pj.tags=regOut();          // the registry, or no key (0.61.0)
  return {project:pj,phases:phs,
          items:items.filter(r=>!isDerivedGroup(r)).map(clean)};
}
// Byte-for-byte the server's itemsToCSV, because a CSV that round-trips has to
// re-import cleanly and the two must not drift. Kept client-side so the CSV export
// works with no server, same as JSON.
const CSV_FORMULA_LEAD=/^[=+\-@\t\r]/;
function csvGuardCell(s){return CSV_FORMULA_LEAD.test(s)?"'"+s:s;}
function clientItemsToCSV(itemsArr,phasesArr){
  const headers=['uid','parent','id','name','start','end','phase','owner','status','description','provenance','provenance_url','deps','tags','type','label','date','striped'];
  const cell=v=>{if(v==null)return '';const s=csvGuardCell(String(v));
    return (s.includes(',')||s.includes('"')||s.includes('\n')||s.includes('\r'))?'"'+s.replace(/"/g,'""')+'"':s;};
  const phaseName=i=>{const p=phasesArr&&phasesArr[i];return p&&p.name?p.name:(i==null?'':i);};
  const lines=[headers.join(',')];
  itemsArr.forEach(item=>{
    lines.push(headers.map(h=>{
      if(h==='deps')return cell((item.deps||[]).join(';'));
      if(h==='tags')return cell((normalizeTags(item.tags)||[]).join(';'));
      if(h==='phase')return cell(phaseName(item.phase));
      if(h==='status')return cell(statusKey(item.status));
      if(h==='provenance'){const p=item.provenance;return cell(typeof p==='string'?p:(p&&p.label)||'');}
      if(h==='provenance_url'){const p=item.provenance;return cell((p&&typeof p==='object'&&p.url)||'');}
      return cell(item[h]);
    }).join(','));
  });
  return lines.join('\n');
}
function downloadBlob(blob,fname){
  const url=URL.createObjectURL(blob);
  const a=document.createElement('a');a.href=url;a.download=fname;
  document.body.appendChild(a);a.click();a.remove();
  setTimeout(()=>URL.revokeObjectURL(url),1500);
}
// The export BUTTON serialises the client's current state. JSON and CSV are built
// here and need no server at all. SVG and HTML are rendered by POSTing that same
// client model to the server, so they reflect what is on screen rather than what
// is on disk, which is the whole fix: a user whose save is conflict-blocked can
// still get their in-memory edits out. The server /export GETs are untouched and
// still serve disk state for the AI handoff loop.
async function doExport(fmt){
  const fname=`${project.title.replace(/[^a-zA-Z0-9]/g,'-').toLowerCase()}.${fmt==='html'?'html':fmt}`;
  try{
    let blob;
    if(fmt==='json'){
      blob=new Blob([JSON.stringify(clientMergedData(),null,2)],{type:'application/json'});
    }else if(fmt==='csv'){
      const m=clientMergedData();
      blob=new Blob([clientItemsToCSV(m.items,m.phases)],{type:'text/csv'});
    }else{
      const m=clientMergedData();
      const r=await fetch('export/'+fmt,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(m)});
      if(!r.ok)throw new Error('the server answered '+r.status);
      const text=await r.text();
      blob=new Blob([text],{type:fmt==='svg'?'image/svg+xml':'text/html'});
    }
    downloadBlob(blob,fname);
    flash(saveInd,'exported '+fmt.toUpperCase());
  }catch(err){
    flash(saveInd,'export failed: '+err.message,4500);
  }
}

// ── Import menu ──
let importFormat = null;
// The files the chooser offers for each format.
const IMPORT_ACCEPT={json:'.json,.csv,.xml',csv:'.csv','msproject-xml':'.xml','jira-csv':'.csv','clickup-csv':'.csv','clickup-json':'.json'};
// A ClickUp import answers with a report: what changed on the way in and what did not come
// across. The import reloads this tab (and every other open one), so the report is held in
// this tab's session storage across the reload and shown once, over the plan it produced.
// Nobody else's tab gets it: they did not import anything.
const IMPORT_REPORT_KEY='adom-gantt.importReport';
// Shown once: the key is taken out of storage before anything is drawn, so a refresh, or a
// later reload for somebody else's change, never brings it back. It sits where the
// undated-rows warning sits and closes with its x; nothing else on the page waits on it.
function showImportReport(){
  if(SNAPSHOT)return;
  let held=null;
  try{ const raw=sessionStorage.getItem(IMPORT_REPORT_KEY); sessionStorage.removeItem(IMPORT_REPORT_KEY); if(raw)held=JSON.parse(raw); }catch(e){ held=null; }
  const rp=held&&held.report;
  const box=$('importReport');
  if(!rp||typeof rp!=='object'||!box)return;
  const arr=v=>Array.isArray(v)?v:[];
  const num=v=>Number.isFinite(+v)?+v:0;
  const nw=(k,one,many)=>`${k} ${k===1?one:many}`;
  const kinds=[nw(num(rp.tasks),'task','tasks')]; if(num(rp.milestones))kinds.push(nw(num(rp.milestones),'milestone','milestones'));
  const extra=[]; if(num(rp.subtasks))extra.push(`${num(rp.subtasks)} of them subtasks`); if(num(rp.undated))extra.push(`${num(rp.undated)} undated`); if(num(rp.deps))extra.push(nw(num(rp.deps),'dependency','dependencies'));
  const list=(title,rows,cls)=>rows.length?`<h4>${esc(title)}</h4><ul${cls?` class="${cls}"`:''}>${rows.map(x=>`<li>${x}</li>`).join('')}</ul>`:'';
  const changed=arr(rp.changed).map(s=>esc(s));
  const statuses=arr(rp.statuses).map(s=>`<span class="ir-cu">${esc(s.name)}</span> &rarr; ${esc(statusTextFor(s.to))} <span class="ir-dim">(${num(s.count)}${s.by==='guess'?', a guess':s.by==='date'?', it has a done date':''})</span>`);
  const dropped=arr(rp.dropped).map(d=>`${esc(d.what)}${num(d.count)?` <span class="ir-dim">on ${nw(num(d.count),'task','tasks')}</span>`:''}${d.detail?`<span class="ir-dim">: ${esc(d.detail)}</span>`:''}`);
  box.innerHTML=`<div class="ir-head"><div class="ir-title">Imported from ClickUp</div>`
    +`<button type="button" class="ir-close" id="irClose" title="Close the import report" aria-label="Close the import report">&times;</button></div>`
    +`<div class="ir-body"><div class="ir-sum">${esc(nw(num(rp.rows),'row','rows'))} in ${esc(nw(num(rp.sections),'section','sections'))}: ${esc(kinds.join(' and '))}${extra.length?', '+esc(extra.join(', ')):''}. `
    +`<span class="ir-dim">From ${held.file?esc(held.file)+', ':''}${/^ClickUp API/.test(rp.from||'')?'the':'a'} ${esc(rp.from||'ClickUp export')}.</span></div>`
    +(rp.unchecked?`<div class="ir-note">${esc(rp.unchecked)}</div>`:'')
    +list('Changed on the way in',changed)
    +list('Statuses',statuses,'ir-st')
    +list('Not carried over',dropped)
    +`</div>`;
  box.classList.add('show');
  $('irClose').addEventListener('click',()=>box.classList.remove('show'));
}
function wireImportAndHelp(){
$('btnImport').addEventListener('click',e=>{
  closeMenus();const rect=e.currentTarget.getBoundingClientRect();
  importMenu.innerHTML=`
    <div class="dd-label">import from</div>
    <div class="dd-item" data-fmt="json">JSON (adom-gantt)</div>
    <div class="dd-item" data-fmt="csv">CSV</div>
    <div class="dd-item" data-fmt="msproject-xml">MS Project XML</div>
    <div class="dd-item" data-fmt="jira-csv">Jira CSV</div>
    <div class="dd-item" data-fmt="clickup-csv" title="A List or Table view's Export view (CSV), or the workspace export">ClickUp CSV</div>
    <div class="dd-item" data-fmt="clickup-json" title="What ClickUp's API answers for a List's tasks, saved to a file. It carries the dependencies a CSV export leaves out">ClickUp API JSON</div>`;
  importMenu.style.left=Math.min(rect.left,window.innerWidth-200)+'px';importMenu.style.top=(rect.bottom+4)+'px';importMenu.classList.add('show');
  importMenu.querySelectorAll('.dd-item').forEach(item=>{item.addEventListener('click',()=>{importFormat=item.dataset.fmt;importFileEl.accept=IMPORT_ACCEPT[importFormat]||'.json,.csv,.xml';importFileEl.click();closeMenus();});});
  e.stopPropagation();
});

importFileEl.addEventListener('change',async e=>{
  const file=e.target.files[0];if(!file)return;
  const text=await file.text();
  try{
    // A ClickUp task with no date lands on the day of the import (Drew, 2026-11-03): this
    // person's day, not the server's, which may already be tomorrow.
    const q=importFormat.startsWith('clickup')?'?today='+isoDay(new Date()):'';
    const r=await fetch(`import/${importFormat}${q}`,{method:'POST',headers:{'Content-Type':'text/plain'},body:text});
    const res=await r.json();
    if(res.ok){
      if(res.report){ try{ sessionStorage.setItem(IMPORT_REPORT_KEY,JSON.stringify({report:res.report,file:file.name})); }catch(err){} }
      flash(saveInd,`imported ${res.items} items`);setTimeout(()=>location.reload(),500);}
    else{alert('Import failed: '+(res.error||'unknown error'));}
  }catch(err){alert('Import error: '+err.message);}
  importFileEl.value='';
});

// ── Help menu ──
$('btnHelp').addEventListener('click',e=>{
  closeMenus();const rect=e.currentTarget.getBoundingClientRect();
  exportMenu.innerHTML=`
    <div class="dd-label">interactions</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click bar</span>Edit dates + deps</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+click</span>Toggle in selection</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+click</span>Range select</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">row handle</span>The dots on a row's left edge: select the row and nothing else</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click name</span>Rename it in place</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag edge</span>Resize start or end</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag horiz.</span>Move bar dates</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag vert.</span>Reorder rows</div>
    <div class="dd-sep"></div>
    <div class="dd-label">table view</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Gantt / Table</span>Same plan, two lenses</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click cell</span>Edit owner, dates, status, notes</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click header</span>Sort by that column</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">owner</span>Filter to one person's work</div>
    <div class="dd-sep"></div>
    <div class="dd-label">keyboard</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+Z</span>Undo</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+Shift+Z</span>Redo, also Ctrl+Y. Any new edit clears it</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Up/Down</span>Move the selected row in the table. The pane only scrolls to keep it in view</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Enter</span>Edit the selected row's name. In a cell, commit and step down a row</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Delete</span>Delete the selected rows, in two presses: the first says what would go and marks it, the second does it. Undoable</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+D</span>Add dependencies: click the task or milestone that comes first, then the one that waits on it</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+N</span>New task or milestone</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+C</span>Copy the selected rows. Also puts their IDs on the system clipboard for an AI chat</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+V</span>Paste them below the selected row, or at the end of the lane you are looking at if nothing is selected. Dependencies among the copied rows are remapped to the copies; one Ctrl+Z takes the whole paste back</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Alt+Up/Down</span>Move the row one slot. Under a sort it stays inside its tied group</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Tab</span>Commit and open the next field. Shift+Tab goes back</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Esc</span>Cancel the field, then the row, then the selection</div>
    <div class="dd-sep"></div>
    <div class="dd-label">legend</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click phase</span>Toggle phase visibility</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click deps</span>Toggle dependency arrows</div>
    <div class="dd-sep"></div>
    <div class="dd-label">data</div>
    <div class="dd-item" style="cursor:default"><span style="color:var(--text-body);font-size:10px">Edits auto-save to sidecar JSON. Ctrl+Z undoes up to 100 steps, Ctrl+Shift+Z puts them back.</span></div>`;
  exportMenu.style.left=Math.min(rect.right-220,window.innerWidth-240)+'px';exportMenu.style.top=(rect.bottom+4)+'px';exportMenu.classList.add('show');
  e.stopPropagation();
});
}

// ── View controls: theme, milestone style, granularity, project start ──

function rebuildTimeline(){
  computeRange();
  fitCOL();
  buildTimeHeaders();buildGridLines();
  syncPlanSpan();
  relayout();
}

// ── Project Duration and Time Remaining ──
//
// These replace the "25 weeks" readout, which was the number of AXIS CELLS at the
// current grain: it changed when the grain picker did, it counted the padding either
// side of the plan, and on the plan it was reported against it said 25 weeks for
// work the plan itself describes as 16. Both numbers here come from the plan's own
// dates and nothing else, so no view setting can move them.
//
// The span is every task's start and end and every milestone's date, CANCELLED ROWS
// LEFT OUT (changed after review): cancelled work is work that will not happen, and
// Auto-schedule, the critical path and every hours total already leave it out, so a
// cancelled row running to June must not tell the header the plan runs to June. The
// tooltip says how many were left out. If every dated row is cancelled there is no
// live work to measure and both read "none", with the tooltip saying why.
// Since 0.75.0 the project's own dates win where they are set (Setup, Project dates):
// Duration is start to end and Remaining is today to the end, and the rows only stand in
// for an end that is not set. Work outside the dates turns both readouts amber.
// Both ends are INCLUSIVE, the way a bar is: a task from Monday to Friday is five
// days, so a plan whose first and last date are the same day runs one day, not zero.
// A date the loader accepts may be a bare year-month ("2026-10"); it counts as the
// first of that month where it opens a span and the last day where it closes one,
// and it is written back out padded, so every figure below is a real day.
function spanDay(ds,atEnd){
  const[y,m,d]=String(ds).split('-').map(Number);
  const dd=d||(atEnd?new Date(y,m,0).getDate():1);
  return `${y}-${String(m).padStart(2,'0')}-${String(dd).padStart(2,'0')}`;
}
function planSpan(){
  let first='', last='', cancelled=0;
  items.forEach(r=>{
    if(r.type==='group')return;
    const ds=r.type==='milestone'?[r.date,r.date]:[r.start,r.end];
    const a=isDateStr(ds[0])?spanDay(ds[0],false):'', b=isDateStr(ds[1])?spanDay(ds[1],true):'';
    if(!a&&!b)return;
    if(isCancelled(r)){ cancelled++; return; }
    if(a&&(!first||dayNum(a)<dayNum(first)))first=a;
    if(b&&(!last||dayNum(b)>dayNum(last)))last=b;
  });
  return {first,last,cancelled};
}
// One readable figure per span. Days while a count of days is still something you
// can picture, weeks and days through half a year (the unit plans are argued in),
// then months and years to one decimal. The exact count is always on the tooltip,
// so the short form is free to round.
function fmtSpan(n){
  if(n<=0)return '0 days';
  if(n===1)return '1 day';
  if(n<14)return n+' days';
  if(n<183){ const w=Math.floor(n/7), d=n%7; return d?`${w}w ${d}d`:`${w}w`; }
  if(n<730)return (Math.round(n/30.4375*10)/10)+' months';
  return (Math.round(n/365.25*10)/10)+' years';
}
function plural(n,w){ return n+' '+w+(n===1?'':'s'); }
// 0.74.0 (Drew): "14.5 wks / 73 workdays", weeks as a decimal and the working days left, so
// the time ticking away is always in view. Weeks are calendar weeks (days / 7, one decimal);
// workdays leave out weekends and observed federal holidays, as everywhere in the plan.
function fmtSpanWd(days,wd){ return (Math.round(days/7*10)/10).toFixed(1)+' wks / '+plural(wd,'workday'); }
// Read at call time, never cached, so a tab left open overnight moves on with the
// calendar (see the minute tick below). Tests pin the page's clock, not this.
function spanToday(){ return isoDay(new Date()); }
let spanPaintedDay='';
function syncPlanSpan(){
  const dv=$('spanDurV'), lv=$('spanLeftV');
  if(!dv||!lv)return;
  const span=planSpan(), {cancelled}=span, today=spanToday();
  // An end that is not set stands in from the rows, but never on the wrong side of the one
  // that is: an end set before every row (or a start after every row) is a zero-length
  // window, not a negative one.
  let first=planDates.start||span.first, last=planDates.end||span.last;
  if(first&&last&&dayNum(first)>dayNum(last)){ if(!planDates.start)first=last; else if(!planDates.end)last=first; }
  const fromWhat=planDates.start?'the project start':"the plan's first date", toWhat=planDates.end?'the project end':"the plan's last date";
  spanPaintedDay=today;
  const dur=$('spanDur'), left=$('spanLeft'), note=$('spanLeftN');
  const nOut=rowsOutsideDates().length;
  dur.dataset.outside=left.dataset.outside=nOut?'1':'';
  const out=(cancelled?` ${plural(cancelled,'cancelled row')} left out.`:'')
    +(nOut?` ${plural(nOut,'row')} ${nOut===1?'falls':'fall'} outside the project dates (hatched on the chart).`:'');
  if(note)note.textContent='';
  if(!first||!last){
    dv.textContent=lv.textContent='none';
    dur.title=left.title=cancelled
      ?`Every dated row in the plan is cancelled (${plural(cancelled,'row')}), so there is no live work to measure.`
      :'The plan has no dated tasks or milestones yet, so it has no duration.';
    left.dataset.state=dur.dataset.state='empty';
    return;
  }
  const days=dayNum(last)-dayNum(first)+1;
  dv.textContent=fmtSpanWd(days,bizBetween(first,last));
  dur.dataset.state='ok';
  dur.title=`${plural(days,'calendar day')} (${plural(bizBetween(first,last),'working day')}), from ${fromWhat} ${fmt(first)} to ${toWhat} ${fmt(last)}, both counted.${out}`;
  const T=dayNum(today), F=dayNum(first), L=dayNum(last);
  if(T>L){
    lv.textContent='ended';
    left.dataset.state='ended';
    left.title=`${toWhat.charAt(0).toUpperCase()+toWhat.slice(1)}, ${fmt(last)}, was ${plural(T-L,'day')} ago (today is ${fmt(today)}).${out}`;
    return;
  }
  const rem=L-T+1;
  lv.textContent=fmtSpanWd(rem,bizBetween(today,last));
  left.dataset.state=T<F?'not-started':'running';
  // Before the first date Remaining is longer than Duration, which is right (it counts
  // from today, as Drew asked) and would read as a mistake if nothing said why, so the
  // state is on the readout itself and not only in the tooltip.
  if(note&&T<F)note.textContent='not started';
  left.title=`${plural(rem,'calendar day')} (${plural(bizBetween(today,last),'working day')}) from today, ${fmt(today)}, to ${toWhat} ${fmt(last)}, both counted.`
    +(T<F?` The plan has not started yet: ${fromWhat}, ${fmt(first)}, is ${plural(F-T,'day')} away.`:'')+out;
}
// The one thing here that changes with no edit at all is today. A minute tick that
// repaints only when the date has rolled over costs nothing and keeps a tab left open
// overnight honest; coming back to a hidden tab repaints at once.
function wirePlanSpanClock(){
  setInterval(()=>{ if(spanToday()!==spanPaintedDay)syncPlanSpan(); },60000);
  document.addEventListener('visibilitychange',()=>{ if(document.visibilityState==='visible'&&spanToday()!==spanPaintedDay)syncPlanSpan(); });
}

// ── The dependency graph ─────────────────────────────────────────────────────
//
// One reading of `deps` for the two features that need it, so auto-scheduling and
// the critical path can never disagree about what the plan's edges are. A row is a
// NODE if it is dated: a task with a start and an end, or a milestone with a date.
// An edge is a `deps` entry that resolves to a dated task; a milestone carries no
// id, so nothing can depend on one, but a milestone may itself depend on tasks and
// the chart draws no arrow for that, which is why this reads the data rather than
// the drawn layer.
//
// The graph ignores phases entirely. A dependency crosses lanes freely and always
// has; a phase's displayed range is derived from the rows in it, so it follows.
//
// CANCELLED WORK IS NOT IN THE SCHEDULING GRAPH. 0.53.0. A cancelled row is work that
// will not happen, so nothing can sensibly wait on it and it cannot sensibly be the
// longest run of anything: it is dropped as a NODE, which drops its edges with it and
// BREAKS any chain that ran through it, because a path through work nobody will do is
// not a path. The `deps` arrays are untouched on the rows themselves, so un-cancelling
// puts the node, its edges and every chain through it straight back.
//
// It is a MODE rather than the only reading, because the two questions are different.
// Auto-schedule and the critical path are asking what the plan will DO, so they read
// the LIVE graph; the analytics Dependencies card is describing what the plan SAYS, so
// it reads the whole one and its counts are unmoved by 0.53.0. schedGraph() below is
// the single call the two scheduling features share, which is what still makes it
// impossible for them to disagree about what the plan's edges are.
//
// `cancelled` is the rows the live reading dropped that would otherwise have been
// nodes, in items order. It is what the Auto-schedule toast counts.
//
// A milestone is a node with its date as both its start and its end, and since 0.78.0 it
// is a node with an id, so it is an end of edges like any task: it waits on the work
// before it and the work after it waits on it. That is what makes it a GATE. Auto-schedule
// moves it to the day its last predecessor ends when that work slips, and a task waiting
// on it starts on its date at the earliest, the same rule as a task after a task. It weighs
// nothing on the critical path, so a chain through a gate is as long as the work in it.
function depGraph(skipCancelled){
  const nodes=[],byId=new Map(),byRow=new Map(),cancelled=[];
  items.forEach(r=>{
    if(r.type==='group')return;
    let n=null;
    if(r.type==='milestone'){ if(r.date)n={r,id:r.id||null,ms:true,start:r.date,end:r.date}; }
    else if(r.id&&r.start&&r.end)n={r,id:r.id,ms:false,start:r.start,end:r.end};
    if(!n)return;
    // Tested AFTER the dated check, so the count is of rows that were really in the
    // graph and not of every cancelled row in the plan.
    if(skipCancelled&&isCancelled(r)){cancelled.push(r);return;}
    n.preds=[];n.succs=[];
    nodes.push(n);byRow.set(r,n);
    if(n.id&&!byId.has(n.id))byId.set(n.id,n);
  });
  nodes.forEach(n=>{
    const d=n.r.deps;
    if(!Array.isArray(d))return;
    // Deduplicated, and a self-edge dropped: either would make a row wait on itself.
    const seen=new Set();
    d.forEach(id=>{
      if(!id||id===n.id||seen.has(id))return;
      const p=byId.get(id);
      if(!p)return;
      seen.add(id);
      n.preds.push(p);p.succs.push(n);
    });
  });
  return {nodes,byId,byRow,cancelled,edges:nodes.reduce((a,n)=>a+n.preds.length,0)};
}
// The one graph Auto-schedule and the critical path both read.
function schedGraph(){ return depGraph(true); }
// Kahn, so the order is a real topological order and what is left over when it
// stops is exactly the set of nodes on or downstream of a cycle. Deterministic:
// the queue is seeded and refilled in `items` order, which is the order on screen.
function topoOrder(g){
  const indeg=new Map(),out=[];
  g.nodes.forEach(n=>indeg.set(n,n.preds.length));
  let q=g.nodes.filter(n=>indeg.get(n)===0);
  while(q.length){
    const next=[];
    q.forEach(n=>{
      out.push(n);
      n.succs.forEach(s=>{const k=indeg.get(s)-1;indeg.set(s,k);if(k===0)next.push(s);});
    });
    // Refilled in items order rather than in the order the decrements happened, so
    // the pass does not depend on how the edges were listed in the data file.
    q=next.sort((a,b)=>g.nodes.indexOf(a)-g.nodes.indexOf(b));
  }
  const stuck=g.nodes.filter(n=>!out.includes(n));
  return {order:out,stuck};
}
// What makes the two plan buttons live. It asks the LIVE graph, so a plan whose only
// dependencies run through cancelled work has nothing left for either button to say
// and both of them go quiet, which is the same answer they give a plan with no
// dependencies at all.
const planHasDeps=()=>schedGraph().edges>0;

// ── Busy buttons ─────────────────────────────────────────────────────────────
//
// The other half of .btn-busy. Two calls, and they know nothing about what the button
// is doing: startBusy puts the panning ring on and shuts the button against a second
// press, endBusy takes both off. Auto-schedule is the only caller in 0.48.0.
//
// The MINIMUM. A pass of work in this app is synchronous JS, and how long it takes is
// entirely a question of how big the plan is. Measured, on synthetic fixtures: one
// Auto-schedule press is a 201ms task on a 40-row plan and a single 2456ms task on a
// 160-row one. The small case is the one that needs a floor: started and stopped
// honestly it would go on and come off inside one frame, and the user would see nothing
// at all, so the button would look exactly as it did before, like a control that had
// not responded. So endBusy holds the ring until the run has been visible for
// BUSY_MIN_MS.
//
// 1600ms, and the number is not a taste call. It is the ring's own animation period, so
// the shortest run anyone can see is exactly ONE pan out and back: the ring appears at
// the start of its travel, crosses, returns, and is removed at rest. A shorter floor
// would cut the pan off somewhere in the middle, which reads as an interruption rather
// than as a finish; a longer one would be this app inventing work it is not doing.
//
// It is a FLOOR and never a ceiling. Nothing here estimates, counts or paces anything:
// if the pass genuinely runs longer than 1600ms the ring simply keeps panning until the
// pass returns, and then goes. That is the difference between this and a fake progress
// bar, which claims to know how far along it is.
const BUSY_MIN_MS=1600;
// Returns a handle, or NULL if this button is not available to start a run: there is no
// such button, or it is already running one. A caller that treats null as "do not run"
// gets the double-press guard for free, without having to remember the disabled
// attribute, and cannot fire its work twice however it was reached.
function startBusy(btn){
  if(!btn||btn.classList.contains('btn-busy'))return null;
  // The ring is a span rather than a pseudo-element because the strip inside it has to
  // be its own layer for the compositor to animate; see the stylesheet. It is inserted
  // and removed rather than left in the markup so a button opts in with one class and
  // carries nothing when it is idle. Absolutely positioned, so a flex button's own
  // layout does not see it.
  const ring=document.createElement('span');
  ring.className='busy-ring';
  ring.setAttribute('aria-hidden','true');
  btn.appendChild(ring);
  btn.classList.add('btn-busy');
  btn.setAttribute('aria-busy','true');
  btn.disabled=true;
  return {btn,ring,t0:Date.now()};
}
// `settle` is whatever the caller needs run once the button is its own again. The
// disabled state is handed BACK rather than guessed at: endBusy re-enables, and a
// caller whose button has its own availability rule (Auto-schedule is disabled on a
// plan with no dependencies) passes that rule in and it wins.
function endBusy(h,settle){
  if(!h||!h.btn){if(settle)settle();return;}
  const left=Math.max(0,BUSY_MIN_MS-(Date.now()-h.t0));
  setTimeout(()=>{
    h.btn.classList.remove('btn-busy');
    h.btn.removeAttribute('aria-busy');
    if(h.ring&&h.ring.parentNode)h.ring.parentNode.removeChild(h.ring);
    h.btn.disabled=false;
    if(settle)settle();
  },left);
}

// ── Auto-schedule ────────────────────────────────────────────────────────────
//
// One pass over the whole plan: no task starts before the work it waits on has
// finished, and no task starts or ends on a Saturday, a Sunday or a federal holiday.
//
// PUSH ONLY. 0.53.0, and it is the change that matters most here. Through 0.52.0 this
// went both ways: slack was pulled IN so every successor sat exactly back to back with
// its predecessor, and a conflict was pushed OUT. Drew's report is that a real plan has
// gaps somebody meant: an order is placed, and the receive that waits on it is three
// weeks later because that is the lead time, not because nobody tidied the dates. The
// old pass closed every one of those and there was no way to keep one.
//
// So the finish-to-start point is now a MINIMUM rather than an answer. For every task
// with live predecessors:
//
//   * its minimum start is the latest predecessor end, moved to a working day (the
//     0.43.0 zero-lag convention: successor.start IS predecessor.end, which is what
//     draws the two bars touching);
//   * if it already starts on or after that minimum, it is LEFT WHERE IT IS, and the
//     gap between the two, however wide, survives the press untouched;
//   * if it starts before it, it is PUSHED later to exactly that minimum, keeping its
//     working-day duration.
//
// Pushes still propagate: a pushed task's successors are measured against its NEW end,
// so a violation three links up moves the whole tail out. Nothing is ever pulled in, so
// the pass can only ever move work later and the plan cannot drift backwards over it.
// It is a fixed point after one press, because a task the pass has just put at its
// minimum is by definition no longer before it: the second press moves nothing.
//
// CANCELLED WORK IS NOT IN THIS PASS AT ALL. schedGraph() has already dropped it, which
// does both halves of what that means in one stroke: a cancelled row is never moved,
// and it imposes NO constraint on the rows that wait on it, so a successor whose only
// predecessors are cancelled is an anchor and keeps the dates it has. Its own dates
// freeze exactly as set, violating or not, because a date on work that will not happen
// is a record of what was planned and not a thing to correct.
//
// BACK TO BACK is a statement about the DRAWN chart, and the chart is what a reader
// is looking at when they say two tasks have a gap between them. It is now what a
// PUSHED pair lands on rather than what every pair is forced to; a pair the author
// spaced deliberately keeps its column of white and that column is the point. A bar runs from
// toX(start) to toX(end), so its right edge is the LEADING edge of the end date's
// cell: the end date is the boundary the work finishes on, not a cell the bar
// paints. The successor's bar therefore meets the predecessor's exactly when the
// successor's start IS the predecessor's end, and one calendar day later leaves a
// visible column of white between them, which is what 0.42.0 did and what 0.43.0
// stopped doing. The weekend never enters it: a predecessor ending on the Friday
// hands off on the Friday, and its successor's five business days still run Mon to
// Fri of the following week because addBiz counts working days, not calendar ones.
//
// nextBiz still guards the handoff. A scheduled end is always a business day, so it
// is the identity there; it earns its place for a predecessor left where it was by
// a cycle, whose end may be a Saturday.
//
// A task with no LIVE predecessors is an ANCHOR and keeps the start it has. That is the
// only thing in the plan this cannot derive, so it is the only thing it does not
// touch, beyond nudging a start that lands on a weekend to the Monday. Since 0.53.0 a
// task whose predecessors are all cancelled is an anchor by the same rule, and since
// 0.53.0 every other task is one too whenever it already clears its minimum.
//
// THE OFF-DAY NUDGE IS LATER-ONLY as well, and always was: nextBiz walks FORWARD off a
// Saturday or a holiday and never back. The end is still derived rather than nudged,
// which is the one place a date can come out earlier than it went in: a Monday-to-
// Saturday task is five working days, so it is redrawn Monday to Friday. No work moved
// earlier there, a non-working tail was dropped, and that arithmetic is untouched.
//
// DURATION is preserved in BUSINESS days, not in calendar days, which is the whole
// point of the weekend rule: a five-day task that would finish on the Saturday
// finishes on the Monday instead, and the same task moved to a Wednesday still
// takes five working days. A task whose current span contains no business day at
// all (a Saturday-to-Sunday task) is treated as one business day, since zero would
// give it an end before its start.
function autoScheduleCompute(){
  const g=schedGraph();
  const {order,stuck}=topoOrder(g);
  const dur=new Map();
  g.nodes.forEach(n=>{ dur.set(n, n.ms?0:Math.max(1,bizBetween(n.start,n.end))); });
  const plan=new Map();
  order.forEach(n=>{
    // Where the row sits now, off a non-working day if it landed on one. This is the
    // candidate the push-only rule defends, and moving it forward off a Saturday is
    // the only thing that can happen to it before the comparison.
    const cur=nextBiz(n.start);
    let s=cur;
    if(n.preds.length){
      // The LATEST predecessor end, which is the EARLIEST this row may start so the
      // two bars at worst meet. A predecessor left unscheduled because it is in a
      // cycle contributes the date it already has, which is the one case that can be
      // a weekend.
      let latest=null;
      n.preds.forEach(p=>{
        const pe=plan.has(p)?plan.get(p).end:p.end;
        if(latest===null||dayNum(pe)>dayNum(latest))latest=pe;
      });
      const min=nextBiz(latest);
      // The push-only rule, in one comparison: at or past the minimum is a gap the
      // author put there and is kept; before it is an overlap and is closed by moving
      // THIS row out, never by pulling the predecessor in.
      if(dayNum(cur)<dayNum(min))s=min;
    }
    plan.set(n,{start:s,end:n.ms?s:addBiz(s,dur.get(n))});
  });
  // A cycle member is left exactly as it is, and so is anything downstream of one:
  // there is no honest answer for a row that waits on itself, and moving it to a
  // guess would be worse than leaving it where the author put it.
  const moves=[];
  plan.forEach((v,n)=>{
    if(n.ms){ if(v.start!==n.r.date)moves.push({n,date:v.start}); }
    else if(v.start!==n.r.start||v.end!==n.r.end)moves.push({n,start:v.start,end:v.end});
  });
  // `total` is the LIVE plan: cancelled rows were never nodes, so they are neither
  // moved nor unchanged and are reported as their own third number instead of being
  // quietly folded into the count of work that was already aligned.
  return {moves,stuck,total:g.nodes.length,scheduled:order.length,cancelled:g.cancelled.length};
}
// The tail every Auto-schedule line ends in, so the two branches cannot word it
// differently. Silent when the plan has no cancelled work, which is most plans.
function excludedNote(res){ return res.cancelled?', '+res.cancelled+' cancelled excluded':''; }
// The result of the pass, applied as ONE undo entry and ONE changelog event. The
// snapshot is the same group snapshot a plan shift takes, so Ctrl+Z puts every
// date back in a single press; `via` is what collapses the diff into one line.
function runAutoSchedule(){
  const res=autoScheduleCompute();
  if(!res.moves.length){
    // Nothing moved, so nothing is recorded. A second click on an already aligned
    // plan is a no-op, not a changelog line saying zero things happened.
    flash(saveInd,'Auto-scheduled: 0 tasks moved, '+res.total+' already aligned'+excludedNote(res),4000);
    return res;
  }
  snapshotGroup(items.filter(r=>r.id).map(r=>r.id));
  res.moves.forEach(m=>{
    if(m.n.ms)m.n.r.date=m.date;
    else{m.n.r.start=m.start;m.n.r.end=m.end;}
  });
  rebuildTimeline();
  updateBars();
  items.filter(r=>r.type==='milestone').forEach(paintMilestone);
  drawDeps();
  refreshChartGeometry();
  renderTable();
  scheduleSave('auto-schedule');
  const n=res.moves.length,un=res.total-n;
  let msg='Auto-scheduled: '+n+' task'+(n===1?'':'s')+' moved, '+un+' unchanged'+excludedNote(res);
  if(res.stuck.length)msg+='. '+res.stuck.length+' left alone: '+cycleNames(res.stuck);
  undoToast.innerHTML=esc(msg)+' &middot; <kbd>Ctrl+Z</kbd>';
  showToast(undoToast,res.stuck.length?7000:4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});
  return res;
}
// The button's entry point: the same pass, with the ring around it.
//
// The two rAFs are the load-bearing part. runAutoSchedule blocks the thread from the
// moment it is called until the toast is up, so calling it straight after adding the
// class would compute and paint in the SAME frame and the ring would never exist on
// screen. The first rAF runs before the next paint, the second runs before the one
// after it, so by the time the pass starts the frame carrying the ring has been
// painted and the animation is already running.
//
// Everything the pass does is unchanged, including the toast, which still fires the
// moment the pass returns and not when the ring settles: the result is news and should
// not wait on an animation.
function runAutoScheduleBusy(){
  // Null means the button is already running a pass. The disabled attribute stops that
  // from the UI, and this stops it from anywhere else: the pass cannot be fired twice.
  const h=startBusy($('btnAuto'));
  if(!h)return;
  requestAnimationFrame(()=>requestAnimationFrame(()=>{
    try{ runAutoSchedule(); }
    finally{ endBusy(h,syncPlanCtl); }
  }));
}
function cycleNames(stuck){
  const nm=stuck.map(n=>n.r.name||n.id||'a row');
  return nm.slice(0,4).join(', ')+(nm.length>4?', and '+(nm.length-4)+' more':'')+' (dependency cycle)';
}

// ── Critical path: the LONGEST path in the plan ──────────────────────
//
// 0.49.0 REDEFINES this. Through 0.48.0 it was textbook CPM: a forward and a backward
// pass, and every row with zero float lit up. The backward pass anchors on the plan's
// LATEST finish, so what that answered was always "which chain ends last". Drew's
// report is that on a real plan this picks a short, simple two-row chain that happens
// to run into December over a ten-row interconnected build that finishes in November,
// and the ten-row build is plainly the longer piece of work.
//
// So the question the button answers now is the one he asked for: what is the LONGEST
// run of work in this plan. Precisely:
//
//   * The dependency graph is a DAG. Tasks and milestones are NODES; a `deps` entry
//     between two dated rows is an EDGE. (Same schedGraph() Auto-schedule reads, so the
//     two features can still never disagree about what the plan's edges are.)
//   * CANCELLED WORK IS NOT A NODE, from 0.53.0. It can never glow, it carries no
//     weight, and a chain that ran through it is BROKEN at that row: the node is absent,
//     so its predecessors and its successors are no longer joined by way of it and the
//     DP has to find its longest path some other way, or settle for the two stumps. A
//     section whose rows depend on nothing inside it falls back to its longest single
//     ROW, and that fallback ignores cancelled rows for the same reason: they are not in
//     the graph to be considered. Un-cancelling puts the node, its edges and every path
//     through it back exactly as they were, because nothing was deleted from the data.
//   * A node WEIGHS its own duration in WORKING days: inclusive business days with
//     weekends and observed federal holidays taken out, the same isOffDay arithmetic
//     0.45.0 put behind every other count in this app. A milestone is an instant, so
//     it weighs 0.
//   * A path's LENGTH is the sum of the weights of the nodes along it. The critical
//     path is the maximum-weight path in the DAG, found by the classic longest-path
//     DP over a topological order.
//
// Nothing is rescheduled and no date is read except the ones the plan has right now,
// exactly as before. Rows on or downstream of a cycle are left out, because there is
// no topological order through them; they are reported the way they always were.
//
// ONE PATH PER SECTION, each computed inside that section's own subgraph: the nodes are
// that section's rows and the edges are the `deps` whose two ends are BOTH in it. A
// dependency that crosses sections is deliberately not an edge here and can never light
// up, because the question being asked is "what is the longest run of work inside this
// section" and a chain that leaves the section is not an answer to it. A section whose
// rows depend on nothing inside it therefore has no edges at all, and its longest path is
// its single longest ROW. That is the same rule, not an exception to it, and it is what
// gets lit.
//
// 0.49.0 also offered the plan-wide answer, one path across the whole graph, as a second
// state of the same button. 0.50.0 drops it: on a real plan it lights one section's chain
// and leaves every other section dark, which is a narrower reading than the per-section
// answer that contains it, and two states of a toggle is one more than the question has.
// The longest-path DP below is unchanged and is simply run once per section subgraph.
//
// AN ISOLATED ROW IS A PATH OF ONE. This is the second break with the old code, which
// only ever considered rows that were in the graph. A one-node path is a real path and
// it wins if it is genuinely the longest thing in the plan; on any plan with a chain
// worth the name it is not, and it never was under CPM either.
//
// TIE-BREAKS, in order, applied to whole candidate paths and to the prefixes the DP
// carries, so the answer is deterministic and reproducible rather than a function of
// the order rows happen to sit in the file:
//
//   1. more working days                (the definition itself)
//   2. greater CALENDAR span, first start to last end
//   3. more nodes
//   4. earliest first node in plan order  (stable, and total: two paths cannot start
//                                          at the same row and still be tied here)
//
// When two disjoint paths are still tied after 1-3, rule 4 picks exactly one, because
// Drew asked for THE critical path and two of them is not an answer.
//
// GAPS DO NOT ADD WEIGHT, and 0.53.0's push-only Auto-schedule leaves a lot more of them
// standing than 0.52.0 did, so it is worth saying plainly what that does and does not
// change here. Rule 1 is a sum of NODE weights: a three-week wait between an order and
// its receive is nobody's working days and adds nothing to either path. So the winner is
// the same before and after a press, and lengthening a gap can never promote a chain.
// Rule 2 is the only rule a gap can reach, and only to separate two chains ALREADY tied
// on working days, where it says the one that occupies more of the calendar is the more
// critical of the two. That is the right reading and it is the one it always had: a
// deliberate gap is real elapsed time the plan has to live through.
function nodeDays(n){ return n.ms?0:Math.max(1,bizBetween(n.start,n.end)); }
// True when path a beats path b. Both are DP records; `b` is null on the first
// candidate, which a always beats.
function pathBeats(a,b){
  if(!b)return true;
  if(a.days!==b.days)return a.days>b.days;
  if(a.span!==b.span)return a.span>b.span;
  if(a.count!==b.count)return a.count>b.count;
  return a.first<b.first;
}
// The longest path through `order` (a topological order), restricted to `members` when
// one is given. One pass: every node's best incoming prefix is chosen from its
// predecessors' already-decided ones, which is sound because a topological order puts
// every predecessor first.
//
// The running maximum is taken over EVERY node rather than only over sinks, and that is
// not a shortcut: node weights are never negative and extending a path can only widen
// its calendar span and add to its count, so a prefix can never beat its own extension
// under the comparator above. Taking the maximum everywhere therefore lands on a
// maximal path anyway, and it is what carries a trailing zero-weight milestone onto the
// answer rather than stopping at the task before it.
function longestPath(order,members,idx){
  const best=new Map();
  let win=null;
  order.forEach(n=>{
    if(members&&!members.has(n))return;
    const d=nodeDays(n),s=dayNum(n.start),e=dayNum(n.end);
    let rec=null;
    n.preds.forEach(p=>{
      if(members&&!members.has(p))return;
      const b=best.get(p);
      if(!b)return;                       // on or downstream of a cycle: no order, no path
      const c={days:b.days+d,minS:Math.min(b.minS,s),maxE:Math.max(b.maxE,e),
               count:b.count+1,first:b.first,node:n,prev:b};
      c.span=c.maxE-c.minS;
      if(pathBeats(c,rec))rec=c;
    });
    if(!rec)rec={days:d,minS:s,maxE:e,span:e-s,count:1,first:idx.get(n),node:n,prev:null};
    best.set(n,rec);
    if(pathBeats(rec,win))win=rec;
  });
  if(!win)return null;
  // Walk the chosen record back to its start. The EDGE ids are collected on the way, so
  // only the links actually ON the path are ever lit: two chains that touch cannot make
  // the link between them glow, which is the same guarantee the old edge test gave.
  const crit=new Set(),edges=new Set();
  for(let r=win;r;r=r.prev){
    crit.add(r.node);
    if(r.prev&&r.prev.node.id&&r.node.id)edges.add(r.prev.node.id+'>'+r.node.id);
  }
  return {crit,edges,days:win.days,count:win.count};
}
// One path per section, unioned into the one set the painters read. The per-path detail
// is kept alongside it for the message the button flashes.
function criticalSet(){
  const g=schedGraph();
  const {order,stuck}=topoOrder(g);
  const idx=new Map();
  g.nodes.forEach((n,i)=>idx.set(n,i));
  const paths=[];
  if(order.length){
    // Bucketed in ONE pass, and each bucket keeps the topological order it was cut
    // from, so a section's pass walks only that section's rows and still sees every
    // predecessor before the row that waits on it. Visited top to bottom, and a
    // section with no dated rows simply never appears.
    const byPhase=new Map();
    order.forEach(n=>{
      const ph=typeof n.r.phase==='number'?n.r.phase:-1;
      if(!byPhase.has(ph))byPhase.set(ph,{seq:[],set:new Set()});
      const bag=byPhase.get(ph);
      bag.seq.push(n);bag.set.add(n);
    });
    [...byPhase.keys()].sort((a,b)=>a-b).forEach(ph=>{
      const bag=byPhase.get(ph);
      const w=longestPath(bag.seq,bag.set,idx);
      if(w){w.phase=ph;paths.push(w);}
    });
  }
  const crit=new Set(),edges=new Set();
  paths.forEach(w=>{w.crit.forEach(n=>crit.add(n));w.edges.forEach(e=>edges.add(e));});
  return {crit,edges,cycle:stuck,paths,graph:g};
}
// The glow is a CLASS on the elements that are already there, added and removed by
// this one function, so there is exactly one place that decides what is lit. It is
// idempotent: everything is cleared first, which is what makes "recompute" and
// "turn off" the same code path with a different flag.
function refreshCritical(){
  document.querySelectorAll('.bar.crit').forEach(el=>el.classList.remove('crit'));
  document.querySelectorAll('.crit-ms').forEach(el=>el.classList.remove('crit-ms'));
  document.querySelectorAll('.data-table tr.crit-row').forEach(el=>el.classList.remove('crit-row'));
  if(depSvg)depSvg.querySelectorAll('.crit').forEach(el=>el.classList.remove('crit'));
  if(!critOn()){critInfo=null;return null;}
  const info=criticalSet();
  critInfo=info;
  info.crit.forEach(n=>{
    if(n.id&&barEls[n.id])barEls[n.id].bar.classList.add('crit');
    if(n.ms){
      if(n.r._msEl)n.r._msEl.classList.add('crit-ms');
      if(n.r._msLine)n.r._msLine.classList.add('crit-ms');
    }
  });
  paintCritDeps();
  paintCritTable();
  return info;
}
// The arrows are thrown away and rebuilt on every geometry change, so their half of
// the glow goes back on from state rather than being recomputed, exactly as the pick
// and the armed state are.
function paintCritDeps(){
  if(!depSvg)return;
  depSvg.querySelectorAll('.crit').forEach(el=>el.classList.remove('crit'));
  if(!critOn()||!critInfo)return;
  depSvg.querySelectorAll('[data-from]').forEach(el=>{
    if(critInfo.edges.has(el.getAttribute('data-from')+'>'+el.getAttribute('data-to')))el.classList.add('crit');
  });
}
let critInfo=null;
// The table's half of the same answer, so switching lens does not lose it. It
// reads the set the last compute produced rather than recomputing, because the
// table is re-rendered on every filter keystroke and the graph has not changed for
// any of them. The arrows have the same relationship with drawDeps: the layer is
// rebuilt wholesale and the classes are put back from state afterwards, which is
// what paintDepSel has always done.
function paintCritTable(){
  if(!tableBody)return;
  tableBody.querySelectorAll('tr.crit-row').forEach(el=>el.classList.remove('crit-row'));
  if(!critOn()||!critInfo)return;
  const keys=new Set();
  critInfo.crit.forEach(n=>keys.add(rowKey(n.r)));
  tableBody.querySelectorAll('tr[data-key]').forEach(tr=>{
    if(keys.has(tr.dataset.key))tr.classList.add('crit-row');
  });
}
// One button, two states, and with the plan-wide mode gone it is an ordinary toggle
// rather than the cycling control the milestone style still is. The label is therefore
// CONSTANT: there is one thing this button does and one state it can be in, so `.on` and
// aria-pressed carry all of it and a label that changed would only be restating them in a
// word. That is also what lets the button size like its neighbours again, since 0.49.0's
// pin existed solely to stop the longest of three labels dragging the group sideways.
// The tooltip is the only text that moves, and it says what the next press will do.
const CRIT_TITLE = {
  off:  'Critical path: light up the longest run of work inside each section. A dependency that crosses sections is left out.',
  phase:'Critical path: the longest run of work INSIDE each section is lit, and a dependency that crosses sections is left out. Press again to turn it off.'
};
// Enter a state. Idempotent, and the one place the mode is written: the attribute, the
// button, the glow and the saved preference all follow from it.
function setCritical(mode){
  prefs.critMode=CRIT_MODES.indexOf(mode)<0?'off':mode;
  document.documentElement.setAttribute('data-crit',prefs.critMode);
  savePrefs();
  const b=$('btnCrit');
  if(b){b.classList.toggle('on',critOn());b.setAttribute('aria-pressed',critOn()?'true':'false');}
  syncPlanCtl();
  const info=refreshCritical();
  if(critOn()&&info){
    flash(saveInd,critMessage(info),info.cycle&&info.cycle.length?6000:3200);
  }
}
function toggleCritical(){
  setCritical(critOn()?'off':'phase');
}
// What the status line says on the way in. It reports the LENGTH, in working days,
// because that is now the whole definition and a reader who cannot see the number has
// no way to check the app against their own arithmetic.
function critMessage(info){
  let msg;
  if(!info.paths.length){
    msg='No critical path: nothing in this plan is dated';
  }else{
    const items=info.crit.size;
    msg='Longest path in each section: '+info.paths.length+' section'+(info.paths.length===1?'':'s')+', '
        +items+' item'+(items===1?'':'s')+', longest is '
        +Math.max(...info.paths.map(w=>w.days))+' working days';
  }
  if(info.cycle&&info.cycle.length)msg+='. '+info.cycle.length+' left out: '+cycleNames(info.cycle);
  return msg;
}

function syncThemeBtn(){ $('themeLabel').textContent = prefs.theme==='dark' ? 'Light' : 'Dark'; }
function wireThemeButton(){
$('btnTheme').addEventListener('click',()=>{
  prefs.theme = prefs.theme==='dark' ? 'light' : 'dark';
  applyTheme();savePrefs();syncThemeBtn();
  // An arrow's colour is DERIVED from the phase and the page, and both of those just
  // changed: the ramp is a different set of hexes in the other theme, and the page the
  // stroke is measured against is the opposite end of the scale. The value written on
  // the element is a resolved rgb(), so nothing recomputes it on its own. The memo is
  // keyed by theme, so this is a recompute and not a stale read.
  recolorDepArrows();
  // and a tag chip's ink is measured against the page the same way (0.61.0)
  repaintTagInks();
});
syncThemeBtn();
}

// One button, three styles, cycled in MS_STYLES order. The icon is the shape the
// style actually draws, so the button shows what you are about to get: the dashed
// rule, the diamond, or the two of them together.
const MS_BTN = { line:{icon:'¦',label:'Lines'}, diamond:{icon:'◆',label:'Diamonds'}, both:{icon:'¦◆',label:'Both'} };
function syncMsBtn(){
  const m = MS_BTN[prefs.msStyle] || MS_BTN.line;
  $('msStyleIcon').textContent = m.icon;
  $('msStyleLabel').textContent = m.label;
}
function wireMsStyleAndGrain(){
$('btnMsStyle').addEventListener('click',()=>{
  prefs.msStyle = MS_STYLES[(MS_STYLES.indexOf(prefs.msStyle)+1) % MS_STYLES.length];
  document.documentElement.setAttribute('data-ms',prefs.msStyle);
  // Both mode brings the grid row back and the caps with it, so the band has to be
  // laid out again on the way in: the caps were left unplaced while the style was
  // diamonds, and nothing else would measure them until the next relayout.
  //
  // The style also adds or removes the milestone rows, which moves every row under
  // them, so this runs the same pass the scroll handler does rather than only the
  // layout half of it: without it the caps keep the visibility they were left with
  // while the style was diamonds, where maskTopBand skips them entirely, and a cap
  // that scrolled under the pinned header stays hidden after the toggle brings it
  // back into view.
  savePrefs();syncMsBtn();layoutMsCaps();sizeTailroom();drawDeps();updatePinnedHeader();
});
syncMsBtn();

$('grainSel').value = G();
wireAppSelect($('grainSel'));
$('grainSel').addEventListener('apppick',function(){
  prefs.granularity = this.value;
  savePrefs();
  rebuildTimeline();
});
}

// ── A row's tag box (0.60.0) ──
//
// The Setup page's chip editor, for a ROW, and one of it: the item editor, the New panel
// and the table's Tags cell all build this box. Enter or a comma adds what was typed, the
// x on a chip removes it, clicking away keeps what was typed, and the suggestions are the
// plan's tags (#tagList). Unlike the Setup box it edits a DRAFT: nothing reaches the plan
// until the panel's Apply or Create, or the cell's commit, reads get(), so a panel with
// three tag changes in it is still one undo and one save. `kind` pins the x buttons for
// the coverage gate (im, nc, cell).
function tagBoxHtml(id,label){
  return `<div class="tag-box" id="${id}"><span class="tb-chips"></span><input type="text" class="tb-in" id="${id}In" list="tagList" maxlength="${TAG_MAX_LEN}" autocomplete="off" aria-label="${esc(label)}"></div>`;
}
function wireTagBox(box,initial,kind,onChange){
  let list=(normalizeTags(initial)||[]).slice();
  const chips=box.querySelector('.tb-chips'), inp=box.querySelector('.tb-in');
  const paint=()=>{
    chips.innerHTML=list.map(t=>`<span class="sec-tag"${tagAttrs(t)}>${esc(t)}<button type="button" class="tb-x" data-tb="${kind}" data-tag="${esc(t)}" tabindex="-1" title="Remove the tag ${esc(t)}" aria-label="Remove the tag ${esc(t)}">&times;</button></span>`).join('');
    const full=list.length>=TAG_MAX_COUNT;
    inp.placeholder=full?'12 tags, the most a row can carry':list.length?'+ tag':'+ add a tag, Enter';
    inp.readOnly=full;
    // A box that grew past the bottom of its scroller (the table's last row, ten chips)
    // brings the caret back into view, so nobody types blind (0.60.0 review).
    if(kind==='cell'&&inp.isConnected)try{ inp.scrollIntoView({block:'nearest',inline:'nearest'}); }catch(_){}
  };
  // `onChange` (the table cell) saves each chip as it is added or removed, the way the
  // Setup box does, so the cell never shows a pill the plan does not have.
  const changed=()=>{ paint(); if(onChange)onChange(list.slice()); };
  const take=()=>{
    const words=inp.value.split(',').map(w=>w.trim()).filter(Boolean);
    inp.value='';
    if(!words.length)return false;
    const nx=normalizeTags([...list,...words]);
    if(sameTags(nx,list))return false;
    list=nx; changed(); return true;
  };
  // A press on an x must not take the focus out of the box: in a table cell that blur IS
  // the commit, and in a panel it would drop the caret.
  box.addEventListener('mousedown',e=>{ if(e.target!==inp)e.preventDefault(); });
  box.addEventListener('click',e=>{
    const x=e.target.closest('.tb-x');
    if(x){ e.stopPropagation(); list=list.filter(t=>tagKey(t)!==tagKey(x.dataset.tag)); changed(); }
    try{ inp.focus({preventScroll:true}); }catch(_){ inp.focus(); }
  });
  // Registered BEFORE the panel's or the cell's own keys, so Enter with text in the box
  // adds a tag and goes no further; Enter on an empty box is the panel's Apply (or the
  // cell's commit) as it is from every other field.
  inp.addEventListener('keydown',e=>{
    const stop=()=>{ e.preventDefault(); e.stopImmediatePropagation(); e.stopPropagation(); };
    if((e.key==='Enter'||e.key===',')&&inp.value.trim()){ stop(); take(); }
    else if(e.key===','){ stop(); }
    else if(e.key==='Escape'&&inp.value){ stop(); inp.value=''; }
    else if(e.key==='Backspace'&&!inp.value&&list.length&&!e.repeat){ stop(); list=list.slice(0,-1); changed(); }
  });
  inp.addEventListener('input',()=>{ if(inp.value.includes(','))take(); });
  inp.addEventListener('blur',()=>{ if(inp.value.trim())take(); });
  paint();
  return { get:()=>{ take(); return list.slice(); }, input:inp, box };
}

// ── One layout for every panel that edits a task (0.79.0) ──
//
// The item editor (a task or a milestone, from the chart or from the Table), the New panel
// and the batch panel are three jobs with one look: the same width, the same title, the
// same grid of labelled fields and the same row of buttons, all of it in the app's text
// size. edShell is the frame and edF is one field, so a field added to any of them (the
// subtasks branch's parent, which goes full width under the section) is laid out like the
// rest without any CSS of its own. The stylesheet's "edit panel" block says how the grid
// works; here is only what goes in it.
//
// o.title   the title, already escaped
// o.sub     an optional line under it, already escaped
// o.body    the fields (edF ... or an .ed-note)
// o.actions the buttons
// o.err     the id of the panel's error line, which sits over the buttons
function edShell(o){
  return '<div class="drag-bar" aria-hidden="true"></div>'
    +`<div class="ed-head"><div class="ed-title" id="edTitle">${o.title}</div>${o.sub?`<div class="ed-sub">${o.sub}</div>`:''}</div>`
    +`<div class="ed-body"><div class="ed-grid">${o.body}</div></div>`
    +`<div class="ed-foot">${o.err?`<div class="ed-err" id="${o.err}" style="display:none"></div>`:''}<div class="de-actions">${o.actions}</div></div>`;
}
// One field: the label (and its small print) over the control. `w` is 'half', 'third' or
// 'twothirds' for a field that shares its row, nothing for one that takes the whole row;
// `id` names the field for the code that hides it (a milestone's start); `for` ties the
// label to the control; `labelId` names the label for the code that rewords it.
function edF(label,control,o){
  o=o||{};
  return `<div class="ed-f${o.w?' '+o.w:''}${o.sep?' sep':''}"${o.id?` id="${o.id}"`:''}>`
    +`<label${o.for?` for="${o.for}"`:''}${o.labelId?` id="${o.labelId}"`:''}>${label}${o.hint?` <span class="de-hint">${o.hint}</span>`:''}</label>`
    +`<div class="ed-c">${control}</div></div>`;
}
// A text that can run long (a name, a note, the bar label, a provenance, an owner) is a
// textarea that wraps and grows: one line high while it fits, as many lines as it needs
// when it does not, as it is typed and when the panel opens. It is still one line of
// text: Enter applies the panel as it always did (the panel's keys take it), and a line
// break that arrives in a paste becomes a space, so what is saved is exactly what the
// one-line field saved. Its value is assigned by the caller, never interpolated here.
function edTa(id,o){
  o=o||{};
  return `<textarea id="${id}" class="ed-grow" rows="1"${o.ph?` placeholder="${esc(o.ph)}"`:''}${o.max?` maxlength="${o.max}"`:''}${o.attrs||''}></textarea>`;
}
// Fit one growing field to its text. Set to auto first, so it can SHRINK when text goes;
// the body keeps its scroll position across the moment the field is one line high.
function edGrow(ta){
  if(!ta||!ta.isConnected||!ta.offsetParent)return;
  const body=ta.closest('.ed-body'), keep=body?body.scrollTop:0;
  ta.style.height='auto';
  const cs=getComputedStyle(ta);
  ta.style.height=Math.ceil(ta.scrollHeight+(parseFloat(cs.borderTopWidth)||0)+(parseFloat(cs.borderBottomWidth)||0))+'px';
  if(body&&body.scrollTop!==keep)body.scrollTop=keep;
}
// A select cannot wrap, and a section's name can be longer than its field: the field's
// tooltip is the whole name of the section it holds, kept as the pick changes.
function edSelTitle(sel){
  if(!sel)return;
  const set=()=>{ const o=sel.options[sel.selectedIndex]; sel.title=o?o.textContent:''; };
  sel.addEventListener('change',set); set();
}
// Every growing field, then the panel back inside the window. Run when a panel opens and
// whenever the width or the type can have changed under it (a resize, the text size, the
// web fonts arriving), since each of those rewraps the text.
function edFit(){
  if(!editorEl.classList.contains('show'))return;
  edSetMaxH();
  editorEl.querySelectorAll('textarea.ed-grow').forEach(edGrow);
  edKeepInView();
}
function edOneLine(ta){
  const v=ta.value; if(!/[\r\n]/.test(v))return;
  const flat=s=>s.replace(/\r\n?|\n/g,' '), at=ta.selectionStart;
  ta.value=flat(v);
  if(typeof at==='number'){ const p=flat(v.slice(0,at)).length; try{ ta.setSelectionRange(p,p); }catch(_){} }
}
// A field growing at the foot of a body that has started to scroll keeps its last line in
// view, so nobody types below the edge of the panel.
function edShowField(ta){
  const body=ta.closest('.ed-body'); if(!body||document.activeElement!==ta)return;
  const b=body.getBoundingClientRect(), t=ta.getBoundingClientRect();
  if(t.bottom>b.bottom)body.scrollTop+=Math.ceil(t.bottom-b.bottom)+2;
  else if(t.top<b.top)body.scrollTop-=Math.ceil(b.top-t.top)+2;
}
// Wired once, on the panel element, which outlives every panel built in it. The input
// listener CAPTURES, so a field has its new height before its own listeners run (the
// owner field places its list under itself on input, and must measure the grown field).
function wireEditorFit(){
  editorEl.addEventListener('input',e=>{
    const t=e.target;
    if(!t||!t.matches||!t.matches('textarea.ed-grow'))return;
    edOneLine(t); edGrow(t); edShowField(t);
  },true);
  // The panel changes size as its fields grow, its lists fill and its fields show or hide;
  // whatever the cause, it is moved back inside the window. Only its PLACE changes here,
  // never its size, so this can never feed itself.
  if(typeof ResizeObserver==='function')new ResizeObserver(()=>edKeepInView()).observe(editorEl);
  window.addEventListener('resize',()=>edFit());
  try{ if(document.fonts&&document.fonts.addEventListener)document.fonts.addEventListener('loadingdone',()=>edFit()); }catch(_){}
}

// ── The full item editor ──
//
// Everything about one row in one panel: the same fields the create panel offers,
// on an existing item. Reached by double-clicking a label, which upgrades from
// the inline name editor and carries whatever was already typed.
// THE item editor (0.65.0): one panel for a task or a milestone, however it is reached:
// a click on the bar or the milestone, on the name in the label column, on the row's Deps
// cell, the pencil, or a double-click. Through 0.64 a bar click opened a small dates-and-
// dependencies panel, a milestone click a name-date-description panel, and only the pencil
// this one, so the same row was edited three different ways. The bar label and the
// dependencies those small panels held live here now. `opts.anchor` places it beside the
// thing clicked; `opts.focus` is 'deps' when the Deps cell opened it.
function openItemModal(r,carriedName,opts){
  opts=opts||{};
  closeEditor();
  edSubject=r; scheduleFocus();
  const ms=r.type==='milestone';
  const p=provOf(r)||{label:'',url:''};
  // FIELD FOR FIELD THE NEW-ITEM PANEL, in the same order, in the same rows, with the
  // same labels, hints and placeholders. They were two layouts doing one job: this one
  // led with the name and put phase on a row of its own, the create panel led with type
  // and phase and put the name under them, and a person who had just created three rows
  // had to re-find every field the moment they went to edit one. The create panel is the
  // one that stayed, because it is the one people meet first.
  //
  // Three deliberate differences remain, and they are the three places where editing an
  // existing row is genuinely not the same job as making a new one:
  //
  //   TYPE is present but DISABLED. It has to be present or every row below it would sit
  //   one line higher than it does in the other panel and the two would not match. It has
  //   to be disabled because nothing in this app converts a task into a milestone: a task
  //   has a start and an end and a bar, a milestone has a single date and a diamond, and
  //   silently dropping one of a task's two dates is not a conversion. Disabled rather
  //   than hidden also means it still SAYS what the row is, which is worth a line.
  //
  //   STATUS keeps its "none" option. The create panel has no need of it (a new row opens
  //   on not-started), but an existing row can have no status at all and there has to be
  //   a way back to that, or the first person to set a status by accident could never
  //   clear it.
  //
  //   THE BUTTONS are Apply / Cancel / Delete rather than Create / Cancel, with Delete
  //   holding the margin-left:auto that has kept the destructive button away from the
  //   other two since 0.37.0.
  //
  // The layout is every panel's (edShell, 0.79.0): type and section side by side (the
  // section's name is the longer, so it has two thirds of the row), the name across, owner
  // and status, the two dates, effort and the bar label (task only: both are hidden on a
  // milestone, so its next row closes up), notes, provenance beside tags, and the dependency
  // list. A subtask's parent (the subtasks branch) goes full width under the section.
  editorEl.innerHTML=edShell({
    title:ms?'Edit milestone':'Edit task',
    err:'imErr',
    body:edF('type','<select id="imType" disabled title="A task has a start and an end, a milestone has one date. Changing one into the other is not an edit, so make the row you want and delete this one."><option value="task">Task</option><option value="milestone">Milestone</option></select>',{w:'third',for:'imType'})
      +edF('phase',`<select id="imPhase">${phases.map((ph,i)=>`<option value="${i}">${esc(ph.name)}</option>`).join('')}</select>`,{w:'twothirds',for:'imPhase'})
      +edF('name',edTa('imName',{ph:'What is it?'}),{for:'imName',hint:'required'})
      +edF('owner',edTa('imOwner',{ph:'one name',attrs:' autocomplete="off"'}),{w:'half',for:'imOwner'})
      +edF('status',`<select id="imStatus"><option value="">none</option>${STATUS_ORDER.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('')}</select>`,{w:'half',for:'imStatus'})
      // A request on this row (0.80.0): who asked whom, with Accept, Decline or Withdraw.
      +'<div class="ed-note im-req" id="imReq" style="display:none"></div>'
      +edF('start','<input type="date" id="imStart">',{w:'half',id:'imStartWrap',for:'imStart'})
      +edF('deadline','<input type="date" id="imEnd">',{w:'half',for:'imEnd',labelId:'imEndLabel'})
      +edF('effort',`<input type="text" id="imEffort" class="eff-in" inputmode="decimal" autocomplete="off" placeholder="not estimated" title="${esc(EFFORT_HELP)}">`,{w:'half',id:'imEffWrap',for:'imEffort',hint:'hours'})
      +edF('label on the bar',edTa('imNote',{ph:'a few characters',max:24}),{w:'half',id:'imNoteWrap',for:'imNote',hint:'e.g. 6m'})
      +edF('notes',edTa('imDesc',{ph:'Shown on hover and in the table'}),{for:'imDesc'})
      +edF('provenance',p.url
        ?`<a class="prov-fixed" id="imProvLink" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer" title="${esc(p.label+', '+p.url)}">${esc(p.label)}</a>`
        :edTa('imProv'),{w:'half',for:p.url?'':'imProv',hint:p.url?'a link, kept as it is':'where this came from'})
      +edF('tags',tagBoxHtml('imTags','Tags on this row'),{w:'half',for:'imTagsIn',hint:'Enter or a comma adds one'})
      +edF('dependencies','<ul class="dep-list" id="imDepList"></ul><select id="imAddDep"></select>',{id:'imDepsWrap',sep:true,for:'imAddDep'}),
    actions:'<button class="de-btn primary" id="imApply">Apply</button>'
      +'<button class="de-btn secondary" id="imCancel">Cancel</button>'
      +'<button class="de-btn secondary" id="imCopyLink" title="Copy a link to this row, to paste in Chat">Copy link</button>'
      +'<button class="de-btn danger" id="imDelete" title="Delete this row from the plan. Undoable with Ctrl+Z">Delete</button>',
  });
  // values assigned, never interpolated, so nothing typed here can become markup
  $('imName').value=carriedName!==undefined?carriedName:(r.name||'');
  $('imPhase').value=String(r.phase);
  // The start field is HIDDEN for a milestone and not removed, which is exactly what
  // the create panel does when its type select is set to Milestone. Removing it would
  // put the two panels one element apart in a walk of their fields, and matching them
  // is the whole point of this pass.
  $('imType').value=ms?'milestone':'task';
  $('imStartWrap').style.display=ms?'none':'';
  $('imEndLabel').textContent=ms?'date':'deadline';
  $('imStart').value=ms?'':(r.start||'');
  $('imEnd').value=(ms?r.date:r.end)||'';
  $('imOwner').value=r.owner||'';
  rqEditorLine(r);
  $('imEffWrap').style.display=ms?'none':'';
  $('imEffort').value=effortOf(r)===null?'':String(effortOf(r));
  // A milestone has no bar to label, so that field is hidden for it, the way its start
  // field is. Its dependencies are shown (0.78.0): a gate waits on work and work waits on
  // it. Only a row with no id (a milestone in a plan nothing has given ids to) has none.
  const canDep=!!r.id;
  $('imNoteWrap').style.display=ms?'none':'';
  $('imDepsWrap').style.display=canDep?'':'none';
  $('imNote').value=r.note||'';
  // The dependencies are a DRAFT, like every other field here: nothing lands until Apply,
  // and Cancel or Escape leaves the plan as it was. (The small bar panel this replaces
  // wrote each add and remove at once, the one field in the app that ignored Cancel.)
  // `needs` is what this row waits on; a "blocks" line is another row waiting on this
  // one, and its x takes this row out of THAT row's list on Apply. Either may be a task
  // or a milestone, and a milestone is named with its diamond.
  let dNeeds=canDep?[...(r.deps||[])]:[];
  const dUnblock=new Set();
  const depLabel=x=>(x&&(x.name||x.id)||'')+(x&&x.type==='milestone'?' ◆':'');
  const renderDeps=()=>{
    if(!canDep)return;
    const list=$('imDepList'), add=$('imAddDep'); if(!list||!add)return;
    const blocks=items.filter(d=>d.id&&d.deps&&d.deps.includes(r.id)&&!dUnblock.has(d.id));
    const li=(dir,id,name)=>`<li><span class="dep-dir ${dir}">${dir==='in'?'← needs':'→ blocks'}</span><span class="dep-nm" title="${esc(name)}">${esc(name)}</span>`
      +`<span class="dep-rm" role="button" tabindex="0" data-dep="${esc(id)}" data-dir="${dir}" title="${dir==='in'?'This one stops waiting on it':'It stops waiting on this one'}. Lands on Apply">×</span></li>`;
    list.innerHTML=dNeeds.map(id=>li('in',id,depLabel(items.find(d=>d.id===id))||id)).join('')+blocks.map(b=>li('out',b.id,depLabel(b))).join('')
      ||'<li class="dep-none">No dependencies.</li>';
    // Offered: every other task and milestone this one does not wait on yet, minus any that
    // would close a loop (one that already waits on this row, directly or through a chain).
    const cand=items.filter(t=>t.id&&t.id!==r.id&&t.type!=='group'&&!dNeeds.includes(t.id)&&!depPathTo(t.id,r.id));
    add.innerHTML=`<option value="">+ waits on...</option>`+cand.map(t=>`<option value="${esc(t.id)}">${esc(depLabel(t))}</option>`).join('');
    add.value='';
    list.querySelectorAll('.dep-rm').forEach(b=>{
      const go=()=>{ if(b.dataset.dir==='in')dNeeds=dNeeds.filter(d=>d!==b.dataset.dep); else dUnblock.add(b.dataset.dep); renderDeps(); };
      b.addEventListener('click',e=>{ e.stopPropagation(); go(); });
      b.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); e.stopPropagation(); go(); } });
    });
  };
  renderDeps();
  // On `apppick` (a click on the list, or Enter), not on `change`: the arrows walk this
  // list too, and every step of them is a change (0.66.1).
  if(canDep)$('imAddDep').addEventListener('apppick',function(){ if(this.value&&!dNeeds.includes(this.value)){ dNeeds.push(this.value); renderDeps(); $('imAddDep').focus(); } });
  $('imStatus').value=statusOf(r);
  $('imDesc').value=r.description||'';
  { const cl=$('imCopyLink'); if(cl){ cl.textContent=r.uid?'Copy link #'+r.uid:'Copy link'; cl.addEventListener('click',e=>{ e.preventDefault(); copyRowLink(r,cl); }); } }
  if($('imProv'))$('imProv').value=p.label||'';
  // The row's OWN tags only: the section's are the section's, edited on Setup.
  const tagBox=wireTagBox($('imTags'),ownTags(r),'im');
  const secT=phaseTagsOf(r);
  if(secT.length)$('imTags').title='Its section also gives it: '+secT.join(', ')+'. Those are edited on the Setup page.';
  // Beside what was clicked: the bar, the milestone or the Deps cell, flipped to its left
  // when the right has no room; otherwise beside the row's label. placeEditor clamps it.
  // Placed once its real size is known (it depends on the text size and the window).
  const rect=opts.anchor||(r._lDiv||document.body).getBoundingClientRect();
  showEditor(w=>{
    let px=rect.right+12;
    if(opts.anchor&&px+w>window.innerWidth-ED_MARGIN)px=Math.max(ED_MARGIN,rect.left-w-12);
    return {x:px,y:rect.top};
  });
  ['imPhase','imStatus','imAddDep'].forEach(id=>{ const el=$(id); if(el)wireAppSelect(el); });
  edSelTitle($('imPhase'));
  wireOwnerCombo($('imOwner'));
  if(opts.focus==='deps'&&canDep)$('imAddDep').focus();
  else{ $('imName').focus();$('imName').select(); }
  setEditorKeys(e=>{e.stopPropagation();
    if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('imApply').click();}
    else if(e.key==='Escape'){e.preventDefault();closeEditor();}});
  $('imCancel').addEventListener('click',closeEditor);
  $('imDelete').addEventListener('click',()=>deleteFromPanel(r));
  $('imApply').addEventListener('click',()=>{
    const name=$('imName').value.trim();
    if(!name){const er=$('imErr');er.textContent='A name is required.';er.style.display='';$('imName').focus();return;}
    // Read and refused BEFORE the snapshot, so a typo leaves no undo entry behind.
    const eff=ms?null:parseEffort($('imEffort').value);
    if(Number.isNaN(eff)){const er=$('imErr');er.textContent='Effort is hours, like 16, 2d or 1w, or empty for not estimated.';er.style.display='';$('imEffort').focus();return;}
    const np=parseInt($('imPhase').value,10);
    const moves=!!phases[np]&&np!==r.phase;
    // The rows a "blocks" x changes are snapshotted WITH this one, so the whole Apply is
    // still one Ctrl+Z (snapshotMany makes one entry of several rows).
    // A milestone in that list, or as the row itself, takes a milestone's entry (msSnap).
    const unblocked=canDep?[...dUnblock].map(id=>items.find(d=>d.id===id)).filter(Boolean):[];
    if(!r.id)snapshotMs(r); else snapshotMany([r.id,...unblocked.map(b=>b.id)]);
    // ONE Ctrl+Z takes the whole Apply back, the section included. The field snapshot
    // does not carry the lane or the row's place in the plan, and moveRowToPhase records
    // nothing of its own, so until 0.57.1 an undo restored every field and left the row
    // in the section it had been moved to. The two halves go in as one entry, the way
    // the table's phase cell already records its move.
    if(moves){
      const ed=undoStack.pop();
      pushUndo({type:'multi',steps:[placeSnap([r]),ed]});
    }
    r.name=name;
    const end=$('imEnd').value;
    if(ms){ if(end)r.date=end; }
    else{
      const st=$('imStart').value;
      if(st)r.start=st;
      if(end)r.end=end;
      if(r.start&&r.end&&r.start>r.end)r.start=r.end;
    }
    // An owner who is somebody else is asked, after the rest lands, in the same undo (0.80.0).
    const ownNew=$('imOwner').value.trim().replace(/\s+/g,' '), askTo=rqAsks(r,ownNew)?ownNew:'';
    if(!askTo)setOpt(r,'owner',ownNew);
    setOpt(r,'status',STATUS_ORDER.includes($('imStatus').value)?$('imStatus').value:'');
    setOpt(r,'description',$('imDesc').value.trim());
    if(!ms)setEffort(r,eff);
    let depsMoved=false;
    if(!ms)r.note=$('imNote').value.trim();
    if(canDep){
      if(JSON.stringify(dNeeds)!==JSON.stringify(r.deps||[])){ r.deps=dNeeds.slice(); depsMoved=true; }
      unblocked.forEach(b=>{ b.deps=(b.deps||[]).filter(d=>d!==r.id); depsMoved=true; });
    }
    // A linked provenance is left exactly as it was (0.71.0); a plain one is the field.
    if(!p.url){
      const pl=$('imProv').value.trim();
      if(!pl)delete r.provenance;
      else r.provenance=pl;
    }
    // Tags ride in the same undo entry as every other field (editSnapshot carries them).
    // A row that had none and still has none is left without the key; one cleared here
    // keeps `[]`, the sidecar's "cleared".
    const nt=tagBox.get(), tagsMoved=!sameTags(nt,ownTags(r));
    if(tagsMoved)r.tags=nt;
    if(ms){refreshMsChrome(r);updateMilestone(r);} else {updateBar(r.id);refreshLabelRow(r);}
    if(phases[np]&&np!==r.phase)moveRowToPhase(r,np);
    if(tagsMoved)afterTagsChange();
    if(depsMoved)drawDeps();
    applyCollapse();refreshChartGeometry();renderTable();
    closeEditor();
    scheduleSave('item-editor');
    if(askTo)rqAsk(r,askTo,{join:true});
  });
}

// ── Create a row ──
//
// The same inline editor panel the milestone editor uses, so creating a row and
// editing one feel like the same gesture. Everything typed here flows into the
// same rendering paths as data-file content, which is where the escaping lives.
// What the last successful Create left behind, so adding six tasks for the same
// person, in the same lane, in the same week, is six names typed and nothing else.
// Every field is carried except the name and, since 0.66.2, the notes: a note is about
// the one row it was written for (Drew: "do not persist note field between new task
// creation"), where the owner, the lane, the dates and the tags are usually shared by a
// run of rows. In-memory on purpose: a refresh clears
// it, and it is never written to prefs, localStorage or the sidecar, so it is a
// fact about this tab in this sitting and not about the plan or about anyone else
// reading it. Cancel, Escape and a rejected create never touch it; only a create
// that actually produced a row does.
let lastCreated=null;
function openCreateEditor(){
  // Making a row and linking rows are two different jobs, and the second one holds the
  // chart: in dependency mode a click on a bar is half of a link, the row under the
  // pointer wears a wash, and an armed source is outlined waiting for its pair. Opening
  // the create panel on top of all that leaves the user typing a name into a panel while
  // the chart behind it is still armed for something else, and their first click after
  // Create lands as half a dependency they did not ask for.
  //
  // So creating LEAVES the mode first, by the same door Escape uses, and then creates.
  // Every create path in the app comes through here (Shift+N, the New button), so this
  // is the one place it has to be said. Nothing is refused and nothing is deferred: the
  // mode goes, the panel opens.
  if(depMode)setDepMode(false);
  closeEditor();
  const today=isoDay(new Date());
  // One week is the default span for a new task: most things people add here are a
  // week's work, and 14 days was never anything more than a round number. Carried
  // values from the last creation still win over this, because the user set those.
  const plus=addDays(today,7);
  // The NAME always starts empty. It is the one field that makes an item that item,
  // so carrying it over would offer to create the same row twice; everything else
  // is context the user has already told us once.
  const memo=lastCreated||{};
  const ownerVal=memo.owner||'';
  const startVal=memo.start||today;
  const endVal=memo.end||plus;
  const descVal='';                        // never carried (0.66.2), see above
  // Who added it, by name, when there is a name to use. Still an ordinary editable
  // field: this is a default, not a stamp. A provenance the user actually TYPED is
  // carried; one they left alone is not stored at all, so the default keeps
  // following the signed-in identity rather than freezing a name into the session.
  const provDefault=USER_NAME?('Added by '+USER_NAME):'Added by hand';
  const provVal=(memo.prov!==undefined&&memo.prov!==null)?memo.prov:provDefault;
  // The same layout as the item editor, field for field (edShell, 0.79.0).
  editorEl.innerHTML=edShell({
    title:'New item',
    err:'ncErr',
    body:edF('type','<select id="ncType"><option value="task">Task</option><option value="milestone">Milestone</option></select>',{w:'third',for:'ncType'})
      +edF('phase',`<select id="ncPhase">${phases.map((p,i)=>`<option value="${i}">${esc(p.name)}</option>`).join('')}</select>`,{w:'twothirds',for:'ncPhase'})
      +edF('name',edTa('ncName',{ph:'What is it?'}),{for:'ncName',hint:'required'})
      +edF('owner',edTa('ncOwner',{ph:'one name',attrs:' autocomplete="off"'}),{w:'half',for:'ncOwner'})
      +edF('status',`<select id="ncStatus">${STATUS_ORDER.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('')}</select>`,{w:'half',for:'ncStatus'})
      +edF('start',`<input type="date" id="ncStart" value="${startVal}">`,{w:'half',id:'ncStartWrap',for:'ncStart'})
      +edF('deadline',`<input type="date" id="ncEnd" value="${endVal}">`,{w:'half',for:'ncEnd',labelId:'ncEndLabel'})
      +edF('effort',`<input type="text" id="ncEffort" class="eff-in" inputmode="decimal" autocomplete="off" placeholder="not estimated" title="${esc(EFFORT_HELP)}" value="${memo.effort===null||memo.effort===undefined?'':esc(String(memo.effort))}">`,{w:'half',id:'ncEffWrap',for:'ncEffort',hint:'hours'})
      +edF('notes',edTa('ncDesc',{ph:'Shown on hover and in the table'}),{for:'ncDesc'})
      +edF('provenance',edTa('ncProv'),{w:'half',for:'ncProv',hint:'where this came from'})
      +edF('tags',tagBoxHtml('ncTags','Tags on the new row'),{w:'half',for:'ncTagsIn',hint:'Enter or a comma adds one'}),
    actions:'<button class="de-btn primary" id="ncCreate">Create</button><button class="de-btn secondary" id="ncCancel">Cancel</button>',
  });
  // Assigned, never interpolated, like the item editor's fields.
  $('ncOwner').value=ownerVal; $('ncDesc').value=descVal; $('ncProv').value=provVal;
  // Under the New button, its right edge on the button's.
  const r=$('btnNew').getBoundingClientRect();
  showEditor(w=>({x:r.right-w,y:r.bottom+8}));
  // The three selects carry too, and they are set BEFORE syncType runs: a carried
  // milestone has to open with its start field already hidden and its second date
  // already labelled "date", not flip a frame later. A carried phase that no longer
  // exists (its lane was deleted) falls back to the first, rather than leaving the
  // select on a value it cannot show.
  $('ncType').value=(memo.type==='milestone')?'milestone':'task';
  if(memo.phase!==undefined&&memo.phase!==null&&phases[memo.phase])$('ncPhase').value=String(memo.phase);
  $('ncStatus').value=(memo.status&&STATUS_ORDER.includes(memo.status))?memo.status:'not-started';
  const syncType=()=>{
    const ms=$('ncType').value==='milestone';
    $('ncStartWrap').style.display=ms?'none':'';
    $('ncEffWrap').style.display=ms?'none':'';
    $('ncEndLabel').textContent=ms?'date':'deadline';
  };
  $('ncType').addEventListener('change',syncType); syncType();
  // Carried like every other field but the name: six rows for the same "long-lead" work
  // are six names typed and nothing else.
  const tagBox=wireTagBox($('ncTags'),memo.tags||[],'nc');
  ['ncType','ncPhase','ncStatus'].forEach(id=>{ const el=$(id); if(el)wireAppSelect(el); });
  edSelTitle($('ncPhase'));
  wireOwnerCombo($('ncOwner'));
  $('ncName').focus();
  $('ncCancel').addEventListener('click',closeEditor);
  setEditorKeys(e=>{e.stopPropagation();
    if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('ncCreate').click();}
    else if(e.key==='Escape'){e.preventDefault();closeEditor();}});
  // Async, because a new row cannot exist until the server has given it a uid.
  // Every field is read BEFORE the await: the panel can be dismissed while the
  // request is in flight, and reading an input out of a torn-down panel afterwards
  // would throw and lose the row the user had just described.
  let creating=false;
  $('ncCreate').addEventListener('click',async()=>{
    if(creating)return;                       // Enter and a click both land here
    const name=$('ncName').value.trim();
    if(!name){const er=$('ncErr');er.textContent='A name is required.';er.style.display='';$('ncName').focus();return;}
    const isMs=$('ncType').value==='milestone';
    const phase=parseInt($('ncPhase').value,10)||0;
    const end=$('ncEnd').value||endVal;
    const start=$('ncStart').value||startVal;
    const owner=$('ncOwner').value.trim();
    const st=$('ncStatus').value;
    const desc=$('ncDesc').value.trim();
    const prov=$('ncProv').value.trim();
    const tags=tagBox.get();
    const eff=isMs?null:parseEffort($('ncEffort').value);
    if(Number.isNaN(eff)){const er=$('ncErr');er.textContent='Effort is hours, like 16, 2d or 1w, or empty for not estimated.';er.style.display='';$('ncEffort').focus();return;}
    creating=true;
    const btn=$('ncCreate'); if(btn)btn.disabled=true;
    let uid=null;
    try{ uid=await allocUid(); }
    catch(err){
      creating=false;
      const b=$('ncCreate'); if(b)b.disabled=false;
      const er=$('ncErr');
      if(er){ er.textContent='No item ID could be allocated, so nothing was created ('+err.message+'). Check the connection and try again.'; er.style.display=''; }
      else flash(saveInd,'could not allocate an item ID, nothing was created');
      return;
    }
    creating=false;
    // A milestone gets an id like a task (0.78.0), so a dependency can name it. The server
    // makes it unique if someone else made one of the same name in the same second.
    const r={type:isMs?'milestone':'task',uid,name,phase};
    r.id=freshId(name);
    if(isMs){ r.date=end; }
    else { r.start=start<=end?start:end; r.end=end; }
    if(!isMs)r._key=undefined;
    // A new row for somebody else is made unassigned and asked of them (0.80.0).
    const askTo=owner&&rqAsks({owner:''},owner)?owner:'';
    if(owner&&!askTo)r.owner=owner;
    if(st)r.status=st;
    if(desc)r.description=desc;
    if(prov)r.provenance=prov;
    if(tags.length)r.tags=tags.slice();
    if(!isMs&&eff!==null)r.effort=eff;
    // Only now, past validation and past the uid allocation, with a row actually
    // being made. A milestone hides the start field but the input keeps its value,
    // so `start` is whatever was carried in and the remembered start comes through
    // a milestone unchanged, which is what the panel looks like it is doing.
    //
    // `prov` is stored only when the user changed it. Left at the default, it is
    // recorded as absent so the next panel recomputes the default from the
    // signed-in identity instead of freezing today's name into the session.
    lastCreated={type:isMs?'milestone':'task', phase, owner:owner||'', start, end,
                 status:st||'', tags:tags.slice(), effort:isMs?(lastCreated&&lastCreated.effort):eff,
                 prov:(prov===provDefault)?undefined:prov};
    addedItems.push(r);
    insertRow(r);
    pushUndo({type:'create',r});
    closeEditor();
    renderTable();
    rebuildTimeline();          // a new date can widen the axis
    if(tags.length)afterTagsChange();   // a tag new to the plan gets its legend chip
    scheduleSave('create');
    flash(saveInd,'added '+(isMs?'milestone':'task'));
    if(askTo)rqAsk(r,askTo,{created:true,noUndo:true});
  });
}
// Compressed chip in the header, medium card in the pulldown: the same two
// renderings every other Adom surface shows, so identity is one learnable object
// rather than a per-app invention. Real name bold and primary, the two machine
// identifiers together on one muted line, email shown and not hidden behind a
// hover. No orgs at this size, and no kebab: the card is display, the rows below
// are the actions.
// ── Quick add (0.79.0, issue #6) ──
//
// A task typed in place: the + on a section's header (or the "+ Add task" line under a
// section in the Table) opens a blank row at the end of that section, the + on a task opens
// one as its last subtask. Enter adds the task and leaves the row open for the next one;
// Esc, or Enter on an empty row, stops. Each task is made the way New makes one: one undo
// entry, one changelog line, a row in the sidecar's `added`. Its fields are New's remembered
// ones (owner, status, tags) and New's default dates, then whatever the view is filtered to
// (an owner, a status, a tag), so the new row stays on screen, as ClickUp fills a new task
// from the view's filters. Everything else is edited afterwards, as now.
let qa=null;
function openQuickAdd(o){
  if(depMode)setDepMode(false);
  closeEditor(); closeQuickAdd(true);
  const parent=o&&o.parent||null;
  if(parent&&!canHoldKids(parent)){ flash(saveInd,parent.type==='milestone'?'a milestone cannot hold subtasks':'subtasks go '+TREE_DEPTH_MAX+' levels deep at most',3200); return; }
  const phase=parent?parent.phase:(o&&Number.isInteger(o.phase)?o.phase:0);
  if(!phases[phase])return;
  if(parent&&collapsedRows.has(rowTreeKey(parent))){ setRowFolded(parent,false); refreshTreeDom(); applyCollapse(); }
  if(isCollapsed(phase))setCollapsed(phase,false);
  const inp=document.createElement('input');
  inp.type='text'; inp.className='qa-in'; inp.autocomplete='off'; inp.spellcheck=false;
  inp.placeholder=(parent?'Subtask of '+(parent.name||'this task'):'Task in '+(phases[phase].name||'this section'))+'. Enter adds it, Esc stops';
  inp.setAttribute('aria-label',parent?'Name of the new subtask':'Name of the new task');
  qa={phase,parent,lens:prefs.view==='table'?'table':'gantt',depth:parent?treeDepth(parent)+1:0,input:inp,queue:Promise.resolve(),pending:0,made:0,from:o&&o.from||parent||null};
  wireQuickAddInput(inp);
  if(qa.lens==='gantt'){
    const l=document.createElement('div'); l.className='qa-row'; l.style.setProperty('--depth',String(qa.depth));
    const plus=document.createElement('span'); plus.className='qa-plus'; plus.innerHTML=PLUS_SVG; l.appendChild(plus); l.appendChild(inp);
    const b=document.createElement('div'); b.className='qa-bar';
    qa.l=l; qa.b=b;
    placeQuickAdd();
    sizeTailroom(); drawDeps();
  } else renderTable();
  try{ inp.focus({preventScroll:true}); }catch(e){ inp.focus(); }
  const el=qa.lens==='gantt'?qa.l:inp.closest('tr');
  if(el&&el.scrollIntoView)el.scrollIntoView({block:'nearest'});
  if(qa.lens==='gantt')syncLabelScroll();
}
// Where the blank row sits: under the last row of the section, or of the task's subtree.
function quickAddAnchor(){
  if(!qa)return null;
  if(qa.parent){ const d=treeDesc(qa.parent); return d.length?d[d.length-1]:qa.parent; }
  const rows=items.filter(r=>!isDerivedGroup(r)&&r.phase===qa.phase);
  return rows.length?rows[rows.length-1]:items.find(r=>isDerivedGroup(r)&&r.phase===qa.phase)||null;
}
// The chart's blank row follows its anchor through every reflow, keeping the person's typing
// and the caret: it is not one of the plan's rows, so a reflow does not place it.
function isQaEl(el){ return el.classList.contains('qa-row')||el.classList.contains('qa-bar'); }
function placeQuickAdd(){
  if(!qa||qa.lens!=='gantt'||!qa.l)return;
  if(qa.parent&&!isLive(qa.parent)){ closeQuickAdd(); return; }
  const a=quickAddAnchor(); if(!a||!a._lDiv||!a._bDiv){ closeQuickAdd(); return; }
  if(a._lDiv.nextSibling===qa.l&&a._bDiv.nextSibling===qa.b)return;
  const inp=qa.input, had=document.activeElement===inp, s0=inp.selectionStart, s1=inp.selectionEnd;
  qa.moving=true;
  labelsEl.insertBefore(qa.l,a._lDiv.nextSibling); bars.insertBefore(qa.b,a._bDiv.nextSibling);
  qa.moving=false;
  if(had){ try{ inp.focus({preventScroll:true}); inp.setSelectionRange(s0,s1); }catch(e){} }
}
function closeQuickAdd(quiet){
  if(!qa)return;
  const q=qa; qa=null;
  if(q.l){ q.l.remove(); q.b.remove(); if(!quiet){ sizeTailroom(); drawDeps(); } }
  if(q.lens==='table'&&!quiet)renderTable();
}
function wireQuickAddInput(inp){
  inp.addEventListener('keydown',e=>{
    e.stopPropagation();
    if(e.key==='Enter'){
      e.preventDefault();
      const name=inp.value.trim();
      if(!name){ if(!qa||!qa.pending)closeQuickAdd(); return; }
      inp.value='';
      quickAddCreate(name);
    } else if(e.key==='Escape'){ e.preventDefault(); closeQuickAdd(); }
  });
  inp.addEventListener('mousedown',e=>e.stopPropagation());
  inp.addEventListener('click',e=>e.stopPropagation());
  // Clicking away from an empty row puts it away; a name being typed stays until Esc.
  inp.addEventListener('blur',()=>{ setTimeout(()=>{ if(qa&&qa.input===inp&&!qa.moving&&!qa.rendering&&document.activeElement!==inp&&!inp.value.trim()&&!qa.pending)closeQuickAdd(); },200); });
}
// The values a quick-added row starts with: New's remembered owner, status and tags and New's
// default dates, then the view's own filters.
function quickAddDefaults(){
  const memo=lastCreated||{}, today=isoDay(new Date());
  const start=memo.start||today, end=memo.end||addDays(today,7);
  const o={start:start<=end?start:end,end,owner:memo.owner||'',status:memo.status&&STATUS_ORDER.includes(memo.status)?memo.status:'not-started',tags:(memo.tags||[]).slice()};
  if(prefs.ownerFilter)o.owner=prefs.ownerFilter===OWNER_NONE?'':prefs.ownerFilter;
  if(statusSel&&statusSel.value)o.status=statusSel.value;
  if(prefs.tagFilter&&prefs.tagFilter.length){ const want=tagFilterKeys(), sec=new Set(((phases[qa.phase]||{}).tags||[]).map(tagKey));
    if(![...want].some(k=>sec.has(k))&&!o.tags.some(t=>want.has(tagKey(t)))){ const t=usedTags().find(e=>want.has(e.key)); if(t)o.tags.push(t.tag); } }
  const provDefault=USER_NAME?('Added by '+USER_NAME):'Added by hand';
  o.prov=(memo.prov!==undefined&&memo.prov!==null)?memo.prov:provDefault;
  return o;
}
// One task from the blank row. Made in the order the names were entered, each after the one
// before it, however quickly they come (a uid is a round trip to the server).
function quickAddCreate(name){
  const q=qa; if(!q)return;
  q.pending++;
  q.queue=q.queue.then(async()=>{
    let uid=null;
    try{ uid=await allocUid(); }
    catch(err){ flash(saveInd,'no item ID could be allocated, so "'+name+'" was not added ('+err.message+')',5000); return; }
    if(q.parent&&!isLive(q.parent)){ flash(saveInd,'its task was deleted, so "'+name+'" was not added',5000); return; }
    const d=quickAddDefaults();
    const r={type:'task',uid,name,phase:q.parent?q.parent.phase:q.phase,id:freshId(name),start:d.start,end:d.end,status:d.status};
    if(d.owner)r.owner=d.owner;
    if(d.tags.length)r.tags=normalizeTags(d.tags);
    if(d.prov)r.provenance=d.prov;
    if(q.parent)r.parent=rowTreeKey(q.parent);
    const sib=siblingsIn(q.parent,r.phase,null);
    r.pos=keyBetween(sib.length?sib[sib.length-1].pos:null,null);
    addedItems.push(r);
    insertRow(r);
    pushUndo({type:'create',r});
    q.made++;
    renderTable();
    rebuildTimeline();
    if(r.tags&&r.tags.length)afterTagsChange();
    scheduleSave('quick-add');
  }).catch(e=>{ try{ console.warn('adom-gantt: quick add',e); }catch(_){} }).finally(()=>{ q.pending--; });
  return q.queue;
}
function openWhoMenu(){
  const chip=$('whoChip'); if(!chip||!ADOM_USER)return;
  closeMenus();
  const r=chip.getBoundingClientRect();
  exportMenu.innerHTML=`<div class="who-card">${avatarHtml(ADOM_USER,'who-av')}
      <div class="who-card-txt">
        <div class="who-real">${esc(ADOM_USER.displayName)}</div>
        <div class="who-sub">${ADOM_USER.username?'@'+esc(ADOM_USER.username):''}${ADOM_USER.username&&ADOM_USER.email?' &middot; ':''}${esc(ADOM_USER.email||'')}</div>
      </div></div>
    <div class="dd-item" id="whoOut"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>Sign out</div>`;
  exportMenu.style.left=Math.min(r.right-230,window.innerWidth-250)+'px';
  exportMenu.style.top=(r.bottom+6)+'px';
  exportMenu.classList.add('show');
  const out=$('whoOut');
  if(out)out.addEventListener('click',async()=>{
    try{ await fetch('auth/signout',{method:'POST'}); }catch(e){}
    location.reload();
  });
}
function wireWhoChip(){
const chip=$('whoChip');
if(chip)chip.addEventListener('click',e=>{e.stopPropagation();openWhoMenu();});
}
function wireNewButton(){
$('btnNew').addEventListener('click',e=>{e.stopPropagation();closeMenus();openCreateEditor();});
}

// ── Text scale ──
//
// Applies to both lenses, because it is a statement about the reader and not
// about the chart. The multiplier is a CSS variable, so nothing has to be
// rebuilt: the browser reflows and the JS re-measures. Everything downstream
// that was measured in pixels (milestone cap collision rows, the tailroom, the
// dependency arrows) has to be re-run after a change, which is what relayout does.
function applyFontScale(){
  document.documentElement.style.setProperty('--fs', String(prefs.fs));
  // An open panel is in the app's text size too (0.79.0): its fields rewrap at the new size.
  if(editorEl.classList.contains('show'))edFit();
}
function syncFsCtl(){
  const i = FS_STEPS.indexOf(prefs.fs);
  const pct = $('fsPct');
  if(!pct) return;
  pct.textContent = Math.round(prefs.fs*100)+'%';
  pct.classList.toggle('off-default', prefs.fs !== 1);
  $('fsDown').disabled = i <= 0;
  $('fsUp').disabled = i >= FS_STEPS.length-1;
}
function setFontScale(v){
  if(FS_STEPS.indexOf(v)<0 || v===prefs.fs) return;
  prefs.fs = v; savePrefs(); applyFontScale(); syncFsCtl();
  renderTable();
  // Everything measured in pixels has to be measured again: the milestone cap
  // collision rows (at 150% the tags are wider and taller and need more rows
  // than they did at 100%), the tailroom, the dependency arrows.
  //
  // Go through the app's own debounced relayout rather than calling relayout()
  // directly, but first invalidate its echo guard. That guard skips a relayout
  // when the pane reports the same width and height as last time, which is
  // exactly what happens here: the text grew, the pane did not. Without this the
  // caps kept their old row assignments and overlapped each other at 150%.
  lastPaneW = lastPaneH = lastRelayoutW = -1;
  scheduleRelayout();
}
function stepFontScale(d){
  const i = FS_STEPS.indexOf(prefs.fs);
  setFontScale(FS_STEPS[Math.max(0, Math.min(FS_STEPS.length-1, i+d))]);
}
// Another tab (or another snapshot) changed the size: take it on here too, without
// writing it back. This is the fix for cause 2 in READER_PREFS: a tab that follows
// the stored size has no stale one to overwrite it with when it next saves. The
// 'storage' event fires in every OTHER same-origin document, never the writer, so
// this cannot loop.
function adoptStoredFontScale(v){
  if(FS_STEPS.indexOf(v)<0 || v===prefs.fs) return;
  prefs.fs = v; prefBase.fs = JSON.stringify(v); applyFontScale(); syncFsCtl();
  renderTable();
  lastPaneW = lastPaneH = lastRelayoutW = -1;
  scheduleRelayout();
}
function wireFontScale(){
$('fsDown').addEventListener('click',()=>stepFontScale(-1));
$('fsUp').addEventListener('click',()=>stepFontScale(1));
$('fsPct').addEventListener('click',()=>setFontScale(1));
window.addEventListener('storage',e=>{
  if(e.key!==PREF_KEY || !e.newValue) return;
  let v; try { v = JSON.parse(e.newValue).fs; } catch { return; }
  adoptStoredFontScale(v);
});
syncFsCtl();
}

// ── Fit the plan to the pane ──
//
// Zoom is not continuous here: each grain carries a minimum cell width sized to
// the widest label it draws, and below that the timeline scrolls rather than
// squashing into unreadability. So fitting to the pane means choosing a grain,
// not a scale. Finest first, take the first whose whole span clears its own
// minimum in the width we actually have: that is the most detail the pane can
// hold without scrolling sideways. fitCOL() then stretches the cells to fill it
// exactly. If even Years cannot fit, Years is still the closest we can get.
const GRAIN_ORDER = ['day','week','month','quarter','year'];
function paneWidth(){ return (timelineEl.clientWidth || (window.innerWidth - 272)) - 1; }
function bestFitGrain(){
  const avail = paneWidth();
  for(const g of GRAIN_ORDER){
    if(rangeFor(g).units * GRAINS[g].min <= avail) return g;
  }
  return 'year';
}
function fitToWidth(){
  const g = bestFitGrain();
  const changed = g !== G();
  prefs.granularity = g;
  savePrefs();
  $('grainSel').value = g;
  // Always rebuild, even when the grain is unchanged: the pane may have been
  // resized since, and this is the gesture that says "use the width I have now".
  rebuildTimeline();
  flash(saveInd, changed ? `fit to ${GRAINS[g].label.toLowerCase()}` : 'fit to width');
}
function wireFitButton(){
$('btnFit').addEventListener('click',fitToWidth);
}

// ── Resizable name column ──
// Long task names were simply cut off with no way to see them. The width persists,
// and a double-click sizes it to the longest name currently rendered.
const LABEL_MIN=120, LABEL_MAX=760;
function applyLabelWidth(w){
  if(w) document.documentElement.style.setProperty('--label-w', w+'px');
  else  document.documentElement.style.removeProperty('--label-w');
  scheduleRelayout();
}
function wireLabelColumnResize(){
if(prefs.labelW) applyLabelWidth(prefs.labelW);

(()=>{
  const rz=$('colResizer');
  let startX=0, startW=0, dragging=false;
  rz.addEventListener('mousedown',e=>{
    e.preventDefault();
    dragging=true; startX=e.clientX; startW=labelsEl.getBoundingClientRect().width;
    rz.classList.add('dragging'); document.body.classList.add('col-resizing');
  });
  document.addEventListener('mousemove',e=>{
    if(!dragging)return;
    const w=Math.round(Math.max(LABEL_MIN,Math.min(LABEL_MAX,startW+(e.clientX-startX))));
    prefs.labelW=w; applyLabelWidth(w);
  });
  document.addEventListener('mouseup',()=>{
    if(!dragging)return;
    dragging=false; rz.classList.remove('dragging'); document.body.classList.remove('col-resizing');
    // The drag sets prefs.labelW as it goes and saves once here. If startup re-took
    // the baseline mid-drag, the new width would look already saved; forget it first.
    delete prefBase.labelW;
    savePrefs();
  });
  // Double-click: measure the widest rendered name and fit to it.
  rz.addEventListener('dblclick',()=>{
    const probe=document.createElement('span');
    probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;font-family:Satoshi,sans-serif;font-size:12px;';
    document.body.appendChild(probe);
    let max=0;
    labelsEl.querySelectorAll('.label-row').forEach(el=>{
      probe.textContent=el.textContent;
      max=Math.max(max,probe.offsetWidth);
    });
    probe.remove();
    const w=Math.round(Math.max(LABEL_MIN,Math.min(LABEL_MAX,max+40)));
    prefs.labelW=w; applyLabelWidth(w); savePrefs();
  });
})();

// Close menus on outside click
document.addEventListener('click',e=>{if(!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeMenus();});
}

// ── Draggable editor, and the position the user chose ──
//
// Auto-placement anchors a panel next to the thing it edits, which is right the first
// time and wrong the moment the user has an opinion. So the moment they DRAG the
// panel, that is the answer: the position is remembered and every panel opened after
// it lands in the same spot, whichever row it belongs to, however far the chart has
// been scrolled. The position is held in VIEWPORT coordinates, which is what makes it
// independent of scroll: it is where on the screen the user put the thing, not where
// in the plan.
//
// In memory only, and deliberately. It is a property of this sitting at this window
// size, not of the plan, so it goes nowhere near the sidecar or the changelog and a
// reload puts auto-placement back. Nothing about it is a preference to save.
let edDrag=null, edManualPos=null;
// The panel's own size, for clamping, read from the live box (showEditor shows the panel
// before anything places it, so the box is real by then).
function edSize(){
  const r=editorEl.getBoundingClientRect();
  return {w:r.width||400, h:r.height||400};
}
// The one place a panel is positioned. Callers pass where they WOULD like it; if the
// user has dragged a panel this session that wish is overridden, and either way the
// result is clamped so no panel can open half off the screen. Every open path on this
// element goes through here, so "it opens where I put it" is true of all of them and
// cannot drift as panels are added.
// One margin for both the drag and the placement. They used to disagree (the drag
// stopped at 0, placement at 8), so a panel dragged flush to an edge reopened eight
// pixels away from where it was left, which is exactly the kind of small lie this
// feature cannot afford: the promise is that it opens where you put it.
const ED_MARGIN=8;
// The band along the bottom edge that no panel enters (0.79.0): the selection chip, the
// undo toast, the save indicator and the save-blocked banner live there and sit above the
// panel, and the chip once covered Apply on a tall editor. Its height is the stylesheet's
// --ed-bottom, which grows while the banner is up; a data warning that stands taller than
// that is cleared too.
function edBottomBand(){
  let b=parseFloat(getComputedStyle(editorEl).getPropertyValue('--ed-bottom'))||60;
  const dwEl=$('dataWarn'), dw=dwEl&&dwEl.classList.contains('show')?dwEl.getBoundingClientRect():null;
  if(dw&&dw.height)b=Math.max(b,Math.ceil(window.innerHeight-dw.top)+8);
  return b;
}
function edClampX(x,w){ return Math.round(Math.max(ED_MARGIN,Math.min(x,window.innerWidth-w-ED_MARGIN))); }
function edClampY(y,h){ return Math.round(Math.max(ED_MARGIN,Math.min(y,window.innerHeight-edBottomBand()-h))); }
// The panel's height is capped by the stylesheet (max-height) at the window less the top
// margin and the bottom band, so a taller panel scrolls its body; this keeps that cap equal
// to the band edBottomBand measures, data warning included.
function edSetMaxH(){
  const mh=Math.max(120,window.innerHeight-ED_MARGIN-edBottomBand());
  editorEl.style.maxHeight=mh+'px';
}
// The cap and the place again, for a change to the band rather than to the panel.
function edReclamp(){ if(!editorEl.classList.contains('show'))return; edSetMaxH(); edKeepInView(); }
// Back inside the window: after the panel grew (a field wrapped, a list filled), after the
// window or the text size changed. Moves it, never resizes it.
function edKeepInView(){
  if(!editorEl.classList.contains('show'))return;
  const r=editorEl.getBoundingClientRect();
  const x=edClampX(r.left,r.width), y=edClampY(r.top,r.height);
  if(Math.abs(x-r.left)>=1)editorEl.style.left=x+'px';
  if(Math.abs(y-r.top)>=1)editorEl.style.top=y+'px';
}
function placeEditor(x,y){
  if(edManualPos){x=edManualPos.left;y=edManualPos.top;}
  const {w,h}=edSize();
  editorEl.style.left=edClampX(x,w)+'px';
  editorEl.style.top =edClampY(y,h)+'px';
}
// Show the panel in the shape this caller needs, then position it. Shown BEFORE
// placing on purpose: a display:none box measures zero, and the clamp is only honest
// if it measures the real one. Both happen inside one task, so nothing paints between
// them and there is no flash at the old position.
// ── The open panel is a live view of the row it is editing ──
//
// Which row the panel on screen belongs to. Nothing knew this before: the panel was
// built from a row and then forgot it, so dragging the very bar whose panel was open
// left the fields showing the dates the row USED to have, and Apply would have written
// them straight back over the drag.
let edSubject=null;
// The same numbers the bar is painting, in the fields, at every snap step rather than
// only at the release. Only the row the panel is actually editing, and only its dates:
// a note or an owner typed in there is the user's and is never touched. Each field is
// written only when it would change, so a caret sitting in one of them is left alone.
// The compact panel's header. The name is ASSIGNED rather than interpolated into the
// panel's markup, the way the full item editor assigns its fields, so a row named with
// a tag is a row named with a tag and never becomes one. The title attribute carries the
// same string, because the header is one ellipsised line and the hover is where the rest
// of a long name lives. Panels without a header (the milestone panel, which has a real
// name FIELD, and every non-square panel) have no #deTitle and this is a no-op for them.
function setEdTitle(r){
  const el=$('deTitle'); if(!el||!r)return;
  const n=r.name||r.id||'';
  if(el.textContent!==n)el.textContent=n;
  if(el.title!==n)el.title=n;
}
function syncEditorDates(r){
  if(!r||!edSubject||edSubject!==r)return;
  if(!editorEl.classList.contains('show'))return;
  // The header names the row, so a rename that lands while the panel is open has to
  // reach it too. Same chokepoint as the dates, for the same reason: every path that
  // changes a row already comes through here, whether it is a drag, an undo, the other
  // lens's table cell or an inline label edit.
  setEdTitle(r);
  // A date the person has typed into is theirs until Apply (0.75.1): a live merge of someone
  // else's save used to copy the stored dates back over it, so Apply saved the old date with
  // the new status. Their own drag still moves it, since that is newer than what they typed.
  const set=(id,v)=>{const el=$(id);if(!el||!v||el.value===v)return;if(liveMerging&&el.dataset.touched)return;el.value=v;};
  if(r.type==='milestone'){ set('meDate',r.date); set('imEnd',r.date); return; }
  set('deStart',r.start); set('deEnd',r.end);
  set('imStart',r.start); set('imEnd',r.end);
}
// Every panel has one shape since 0.79.0 (the square task panel and the compact metrics
// are gone), so a caller says only where it would like the panel: a point, or a function
// of the panel's measured width and height that returns one, for a caller that places the
// panel by its size (beside a bar, flipped when the right has no room; centred).
function showEditor(x,y){
  // Every panel in the app opens through here, so this is the one place an armed
  // Delete has to be withdrawn when the user's attention moves to a panel instead.
  disarmDelete();
  editorEl.classList.add('show');
  edSetMaxH();
  // The fields at their real height before anything is measured: a long name opens
  // wrapped, not cut to one line and grown a frame later.
  editorEl.querySelectorAll('textarea.ed-grow').forEach(edGrow);
  if(typeof x==='function'){ const {w,h}=edSize(); const at=x(w,h); x=at.x; y=at.y; }
  placeEditor(x,y);
}
// ── One Tab per field in the panels, the way the table already walks a row ──
//
// A date input is three controls to the browser, so a plain Tab walks day, month and
// year before it reaches the next field: FOUR presses to cross one date, measured, and
// eight to cross a start and a deadline. The table answered this in 0.22.2 by claiming
// Tab in the capture phase, before the input's own default action can run, and the
// panels get the same answer so the two lenses tab alike.
//
// Only Tab, and only OUT OF a date field. Inside one the arrows still move between
// segments and still change the value, and typing still fills them, because none of
// those keys are touched. Every other field keeps the browser's own walk, which was
// already one stop per field, so this adds nothing to the fields that did not need it.
// Every panel in this app renders into the same element, so one listener covers the
// task editor, the milestone editor, the full item editor, the create panel and the
// batch panel, and any panel added later is covered before it is written.
function edTabStops(){
  return [...editorEl.querySelectorAll('input, select, textarea, button')]
    .filter(el=>!el.disabled && el.tabIndex>=0 && el.offsetParent!==null);
}
function wireEditorTab(){
  // A date field the person changed is theirs until Apply (see syncEditorDates). Every
  // panel is fresh markup, so the mark starts clear on each open.
  const touch=e=>{ const t=e.target; if(t&&t.matches&&t.matches('input[type="date"]'))t.dataset.touched='1'; };
  editorEl.addEventListener('input',touch,true);
  editorEl.addEventListener('change',touch,true);
  editorEl.addEventListener('keydown',e=>{
    if(e.key!=='Tab'||e.altKey||e.ctrlKey||e.metaKey)return;
    const ae=document.activeElement;
    if(!ae||ae.tagName!=='INPUT'||ae.type!=='date')return;
    if(!editorEl.contains(ae))return;
    const stops=edTabStops();
    const i=stops.indexOf(ae);
    if(i<0)return;
    const next=stops[i+(e.shiftKey?-1:1)];
    // At either end of the panel's own cycle the browser's walk is the right one:
    // it is the way out of the panel and this has no opinion about that.
    if(!next)return;
    e.preventDefault();e.stopPropagation();
    next.focus();
    // A text field is landed on the way Tab lands on one, with its contents selected.
    // A date field is not: select() on one throws in Blink, and there is nothing to
    // select anyway since it lands on its first segment.
    if(((next.tagName==='INPUT'&&next.type!=='date')||next.tagName==='TEXTAREA')&&next.select)next.select();
  },true);
  // A growing field (a textarea since 0.79.0) is landed on by Tab the way the one-line
  // field it replaced was: its whole text selected, so typing replaces it. The browser
  // does that for an input and not for a textarea, so it is done here, for a Tab only;
  // a click still puts the caret where it lands.
  let tabAt=0;
  editorEl.addEventListener('keydown',e=>{ if(e.key==='Tab')tabAt=performance.now(); },true);
  editorEl.addEventListener('focusin',e=>{
    const t=e.target;
    if(t&&t.matches&&t.matches('textarea.ed-grow')&&performance.now()-tabAt<500){ try{ t.select(); }catch(_){} }
  });
}
function wireEditorDrag(){
editorEl.addEventListener('mousedown',e=>{
  // Only the grab strip and the header under it start a drag, so preventDefault (which
  // is what stops the pointer sweeping a text selection across the panel) never touches
  // the inputs: click, drag-select and caret placement inside a field are all untouched.
  // The header is in here rather than carrying its own handler so there is still exactly
  // one drag in this panel, and grabbing the name behaves the same as grabbing the grip
  // down to the clamp and the remembered position.
  if(!e.target.closest('.drag-bar,.ed-head'))return;
  e.preventDefault();
  const rect=editorEl.getBoundingClientRect();
  edDrag={sx:e.clientX-rect.left,sy:e.clientY-rect.top,moved:false};
});
document.addEventListener('mousemove',e=>{
  if(!edDrag)return;
  if(!edDrag.moved){
    edDrag.moved=true;
    document.body.classList.add('ed-dragging');
  }
  const {w,h}=edSize();
  // Clamped while it moves, not just where it lands, so the panel cannot be shoved
  // past an edge and left with its Apply button off the screen. Same clamp the
  // placement uses, so where it stops is exactly where it will reopen.
  editorEl.style.left=edClampX(e.clientX-edDrag.sx,w)+'px';
  editorEl.style.top =edClampY(e.clientY-edDrag.sy,h)+'px';
});
document.addEventListener('mouseup',()=>{
  if(edDrag&&edDrag.moved){
    // A real move, so the user has chosen a place. A bare click on the grab strip is
    // not a choice and deliberately does not pin anything.
    const r=editorEl.getBoundingClientRect();
    edManualPos={left:r.left,top:r.top};
    // The release is not a click away from the panel. A panel pushed against an edge (or
    // into the bottom band, 0.79.0) stops while the pointer goes on, so the button comes up
    // outside it, and the click the browser makes of the press and the release landed on
    // the page and closed the panel that had just been put there. Every click handler
    // already ignores the click that ends a drag; this marks this drag as one.
    dragEndedAt=Date.now();
  }
  document.body.classList.remove('ed-dragging');
  edDrag=null;
});
// A window resize can leave a remembered position off the edge of the smaller
// viewport, so it is re-clamped rather than forgotten: the user's choice survives,
// it just moves back into view.
window.addEventListener('resize',()=>{
  if(!edManualPos)return;
  const {w,h}=edSize();
  edManualPos={left:edClampX(edManualPos.left,w), top:edClampY(edManualPos.top,h)};
  if(editorEl.classList.contains('show'))placeEditor(edManualPos.left,edManualPos.top);
});
}

// ── Table view ──
//
// The second lens. Every task and milestone is one row, with the owner, the
// dates, the status and the notes editable in place. An edit here is the same
// kind of event as a drag on the chart: it mutates the item, it goes through
// scheduleSave, and it lands in the sidecar and the changelog. Nothing about
// the table is a separate store.

const tableBody=$('tableBody'), tableHead=$('tableHead'), ownerListEl=$('ownerList');
const statusSel=$('tblStatus'), searchInp=$('tblSearch'), countEl=$('tblCount');
const STATUS_OPTS=STATUS_ORDER;
const UNASSIGNED='__unassigned__';   // sentinel, so a real owner can never collide with it
// null means plan order: the order the rows are actually in, which is the only
// order it makes sense to drag rows around in. A column sort is a lens over that.
let sortKey='deadline', sortDir=1, tblSearchTimer=null;
function restoreTableSort(){
if(prefs.tableSort){
  sortKey=prefs.tableSort.key||null;
  sortDir=prefs.tableSort.dir===-1?-1:1;
}
}
function manualOrder(){ return !sortKey; }

function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function isGroup(r){return r.type==='group';}
function isMs(r){return r.type==='milestone';}
// A task's deadline is its end; a milestone's is the date it stands on. One
// column, so sorting by deadline sorts the whole plan at once.
function deadlineOf(r){return isMs(r)?(r.date||''):(r.end||'');}
function phaseNameOf(r){const p=phases[r.phase];return p?p.name:'';}
function ownerOf(r){return (r.owner||'').trim();}
// Only http and https ever become an href. Everything else, javascript: above
// all, falls through to inert text: the label still shows, it just is not a
// link. new URL() also kills protocol-relative "//evil.example" and any scheme
// smuggled past a naive regex.
function safeUrl(u){
  if(typeof u!=='string')return '';
  const t=u.trim();
  if(!/^https?:\/\//i.test(t))return '';
  try{ const q=new URL(t); return (q.protocol==='http:'||q.protocol==='https:') ? q.href : ''; }
  catch(e){ return ''; }
}
// provenance is {label, url} or a bare string label. Normalised once here so
// nothing downstream has to care which shape the data file used.
function provOf(r){
  const p=r&&r.provenance;
  if(!p)return null;
  if(typeof p==='string'){ const l=p.trim(); return l?{label:l,url:''}:null; }
  if(typeof p==='object'){
    const l=String(p.label==null?'':p.label).trim(), u=safeUrl(p.url);
    if(!l&&!u)return null;
    return {label:l||u, url:u};
  }
  return null;
}
function provLabel(r){ const p=provOf(r); return p?p.label:''; }
// A provenance with a link is a citation: the text names the source the link opens, so it
// is kept exactly as it came (0.71.0). Only a plain-text provenance is edited in the app.
function provLinked(r){ const p=provOf(r); return !!(p&&p.url); }
// What goes in the sidecar and the changelog: the bare string when there is no
// url, so a hand-written provenance stays hand-written.
function provValue(r){ const p=provOf(r); return p ? (p.url?{label:p.label,url:p.url}:p.label) : ''; }
function provSig(r){ const p=provOf(r); return p ? p.label+'\u0001'+p.url : ''; }
// A table row's key. The milestone half used to be `m:<index into the filtered
// milestone list>`, computed when the table rendered and resolved whenever it was
// read: the same stale-position bug the caps had, one row over. `focusedKey`
// outlives a render, so a reorder between the two could hand a keystroke to the
// wrong milestone. Both halves are durable now, and neither is a position.
function rowKey(r){return isMs(r)?('m:'+(r.uid||'')):('t:'+r.id);}
function rowItem(key){
  if(!key)return null;
  if(key.charAt(0)==='t')return items.find(r=>r.id===key.slice(2));
  const r=rowByUid(key.slice(2));
  return (r&&isMs(r))?r:null;
}
// Re-render the chart's own label row, so a status changed in the table is
// already true on the chart when you toggle back.
function refreshLabelRow(r){
  if(!r||!r._lDiv)return;
  // The cancel mark first, and outside the guard: a milestone reaches this function
  // from the bulk restate at load, and its row goes quiet for the same reason a task's
  // does. Its name is refreshMsChrome's to draw (it has an id since 0.78.0, so the guard
  // asks what it is rather than whether it has one).
  syncCancelMark(r);
  if(!r.id||r.type==='milestone')return;
  // into the name span only: rewriting the row would take the edit button with it
  setHtmlIfChanged(r._nameSpan||r._lDiv,nameInner(r));
}
// A write the page already shows is skipped (0.75.1). A live merge restates every row, and
// rewriting all ~500 names dirtied all ~500 rows, so the next layout redid the whole chart
// for one changed cell. Unchanged markup now leaves its row alone, and layout stays local.
function setHtmlIfChanged(el,html){ if(!el||el.__html===html)return; el.innerHTML=html; el.__html=html; }
function setStyleIfChanged(el,prop,v){ if(el.style[prop]!==v)el.style[prop]=v; }
function setDataIfChanged(el,key,v){ if(el.dataset[key]!==v)el.dataset[key]=v; }
// Cancelled work stays on the plan and stays readable; it just stops asking for
// attention. The mark is a class carried on the row's own elements rather than a
// question every reader re-asks, so the chart, the label column and the table all go
// quiet in the same instant and there is one place to look when they do not.
function syncCancelMark(r){
  if(!r)return;
  const on=isCancelled(r);
  [r._lDiv,r._bDiv,r._msEl,r._msLine].forEach(el=>{ if(el)el.classList.toggle('cancelled',on); });
  const be=r.id&&barEls[r.id];
  if(be&&be.bar)be.bar.classList.toggle('cancelled',on);
  // The arrows too, since 0.53.0. Nothing about a status change moves a pixel, so
  // there is no geometry event to trigger a redraw and the layer would otherwise keep
  // the old brightness until the next resize. Coalesced (0.75.1): a bulk refresh of every
  // row used to rescan every arrow once per row.
  paintCancelledDepsSoon();
}
let cancelPaintQueued=false;
function paintCancelledDepsSoon(){
  if(cancelPaintQueued)return;
  cancelPaintQueued=true;
  queueMicrotask(()=>{ cancelPaintQueued=false; paintCancelledDeps(); });
}

function distinctOwners(){
  const s=new Set();
  items.forEach(r=>{if(isGroup(r))return;const o=ownerOf(r);if(o)s.add(o);});
  return [...s].sort((a,b)=>a.localeCompare(b));
}
// The status filter's five options, from the one list that owns their order. Called once
// at startup: the vocabulary is fixed, so unlike the owner list there is nothing here to
// resync when the plan changes. The "All" blank stays in the markup, because it is the
// filter's own idea and not one of the five states.
function buildStatusFilter(){
  if(!statusSel)return;
  statusSel.insertAdjacentHTML('beforeend',
    STATUS_ORDER.map(k=>`<option value="${k}">${esc(statusTextFor(k))}</option>`).join(''));
}
function syncOwnerControls(){
  // Typing an owner offers the names already on the plan without forcing them. The filter
  // itself is the legend's (syncOwnerPick), shared by both lenses since 0.77.1.
  ownerListEl.innerHTML=distinctOwners().map(o=>`<option value="${esc(o)}"></option>`).join('');
}

// The value a column sorts on. It lives out here rather than inside tableRows
// because a keyboard move has to ask "which rows does the sort consider tied with
// this one", and the only safe answer is the one the sort itself used.
function sortValueOf(r,key){
  if(key==='uid')return r.uid||'';
  if(key==='name')return (r.name||'').toLowerCase();
  if(key==='phase')return (phaseNameOf(r)||'').toLowerCase();
  if(key==='owner')return ownerOf(r).toLowerCase();
  if(key==='start')return isMs(r)?'':(r.start||'');
  if(key==='status'){const k=statusOf(r);const i=STATUS_ORDER.indexOf(k);return i<0?(k?'z'+k:''):String(i);}
  if(key==='deps'){const d=depsOf(r);const n=d.needs.length+d.blocks.length;return n?String(1000+n):'';}
  if(key==='notes')return (r.description||'').toLowerCase();
  // Own tags first, then the section's, the order the cell shows them in; untagged last.
  if(key==='tags')return effTags(r).join(' ').toLowerCase();
  if(key==='prov')return provLabel(r).toLowerCase();
  // Numeric, as a fixed-width string so the string compare below orders it; blank last.
  if(key==='effort'){ const e=effortOf(r); return e===null?'':String(Math.round(e*10)).padStart(8,'0'); }
  return deadlineOf(r);
}
// What the search box looks through, one place for the filter and for the count's reasons.
function tableSearchHay(r){
  return `${r.name||''} ${ownerOf(r)} ${phaseNameOf(r)} ${effTags(r).join(' ')} ${r.description||''} ${r.status||''} ${deadlineOf(r)} ${fmt(deadlineOf(r))} ${provLabel(r)}`.toLowerCase();
}
// "333 of 415 items" says how many; its tooltip says why (0.71.0). Each hidden row is put
// down to the FIRST rule that hides it, in the order tableRows applies them.
function tableHiddenWhy(shown,total){
  const q=(searchInp.value||'').trim().toLowerCase(), st=statusSel.value, ks=tagFilterKeys();
  const n={collapsed:0,section:0,legend:0,ms:0,status:0,search:0};
  items.forEach(r=>{
    if(isGroup(r))return;
    if(r.phase!==undefined&&isCollapsed(r.phase)){ n.collapsed++; return; }
    if(r.phase!==undefined&&!phaseVis[r.phase]){ n.section++; return; }
    if(!rowShown(r,ks)){ n.legend++; return; }
    if(isMs(r)&&!msVis){ n.ms++; return; }
    if(st&&statusOf(r)!==st){ n.status++; return; }
    if(q&&tableSearchHay(r).indexOf(q)<0){ n.search++; return; }
  });
  const on=[];
  if(ownerFilterOn())on.push('owner '+(prefs.ownerFilter===OWNER_NONE?'Unassigned':prefs.ownerFilter));
  if(tagFilterOn())on.push('tags '+prefs.tagFilter.join(', '));
  if(prefs.currentOnly)on.push('Current');
  if(prefs.overdueOnly)on.push('Overdue');
  const why=[];
  if(n.collapsed)why.push(n.collapsed+' in collapsed sections');
  if(n.section)why.push(n.section+' in sections switched off in the legend');
  if(n.legend)why.push(n.legend+' by the legend filters'+(on.length?' ('+on.join('; ')+')':''));
  if(n.ms)why.push(n.ms+' milestones, switched off in the legend');
  if(n.status)why.push(n.status+' by the status filter');
  if(n.search)why.push(n.search+' by the search');
  return 'Showing '+shown+' of the plan\'s '+total+' tasks and milestones. Hidden: '+why.join('; ')+'.';
}
// Whether one row is in the table as filtered now (tableRows' rule, for one row).
function tableRowIn(r){
  const q=(searchInp.value||'').trim().toLowerCase(), st=statusSel.value;
  if(isGroup(r))return false;
  // A folded task's subtasks are out, and a dimmed task is in only for a subtask (0.79.0).
  if(manualOrder()){ let p=treeParent(r); while(p){ if(collapsedRows.has(p))return false; p=tree.parentOf.get(p); } }
  if(tableCtx.has(r))return true;
  if(r.phase!==undefined&&(isCollapsed(r.phase)||!phaseVis[r.phase]))return false;
  if(!rowShown(r,tagFilterKeys()))return false;
  if(isMs(r)&&!msVis)return false;
  if(st&&statusOf(r)!==st)return false;
  if(q&&tableSearchHay(r).indexOf(q)<0)return false;
  return true;
}
// A few rows changed in fields only they show (0.77.0): their own table rows are rewritten
// and nothing else is built. Falls back to the whole table (false) when the change could
// move a row (a sort, a filter it now passes or fails) or show in another row's cell (a
// name or a dependency, which the Deps column of the rows around it names).
//
// Since 0.78.1 a sorted table, a new order and rows that start or stop passing a filter are
// patched too, the way a document editor applies somebody else's keystroke: the rows to show
// and their order are worked out again (filters and the sort are cheap), markup is made only
// for the rows that changed or newly show, and reconcileTable touches only those and the rows
// out of place. The table sorts by deadline unless somebody picked plan order, so the old
// patch, which needed plan order, almost never ran: every open table rebuilt itself on every
// change anyone made, ~300 ms a date edit.
function patchTableRows(rows,fields){
  if(!tableBody||!tableRowsCache||fields.has('deps')||fields.has('name')||fields.has('phase'))return false;
  if(tblEditing)return false;
  // A task's hours with its subtasks' (0.79.0) change with a subtask's, so its row is rewritten too.
  if(fields.has('effort')){ const more=new Set(rows); rows.forEach(r=>{ let p=treeParent(r); while(p){ const x=treeRows.get(p); if(x)more.add(x); p=tree.parentOf.get(p); } }); rows=[...more]; }
  const c=tableRowsCache, at=new Map(c.keys.map((k,i)=>[k,i]));
  const touched=new Set(rows.map(r=>rowKey(r))), ctx0=tableCtx;
  let now, keys, html;
  withDepIndex(()=>{
    now=tableRows();
    if(!now.length)return;
    // A task shown dimmed for its subtasks' sake, or no longer (0.79.0), is written again too.
    now.forEach(r=>{ if(tableCtx.has(r)!==ctx0.has(r))touched.add(rowKey(r)); });
    keys=now.map(r=>rowKey(r));
    html=now.map((r,i)=>{ const j=at.get(keys[i]); return j===undefined||touched.has(keys[i])||tableForceKeys.has(keys[i])?tableRowHtml(r):c.html[j]; });
  });
  if(!now.length)return false;   // the empty message is renderTable's
  const qaSel=tableQaFocus();
  tableQaLines(now,keys,html);
  reconcileTable(keys,html);
  tableForceKeys.clear();
  tableRowsCache={keys,html};
  syncTableCount(now.length);
  tableQaAttach(qaSel);
  markFocusedRow(); paintCritTable(); paintPeerFocus();
  return true;
}
// The table's body brought to `keys` and `html` touching only what changed (0.78.1): a row
// whose markup is the same keeps its element, a changed or new row gets a new one, a row no
// longer listed goes, and only rows out of place are moved. With nothing to keep (the first
// render, the empty message) or most rows new, the body is written in one go, which is faster.
// The quick-add lines (0.79.0) are keyed like the rows: 'qa:' and the section, or 'qa:entry'.
function tableKidKey(el){ return el&&el.dataset?(el.dataset.key||(el.dataset.qa!==undefined?'qa:'+el.dataset.qa:'')):''; }
function reconcileTable(keys,html){
  const c=tableRowsCache, kids=tableBody.children;
  let byKey=null;
  if(c&&kids.length===c.keys.length){
    byKey=new Map();
    for(let i=0;i<kids.length;i++){ const el=kids[i]; if(tableKidKey(el)!==c.keys[i]){ byKey=null; break; } byKey.set(c.keys[i],{el,html:c.html[i]}); }
  }
  if(!byKey){ tableBody.innerHTML=html.join(''); return; }
  const want=new Array(keys.length); let fresh=0;
  for(let i=0;i<keys.length;i++){
    const w=byKey.get(keys[i]);
    if(w&&w.html===html[i]&&!tableForceKeys.has(keys[i])){ want[i]=w.el; continue; }
    const t=document.createElement('template'); t.innerHTML=html[i]; want[i]=t.content.firstElementChild; fresh++;
  }
  if(fresh>keys.length/2){ tableBody.innerHTML=html.join(''); return; }
  const keep=new Set(want);
  for(const el of [...kids])if(!keep.has(el))el.remove();
  if(placeInOrder(tableBody,want,el=>el.tagName==='TR')<0)tableBody.innerHTML=html.join('');
}
function syncTableCount(n){
  const total=items.filter(r=>!isGroup(r)).length;
  countEl.textContent = n===total ? `${total} items` : `${n} of ${total} items`;
  countEl.title = n===total ? 'Every task and milestone in the plan is listed.' : tableHiddenWhy(n,total);
}
// Rows in the Table. In plan order, subtasks follow the tree (0.79.0): a folded task hides
// them, and a task that does not pass the filters but has a subtask that does stays on screen,
// dimmed, for context (tableCtx). Under a sort every row stands on its own, as ClickUp shows
// subtasks as separate tasks to sort them.
let tableCtx=new Set();
function tableRowPasses(r,q,st,ks){
  if(isGroup(r))return false;
  // A collapsed phase hides its rows in the table too, so the two lenses agree: a
  // milestone (and every task) in a collapsed lane drops out of the list exactly as
  // its caps and bars drop off the chart. isCollapsed is read straight off the set
  // rather than the per-row _collapsed flag, so it is right even before a reflow.
  if(r.phase!==undefined&&isCollapsed(r.phase))return false;
  if(r.phase!==undefined&&!phaseVis[r.phase])return false;
  // The tag filter is a ROW rule since 0.60.0: own tags plus the section's. Since
  // 0.64.0 rowShown also carries the owner (one choice shared with the chart's legend)
  // and the Overdue chip, so the table no longer filters owners on its own.
  if(!rowShown(r,ks))return false;
  if(isMs(r)&&!msVis)return false;
  if(st&&statusOf(r)!==st)return false;
  if(q&&tableSearchHay(r).indexOf(q)<0)return false;
  return true;
}
function tableRows(){
  const q=(searchInp.value||'').trim().toLowerCase();
  const st=statusSel.value, ks=tagFilterKeys();
  let rows=items.filter(r=>tableRowPasses(r,q,st,ks));
  tableCtx=new Set();
  if(manualOrder()&&tree.order.some(k=>tree.parentOf.get(k))){
    const ok=new Set(rows), up=new Set(), folded=new Set();
    ok.forEach(r=>{ let p=treeParent(r); while(p){ const x=treeRows.get(p); if(!x||ok.has(x)||up.has(x))break; up.add(x); p=tree.parentOf.get(p); } });
    tree.order.forEach(k=>{ const p=tree.parentOf.get(k); if(p&&(collapsedRows.has(p)||folded.has(p)))folded.add(k); });
    rows=items.filter(r=>!isGroup(r)&&!folded.has(rowTreeKey(r))&&(ok.has(r)||(up.has(r)&&!isCollapsed(r.phase)&&phaseVis[r.phase]!==false)));
    rows.forEach(r=>{ if(!ok.has(r))tableCtx.add(r); });
  }
  if(manualOrder())return rows;      // already in plan order
  const val=r=>sortValueOf(r,sortKey);
  // A blank sorts last in both directions: an unassigned row should never push
  // the thing you are looking for off the top of the list.
  rows.sort((a,b)=>{
    const va=val(a), vb=val(b);
    if(va===vb) return (a._itemIdx||0)-(b._itemIdx||0);
    if(va==='') return 1;
    if(vb==='') return -1;
    return va<vb ? -sortDir : sortDir;
  });
  return rows;
}

function syncSortHeaders(){
  tableHead.querySelectorAll('th[data-sort]').forEach(th=>{
    const on=th.dataset.sort===sortKey;
    th.classList.toggle('sorted',on);
    const old=th.querySelector('.sort-caret'); if(old)old.remove();
    if(on){const c=document.createElement('span');c.className='sort-caret';c.textContent=sortDir===1?'▲':'▼';th.appendChild(c);
      th.title='Sorted '+(sortDir===1?'ascending':'descending')+'. Click again for plan order, which is the order you can drag rows in.';}
    else th.title='Sort by this column';
  });
}

// Blank when absent, never the string "undefined". A link only when the url
// survived safeUrl; otherwise the same chip as plain text, so a javascript: url
// shows its label and does nothing at all.
function provCell(r){
  const p=provOf(r);
  if(!p)return '';
  const tip=esc(p.label+(p.url?', '+p.url:''));
  // tabindex -1: a provenance link is a mouse affordance in this table, and leaving
  // it in the browser's tab order is what let a stray Tab land three rows up. It
  // stays clickable; the keyboard reaches the row through the row focus instead.
  if(p.url) return `<a class="prov-chip" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer" tabindex="-1" title="${tip}">${esc(p.label)}</a>`;
  return `<span class="prov-chip" title="${tip}">${esc(p.label)}</span>`;
}

// Read-mostly indicator, not an editor: how many rows this one needs, how many wait
// on it, and the actual names on hover. Deliberately NOT inline-editable, because a
// dependency is a relationship between two rows and a one-cell editor is the wrong
// shape for it; the cell opens the editor that already does the job properly.
function depsOf(r){
  if(!r||!r.id)return {needs:[],blocks:[]};
  const needs=(r.deps||[]).map(d=>({id:d,name:depName(d)}));
  const blocks=(depIndex?(depIndex.get(r.id)||[]):items.filter(d=>d.deps&&d.deps.includes(r.id))).map(d=>({id:d.id,name:d.name||d.id}));
  return {needs,blocks};
}
// Who depends on whom, built once for one pass over the table (0.78.1): every row's Deps
// cell used to scan the whole plan for the rows it blocks, ~250,000 checks per render.
// Same rows, same order (the plan's) as that scan.
let depIndex=null;
function withDepIndex(fn){
  if(depIndex)return fn();
  depIndex=new Map();
  items.forEach(d=>{ if(!d.deps||!d.id)return; d.deps.forEach(x=>{ let a=depIndex.get(x); if(!a)depIndex.set(x,a=[]); if(a[a.length-1]!==d)a.push(d); }); });
  try{ return fn(); }finally{ depIndex=null; }
}
function depCell(r){
  const {needs,blocks}=depsOf(r);
  if(!needs.length&&!blocks.length)return '';
  const tip=[needs.length?('needs: '+needs.map(n=>n.name).join(', ')):'',
             blocks.length?('blocks: '+blocks.map(n=>n.name).join(', ')):'']
            .filter(Boolean).join(' / ');
  return `<span class="dep-count" data-title="Dependencies" data-body="${esc(tip)}">`+
    (needs.length?`<span class="in">&larr;${needs.length}</span>`:'')+
    (needs.length&&blocks.length?' ':'')+
    (blocks.length?`&rarr;${blocks.length}`:'')+'</span>';
}
// The Tags cell: the row's own tags as pills, then its section's, dashed and fainter, so
// what the legend's tag filter reads (own plus section) is on the row in plain sight. Only
// the row's own are edited here; the title says where the others come from.
function tagsCell(r,ed){
  const own=ownTags(r), ok=new Set(own.map(tagKey)), sec=phaseTagsOf(r).filter(t=>!ok.has(tagKey(t)));
  const tip=[own.length?'Own: '+own.join(', '):'',sec.length?'From its section: '+sec.join(', '):''].filter(Boolean).join('. ');
  return `<td class="c-tags${ed}${own.length||sec.length?'':' empty'}" data-field="tags"${tip?` title="${esc(tip)}"`:''}><span class="tags-trunc">`
    +own.map(t=>`<span class="row-tag"${tagAttrs(t)}>${esc(t)}</span>`).join('')
    +sec.map(t=>`<span class="row-tag inh"${tagAttrs(t)}>${esc(t)}</span>`).join('')+'</span></td>';
}
// Several table renders asked for inside one live merge (applyState's filter pass, then the
// merge itself) make one, at the end (0.75.1).
let tableDefer=0, tableRenderOwed=false;
function withTable(fn){
  tableDefer++;
  try{ return fn(); }
  finally{ tableDefer--; if(!tableDefer&&tableRenderOwed){ tableRenderOwed=false; if(!tblEditing&&prefs.view==='table')renderTable(); } }
}
function renderTable(){
  if(tableDefer){ tableRenderOwed=true; return; }
  if(!tableBody)return;
  // A Tags cell saving chip by chip must not be repainted out from under its own editor;
  // finishing the cell renders the table.
  if(tblEditing&&tblEditing.field==='tags'&&tblEditing.td.isConnected)return;
  syncOwnerControls();
  syncSortHeaders();
  syncSortLock();
  applyColWidths();
  let rows, html;
  withDepIndex(()=>{ rows=tableRows(); if(rows.length)html=rows.map(tableRowHtml); });
  syncTableCount(rows.length);
  if(!rows.length){
    // Collapse all empties the table as surely as a filter does, and "no items match"
    // would send the reader looking at the filters for a cause that is not there.
    const why=allLanesCollapsed()?'Every section is collapsed. Expand all, at the start of the legend, to see the rows.'
      :prefs.overdueOnly&&!items.some(r=>!isGroup(r)&&rowShown(r))?'Nothing is overdue'+(ownerFilterOn()||tagFilterOn()?' for this owner and these tags.':'.')+' Click Overdue in the legend to show everything again.'
      :prefs.currentOnly&&!items.some(r=>!isGroup(r)&&rowShown(r))?'Nothing is current'+(ownerFilterOn()||tagFilterOn()?' for this owner and these tags.':'.')+' Click Current in the legend to show everything again.'
      :ownerFilterOn()&&!items.some(r=>!isGroup(r)&&rowShown(r))?'This owner has no rows'+(tagFilterOn()?' with these tags':'')+'. Pick another owner, or All owners.'
      :tagFilterOn()&&!items.some(r=>!isGroup(r)&&rowShown(r))?'No row carries these tags, on itself or through its section. Pick other tags in the legend, or clear them.'
      :'No items match these filters.';
    tableRowsCache=null;
    tableBody.innerHTML='<tr><td colspan="13" class="table-empty">'+why+'</td></tr>';return;}
  const keys=rows.map(r=>rowKey(r));
  const qaSel=tableQaFocus();
  tableQaLines(rows,keys,html);
  // Keyed patch (0.75.1). Only the rows whose markup changed are replaced: rewriting all
  // ~500 rows cost the browser about half a second of style and layout on every save
  // anyone made, for one changed cell. Since 0.78.1 also when rows moved, came or went
  // (a sort, a filter, a new order), which used to rewrite the whole body.
  reconcileTable(keys,html);
  tableForceKeys.clear();
  tableRowsCache={keys,html};
  tableQaAttach(qaSel);
  markFocusedRow();
  paintCritTable();
  paintPeerFocus();
}
// Quick add in the Table (0.79.0): the blank row being typed into, spliced in among the rows'
// keys and markup. Keyed like the rows, so a live change still patches the table instead of rebuilding it.
function tableQaLines(rows,keys,html){
  if(!manualOrder()&&qa&&qa.lens==='table'){
    // Under a sort the rows of a section are not together, so the blank row opens under the row
    // whose + was pressed; what it adds takes its place in the sort.
    const i=qa.from?rows.indexOf(qa.from):-1;
    if(i>=0){ keys.splice(i+1,0,'qa:entry'); html.splice(i+1,0,`<tr class="qa-entry" data-qa="entry"><td colspan="13" class="qa-cell"><span class="t-ind"></span></td></tr>`); }
  }
  if(manualOrder()){
    // The blank row goes under the last row on screen of its section, or of its task's subtree.
    let at=null;
    if(qa&&qa.lens==='table')for(let i=rows.length-1;i>=0&&!at;i--){ const x=rows[i]; if(qa.parent?(x===qa.parent||treeUnderRow(x,qa.parent)):x.phase===qa.phase)at=x; }
    for(let i=rows.length-1;i>=0;i--){
      const ph=rows[i].phase;
      // No "+ Add task" line under each section (0.79.0, Drew's default): quick add is the + on
      // hover, on a row for a subtask, on a section's header in the chart, and New for the rest.
      if(at&&rows[i]===at){ keys.splice(i+1,0,'qa:entry'); html.splice(i+1,0,`<tr class="qa-entry" data-qa="entry"><td colspan="13" class="qa-cell" style="--depth:${qa.depth}"><span class="t-ind"></span></td></tr>`); }
    }
  }
}
// Where the caret is in the blank row's field, when it has the focus.
function tableQaFocus(){ return qa&&qa.lens==='table'&&document.activeElement===qa.input?[qa.input.selectionStart,qa.input.selectionEnd]:null; }
// The blank row's field is the same element through every render, so what is typed and the
// caret survive somebody else's change arriving mid-word, the row moving included (moving a
// node takes the focus out of it).
function tableQaAttach(sel){
  if(!qa||qa.lens!=='table')return;
  const cell=tableBody.querySelector('tr.qa-entry .qa-cell');
  if(!cell){ closeQuickAdd(true); return; }
  if(qa.input.parentNode!==cell){ qa.rendering=true; cell.appendChild(qa.input); qa.rendering=false; }
  if(sel&&document.activeElement!==qa.input){ qa.rendering=true; try{ qa.input.focus({preventScroll:true}); qa.input.setSelectionRange(sel[0],sel[1]); }catch(e){} qa.rendering=false; }
}
let tableRowsCache=null;
// Rows whose DOM was changed by hand (a cell editor), re-rendered whatever their markup says.
const tableForceKeys=new Set();
function tableRowHtml(r){
    const ms=isMs(r), editable=ms||!!r.id;
    const o=ownerOf(r), st=statusOf(r), dl=deadlineOf(r), sd=ms?'':(r.start||'');
    const dot=r.phase!==undefined?`<span class="phase-dot" style="background:${esc(phaseColor(r.phase))}"></span>`:'';
    const dates=ms?(dl?fmt(dl):''):((sd&&dl)?fmt(sd)+' → '+fmt(dl):'');
    const ed=editable?' editable':'';
    // The tree (0.79.0): in plan order a row is indented under its task, a task with subtasks
    // carries the chevron that folds them, and a task held for context is dimmed. Under a sort
    // a subtask says which task it sits under.
    const plan=manualOrder(), kids=treeHasKids(r), folded=kids&&collapsedRows.has(rowTreeKey(r)), pk=treeParent(r);
    const trCls=[ms?'is-ms':'',isCancelled(r)?'cancelled':'',tableCtx.has(r)?'ctx-dim':'',folded?'kids-folded':''].filter(Boolean).join(' ');
    const nested=plan&&labelsEl.classList.contains('has-nest');
    const lead=nested?`<span class="t-ind" style="--depth:${treeDepth(r)}"></span>`+(kids?`<button type="button" class="sub-tog" tabindex="-1" aria-expanded="${folded?'false':'true'}" title="${esc((folded?'Show':'Hide')+' its '+plural(treeDesc(r).length,'subtask')+'. This is your view only, it does not change the plan.')}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg></button>`:'<span class="sub-tog sub-none"></span>'):'';
    const subOf=!plan&&pk&&treeRows.get(pk)?`<span class="sub-of" title="${esc('A subtask of '+(treeRows.get(pk).name||''))}">&#8627;</span>`:'';
    const addSub=canHoldKids(r)?`<button type="button" class="row-add" tabindex="-1" title="Add a subtask" aria-label="Add a subtask">${PLUS_SVG}</button>`:'';
    return `<tr data-key="${esc(rowKey(r))}"${trCls?` class="${trCls}"`:''}>`
      +`<td class="c-uid">${r.uid?ROW_SEL_HTML:''}${r.uid?`<button class="uid-link" type="button" tabindex="-1" title="Copy a link to this row, to paste in Chat">#${esc(r.uid)}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg></button>`:''}</td>`
      +`<td class="c-name${ed}" data-field="name">${lead||subOf?`<span class="t-name">${lead}${subOf}`:''}<span class="trunc" data-title="${esc(r.name)}" data-dates="${esc(dates)}" data-body="${esc(r.description||'')}">`
        +`${ms?'<span class="ms-mark">◆</span>':''}${esc(r.name)}</span>${kids?subCountHtml(r):''}${lead||subOf?'</span>':''}${addSub}</td>`
      +`<td class="c-phase${ed}" data-field="phase"${phaseTagsOf(r).length?` title="Tags: ${esc(phaseTagsOf(r).join(', '))}"`:''}>${dot}${esc(phaseNameOf(r))}${r.phase===undefined?'':`<button type="button" class="sec-add" tabindex="-1" title="${esc('Add a task to '+phaseNameOf(r))}" aria-label="Add a task to this section">${PLUS_SVG}</button>`}</td>`
      +`<td class="c-owner${ed}${o?'':' empty'}${o&&rqUnlinked(o)?' unlinked':''}" data-field="owner"${o&&rqUnlinked(o)?` title="${esc(o)} is not linked to an Adom account. The plan's owner links names on Setup"`:''}>${o?esc(o):'NO OWNER'}${reqPillHtml(r)}</td>`
      +(ms?'<td class="c-date empty"></td>'
          :`<td class="c-date${ed}${sd?'':' empty'}" data-field="start">${sd?esc(fmt(sd)):'not set'}</td>`)
      +`<td class="c-date${ed}${dl?'':' empty'}" data-field="deadline">${dl?esc(fmt(dl)):'not set'}</td>`
      +`<td class="c-status${ed}" data-field="status">${st?`<span class="tag ${statusTagFor(st)}">${esc(statusTextFor(st))}</span>`:'<span class="tag tag-not-started">none</span>'}</td>`
      +(ms?'<td class="c-effort empty"></td>'
          :`<td class="c-effort${ed}${effortOf(r)===null?' empty':''}" data-field="effort">${effortOf(r)===null?'not est.':esc(fmtEffort(effortOf(r)))}${effortRollHtml(r)}</td>`)
      +`<td class="c-deps${(()=>{const d=depsOf(r);return (d.needs.length||d.blocks.length)?'':' empty';})()}">${depCell(r)}</td>`
      +`<td class="c-notes${ed}${r.description?'':' empty'}" data-field="notes"><span class="trunc">${r.description?esc(r.description):'none'}</span></td>`
      +tagsCell(r,ed)
      +(provLinked(r)?`<td class="c-prov prov-locked" data-field="prov" title="A linked source: click the link to open it. Its text is kept as it is.">${provCell(r)}</td>`
          :`<td class="c-prov${ed}" data-field="prov">${provCell(r)}</td>`)
      +`<td class="c-del">${editable?`<button class="row-del" type="button" tabindex="-1" title="Delete this row from the plan">&times;</button>`:''}</td>`
      +'</tr>';
}

// ── Drag a lane ──
//
// Group headers only exist in the chart's label column (the table is a flat list
// of tasks and milestones, with the lane in a column instead), so moving a whole
// lane is a chart gesture. Dragging one takes its entire block of rows with it as
// one move, one undo, one changelog entry.
//
// phases[] itself is NOT reordered: a phase's index is written into every item's
// `phase` field and into the sidecar, so renumbering lanes would silently
// re-home every row in the plan. Only the row order changes, which means the
// legend can list lanes in a different order than the chart draws them.
let laneDrag=null;
function laneBlock(g){
  const gi=items.indexOf(g);
  if(gi<0)return null;
  let end=gi+1;
  while(end<items.length&&items[end].type!=='group')end++;
  return {gi,end,rows:items.slice(gi,end)};
}
function restoreTransientCollapse(){
  if(!transientCollapse)return;
  collapsed.clear();
  transientCollapse.forEach(n=>collapsed.add(n));
  transientCollapse=null;
  applyCollapse();layoutMsCaps();sizeTailroom();drawDeps();
}
function endLaneDrag(commit){
  if(!laneDrag)return;
  clearTimeout(laneDrag.timer);
  const d=laneDrag; laneDrag=null;
  restoreTransientCollapse();
  document.body.classList.remove('row-dragging');
  hideDropIndicators();
  (d.block?d.block.rows:[]).forEach(r=>{if(r._lDiv)r._lDiv.classList.remove('dragging');if(r._bDiv)r._bDiv.classList.remove('dragging');});
  if(!commit||!d.armed||d.targetGroup==null||d.targetGroup===d.g)return;
  // Dragging a lane now moves the SECTION, not just its rows: phases[] is
  // reordered and every item.phase is remapped in the same pass, so the legend
  // and the chart cannot disagree about section order. That retires the
  // disagreement 0.12.0 wrote down and worked around.
  const from=d.g.phase, tgt=d.targetGroup.phase;
  let to=tgt;
  if(!d.before&&from>tgt)to=tgt+1;
  else if(d.before&&from<tgt)to=tgt-1;
  if(to<0)to=0; if(to>=phases.length)to=phases.length-1;
  const name=phases[from]?phases[from].name:'';
  if(reorderPhases(from,to)) moveHint='the '+name+' section';
}
function wireLaneDrag(){
labelsEl.addEventListener('mousedown',e=>{
  if(e.button!==0)return;
  if(e.target.closest('.lane-add'))return;      // quick add, not a lane drag (0.79.0)
  const row=e.target.closest('.label-row.group');
  if(!row)return;
  const g=items.find(r=>r._lDiv===row);
  if(!g)return;
  laneDrag={g,row,x:e.clientX,y:e.clientY,armed:false,targetGroup:null};
  laneDrag.timer=setTimeout(()=>{
    if(!laneDrag)return;
    laneDrag.armed=true;
    laneDrag.block=laneBlock(g);
    // Collapse every lane for the duration. Moving a lane means choosing a place
    // among the other lanes, and with 70 rows expanded the place you are aiming
    // for is usually off-screen. This is transient: the exact prior state is
    // restored on drop or cancel, and it is never written to prefs.
    transientCollapse=[...collapsed];
    phases.forEach((p,i)=>collapsed.add(i));
    applyCollapse();layoutMsCaps();sizeTailroom();drawDeps();
    (laneDrag.block?laneDrag.block.rows:[]).forEach(r=>{if(r._lDiv)r._lDiv.classList.add('dragging');if(r._bDiv)r._bDiv.classList.add('dragging');});
    document.body.classList.add('row-dragging');
    hideTip();
  },350);
});
document.addEventListener('mousemove',e=>{
  if(!laneDrag)return;
  if(!laneDrag.armed){
    if(Math.abs(e.clientX-laneDrag.x)>4||Math.abs(e.clientY-laneDrag.y)>4){clearTimeout(laneDrag.timer);laneDrag=null;}
    return;
  }
  e.preventDefault();
  const lr=labelsEl.getBoundingClientRect();
  const y=e.clientY-lr.top+labelsEl.scrollTop;
  const groups=items.filter(r=>r.type==='group'&&r!==laneDrag.g);
  let target=null,before=true;
  for(const g of groups){
    const blk=laneBlock(g); if(!blk)continue;
    const top=g._lDiv.offsetTop;
    const last=blk.rows[blk.rows.length-1];
    const bottom=last._lDiv.offsetTop+last._lDiv.offsetHeight;
    if(y<(top+bottom)/2){target=g;before=true;break;}
    target=g;before=false;
  }
  laneDrag.targetGroup=target;laneDrag.before=before;
  if(!target){hideDropIndicators();return;}
  const blk=laneBlock(target);
  const anchor=before?target:blk.rows[blk.rows.length-1];
  const top=before?anchor._lDiv.offsetTop:anchor._lDiv.offsetTop+anchor._lDiv.offsetHeight;
  labelDropInd.style.top=top+'px';labelDropInd.classList.add('show');
  const btop=before?anchor._bDiv.offsetTop:anchor._bDiv.offsetTop+anchor._bDiv.offsetHeight;
  barDropInd.style.top=btop+'px';barDropInd.classList.add('show');
});
document.addEventListener('mouseup',()=>endLaneDrag(true));
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&laneDrag)endLaneDrag(false);},true);
}

// ── Resizable table columns ──
//
// Same idiom as the name-column divider: drag to resize, double-click to fit the
// longest content. Widths persist in prefs, because a column width is how one
// person reads the table rather than a fact about the plan.
//
// The first resize snapshots every column's current width and switches the table
// to fixed layout. Auto layout treats a width as a suggestion and re-solves the
// whole row, so one column being dragged would shove its neighbours around;
// fixed layout makes the number you dragged to the number you get.
const COL_MIN=56;
function colKeys(){ return [...tableHead.querySelectorAll('th')].map(th=>th.dataset.sort||'del'); }
function fixedCols(){ return prefs.tableCols&&Object.keys(prefs.tableCols).length>0; }
// The default sizing rule, and it is a rule about COLUMN TYPES rather than about
// content: a name and a note are wide because they are prose, dates and status are
// narrow because their values have a known shape, and the delete column is an icon.
// The same nine numbers for every plan and every data set.
//
// This replaces a table that was in `table-layout: auto` until the user's first
// resize, which is what "columns resize depending on where I click" was. Auto
// layout re-solves every column from the body content, and renderTable() replaces
// the whole tbody on a click, an edit, a sort, a filter or a new row, so the widths
// were being recomputed constantly and visibly. Under `table-layout: fixed` the
// browser sizes columns from the header row alone and nothing in the body can
// reach them, which is the Excel model: the grid is a grid, and only you move it.
//
// Percentages rather than pixels so the grid fills the pane it is given. The first
// drag calls snapshotColWidths(), which freezes every column to its current pixel
// width, and the table is pixel-driven from then on. That is also what Excel does:
// before you touch anything the columns are whatever the sheet says, and the moment
// you drag one they are numbers you own.
const COL_DEFAULTS={uid:'82px',name:'20%',phase:'9%',owner:'8%',start:'7%',deadline:'7%',status:'8%',effort:'5%',deps:'5%',notes:'13%',tags:'8%',prov:'10%',del:'30px'};
function applyColWidths(){
  const table=tableHead.closest('table');
  if(!table)return;
  const on=fixedCols();
  // The class now means "the user has set widths", not "use fixed layout": the
  // layout is fixed either way.
  table.classList.toggle('fixed-cols',on);
  const w=prefs.tableCols||{};
  tableHead.querySelectorAll('th').forEach(th=>{
    const k=th.dataset.sort||'del';
    th.style.width=(on&&w[k])?(w[k]+'px'):(COL_DEFAULTS[k]||'');
  });
}
function snapshotColWidths(){
  if(fixedCols())return;
  const w={};
  tableHead.querySelectorAll('th').forEach(th=>{
    w[th.dataset.sort||'del']=Math.round(th.getBoundingClientRect().width);
  });
  prefs.tableCols=w;
}
// Measure the widest rendered cell in a column, the way the name column's
// double-click already does, using the column's own font so the number is real.
function fitColumn(key){
  const idx=colKeys().indexOf(key);
  if(idx<0)return;
  const probe=document.createElement('span');
  const sample=tableBody.querySelector('tr td:nth-child('+(idx+1)+')');
  const cs=sample?getComputedStyle(sample):null;
  probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;'+
    (cs?`font:${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize}/1 ${cs.fontFamily};`:'');
  document.body.appendChild(probe);
  let max=0;
  probe.textContent=tableHead.querySelectorAll('th')[idx].textContent;
  max=probe.offsetWidth;
  tableBody.querySelectorAll('tr td:nth-child('+(idx+1)+')').forEach(td=>{
    probe.textContent=td.textContent;
    max=Math.max(max,probe.offsetWidth);
  });
  probe.remove();
  snapshotColWidths();
  prefs.tableCols[key]=Math.max(COL_MIN,Math.min(720,Math.round(max)+28));
  savePrefs();applyColWidths();
}
function wireTableColumnResize(){
(function(){
  let grip=null;
  tableHead.querySelectorAll('th').forEach((th,i,all)=>{
    if(i===all.length-1)return;                 // nothing to drag past the last edge
    const g=document.createElement('div');
    g.className='col-grip';
    g.title='Drag to resize this column. Double-click to fit the widest value.';
    g.addEventListener('mousedown',e=>{
      e.preventDefault();e.stopPropagation();
      snapshotColWidths();
      grip={key:th.dataset.sort||'del',x:e.clientX,w:Math.round(th.getBoundingClientRect().width),el:g};
      g.classList.add('dragging');document.body.classList.add('col-sizing');
    });
    g.addEventListener('click',e=>e.stopPropagation());   // never sorts
    g.addEventListener('dblclick',e=>{e.preventDefault();e.stopPropagation();fitColumn(th.dataset.sort||'del');});
    th.appendChild(g);
  });
  document.addEventListener('mousemove',e=>{
    if(!grip)return;
    prefs.tableCols[grip.key]=Math.max(COL_MIN,Math.min(720,grip.w+(e.clientX-grip.x)));
    applyColWidths();
  });
  document.addEventListener('mouseup',()=>{
    if(!grip)return;
    grip.el.classList.remove('dragging');document.body.classList.remove('col-sizing');
    grip=null;document.body.classList.remove('col-sizing');savePrefs();
  });
})();
}

// ── Drag a row to reorder it ──
//
// Arming is a 350ms click-and-hold. Everything the table already does on click
// (open a cell editor, delete a row, sort a header) is a short click, so none of
// it had to change and none of it can be swallowed by a drag that never armed.
//
// Dragging is only offered in plan order. Under a column sort the rows on screen
// are not the rows in the plan, so a drop indicator between two of them would be
// describing a position that does not exist. Rather than silently reordering the
// underlying plan to match a gesture made against a sorted view, or quietly
// flipping the sort off when the user presses the mouse down, the row says why
// and offers the way back.
const rowDrop=$('rowDrop'), sortLockNote=$('sortLockNote');
let rowDrag=null, autoScroll=null;

function syncSortLock(){
  if(!sortLockNote)return;
  if(manualOrder()){sortLockNote.style.display='none';sortLockNote.textContent='';return;}
  sortLockNote.style.display='';
  sortLockNote.innerHTML='Sorted, so rows cannot be dragged. <button type="button" id="toPlanOrder">Back to plan order</button>';
  const b=$('toPlanOrder');
  if(b)b.addEventListener('click',()=>{sortKey=null;prefs.tableSort={key:null,dir:1};savePrefs();renderTable();});
}

function endRowDrag(commit){
  if(!rowDrag)return;
  clearTimeout(rowDrag.timer);
  clearInterval(autoScroll); autoScroll=null;
  const d=rowDrag; rowDrag=null;
  document.body.classList.remove('row-dragging');
  rowDrop.classList.remove('show');
  if(d.tr)d.tr.classList.remove('row-lifted');
  document.querySelectorAll('#tableBody tr.lane-target').forEach(t=>t.classList.remove('lane-target'));
  clearNestMark();
  if(!commit||!d.armed||!d.drop)return;
  // One undo for the whole gesture, the row's place and section together (0.79.0: onto a
  // task's middle nests it, between rows moves it at the level the pointer picks).
  applyDrop(d.drop,d.drop.kind==='nest'?'drag-nest':'drag-row');
}

function rowDragMove(e){
  if(!rowDrag)return;
  if(!rowDrag.armed){
    if(Math.abs(e.clientX-rowDrag.x)>4||Math.abs(e.clientY-rowDrag.y)>4){clearTimeout(rowDrag.timer);rowDrag=null;}
    return;
  }
  e.preventDefault();
  const scroller=document.querySelector('.table-scroll');
  const sr=scroller.getBoundingClientRect();
  // auto-scroll when the pointer sits near either edge of the pane
  const EDGE=44;
  let dir=0;
  if(e.clientY<sr.top+EDGE)dir=-1; else if(e.clientY>sr.bottom-EDGE)dir=1;
  if(dir&&!autoScroll){autoScroll=setInterval(()=>{scroller.scrollTop+=dir*14;},16);}
  else if(!dir&&autoScroll){clearInterval(autoScroll);autoScroll=null;}
  else if(dir&&autoScroll){clearInterval(autoScroll);autoScroll=setInterval(()=>{scroller.scrollTop+=dir*14;},16);}

  // The rows on screen, less the lifted one and its subtasks, which travel with it (0.79.0).
  const skip=new Set([rowDrag.r,...treeDesc(rowDrag.r)]);
  const trs=[...tableBody.querySelectorAll('tr[data-key]')].filter(t=>{ const x=rowItem(t.dataset.key); return x&&!skip.has(x); });
  const seq=trs.map(t=>rowItem(t.dataset.key));
  let at=-1, frac=1;
  for(let k=0;k<trs.length;k++){ const b=trs[k].getBoundingClientRect(); if(e.clientY<b.bottom){ at=k; frac=Math.max(0,(e.clientY-b.top)/b.height); break; } }
  if(at<0&&trs.length)at=trs.length-1;
  document.querySelectorAll('#tableBody tr.lane-target').forEach(t=>t.classList.remove('lane-target'));
  clearNestMark();
  const drop=at>=0?dropAt(seq,at,frac,e.clientX-rowDrag.x,[rowDrag.r]):null;
  rowDrag.drop=drop;
  if(!drop){rowDrop.classList.remove('show');return;}
  if(drop.kind==='nest'){ rowDrop.classList.remove('show'); trs[seq.indexOf(drop.target)].classList.add('nest-target'); return; }
  const ref=drop.above?drop.Y:drop.X, tr=trs[seq.indexOf(ref)];
  if(!tr){rowDrop.classList.remove('show');return;}
  const tb=tr.getBoundingClientRect();
  rowDrop.style.top=((drop.above?tb.top:tb.bottom)-sr.top+scroller.scrollTop)+'px';
  rowDrop.style.left=(drop.depth?28+drop.depth*16:0)+'px';
  rowDrop.classList.add('show');
  if(drop.phase!==rowDrag.r.phase)tr.classList.add('lane-target');
}

function wireTableRowDrag(){
tableBody.addEventListener('mousedown',e=>{
  if(e.button!==0)return;
  // The row handle keeps the focus where it was and starts no text selection; a press
  // held on it still lifts the row, the same as anywhere else on the row.
  if(e.target.closest('.row-sel'))e.preventDefault();
  // A modified click is a selection gesture, so the browser must not treat it as a
  // text-selection anchor. This is the blue swath: with no row multi-select, ctrl
  // and shift clicks fell through to the native anchor-and-extend machinery and
  // painted across the cells. Unmodified click-drag is left alone, so selecting and
  // copying a cell's text still works.
  if((e.ctrlKey||e.metaKey||e.shiftKey)&&!e.target.closest('.cell-edit')){
    e.preventDefault();
    document.body.classList.add('tbl-picking');
    return;
  }
  if(e.target.closest('.row-del')||e.target.closest('.cell-edit'))return;
  if(e.target.closest('.sub-tog, .row-add, .sec-add, .qa-add, tr.qa-entry'))return;   // a fold, a quick add (0.79.0)
  const tr=e.target.closest('tr[data-key]');if(!tr)return;
  if(!manualOrder())return;                 // sorted: the note explains why
  const r=rowItem(tr.dataset.key);if(!r)return;
  rowDrag={tr,r,x:e.clientX,y:e.clientY,armed:false,targetIdx:null};
  rowDrag.timer=setTimeout(()=>{
    if(!rowDrag)return;
    rowDrag.armed=true;
    tr.classList.add('row-lifted');
    document.body.classList.add('row-dragging');
    hideTip();
  },350);
});
document.addEventListener('mousemove',rowDragMove);
document.addEventListener('mouseup',()=>{const wasArmed=rowDrag&&rowDrag.armed;endRowDrag(true);
  if(wasArmed)dragEndedAt=Date.now();});
// Esc abandons the drag and leaves the plan exactly as it was.
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&rowDrag)endRowDrag(false);},true);
}
// A drag must not also count as a click on the cell it finished over. Timestamped
// rather than a sticky flag: a drag that ends outside the table never produces a
// click here, and a flag left armed would then eat the next real one.
let dragEndedAt=0;
function wireRowDragClickGuard(){
tableBody.addEventListener('click',e=>{
  if(Date.now()-dragEndedAt<150){e.stopPropagation();e.preventDefault();}
},true);
}

// ── Inline cell editing ──
//
// The focused row is the row whose editor you touched last. It is not a
// second click target competing with the one-click cell editors: nothing new to
// aim at, no extra gesture to learn, and the thing you were already doing tells
// the app which row you mean. Esc gives it back, innermost first: the field, then
// the row.
let focusedKey=null;
function markSelectedRows(){
  if(!tableBody)return;
  markArmedRows();
  tableBody.querySelectorAll('tr.multi-sel').forEach(t=>t.classList.remove('multi-sel'));
  if(!selected.size)return;
  tableBody.querySelectorAll('tr[data-key]').forEach(t=>{
    const r=rowItem(t.dataset.key);
    if(r&&r.uid&&selected.has(r.uid))t.classList.add('multi-sel');
  });
}
function markFocusedRow(){
  if(!tableBody)return;
  markSelectedRows();
  tableBody.querySelectorAll('tr.row-focus').forEach(t=>t.classList.remove('row-focus'));
  if(!focusedKey)return;
  const tr=[...tableBody.querySelectorAll('tr[data-key]')].find(t=>t.dataset.key===focusedKey);
  if(tr)tr.classList.add('row-focus');
}
function setFocusedRow(k){ focusedKey=k||null; markFocusedRow(); scheduleFocus(); }
function visibleRowEls(){ return [...tableBody.querySelectorAll('tr[data-key]')]; }
function focusedRowEl(){ return focusedKey?visibleRowEls().find(t=>t.dataset.key===focusedKey)||null:null; }
// Keep the focused row in view, and move the pane by the least it can get away
// with. The arrows themselves never scroll (preventDefault sees to that, which is
// the whole point of the request); this puts back exactly the amount of scrolling
// that walking off the edge actually needs. Done by hand rather than with
// scrollIntoView because the header is sticky, so the usable top of the pane is
// its bottom edge and not the pane's, and because only this scroller should move.
function scrollRowIntoView(tr){
  const sc=document.querySelector('.table-scroll');
  if(!sc||!tr)return;
  const pane=sc.getBoundingClientRect(), row=tr.getBoundingClientRect();
  const top=Math.max(pane.top, tableHead.getBoundingClientRect().bottom);
  if(row.top<top) sc.scrollTop-=(top-row.top);
  else if(row.bottom>pane.bottom) sc.scrollTop+=(row.bottom-pane.bottom);
}
function focusRowAt(i){
  const rows=visibleRowEls();
  if(!rows.length)return false;
  const tr=rows[Math.max(0,Math.min(rows.length-1,i))];
  setFocusedRow(tr.dataset.key);
  scrollRowIntoView(tr);
  return true;
}
// Up and Down walk the rows the table is SHOWING, so a sort or a filter changes
// what "the next row" means and the walk follows without knowing anything about
// either.
function moveRowFocus(dir){
  const rows=visibleRowEls();
  if(!rows.length)return;
  const i=rows.findIndex(t=>t.dataset.key===focusedKey);
  // Nothing focused yet, so the first press lands on the first visible row rather
  // than moving from nowhere. If a row was touched earlier it is already the
  // focused row and the walk simply carries on from it.
  if(i<0){ focusRowAt(0); return; }
  focusRowAt(i+dir);
}
// Arrow navigation belongs to the table and only when the table is otherwise idle,
// because a native select and a date input do their own thing with the arrows and
// they have to win.
function tableNavReady(){ return prefs.view==='table' && appIsIdle(); }
// Tab commits the field and opens the next one to the right, ready to type. The
// walk is over the rendered cells, so it follows the visible order under a sort,
// skips the milestone start cell and the delete button because neither carries
// `editable`, and wraps around the ends rather than dead-ending.
// The table's half of the one selection model. Ctrl toggles, shift takes the range
// from the anchor, and the range walks the rows the table is SHOWING, so a sort or a
// filter changes what "everything between these two" means and the gesture follows.
// The anchor is the chart's `selAnchor`, deliberately: selecting rows in one lens and
// finding them selected in the other is the point of sharing the model.
function tableSelect(key,e){
  const r=rowItem(key); const uid=selUid(r);
  if(!uid)return;
  const ctrl=e.ctrlKey||e.metaKey, shift=e.shiftKey;
  // The rows the table is SHOWING, as uids, milestones among them.
  const vis=[...tableBody.querySelectorAll('tr[data-key]')]
    .map(t=>selUid(rowItem(t.dataset.key))).filter(Boolean);
  if(shift&&selAnchor&&vis.includes(selAnchor)){
    const ai=vis.indexOf(selAnchor), ci=vis.indexOf(uid);
    if(ai>=0&&ci>=0){
      if(!ctrl)selected.clear();
      for(let i=Math.min(ai,ci);i<=Math.max(ai,ci);i++)selected.add(vis[i]);
    }
  } else if(ctrl){
    if(selected.has(uid))selected.delete(uid); else selected.add(uid);
    selAnchor=uid;
  } else {
    selected.clear(); selected.add(uid); selAnchor=uid;
  }
  setFocusedRow(key);
  refreshSelVisuals(); updateSelCount();
}
function tabToCell(key,field,dir){
  const rows=[...tableBody.querySelectorAll('tr[data-key]')];
  if(!rows.length)return;
  const ri=rows.findIndex(t=>t.dataset.key===key);
  if(ri<0)return;                                   // the edit filtered its own row away
  const cells=[...rows[ri].querySelectorAll('td.editable[data-field]')];
  const ci=cells.findIndex(c=>c.dataset.field===field);
  if(ci>=0&&ci+dir>=0&&ci+dir<cells.length){ startCellEdit(cells[ci+dir]); return; }
  // Past the end of the row: the next row that has anything editable in it.
  for(let n=1;n<=rows.length;n++){
    const nr=rows[(ri+dir*n+rows.length*n)%rows.length];
    const nc=[...nr.querySelectorAll('td.editable[data-field]')];
    if(nc.length){ startCellEdit(dir>0?nc[0]:nc[nc.length-1]); return; }
  }
}
let tblEditing=null;
// ── The choice list for select cells ──
//
// A native <select> keeps every bit of its keyboard behaviour: arrows change the
// value, type-ahead works, Enter and Tab and Esc all reach our own handler. What it
// does NOT do any more is open the browser's popup, because that popup is browser
// UI: while it is open the page gets no key events at all, so the first Tab was
// spent closing it and the second one moved a column. This list mirrors the select
// instead, so landing on a dropdown still shows the choices and leaving it still
// costs exactly one Tab.
let cellList=null;
// ── Keeping a popup list open while it is being SCROLLED ───────────────────
//
// Every list of our own is a fixed-position box anchored to something the page can
// scroll out from under it, so all of them close when the page scrolls: an option list
// left hanging over a cell that is no longer there is worse than one that went away.
// That is why the listener is `scroll` in the CAPTURE phase, which is the only way to
// hear a scroll of an inner pane at all, since scroll does not bubble.
//
// Those two facts collided. A capture listener on window hears a scroll of EVERY
// element on the page, and the list itself is one of them: it is 232px tall with
// overflow-y auto, so reaching the tenth name in it means scrolling it, and scrolling
// it dismissed it before the name could be clicked. That is Drew's report on 0.51.0,
// and it applied to the owner pill, the status pill and every select cell in the table
// alike, because all three go through this one pair of listeners.
//
// The guard is the event's TARGET. A scroll that started inside a list of ours is that
// list scrolling itself, which is somebody reading it and never a reason to take it
// away. A scroll anywhere else is the page moving under the anchor, which still is.
function inOwnPopup(ev){
  const t=ev&&ev.target;
  return !!(t&&t.nodeType===1&&t.closest&&t.closest('.cell-list'));
}
// A scroll that cannot move the anchor leaves the list open (0.77.1): the chart's panes scroll
// on their own when somebody else's change lands, and that closed a list opened in the legend.
function popupScrollGuard(close,anchor){ return ev=>{
  if(inOwnPopup(ev))return;
  const t=ev&&ev.target;
  if(anchor&&t&&t.nodeType===1&&!t.contains(anchor))return;
  close(); }; }
// The OTHER half of the same report. Both lists cancel mousedown, for two good reasons
// (never steal focus from the offscreen select, never let the native select popup open
// on top of ours), but they cancelled it everywhere on the element including its own
// scrollbar, and preventDefault on a scrollbar mousedown cancels the browser's native
// drag. So the bar could be grabbed and would not move: the list stayed open, which
// looked like nothing happening at all, and the wheel that followed then dismissed it.
//
// A mousedown on an element's own scrollbar arrives with that element as the TARGET,
// which is the first half of telling it apart from a press on an option: over a child
// the offsets are the child's own and say nothing about the scrollbar.
//
// The second half depends on which kind of scrollbar the platform draws, and both kinds
// are in the field. A CLASSIC scrollbar (Chrome on Windows and on most Linux desktops,
// which is where Drew is) takes layout width, so it sits in the gutter between the
// client box and the border and `offsetX >= clientWidth` names it exactly. An OVERLAY
// scrollbar (macOS, and headless Chrome) takes no layout width at all, so there is no
// gutter to measure and the only signal left is the band at the edge it is painted
// over. Both are only live when the element has something to scroll, which is what
// stops a press on the right-hand padding of a short list from being read as a grab.
const OVERLAY_SB=15;         // Chrome's overlay thickness, plus a pixel of slop
function onOwnScrollbar(el,ev){
  if(!el||ev.target!==el)return false;
  const bw=Math.max(0,el.offsetWidth-el.clientWidth-2*el.clientLeft);   // 0 when overlay
  const bh=Math.max(0,el.offsetHeight-el.clientHeight-2*el.clientTop);
  if(el.scrollHeight>el.clientHeight){
    const edge=bw>0?el.clientWidth:el.clientWidth-OVERLAY_SB;
    if(ev.offsetX>=edge)return true;
  }
  if(el.scrollWidth>el.clientWidth){
    const edge=bh>0?el.clientHeight:el.clientHeight-OVERLAY_SB;
    if(ev.offsetY>=edge)return true;
  }
  return false;
}
let cellListScroll=null;
function closeCellList(){
  if(!cellList)return;
  cellList.el.remove();
  if(cellListScroll)window.removeEventListener('scroll',cellListScroll,true);
  cellListScroll=null;
  window.removeEventListener('resize',closeCellList);
  cellList=null;
}
function syncCellList(){
  if(!cellList)return;
  const v=cellList.sel.value;
  cellList.el.querySelectorAll('.cl-opt').forEach(o=>{
    const on=o.dataset.value===v;
    o.classList.toggle('on',on);
    if(on&&o.scrollIntoView)o.scrollIntoView({block:'nearest'});
  });
}
// One keyboard for a list of ours whose select is NOT the focused control (the status
// pill's: it opens from a click on a pill, so nothing on the page holds the focus that
// would walk it). The same keys a focused select answers to, applied to the select by
// hand: the arrows step, Home and End jump, a letter goes to the next option starting with
// it, wrapping. Returns true when the key was the list's, whether or not it moved.
// TYPE-AHEAD (0.72.0). Typing in any list of ours HIGHLIGHTS, it never picks: letters typed
// within 0.9 s of each other build one search ("fa" finds factoryOS past Final check), the
// first entry starting with it is marked, else the first containing it, and the same letter
// again steps through the entries that start with it. The browser's own type-ahead on a
// select CHANGES the value and fires change on the first key, which the Table's cells read
// as the pick (Drew, 2026-10-27: "the first keystroke picks an item from list").
const TA_GAP_MS=900;
function taActive(sel){ return !!sel._ta&&performance.now()-(sel._taAt||0)<TA_GAP_MS; }
function typeAheadIndex(sel,ch){
  const now=performance.now();
  if(now-(sel._taAt||0)>TA_GAP_MS)sel._ta='';
  sel._taAt=now;
  const n=sel.options.length, i=Math.max(0,sel.selectedIndex);
  const lab=j=>sel.options[j].textContent.trim().toLowerCase();
  let q=(sel._ta||'')+ch.toLowerCase(); sel._ta=q;
  if(q.length>1&&[...q].every(c=>c===q[0]))q=q[0];       // "bbb" walks the b's
  const from=q.length===1?i+1:i;                          // a longer search may stay put
  for(let s=0;s<n;s++){ const j=(from+s)%n; if(lab(j).startsWith(q))return j; }
  for(let s=0;s<n;s++){ const j=(from+s)%n; if(lab(j).includes(q))return j; }
  return -1;
}
function listKeyStep(sel,ev){
  const n=sel.options.length; if(!n)return false;
  const i=sel.selectedIndex; let to=null;
  if(ev.key==='ArrowDown')to=Math.min(n-1,i+1);
  else if(ev.key==='ArrowUp')to=Math.max(0,i-1);
  else if(ev.key==='Home'||ev.key==='PageUp')to=0;
  else if(ev.key==='End'||ev.key==='PageDown')to=n-1;
  else if(ev.key.length===1&&!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&(/\S/.test(ev.key)||taActive(sel))){
    const j=typeAheadIndex(sel,ev.key);
    if(j<0)return true;                // nothing matches: still the list's key
    to=j;
  }
  if(to===null)return false;
  sel.selectedIndex=to;
  return true;
}
// The keys on a FOCUSED select of ours (0.72.0): the arrows, Home/End and typing move the
// mark by hand (preventDefault), so the browser never changes the value for a key and never
// fires change for one. Opens the list if it is closed. True when the key was one of these.
function listNavKey(sel,e,open){
  if(e.ctrlKey||e.metaKey||e.altKey)return false;
  const typing=e.key.length===1&&(/\S/.test(e.key)||taActive(sel));
  if(!typing&&!/^(ArrowUp|ArrowDown|Home|End|PageUp|PageDown)$/.test(e.key))return false;
  e.preventDefault(); e.stopImmediatePropagation();
  listKeyStep(sel,e);
  open(); syncCellList();
  return true;
}
// The status pill in the label column is editable in place. It reuses the table's
// custom option list (openCellList) rather than a native <select> popup, for the
// same reason the table does (no double-list bug, on-brand styling), and it commits
// through commitCell, the SAME path the table's status editor uses: one snapshot
// for undo, the chart and bar refreshed, and a save whose diff logs the status
// event. So a status changed here and one changed in the table are the same edit.
function openStatusPill(r,pillEl){
  if(!r||!r.id||r.type==='milestone')return;
  closeCellList(); closeLabelEditors(true);
  const sel=document.createElement('select');
  sel.innerHTML=STATUS_OPTS.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('');
  const cur=statusOf(r);
  if(cur&&!STATUS_OPTS.includes(cur))sel.innerHTML+=`<option value="${esc(cur)}">${esc(cur)}</option>`;
  sel.value=cur;
  // Offscreen: the list reads its options and current value from it, but nothing
  // ever focuses or shows the select itself.
  sel.style.cssText='position:fixed;left:-9999px;top:0;opacity:0';
  document.body.appendChild(sel);
  openCellList(sel,pillEl);
  // escKey and NOT esc: `esc` is the app's HTML-escape helper, and a local of that name
  // shadows it for the WHOLE function, which put the esc(cur) two lines above this into
  // the temporal dead zone. That throw only fires for a row carrying a status word none
  // of ours matches, which is why nobody had met it; the rename retires it.
  const cleanup=()=>{ closeCellList(); if(sel.parentNode)sel.remove();
    document.removeEventListener('mousedown',outside,true); window.removeEventListener('keydown',escKey,true); };
  const outside=ev=>{ if(!ev.target.closest('.cell-list'))cleanup(); };
  // THE KEYBOARD (0.69.0). The list walks with the arrows and the list follows the choice,
  // Enter takes it (the same change the click makes), Escape and Tab close it and change
  // nothing. Read on the window in the capture phase, so the chart's own arrow keys (row
  // focus) never see a key the list took.
  const escKey=ev=>{
    if(ev.key==='Escape'){ ev.preventDefault(); ev.stopImmediatePropagation(); cleanup(); return; }
    if(ev.key==='Tab'){ cleanup(); return; }
    if(listKeyStep(sel,ev)){ ev.preventDefault(); ev.stopImmediatePropagation(); syncCellList(); return; }
    if(ev.key==='Enter'){ ev.preventDefault(); ev.stopImmediatePropagation(); sel.dispatchEvent(new Event('change',{bubbles:true})); }
  };
  sel.addEventListener('change',()=>{ const v=sel.value; cleanup(); commitCell(r,'status',v); renderTable(); });
  // Deferred, so the click that opened the list does not immediately close it.
  setTimeout(()=>{ document.addEventListener('mousedown',outside,true); window.addEventListener('keydown',escKey,true); },0);
}

// ── One picker per kind of field (0.65.0) ──
//
// THE LIST. Every select a person picks from (phase, status, type, "waits on" in the item
// editor and the New panel) opens the app's own list, the one the Table's cells and the
// status pill already used, instead of the browser's popup. Before this the same choice
// looked like two different controls depending on where you made it. The select is still
// the real control underneath: the keyboard is its own, and a pick writes its value and
// fires its change event, so nothing that reads the select had to change.
function wireAppSelect(sel){
  if(!sel||sel._appList)return; sel._appList=true;
  // KEYBOARD (0.66.1). The select keeps the focus, so the arrows, Home/End and type-ahead
  // are its own and move the value; the open list FOLLOWS them (it used to close on the
  // first arrow, because that change looked like a pick). Enter takes what is highlighted
  // and closes the list without applying the panel; the next Enter applies. Escape closes
  // the list. Space or Alt+Down opens it, instead of the browser's own popup on top of it.
  // `apppick` is fired for a real pick (a click, or Enter), never for a step of the
  // arrows, for a select whose pick DOES something (the editor's "waits on" adds one).
  const NAV=/^(ArrowUp|ArrowDown|Home|End|PageUp|PageDown)$/;
  const isOpen=()=>!!(cellList&&cellList.sel===sel);
  const open=()=>{ if(!isOpen()){ openCellList(sel,sel); cellList.el.addEventListener('click',()=>{ if(isOpen())closeCellList(); }); } };
  sel.addEventListener('mousedown',e=>{
    if(sel.disabled||e.button!==0)return;
    e.preventDefault();
    if(document.activeElement!==sel){ try{ sel.focus({preventScroll:true}); }catch(_){ sel.focus(); } }
    if(isOpen()){ closeCellList(); return; }
    open();
  });
  sel.addEventListener('keydown',e=>{
    if(sel.disabled)return;
    if((e.key===' '&&!taActive(sel))||(e.altKey&&(e.key==='ArrowDown'||e.key==='ArrowUp'))){ e.preventDefault(); e.stopImmediatePropagation(); if(isOpen())closeCellList(); else open(); return; }
    // A step of the keys (0.72.0: typing builds a search). The value moves and `change`
    // fires, flagged as a step, so a panel that shows fields by type still follows it;
    // nothing PICKS until Enter or a click.
    const before=sel.selectedIndex;
    if(listNavKey(sel,e,open)){ if(sel.selectedIndex!==before){ sel._navAt=performance.now(); sel.dispatchEvent(new Event('change',{bubbles:true})); } return; }
    if(e.key==='Enter'&&isOpen()){ e.preventDefault(); e.stopImmediatePropagation(); closeCellList(); sel.dispatchEvent(new CustomEvent('apppick')); return; }
    if(e.key==='Escape'&&isOpen()){ e.preventDefault(); e.stopImmediatePropagation(); closeCellList(); }
  },true);
  sel.addEventListener('change',()=>{
    const nav=performance.now()-(sel._navAt||0)<200;
    if(isOpen()){
      if(nav){ syncCellList(); return; }                                   // a step of the keys
      closeCellList(); sel.dispatchEvent(new CustomEvent('apppick')); return; // a click
    }
    // A change with no list open and no key behind it (a script, an assistive tool)
    // is a pick too.
    if(!nav)sel.dispatchEvent(new CustomEvent('apppick'));
  });
  sel.addEventListener('blur',()=>{ if(isOpen())closeCellList(); });
}
// THE OWNER. One picker everywhere an owner is set: the item editor, the New panel, the
// Table's Owner cell and the owner pill on the chart. A field you can type into, with the
// plan's people listed under it, filtered as you type; a name nobody has yet is just
// typed. It was a pop-up list on the pill and the browser's datalist everywhere else,
// which offered the same names in two different ways.
//
// KEYBOARD (0.66.1): the datalist it replaced took the arrow keys and this did not, which
// Drew noticed at once. Down and Up walk the list (Down from a closed field opens it),
// Enter takes the highlighted name, Tab takes it and moves on, Escape closes the list.
// With nothing highlighted, Enter and Tab do what they would in that field anyway
// (commit what was typed, apply the panel, move on), so a NEW name is still just typed.
// Nothing is highlighted until a key or the mouse asks for it: a person typing a new
// name that happens to begin like an old one must not be handed the old one by Enter.
// While filtering, the matches come first and "no owner" last, so the first Down lands on
// the best match.
let ownerCombo=null;
function ownerOptionsHtml(cur,filter){
  const f=(filter||'').trim().toLowerCase();
  const owners=distinctOwners().filter(o=>!f||o.toLowerCase().includes(f));
  const none=`<div class="cl-opt${cur?'':' on'}" data-value="">no owner</div>`;
  const people=owners.map(o=>`<div class="cl-opt${o===cur?' on':''}" data-value="${esc(o)}">${esc(o)}</div>`).join('');
  return f?people+none:none+people;
}
function closeOwnerCombo(){
  if(!ownerCombo)return;
  const c=ownerCombo; ownerCombo=null;
  c.el.remove();
  window.removeEventListener('scroll',c.onScroll,true);
  window.removeEventListener('keydown',c.keys,true);
  window.removeEventListener('resize',closeOwnerCombo);
}
function wireOwnerCombo(inp,onPick){
  if(!inp||inp._ownerWired)return; inp._ownerWired=true;
  const place=el=>{
    const r=inp.getBoundingClientRect();
    el.style.minWidth=Math.max(172,Math.round(r.width))+'px';
    el.style.left=Math.max(4,Math.min(r.left,window.innerWidth-el.offsetWidth-6))+'px';
    const below=window.innerHeight-r.bottom;
    el.style.top=(below>el.offsetHeight+8?r.bottom+2:Math.max(4,r.top-el.offsetHeight-2))+'px';
  };
  const mine=()=>!!(ownerCombo&&ownerCombo.inp===inp);
  const opts=()=>mine()?[...ownerCombo.el.querySelectorAll('.cl-opt')]:[];
  const setActive=i=>{
    if(!mine())return;
    const os=opts(); ownerCombo.active=i;
    os.forEach((o,k)=>{ o.classList.toggle('active',k===i); o.setAttribute('aria-selected',k===i?'true':'false'); });
    if(i>=0&&os[i]&&os[i].scrollIntoView)os[i].scrollIntoView({block:'nearest'});
  };
  // A panel's owner field wraps and grows (0.79.0); a value set here is not typed, so no input
  // event refits it: it is refitted by hand.
  const fit=()=>{ if(inp.matches&&inp.matches('textarea.ed-grow'))edGrow(inp); };
  const pick=v=>{ inp.value=v; fit(); closeOwnerCombo(); if(onPick)onPick(v); };
  // On the WINDOW, in the capture phase, and only while this list is open: these keys are
  // decided before the panel's Enter (Apply) and the Table cell's Enter and Tab (commit)
  // can see them, and when nothing is highlighted they are left alone for exactly those.
  const keys=e=>{
    if(!mine()||e.target!==inp)return;
    const os=opts(), a=ownerCombo.active;
    if(e.key==='ArrowDown'||e.key==='ArrowUp'){
      e.preventDefault(); e.stopImmediatePropagation();
      if(!os.length)return;
      setActive(e.key==='ArrowDown'?(a<0?0:Math.min(os.length-1,a+1)):(a<=0?-1:a-1));
      return;
    }
    if(e.key==='Enter'&&a>=0&&os[a]){ e.preventDefault(); e.stopImmediatePropagation(); pick(os[a].dataset.value); return; }
    if(e.key==='Tab'&&a>=0&&os[a]){ inp.value=os[a].dataset.value; fit(); closeOwnerCombo(); return; }   // and Tab goes on
    if(e.key==='Escape'){ e.preventDefault(); e.stopImmediatePropagation(); closeOwnerCombo(); }
  };
  const open=()=>{
    if(!inp.isConnected)return;
    const cur=(inp.value||'').trim();
    if(!mine()){
      closeOwnerCombo(); closeCellList();
      const el=document.createElement('div'); el.className='cell-list owner-list owner-combo';
      el.setAttribute('role','listbox');
      document.body.appendChild(el);
      // Never steal the focus from the field, except on the list's own scrollbar.
      el.addEventListener('mousedown',ev=>{ if(!onOwnScrollbar(el,ev))ev.preventDefault(); });
      el.addEventListener('click',ev=>{
        const o=ev.target.closest('.cl-opt'); if(!o)return;
        ev.stopPropagation();
        pick(o.dataset.value);
      });
      el.addEventListener('mousemove',ev=>{ const o=ev.target.closest('.cl-opt'); if(o){ const i=opts().indexOf(o); if(i!==ownerCombo.active)setActive(i); } });
      // A scroll MOVES the list with its field rather than closing it: focusing a Table
      // cell scrolls the table, and that scroll arrives a frame after the list opened.
      const onScroll=ev=>{ if(inOwnPopup(ev))return; if(!inp.isConnected){ closeOwnerCombo(); return; } if(ownerCombo)place(ownerCombo.el); };
      window.addEventListener('scroll',onScroll,true);
      window.addEventListener('keydown',keys,true);
      window.addEventListener('resize',closeOwnerCombo);
      ownerCombo={el,inp,onScroll,keys,active:-1};
    }
    // The whole list while the field holds nothing or a name already on it; a filter
    // once the person is typing something else.
    const known=!cur||distinctOwners().includes(cur);
    ownerCombo.el.innerHTML=ownerOptionsHtml(cur,known?'':cur);
    ownerCombo.active=-1;
    place(ownerCombo.el);
  };
  inp.setAttribute('role','combobox'); inp.setAttribute('aria-autocomplete','list');
  inp.addEventListener('focus',open);
  inp.addEventListener('click',open);
  inp.addEventListener('input',()=>{ if(!ownerCombo||mine())open(); });
  inp.addEventListener('blur',()=>{ if(mine())closeOwnerCombo(); });
  // Down on a field whose list is closed (after Escape, or a pick) opens it, highlighted.
  inp.addEventListener('keydown',e=>{ if(e.key==='ArrowDown'&&!mine()){ e.preventDefault(); e.stopImmediatePropagation(); open(); setActive(0); } },true);
  if(document.activeElement===inp)open();
}

// The owner pill in the label column, editable in place. Same deal as the status pill
// above, and the same commit path: commitCell(r,'owner',v), which is the SAME call the
// table's Owner cell makes, so a name set here and a name typed there are one kind of
// edit with one undo step, one sidecar override and one `owner` event in the changelog.
// The two lenses cannot disagree because there is only one path.
//
// It is a field, not a list: status is a closed set of five, but the people on a project
// are an open set, so anyone already on the plan is a pick and a new name is typed. The
// moment it commits, distinctOwners picks it up for every other owner picker.
function openOwnerPill(r,pillEl){
  if(!r||!r.id||r.type==='milestone')return;
  closeCellList(); closeLabelEditors(true); closeOwnerCombo();
  const old=document.querySelector('.pill-owner-inp'); if(old)old.remove();
  // Since 0.66.1 the pill opens THE owner picker (the one the editor and the Table use):
  // a field laid over the pill, holding the current owner selected, with the plan's people
  // under it. It was a list with a type-in row at the bottom, the one owner control in the
  // app the keyboard could not walk.
  const rect=pillEl.getBoundingClientRect();
  const cur=(r.owner||'').trim();
  const inp=document.createElement('input');
  inp.type='text'; inp.className='pill-owner-inp'; inp.autocomplete='off'; inp.maxLength=60;
  inp.placeholder='owner'; inp.value=cur;
  inp.setAttribute('aria-label','Owner of '+(r.name||'this row'));
  inp.style.left=Math.max(4,Math.min(rect.left,window.innerWidth-186))+'px';
  inp.style.top=Math.round(rect.top-2)+'px';
  inp.style.width=Math.max(150,Math.round(rect.width)+40)+'px';
  document.body.appendChild(inp);
  let done=false;
  const onScroll=ev=>{ if(inOwnPopup(ev))return; finish(true); };
  // One commit path, the Table cell's: commitCell(r,'owner',v), one undo, one `owner`
  // line. A click away commits what is in the field, as the Table cell does.
  const finish=save=>{
    if(done)return; done=true;
    window.removeEventListener('scroll',onScroll,true);
    const v=inp.value.trim().replace(/\s+/g,' ');
    closeOwnerCombo(); inp.remove();
    if(save&&v!==cur){ commitCell(r,'owner',v); renderTable(); }
  };
  wireOwnerCombo(inp,v=>{ inp.value=v; finish(true); });
  inp.addEventListener('keydown',e=>{
    e.stopPropagation();                         // the page's own chords stay out of a field
    if(e.key==='Enter'){ e.preventDefault(); finish(true); }
    else if(e.key==='Escape'){ e.preventDefault(); finish(false); }
  });
  inp.addEventListener('blur',()=>finish(true));
  inp.addEventListener('mousedown',e=>e.stopPropagation());
  inp.addEventListener('click',e=>e.stopPropagation());
  inp.focus(); inp.select();
  setTimeout(()=>{ if(!done)window.addEventListener('scroll',onScroll,true); },0);
}
function openCellList(sel,td){
  closeCellList();
  const el=document.createElement('div');
  el.className='cell-list';
  el.innerHTML=[...sel.options].map(o=>
    `<div class="cl-opt" data-value="${esc(o.value)}">${esc(o.textContent)}</div>`).join('');
  document.body.appendChild(el);
  const r=td.getBoundingClientRect();
  // A list opened from a panel's field is at least as wide as the field (0.79.0), the way
  // the owner list is, so it reads as that field's list.
  if(td.closest&&td.closest('#editor'))el.style.minWidth=Math.round(r.width)+'px';
  el.style.left=Math.max(4,Math.min(r.left,window.innerWidth-el.offsetWidth-6))+'px';
  const below=window.innerHeight-r.bottom;
  el.style.top=(below>el.offsetHeight+8?r.bottom+2:Math.max(4,r.top-el.offsetHeight-2))+'px';
  // never steal focus from the select, EXCEPT on the list's own scrollbar, where
  // cancelling the mousedown would cancel the native drag along with it
  el.addEventListener('mousedown',e=>{ if(!onOwnScrollbar(el,e))e.preventDefault(); });
  el.addEventListener('click',e=>{
    const o=e.target.closest('.cl-opt'); if(!o)return;
    sel.value=o.dataset.value;
    sel.dispatchEvent(new Event('change',{bubbles:true}));
  });
  cellList={el,sel};
  cellListScroll=popupScrollGuard(closeCellList,td);
  window.addEventListener('scroll',cellListScroll,true);
  window.addEventListener('resize',closeCellList);
  syncCellList();
}
function startCellEdit(td,caret){
  if(tblEditing)return;
  const tr=td.closest('tr'); if(!tr)return;
  setFocusedRow(tr.dataset.key);
  const r=rowItem(tr.dataset.key), field=td.dataset.field;
  if(!r||!field)return;
  let el;
  if(field==='status'){
    el=document.createElement('select');
    el.innerHTML='<option value="">none</option>'+STATUS_OPTS.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('');
    const cur=statusOf(r);
    if(cur&&!STATUS_OPTS.includes(cur))el.innerHTML+=`<option value="${esc(cur)}">${esc(cur)}</option>`;
    el.value=cur;
  } else if(field==='start'||field==='deadline'){
    el=document.createElement('input');el.type='date';
    el.value=(field==='start'?r.start:deadlineOf(r))||'';
  } else if(field==='name'){
    el=document.createElement('input');el.type='text';el.value=r.name||'';
  } else if(field==='phase'){
    el=document.createElement('select');
    el.innerHTML=phases.map((p,i)=>`<option value="${i}">${esc(p.name)}</option>`).join('');
    el.value=String(r.phase);
  } else if(field==='prov'){
    // A linked provenance never gets here (its cell is not editable, 0.71.0); a plain
    // one is edited as text.
    if(provLinked(r))return;
    el=document.createElement('input');el.type='text';
    el.placeholder='where this came from';el.value=provLabel(r);
  } else if(field==='effort'){
    el=document.createElement('input');el.type='text';el.inputMode='decimal';el.autocomplete='off';
    el.placeholder='hours, 2d, 1w';el.title=EFFORT_HELP;
    el.value=effortOf(r)===null?'':String(effortOf(r));
  } else if(field==='owner'){
    el=document.createElement('input');el.type='text';el.autocomplete='off';el._ownerCombo=true;
    el.placeholder='one name';el.value=r.owner||'';
  } else if(field==='tags'){
    // The same chip box the item editor uses, laid over the cell; `el` is its input, so
    // Tab, Enter on an empty box, Escape and the blur commit are the cell's own. The draft
    // is read at commit (el._tags), and the whole cell session is one undo.
    const holder=document.createElement('div'); holder.innerHTML=tagBoxHtml('cellTags','Tags on this row');
    const box=holder.firstElementChild; box.removeAttribute('id');
    el=box.querySelector('.tb-in'); el.removeAttribute('id');
    // Saved per chip while the cell stays open (0.60.0 review): each add or remove is its
    // own edit, one undo and one `tags` line, as on the Setup page. The table does not
    // repaint under the open editor (renderTable waits for it), and the commit at the
    // end only picks up text still in the box; Escape discards just that text.
    const tb=wireTagBox(box,ownTags(r),'cell',list=>editTags(null,null,{rows:x=>x===r?list:null,via:'table-edit'}));
    el._box=box; el._tags=tb.get;
  } else {
    el=document.createElement('input');el.type='text';el.value=r.description||'';
  }
  el.className=el._box?'cell-input tb-in':'cell-input';
  // Overlaid, not swapped in: the cell keeps its own content underneath so its
  // intrinsic width never changes, and the overlay is out of flow so the column
  // cannot resize around the editor.
  const wrap=document.createElement('div');wrap.className='cell-edit'+(el._box?' tags-edit':'');wrap.appendChild(el._box||el);
  td.appendChild(wrap);

  // One click has to leave a usable editor, not just a rendered one. This runs
  // synchronously inside the click, which is the only place showPicker() is
  // allowed to open anything.
  el.focus();
  if(el.tagName==='SELECT'){
    // Our own list rather than the browser's popup. Same benefit on arrival, and
    // leaving it costs one Tab like every other field.
    openCellList(el,td);
    el.addEventListener('change',syncCellList);
    // Keys move the mark and never commit (0.72.0); Enter commits, Escape cancels, a click
    // on the list commits. Before, the browser's own step fired change, and change commits.
    el.addEventListener('keydown',e=>{ listNavKey(el,e,()=>{ if(!(cellList&&cellList.sel===el))openCellList(el,td); }); },true);
    // ...and the browser's popup must never open ON TOP of it. 0.22.2 removed the
    // showPicker path but left the plain one: clicking the already-focused select
    // still opened the native popup, so a second click on a phase cell produced
    // TWO differently-styled lists stacked on each other. preventDefault on
    // mousedown is what suppresses a native select popup, and it is deliberately
    // only on mousedown: the keyboard is untouched, so arrows, type-ahead and
    // Enter are still the real select's behaviour.
    //
    // The gesture is not dead, it is routed. The custom list IS the list, so a
    // click TOGGLES it, which is what clicking a dropdown has always meant.
    el.addEventListener('mousedown',e=>{
      e.preventDefault();
      if(cellList&&cellList.sel===el)closeCellList(); else openCellList(el,td);
      // preventDefault also suppresses the focus mousedown would have given. The
      // select is already focused from the mount, so this is a no-op in the normal
      // case and a repair in any case where it is not.
      try{ el.focus({preventScroll:true}); }catch(_){ try{ el.focus(); }catch(__){} }
    });
  } else if(el.hasAttribute('list')){
    // The owner datalist is a suggestion list, not a fixed set of choices, and its
    // popup does not intercept Tab the way a select popup does, so it keeps the
    // native affordance. Feature-detected and swallowed, so a browser without
    // showPicker just gets a focused control instead of a console error.
    if(typeof el.showPicker==='function'){ try{ el.showPicker(); }catch(e){} }
  }
  if(el.tagName==='INPUT'&&el.type==='text'){
    // Short identifying values are usually replaced wholesale, so select all and
    // let typing overwrite. Notes are prose you almost always add to, so the
    // caret goes to the end instead of destroying what is there on first keypress.
    if(field==='notes'){ const n=el.value.length; el.setSelectionRange(n,n); }
    // A name cell clicked on its text is the table's half of the same gesture the
    // chart's labels answer: the caret starts at the character that was clicked, so
    // fixing one word costs one click. Reached by Tab or Enter it still selects all,
    // because there was no click and so no character to start at.
    else if(caret!=null){ const n=Math.max(0,Math.min(el.value.length,caret)); el.setSelectionRange(n,n); }
    else el.select();
  }
  // Date cells get focus so the keyboard works at once, but the native calendar
  // is deliberately NOT forced open: it covers the rows below, steals the
  // keyboard, and the field is usually a small nudge that is faster to type. The
  // picker icon is right there for anyone who wants it.

  let done=false;
  const finish=save=>{
    if(done)return;done=true;tblEditing=null;
    tableForceKeys.add(rowKey(r));
    closeCellList(); closeOwnerCombo();
    // Blur before the re-render so focus lands on the document rather than being
    // orphaned on a node that is about to be thrown away.
    try{ el.blur(); }catch(e){}
    if(save)commitCell(r,field,el._tags?el._tags():el.value);
    renderTable();
  };
  tblEditing={td,el,r,field,cancel:()=>finish(false)};
  // Ctrl+Z inside a field is the browser's own text undo, and Esc means "cancel
  // this cell", not "clear the chart selection", so the app's keys stop here.
  // Capture phase: Tab is claimed before anything else can act on it, which is what
  // stops a date input walking its own day, month and year segments instead of
  // moving to the next column. Arrow keys are deliberately NOT touched, so left and
  // right still move between segments and up and down still change the value.
  el.addEventListener('keydown',e=>{
    if(e.key!=='Tab')return;
    e.preventDefault();e.stopPropagation();
    const fromKey=tr.dataset.key, fromField=field, back=e.shiftKey;
    finish(true);
    tabToCell(fromKey,fromField,back?-1:1);
  },true);
  el.addEventListener('keydown',e=>{e.stopPropagation();
    if(e.key==='Enter'){
      // Commit and step down a row, the spreadsheet convention, so a column of
      // values is Enter, type, Enter, type. It does NOT open the next editor:
      // Enter again does that, and the pair reads as "done, next".
      e.preventDefault();
      const fromKey=tr.dataset.key;
      finish(true);
      const rows=visibleRowEls();
      const i=rows.findIndex(t=>t.dataset.key===fromKey);
      if(i>=0)focusRowAt(i+1);       // clamped, so the last row commits and stays
    }
    else if(e.key==='Escape'){e.preventDefault();finish(false);}
    });
  el.addEventListener('blur',()=>finish(true));
  if(el.tagName==='SELECT')el.addEventListener('change',()=>finish(true));
  // The Owner cell takes the one owner picker (0.65.0): a pick commits the cell.
  if(el._ownerCombo)wireOwnerCombo(el,v=>{ el.value=v; finish(true); });
}

function commitCell(r,field,v){
  const ms=isMs(r);
  // Tags go through the one tag path, so the entry, the filter prune, the legend and the
  // changelog line (`tags`, via table-edit) are the ones every other tag edit makes.
  if(field==='tags'){ editTags(null,null,{rows:x=>x===r?v:null,via:'table-edit'}); return; }
  const snap=()=>{ if(ms)snapshotMs(r); else snapshot(r.id); };
  if(field==='owner'){
    v=v.trim().replace(/\s+/g,' ');
    if((r.owner||'')===v)return;
    // Somebody else's name asks them (0.80.0); the row keeps its owner until they accept.
    if(rqAsks(r,v)){ rqAsk(r,v); return; }
    snap();setOpt(r,'owner',v);
  } else if(field==='status'){
    if(statusOf(r)===v)return;
    snap();setOpt(r,'status',STATUS_OPTS.includes(v)?v:'');
  } else if(field==='name'){
    v=v.trim();
    // A row with no name cannot be identified in either lens, so refuse it.
    if(!v||v===r.name)return;
    snap();r.name=v;
  } else if(field==='phase'){
    const np=parseInt(v,10);
    if(!phases[np]||np===r.phase)return;
    // One undo step covers both halves: the field and the re-homing it causes.
    pushUndo(placeSnap([r]));
    moveRowToPhase(r,np);
    scheduleSave('table-edit');
    return;
  } else if(field==='prov'){
    v=v.trim();
    const cur=provOf(r);
    if(cur&&cur.url)return;              // a linked source is kept as it is (0.71.0)
    if((cur?cur.label:'')===v)return;
    snap();
    if(!v)delete r.provenance;
    else r.provenance = (cur&&cur.url) ? {label:v,url:cur.url} : v;
  } else if(field==='notes'){
    v=v.trim();
    if((r.description||'')===v)return;
    snap();setOpt(r,'description',v);
  } else if(field==='effort'){
    if(ms)return;
    const e=parseEffort(v);
    if(Number.isNaN(e)){ flash(saveInd,'effort is hours, like 16, 2d or 1w',3500); return; }
    if(e===effortOf(r))return;
    snap();setEffort(r,e);
  } else if(field==='deadline'){
    if(!v)return;
    if(ms){ if(r.date===v)return; snap(); r.date=v; }
    else { if(r.end===v)return; snap(); r.end=v;
           // A deadline pulled in front of the start would draw a backwards bar.
           if(r.start&&r.start>v)r.start=v; }
  } else if(field==='start'){
    if(!v||ms||r.start===v)return;
    snap();r.start=v;
    if(r.end&&r.end<v)r.end=v;
  } else return;

  // updateBar and refreshMsChrome both restate the tooltip, so the chart is
  // already telling the truth by the time we toggle back to it.
  if(ms){refreshMsChrome(r);updateMilestone(r);}
  else{updateBar(r.id);refreshLabelRow(r);}
  if(field==='name')refreshChartGeometry();   // a longer cap can need another row
  scheduleSave('table-edit');
}

// ── Table wiring ──
// Three states per column: ascending, descending, then back to plan order. That
// last click is the way back to a draggable table, so it is the same control the
// user already reached for rather than a separate "clear sort" affordance.
function wireTableHeadAndDelete(){
tableHead.addEventListener('click',e=>{
  const th=e.target.closest('th[data-sort]');if(!th)return;
  const k=th.dataset.sort;
  if(sortKey!==k){sortKey=k;sortDir=1;}
  else if(sortDir===1)sortDir=-1;
  else sortKey=null;
  prefs.tableSort={key:sortKey,dir:sortDir};savePrefs();
  renderTable();
});
tableBody.addEventListener('click',e=>{
  // Subtasks and quick add (0.79.0).
  const tog=e.target.closest('button.sub-tog');
  if(tog){ e.stopPropagation(); const tr=tog.closest('tr'); const r=rowItem(tr&&tr.dataset.key); if(r&&treeHasKids(r))toggleRowFold(r); return; }
  const ra=e.target.closest('.row-add');
  if(ra){ e.stopPropagation(); const tr=ra.closest('tr'); const r=rowItem(tr&&tr.dataset.key); if(r)openQuickAdd({parent:r}); return; }
  const qb=e.target.closest('.qa-add');
  if(qb){ e.stopPropagation(); openQuickAdd({phase:+qb.dataset.phase}); return; }
  const sa=e.target.closest('.sec-add');
  if(sa){ e.stopPropagation(); const tr=sa.closest('tr'); const r=rowItem(tr&&tr.dataset.key); if(r)openQuickAdd({phase:r.phase,from:r}); return; }
  if(e.target.closest('tr.qa-entry'))return;
  // The row handle selects the row and opens no cell (selectFromHandle). Ctrl and Shift on
  // it are the Table's own multi-select, over the rows the Table is showing.
  const rs=e.target.closest('.row-sel');
  if(rs){
    e.preventDefault(); e.stopPropagation();
    document.body.classList.remove('tbl-picking');
    const tr=rs.closest('tr[data-key]'), hr=tr&&rowItem(tr.dataset.key);
    if(hr)selectFromHandle(hr,e,'table');
    return;
  }
  const ul=e.target.closest('.uid-link');
  if(ul){ e.stopPropagation(); const tr=ul.closest('tr'); copyRowLink(rowItem(tr&&tr.dataset.key),ul); return; }
  const del=e.target.closest('.row-del');
  if(del){
    const tr=del.closest('tr'); const r=rowItem(tr&&tr.dataset.key);
    if(r)deleteRow(r);
    return;
  }
  // Modified clicks pick rows and never open an editor.
  const mod=e.ctrlKey||e.metaKey||e.shiftKey;
  if(mod){
    e.preventDefault();e.stopPropagation();
    const tr=e.target.closest('tr[data-key]');
    document.body.classList.remove('tbl-picking');
    if(!tr)return;
    tableSelect(tr.dataset.key, e);
    return;
  }
  const dc=e.target.closest('td.c-deps');
  if(dc){
    // The document-level "click outside closes the editor" handler would otherwise
    // see this very click and shut the editor we are about to open. The bar path
    // escapes it by being in that handler's allowlist; this one stops here instead.
    e.stopPropagation();
    const tr=dc.closest('tr'); const r=rowItem(tr&&tr.dataset.key);
    // A task or a milestone (0.78.0); only a row with no id yet has none to show.
    if(r&&r.id&&(barEls[r.id]||isMs(r))){ setFocusedRow(tr.dataset.key); openItemModal(r,undefined,{anchor:dc.getBoundingClientRect(),focus:'deps'}); }
    else if(r) flash(saveInd,'this row has no id yet, so it cannot be linked: reload the plan',2400);
    return;
  }
  if(selected.size)clearSelection();     // a plain click collapses the set, as on the chart
  const td=e.target.closest('td.editable');
  if(td){
    const tn=td.dataset.field==='name'?(e.target.closest('.trunc')||td.querySelector('.trunc')):null;
    startCellEdit(td,tn?caretIndexAt(tn,e.clientX,tn.textContent||''):undefined);
  }
});
}
// One click, undoable. A confirm dialog for something Ctrl+Z already covers is
// a speed bump, not a safety net.
function deleteRow(r){
  // A task goes with its subtasks (0.79.0), the way ClickUp sends them to the trash together:
  // one Ctrl+Z puts them all back.
  if(treeHasKids(r)){ deleteRows([r],'table-delete'); return; }
  const label=r.name||r.label||'row';
  if(focusedKey===rowKey(r))focusedKey=null;
  const info=removeRow(r);
  if(!info)return;
  pushUndo({type:'delete',r,idx:info.idx,droppedDeps:info.droppedDeps});
  renderTable();
  clearTimeout(undoTimer);
  undoToast.innerHTML='Deleted '+esc(label)+' &middot; <kbd>Ctrl+Z</kbd>';
  // 4500, was 3200. Something just left the plan and this is how you get it back.
  showToast(undoToast,4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});
  scheduleSave('table-delete');
}
// A SET goes in one keystroke and comes back in one Ctrl+Z, the way a file manager
// behaves. Removed bottom-up so each recorded index is still valid when the next one
// goes. Both lenses come through here: the table's Delete key and the chart's armed
// Delete are the same delete, one undo entry and one changelog batch either way, and
// `via` is the only thing that differs so the log still says which lens it came from.
// A set with every subtask of every row in it, in plan order.
function withSubtasks(rows){
  const set=new Set(); rows.forEach(r=>{ set.add(r); treeDesc(r).forEach(x=>set.add(x)); });
  return items.filter(r=>set.has(r));
}
function deleteRows(victims,via){
  if(!victims||!victims.length)return 0;
  const named=victims.length, lead=victims[0];
  victims=withSubtasks(victims);
  const subs=victims.length-named;
  const steps=[];
  victims.slice().reverse().forEach(v=>{
    const info=removeRow(v,true);
    if(info)steps.unshift({type:'delete',r:v,idx:info.idx,droppedDeps:info.droppedDeps});
  });
  if(!steps.length)return 0;
  pushUndo({type:'multi',steps});
  clearSelection();
  keepTailLast();applyFilter();refreshChartGeometry();renderTable();
  // One row is named, the way the single delete names it ("Deleted 1 rows" read badly); a task
  // deleted with its subtasks says how many went with it (0.79.0).
  undoToast.innerHTML=(named===1&&subs?'Deleted '+esc(lead.name||lead.label||'row')+' and its '+plural(subs,'subtask'):'Deleted '+(steps.length===1?esc(steps[0].r.name||steps[0].r.label||'1 row'):steps.length+' rows'))+' &middot; <kbd>Ctrl+Z</kbd>';
  showToast(undoToast,4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});
  scheduleSave(via||'table-delete');
  return steps.length;
}
// ── Deleting from the chart lens ──
//
// The table has had a delete button on every row and a Delete key since 0.19.0. The
// chart had neither: a row could be created, moved, renamed and rescheduled there and
// still only be removed by switching lens. Two ways in, both landing on the functions
// above, so there is exactly one delete in this app however you reach it.
//
// The key is armed rather than immediate. Delete on the chart has no row under a
// cursor to aim it: it acts on the SELECTION, which may be off screen and may be
// twenty rows, so a single press is too cheap for what it does. The first press says
// what would go and marks it; the second press inside the window does it.
let delArm=null;
function disarmDelete(){
  if(!delArm)return;
  clearTimeout(delArm.timer);
  document.querySelectorAll('.del-armed').forEach(el=>el.classList.remove('del-armed'));
  delArm=null;
  if(undoToast.__armed){ undoToast.__armed=false; undoToast.classList.remove('show'); undoToast.innerHTML=UNDO_TOAST_DEFAULT; }
}
function armDelete(victims){
  disarmDelete();
  const named=victims.length, namedUids=victims.map(v=>v.uid);
  victims=withSubtasks(victims);
  const n=victims.length, subs=n-named;
  victims.forEach(v=>{
    if(v._lDiv)v._lDiv.classList.add('del-armed');
    if(v._bDiv)v._bDiv.classList.add('del-armed');
    if(v._msLine)v._msLine.classList.add('del-armed');
  });
  // The second press is checked against the rows NAMED (armedFor); every row that would go,
  // the subtasks with them (0.79.0), wears the mark.
  delArm={uids:namedUids,marked:victims.map(v=>v.uid),timer:setTimeout(disarmDelete,4000)};
  markArmedRows();
  undoToast.__armed=true;
  // One row is named, so the question says WHICH row (the Table's cursor and its
  // selection can be two different rows); a set is counted.
  undoToast.innerHTML='Press <kbd>Delete</kbd> again to delete '+(n===1?'&ldquo;'+esc(victims[0].name||victims[0].label||'this row')+'&rdquo;'
    :named===1?'&ldquo;'+esc(victims[0].name||victims[0].label||'this row')+'&rdquo; and its '+plural(subs,'subtask')
    :n+' rows'+(subs?' ('+plural(subs,'subtask')+' among them)':''));
  showToast(undoToast,4000,()=>{undoToast.__armed=false;undoToast.innerHTML=UNDO_TOAST_DEFAULT;disarmDelete();});
}
// The Table's rows wear the armed mark too. Called again after the Table repaints (from
// markSelectedRows), so a live change landing inside the four seconds keeps it on.
function markArmedRows(){
  if(!tableBody||!delArm)return;
  tableBody.querySelectorAll('tr[data-key]').forEach(t=>{
    const r=rowItem(t.dataset.key);
    t.classList.toggle('del-armed',!!(r&&r.uid&&(delArm.marked||delArm.uids).includes(r.uid)));
  });
}
// The second press acts on what the first press NAMED. Anything that could have changed
// the target in between (a click, any other key, the timeout) has already disarmed, so
// the two sets can only differ if something new arrived, and re-arming on the fresh set
// is the honest answer to that.
function armedFor(victims){
  return !!delArm&&delArm.uids.length===victims.length&&victims.every(v=>delArm.uids.includes(v.uid));
}
// The chart's Delete is only live when the chart is what you are looking at and
// nothing else has the keyboard. appIsIdle() is the same gate the table's Delete and
// the arrow keys use, so a Delete inside a name field, a date input or an open panel
// still only edits text.
function chartDeleteReady(){
  if(prefs.view!=='gantt')return false;
  if(!selected.size)return false;
  if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;
  if(isTypingTarget(document.activeElement))return false;
  // A panel open on ONE row is a row being edited, and Delete there belongs to the
  // field the user is in rather than to the plan. The batch panel is the exception: it
  // is not an editor someone opened on a row, it IS the multi-selection made visible,
  // and it opens by itself the moment a second row is picked. Refusing there would mean
  // a set could never be deleted from the chart at all, which is most of the point.
  if(editorEl.classList.contains('show')&&selected.size<2)return false;
  return true;
}
function chartDeleteKey(e){
  const victims=selectedRows();
  if(!victims.length){disarmDelete();return;}
  e.preventDefault();
  if(!armedFor(victims)){armDelete(victims);return;}
  disarmDelete();
  deleteRows(victims,'chart-delete');
}
// The Table's Delete key, armed the same way, so Delete twice means one thing in both
// lenses. Until the row handle it deleted at the first press, and a second press out of
// the chart's habit took the next row too. It acts on the SELECTION when there is one
// (what the handle, Ctrl+click and Shift+click make, and what is highlighted), and with
// nothing selected on the focused row, the arrows' cursor. One row goes by deleteRow (the
// row button's path), a set by deleteRows; either way the focus then lands on the row that
// slid up into the first one's place, so a cleanup run is Delete twice, Delete twice.
function tableDeleteKey(e){
  const rows=visibleRowEls();
  const fi=rows.findIndex(t=>t.dataset.key===focusedKey);
  let victims=selectedRows();
  if(!victims.length&&fi>=0){ const fr=rowItem(focusedKey); if(fr)victims=[fr]; }
  if(!victims.length){disarmDelete();return;}
  e.preventDefault();
  if(!armedFor(victims)){armDelete(victims);return;}
  disarmDelete();
  const gone=new Set(victims.map(rowKey));
  const at=rows.findIndex(t=>gone.has(t.dataset.key));
  if(victims.length===1){
    const r=victims[0];
    if(r.uid)selected.delete(r.uid);
    deleteRow(r);
    refreshSelVisuals(); updateSelCount();
  } else deleteRows(victims,'table-delete');
  // The list is shorter now, so the same index is the row that moved up, and the clamp
  // inside focusRowAt lands on the last row when the last one went.
  const left=visibleRowEls().length;
  if(left)focusRowAt(Math.min(at>=0?at:Math.max(fi,0),left-1));
}
// One row, from a panel's Delete button. Straight to deleteRow, which is the table
// button's path: same undo entry, same toast, same retired uid, same dropped
// dependencies. The panel goes first so nothing is left open over a row that has left
// the plan, and the selection lets go of it so the next click on the chart is clean.
function deleteFromPanel(r){
  if(!r)return;
  disarmDelete();
  if(r.uid)selected.delete(r.uid);
  closeEditor();
  deleteRow(r);
  refreshSelVisuals();updateSelCount();
}
// The SET, from the batch panel's Delete button. Straight to deleteRows, which is the
// chart's armed Delete and the table's Delete key: one `multi` undo entry restoring every
// row where it stood, one changelog batch, dependencies on the departed rows dropped and
// their uids retired. `via` is the only thing that differs, so the log still says which
// hand did it.
//
// One press, no arming, unlike the chart's Delete key. The key has no target of its own
// and acts on a selection that may be entirely off screen, so it asks twice; a button
// inside the panel that IS the selection made visible has already been aimed, the same
// argument the single-row panels' Delete has run on since 0.37.0. Ctrl+Z is the safety
// net in both cases and the toast says so.
function deleteBatchFromPanel(){
  const victims=selectedRows();
  if(!victims.length){closeEditor();return;}
  disarmDelete();
  // The panel goes first: it is a view of a selection that is about to be empty, and
  // deleteRows clears the selection itself, which would leave it open over nothing.
  closeEditor();
  deleteRows(victims,'batch-delete');
}
function wireCopyIds(){
selCountEl.addEventListener('click',e=>{e.stopPropagation();copySelectedIds();});
const b=$('btnCopyIds');
if(b)b.addEventListener('click',e=>{e.stopPropagation();copySelectedIds();});
}
function wireTableFilters(){
// The toolbar's filters are our list too (0.72.0): typing and the arrows highlight, Enter or
// a click applies, the same as every other list.
wireAppSelect(statusSel);
statusSel.addEventListener('apppick',renderTable);
searchInp.addEventListener('keydown',e=>e.stopPropagation());
searchInp.addEventListener('input',()=>{clearTimeout(tblSearchTimer);tblSearchTimer=setTimeout(renderTable,120);});
// The Clear button used to be wired here. It set the owner select, the status select and
// the search box back to empty, wrote the empty owner into prefs and re-rendered. Each of
// those three still has its own way back (All owners, All, and deleting the text, which
// the input event below already answers), so the button was a fourth control for a job
// the other three were already doing.
}


// ── Analytics ───────────────────────────────────────────────────────────────
//
// The third lens. Every number on it is derived from `items` and `phases` at the
// moment it is drawn, so there is no second copy of the plan to fall out of step
// with the first one: switching lens re-derives, and an edit made in either of the
// other two is already in the model by the time this runs.
//
// THE HOUR RULE, stated once and used everywhere below: a task's effort is the
// BUSINESS days in its span, counting both ends, times eight. A forty-hour week, so
// a task that runs Monday to Friday is 40h and the same task dragged across the
// weekend to the following Tuesday is 56h and not 64h. A milestone is a date rather
// than work and carries no hours at all.
const HOURS_PER_DAY=8;
// Hours, always in hours. A column that says 80h on one row and 23d on the next is a
// column the reader has to convert in their head before they can compare two numbers
// in it, which is the one thing a totals table exists to save them. The separator is
// what makes a four-figure number readable instead.
function hrs(h){ return (h||0).toLocaleString()+'h'; }
function pct(a,b){ return b>0?Math.round(a*1000/b)/10:0; }
function anBar(share){ return '<div class="an-bar"><i style="width:'+Math.max(0,Math.min(100,share))+'%"></i></div>'; }
// Today as a plan date string, read at render time and not at load, so a tab left
// open overnight does not keep counting yesterday as today.
function anToday(){ return isoDay(new Date()); }
function daysFromToday(ds){ return ds?daysBetween(anToday(),ds):null; }
function relDays(n){
  if(n===null)return '';
  if(n===0)return 'today';
  return n>0?('in '+n+'d'):(Math.abs(n)+'d ago');
}
// The whole model, read once per render. Every card below takes a slice of this, so
// the totals on one card and the rows on another cannot disagree about the plan.
function analyticsModel(){
  const rows=items.filter(r=>!isGroup(r));
  const tasks=rows.filter(r=>!isMs(r)&&r.start&&r.end);
  const ms=rows.filter(r=>isMs(r)&&r.date);
  // effortHoursOf: a task's Effort estimate (0.63.0), nothing when it has none, and
  // nothing when it is cancelled, because cancelled work is not effort anybody owes. The
  // row keeps its estimate (the Table, the undo snapshot and the export still say it);
  // only the sums stop including it. This total is also the divisor for the per-person
  // share bars, so cancelling work makes everyone else's percentage go up, which is the
  // true reading: their share of what is left to do really did grow.
  const totalHours=tasks.reduce((a,r)=>a+effortHoursOf(r),0);
  // How complete those totals are: live tasks, and how many of them nobody has estimated.
  const liveTasks=tasks.filter(r=>!isCancelled(r)).length;
  const unest=tasks.filter(unestimated).length;
  const dates=[];
  tasks.forEach(r=>{dates.push(r.start,r.end);});
  ms.forEach(r=>dates.push(r.date));
  const first=dates.length?dates.reduce((m,d)=>dayNum(d)<dayNum(m)?d:m):'';
  const last=dates.length?dates.reduce((m,d)=>dayNum(d)>dayNum(m)?d:m):'';
  const spanBiz=first&&last?bizBetween(first,last):0;
  // People. The unassigned bucket is a real row and not a footnote: work with nobody
  // against it is the single most useful thing this page can point at.
  const byOwner=new Map();
  const bucket=o=>{
    const k=o||'';
    if(!byOwner.has(k))byOwner.set(k,{owner:k,hours:0,tasks:0,ms:0,unest:0,status:{}});
    return byOwner.get(k);
  };
  rows.forEach(r=>{
    const b=bucket(ownerOf(r));
    const st=statusOf(r)||'not-started';
    b.status[st]=(b.status[st]||0)+1;
    if(isMs(r)){ if(r.date)b.ms++; return; }
    if(!r.start||!r.end)return;
    // The task COUNT still counts it and the status column still counts it: a
    // cancelled row is a real decision somebody made and the card has to show it.
    // Only the hours stop.
    b.tasks++; b.hours+=effortHoursOf(r); if(unestimated(r))b.unest++;
  });
  const people=[...byOwner.values()]
    .filter(b=>b.tasks||b.ms)
    // Hours first, then task count, then the unassigned bucket after the named people
    // it is tied with, then the name. Total, so a redraw that changed nothing puts
    // every row back exactly where it was.
    .sort((a,b)=>b.hours-a.hours||b.tasks-a.tasks||(a.owner?0:1)-(b.owner?0:1)||a.owner.localeCompare(b.owner));
  const status={};
  STATUS_ORDER.forEach(k=>status[k]=0);
  rows.forEach(r=>{const k=statusOf(r)||'not-started';status[k]=(status[k]||0)+1;});
  const lanes=phases.map((ph,i)=>{
    const mine=rows.filter(r=>r.phase===i);
    const mt=mine.filter(r=>!isMs(r)&&r.start&&r.end);
    const ds=[];
    mt.forEach(r=>{ds.push(r.start,r.end);});
    mine.filter(r=>isMs(r)&&r.date).forEach(r=>ds.push(r.date));
    return {i,name:ph.name,color:phaseColor(i),items:mine.length,
            hours:mt.reduce((a,r)=>a+effortHoursOf(r),0), unest:mt.filter(unestimated).length,
            from:ds.length?ds.reduce((m,d)=>dayNum(d)<dayNum(m)?d:m):'',
            to:ds.length?ds.reduce((m,d)=>dayNum(d)>dayNum(m)?d:m):''};
  });
  // Per tag (0.60.0), by EFFECTIVE tags, the same rule the legend filter uses: a row
  // counts under its own tags and its section's. A row with two tags counts under both, so
  // the shares can add up to more than 100%; the rows with no tag at all are one line of
  // their own, so what nobody has classified is as visible as what somebody has.
  const tagRows=usedTags().map(e=>({tag:e.tag,key:e.key,items:0,tasks:0,ms:0,hours:0}));
  const tagIdx=new Map(tagRows.map(t=>[t.key,t]));
  const untagged={items:0,tasks:0,ms:0,hours:0};
  rows.forEach(r=>{
    const ks=effTags(r).map(tagKey);
    const bs=ks.length?ks.map(k=>tagIdx.get(k)).filter(Boolean):[untagged];
    bs.forEach(b=>{ b.items++; if(isMs(r))b.ms++; else if(r.start&&r.end){ b.tasks++; b.hours+=effortHoursOf(r); } });
  });
  const today=anToday();
  // Overdue is "this was due and it did not happen", so it takes a SET of statuses
  // that settle a row rather than the single compare it used to be: complete means it
  // happened, cancelled means it was called off, and neither is late. Everything else,
  // including a blank status, is still owed.
  const SETTLED=new Set(['complete','cancelled']);
  const overdue=rows.filter(r=>{
    const dl=deadlineOf(r);
    return dl&&!SETTLED.has(statusOf(r))&&dayNum(dl)<dayNum(today);
  }).sort((a,b)=>dayNum(deadlineOf(a))-dayNum(deadlineOf(b)));
  const g=depGraph();
  const cyc=topoOrder(g).stuck;
  const noDeps=g.nodes.filter(n=>!n.preds.length&&!n.succs.length).length;
  // WORKING HOURS LEFT is a different question from the sum of the tasks, and the
  // more useful one once a plan is under way: it is the calendar capacity between now
  // and the finish, one person-shift wide, so a plan whose task hours exceed it needs
  // more than one person on it. Counted from today INCLUSIVE when today is itself a
  // working day, because a plan opened on a Monday morning still has that Monday's
  // eight hours in front of it; bizBetween does that inclusion and drops today again
  // when today is a Saturday or a federal holiday. A finished plan has none left.
  const remainBiz=last&&dayNum(today)<=dayNum(last)?bizBetween(today,last):0;
  const remainingHours=remainBiz*HOURS_PER_DAY;
  // The average is over the people who actually carry hours. The unassigned bucket is
  // in NEITHER the numerator nor the divisor: counting it as a person would invent a
  // teammate, and leaving its hours in the numerator while dropping it from the
  // divisor would inflate everyone. So this is the mean load of the named people, and
  // the per-person card beside it is where the outliers show.
  const loaded=people.filter(b=>b.owner&&b.hours>0);
  const assignedHours=loaded.reduce((a,b)=>a+b.hours,0);
  const avgHours=loaded.length?Math.round(assignedHours/loaded.length):0;
  return {rows,tasks,ms:ms.sort((a,b)=>dayNum(a.date)-dayNum(b.date)),totalHours,liveTasks,unest,
          first,last,spanBiz,people,status,lanes,overdue,
          remainBiz,remainingHours,loaded:loaded.length,assignedHours,avgHours,tagRows,untagged,
          deps:{edges:g.edges,isolated:noDeps,cycle:cyc},today};
}
function renderAnalytics(){
  const el=$('anScroll');
  if(!el)return null;
  const m=analyticsModel();
  const card=(cls,title,sub,body)=>'<div class="an-card'+(cls?' '+cls:'')+'"><h3>'+esc(title)+'</h3>'
      +(sub?'<p class="an-sub">'+esc(sub)+'</p>':'')+body+'</div>';
  const stat=(v,u,k)=>'<div class="an-stat"><div class="v">'+esc(v)+(u?'<small>'+esc(u)+'</small>':'')+'</div><div class="k">'+esc(k)+'</div></div>';
  const out=[];

  // 1. the plan in seven numbers: what it costs, what is left, and how it is spread
  out.push(card('wide','Project total',
    'Effort is each task\'s estimate in hours, set in the Table\'s Effort column or the task editor (2d is two 8-hour days). '
    +'A bar is the window the work happens in, not what it costs, so a task nobody has estimated adds nothing to any total here and is counted as not estimated. '
    +'Hours left is the calendar capacity from today to the last dated row, counting today when today is a working day. '
    +'The average divides the hours that have an owner by the '+m.loaded+' '+(m.loaded===1?'person':'people')+' carrying them, so the unassigned bucket is in neither half of it.',
    '<div class="an-hero">'
    +stat(m.totalHours.toLocaleString(),'h','total effort, from estimates')
    +stat((m.liveTasks-m.unest)+' of '+m.liveTasks,'','tasks estimated'+(m.unest?', '+m.unest+' not yet':''))
    +stat(m.remainingHours.toLocaleString(),'h','working hours left to project end'+(m.last?', '+fmt(m.last):''))
    +stat(m.avgHours.toLocaleString(),'h','average per person assigned')
    +stat(String(m.spanBiz),'working days','calendar span'+(m.first?', '+fmt(m.first)+' to '+fmt(m.last):''))
    +stat(String(m.tasks.length),'','dated tasks')
    +stat(String(m.ms.length),'','milestones')
    +stat(String(m.people.length),'','people, including unassigned')
    +'</div>'));

  // 2. per person: the load and the state of it, on ONE row each
  //
  // These were two cards, one for hours and one for the status counts, over the
  // same list of owners in the same order. Reading them meant finding the same name
  // twice and holding the first half in your head, which is exactly the comparison a
  // table exists to do for you. Hours come first because that is what the card is
  // sorted by; the counts follow on the same line.
  const stTot={};
  STATUS_ORDER.forEach(k=>stTot[k]=m.people.reduce((a,b)=>a+(b.status[k]||0),0));
  const ph=m.people.map(b=>'<tr>'
      +'<td class="name'+(b.owner?'':' muted')+'">'+esc(b.owner||'unassigned')+'</td>'
      +'<td style="width:18%">'+anBar(pct(b.hours,m.totalHours))+'</td>'
      +'<td class="n">'+esc(hrs(b.hours))+'</td>'
      +'<td class="n muted">'+pct(b.hours,m.totalHours)+'%</td>'
      +'<td class="n muted">'+b.tasks+(b.ms?' +'+b.ms+'M':'')+'</td>'
      +'<td class="n'+(b.unest?'':' muted')+'">'+b.unest+'</td>'
      +STATUS_ORDER.map(k=>'<td class="n'+((b.status[k]||0)?'':' muted')+'">'+(b.status[k]||0)+'</td>').join('')
      +'</tr>').join('');
  out.push(card('wide','Per person',
    'Every owner in the plan, unassigned included, sorted by hours. Milestones are dates rather than work, so they carry no hours and are counted separately as +nM, but they do carry a status and are counted in the '+STATUS_ORDER.length+' status columns on the right. Cancelled work still counts as an item and still shows in its column; its hours do not, so a cancelled task leaves the count alone and takes its hours out of the share.',
    m.people.length?('<table class="an-table people"><thead><tr><th>Owner</th><th>Share</th><th class="n">Hours</th><th class="n">%</th><th class="n">Items</th><th class="n" title="Live tasks with no Effort estimate. They add nothing to the hours">Not est.</th>'
      +STATUS_ORDER.map(k=>'<th class="n">'+esc(statusTextFor(k))+'</th>').join('')
      +'</tr></thead><tbody>'
      +ph+'<tr class="total"><td>Total</td><td></td><td class="n">'+esc(hrs(m.totalHours))+'</td><td class="n">100%</td><td class="n">'+m.tasks.length+(m.ms.length?' +'+m.ms.length+'M':'')+'</td><td class="n">'+m.unest+'</td>'
      +STATUS_ORDER.map(k=>'<td class="n">'+stTot[k]+'</td>').join('')
      +'</tr></tbody></table>'):'<div class="an-empty">Nothing in this plan carries dates yet.</div>'));

  // 3. status
  const stTotal=Object.values(m.status).reduce((a,b)=>a+b,0);
  const stRows=STATUS_ORDER.map(k=>'<tr><td class="name"><span class="tag '+statusTagFor(k)+'">'+esc(statusTextFor(k))+'</span></td>'
      +'<td style="width:36%">'+anBar(pct(m.status[k]||0,stTotal))+'</td>'
      +'<td class="n">'+(m.status[k]||0)+'</td><td class="n muted">'+pct(m.status[k]||0,stTotal)+'%</td></tr>').join('');
  out.push(card('','Status','Every task and milestone in the plan, by the status someone set on it. A row nobody has touched counts as not started.',
    '<table class="an-table"><thead><tr><th>Status</th><th>Share</th><th class="n">Items</th><th class="n">%</th></tr></thead><tbody>'+stRows+'</tbody></table>'));

  // 4. phases
  const lr=m.lanes.map(l=>'<tr><td class="name"><span class="an-chip" style="background:'+esc(l.color)+'"></span>'+esc(l.name)+'</td>'
      +'<td class="n">'+l.items+'</td><td class="n">'+esc(hrs(l.hours))+'</td>'
      +'<td class="n'+(l.unest?'':' muted')+'">'+l.unest+'</td>'
      +'<td class="n muted">'+(l.from?esc(fmt(l.from))+' to '+esc(fmt(l.to)):'no dates')+'</td></tr>').join('');
  out.push(card('wide','Per section','Items, effort and the range the section actually covers, which is derived from its rows and not declared.',
    m.lanes.length?('<table class="an-table"><thead><tr><th>Section</th><th class="n">Items</th><th class="n">Hours</th><th class="n" title="Live tasks with no Effort estimate">Not est.</th><th class="n">Range</th></tr></thead><tbody>'
      +lr+'<tr class="total"><td>Total</td><td class="n">'+m.rows.length+'</td><td class="n">'+esc(hrs(m.totalHours))+'</td><td class="n">'+m.unest+'</td>'
      +'<td class="n">'+(m.first?esc(fmt(m.first))+' to '+esc(fmt(m.last)):'')+'</td></tr></tbody></table>'):'<div class="an-empty">This plan has no sections.</div>'));

  // 4b. tags (0.60.0), only when the plan has any: a card that says "no tags" on every
  // plan that never uses them is noise.
  if(m.tagRows.length){
    const trow=(name,b,muted)=>'<tr><td class="name'+(muted?' muted':'')+'">'+name+'</td>'
      +'<td style="width:24%">'+anBar(pct(b.hours,m.totalHours))+'</td>'
      +'<td class="n">'+b.tasks+(b.ms?' +'+b.ms+'M':'')+'</td><td class="n">'+esc(hrs(b.hours))+'</td>'
      +'<td class="n muted">'+pct(b.hours,m.totalHours)+'%</td></tr>';
    const tr=m.tagRows.map(t=>trow('<span class="sec-tag an-tag"'+tagAttrs(t.tag)+'>'+esc(t.tag)+'</span>',t)).join('')
      +trow('no tag',m.untagged,true);
    out.push(card('wide','Per tag','Every tag in the plan, counted the way the legend filters: a row carries its own tags and its section\'s. A row with two tags counts under both, so the shares can add up to more than 100%. Cancelled work counts as an item and not in the hours.',
      '<table class="an-table" id="anTags"><thead><tr><th>Tag</th><th>Share of hours</th><th class="n">Items</th><th class="n">Hours</th><th class="n">%</th></tr></thead><tbody>'+tr+'</tbody></table>'));
  }

  // 5. milestones
  const mr=m.ms.map(r=>{const d=daysFromToday(r.date);
    return '<tr><td class="name">'+esc(r.name)+'</td><td class="n">'+esc(fmt(r.date))+'</td>'
      +'<td class="n'+(d!==null&&d<0?' muted':'')+'">'+esc(relDays(d))+'</td>'
      +'<td class="n muted">'+esc(ownerOf(r)||'unassigned')+'</td></tr>';}).join('');
  out.push(card('','Milestones','Counted from today, '+fmt(m.today)+'.',
    m.ms.length?('<table class="an-table"><thead><tr><th>Milestone</th><th class="n">Date</th><th class="n">From today</th><th class="n">Owner</th></tr></thead><tbody>'+mr+'</tbody></table>')
      :'<div class="an-empty">This plan has no dated milestones.</div>'));

  // 6. dependencies
  const dep=m.deps;
  out.push(card('','Dependencies','What waits on what. The graph crosses sections freely, which is why these counts are for the whole plan.',
    '<table class="an-table"><tbody>'
    +'<tr><td class="name">Dependency links</td><td class="n">'+dep.edges+'</td></tr>'
    +'<tr><td class="name">Rows in the graph</td><td class="n">'+(m.rows.filter(r=>(!isMs(r)&&r.start&&r.end)||(isMs(r)&&r.date)).length-dep.isolated)+'</td></tr>'
    +'<tr><td class="name">Rows outside it entirely</td><td class="n">'+dep.isolated+'</td></tr>'
    +(dep.cycle.length?'<tr><td class="name an-warn">In a dependency cycle</td><td class="n an-warn">'+dep.cycle.length+'</td></tr>':'')
    +'</tbody></table>'
    +(dep.cycle.length?'<div class="an-foot an-warn">'+esc(dep.cycle.map(n=>n.r.name||n.id).slice(0,6).join(', '))+' wait on each other, directly or in a ring. Auto-schedule leaves these exactly where they are.</div>':'')));

  // 7. overdue
  const or=m.overdue.map(r=>{const dl=deadlineOf(r),d=daysFromToday(dl);
    return '<tr><td class="name">'+(isMs(r)?'<span class="muted">&#9670; </span>':'')+esc(r.name)+'</td>'
      +'<td class="n">'+esc(ownerOf(r)||'unassigned')+'</td>'
      +'<td class="n">'+esc(fmt(dl))+'</td><td class="n an-late">'+esc(relDays(d))+'</td>'
      +'<td class="n muted">'+esc(statusTextFor(statusOf(r))||'none')+'</td></tr>';}).join('');
  out.push(card('wide',m.overdue.length?('Overdue: '+m.overdue.length+' item'+(m.overdue.length===1?'':'s')):'Overdue',
    'A deadline in the past with a status that is not complete, as of '+fmt(m.today)+'.',
    m.overdue.length?('<table class="an-table"><thead><tr><th>Item</th><th class="n">Owner</th><th class="n">Deadline</th><th class="n">Late by</th><th class="n">Status</th></tr></thead><tbody>'+or+'</tbody></table>')
      :'<div class="an-empty">Nothing is overdue.</div>'));

  el.innerHTML='<div class="an-grid">'+out.join('')+'</div>';
  return m;
}

// ── View switching ──
// Four lenses since 0.58.0. `VIEWS` is the one list: the toggle is built from the
// markup, the preference is validated against this, and an unknown value (an old
// preference, a hand-edited file) falls back to the chart rather than showing nothing.
// viewDefaults() reads it too, which is why it is VIEW_LIST, declared up with the
// prefs: that runs before this line does.
const VIEWS=VIEW_LIST;
function applyView(){
  if(prefs.view==='setup'&&typeof renderChatCard==='function'){ renderShareCard(); renderChatCard(); renderPeopleCard(); }
  const v = VIEWS.includes(prefs.view) ? prefs.view : 'gantt';
  document.documentElement.setAttribute('data-view',v);
  const vt=$('viewToggle');
  if(vt)vt.querySelectorAll('.vt-btn').forEach(b=>{
    const on=b.dataset.view===v;
    b.classList.toggle('on',on);
    b.setAttribute('aria-selected',on?'true':'false');
  });
  // The chart-only controls come and go with the lens, so the header's fit changes.
  if($('hdr')&&$('hdr').firstElementChild)fitHeader();
}
// ── The Google Chat card on Setup (0.67.0) ──
// Read from the server each time the page is shown (GET /notify carries no secret: the
// space is shown as its first characters, the webhook never). The cadence is set here; the
// webhook itself is set on the server.
function agoText(iso){
  const t=Date.parse(iso); if(!t)return '';
  const m=Math.round((Date.now()-t)/60000);
  return m<1?'just now':m<60?m+' min ago':m<1440?Math.round(m/60)+' h ago':Math.round(m/1440)+' days ago';
}
// ── People (0.80.0): owner names linked to Adom accounts, on Setup ──
// The plan's owner picks the account behind each owner name; everybody else sees the list.
// Every name on a row is listed, with how it resolves now: linked here, matched by name,
// matched by a first name, shared by two people (a warning, until it is linked), or no account.
async function renderPeopleCard(){
  const card=$('suPeople'); if(!card)return;
  if(SNAPSHOT||!ADOM_USER||!REQ.on){ card.style.display='none'; return; }
  card.style.display='';
  const names=new Map();
  items.forEach(r=>{ if(isGroup(r))return; const o=rqClean(r.owner); if(!o)return; const k=rqNorm(o); if(!names.has(k))names.set(k,{name:o,rows:0}); names.get(k).rows++; });
  Object.keys(REQ.people||{}).forEach(k=>{ if(!names.has(k))names.set(k,{name:(REQ.people[k]&&REQ.people[k].as)||k,rows:0}); });
  const list=[...names.values()].sort((a,b)=>a.name.localeCompare(b.name));
  const accName=id=>{ const a=REQ.accounts.find(x=>x.id===id); return a?a.name+(a.username?' (@'+a.username+')':''):id; };
  let unlinked=0, shared=0;
  const can=REQ.youOwner;
  const rows=list.map(p=>{
    const res=rqResolve(p.name), k=rqNorm(p.name), link=REQ.people[k];
    if(p.rows&&(res.how==='none'||res.how==='off'))unlinked++;
    if(res.how==='shared')shared++;
    const cur=link&&link.none?'none':link&&link.id?link.id:'';
    const opts=`<option value="">${res.how==='linked'||res.how==='off'?'not linked':res.ids.length===1?'matched: '+esc(accName(res.ids[0])):res.how==='shared'?'not linked (two people)':'not linked'}</option>`
      +REQ.accounts.map(a=>`<option value="${esc(a.id)}"${cur===a.id?' selected':''}>${esc(a.name+(a.username?' (@'+a.username+')':''))}</option>`).join('')
      +`<option value="none"${cur==='none'?' selected':''}>no Adom account</option>`;
    const why=res.how==='shared'?`<div class="pp-why warn">${res.ids.map(accName).map(esc).join(' and ')} share this first name: both are asked until you pick one.</div>`
      :res.how==='none'?`<div class="pp-why">Nobody who has opened this plan answers to it, so work put on it is assigned without asking.</div>`
      :res.how==='first'?`<div class="pp-why">Matched by first name.</div>`:'';
    return `<span class="pp-nm" title="${esc(p.name)}">${esc(p.name)}</span><span class="pp-n">${p.rows}</span>`
      +`<select class="pp-acc" data-name="${esc(p.name)}" aria-label="${esc('Adom account for '+p.name)}"${can?'':' disabled'}>${opts}</select>${why}`;
  });
  $('ppList').innerHTML=list.length?'<span class="pp-h">Name</span><span class="pp-h">Rows</span><span class="pp-h">Adom account</span>'+rows.join(''):'<span class="pp-why">No row has an owner yet.</span>';
  $('ppState').textContent=list.length?`${list.length} name${list.length===1?'':'s'}${unlinked?', '+unlinked+' not linked':''}${shared?', '+shared+' shared first name'+(shared===1?'':'s'):''}`:'';
  $('ppList').querySelectorAll('.pp-acc').forEach(sel=>sel.addEventListener('change',()=>{
    rqSend({op:'people.link',name:sel.dataset.name,account:sel.value===''?null:sel.value});
    const k=rqNorm(sel.dataset.name);
    if(sel.value==='')delete REQ.people[k];
    else if(sel.value==='none')REQ.people[k]={none:true,as:sel.dataset.name};
    else{ const a=REQ.accounts.find(x=>x.id===sel.value)||{}; REQ.people[k]={id:sel.value,name:a.name||'',username:a.username||'',as:sel.dataset.name}; }
    flash(saveInd,sel.value===''?`"${sel.dataset.name}" is not linked`:sel.value==='none'?`"${sel.dataset.name}" has no Adom account: assigned without asking`:`"${sel.dataset.name}" is ${accName(sel.value)}`,4000);
    renderPeopleCard();
    items.forEach(r=>{ if(rqNorm(r.owner)===k){ if(r.type==='milestone')refreshMsChrome(r); else refreshLabelRow(r); } });
  }));
}
async function renderChatCard(){
  const card=$('suChat'); if(!card)return;
  if(SNAPSHOT){ card.style.display='none'; return; }
  let st=null;
  try{ const r=await fetch('notify'); if(r.ok)st=await r.json(); }catch(e){}
  const sel=$('chatCadence'), test=$('chatTest'), stEl=$('chatStatus'), state=$('chatState');
  const hookRow=$('chatHookRow');
  if(!st){ state.textContent='unavailable'; state.className='su-n'; stEl.textContent='The server did not answer.'; sel.disabled=test.disabled=true; hookRow.style.display='none'; return; }
  sel.value=st.cadence; sel.disabled=!st.configured; test.disabled=!st.configured||st.cadence==='off';
  state.textContent=!st.configured?'not set up':st.cadence==='off'?'off':'on'; state.className='su-n'+(st.configured&&st.cadence!=='off'?' on':'');
  // The owner's field (0.68.0): a place to paste a webhook, never a place it is shown.
  hookRow.style.display=st.canSetWebhook?'':'none';
  $('chatHook').placeholder=st.configured?'Paste a new webhook URL to move to another space':'Paste the space\'s incoming webhook URL';
  $('chatHookClear').style.display=st.configured?'':'none';
  if(!st.configured){ stEl.textContent=st.canSetWebhook?'No space is set up for this plan yet. In Google Chat, open the space, then Apps & integrations, Webhooks, and add one; paste its URL here.':'No space is set up for this plan yet. The plan\'s owner sets one up here.'; return; }
  const parts=['Posting to '+st.space+'.'];
  if(st.lastPostAt)parts.push('Last post '+agoText(st.lastPostAt)+'.');
  if(st.pending)parts.push(st.pending+' change'+(st.pending===1?'':'s')+' waiting for the next digest.');
  stEl.innerHTML=esc(parts.join(' '))+(st.lastError?' <span class="bad">The last post failed: '+esc(st.lastError.message||'')+' It will be tried again.</span>':'');
}
function wireChatCard(){
  const sel=$('chatCadence'), test=$('chatTest'); if(!sel||!test)return;
  // A pick, not each step of the arrows, changes the setting: the same list as every other.
  wireAppSelect(sel);
  sel.addEventListener('apppick',async()=>{
    try{ const r=await fetch('notify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cadence:sel.value})});
      flash(saveInd,r.ok?'Chat posting: '+sel.options[sel.selectedIndex].text.toLowerCase():'could not change the Chat setting',3000); }catch(e){ flash(saveInd,'could not reach the server',3000); }
    renderChatCard();
  });
  test.addEventListener('click',async()=>{
    test.disabled=true;
    try{ const r=await fetch('notify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({test:true})});
      const d=await r.json().catch(()=>({}));
      flash(saveInd,r.ok?'test message sent to Google Chat':'the test did not go through: '+(d.error||r.status),4500); }catch(e){ flash(saveInd,'could not reach the server',3000); }
    renderChatCard();
  });
  const hook=$('chatHook'), set=$('chatHookSave'), clr=$('chatHookClear');
  const sendHook=async(value,done)=>{
    try{ const r=await fetch('notify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({webhook:value})});
      const d=await r.json().catch(()=>({}));
      if(r.ok){ hook.value=''; flash(saveInd,done(d),4000); }
      else flash(saveInd,'the webhook was not set: '+(d.error||r.status),4500); }catch(e){ flash(saveInd,'could not reach the server',3000); }
    renderChatCard();
  };
  set.addEventListener('click',()=>{ const v=hook.value.trim(); if(!v){ hook.focus(); return; } sendHook(v,d=>'Google Chat: posting to '+(d.space||'the space')); });
  hook.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); set.click(); } });
  // Two clicks, like every other step that loses something: the URL is not shown again.
  clr.addEventListener('click',()=>{
    if(clr.dataset.armed!=='1'){
      clr.dataset.armed='1'; clr.textContent='Confirm remove';
      setTimeout(()=>{ if(clr.dataset.armed==='1'){ clr.dataset.armed='0'; clr.textContent='Remove webhook'; } },4000);
      return;
    }
    clr.dataset.armed='0'; clr.textContent='Remove webhook';
    sendHook('',()=>'Google Chat: webhook removed');
  });
}
// ── The Sharing card on Setup (0.68.0) ──
// Who can open the plan: only its owner, members of one Adom org, or anyone signed in to
// Adom. The server enforces it on every request; this card shows it and, for the owner,
// changes it. The orgs offered are the owner's own, as read at their sign-in.
// ── Owner and team views (0.73.0) ──
// The plan's owner (and the server's operators) see everything. Everyone else the plan is
// shared with is the team: same plan, same editing, but no Analytics lens and no Sharing or
// Google Chat settings. The owner can preview the team's view from the Sharing card; the
// preview lasts for this tab until "Back to my view".
let viewerIsOwner=true;     // a snapshot, or a server that cannot say, is the owner's view
const PREVIEW_KEY='adom-gantt-preview-member';
function previewingMember(){ try{ return sessionStorage.getItem(PREVIEW_KEY)==='1'; }catch(e){ return false; } }
function applyRole(){
  const member=!viewerIsOwner||previewingMember();
  document.documentElement.dataset.role=member?'member':'owner';
  let bar=$('memberPreview');
  if(viewerIsOwner&&previewingMember()){
    if(!bar){ bar=document.createElement('div'); bar.id='memberPreview';
      bar.innerHTML='<span>You are seeing the plan the way your team does: no Analytics, no Sharing or Google Chat settings.</span><button type="button" class="de-btn primary" id="previewBack">Back to my view</button>';
      document.body.appendChild(bar);
      $('previewBack').addEventListener('click',()=>{ try{ sessionStorage.removeItem(PREVIEW_KEY); }catch(e){} applyRole(); flash(saveInd,'back to your own view'); }); }
  } else if(bar) bar.remove();
  if(member&&prefs.view==='analytics'){ prefs.view='gantt'; applyView(); applyFilter(); }
  document.documentElement.setAttribute('data-crit',critOn()?prefs.critMode:'off');
  if(typeof refreshCritical==='function')refreshCritical();
  renderDatesCard();
  renderCalCard();
}
async function loadRole(){
  if(!SNAPSHOT){ try{ const r=await fetch('access'); if(r.ok){ const a=await r.json(); viewerIsOwner=!!a.canChange; } }catch(e){} }
  applyRole();
}
function shareText(a){ return a.mode==='me'?'only the owner':a.mode==='org'?a.org+' members':'anyone signed in to Adom'; }
async function renderShareCard(){
  const card=$('suShare'); if(!card)return;
  if(SNAPSHOT){ card.style.display='none'; return; }
  let a=null;
  try{ const r=await fetch('access'); if(r.ok)a=await r.json(); }catch(e){}
  const mode=$('shareMode'), org=$('shareOrg'), st=$('shareStatus'), state=$('shareState');
  if(!a){ state.textContent='unavailable'; state.className='su-n'; st.textContent='The server did not answer.'; mode.disabled=org.disabled=true; return; }
  const orgs=[...new Set([a.org].concat((a.you&&a.you.orgs)||[]))];
  org.innerHTML=orgs.map(o=>'<option value="'+esc(o)+'">'+esc(o)+'</option>').join('');
  mode.value=a.mode; org.value=a.org; org.style.display=a.mode==='org'?'':'none';
  mode.disabled=org.disabled=!a.canChange;
  state.textContent=shareText(a); state.className='su-n'+(a.mode==='any'?'':' on');
  const owner=a.owner?(a.owner.displayName||a.owner.username):'';
  const parts=[];
  if(a.authOff)parts.push('Sign-in is off on this server, so this is not enforced here: anyone who can reach it can open the plan.');
  parts.push(owner?'Owner: '+owner+(a.youAreOwner?' (you).':'.'):'No owner is named yet; that is done on the server.');
  if(!a.canChange)parts.push('Only the owner can change who this plan is shared with.');
  else if(a.mode!=='me')parts.push('Changing this takes effect at once, for everyone, including anyone who has the plan open.');
  st.textContent=parts.join(' ');
}
function wireShareCard(){
  const mode=$('shareMode'), org=$('shareOrg'); if(!mode||!org)return;
  wireAppSelect(mode); wireAppSelect(org);
  const pv=$('sharePreview');
  if(pv)pv.addEventListener('click',()=>{ try{ sessionStorage.setItem(PREVIEW_KEY,'1'); }catch(e){} applyRole(); setView('gantt'); });
  const send=async body=>{
    try{ const r=await fetch('access',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
      const d=await r.json().catch(()=>({}));
      flash(saveInd,r.ok?'sharing: '+shareText(d):'sharing was not changed: '+(d.error||r.status),4500); }catch(e){ flash(saveInd,'could not reach the server',3000); }
    renderShareCard();
  };
  mode.addEventListener('apppick',()=>{ const b={mode:mode.value}; if(mode.value==='org')b.org=org.value||'adom'; send(b); });
  org.addEventListener('apppick',()=>send({mode:'org',org:org.value}));
}
function setView(v){
  if(!VIEWS.includes(v))return;
  if(v==='analytics'&&document.documentElement.dataset.role==='member')return;
  if(prefs.view===v)return;
  // The marks live on chart rows. Leaving the lens leaves the question behind too,
  // rather than carrying an armed delete into a view that never showed it.
  disarmDelete();
  closeQuickAdd(true);
  if(v!=='gantt'&&depMode)setDepMode(false);   // the mode is a chart mode
  prefs.view=v;savePrefs();applyView();
  // Analytics is derived from the model, so it is rendered on the way IN and never
  // kept warm: whatever changed in another lens is already in `items`, and reading
  // it once here is cheaper and less to get wrong than keeping a second copy live.
  if(v==='analytics'){renderAnalytics();return;}
  // Setup likewise: the row counts and date ranges are read from `items` on the way in.
  if(v==='setup'){renderSections(true);return;}
  if(v==='table'){renderTable();return;}
  // The chart measured nothing while it was hidden, so lay it out from scratch
  // once it is back on screen. Through the app's own debounced path with the echo
  // guard invalidated, not a raw rAF: the pane reports the same size it did
  // before, and a rAF here proved unreliable, which is how milestone caps
  // renamed or deleted in the table came back overlapping.
  lastPaneW=lastPaneH=lastRelayoutW=-1;
  scheduleRelayout();
  // The bars and the arrows were rebuilt while the glow was off screen, so it goes
  // back on with them. Deferred behind the relayout, since the arrows do not exist
  // until it has run.
  if(critOn())setTimeout(()=>refreshCritical(),0);
}
// ── The two plan-level buttons ──
//
// Both are statements about the dependency graph, so both are meaningless without
// one and both say so in the same way rather than failing quietly when pressed. The
// state is re-read whenever the graph can have changed, which is every save.
//
// The live tooltip is a CONSTANT, and the markup carries the same words so the button
// reads correctly before the first sync as well as after it. It was written out twice
// through 0.52.0 and the two copies could drift; 0.53.0 rewrote the sentence and that is
// exactly the kind of edit that would have left one of them behind, so there is one
// string now and the markup is checked against it below.
const AUTO_TITLE='Align dependencies by pushing later tasks out so each one starts when '
  +'the work it waits on finishes, skipping weekends and the plan\'s days off. Never pulls '
  +'work earlier, so intentional lead-time gaps are preserved and cancelled work is left out';
function syncPlanCtl(){
  const has=planHasDeps();
  const a=$('btnAuto'), c=$('btnCrit');
  if(a){
    // A pass in flight owns the button. syncPlanCtl runs on every save and the pass
    // itself saves, so without this the button would re-enable under its own ring
    // halfway through and take a second click. endBusy calls back here afterwards,
    // which is where the plan's real answer lands.
    if(!a.classList.contains('btn-busy'))a.disabled=!has;
    a.title=has?AUTO_TITLE
               :'Auto-schedule needs dependencies: nothing in this plan waits on anything yet';
  }
  if(c){
    // Still gated on the plan having edges at all. The definition changed, but the
    // question did not: a PATH is a run of work through dependencies, and a plan where
    // nothing waits on anything has no runs to be longest. The message is the one it
    // has always been, for the same reason.
    c.disabled=!has;
    c.title=has?(CRIT_TITLE[prefs.critMode]||CRIT_TITLE.off)
               :'The critical path needs dependencies: nothing in this plan waits on anything yet';
    c.setAttribute('aria-pressed',critOn()?'true':'false');
    c.classList.toggle('on',critOn());
  }
}
function wirePlanCtl(){
  const a=$('btnAuto'), c=$('btnCrit');
  const team=()=>document.documentElement.dataset.role==='member';
  if(a)a.addEventListener('click',()=>{ if(!a.disabled&&!team())runAutoScheduleBusy(); });
  if(c)c.addEventListener('click',()=>{ if(!c.disabled&&!team())toggleCritical(); });
  syncPlanCtl();
}
async function startApp(){
$('viewToggle').addEventListener('click',e=>{const b=e.target.closest('.vt-btn');if(b)setView(b.dataset.view);});

// ── Init ──
// A snapshot carries its state already merged into its data, and has no server to ask
// (0.75.1: guarded here, not only by the export's rewrite of this line, which an edit to the
// line silently stopped matching, so snapshots fetched a state that was not there).
if(!SNAPSHOT)try{const r=await fetch('state');if(r.ok){const st=await r.json();if(!st.empty){baseRev=typeof st.stateRev==='number'?st.stateRev:0;rqLoad(st.requests,st.people,st.notices,st.delegates);withDeps(()=>{applyState(st);updateBars();});}else baseRev=0;}}catch(e){}
// What the server holds, as of now: the baseline every later change is measured against.
initShadow();
// Whether requests are on, and whether this person is the plan's owner (0.80.0).
rqFetch();
// The sidecar can carry a status or a description that the chart's own label rows
// and tooltips were built from the data file with, so restate them.
items.forEach(r=>{ if(r.type==='milestone')refreshMsChrome(r); else if(r.id)refreshLabelRow(r); });
// The chevron counts were written during the build, before the sidecar was merged,
// so they reported the data file's lanes and not the plan's. Re-read them now that
// the created rows, the deletions and the phase overrides are all in.
// The tags are the plan's from here on (the sidecar can add or clear them), so the
// saved tag filter is checked against them now, and the legend is rebuilt with them.
tagsReady=true;
pruneTagFilter();
syncTagList();
tagSig=tagSigNow();
rebuildLegend();
applyCollapse();
// Re-baseline AFTER restoring the sidecar, so replaying saved edits on load is
// not mistaken for a fresh round of user changes.
lastSnap=snapSemantic();
startLive();
wireChatCard();
wireShareCard();
wireDatesCard();
wireCalCard();
await loadRole();
openRowLink();
window.addEventListener('hashchange',openRowLink);
// The page is built and the plan is in. The suite waits for this rather than for the
// network to go quiet, which it never does once the live stream is open (0.66.0).
document.documentElement.dataset.ready='1';
// The rev used to sit in a floating chip over the chart, where it collided with
// the milestone labels at the bottom of a tall plan. It is machinery for the AI
// handoff loop, not something a reader needs on the canvas, and it stays fully
// available at GET /changelog (and ?format=md). The header carries the app
// version instead, which is the thing a person actually wants to glance at.
// The sidecar may carry shifted dates, so reshape the axis before the first paint.
rebuildTimeline();
sizeTailroom();
drawDeps();
// "What's mine" is a standing question, so the owner filter survives a reload
// (syncOwnerPick picks the saved owner filter up on this first render).
renderTable();
// The sidecar can add or drop a dependency, so what the two plan buttons are allowed
// to do is only settled once it is in.
syncPlanCtl();
// The saved critical-path mode comes back with the plan, the way the saved milestone
// style does. It is PAINTED rather than re-entered: setCritical would flash the status
// line, and a message about a chart the reader has only just opened is noise.
//
// A plan with no dependencies at all cannot carry a path, so a mode saved on some other
// chart is dropped for this one. Dropped in memory only, deliberately: the preference is
// one key for every plan this browser opens, and opening a dependency-free chart must
// not silently un-choose the mode for the plan the reader was actually working on.
if(critOn()&&!planHasDeps()){
  prefs.critMode='off';
  document.documentElement.setAttribute('data-crit','off');
  syncPlanCtl();
}
if(critOn())refreshCritical();
if(prefs.view==='analytics')renderAnalytics();
if(prefs.view==='setup')renderSections();
healStoredPrefs();
rebasePrefs();
// Say it after the first paint, so the message sits over a chart that clearly
// still works rather than over a blank page.
if(UNDATED.length){
  const dw=$('dataWarn');
  const names=UNDATED.map(r=>r.id||r.name||r.label||'(unnamed)');
  dw.innerHTML=`<button class="dw-close" title="Dismiss">&times;</button>
    <div class="dw-title">${UNDATED.length} row${UNDATED.length>1?'s':''} left out of this chart</div>
    <div>A task needs a start and an end; a milestone needs a date. The rest of the plan is drawn and editable, and these rows are untouched in the data file.</div>
    <div class="dw-rows">${esc(names.slice(0,12).join(', '))}${names.length>12?', and '+(names.length-12)+' more':''}</div>`;
  dw.classList.add('show');
  dw.querySelector('.dw-close').addEventListener('click',()=>dw.classList.remove('show'));
}
showImportReport();
}


// ── Startup ──
//
// Everything above this line declares or defines. Nothing above it runs on its own,
// and this is the only ordered sequence in the module: it is the whole of startup.
//
// The file used to open with a two-hundred-statement imperative prologue interleaved
// with the declarations it depended on, which put three separate shipped-or-caught
// bugs in the same temporal dead zone (STATUS_ORDER in 0.11.0, collapsed and prefs
// in 0.14.0): a statement near the top ran before a const further down existed, the
// module threw part-way through the build, and the symptom was a chart with labels
// and no bars rather than anything that named the real cause. Function declarations
// hoist, so wrapping each block where it already sat takes file position out of the
// question entirely: by the time any of these run, every declaration in the module
// has been initialised. Anything genuinely order-dependent is declared bare and
// assigned inside its step (W, lastSnap, the relayout echo guard's baseline).
async function init(){
  // Sections first: a legacy file's group rows become phase annotations and stop
  // being rows, then exactly one derived header per phase takes their place.
  absorbLegacyGroups();
  buildDerivedHeaders();
  placeHeaders();
  assignRowKeys();
  giveSnapshotMilestoneIds();
  dropUndatedRows();
  clampFontScalePref();
  clampMsStylePref();
  clampCritModePref();
  clampTagFilterPref();
  applyStartupPrefs();
  wireLaneToggle();
  initTimeRange();
  buildHeader();
  wireGlobalKeys();
  captureBaselineSnapshot();
  wireSaveFlush();
  buildLegendCollapseAll();
  wireLegendTags();
  wireLegendOwner();
  wireOverdueClock();
  buildLegendPhases();
  buildLegendMilestone();
  buildLegendDeps();
  buildLegendToday();
  fitTimelineToPane();
  mountQHeaderSpacer();
  mountQRow();
  mountMRow();
  sizeBarsPane();
  mountGridLayer();
  buildTimeAxis();
  buildRows();
  mountTodayLine();
  mountBarTailroom();
  mountLabelTailroom();
  wireChartWheel();
  wireRelayoutObservers();
  wireDepRedraw();
  wireLabelClicks();
  mountLabelDropIndicator();
  mountBarDropIndicator();
  wireLabelDragArm();
  wireBarClicks();
  wireBarDrags();
  wireBarDblclick();
  wireTooltipsAndExport();
  wireImportAndHelp();
  wireSetup();
  wireThemeButton();
  wireMsStyleAndGrain();
  wireDepMode();
  wireWhoChip();
  wireRequests();
  wireNewButton();
  wireFontScale();
  wirePlanSpanClock();
  wireHeaderFit();
  wireFitButton();
  wirePlanCtl();
  wireLabelColumnResize();
  wireEditorDrag();
  wireEditorTab();
  wireEditorFit();
  restoreTableSort();
  wireLaneDrag();
  wireTableColumnResize();
  wireTableRowDrag();
  wireRowDragClickGuard();
  wireTableHeadAndDelete();
  wireCopyIds();
  buildStatusFilter();      // its options exist before anything can listen for a change
  wireTableFilters();
  await startApp();
}
await init();
})();
</script>
</body>
</html>