/* ==========================================================================
   Project Portal - the design system.
   This file IS the design system. Every page is built from the tokens and
   components below; no page carries its own copy of a shared style, and no
   page introduces a colour, radius or spacing value that is not defined here.
   Colours, fonts and usage rules come from brand/DESIGN.md, which is the
   canonical source (the brand guidelines PDF beats both).
   ========================================================================== */

/* ── Brand faces, served from this origin ─────────────────────
   SELF-HOSTED ON PURPOSE, AND THE CSP DEPENDS ON IT. The portal used to pull
   these from fonts.googleapis.com / fonts.gstatic.com, which meant three public
   pages (the sign-off, the survey and the shared plan) made a customer's
   browser announce its IP address and user agent to a third party before the
   customer had agreed to anything. The files below removed that request, which
   is what let styleSrc and fontSrc in server/app.js collapse to 'self'. Adding
   a font back from a CDN would mean loosening the policy again; do not.

   ONLY THE WEIGHTS THE DESIGN SYSTEM ACTUALLY USES:
     - Poppins 600 and 700 are the only weights any --font-heading rule sets.
     - Open Sans is one variable file per subset covering 400 to 700, which is
       cheaper than the four static instances it replaces (Google serves the
       same file for each of them) and covers the 500 that .nav .group a wants.
   The Google links also asked for Poppins 500 and Open Sans 300. Nothing has
   ever set either, so they are not here. If a rule ever needs one, add the file
   rather than letting the browser fake it.

   Latin and latin-ext are split on the same unicode-range Google uses, so
   latin-ext is only fetched by a page that actually prints an accented name.
   font-display: swap: the text is readable in the fallback while the face
   loads, which matters most on the customer pages, where the reader arrived
   from an email and is being asked to sign something.

   Both families are SIL Open Font License 1.1; the licence text ships beside
   the files. See assets/fonts/README.md for provenance and how to refresh. */

@font-face {
  font-family: 'Open Sans';
  font-style: normal;
  font-weight: 400 700;
  font-stretch: 100%;
  font-display: swap;
  src: url('/assets/fonts/open-sans-400-700-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Open Sans';
  font-style: normal;
  font-weight: 400 700;
  font-stretch: 100%;
  font-display: swap;
  src: url('/assets/fonts/open-sans-400-700-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Poppins';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/assets/fonts/poppins-600-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Poppins';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/assets/fonts/poppins-600-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Poppins';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('/assets/fonts/poppins-700-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Poppins';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('/assets/fonts/poppins-700-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  /* Brand primary */
  --dufeu-blue:   #6CACE4;
  --dufeu-green:  #96D404;
  /* Brand secondary */
  --dufeu-teal:   #127691;
  --dufeu-indigo: #1C2133;
  --dufeu-iris:   #6477BA;
  /* Brand greys */
  --grey-dark:    #4E4E4F;
  --grey-medium:  #818285;
  --grey-light:   #BEBEBF;
  --off-white:    #F2F2F2;

  /* Surfaces derived from the brand palette */
  --bg:        var(--off-white);
  --panel:     #FFFFFF;
  --line:      var(--grey-light);
  --line-soft: #E5E5E6;
  /* The rule BETWEEN ROWS of a table, sitting between the two above. Neil,
     2026-08-25: "I think we need to darken the lines between the tasks as
     well". `--line-soft` was faint enough that a long plan read as one block of
     text rather than as rows, and `--line` is heavy enough to turn a table into
     a grid. A token rather than a literal, so the next table to want it does not
     invent a fourth grey. */
  --line-mid:  #C9C9CC;
  --text:      var(--dufeu-indigo);
  --text-dim:  var(--grey-dark);
  --text-mute: var(--grey-medium);

  /* Status. Amber and red are outside the brand palette by necessity: the
     brand has no warning or danger colour, and Dufeu Green must keep meaning
     "good" rather than being spent on a neutral accent. */
  --ok:     var(--dufeu-green);
  --warn:   #B26B00;
  --danger: #C0392B;

  /* Impact, and it is a FOUR-STEP SCALE rather than a set of states, which is
     why it gets its own hues instead of borrowing the status ones. Green to
     yellow to orange to red is the ramp everybody already reads on a risk key,
     and the two middle steps need enough hue between them to be told apart at
     chip size on paper. `--warn` is not reused for the orange: it is an amber
     that sits too close to the yellow to be a separate step. */
  --impact-none:   var(--dufeu-green);
  --impact-low:    #D6B200;
  --impact-medium: #D66A00;
  --impact-high:   var(--danger);

  /* A WHOLE PLAN ROW TINTED BY HOW FAR THE TASK HAS GOT. Neil, 2026-08-26:
     "Completed makes that line go green and In-progress makes that line go
     yellow."

     THE HUES ARE ALREADY HERE: Dufeu Green (--dufeu-green) and the impact
     yellow (--impact-low). Written as rgba of those two values rather than as
     var(), because a channel list cannot be lifted out of a token, and that is
     how every other tint in this file is expressed.

     0.16 IS NOT AN ARBITRARY ALPHA. A row wash sits UNDER the chips in that row
     and must not swallow them. Measured against the four impact chips and the
     three location bubbles, all of which are opaque fills: at 0.16 the closest
     pair is the orange Medium chip on a yellow row, still a clearly separate
     patch (CIE dE 8.3), and everything else is further off. Pushed to 0.24 the
     Low chip closes to dE 6.7 and starts to disappear into the row, which is
     the wall this value is standing back from. Body text is untouched at
     14.5:1 and 14.3:1.

     NEITHER TINT IS THE SIGNAL ON ITS OWN. Every marked row also carries a chip
     with the word on it, exactly as the Impact column does, so the state reads
     for somebody who cannot separate the two hues and survives a greyscale
     printout. */
  --plan-row-done:  rgba(150, 212, 4, 0.16);
  --plan-row-doing: rgba(214, 178, 0, 0.16);

  --font-heading: "Poppins", system-ui, -apple-system, sans-serif;
  --font-body:    "Open Sans", system-ui, -apple-system, sans-serif;

  --radius:    10px;
  --radius-sm: 8px;
  --radius-pill: 999px;

  --shadow-card:  0 1px 2px rgba(28, 33, 51, 0.05);
  --shadow-hover: 0 4px 12px rgba(28, 33, 51, 0.08);
  --shadow-pop:   0 12px 32px rgba(28, 33, 51, 0.16);

  /* Brand gradients. Backgrounds and large shapes only, never text. */
  --gradient-primary: linear-gradient(90deg, #96D404 0%, #6CACE4 100%);
  --gradient-extended: linear-gradient(90deg, #96D404 0%, #6FC2BC 33%, #6CACE4 66%, #6477BA 100%);
}

/* ── Base ─────────────────────────────────────────────────── */

*, *::before, *::after { box-sizing: border-box; }

/* The `hidden` attribute must win. The browser's own rule for it is a bare
   attribute selector, so any class here that sets `display` (every .btn, for
   one) would otherwise out-specify it and leave a hidden element on screen. */
[hidden] { display: none !important; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  /* Dark Grey for body text, not black. See brand/DESIGN.md. */
  color: var(--text-dim);
  font-family: var(--font-body);
  font-size: 15px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3, h4 {
  font-family: var(--font-heading);
  color: var(--text);
  margin: 0;
  font-weight: 600;
}
h1 { font-size: 24px; font-weight: 700; letter-spacing: -0.01em; }
h2 { font-size: 18px; }
h3 { font-size: 15px; }

a { color: var(--dufeu-teal); text-decoration: none; }
a:hover { text-decoration: underline; }

.mono { font-variant-numeric: tabular-nums; }
.muted { color: var(--text-mute); }
.nowrap { white-space: nowrap; }
/* Text somebody typed into a textarea, shown back to them with the line breaks
   they typed. pre-line rather than pre-wrap, so the indentation of the markup
   this sits in cannot become part of what is read. */
.prewrap { white-space: pre-line; overflow-wrap: anywhere; }
/* A short bulleted list of typed lines. Bullets kept, because the customer's own
   page draws these as a list and a staff view that ran them together would not
   be showing the same thing. */
.bullets { margin: 0; padding-left: 18px; }
.bullets li { margin: 0 0 2px; overflow-wrap: anywhere; }

/* Visible to a screen reader, not on screen. For a column header that names an
   actions column the sighted user reads from context. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}

/* One visible focus treatment everywhere, keyboard only. */
:focus-visible {
  outline: 2px solid var(--dufeu-teal);
  outline-offset: 2px;
  border-radius: 4px;
}

/* A HEADING IS NOT A CONTROL, SO IT MUST NOT WEAR A CONTROL'S RING.

   Several pages move focus to the heading of whatever they have just drawn, so
   a screen reader reads the new content instead of being left where the old
   page was: the customer's plan (js/plan-view.js), the Data Migration Plan
   (portal/archive-view.html), the version comparison (portal/plan-diff.html),
   the sign-off and the survey all do it. tabindex="-1" is what makes that
   possible, and it means a focus TARGET, never a tab stop: nothing here is
   reachable by Tab and nothing here can be pressed.

   The rule above was still following them, and the reason is worth writing down
   because it looks like a browser bug and is not. :focus-visible is applied to a
   PROGRAMMATIC focus whenever the visitor's last interaction was the keyboard,
   and typing or pasting a URL and pressing Enter is exactly that. So somebody
   opening a shared link met a teal box around the words "Project plan" that read
   as a button already pressed, and it vanished the moment they clicked anywhere.
   Neil, 2026-08-25: "When share link is browsed the Project plan has a box
   around it like it's been pressed, when click the screen it goes away."

   THE FOCUS STILL MOVES. Only the outline goes, and only on a heading, so the
   accessibility behaviour this whole arrangement exists for is untouched. WCAG
   2.2 SC 2.4.7 asks for a visible indicator on a keyboard-OPERABLE component,
   which a heading nobody can Tab to or activate is not.

   HEADINGS ONLY, on purpose. A <div tabindex="-1"> is a different thing (the
   plan page's approval notice, the phase picker's region) and keeps its ring. */
:is(h1, h2, h3, h4, h5, h6)[tabindex="-1"]:focus,
:is(h1, h2, h3, h4, h5, h6)[tabindex="-1"]:focus-visible {
  outline: none;
}

/* ── Layout shell ─────────────────────────────────────────── */

/* Sidebar navigation down the left, content to the right of it. The sidebar is
   its own scroll context and stays put while the content scrolls. */
.shell { min-height: 100vh; display: flex; }

.sidebar {
  position: sticky; top: 0; z-index: 40;
  flex-shrink: 0; width: 236px; height: 100vh;
  display: flex; flex-direction: column;
  background: var(--dufeu-indigo);
}

/* Brand gradient down the sidebar's trailing edge. A background shape, which
   is the only thing the brand gradient is for. */
.sidebar::after {
  content: ""; position: absolute; top: 0; right: 0; bottom: 0; width: 3px;
  background: linear-gradient(180deg, #96D404 0%, #6FC2BC 33%, #6CACE4 66%, #6477BA 100%);
}

.content { flex: 1; min-width: 0; display: flex; flex-direction: column; }

.brand {
  display: flex; flex-direction: column; align-items: flex-start; gap: 7px;
  /* The logo clearspace rule: at least the height of the lowercase 'd', which
     is roughly 40% of a 26px horizontal logo, so about 11px on every side.
     18px clears it with room to spare. */
  padding: 18px 18px 16px;
}
.brand img { height: 26px; width: auto; display: block; }
/* The logo already says Dufeu, so the wordmark beside it carries only the
   product half of the name. A reader sees "Dufeu Project Portal" without the
   word Dufeu being printed twice. The full customer-facing form is used on the
   sign-in page and the customer pages, where there is no logo context. */
.brand .app-name {
  font-family: var(--font-heading);
  font-weight: 600;
  font-size: 13px;
  letter-spacing: 0.02em;
  color: rgba(255, 255, 255, 0.82);
  padding-left: 2px;
}
/* The masthead is a link to Today, the page the app opens on. It takes no
   colour or underline from the global anchor rule: the wordmark is white-on-
   indigo type, not body copy, and an underline appearing under it on hover
   would read as a defect. The hover brightening is the same one .nav a already
   uses, so the logo behaves like the rows beneath it. */
a.brand { color: inherit; }
a.brand:hover { text-decoration: none; }
a.brand:hover .app-name { color: #FFFFFF; }

.nav { flex: 1; overflow-y: auto; padding: 6px 10px 14px; }
.nav a {
  display: flex; align-items: center; gap: 10px;
  font-size: 14px; font-weight: 600;
  color: rgba(255, 255, 255, 0.72);
  padding: 8px 11px; margin-bottom: 2px;
  border-radius: var(--radius-sm);
  text-decoration: none;
  transition: background 0.12s, color 0.12s;
}
.nav a:hover { color: #FFFFFF; background: rgba(255, 255, 255, 0.08); text-decoration: none; }
/* Indigo ink on Dufeu Blue: white on blue is about 2.4:1 and fails AA. */
.nav a[aria-current="page"] { color: var(--dufeu-indigo); background: var(--dufeu-blue); }
.nav a svg { width: 17px; height: 17px; flex-shrink: 0; stroke-width: 1.9; }

/* A group heading: a label for the pages nested under it, not a link. */
.nav .group-label {
  font-family: var(--font-heading);
  font-size: 11px; font-weight: 600; letter-spacing: 0.07em; text-transform: uppercase;
  color: rgba(255, 255, 255, 0.42);
  padding: 15px 11px 6px;
}
.nav .group a { padding-left: 22px; font-weight: 500; }

/* A row now, because the notification control sits beside the name. Still the
   positioning context for both pop-outs, which are out of flow. */
.usermenu {
  position: relative; padding: 10px; border-top: 1px solid rgba(255, 255, 255, 0.12);
  display: flex; align-items: center; gap: 4px;
}
.usermenu-btn {
  display: flex; align-items: center; gap: 10px; width: 100%;
  /* Gives the ellipsis on .label something to happen inside, so a long name
     shortens instead of pushing the bell off the sidebar. */
  flex: 1; min-width: 0;
  background: none; border: 0; cursor: pointer; text-align: left;
  font-family: inherit; font-size: 13.5px; color: #FFFFFF;
  padding: 7px 8px; border-radius: var(--radius-sm);
}
.usermenu-btn:hover { background: rgba(255, 255, 255, 0.08); }
.usermenu-btn .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.avatar {
  width: 28px; height: 28px; border-radius: 50%;
  background: var(--dufeu-blue); color: var(--dufeu-indigo);
  font-family: var(--font-heading); font-weight: 600; font-size: 12px;
  display: grid; place-items: center; flex-shrink: 0;
}
.usermenu-pop {
  position: absolute; left: 10px; right: 10px; bottom: calc(100% - 2px); z-index: 50;
  padding: 6px;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: 11px; box-shadow: var(--shadow-pop);
}
.usermenu-pop .who { padding: 9px 10px 10px; border-bottom: 1px solid var(--line-soft); margin-bottom: 5px; }
.usermenu-pop .who strong { display: block; color: var(--text); font-size: 13.5px; }
.usermenu-pop .who span { font-size: 12px; color: var(--text-mute); word-break: break-all; }
.usermenu-pop form { margin: 0; }
/* The rows of the pop-out. Admin is a link and Sign out is a form submit, but
   they are the same row to look at and to hit, so they share one rule rather
   than two that drift. */
.usermenu-pop a.adminlink,
.usermenu-pop button.signout {
  display: block;
  width: 100%; text-align: left; background: none; border: 0; cursor: pointer;
  font-family: inherit; font-size: 13.5px; color: var(--text-dim);
  padding: 8px 10px; border-radius: var(--radius-sm);
  text-decoration: none;
}
.usermenu-pop a.adminlink { margin-bottom: 2px; }
.usermenu-pop a.adminlink:hover { background: var(--off-white); color: var(--text); text-decoration: none; }
/* SIGN OUT IS THE ROW THAT ENDS SOMETHING, and it says so the same two ways
   `.menu-item.danger` already says it in every actions menu in this
   application: the danger ink, and a rule setting it apart from the rows above.
   No second separator pattern enters the system for it. The rule is
   --line-soft, which is the same 1px the `.who` block above already draws under
   the signed-in name, so the pop-out has one divider colour rather than two.
   --danger on --panel is 5.44:1 and on the hover wash 4.83:1, both past AA for
   text at 13.5px. The hover keeps the red rather than repainting it in the
   ordinary ink, exactly as `.menu-item.danger:hover` does; the global
   :focus-visible outline sets no colour, so the keyboard keeps it too. */
.usermenu-pop button.signout { color: var(--danger); }
.usermenu-pop button.signout:hover { background: rgba(192, 57, 43, 0.08); color: var(--danger); }
/* THE DIVIDER ONLY EXISTS WHEN THERE IS SOMETHING ABOVE IT TO DIVIDE. Admin is
   gated on four permissions, so for most people Sign out is the only row here,
   and a lone row under a rule reads as the remains of a list: the same defect
   `.context-host .menu-item.danger:first-child` exists to undo. Drawn off the
   adjacent sibling, so the empty case needs no class and nothing has to be told
   which case it is in. The top corners square off against the rule, again as
   `.menu-item.danger` does, so the hover fill does not curl away from it. */
.usermenu-pop a.adminlink + form button.signout {
  margin-top: 5px; padding-top: 11px; border-top: 1px solid var(--line-soft);
  border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
/* The one row in here that is a PLACE, so it says when you are already there.
   Indigo ink on Dufeu Blue, the pairing .nav a[aria-current] uses, because
   this row took over the job that entry used to do. */
.usermenu-pop a[aria-current="page"] {
  background: var(--dufeu-blue); color: var(--dufeu-indigo);
}
.usermenu-pop a[aria-current="page"]:hover {
  background: var(--dufeu-blue); color: var(--dufeu-indigo);
}

/* ── The notification control and its panel ────────────────── */
/*
   The bell beside the signed-in name, carrying the unread count, and the panel
   it opens. The panel IS .usermenu-pop with a size on it: the sidebar already
   has a pop-out that opens upwards from its foot, and a second shape there
   would be a second answer to the same question.
*/
.notif-btn {
  position: relative; flex-shrink: 0;
  display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px;
  background: none; border: 0; cursor: pointer; color: #FFFFFF;
  border-radius: var(--radius-sm);
}
.notif-btn:hover { background: rgba(255, 255, 255, 0.08); }
.notif-btn[hidden] { display: none; }
.notif-btn svg { width: 18px; height: 18px; stroke-width: 1.6; }
/* Over the bell's shoulder rather than after it, so the control stays one
   square target and the count does not widen the row as it grows. */
.notif-btn .nav-badge-host { position: absolute; top: 0; right: 0; margin-left: 0; }

/* Wider than the sidebar, because a line names a company and a project and the
   sidebar is 236px. Clamped to the viewport so the drawer form on a phone
   cannot push it off screen. 320px is about 40 characters, which is what the
   two-line row below is sized against. */
.notif-pop {
  left: 10px; right: auto; width: min(320px, calc(100vw - 24px)); padding: 8px;
}
.notif-head {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 5px 6px 8px; border-bottom: 1px solid var(--line-soft); margin-bottom: 4px;
}
.notif-head strong { color: var(--text); font-size: 13.5px; }
/* The same link-button as .dd-menu .lnk, which is the pattern for a secondary
   action inside a pop-out. */
.notif-pop .lnk {
  background: none; border: 0; padding: 4px 2px; cursor: pointer;
  font-family: inherit; font-size: 12.5px; font-weight: 600; color: var(--dufeu-teal);
}
.notif-pop .lnk:hover { text-decoration: underline; }
.notif-pop .lnk:disabled { color: var(--text-mute); cursor: not-allowed; text-decoration: none; }
.notif-pop .lnk[hidden] { display: none; }

.notif-list { max-height: 320px; overflow-y: auto; }
/* A STATEMENT, NOT A CONTROL. The row is a div on both surfaces and nothing in
   it is pressable, so it carries no hover, no focus ring and no pointer cursor:
   an affordance here would promise a destination that does not exist. It was an
   anchor with a hover tint until 2026-08-27. */
.notif-item {
  display: block; padding: 8px 10px; border-radius: var(--radius-sm);
  color: var(--text-dim); cursor: default;
}
/* TWO LINES, the same shape a two-line table cell has (`.name` over `.sub`):
   the headline says what happened, the detail underneath names the project.
   The headline is the short fixed phrase alone, so it never wraps. */
.notif-item .line { display: block; font-size: 13px; font-weight: 600; }
/* THE PROJECT NAME, CLIPPED TO ONE LINE. The panel is 320px, and a real
   Autotask project name ran a row to three or four lines before this. The full
   text is on the span's title, so nothing is lost by the ellipsis. */
.notif-item .sub {
  display: block; margin-top: 1px;
  font-size: 12.5px; color: var(--text-mute);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.notif-item .when {
  display: block; margin-top: 2px;
  font-size: 12px; color: var(--text-mute); font-variant-numeric: tabular-nums;
}
/* Unread is weight and a marker, never colour alone. The dot repeats what the
   count says, so a reader who cannot tell the two weights apart still can. */
.notif-item.unread .line { font-weight: 700; color: var(--text); }
.notif-item.unread { position: relative; padding-left: 20px; }
.notif-item.unread::before {
  content: ""; position: absolute; left: 8px; top: 13px;
  width: 6px; height: 6px; border-radius: var(--radius-pill);
  background: var(--dufeu-teal);
}
.notif-empty { margin: 0; padding: 14px 10px; color: var(--text-mute); font-size: 13.5px; }

/* Mobile-only bar carrying the sidebar toggle. Hidden on desktop. */
.mobilebar { display: none; }
.scrim { display: none; }

.main { flex: 1; width: 100%; max-width: 1180px; padding: 26px 28px 56px; }

.page-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 16px; flex-wrap: wrap; margin-bottom: 20px;
}
/* THE ACTIONS SIT AT THE RIGHT OF A HEAD, however many other children it has.
   `justify-content: space-between` only does that when there are exactly TWO
   children: a head carrying the actions ALONE (no title beside them) collapses
   to flex-start and the button lands on the LEFT, which is the defect found on
   portal/archive-view.html. `margin-left: auto` states the rule the layout
   actually wants, so a head with one child, two or three all put the actions in
   the same place and no page needs a local override to push them across. */
.page-head .actions { display: flex; align-items: center; gap: 10px; margin-left: auto; }
/* The same rule for a card's head, for the same reason: the actions belong at
   the right edge whether or not an <h2> shares the row with them. */
.card-head .actions { margin-left: auto; }

/* ── Cards ────────────────────────────────────────────────── */

.card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow-card);
}
.card + .card { margin-top: 18px; }
/* Except inside a grid, where `gap` already owns the spacing. The stacking
   margin above is for cards following one another down a page; applied to a
   grid item it pushes every card but the first down by 18px, which reads as
   one tile hanging lower than its neighbour. Found on the dashboard tiles,
   2026-08-11. */
.grid > .card + .card { margin-top: 0; }
.card-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
  padding: 15px 18px; border-bottom: 1px solid var(--line-soft);
}
.card-body { padding: 18px; }
.card-body.flush { padding: 0; }

.grid { display: grid; gap: 18px; }
/* A block set apart from the one above it, for prose following a grid of short
   facts, with a rule to make the break explicit rather than implied by
   whitespace alone.
   The space is split either side of the line so the rule sits between the two
   blocks rather than belonging to the lower one, and --line is the same border
   colour every card and table edge already uses, so no new value enters the
   system for it. */
.set-apart {
  margin-top: 28px;
  padding-top: 28px;
  border-top: 1px solid var(--line);
}
/* The same break WITHOUT the rule, for two groups inside one form that belong
   together but should not run into each other. A line there would read as a
   section boundary in a form that is one section. */
.gap-above { margin-top: 24px; }
.grid.cols-3 { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }

/* ── Statistic tiles ──────────────────────────────────────── */

.stat { padding: 16px 18px; }

/* A stat tile that filters. Pressing one narrows the table below, so the tile
   has to LOOK pressed: a teal edge, a teal wash and a lifted-away shadow, all
   from tokens already here. This replaces a "Selected" badge that said in a
   word what the control should have been showing in its own appearance.
   `aria-pressed` still carries it for a screen reader, so the meaning is not
   colour-only. */
button.stat { text-align: left; width: 100%; cursor: pointer; transition: border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease; }
/* The pointer cursor plus the card hover shadow, so a tile that opens a
   listing reads as pressable before it is pressed. */
button.stat:hover { border-color: var(--grey-medium); box-shadow: var(--shadow-hover); }
/* A tile that NAVIGATES is an anchor rather than a button (the dashboard's
   doors into Sign-offs and Surveys), and it wears the same clothes: the link
   ink is dropped so the number keeps the card's own colours. */
a.stat { display: block; text-decoration: none; color: inherit; transition: border-color 120ms ease, box-shadow 120ms ease; }
a.stat:hover { border-color: var(--grey-medium); box-shadow: var(--shadow-hover); }
@media (prefers-reduced-motion: reduce) { a.stat { transition: none; } }
button.stat[aria-pressed="true"] {
  border-color: var(--dufeu-teal);
  background: rgba(18, 118, 145, 0.06);
  /* A second inset ring, so the edge reads as deliberate at a glance rather
     than as a one-pixel colour change somebody has to look for. */
  box-shadow: inset 0 0 0 1px var(--dufeu-teal);
}
button.stat[aria-pressed="true"] .value { color: var(--dufeu-teal); }
@media (prefers-reduced-motion: reduce) { button.stat { transition: none; } }
.stat .label { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-mute); }
.stat .value { font-family: var(--font-heading); font-size: 30px; font-weight: 700; color: var(--text); line-height: 1.15; margin-top: 6px; }

/* A BOARD OF FIGURES: several numbers meant to be read at a glance, rather than
   the one or two facts a page heads itself with. `.grid.cols-3` leaves a tile
   three times as wide as it is tall, which reads as a strip and wastes the room
   a second row of numbers could have used; this is the same tile, squarer, so
   eight of them sit as a block somebody takes in at once.

   THE SHAPE IS THE ONLY DIFFERENCE. No new colour, radius, font size or ink:
   the tile is still `.card.stat`, so anything that already knows how to draw a
   stat tile (the pressed state, the link hover, the dash for a missing value)
   goes on working inside this grid without a second implementation. Use it
   wherever a page shows a row of figures; it is not the dashboard's private
   layout.

   The label is pinned to the top and the number to the bottom of the same box,
   so a label that wraps to two lines does not push its number out of line with
   the tile beside it.

   auto-FILL, NOT auto-fit. auto-fit collapses the tracks nothing lands in, so a
   grid holding one figure stretched it into a banner the width of the page, and
   two groups of different sizes drew tiles of different widths beside each
   other. auto-fill keeps the empty tracks, so a tile is the same size whether
   its group has one figure in it or four, which is what lets the board be read
   at a glance rather than as several boards. */
.grid.figures { grid-template-columns: repeat(auto-fill, minmax(224px, 1fr)); }
.figures > .stat {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  min-height: 146px;
}

/* A BOARD SPLIT INTO GROUPS. A card with a `.card-head` and an `<h2>`, which is
   the sectioning idiom every other page here already uses, holding a
   `.grid.figures` of the tiles that belong together. Nothing new: the group is
   a card and the tiles inside it are the same `.card.stat` they were loose.
   `overflow: hidden` is only so the tinted head below sits inside the card's
   own corner rather than squaring it off.

   WHAT THE COLOUR MEANS, AND WHAT IT DOES NOT DO. The edge and the wash on the
   head say which side of the business a group of figures belongs to: teal for
   work in delivery, iris for work not yet won. THE HEADING IS WHAT CARRIES
   THAT; the colour repeats it, so a reader who cannot tell the two hues apart
   has lost nothing. The edge is the attention-edge shape `.card.approvals`
   already uses and the head takes the wash shape `.sa-row` already
   uses, so no new device enters the system, and the inks are existing tokens.
   The wash is 0.12 rather than the 0.07 that shape uses elsewhere: there it
   sits behind a whole tree of rows and only has to tint, here it is one 50px
   band and at 0.07 it read as a printing fault rather than a colour.

   THE THIRD GROUP IS DELIBERATELY UNTINTED. Sign-offs and surveys belong to
   neither side (a sign-off is sent as a live project ends and the survey after
   it closes), so a third hue would be decoration rather than meaning. Its edge
   is --line, the card's own border colour, which keeps the three heads aligned
   to the same pixel: a 1px edge beside two 3px ones shifts a whole card's
   contents across by two.

   CONTRAST. The heading stays --text on the wash: 13.61:1 on the teal wash,
   13.89:1 on the iris. The edges are 5.22:1 (teal) and 4.29:1 (iris) against the
   card, both past the 3:1 non-text bar even though they carry nothing on their
   own. Dufeu Blue was the obvious third accent and is not used: it is 2.43:1
   against white, a line nobody would see. */
.figures-group { overflow: hidden; border-left: 3px solid var(--line); }
.figures-group[data-group="live"] { border-left-color: var(--dufeu-teal); }
.figures-group[data-group="live"] > .card-head { background: rgba(18, 118, 145, 0.12); }
.figures-group[data-group="presales"] { border-left-color: var(--dufeu-iris); }
.figures-group[data-group="presales"] > .card-head { background: rgba(100, 119, 186, 0.12); }

/* ── Buttons ──────────────────────────────────────────────── */

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  height: 38px; padding: 0 15px;
  font-family: inherit; font-size: 14px; font-weight: 600;
  border-radius: 9px; border: 1px solid var(--line);
  background: var(--panel); color: var(--text-dim);
  cursor: pointer; white-space: nowrap;
  transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.btn:hover { border-color: var(--dufeu-blue); color: var(--text); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn:disabled:hover { border-color: var(--line); color: var(--text-dim); }

/* Dark Grey / Indigo ink on Dufeu Blue, never white: white on Dufeu Blue is
   about 2.4:1 and fails WCAG AA. See brand/DESIGN.md. */
.btn.primary { background: var(--dufeu-blue); border-color: var(--dufeu-blue); color: var(--dufeu-indigo); }
.btn.primary:hover { background: #5C9FDC; border-color: #5C9FDC; color: var(--dufeu-indigo); }
/* A button that is a control rather than an offer: no border, so a collapse
   toggle sitting in a card head does not compete with the actions beside it. */
.btn.ghost { border-color: transparent; background: transparent; }
.btn.ghost:hover { border-color: var(--line); }

/* The triangle on a collapse toggle. Drawn from borders rather than a glyph so
   it is the same shape in every font, and rotated rather than swapped so the
   two states are visibly the same control pointing a different way. Points DOWN
   when the section is open and RIGHT when it is closed, which is the convention
   a disclosure triangle has everywhere else.

   aria-hidden on the span: the button's own aria-expanded is what a screen
   reader reads, and a decorative triangle announced beside it would say the
   same thing twice. */
.caret {
  display: inline-block; width: 0; height: 0; margin-right: 6px;
  border-left: 4px solid transparent; border-right: 4px solid transparent;
  border-top: 5px solid currentColor;
  transition: transform 0.15s;
}
[aria-expanded="false"] .caret { transform: rotate(-90deg); }
@media (prefers-reduced-motion: reduce) { .caret { transition: none; } }

.btn.danger { border-color: var(--danger); color: var(--danger); }
.btn.danger:hover { background: rgba(192, 57, 43, 0.07); border-color: var(--danger); color: var(--danger); }
.btn.small { height: 31px; padding: 0 11px; font-size: 13px; }

/* ── Forms ────────────────────────────────────────────────── */

.field { display: flex; flex-direction: column; gap: 6px; }
/* A fact's VALUE breaks inside itself rather than running out of its cell. A
   long unbroken string (an email address is the usual one) has no break
   opportunity, so without this it overflows into the fact beside it and the two
   read as one run-on value: `rob@northfieldlogistics.co.uk05 Aug 2026`. Scoped
   to the value, not the label, and `min-width: 0` because a flex/grid item's
   automatic minimum size is its content, which would otherwise widen the cell
   instead of wrapping the text. Found on the survey response dialog,
   2026-08-11. */
.field { min-width: 0; }
.field > span, .field > div { overflow-wrap: anywhere; }
.field label { font-size: 13px; font-weight: 600; color: var(--text-dim); }
/* A block of FACTS rather than a form: labels that are headers for their values,
   carrying full weight, full-strength ink and one step more size than a form
   prompt. NOT a form label: the same .field label above a control is a prompt,
   where the CONTROL should draw the eye, so bolding those too would flatten the
   difference. That distinction is the whole rule.

   TWO SELECTORS, ONE PATTERN. `#summary-body` is the project detail page, which
   is where this started and is still the reference; `.facts` is the same thing
   as a class, so any other page can opt in without a second copy of the rule.
   New surfaces use the class (portal/lesson.html does). The id stays until
   something migrates the project page onto the class, and until then both must
   keep pointing at THIS declaration, or the two drift.

   Neil, 2026-08-14, on the Lessons Learnt detail page: "it should be similar to
   how it looks under projects at the moment. We need to ensure that it's uniform
   across the app. Although as mentioned I would like the font slightly bigger so
   let's go with that." The size bump is 13px to 14px and it applies to the
   project page too, which is what "uniform" means here.

   THE THIRD SELECTOR IS WHAT MAKES IT ACTUALLY UNIFORM, added 2026-08-14 after
   Neil read the sign-off request box and its audit trail against the pages that
   had already moved: "the headers of the section don't replicate the other
   parts of the app we changed recently ... no bold on the headers and needs to
   be same size font as the other sections". Both of those draw their facts into
   `.grid.cols-3` without opting into `.facts`, and so did the survey detail, the
   Data Migration Plan panels and every confirm dialog. `.grid.cols-3` IS the
   facts grid by rule (UI-CONVENTIONS.md section 4: "the facts grid
   `.grid.cols-3` is for facts, never form fields"), so naming it here is the one
   declaration that catches every surface rather than a class somebody has to
   remember to add. The two selectors above stay because a facts block is not
   always three columns (portal/lesson.html's prose card is one). */
#summary-body .field label,
.grid.cols-3 .field label,
.facts .field label { font-size: 14px; font-weight: 700; color: var(--text); }
/* `.cl-date` RIDES ON THIS RULE RATHER THAN RESTATING IT. The checklist's record
   list puts its label to the LEFT of the control instead of above it, so the box
   has no `.field` around it and would otherwise have needed its own copy of the
   height, the radius and the focus ring: three values that would then be free to
   drift from every other box in the portal. Joining the selector is the cheaper
   half to pay. */
.field input, .field select, .cl-date {
  height: 38px; padding: 0 11px;
  font-family: inherit; font-size: 14px; color: var(--text);
  background: var(--panel); border: 1px solid var(--line); border-radius: 9px;
}
.field input:focus, .field select:focus, .cl-date:focus {
  outline: none; border-color: var(--dufeu-teal);
  box-shadow: 0 0 0 3px rgba(18, 118, 145, 0.15);
}
.field input[aria-invalid="true"] { border-color: var(--danger); }

/* A CONTROL SIZED FOR WHAT IS TYPED INTO IT, rather than for the card it happens
   to sit in. The same reasoning css/signoff.css gives its code box: a box the
   width of the card asks for a sentence, and one value typed once is not one.
   The customer gate on portal/plan-view.html and portal/archive-view.html is
   what it was added for, where the card is the full width of the document and
   the box was running the whole way across it.

   THE CAP IS ON THE CONTROL ALONE. The label above and the error under it keep
   the full column, so a refusal reads as a sentence instead of wrapping into a
   narrow gutter. Below 320px the box is simply the column, so a phone still gets
   an edge-to-edge target at the standard 38px height.

   ONE ADDRESS BOX, STAFF AND CUSTOMER ALIKE (2026-08-21). Neil, of the share
   dialogs and the customer gate together: "just a box for email addresses,
   don't make that as big either". There were two rules doing half of that each,
   which is exactly how the same box came to be two sizes: this one capped the
   WIDTH of an `input` on the two customer pages, and a separate `.field.compact`
   released the HEIGHT of the `textarea` in the staff share dialog and left it as
   wide as the dialog. An address is an address on either side of the link, so
   the two halves are stated once, here, and both surfaces get both.

   `textarea` JOINS THE CAP, and the min-height floor below it is released in the
   same breath. `.field textarea` carries an 84px floor sized for a wizard answer
   somebody writes a paragraph into; an address box is not that, and without the
   release a `rows="2"` box still draws three rows tall. Releasing the floor
   rather than setting a second one leaves the height stated once, in the markup,
   by the control's own `rows`.

   TWO CLASS NAMES, ONE RULE, and the second is on its way out. `narrow` is the
   canonical name. `compact` is the name js/plan.js's share dialog already had
   when this was written, and it is joined to the same declarations rather than
   left to drift as a second opinion about the same box; it goes when that file
   next moves onto `narrow`. Nothing new should reach for it. */
.field.narrow input,
.field.narrow select,
.field.narrow textarea,
.field.compact input,
.field.compact textarea { max-width: 320px; }
.field.narrow textarea,
.field.compact textarea { min-height: 0; }

/* THE SHARE EXPIRY PICKER IS SIZED TO WHAT IT HOLDS. Neil, 2026-08-23: "Make the
   expiry date picker box waaaaay smaller." A date input holds `dd/mm/yyyy` and a
   calendar button and never more, so 320px (the `narrow` cap) or a whole dialog
   width was room it could not use, and beside a 320px address box it read as the
   more important of the two.

   MATCHED BY WHAT THEY ARE RATHER THAN BY A NEW CLASS, so both share dialogs get
   it without either JS file having to opt in: js/plan.js marks its input
   `data-role="expiry"` and js/sp-archive.js gives its own an id. The board's own
   date cells and the Archive-by-date range pickers are deliberately NOT matched:
   they sit in a grid column that sizes them already. */
.field [data-role="expiry"],
#sa-share-expiry { max-width: 160px; }

/* AND THE KICKOFF MEETING DATE, for the same reason. Neil, 2026-08-23: "Make the
   date box smaller on hte kickoff as well." Its editing row is one column, so
   the picker stretched the whole width of the facts card while holding the same
   ten characters. Scoped to the facts block, so the meeting ACTIONS row keeps
   its own 132px Due date track, which is already sized by its grid. */
.kb-facts input[type="date"] { max-width: 160px; }

.form-row { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; }
.form-row .field { flex: 1 1 190px; }

/* ── Type-to-search picker ────────────────────────────────── */
/*
   ONE text box with the matches under it, drawn by js/combobox.js. THE pattern
   for choosing one row out of a table too big for a <select>: the company
   directory is capped at 200 names by the endpoint that serves it, so a select
   is either short and wrong or long and unusable.

   Not the dropdown above (a multi-select of tick boxes over a fixed list) and
   not the actions menu below (a list of things to do). Focus stays in the text
   box and the active row is pointed at with aria-activedescendant, which is
   what role="combobox" promises.
*/
.combo-wrap { position: relative; }
.combo-input { width: 100%; }
/* BIGGER THAN A PLAIN .field input, AND MARKED AS A SEARCH. At 38px with no
   mark on it this box reads as a name field, so people typed nothing into it
   and never found out it was a list at all, which is the exact failure the
   control exists to prevent. The copy rule forbids a placeholder saying so, so
   the affordance is a magnifier and the size.

   THE SIZE IS ON THE BASE CLASS, NOT ON A VARIANT. Every combobox in this app
   is here for one reason: a list too big for a <select> (a short fixed list is
   js/dropdown.js, a list of actions is js/menu.js). There is no flavour of
   this control where a long list is NOT expected, so a variant that had to be
   opted into per call site would only guarantee that half the pickers stayed
   wrong.

   The icon colour is written out rather than var(--text-mute): a custom
   property does not resolve inside url(). It is the same #818285. */
.combo .combo-input {
  height: 44px;
  padding-left: 38px;
  font-size: 15px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23818285' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='M20 20l-4-4'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: 13px center;
  background-size: 17px 17px;
}
/* Tall enough to show a handful of names rather than a couple, and clamped to
   the viewport so a laptop screen does not get a list taller than the dialog
   holding it. */
.combo-list {
  position: absolute; top: calc(100% + 5px); left: 0; z-index: 45;
  width: 100%; max-height: min(340px, 46vh); overflow-y: auto;
  margin: 0; padding: 5px; list-style: none;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: 11px; box-shadow: var(--shadow-pop);
}
/* THE LIST IS INSIDE A SCROLL BOX, AND WAS BEING CUT OFF BY IT. .modal-body
   scrolls, so it clips anything absolutely positioned inside it: on a step
   whose only field IS the picker the body is about 95px tall, so a 340px list
   showed a row and a half and the rest had to be scrolled to. The room is
   reserved while the list is open and given back when it closes, the same way
   .table-scroll gives an open Actions menu room above.

   Clamped in vh because .modal is max-height'd and overflow: hidden, so a
   floor taller than the viewport allows would push the dialog's own footer,
   which is where Next and Cancel live, out of sight on a short screen. */
.modal-body:has(.combo-list:not([hidden])) { min-height: min(420px, 52vh); }

/* 36px rather than the 24px WCAG 2.2 asks for as a minimum: these rows are
   chosen by pointer in a dialog, and a 24px row of names is a target somebody
   misses. */
.combo-option {
  display: flex; align-items: center; min-height: 36px;
  padding: 7px 10px; border-radius: 7px; cursor: pointer;
  font-size: 14px; font-weight: 600; color: var(--text-dim);
}
.combo-option:hover { background: var(--off-white); color: var(--text); }
/* The keyboard's position in the list. A border as well as a fill, because the
   fill alone is a colour difference of about 1.2:1 against the panel and the
   row somebody is on must not be carried by colour alone. */
.combo-option.active {
  background: var(--off-white); color: var(--text);
  box-shadow: inset 0 0 0 2px var(--dufeu-teal);
}
/* No matches, and the note on a capped list. Not selectable, and not an
   instruction: it states a fact about the list. */
.combo-empty {
  padding: 8px 10px;
  font-size: 13px; font-weight: 600; color: var(--text-mute);
}

/* ── What has been picked ─────────────────────────────────── */
/*
   The list under a type-to-search box, for the case where the answer is SEVERAL
   rows rather than one: the approvers on a role in Admin > Roles. The picker
   above stays a single choice (that is what role="combobox" promises); choosing
   several is choosing one several times, and this is where they land.

   A LIST, not a row of pills, because each entry carries two facts (a name and
   an address) and a pill that has to hold both is either truncated or enormous.
   Each row keeps the 44px target the tick boxes below do, for the same WCAG 2.2
   reason: the Remove button is the smallest thing on it.
*/
.picked { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; }
.picked:empty { display: none; }
.picked-item {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  min-height: 44px; padding: 4px 6px 4px 11px;
  background: var(--off-white); border: 1px solid var(--line);
  border-radius: 9px;
}
.picked-who { display: flex; flex-direction: column; min-width: 0; }
.picked-who .name { font-size: 14px; font-weight: 600; color: var(--text); }
.picked-who .sub { font-size: 12px; color: var(--text-dim); }

/* Grouped tick boxes. THE pattern for a long list of options, currently the
   permission catalogue in the role builder. The 44px minimum row height is a
   WCAG 2.2 target-size requirement, not padding for looks: without it a list
   this dense is unusable on a phone and fails 2.5.8. */
fieldset.checks {
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  padding: 6px 12px 10px;
  margin: 0;
  min-width: 0;
}
fieldset.checks legend {
  font-family: var(--font-heading);
  font-size: 12px; font-weight: 600;
  letter-spacing: 0.03em; text-transform: uppercase;
  color: var(--text-dim);
  padding: 0 4px;
}
.check { display: flex; align-items: center; gap: 9px; min-height: 44px; cursor: pointer; }
.check input[type="checkbox"] {
  width: 18px; height: 18px; flex-shrink: 0;
  accent-color: var(--dufeu-teal); cursor: pointer;
}
/* A permission a customer role may not hold. Brand grey rather than the
   browser's default, and never colour alone: the box is disabled too, which is
   what assistive technology reports. */
.check:has(input:disabled) { color: var(--text-mute); cursor: not-allowed; }
.check:has(input:disabled) input { cursor: not-allowed; }

/* ── Tick-box group: the facts and the two links ──────────── */
/*
   Three additions to the group above, for a list whose rows carry a NUMBER as
   well as a name and whose contents arrive over the network. Written here
   rather than against the one dialog that wants them (the Autotask phases on
   New plan) because none of the three is about phases: a group of options with
   a count on each row, a group that can state why it is empty, and a group big
   enough to want ticking wholesale are all shapes this portal will meet again.

   THE PAIR OF LINKS IS THE DROPDOWN'S PAIR. js/dropdown.js puts Select all and
   Clear under its menu, so a group that is always open instead of behind a
   button must not rename them or move them. The rules are separate from
   `.dd-menu .foot` only because this footer is not inside a popover; nothing
   about how they read should drift apart. */

/* The fact a group states instead of options: that there are none, or that
   there are more than are listed. Never a sentence about what the group is for.
   Dark Grey rather than the muted grey: at 13.5px the muted tone is about 4:1
   on the panel, which is under WCAG AA for text this size. */
.checks-note { margin: 0; padding: 2px 0 6px; color: var(--text-dim); font-size: 13.5px; }

/* The number on a row. Pushed to the far end by the flex `.check` it sits in,
   so names line up on the left and counts on the right however long a title
   runs, and tabular figures so the column of numbers is a column. */
.check .check-note {
  margin-left: auto; padding-left: 12px;
  color: var(--text-dim); font-size: 12.5px;
  white-space: nowrap; font-variant-numeric: tabular-nums;
}

/* A row that sits UNDER the one above it, for a catalogue that nests (Autotask
   phases carry sub-phases). Three deep and no further: past that a row is
   mostly margin, and the indent is a second signal rather than the only one,
   because the source orders parents and children together. */
/* BOTH SELECTORS, because the row grew a wrapper. The depth class went on the
   `.check` label when the picker was a flat list of bands; the arrow put a
   `.phase-pick` div round it and the class went with the div, so from that day
   `.check.depth-1` matched nothing and a nested Autotask sub-phase drew level
   with its parent. Fixed 2026-08-27 by naming both, rather than by moving the
   class back onto a label the arrow now shares a row with. */
.check.depth-1, .phase-pick.depth-1 { padding-left: 22px; }
.check.depth-2, .phase-pick.depth-2 { padding-left: 44px; }
.check.depth-3, .phase-pick.depth-3 { padding-left: 66px; }

.checks-foot {
  display: flex; gap: 12px; justify-content: space-between;
  padding-top: 7px; margin-top: 4px; border-top: 1px solid var(--line-soft);
}
/* 24px minimum, which is WCAG 2.2 target size (2.5.8) and not padding for
   looks: at 12.5px the text alone is a target about 17px tall. */
.checks-foot .lnk {
  background: none; border: 0; padding: 4px 2px; min-height: 24px; cursor: pointer;
  font-family: inherit; font-size: 12.5px; font-weight: 600; color: var(--dufeu-teal);
}
.checks-foot .lnk:hover { text-decoration: underline; }

/* -- A phase, and the tasks inside it ----------------------
   The Autotask import picker's rows. Neil, 2026-08-25: "have a little arrow
   pointing to the right that when you press goes downwards and shows all the
   tasks within that phase (collapsed by default) then give us the ability to
   import certain tasks as part of that phase".

   THE ARROW ROTATES, IT IS NOT SWAPPED. One mark turned 90 degrees is the same
   control in both states, so it keeps its size and its place in the row and can
   be animated; two glyphs would jump. The rotation hangs off aria-expanded,
   which is the state a screen reader reads, so the drawing and the announcement
   cannot come apart.

   THE TASKS ARE INDENTED PAST THE ARROW, not past the tick box, so a task lines
   up under its band's NAME rather than under the control that opened it. */
.phase-pick { display: block; }
.phase-pick-head { display: flex; align-items: center; gap: 2px; }
/* The label is the row: it takes the width so its 44px target and its
   right-aligned note both behave exactly as they do without an arrow beside
   them. */
.phase-pick-head .check { flex: 1 1 auto; min-width: 0; }
.disclose {
  display: inline-grid; place-items: center; flex: 0 0 auto;
  width: 24px; height: 24px; padding: 0;
  background: none; border: 0; border-radius: var(--radius-sm);
  color: var(--text-mute); cursor: pointer;
}
/* A heading that can carry something beside the words, hard against them rather
   than at the card's far edge. The Audit trail's disclosure arrow lived here for
   a day (Neil, 2026-08-26: "the arrow needs to be next to the date") and moved
   onto each row on 2026-08-27, which is nearer still to the date it opens. The
   rule stays because it is what a card heading is, and because a heading that
   silently reverted to `block` would move every card title by a pixel. */
.card-title { display: inline-flex; align-items: center; gap: 4px; }

.disclose svg { transition: transform 140ms ease; }
.disclose[aria-expanded="true"] svg { transform: rotate(90deg); }
.disclose:hover { color: var(--text); background: var(--off-white); }
.disclose:focus-visible { outline: 2px solid var(--dufeu-teal); outline-offset: 1px; }
/* A band with nothing in it draws no arrow, and this holds its place so every
   row's tick box starts in the same column. */
.disclose-gap { display: inline-block; flex: 0 0 auto; width: 24px; height: 24px; }
/* Indented to clear the arrow plus the gap, and ruled on the left so a long
   list of tasks still reads as belonging to the band above it. */
.phase-pick-tasks {
  margin: 0 0 2px 12px;
  padding-left: 12px;
  border-left: 2px solid var(--line-soft);
}
@media (prefers-reduced-motion: reduce) {
  .disclose svg { transition: none; }
}
.checks-foot .lnk:disabled { color: var(--text-mute); cursor: not-allowed; text-decoration: none; }

/* ── Data Migration Planner ───────────────────────────────── */
/*
   ONE SCREEN, TWO HALVES: the WORK on the left (a folder grid, one row per
   folder, one decision per row) and the FIGURES on the right (what each
   decision adds up to, the path budget, the date sweep). Nothing here is a new
   device. The layout is the two-column `.grid` idiom with the right column made
   sticky; the chips are `.badge` geometry given a pressed state; the row edge
   and wash are the shape the tick-box tree used before it, at the same alpha;
   the summary table is `.table` type at rail scale.

   THE THREE DECISIONS AND THEIR INKS. Live is the ok green, Archive the house
   amber, Leave the `.badge.info` iris. UNDECIDED HAS NO WASH and a --line edge,
   because it is the absence of an answer: a fourth colour would draw it as a
   fourth answer, and undecided is the one state the whole screen exists to
   empty. Every ink is a darkened literal of its own hue, the rule stated at the
   top of this file: #4A6A00 on green (already `.badge.active`), #8A5300 on amber
   (the ink the archive chips have always worn), #48579A on iris. On their own washes those
   measure 6.6:1, 5.9:1 and 5.5:1 against a white card, all past AA. --warn
   itself is 4.0:1 and so is never ink here, only an edge and a swatch, where the
   3:1 non-text bar applies.

   COLOUR IS NEVER ALONE. Every row states its decision in words in the control
   at the end of it, and the chip row above the grid names each colour beside its
   swatch, so a reader who cannot tell the hues apart has lost nothing.

   ROW HEIGHT IS 34px AND THE HEAD IS STICKY, because a real audit is several
   hundred folders: this is a grid to run an eye down, not a form to fill in. */

/* Work left, figures right. The rail sticks so the totals stay on screen while
   the rows go past. Below the breakpoint the two stack and the stickiness comes
   off: a sticky block in a single column is a block that covers what is under
   it. */
.sa-planner { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; align-items: start; margin-top: 18px; }
.sa-rail { position: sticky; top: 0; display: flex; flex-direction: column; gap: 18px; min-width: 0; }
.sa-rail .card + .card { margin-top: 0; }
@media (max-width: 1080px) {
  .sa-planner { grid-template-columns: minmax(0, 1fr); }
  .sa-rail { position: static; }
}

/* The key line and the filter are ONE control: each chip names a decision,
   carries its colour and its count, and filters the grid to it. Two rows of
   chips saying the same words, one to read and one to press, is what this
   avoids. */
.sa-keys {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  padding: 12px 18px; border-bottom: 1px solid var(--line-soft);
}
.sa-chip {
  display: inline-flex; align-items: center; gap: 7px;
  height: 28px; padding: 0 11px; border-radius: var(--radius-pill);
  border: 1px solid var(--line); background: var(--panel);
  font-family: inherit; font-size: 12.5px; font-weight: 700; color: var(--text-dim);
  cursor: pointer; white-space: nowrap;
}
.sa-chip:hover { border-color: var(--dufeu-blue); color: var(--text); }
/* THE COUNT IS NOT A TARGET, it is part of the chip. Without this the
   generic delegated lookup (closest('[data-sa]')) stops at this span, so a
   press on the DIGIT resolves to n-archive rather than the chip and filters
   nothing: a dead spot over a third of a 28px pill. The same shape as the
   stat-tile bug fixed on 2026-08-10. */
.sa-chip .n { font-variant-numeric: tabular-nums; pointer-events: none; }
.sa-chip .sa-sw { pointer-events: none; }
.sa-chip[aria-pressed="true"] { border-color: var(--dufeu-teal); box-shadow: inset 0 0 0 1px var(--dufeu-teal); color: var(--text); }
.sa-chip.k-live[aria-pressed="true"] { background: rgba(150, 212, 4, 0.18); border-color: #4A6A00; box-shadow: inset 0 0 0 1px #4A6A00; color: #4A6A00; }
.sa-chip.k-archive[aria-pressed="true"] { background: rgba(178, 107, 0, 0.14); border-color: #8A5300; box-shadow: inset 0 0 0 1px #8A5300; color: #8A5300; }
.sa-chip.k-leave[aria-pressed="true"] { background: rgba(100, 119, 186, 0.16); border-color: #48579A; box-shadow: inset 0 0 0 1px #48579A; color: #48579A; }

/* The swatch, shared by the chips and the summary table so one decision is one
   square wherever it appears. */
.sa-sw { display: inline-block; width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; }
.sa-sw.k-live { background: var(--ok); }
.sa-sw.k-archive { background: var(--warn); }
.sa-sw.k-leave { background: var(--dufeu-iris); }
.sa-sw.k-none { background: var(--grey-light); }
.sa-sw.k-over { background: var(--danger); }

/* THE GRID. The head and the rows declare the SAME track list, so the columns
   line up without a table: every row is the same width, so every 1fr resolves
   to the same pixel. Six tracks with a migration, FIVE WITHOUT: the Path column
   and its track both go rather than the track going to zero, because a
   zero-width track still takes the gap either side of it and eats into the
   control beside it. The head carries a transparent left border matching the
   row's 3px edge, or the two would be three pixels out of step.

   THE FIXED TRACKS ADD UP TO 428px, which is the whole budget there is. This
   app's `.main` caps at 1180px and the rail beside the grid takes 340, so the
   name gets about 260: enough for a folder name at 14px, and the ones it cannot
   fit ellipse with the full path on their own title. Widening any of these five
   comes straight out of the name. */
/* THE FRAME AND THE POP-OUT TRIGGER. The frame does not scroll and does not
   move: it is the positioning context for the trigger, and it is what the
   scroll box is appended back into when the window closes. The trigger sits
   OVER the sticky head's last track, which carries the word Decision in its
   first 66px and nothing in the rest, so it costs the name column nothing.
   Above the head rather than under it (the head is sticky at z-index 1), and
   clear of the scroll box's scrollbar, which is why the offset is 18px and not
   the 16px the rows are padded by. The window it opens is `.modal.sa-window`,
   with the modal sizes. */
.sa-frame { position: relative; }
.sa-pop {
  position: absolute; top: 4px; right: 18px; z-index: 2;
  display: inline-flex; align-items: center; justify-content: center;
  width: 26px; height: 26px; padding: 0;
  /* Bordered, unlike the row carets: those sit in a column of their own where
     the shape is obvious, and this one has to read as a control while it is
     alone over the head. */
  border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--panel);
  color: var(--text-mute); cursor: pointer;
}
.sa-pop:hover { background: var(--off-white); color: var(--text); }
.sa-pop svg { width: 14px; height: 14px; }

.sa-tree { max-height: 62vh; overflow: auto; }
@media (max-width: 1080px) { .sa-tree { max-height: none; } }
.sa-head, .sa-row {
  display: grid; align-items: center; gap: 8px; padding: 0 16px;
  grid-template-columns: minmax(0, 1fr) 66px 64px 84px 44px 170px;
}
/* NO PATH COLUMN, BUT STILL THREE DECISIONS. The path length needs a prefix
   to mean anything, and the customer surface is never given one, so the
   column goes while the Decision control keeps the width three buttons need.
   Declared BEFORE archive-only so that, on a dataset which is both, the
   two-button width below wins on source order. */
.sa-tree.no-path .sa-head, .sa-tree.no-path .sa-row {
  grid-template-columns: minmax(0, 1fr) 66px 64px 84px 170px;
}
.sa-tree.archive-only .sa-head, .sa-tree.archive-only .sa-row {
  grid-template-columns: minmax(0, 1fr) 66px 64px 84px 126px;
}
.sa-head {
  position: sticky; top: 0; z-index: 1;
  height: 34px; background: var(--panel);
  border-bottom: 1px solid var(--line); border-left: 3px solid transparent;
  font-family: var(--font-heading); font-size: 12px; font-weight: 600;
  letter-spacing: 0.03em; text-transform: uppercase; color: var(--text-mute);
}
.sa-head span { text-align: right; }
.sa-head span:first-child, .sa-head span:last-child { text-align: left; }
.sa-row {
  min-height: 34px; font-size: 14px;
  border-bottom: 1px solid var(--line-soft); border-left: 3px solid var(--line);
}
/* A grid item's `display: grid` beats the hidden attribute's UA display:none,
   so the attribute has to be honoured explicitly or a filtered-out row still
   draws. */
.sa-row[hidden] { display: none; }
.sa-row:hover { background: rgba(108, 172, 228, 0.05); }
.sa-row.d-live { border-left-color: var(--ok); background: rgba(150, 212, 4, 0.07); }
.sa-row.d-archive { border-left-color: var(--warn); background: rgba(178, 107, 0, 0.09); }
.sa-row.d-leave { border-left-color: var(--dufeu-iris); background: rgba(100, 119, 186, 0.07); }
.sa-row.d-live:hover, .sa-row.d-archive:hover, .sa-row.d-leave:hover { filter: brightness(0.985); }

/* The name cell ELLIPSES rather than wrapping: a wrapped name makes one row
   taller than the rest and the column of decisions stops being a column. The
   full path is the name's own title attribute. */
.sa-name { display: flex; align-items: center; gap: 6px; min-width: 0; }
.sa-lbl { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.sa-row.sa-parent .sa-lbl { font-weight: 600; }
.sa-from { flex-shrink: 0; }

/* Tabular figures, so a column of numbers is a column. Over the 255 cap wears
   the danger ink; within 20 of it wears the darkened amber rather than --warn
   itself, which is only 4:1 on the panel. */
.sa-num { text-align: right; font-size: 13px; color: var(--text-dim); white-space: nowrap; font-variant-numeric: tabular-nums; }
.sa-num.over { color: var(--danger); font-weight: 700; }
.sa-num.near { color: #8A5300; font-weight: 700; }

/* The disclosure: the house .caret in an icon-only square, sized off the 26px
   footprint the tree has always used. A LEAF keeps the same footprint as a
   spacer, so siblings line up whether or not they have children. */
.sa-toggle {
  display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
  width: 26px; min-width: 26px; height: 26px; padding: 0;
  border: 0; border-radius: var(--radius-sm); background: none;
  color: var(--text-mute); cursor: pointer;
}
.sa-toggle:hover { background: var(--off-white); color: var(--text); }
.sa-toggle .caret { margin-right: 0; }
.sa-spacer { display: inline-block; width: 26px; flex-shrink: 0; }

/* THE THREE-WAY CONTROL. One press per folder and no menu to open, because the
   press is the whole job and it is done several hundred times. 24px minimum
   height is WCAG 2.2 target size (2.5.8). The unpressed ink is --text-dim, not
   the muted grey the rest of a row's small print uses: at 12px the muted tone is
   3.6:1 and these are labels somebody has to read before pressing. The pressed
   one is told apart by its fill AND its ink AND its weight. */
.sa-decide { display: inline-flex; border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden; background: var(--panel); }
.sa-seg {
  appearance: none; flex: 1 1 auto; min-height: 24px; padding: 0 9px;
  border: 0; border-right: 1px solid var(--line-soft); background: none;
  font-family: inherit; font-size: 12px; font-weight: 600; color: var(--text-dim);
  cursor: pointer; white-space: nowrap;
}
.sa-seg:last-child { border-right: 0; }
.sa-seg:hover { background: var(--off-white); color: var(--text); }
/* The group clips, so the shared 2px outward focus ring would be cut off. */
.sa-seg:focus-visible { outline-offset: -2px; }
.sa-seg:disabled { cursor: not-allowed; opacity: 0.6; }
.sa-seg:disabled:hover { background: none; color: var(--text-dim); }
.sa-seg[aria-pressed="true"] { font-weight: 700; }
.sa-seg.s-live[aria-pressed="true"] { background: rgba(150, 212, 4, 0.18); color: #4A6A00; }
.sa-seg.s-archive[aria-pressed="true"] { background: rgba(178, 107, 0, 0.14); color: #8A5300; }
.sa-seg.s-leave[aria-pressed="true"] { background: rgba(100, 119, 186, 0.16); color: #48579A; }

/* The summary card, one row per decision, the same swatches as the chips, and
   Total ruled off. It sits ABOVE .sa-planner at the full width of the page on
   the staff side and inside its own section on the customer's, so neither copy
   is in a rail. `.table` type stepped down all the same: this is a five-column
   table of figures that has to stay readable at a phone's width, and the page
   table's 18px cells put it over. */
.sa-matrix { width: 100%; border-collapse: collapse; font-size: 14px; }
.sa-matrix th {
  text-align: right; padding: 11px 18px; white-space: nowrap;
  font-family: var(--font-heading); font-size: 11.5px; font-weight: 600;
  letter-spacing: 0.03em; text-transform: uppercase; color: var(--text-mute);
  border-bottom: 1px solid var(--line);
}
.sa-matrix th:first-child { text-align: left; }
.sa-matrix td {
  padding: 11px 18px; text-align: right; white-space: nowrap;
  border-bottom: 1px solid var(--line-soft); color: var(--text);
  font-variant-numeric: tabular-nums;
}
.sa-matrix td:first-child { text-align: left; font-weight: 600; color: var(--text-dim); }
/* A ROW header is not a COLUMN header. The customer page names each decision
   with &lt;th scope="row"&gt;, which is right for a screen reader and wrong for the
   uppercase 11.5px type above, so the row read LIVE / ARCHIVE / UNDECIDED in
   caps while the staff copy read Live / Archive / Unassigned. Same table, same
   words, same case. */
.sa-matrix tbody th, .sa-matrix tfoot th {
  text-align: left; text-transform: none; letter-spacing: 0;
  font-family: var(--font-body); font-size: 14px; font-weight: 600;
  color: var(--text-dim); border-bottom: 1px solid var(--line-soft);
  padding: 11px 18px;
}
.sa-matrix tfoot th { border-bottom: 0; border-top: 1px solid var(--line); font-weight: 700; }
.sa-matrix tbody tr:last-child td { border-bottom: 0; }
/* THE TOTAL ROW IS RULED OFF ON BOTH SURFACES, and they build it differently:
   the staff copy is <tr class="total"> inside tbody with a plain first cell,
   the customer's is <tfoot> with <th scope="row"> (right for a screen reader).
   Rule the CELLS of both, or the customer's Total drew its line above the word
   Total and under the figures beside it: the tfoot th took the border-top the
   tds never got, and the tds kept the border-bottom tbody's last row drops. */
.sa-matrix tr.total td,
.sa-matrix tfoot td { border-top: 1px solid var(--line); border-bottom: 0; font-weight: 700; }
.sa-matrix td.over { color: var(--danger); font-weight: 700; }
.sa-matrix .sa-sw { margin-right: 6px; vertical-align: baseline; }
/* THE MATRIX MUST NEVER CLIP. Five columns of tabular figures fit a full-width
   card comfortably on a desktop and not at all on a phone, and the figures grow
   with the customer: a share with terabytes and six-figure file counts widens
   every column at once. Tight cells fit the ordinary case; the scroll is what
   stops the unusual one silently losing its right-hand column, which is what it
   did before (the Path column was cut off at 338px against a 382px table).
   :has() is already the idiom here, see .check:has(input:disabled) above. */
.card-body.flush:has(.sa-matrix) { overflow-x: auto; }

/* A label and its value on one line: the facts-grid idiom laid along a rail
   narrow enough that a label above a value would run to three lines. */
.sa-fig { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 13.5px; }
.sa-fig .v { font-family: var(--font-heading); font-size: 19px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
.sa-fig .v.ok { color: #4A6A00; }
.sa-fig .v.near { color: #8A5300; }
.sa-fig .v.over { color: var(--danger); }

/* The in-place disclosure on a share row (js/sp-archive.js): the house
   .btn.small.ghost shrunk to the same icon-only square the tree's caret uses,
   so the two carets are one control at one size. */
.sp-toggle { width: 26px; min-width: 26px; height: 26px; padding: 0; justify-content: center; }
.sp-toggle .caret { margin-right: 0; }

/* The unread count on the Customer Submissions rail pill (and on the archive
   row in a plans list): the sidebar's .nav-badge, given breathing room beside
   the text it sits against. On the rail it follows the words; in a plans list
   it LEADS the name (Neil, 2026-08-13), so the margin swaps sides with it. */
.rail button .nav-badge { margin-left: 6px; }
.table td .nav-badge { margin-right: 6px; vertical-align: middle; }


/* The path-length rows: over the 255 cap wears the danger ink, within 20 of
   it the amber. The Headroom cell carries the number either way, so colour is
   never the only signal.

   NOT var(--warn) FOR THE AMBER, and this was a real failure rather than a
   preference: #B26B00 on the panel is about 4.0:1, under the 4.5:1 WCAG AA
   needs for body text. The darkened #8A5300 is the ink the amber chips
   already use for exactly this reason. --warn stays what it is, an EDGE and
   a swatch colour, where the contrast minimum does not apply. */
.table tr.sp-budget-over td { color: var(--danger); }
.table tr.sp-budget-warn td { color: #8A5300; }

/* ── Slide switch ─────────────────────────────────────────── */
/*
   A checkbox drawn as a slide, for the handful of settings where the state
   itself is the thing somebody has come to read. `.check` stays the pattern for
   a field on a form (a permission in a list, a flag in an editor); this is for a
   switch that governs something and is looked at rather than filled in.

   IT IS STILL A CHECKBOX. The input is present, focusable and reachable by
   assistive technology exactly as before: `appearance: none` removes only the
   browser's own drawing. So the label, the checked state, the keyboard and the
   focus ring all keep working without a scrap of JavaScript or a single ARIA
   attribute, which is what makes this safe to reuse. Never build one out of a
   div and a click handler.

   NEVER COLOUR ALONE. On is Dufeu Teal, off is a grey track, and the two are
   also a different knob position and a different word in the label beside it,
   because a switch read by somebody who cannot tell the two hues apart still has
   to say which way it is set. Teal rather than Dufeu Green: green on this scale
   reads as a status badge, and the switch is a control. */
.switch { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer; }
/* THE KNOB IS A BACKGROUND, NOT A PSEUDO-ELEMENT, AND HAS TO STAY ONE.
   An <input> is a replaced element, so Firefox renders no ::before or ::after
   on it even with appearance: none. Drawn that way the track still turned teal
   but the knob did not exist and did not move, which left Firefox users with a
   switch reporting its state by colour alone: exactly what the note above says
   this control must never do. The wrapper trick (draw the knob on a span) is
   the usual answer and is rejected here for the reason at the top of this
   block: it needs markup in every page that uses a switch, and a control that
   depends on remembering extra markup is one somebody will eventually build
   out of a div.
   A radial-gradient on the input's OWN background renders everywhere, and
   background-position animates as smoothly as a transform did. */
.switch input[type="checkbox"] {
  appearance: none; -webkit-appearance: none;
  position: relative; flex-shrink: 0;
  width: 44px; height: 24px; margin: 0;
  background-color: var(--grey-light);
  /* 18px knob (9px radius), centred 11px in from the left of the 42px content
     box, matching the 2px inset the pseudo-element used. */
  background-image: radial-gradient(circle 9px at 11px 11px, var(--panel) 99%, transparent 100%);
  background-repeat: no-repeat;
  background-position: 0 0;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  cursor: pointer;
  transition: background-color 0.15s, border-color 0.15s, background-position 0.15s;
}
.switch input[type="checkbox"]:checked {
  background-color: var(--dufeu-teal);
  border-color: var(--dufeu-teal);
  /* The same 20px the knob used to travel. */
  background-position: 20px 0;
}
.switch input[type="checkbox"]:focus-visible {
  outline: 2px solid var(--dufeu-teal);
  outline-offset: 2px;
}
.switch:has(input:disabled) { color: var(--text-mute); cursor: not-allowed; }
.switch:has(input:disabled) input { cursor: not-allowed; opacity: 0.6; }
/* The word beside the slide, which is what actually reports the state. */
.switch .switch-label { font-size: 14px; font-weight: 600; color: var(--text); }
@media (prefers-reduced-motion: reduce) {
  /* One selector now: the knob moved from a pseudo-element onto the input's own
     background, so the input carries every transition there is. */
  .switch input[type="checkbox"] { transition: none; }
}

/* A select used inline in a table row, where the label is the column header.
   Shorter than the form control so it does not dominate the row. */
.table select {
  height: 31px; padding: 0 8px;
  font-family: inherit; font-size: 13.5px; font-weight: 600; color: var(--text-dim);
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-sm);
  cursor: pointer;
}
.table select:hover { border-color: var(--dufeu-blue); color: var(--text); }
.table select:focus { outline: none; border-color: var(--dufeu-teal); box-shadow: 0 0 0 3px rgba(18, 118, 145, 0.15); }

/* ── Tables ───────────────────────────────────────────────── */

.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th {
  text-align: left; font-family: var(--font-heading); font-weight: 600;
  font-size: 12px; letter-spacing: 0.03em; text-transform: uppercase;
  color: var(--text-mute); padding: 11px 18px;
  border-bottom: 1px solid var(--line-soft); white-space: nowrap;
}
/* EVERY TABLE, not only a plan's tasks. The complaint was about a plan, but the
   rule is one rule and every list in the portal draws through it: scoping the
   darker line to plans alone would leave the projects list, sign-offs, surveys
   and Admin visibly fainter than the page next to them. */
.table td { padding: 12px 18px; border-bottom: 1px solid var(--line-mid); color: var(--text-dim); vertical-align: middle; }
.table tbody tr:last-child td { border-bottom: 0; }
.table tbody tr:hover td { background: rgba(108, 172, 228, 0.05); }
.table td .name { color: var(--text); font-weight: 600; }
.table td .sub { display: block; font-size: 12.5px; color: var(--text-mute); }

/* THE DETAIL ROW AN AUDIT TRAIL'S ARROW OPENS (js/audit-trail.js). The browser,
   the address the visitor came from and the raw user agent stopped being
   columns on 2026-08-27: they are one spanning row under the open they belong
   to, `hidden` until the arrow is pressed.
   Tinted so it reads as an aside rather than as another entry in the list, and
   with the border between it and its own open removed so the pair reads as one
   thing. Only while it is showing: collapsed, the list keeps its own rhythm. */
/* THE ARROW SITS AT THE HEAD OF ITS OWN ROW, left of the date. Neil,
   2026-08-27: "The triangle arrow should be next to each time it's been opened
   (left of the first word on that row)". An inline-flex wrapper rather than a
   flex cell, because a cell told to be a flex container stops taking its width
   from the column and the table loses its alignment.
   A row with nothing recorded draws `.disclose-gap` instead of a button, so
   every date on the table still starts in one column. */
.trail-open { display: inline-flex; align-items: center; gap: 4px; }
.trail-open .disclose { margin-left: -4px; }
.table tr.trail-detail td { padding: 10px 18px 14px 46px; background: var(--off-white); }
.table tbody tr:has(+ tr.trail-detail:not([hidden])) td { border-bottom: 0; }
/* AND THE FOOT OF THE TABLE NEVER CARRIES A LINE. `tbody tr:last-child` above
   drops it, but the last child can now be a detail row that is hidden, which
   would leave the rule dropping a border nobody can see and the last VISIBLE
   row drawing one under the table. */
.table tbody tr:has(+ tr.trail-detail:last-child) td { border-bottom: 0; }
/* The hover tint points at ONE entry, so it must not repaint a detail that has
   its own ground. */
.table tbody tr.trail-detail:hover td { background: var(--off-white); }
.trail-detail-body { display: flex; flex-direction: column; gap: 10px; }
/* Two short facts side by side; the user agent is a line of its own below,
   because it is long enough to push anything beside it off the row. */
.trail-detail-facts { display: flex; flex-wrap: wrap; gap: 10px 28px; }
/* A USER AGENT IS UNBOUNDED AND IT IS NOT OURS. A visitor's browser sends
   whatever string it likes, and this panel prints it verbatim because it is the
   evidence a disputed open is argued over. Escaped, so there is nothing to
   inject; but a 4KB agent with no spaces in it would otherwise push the card
   wider than the dialog and take the columns with it. Raised in the pre-push
   security review of PR #603 as defence in depth, which is exactly what it is:
   the panel is staff-only, so the worst case was a distorted card rather than a
   disclosure. Breaking anywhere is right for a string that has no words. */
.trail-detail-body .field span { font-size: 13px; color: var(--text); overflow-wrap: anywhere; }
/* The signer's raw user agent, which sits on the sign-off card rather than in
   the table above. Same unbounded string, same reason, and it stopped being
   hidden behind a disclosure on 2026-08-27, so it is now always on screen and
   always has to fit. See js/signoff-detail.js for why the class is on the grid. */
.signature-facts .field span { overflow-wrap: anywhere; }

/* A name that is a SENTENCE rather than a label, held to two lines.
   Added for the Lessons Learnt register, whose first column is the lesson
   itself: a lesson is prose, and prose in a table cell either wraps to six
   lines and destroys the row rhythm or is cut mid-word with an ellipsis that
   says nothing about how much was cut.
   TWO LINES, because one is not enough to carry a sentence and three stops
   being a row. The whole text is always one click away on the record's own
   page, which is what makes clamping honest here and would not make it honest
   on a surface with nowhere to go.
   The -webkit- prefixed properties are the standard: line-clamp is supported
   under this spelling in every engine, and the unprefixed form is not yet. */
.clamp2 {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  /* A clamped cell must be allowed to wrap, whatever the column would
     otherwise do to it. */
  white-space: normal;
}
.table td.actions { text-align: right; white-space: nowrap; }

/* A sortable column heading. A real button inside the th, so it is reachable by
   keyboard and announced as a control; the th's aria-sort is what states the
   current column and direction. It inherits the heading's own type rather than
   the button type, so a sortable row of headings still reads as headings.
   Focus is the one global :focus-visible outline, so there is nothing here. */
.table th .sort {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 0; border: 0; background: none; cursor: pointer;
  font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit;
}
.table th .sort:hover { color: var(--text); }
/* The sorted column is darker than the rest AND carries the triangle: the
   colour alone would say nothing on a monochrome display. */
.table th[aria-sort="ascending"] .sort,
.table th[aria-sort="descending"] .sort { color: var(--text); }

/* The direction, drawn from borders like .caret so it is the same shape in
   every font. Up for ascending, down for descending, and nothing at all on a
   column that is not the one being sorted. */
.table th .sort-mark {
  display: inline-block; width: 0; height: 0;
  border-left: 4px solid transparent; border-right: 4px solid transparent;
}
.table th[aria-sort="ascending"] .sort-mark { border-bottom: 5px solid currentColor; }
.table th[aria-sort="descending"] .sort-mark { border-top: 5px solid currentColor; }

.table-scroll { overflow-x: auto; }
/* A row's Actions menu (js/menu.js) can live inside a table cell now, and
   overflow-x: auto forces overflow-y out of visible, so an open popover near
   the bottom of a short table would be clipped by the card. While a menu
   inside the container is open, the container lets it out; the menu closes on
   any choice or dismissal, so the horizontal scroll is only ever paused for
   that moment. */
.table-scroll:has(.menu-pop:not([hidden])) { overflow-x: visible; }

/* ── Badges ───────────────────────────────────────────────── */

.badge {
  display: inline-flex; align-items: center; gap: 5px;
  height: 22px; padding: 0 9px; border-radius: var(--radius-pill);
  font-size: 12px; font-weight: 700; letter-spacing: 0.01em;
  background: var(--off-white); color: var(--text-dim); border: 1px solid var(--line);
}
/* Role badges cannot be keyed to role names: roles are administrator-composed
   and renameable, so a per-role class would be dead the moment somebody typed
   their own. Two neutral variants instead, chosen by what the row IS rather
   than what it is called. */
/* THE INK IS A DARKENED VERSION OF THE HUE, NOT THE HUE, which is the recipe the
   .impact-*, .ptype-* and .status-* families all follow and the one the badges
   never had applied. Measured on 2026-08-25 while auditing every chip in this
   file: `.badge.info` was 3.73:1 and `.badge.system` 4.54:1, so one FAILED WCAG
   AA outright and the other passed by a whisker. Using the brand colour straight
   is what caused it: these hues are chosen to be seen as a fill, not read as
   text. The darker inks below are the same values their sibling chips already
   use, so nothing new is invented, and they land at 6.7:1 and 7.3:1. */
.badge.system { background: rgba(18, 118, 145, 0.1); border-color: rgba(18, 118, 145, 0.35); color: #0B4A5C; }
.badge.info { background: rgba(100, 119, 186, 0.12); border-color: rgba(100, 119, 186, 0.4); color: #3A4680; }
/* THE THREE DECISIONS AS A BADGE, for the places a decision is reported
   rather than set: the path-length dialog. The same three hues the tree rows
   wear, so a folder reads the same wherever it is named, and the same
   darkened inks, because a badge is text on a tint and the raw tokens do not
   clear AA at this size. The WORD is always present, so the hue is
   confirmation and never the signal. */
.badge.d-live { background: rgba(150, 212, 4, 0.18); border-color: rgba(150, 212, 4, 0.55); color: #4A6A00; }
.badge.d-archive { background: rgba(178, 107, 0, 0.14); border-color: rgba(178, 107, 0, 0.45); color: #8A5300; }
.badge.d-leave { background: rgba(100, 119, 186, 0.16); border-color: rgba(100, 119, 186, 0.45); color: #48579A; }
/* Indigo ink on Dufeu Green: white on green is about 1.7:1 and fails AA. */
.badge.active { background: rgba(150, 212, 4, 0.18); border-color: rgba(150, 212, 4, 0.55); color: #4A6A00; }
/* `--text-mute` on `--off-white` measured 3.43:1, the worst chip in the file and
   a clear AA failure. `--text-dim` is the same grey family two steps darker and
   is what every other neutral chip uses (`.status-draft`, `.ptype-any`), which
   takes it to 7.4:1. */
.badge.inactive { background: var(--off-white); border-color: var(--line); color: var(--text-dim); }
.badge.outcome-success { background: rgba(150, 212, 4, 0.16); border-color: rgba(150, 212, 4, 0.5); color: #4A6A00; }
/* Both were the raw hue as ink again: denied measured 3.62:1 (an AA failure) and
   failure 4.69:1. The replacements are the inks `.status-attention` and
   `.impact-high` already carry for the same two hues, at 6.7:1 and 6.1:1. These
   two report the outcome of an access-code challenge, so somebody is reading
   them to find out whether a customer got in: worth being legible. */
.badge.outcome-denied { background: rgba(178, 107, 0, 0.12); border-color: rgba(178, 107, 0, 0.38); color: #6E4200; }
.badge.outcome-failure { background: rgba(192, 57, 43, 0.1); border-color: rgba(192, 57, 43, 0.35); color: #A32E22; }

/* ── Status chips ─────────────────────────────────────────── */
/*
   One chip per state of a piece of work, and the ONLY place those colours are
   defined. A status is read at a glance across a long list, so the palette does
   real work here and is chosen by meaning rather than by what looks pretty.
   The line that decides a colour: A STATE SOMEBODY HERE MUST ACT ON NEVER
   WEARS THE CHIP OF A STATE THEY ARE WAITING ON.

     nothing has gone out yet   grey            (draft, pending)
     dispatched, with them      green tint      (sent: it left the building)
     chased, still with them    amber tint      (reminded)
     WE owe the next action     amber, deeper   (needs attention)
     the machine is working     blue            (active: closing, freezing, surveying)
     being shaped by us         yellow tint     (planning, proposing, scheduling)
     a good answer landed       FILLED green    (signed off, approved, paid)
     finished                   FILLED teal     (complete)
     stopped on purpose         red tint        (declined, cancelled)
     went wrong                 red, deeper     (failed)
     closed without a reply     muted iris      (auto closed: intended, not a fault)

   Sent is green on purpose (a sent request is the good news this app exists to
   make happen), and the two end states are the only FILLED chips, so "it went
   out" and "it is done" cannot be mistaken for each other at a glance. The
   amber and red pairs are separated the same way declined/failed always were:
   depth of tint and darkness of ink.

   Colour is never the only signal: every chip carries its own words, because
   about one man in twelve cannot reliably separate the red from the green.
   Ink is Indigo or a darkened tint on every fill, since white text fails WCAG
   AA on Dufeu Blue and Green (white on Teal clears AA at 5.3:1, which is what
   lets the complete chip fill).
*/
.status {
  display: inline-flex; align-items: center; gap: 6px;
  height: 24px; padding: 0 10px; border-radius: var(--radius-pill);
  font-size: 12px; font-weight: 700; letter-spacing: 0.01em;
  border: 1px solid; white-space: nowrap;
}
/* A 7px dot, so the state is still distinguishable in a greyscale printout. */
.status::before {
  content: ""; width: 7px; height: 7px; border-radius: 50%;
  background: currentColor; flex-shrink: 0;
}
.status-draft      { background: var(--off-white);            border-color: var(--line);                  color: var(--grey-dark); }
/* SENT IS ORANGE, AND COMPLETE IS THE GREEN IT USED TO BORROW. Neil's call,
   2026-08-14, on the chips this application owns as against the ones mirrored
   from an Autotask project status: "Sent (Orange), Completed (Green), Not
   Completed (Darker red), Draft (Grey)".

   It reverses the reading the block above was built on, where sent was green
   because the email leaving the building was the good news. Neil's line is the
   plainer one and it is his: green means the thing is FINISHED, and something
   sitting in a customer's inbox unanswered is not finished. Orange says waiting.

   REMINDED FOLLOWS SENT INTO ORANGE rather than staying amber beside it. It is
   the same wait wearing thinner (it is counted in the same tile), and an amber
   chip next to the new orange one would read as two hues nobody can name apart
   at 12px. The pair separates by depth of tint and darkness of ink, exactly the
   way declined and failed always have. The inks are the impact ramp's proven
   ones, both clear of 6:1 on their own tint. */
.status-sent       { background: rgba(214, 106, 0, 0.15);     border-color: rgba(214, 106, 0, 0.48);      color: #8A4200; }
.status-reminded   { background: rgba(214, 106, 0, 0.30);     border-color: rgba(214, 106, 0, 0.72);      color: #6B3300; }
.status-attention  { background: rgba(178, 107, 0, 0.20);     border-color: rgba(178, 107, 0, 0.60);      color: #6E4200; }
.status-active     { background: rgba(108, 172, 228, 0.18);   border-color: rgba(108, 172, 228, 0.55);    color: #1F5C8B; }
/* Yellow, not the iris it launched as: Neil's 2026-08-11 call for Planning.
   Dark ochre ink on the pale tint, ~7.4:1, comfortably AA at chip size. */
.status-planning   { background: rgba(230, 195, 0, 0.18);     border-color: rgba(214, 175, 0, 0.55);      color: #6B5600; }
/* SIGNED OFF STAYS THE ONE FILLED CHIP. Indigo on Dufeu Green is ~8.9:1, AA at
   chip size; white on Green would be ~1.7:1 and is exactly the pairing
   brand/DESIGN.md forbids.

   COMPLETE IS NOW THE GREEN TINT rather than filled teal (Neil, 2026-08-14:
   "Completed (Green)"). Signed off and complete therefore sit in the same hue,
   which is right (both are good answers) and they stay apart the way the rest of
   this block does: fill against tint. Teal is not lost, it is just no longer
   carrying a meaning green now carries. */
.status-signed     { background: var(--dufeu-green);          border-color: rgba(71, 102, 10, 0.50);      color: var(--dufeu-indigo); }
.status-complete   { background: rgba(150, 212, 4, 0.22);     border-color: rgba(150, 212, 4, 0.62);      color: #47660A; }
.status-declined   { background: rgba(192, 57, 43, 0.10);     border-color: rgba(192, 57, 43, 0.40);      color: #A32E22; }
.status-autoclosed { background: rgba(100, 119, 186, 0.10);   border-color: rgba(100, 119, 186, 0.38);    color: #4C5A93; }
.status-failed     { background: rgba(192, 57, 43, 0.16);     border-color: rgba(192, 57, 43, 0.55);      color: #8E2419; }
/* NOT COMPLETED, the deepest red in the set. It is a GROUP rather than a state
   (PENDING, DECLINED and EXPIRED are counted under it on the Surveys page), and
   it gets a class of its own instead of borrowing `.status-attention`, which
   means something else entirely ("we owe the next action") and is used in
   banners. Darker than declined on purpose: Neil asked for "Not Completed
   (Darker red)", and a survey nobody will ever answer is a worse outcome than
   one somebody actively declined. */
.status-notcompleted { background: rgba(192, 57, 43, 0.26);   border-color: rgba(192, 57, 43, 0.68);      color: #7A1F16; }

/* IMPACT IS NOT A STATUS, so it has its own four classes rather than borrowing
   four status ones that happened to be near enough. It used to borrow them, and
   the cost was invisible: recolouring a workflow chip would silently recolour a
   customer's risk key, and None read GREY (`status-draft`) when what it means is
   "no impact", which is good news and reads green anywhere else.

   The ramp is None green, Low yellow, Medium orange, High red.

   THE INK IS A DARKENED VERSION OF THE HUE, DELIBERATELY. Yellow and orange at
   full strength fail WCAG AA as text (the same problem as the brand rule about
   white on Dufeu Green, from the other side), so each chip is a light tint of
   its hue with darker ink of the same hue. All four clear 6:1 against their own
   tint. Do not "brighten" these text colours to match the swatch: the swatch is
   the background, not the writing.

   Yellow and orange are the pair that has to survive being small and printed,
   which is why the orange is not `--warn`: that amber sits too close to the
   yellow to read as a separate step.

   The dot in `.status::before` takes `currentColor`, so the four are still told
   apart by tone in a greyscale printout, not by hue alone. */
/* THE FOUR IMPACT CHIPS, BROUGHT INTO LINE WITH THE LOCATION ONES.

   Neil, 2026-08-25, having seen the new Location bubbles: "the colours need to
   be in line with that shade I feel". So these are solid and vivid now rather
   than a 20% wash, and each carries a meter instead of the `.status` dot.

   THE RAMP IS UNCHANGED, and that was the constraint: "they need to stay the
   same". Green to yellow to orange to red, in that order, meaning exactly what
   it meant before. What changed is the STRENGTH of each step, not which step is
   which.

   A LIGHT VERSION OF EACH HUE, NOT A SATURATED ONE, and that is what makes one
   ink treatment possible across all four. Solid `--danger` red needs white text
   while solid green needs dark, and a four-step scale whose first two chips have
   dark ink and whose last has white is the fault the `.chip-c*` block warns
   about. Taking a lighter red instead keeps dark ink legible on every step:
   7.1:1, 8.7:1, 6.1:1 and 5.5:1, all clear of WCAG AA.

   THE METER REPLACES THE DOT, and it is a straight upgrade. The dot only ever
   said "this is a state"; three bars say WHICH state, so the level survives a
   greyscale printout and reads for somebody who cannot separate the red from
   the green. That matters more here than anywhere else in the portal, because
   green-to-red is precisely the ramp colour-blindness breaks. The chip still
   carries its own word, so the meter is a third signal rather than the only
   one.

   Impact still keeps `.status`'s geometry while Location keeps `.ptype`'s. The
   two families now share a weight, so the meter and the icon are what tell them
   apart at a glance: a scale has bars, a property has a picture. */
/* The same pale fill and full-strength edge the location chips take, and for the
   same reason: the meter identifies the level, so the colour is the second
   signal. The ramp and the inks are untouched, so each step gained contrast when
   its fill lightened. */
.impact-none   { background: #CAF3D9; border-color: #4ADE80; color: #0B3D1E; }
.impact-low    { background: #FEF0B9; border-color: #FACC15; color: #3A2E00; }
.impact-medium { background: #FEE6C4; border-color: #FB923C; color: #4A2200; }
.impact-high   { background: #FDD5D5; border-color: #F87171; color: #4C0F0F; }
/* The dot goes wherever a meter is drawn, and only there: a chip carrying both
   says the same thing twice and crowds a narrow column. `.imp-bars` is the mark
   the renderers put on, so a `.status` chip that is not an impact keeps its dot
   exactly as it always had it. */
.status.imp-bars::before { display: none; }
.status.imp-bars svg { width: 12px; height: 12px; flex: 0 0 auto; margin-right: 4px; }
/* The same floor the location chips take, for the same reason and in the same
   unit. Scoped to `.imp-bars` so the workflow chips, which sit in columns of
   their own and have no sibling to line up with, are untouched. */
.status.imp-bars { min-width: 8em; }

/* ── How far a plan task has got ───────────────────────────
   THE MARK A PERSON PUTS ON A PLAN ROW, and the word that keeps its colour from
   being the only signal. Neil, 2026-08-26: "I will add text like TBC, Completed,
   etc. I think it would be good if we have Completed and In-progress as options
   that, when selected, Completed makes that line go green and In-progress makes
   that line go yellow."

   ITS OWN THREE CLASSES, NOT THREE WORKFLOW ONES THAT WERE NEAR ENOUGH. The
   numbers below ARE the numbers .status-complete, .status-planning and
   .status-declined already carry, deliberately: nothing new enters the palette,
   and a Completed task reads in the same green as a completed sign-off. What
   they must not share is the CLASS. A sign-off's status and a task's progress
   are different scales on different records, and borrowing would mean recolouring
   one silently recoloured the other, which is the same trap the note above
   .impact-none warns about.

   TRANSLUCENT ON PURPOSE. The chip sits on the row its own state has tinted, so
   the fill compounds with the wash and the pill comes out DEEPER on a marked row
   than it would on a white one, which is the direction that helps. Both inks
   clear WCAG AA on the compounded fill (5.4:1 and 5.9:1) as well as on white.

   BLOCKED GETS NO ROW COLOUR, only this chip. Two row tints were asked for and
   two exist; a third, red, would sit under the red High impact chip and fight
   it. The state is still said in words rather than being silent, which is what
   it needs: nothing but the Autotask pull sets it. */
.tprog-complete { background: rgba(150, 212, 4, 0.22);  border-color: rgba(150, 212, 4, 0.62);  color: #47660A; }
.tprog-doing    { background: rgba(230, 195, 0, 0.18);  border-color: rgba(214, 175, 0, 0.55);  color: #6B5600; }
.tprog-blocked  { background: rgba(192, 57, 43, 0.10);  border-color: rgba(192, 57, 43, 0.40);  color: #A32E22; }
/* One size down from a full status pill, because it rides inside a table cell
   beside the task's name rather than filling a column of its own. */
.status.tprog { height: 20px; padding: 0 8px; font-size: 11px; }

/* ── Plan type ─────────────────────────────────────────────
   WHAT KIND OF PLAN THIS IS, not what state it is in. A plan type is a fixed
   property of the record and a status is where it has got to, so the two must
   not share a palette: recolouring a workflow state would otherwise silently
   recolour the type beside it.

   The four hues are Neil's, 2026-08-14: "Project types in the pre-sales /
   project plans let's give them some bubble colours: Yellow Pre-sales and Green
   as Project. Data Migration Plan = Orange and Kickoff Plan = light blue".

   The fifth, the Project checklist, arrived with that row and is the one hue in
   the palette none of the four had taken: --dufeu-iris (#6477BA), the far end of
   --gradient-extended. It is a brand colour rather than a new one, put through
   the same recipe as its siblings (a 0.20 tint of the hue, a 0.60 border of it,
   and ink at the same hue and saturation two thirds of the lightness down),
   which lands it at 6.9:1 on its own tint.

   Same geometry as `.status` (they sit in the same columns) but WITHOUT the dot,
   which is what tells a type apart from a state at a glance in a mixed row. Each
   is a light tint of its hue with darker ink of the same hue, the `.impact-*`
   recipe, so every one clears 6:1 on its own tint. Do not brighten the inks to
   match the swatch: the swatch is the background, not the writing. */
.ptype {
  display: inline-flex; align-items: center;
  height: 24px; padding: 0 10px; border-radius: var(--radius-pill);
  font-size: 12px; font-weight: 700; letter-spacing: 0.01em;
  border: 1px solid; white-space: nowrap;
}
.ptype-presales  { background: rgba(230, 195, 0, 0.20);   border-color: rgba(214, 175, 0, 0.60);   color: #6B5600; }
.ptype-project   { background: rgba(150, 212, 4, 0.20);   border-color: rgba(150, 212, 4, 0.60);   color: #47660A; }
.ptype-migration { background: rgba(214, 106, 0, 0.16);   border-color: rgba(214, 106, 0, 0.50);   color: #8A4200; }
.ptype-kickoff   { background: rgba(108, 172, 228, 0.20); border-color: rgba(108, 172, 228, 0.60); color: #1F5C8B; }
.ptype-checklist { background: rgba(100, 119, 186, 0.20); border-color: rgba(100, 119, 186, 0.60); color: #3A4680; }
/* A TEMPLATE THAT IS OFFERED FOR EVERY TYPE, which is a plan template with no
   origin at all (Admin > Templates). Neutral rather than a sixth hue: "All" is
   the absence of a type, and giving it a colour of its own would make the one
   row that has not chosen the loudest in the table. Off-white on the line grey,
   which is `.status-draft`'s recipe and the portal's standing answer for a
   value that is not a state. */
.ptype-any { background: var(--off-white); border-color: var(--line); color: var(--grey-dark); }

/* -- Where the work happens --------------------------------
   The Location cell of an action plan, as a bubble with an icon in it.

   AN ICON, AND THAT IS WHAT CARRIES THE MEANING. Neil's idea, 2026-08-25, after
   two rounds on the colours: "what about little icons next to them to represent
   the location". It is the better answer, because a picture says which location
   this is without the colour having to. That means the row still reads in a
   black-and-white printout, and it reads for the roughly one man in twelve who
   cannot reliably separate one hue from another. Colour became the second
   signal rather than the only one.

   THE THREE HUES ARE DELIBERATELY NOT BRAND COLOURS, and that is a decision
   rather than an oversight. CLAUDE.md says never to introduce a colour that is
   not already a token, and the reason this breaks it is worth writing down: the
   brand palette is blue, green, teal, indigo and iris, which is one arc of the
   wheel, and the file itself says "the brand has no warning or danger colour".
   Three bright, WELL-SEPARATED hues cannot be cut from it: every candidate trio
   came out as two blues and a green, which is exactly what Neil rejected twice
   ("not such a close colour to each other"). He waived the rule for these three
   chips on 2026-08-25: "let's ignore the claude.md file on this occasion as it's
   just the icons not the theme of the entire platform". SCOPE IS THE WHOLE
   POINT of that waiver. These three values are for this one chip family. They
   are not tokens, nothing else may reach for them, and a fourth location does
   not get a fourth invented colour: it gets the neutral chip below.

   Sky, amber and violet sit about 120 degrees apart, so they are told apart by
   hue at a glance and by tone in greyscale. Each carries a deliberately dark
   ink of its own hue, which is the .impact-* recipe: 7.4:1, 8.3:1 and 5.9:1 on
   their own fills, all clear of WCAG AA.

   GEOMETRY IS .ptype's, NOT .status's, AND THE MISSING DOT IS THE POINT.
   Location sits in the same row as Impact, which is a `.status` chip and wears
   the 7px dot. Where the work happens is a property of the task, not a state it
   has reached, so it must not read as a second status. */
/* ONE WIDTH FOR EVERY CHIP IN THE COLUMN. Neil, 2026-08-25: "is there any way
   making the bubbles the same size despite being more letters in some of them".
   A column of pills that each stop at a different point reads as ragged, and the
   eye is being asked to compare them.

   A FLOOR, NOT A FIXED WIDTH, and the difference is what stops this becoming a
   truncation bug. 8em clears the longest word in both families (In-House at
   7.82em, Medium at 7.81em), so every standard chip pads out to the same size.
   An option an administrator invents that is LONGER simply grows past it, which
   is the right failure: a fixed width would either clip somebody's word or force
   it to wrap inside a pill.

   IN em, NOT px, so it scales with the chip's own type. The impact key above a
   plan draws its chips one size down (`.impact-key .status`), and a pixel floor
   would leave those stretched to a width their text no longer fills.

   THE CONTENT STAYS LEFT-ALIGNED, which is what puts every icon in the column on
   the same vertical line. Centring them would make each chip's icon start at a
   different point, which trades one kind of raggedness for another. */
.loc {
  display: inline-flex; align-items: center;
  height: 24px; min-width: 8em; padding: 0 10px; border-radius: var(--radius-pill);
  font-size: 12px; font-weight: 700; letter-spacing: 0.01em;
  /* A VISIBLE EDGE ON THE NEUTRAL CHIP TOO. The three known locations paint this
     over with their own hue; a location the column invented keeps this one, and
     a transparent edge left it as words on a pale ground rather than a bubble.
     Neil, 2026-08-25: "probably ensuring that we have the slight border around
     the bubble". */
  border: 1px solid var(--line); background: var(--off-white); color: var(--grey-dark);
  white-space: nowrap;
}
/* The icon takes the ink of the chip it is in, so one rule colours both. */
.loc svg {
  width: 13px; height: 13px; flex: 0 0 auto; margin-right: 5px;
}
/* PALE FILL, FULL-STRENGTH EDGE. Neil, 2026-08-25, once the icons were in
   place: "now they seem a bit bright and solid". That is the icons doing their
   job. A picture identifies the location on its own, so the colour stopped
   having to carry the meaning and became the second signal, and a second signal
   does not need to fill the row.

   THE EDGE IS WHAT KEEPS IT FROM LOOKING WASHED OUT. The first version of these
   chips was a 20% tint with a 60% border and read as weak rather than as quiet.
   The fill here is lighter still, but the border is the hue at FULL strength, so
   the colour registers as a defined shape instead of a smudge.

   THE INKS ARE UNCHANGED FROM THE SOLID VERSION, which means every chip GAINED
   contrast when the fill lightened: 11.4:1, 12.7:1 and 9.8:1, comfortably past
   WCAG AA and past AAA. */
.loc-remote  { background: #C3EBFD; border-color: #38BDF8; color: #0B2534; }
.loc-onsite  { background: #FEEBC0; border-color: #FBBF24; color: #3A2A00; }
.loc-inhouse { background: #E4DCFD; border-color: #A78BFA; color: #241653; }
/* A LOCATION THE COLUMN INVENTED KEEPS THE NEUTRAL CHIP and draws no icon: an
   option nobody here has met has no picture that would be true of it, and
   picking one would be guessing at somebody's meaning. The bare `.loc` above is
   that chip, so this needs no rule of its own. */

/* ── List foot ─────────────────────────────────────────────
   THE ONE FOOTER EVERY LIST PAGE WEARS, added 2026-08-14 on Neil's ask for the
   Projects page: "let's remove the numbers next to the tabs like Live, Pre-sales
   and Completed. Instead, include the number at the bottom of the screen, right
   side. Also implement number of items on the page like we did for Lessons
   Learnt. But, move the box to the bottom right corner and make it smaller. Do
   this for lessons learnt at the same time so it's uniformed".

   So the count and the per-page control BOTH live down here, right-aligned,
   under the table rather than in the card head. The pager (`.pager`, defined
   with the table styles) sits on the left of the same bar where a page has one,
   which is what keeps Previous/1/2/Next and "50 projects" on one line instead of
   two stacked bars.

   `.count` is the total the filter matched, and it is the ONLY place that number
   is drawn now: a tab that carries its own count and a footer that carries
   another is two numbers to keep in step, and they drifted.

   The select is one step down from a form control (28px, 12.5px) because it is
   furniture rather than a question: "make it smaller" is the whole of the
   instruction, and shrinking the type as well as the box is what stops it
   reading as a shrunken input. */
.list-foot {
  display: flex; align-items: center; justify-content: flex-end;
  gap: 14px; flex-wrap: wrap;
  padding: 12px 18px; border-top: 1px solid var(--line-soft);
}
/* The pager is a bar in its own right when it stands alone (it carries its own
   padding and top rule at the `.pager` declaration), so moving it INSIDE this
   footer has to strip both, or the left of the bar draws a second rule across
   its own width and stands taller than the right of it. */
.list-foot .pager { margin-right: auto; padding: 0; border-top: 0; }
.list-foot .count { font-size: 13px; color: var(--text-mute); font-variant-numeric: tabular-nums; }
.list-foot .perpage { display: inline-flex; align-items: center; gap: 7px; }
.list-foot .perpage label { font-size: 12.5px; color: var(--text-mute); font-weight: 600; }
.list-foot .perpage select {
  height: 28px; padding: 0 7px;
  font-family: inherit; font-size: 12.5px; color: var(--text);
  background: var(--panel); border: 1px solid var(--line); border-radius: 7px;
}
.list-foot .perpage select:focus {
  outline: none; border-color: var(--dufeu-teal);
  box-shadow: 0 0 0 3px rgba(18, 118, 145, 0.15);
}

/* Days a request has been outstanding. Escalates so a stale one is findable by
   scanning rather than by sorting. */
.age { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--text-mute); }
.age.warn { color: var(--warn); }
.age.bad { color: var(--danger); }

/* ── Dropdown (multi-select) ──────────────────────────────── */
/*
   A button that opens a popover of tick boxes, so a long option list is one line
   until somebody needs it. Introduced for the role builder's permission groups,
   where seven always-open fieldsets pushed the save button off the screen, and
   deliberately built as a shared component because filters will want the same
   thing.
*/
.dd-wrap { position: relative; }
.dd-btn {
  display: inline-flex; align-items: center; gap: 8px;
  min-height: 38px; padding: 0 12px; width: 100%;
  font-family: inherit; font-size: 14px; font-weight: 600; text-align: left;
  color: var(--text-dim); background: var(--panel);
  border: 1px solid var(--line); border-radius: 9px; cursor: pointer;
}
.dd-btn:hover { border-color: var(--dufeu-blue); color: var(--text); }
.dd-btn .count {
  min-width: 20px; height: 20px; padding: 0 6px; margin-left: auto;
  border-radius: var(--radius-pill);
  background: var(--dufeu-blue); color: var(--dufeu-indigo);
  font-size: 11px; font-weight: 700;
  display: inline-grid; place-items: center;
}
/* `--text-mute` on `--off-white` is 3.43:1 and fails WCAG AA. It is fine as body
   text on white (4.6:1), which is why it survived here, but a pill puts it on
   the off-white and takes it under. `--text-dim` is the same grey family and the
   one every other neutral pill uses, at 7.4:1. Found 2026-08-25 auditing every
   pill-shaped element in this file. */
.dd-btn .count.zero { background: var(--off-white); color: var(--text-dim); border: 1px solid var(--line); }
.dd-btn .chev { transition: transform 0.15s; flex-shrink: 0; width: 14px; height: 14px; }
.dd-btn[aria-expanded="true"] { border-color: var(--dufeu-blue); color: var(--text); }
.dd-btn[aria-expanded="true"] .chev { transform: rotate(180deg); }
.dd-menu {
  position: absolute; top: calc(100% + 5px); left: 0; z-index: 45;
  min-width: 100%; max-width: 340px; max-height: 320px; overflow-y: auto;
  padding: 6px 10px 8px;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: 11px; box-shadow: var(--shadow-pop);
}
.dd-menu .foot {
  display: flex; gap: 12px; justify-content: space-between;
  padding-top: 7px; margin-top: 4px; border-top: 1px solid var(--line-soft);
}
.dd-menu .lnk {
  background: none; border: 0; padding: 4px 2px; cursor: pointer;
  font-family: inherit; font-size: 12.5px; font-weight: 600; color: var(--dufeu-teal);
}
.dd-menu .lnk:hover { text-decoration: underline; }
.dd-menu .lnk:disabled { color: var(--text-mute); cursor: not-allowed; text-decoration: none; }

/* ── Actions menu ─────────────────────────────────────────── */
/*
   A button that opens a popover of ACTIONS, drawn by js/menu.js. Not the
   dropdown above: that is a multi-select of tick boxes and stays open while you
   tick, this closes on choice the way a native menu does.

   Introduced for the plan page, whose head had grown seven controls and stopped
   reading as the name of a document.
*/
.menu-wrap { position: relative; display: inline-block; }
.menu-btn { display: inline-flex; align-items: center; gap: 7px; }
.menu-btn .chev { transition: transform 0.15s; flex-shrink: 0; width: 14px; height: 14px; }
.menu-btn[aria-expanded="true"] .chev { transform: rotate(180deg); }

/* THE THREE-DOT TRIGGER, for a menu that sits ON a row instead of over a
   record (Neil, 2026-08-14: "press 3 dots and press send, edit, preview etc.
   One less click"). Square, borderless until it is wanted, and the same 24px
   the status chips beside it stand at, so a column of them does not add a
   second rhythm to the row.

   IT SITS IN THE LAST CELL, right-aligned, which is where `td.actions` already
   puts a row's actions. It spent an afternoon beside the name and Neil moved it
   the same day ("Let's move it to the far right next to the number of days"):
   beside the name it sat at a different horizontal position on every row,
   because it followed a project name of any length, so there was no column to
   run an eye down. In the last cell there is.

   BORDERLESS AT REST, OUTLINED ON HOVER AND FOCUS. Forty bordered buttons down
   a table is the "table about its buttons" this is meant to avoid; the
   affordance arrives on approach, and focus-visible keeps it reachable from the
   keyboard. Never `visibility` or `display` on a row-hover reveal: a control
   that leaves the accessibility tree is a row a keyboard cannot act on. */
.menu-wrap.dots { display: inline-block; vertical-align: middle; }
.menu-btn.dots {
  width: 24px; height: 24px; min-width: 0; padding: 0;
  justify-content: center;
  border-color: transparent; background: transparent; color: var(--text-mute);
  border-radius: var(--radius-sm);
}
.menu-btn.dots:hover,
.menu-btn.dots:focus-visible,
.menu-btn.dots[aria-expanded="true"] {
  border-color: var(--line); background: var(--panel); color: var(--text);
}
/* Hard against the right edge of its cell, so the column of triggers is a
   straight line whatever the widths above it do. */
td.actions .menu-wrap.dots { margin-left: auto; }

/* THE NAME IS NO LONGER A CONTROL on the lists that carry a row menu (Neil,
   2026-08-14: "Stop the ability to be able to click the sign off name"). It
   keeps the weight and the ink it had as a link, because it is still the thing
   the eye lands on first, and loses the pointer and the hover underline, which
   were promising a click that no longer happens. `.row-name` on a plain element
   rather than a button is what this rule serves. */
span.row-name { cursor: default; }
span.row-name:hover { text-decoration: none; }
.menu-pop {
  position: absolute; top: calc(100% + 5px); right: 0; z-index: 45;
  min-width: 176px; padding: 6px;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: 11px; box-shadow: var(--shadow-pop);
  display: flex; flex-direction: column;
}
.menu-item {
  display: block; width: 100%; text-align: left;
  padding: 8px 10px; border: 0; border-radius: 7px;
  background: none; cursor: pointer;
  font-family: inherit; font-size: 14px; font-weight: 600; color: var(--text-dim);
}
.menu-item:hover { background: var(--off-white); color: var(--text); }
/* AN ACTION THAT EXISTS BUT CANNOT HAPPEN YET. Greyed rather than dropped, so a
   menu says what a record can carry as well as what it carries today: an absent
   item reads as a feature we do not have, a greyed one as an answer that has not
   arrived. The cursor states it too, because a word that simply does not respond
   to a click reads as a broken control. It keeps the plain surface on hover: a
   highlight under the pointer would promise it is about to do something. */
.menu-item[disabled] { color: var(--text-mute); cursor: not-allowed; }
.menu-item[disabled]:hover { background: none; color: var(--text-mute); }
.menu-item.danger[disabled], .menu-item.danger[disabled]:hover { color: var(--text-mute); }
/* THE ITEM THE KEYBOARD IS ON IS FILLED, NOT FRAMED. This was a 2px blue
   outline, and in the context menu (which opens with its first item already
   focused, so the ring is on screen every single time) a rectangle inside a
   rounded popover read as a box drawn around the words rather than as a
   highlight on a choice. The item now takes the same surface hovering it does,
   with an edge bar in its own ink so it is not a colour change alone, which is
   how a native menu marks the current item.
   The outline is stated as none rather than left to be overpainted: a UA ring
   under a filled row is the frame again, one pixel out.

   AND ONLY WHERE A KEYBOARD IS DRIVING, which is what .keyboard on the popover
   says (js/menu.js writes it on open and again on the first arrow key). The
   sentence above was true and the rule still fired on a menu opened with a
   right-click, because js/ui.js focuses the first item of a row menu itself: the
   bar landed beside whichever action happened to be first, for somebody who had
   chosen nothing and was about to click. Neil, 2026-08-26: "Sometimes when right
   click the phases I get a little black line next to edit (left of it)."
   :focus-visible alone could not tell those apart, because a programmatic focus
   inherits whatever the browser last saw, which is why it was "sometimes".

   THE REFUSED RING IS NOT SCOPED WITH IT, and that split is the whole reason
   these are two rules. The page's global :focus-visible draws a teal ring around
   anything focused, and a menu item says no to it wherever the focus came from:
   scoping the "outline: none" to .keyboard as well would have swapped one
   artefact on a pointer-opened menu for a louder one. */
.menu-item:focus-visible { outline: none; }
.menu-pop.keyboard .menu-item:focus-visible {
  background: var(--off-white); color: var(--text);
  box-shadow: inset 3px 0 0 currentColor;
}
/* Forced colours throw every background away, so the fill alone would leave a
   High Contrast user with no current item at all. There, and only there, the
   ring comes back in the system's own highlight colour. */
@media (forced-colors: active) {
  .menu-pop.keyboard .menu-item:focus-visible { outline: 2px solid Highlight; outline-offset: -2px; }
}
/* Set apart rather than only coloured, so the irreversible one is not simply the
   red word in a list somebody is scanning quickly. */
.menu-item.danger {
  color: var(--danger);
  margin-top: 5px; padding-top: 11px; border-top: 1px solid var(--line-soft); border-radius: 0 0 7px 7px;
}
.menu-item.danger:hover { background: rgba(192, 57, 43, 0.08); color: var(--danger); }
/* The destructive item keeps its own ink when the keyboard is on it. Without
   this the generic focus fill above (which is later in the file, and as
   specific) would repaint Delete in the ordinary text colour at the one moment
   somebody is about to press it. The edge bar is currentColor, so it is red. */
.menu-pop.keyboard .menu-item.danger:focus-visible { background: rgba(192, 57, 43, 0.08); color: var(--danger); }

/* A FLYOUT BESIDE THE POPOVER. An item can carry choices of its own (the plan
   editor's task row: Progress, which opens In progress, Completed and Clear),
   and js/menu.js opens those in a panel to the right of the popover, top-aligned
   with the item that opened it, with the parent list still on screen. Neil,
   2026-08-27: "Can the new menu pop out to the right of the main box when it's
   pressed?" It swapped the popover's contents for half a day before that, which
   is why nothing here is called a page any more.

   IT IS THE SAME SURFACE, so it carries `menu-pop` as well and every rule above
   reaches it: the panel, the border, the shadow, the padding, the greyed item,
   the destructive item and the mark on the item a keyboard is on are written
   once rather than twice. This class is only where it hangs.

   AND WHERE IT HANGS IS MEASURED IN SCRIPT, so top and left arrive inline: the
   flip to the LEFT when the popover is near the right edge of the window, and
   the clamp that keeps a panel opened from the last item on screen, are both
   answers to what the viewport is doing at the moment of the press.

   FIXED, WHATEVER PUT THE POPOVER WHERE IT IS: absolute on an ordinary page,
   fixed inside a scroll box that would otherwise clip it, hung off a moving
   point for a right-click menu. The panel is placed from the popover's own
   rectangle in the viewport's coordinates under all three, and a fixed element
   is clipped by none of them.

   ONE STEP ABOVE THE POPOVER, because the two touch. Not a new layer. */
.menu-flyout { position: fixed; top: auto; left: auto; right: auto; z-index: 46; }
/* THE ITEM WHOSE PANEL IS OUT READS AS THE OPEN ONE, taking the surface hovering
   it already gives. Without it a list with a panel beside it says nothing about
   which of its words the panel came out of. */
.menu-item[aria-expanded="true"] { background: var(--off-white); color: var(--text); }
/* THE ITEM THAT OPENS A PANEL SAYS SO WITH A MARK, not with a sentence: the same
   answer the labelled trigger's chevron and the row's three dots already give,
   pointing the way the panel comes from. Trailing, and the label takes the space
   before it, so a column of items still reads down one edge. */
.menu-item.menu-nav { display: flex; align-items: center; gap: 7px; }
.menu-item.menu-nav > span { flex: 1; }
.menu-item.menu-nav .chev { width: 14px; height: 14px; flex-shrink: 0; opacity: 0.65; }

/* ── Context menu ─────────────────────────────────────────── */
/*
   The SAME actions menu as above, opened at a point on the screen instead of
   under a button. js/plan.js uses it for a right-click on a task row; nothing
   about opening, closing or the keyboard is duplicated for it, because it is
   js/menu.js markup in a host this moves.

   THE HOST IS A ZERO-SIZE FIXED POINT, so positioning the menu is moving one
   element and nothing on the page reflows. The script sets left and top; these
   rules are what make the popover open down and to the RIGHT of that point,
   which is where a context menu opens everywhere else, rather than the
   right-aligned drop the button version wants.

   THE BUTTON js/menu.js NEEDS IS STILL THERE. Opening and closing are its job,
   and a component that is told a menu is open by a click on its own button
   should be told that way. It is simply not something to look at, not something
   the pointer can hit, and not a tab stop (the script sets tabindex).
*/
.context-host { position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 45; }
.context-host .menu-btn {
  position: absolute; top: 0; left: 0;
  width: 1px; height: 1px; min-width: 0; padding: 0; border: 0;
  opacity: 0; pointer-events: none;
}
.context-host .menu-pop { top: 0; left: 0; right: auto; }
/* The rule that sets a destructive action apart from the ones above it has
   nothing to sit under when it is the only action, and a lone item under a rule
   reads as the remains of a list. */
.context-host .menu-item.danger:first-child {
  margin-top: 0; padding-top: 8px; border-top: 0; border-radius: 7px;
}

/* ── Back link ────────────────────────────────────────────── */
/*
   The way out of a detail page, above its title rather than beside it in the
   actions. A page's actions are things it DOES; where you came from is not one
   of them, and putting it there made the primary action compete with a link.

   THE ARROW IS DRAWN HERE AND NOWHERE ELSE (2026-08-21). It used to be an
   inline <svg> pasted into nine pages and built a tenth time from a string
   constant in js/plan.js, which is how three pages ended up with no arrow at
   all: a page written later copied one of the pages that had never been given
   it. Worse, three page scripts set the label with `link.textContent = ...`,
   which wipes every child the anchor has, so adding the arrow to their markup
   would have produced an arrow that vanished the moment the page loaded its
   data.

   A pseudo-element cannot be pasted wrong, cannot be forgotten by a new page,
   and cannot be destroyed by anything a page script writes into the anchor.
   Every `.backlink` gets the arrow by being a `.backlink`.

   `mask` rather than `background-image` so the arrow takes `currentColor`,
   which is what makes it follow the hover colour below without a second
   declaration. The data URI is allowed by the Content Security Policy in
   server/app.js (`imgSrc: ["'self'", 'data:']`); it is an image, not a script,
   so `scriptSrc: 'self'` is untouched.
*/
.backlink {
  display: inline-flex; align-items: center; gap: 5px;
  margin-bottom: 3px;
  font-size: 13px; font-weight: 600; color: var(--text-mute); text-decoration: none;
}
/* THE `currentColor` SPELLING BELOW IS THIS FILE'S, not a slip. Stylelint's
   value-keyword-case wants the CSS-wide lower-case `currentcolor`; the keyword
   is case-INSENSITIVE, so the two are the same declaration, and every other use
   in this file is spelled this way (the sort marks, the status dot, the focused
   menu item). One lower-cased occurrence would be the only one of six, and
   test/planEditor.test.js reads the spelling back out of this file. */
.backlink::before {
  content: ''; flex-shrink: 0; width: 14px; height: 14px;
  /* stylelint-disable-next-line value-keyword-case */
  background-color: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 12H5M12 19l-7-7 7-7'/%3E%3C/svg%3E") no-repeat center / contain;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 12H5M12 19l-7-7 7-7'/%3E%3C/svg%3E") no-repeat center / contain;
}
/* The underline belongs to the words, not to the arrow: a rule running under
   an arrowhead reads as a strikethrough. */
.backlink:hover { color: var(--dufeu-teal); }
.backlink:hover span { text-decoration: underline; }
/* AN ARROW LEFT IN A PAGE'S MARKUP IS REDUNDANT, NOT A SECOND ARROW. The rule
   above owns the icon, so a page still carrying the old inline <svg> would draw
   two of them, and an inline <svg> with no width attribute takes the width it is
   given. The tripwire in test/uiConventions.test.js is what removes the markup;
   this is what stops a page looking broken in the meantime. */
.backlink svg { display: none; }

/* ── Empty and loading states ─────────────────────────────── */

.empty {
  padding: 52px 24px; text-align: center;
  display: flex; flex-direction: column; align-items: center; gap: 14px;
}
.empty .mark {
  width: 52px; height: 52px; border-radius: 14px;
  background: var(--gradient-primary); opacity: 0.9;
}
.empty h2 { font-size: 17px; }
.empty p { margin: 0; color: var(--text-mute); font-size: 14px; }

.spinner {
  width: 16px; height: 16px; border: 2px solid var(--line);
  border-top-color: var(--dufeu-blue); border-radius: 50%;
  animation: spin 0.7s linear infinite; display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }

.loading { padding: 34px; text-align: center; color: var(--text-mute); font-size: 14px; }

/* One muted sentence where a FILTERED table matched nothing, or a load failed
   and the sentence says so. The same treatment as .loading, split out so
   .loading returns to meaning loading only: a spinner, never a sentence. */
.empty-note { padding: 34px; text-align: center; color: var(--text-mute); font-size: 14px; }

/* Inline feedback after an action. Announced to assistive tech via aria-live
   on the container in the markup. */
.notice { padding: 11px 14px; border-radius: 9px; font-size: 13.5px; border: 1px solid; margin-bottom: 16px; }
.notice:empty { display: none; }
.notice.ok { background: rgba(150, 212, 4, 0.12); border-color: rgba(150, 212, 4, 0.5); color: #3F5A00; }
.notice.error { background: rgba(192, 57, 43, 0.08); border-color: rgba(192, 57, 43, 0.35); color: var(--danger); }
/* Something needs doing, and it is not an error: nothing has gone wrong, the
   work is just parked until somebody acts. Amber rather than red for that
   reason, and the same tint and ink as .status-attention below so the chip in a
   list and the banner on the page it opens are recognisably the same fact.
   #8A5300 on this tint is 5.46:1, which clears AA; --warn itself does not. */
.notice.warn { background: rgba(178, 107, 0, 0.12); border-color: rgba(178, 107, 0, 0.40); color: #8A5300; }
/* The same fact at the size of a marker, for a name in a list. It carries a
   visible "!" and an accessible name, so it is never colour on its own. */
.attention-mark {
  display: inline-flex; align-items: center; justify-content: center;
  width: 18px; height: 18px; margin-right: 6px; flex-shrink: 0;
  border-radius: 50%; font-size: 12px; font-weight: 700; line-height: 1;
  background: rgba(178, 107, 0, 0.12); border: 1px solid rgba(178, 107, 0, 0.40); color: #8A5300;
}

/* ── Tabs ─────────────────────────────────────────────────── */

.tabs { display: flex; gap: 3px; border-bottom: 1px solid var(--line); margin-bottom: 20px; }
.tabs button {
  background: none; border: 0; border-bottom: 2px solid transparent;
  font-family: var(--font-heading); font-size: 14px; font-weight: 600;
  color: var(--text-mute); padding: 9px 14px; cursor: pointer; margin-bottom: -1px;
}
.tabs button:hover { color: var(--text); }
.tabs button[aria-selected="true"] { color: var(--dufeu-teal); border-bottom-color: var(--dufeu-teal); }

/* ── Rail: a group WITHIN a tab ───────────────────────────── */
/*
   Admin > Templates holds three kinds of template (Email, Surveys, Project
   plans). They are one subject, so they are one tab; they are three lists, so
   they need switching between.

   A RAIL AND NOT A SECOND TAB STRIP, deliberately. Two identical strips stacked
   is a navigation tree grown inside a page, and CLAUDE.md keeps navigation flat:
   the sidebar is flat, sub-sections are the tab strip, and anything below that
   has to look like a control rather than another level of the same thing. So
   this is a pill group: visibly a filter over one page, not a place you have
   navigated to.
*/
.rail {
  display: flex; flex-wrap: wrap; gap: 6px;
  margin-bottom: 18px;
}
.rail button {
  padding: 7px 14px;
  border: 1px solid var(--line); border-radius: var(--radius-pill);
  background: var(--panel); cursor: pointer;
  font-family: inherit; font-size: 13.5px; font-weight: 600; color: var(--text-dim);
}
.rail button:hover { border-color: var(--dufeu-blue); color: var(--text); }
.rail button[aria-selected="true"] {
  background: var(--dufeu-indigo); border-color: var(--dufeu-indigo); color: #FFFFFF;
}
.rail button:focus-visible { outline: 2px solid var(--dufeu-blue); outline-offset: 2px; }

/* ── Sign-in page ─────────────────────────────────────────── */

.signin {
  min-height: 100vh; display: grid; place-items: center; padding: 24px;
  background: var(--dufeu-indigo);
}
.signin-card {
  width: 100%; max-width: 400px; background: var(--panel);
  border-radius: 14px; box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32); overflow: hidden;
}
.signin-card .top { height: 4px; background: var(--gradient-extended); }
.signin-card .inner { padding: 34px 32px 32px; text-align: center; }
.signin-card img { height: 34px; width: auto; margin-bottom: 22px; }
.signin-card h1 { font-size: 20px; margin-bottom: 4px; }
.signin-card .tagline { color: var(--text-mute); font-size: 13.5px; margin: 0 0 26px; }
.signin-card .btn { width: 100%; }

/* ── Responsive ───────────────────────────────────────────── */

/* Below this width the sidebar becomes an off-canvas drawer behind a toggle in
   a slim top bar, rather than eating a third of a phone screen. */
@media (max-width: 900px) {
  .sidebar {
    position: fixed; top: 0; left: 0; bottom: 0; height: 100%;
    transform: translateX(-100%);
    transition: transform 0.18s ease;
    box-shadow: var(--shadow-pop);
  }
  .sidebar[data-open="true"] { transform: translateX(0); }

  .mobilebar {
    display: flex; align-items: center; gap: 12px;
    position: sticky; top: 0; z-index: 30;
    padding: 10px 14px;
    background: var(--dufeu-indigo);
    border-bottom: 3px solid transparent;
    border-image: var(--gradient-extended) 1;
  }
  .mobilebar img { height: 24px; width: auto; display: block; }
  /* The same link to Today the sidebar masthead carries. flex so the anchor is
     exactly its image and adds no line-box height to the bar. */
  .mobilebar .brand-mark { display: flex; }
  .mobilebar .toggle {
    display: grid; place-items: center; width: 36px; height: 36px;
    background: none; border: 0; border-radius: var(--radius-sm); cursor: pointer;
    color: #FFFFFF;
  }
  .mobilebar .toggle:hover { background: rgba(255, 255, 255, 0.08); }
  .mobilebar .toggle svg { width: 21px; height: 21px; stroke-width: 2; }

  /* Dismiss layer behind the open drawer. */
  .scrim[data-open="true"] {
    display: block; position: fixed; inset: 0; z-index: 35;
    background: rgba(28, 33, 51, 0.45);
  }

  .main { padding: 20px 16px 44px; }
  .card-head, .card-body { padding: 14px; }
  .table th, .table td { padding: 10px 14px; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

@media print {
  .sidebar, .mobilebar, .scrim, .no-print { display: none !important; }
  body { background: #FFFFFF !important; }
}

/* ── Modal ────────────────────────────────────────────────── */
/*
   ONE pop-out idiom for the whole portal, drawn by js/modal.js. Details open in
   a box over the page rather than dropping down inside a row, adding a user is a
   proper form rather than two fields wedged into a card, and both look the same
   because both are this.

   It is a real <dialog> opened with showModal(). That buys the top layer, the
   backdrop, Escape, the inert page behind and focus containment from the
   browser, which is where hand-rolled dialogs go wrong. What is left to style is
   the surface, the backdrop colour and the mobile behaviour.
*/

/* The UA rule is `dialog:not([open]) { display: none }`, so the flex layout is
   attached to [open] rather than to .modal: a bare `display: flex` here would
   out-specify it and leave a closed dialog on the page. */
.modal {
  padding: 0;
  margin: auto;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--panel);
  color: var(--text-dim);
  font-family: var(--font-body);
  font-size: 15px;
  box-shadow: var(--shadow-pop);
  /* The UA caps a dialog at calc(100% - 6px - 2em); this is the width we want.
     TIGHTENED from 560px: these are short forms (a name, a template, a handful
     of plan cells) and a dialog wider than its longest field is mostly empty
     space with a label floating in it. The padding and heading below came down
     with it, so the box is smaller rather than just narrower. */
  width: min(500px, calc(100vw - 32px));
  max-width: none;
  max-height: min(86vh, 880px);
  overflow: hidden;
}
.modal[open] { display: flex; flex-direction: column; }
/* A detail view carrying a table needs more room than a form does. */
.modal.wide { width: min(820px, calc(100vw - 32px)); }

/* THE THIRD SIZE, AND THE ONLY ONE THAT IS NOT A READING WIDTH. The Data
   Migration Plan's folder grid (js/sp-tree.js) pops out into this: several
   hundred rows of six FIXED tracks, where every pixel of extra width goes to
   the folder name and nothing else moves. On the page that grid shares 1180px
   with a 340px rail and the name gets about 260; here it gets about 820.

   Declared AFTER .modal.wide because the grid opens with `size: 'wide'` and
   carries both classes: same specificity, so this has to come second to win.
   The 640px block at the foot of this section then beats both, which is what
   should happen, a phone gets the whole viewport either way.

   The body stops scrolling and the box inside it scrolls instead, so the
   grid's own sticky head keeps working against the same element it does on
   the page and the scroll position round-trips on one scrollTop. The padding
   goes with it: the rows are padded 16px themselves and run edge to edge in
   their card, so a dialog gutter would draw them as something else. */
.modal.sa-window { width: min(1320px, calc(100vw - 32px)); max-height: min(92vh, 1040px); }
/* THE SHARE DIALOG IS WIDER THAN `wide`. It is not a form window: it carries a
   seven-column table of every link a plan has ever had ALONGSIDE the box that
   mints the next one, and 820px squeezed both. Neil, 2026-08-25: "make the box
   smaller in height but longer in length". Applied in js/plan.js the same way
   js/sp-tree.js applies `.sa-window`, and short of that one because this window
   holds a dialog rather than a whole file tree. */
.modal.share-window { width: min(1060px, calc(100vw - 32px)); }
.modal.sa-window .modal-body {
  display: flex; flex-direction: column; min-height: 0;
  padding: 0; overflow: hidden;
}
.sa-hold { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.sa-tree.sa-out { max-height: none; flex: 1 1 auto; min-height: 0; }

/* Indigo at 45%: the same dismiss layer as the mobile navigation scrim, so a
   covered page always dims by the same amount. Written out rather than as
   var(--dufeu-indigo): ::backdrop only began inheriting from its originating
   element recently, and a custom property that fails to resolve here would
   leave the backdrop transparent. */
.modal::backdrop { background: rgba(28, 33, 51, 0.45); }

.modal[open] { animation: modal-in 0.14s ease-out; }
.modal::backdrop { animation: backdrop-in 0.14s ease-out; }
@keyframes modal-in { from { opacity: 0; transform: translateY(8px); } }
@keyframes backdrop-in { from { opacity: 0; } }
/* The global reduced-motion rule cannot reach ::backdrop, because `*` does not
   match a pseudo-element. */
@media (prefers-reduced-motion: reduce) {
  .modal::backdrop { animation-duration: 0.01ms !important; }
}

.modal-head {
  display: flex; align-items: flex-start; gap: 12px;
  padding: 13px 13px 12px 16px;
  border-bottom: 1px solid var(--line-soft);
  flex-shrink: 0;
}
.modal-head .titles { flex: 1; min-width: 0; }
.modal-head h2 { font-size: 16px; }
.modal-head .sub { display: block; margin-top: 2px; font-size: 12.5px; color: var(--text-mute); }
/* 44px square: this is the one control on the dialog somebody stabs at with a
   thumb, and it is an icon with no text to widen its target. */
.modal-close {
  flex-shrink: 0; display: grid; place-items: center;
  width: 44px; height: 44px; margin: -8px -8px 0 0;
  background: none; border: 0; border-radius: var(--radius-sm);
  color: var(--text-mute); cursor: pointer;
}
.modal-close:hover { background: var(--off-white); color: var(--text); }
.modal-close svg { width: 18px; height: 18px; stroke-width: 2; }

/* The body scrolls, the head and foot do not, so the actions stay reachable
   however long the content is. */
.modal-body { flex: 1 1 auto; overflow-y: auto; padding: 16px; }

/* The consequence line in a confirm whose action reaches the outside world
   (an email a customer receives): bold and a step larger than body text,
   because it is the one sentence that must not scroll past unread. */
.modal-body .consequence { font-weight: 700; font-size: 17px; }

/* THE stacked form inside a dialog. Rows of fields still use .form-row. */
.modal-form { display: flex; flex-direction: column; gap: 12px; }
.modal-form .form-row { margin: 0; }

.modal-foot {
  display: flex; align-items: center; justify-content: flex-end; gap: 10px;
  flex-wrap: wrap; flex-shrink: 0;
  padding: 10px 16px;
  background: var(--off-white);
  border-top: 1px solid var(--line-soft);
}
/* Actions declared with align: 'start' (Cancel, Back) sit against the far edge,
   away from the one that commits. */
.modal-foot .lead { display: flex; gap: 10px; margin-right: auto; }
.modal-foot .spinner { margin-right: 2px; }

/* On a phone a centred card wastes the screen and the keyboard covers half of
   what is left, so the dialog takes the whole viewport. */
@media (max-width: 640px) {
  .modal, .modal.wide {
    width: 100%; height: 100%; max-height: 100%;
    margin: 0; border: 0; border-radius: 0;
  }
  .modal-head { padding: 14px; }
  .modal-body { padding: 14px; }
  .modal-foot { padding: 12px 14px; }
  .modal-foot .btn { flex: 1 1 auto; }
  .modal-foot .lead { flex: 1 1 100%; margin-right: 0; }
}

/* ── Wizard ───────────────────────────────────────────────── */
/*
   A multi-step form inside a modal, drawn by js/wizard.js. Every "new
   something" button in the portal opens one of these, so a new sign-off, a new
   user and a new plan are the same shape of task rather than three inventions.

   The step strip borrows the .tabs treatment on purpose: a portal only needs one
   visual idea for "here are several sections and you are in this one".
*/

/* Stated in words as well as drawn, because the strip alone is a picture and a
   picture cannot be read aloud or counted at a glance. */
.wizard-count {
  margin: 0 0 6px;
  font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
  color: var(--text-mute);
}

.wizard-nav {
  display: flex; flex-wrap: wrap; gap: 3px;
  margin: 0 0 18px; padding: 0; list-style: none;
  border-bottom: 1px solid var(--line);
}
.wizard-nav .step {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 8px 12px; margin-bottom: -1px;
  border-bottom: 2px solid transparent;
  font-family: var(--font-heading); font-size: 13px; font-weight: 600;
  color: var(--text-mute);
}
.wizard-nav .step .n {
  width: 20px; height: 20px; flex-shrink: 0;
  display: grid; place-items: center;
  border: 1px solid var(--line); border-radius: 50%;
  background: var(--off-white);
  font-size: 11px; font-variant-numeric: tabular-nums;
}
/* Indigo ink on Dufeu Blue, never white. */
.wizard-nav .step[data-state="current"] { color: var(--dufeu-teal); border-bottom-color: var(--dufeu-teal); }
.wizard-nav .step[data-state="current"] .n {
  background: var(--dufeu-blue); border-color: var(--dufeu-blue); color: var(--dufeu-indigo);
}
/* A finished step is green, the same tint the Active badge uses, and it also
   swaps its number for a tick: colour is never the only signal. */
.wizard-nav .step[data-state="done"] { color: var(--text-dim); }
.wizard-nav .step[data-state="done"] .n {
  background: rgba(150, 212, 4, 0.18); border-color: rgba(150, 212, 4, 0.55); color: #4A6A00;
}
.wizard-nav .step[data-state="done"] .n svg { width: 11px; height: 11px; stroke-width: 3; }

/* A textarea had no style until a wizard step needed one. Same box as .field
   input so a form does not change shape when a step carries a longer answer. */
.field textarea {
  min-height: 84px; padding: 9px 11px; resize: vertical;
  font-family: inherit; font-size: 14px; line-height: 1.5; color: var(--text);
  background: var(--panel); border: 1px solid var(--line); border-radius: 9px;
}
.field textarea:focus {
  outline: none; border-color: var(--dufeu-teal);
  box-shadow: 0 0 0 3px rgba(18, 118, 145, 0.15);
}
.field textarea[aria-invalid="true"],
.field select[aria-invalid="true"] { border-color: var(--danger); }

/* A BOX THAT IS USUALLY LEFT EMPTY, and so must not dominate the form it sits
   in. The 84px floor above is sized for a wizard answer somebody is expected to
   write a paragraph into; the addresses box on a share dialog is one somebody
   opens, ignores, and presses Save, because an empty box is a first-class answer
   there (the link opens on the token alone).

   THE RULE IS NOT HERE. It is the address-box block up in the fields section
   (`.field.narrow`), which caps the width and releases this floor in one place
   for the staff dialog and the customer gate alike. It used to be a second rule
   at this line doing the height half only, and that split is what let one box be
   two sizes depending on which side of the link you were standing on. */

/* Validation lands against the field it is about, not in a browser bubble that
   vanishes on the next keystroke. */
.field .error {
  font-size: 12.5px; font-weight: 600; color: var(--danger);
}

/* The mark on the label of a field that MUST be filled in. Neil, 2026-08-14,
   for the Lessons Learnt form: "the boxes that are mandatory should have a red
   asterisk to inform the user".

   No new colour: --danger is the same red the validation message beside it
   uses, so a required field and a refused one read as one family. The marker is
   decoration, and the control it belongs to carries `required` and
   `aria-required="true"`, so the markup writes it aria-hidden: a reader that
   announced both would say "Issue star required". */
.field label .req {
  margin-left: 3px; color: var(--danger); font-weight: 700;
}

/* A field laid out on ONE line, label beside the control rather than above it.
   For a control that lives in a card head or a toolbar (the Lessons Learnt
   per-page select), where the stacked form layout would make the strip twice as
   tall as the head it sits in. Still a .field, so the label, the box and the
   focus ring are the ones a form already uses; only the direction changes. The
   control takes the `.btn small` height, because it sits beside small buttons. */
.field.inline { flex-direction: row; align-items: center; gap: 8px; }
.field.inline select { height: 31px; padding: 0 8px; font-size: 13px; }

/* ── Numbered pagination ──────────────────────────────────── */
/*
   Page number buttons under a table, Previous and Next either side, with a gap
   marker where numbers were elided.

   THE SECOND PAGINATION MECHANISM IN THE PORTAL, and deliberately so. Neil,
   2026-08-14: "I think a dropdown that allows to change from 10, 25, 50 and 100
   ... Otherwise then it has page number hyperlinks you press to go to the
   pages." Everything else still uses `Load more`; UI-CONVENTIONS.md section 6
   records where each belongs, so this does not spread by accident.

   NO NEW TOKEN. A page number is a `.btn small`, and the page somebody is on is
   the one carrying `aria-current="page"`, which is also the hook the current
   state is coloured through: the fact and the styling are the same attribute,
   so a page cannot look current without being announced as current.
*/
.pager {
  display: flex; align-items: center; justify-content: center;
  gap: 6px; flex-wrap: wrap;
  padding: 14px 18px; border-top: 1px solid var(--line-soft);
}
.pager .btn[aria-current="page"] {
  border-color: var(--dufeu-teal); color: var(--dufeu-teal); font-weight: 700;
}
.pager .gap { color: var(--text-dim); padding: 0 2px; }

/* ── Filter bar ───────────────────────────────────────────── */
/*
   The control at the top of a list page, drawn by js/filterbar.js: a button
   carrying the three lines everybody already reads as "filter", the number
   currently applied, and the controls themselves behind it.

   Collapsed by default so a list page opens as a list. It opens itself when the
   URL arrives carrying filters, because a shared link should show what is being
   filtered rather than an unexplained short list.
*/

.filterbar { margin-bottom: 18px; }
.filterbar .bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }

/* .btn carries the look; this adds the touch target and the count pill. */
.fb-toggle { min-height: 44px; }
.fb-toggle svg { width: 16px; height: 16px; flex-shrink: 0; stroke-width: 2; }
.fb-toggle[aria-expanded="true"] { border-color: var(--dufeu-blue); color: var(--text); }
/* Same pill as the dropdown button's count, for the same reason: it says what is
   inside without opening it. */
.fb-toggle .count {
  min-width: 20px; height: 20px; padding: 0 6px;
  display: inline-grid; place-items: center;
  border-radius: var(--radius-pill);
  background: var(--dufeu-blue); color: var(--dufeu-indigo);
  font-size: 11px; font-weight: 700;
}

.fb-controls {
  margin-top: 12px; padding: 14px;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow-card);
}
/* Narrower than a form field: these sit four or five to a row, and a filter
   value is short. A multi filter is a Dropdown in its own cell, which is why it
   has no label of its own and the row is bottom-aligned by .form-row. */
.fb-controls.form-row .field, .fb-controls .fb-cell { flex: 0 1 210px; min-width: 168px; }

@media (max-width: 640px) {
  .fb-toggle { flex: 1 1 auto; }
  .fb-controls.form-row { flex-direction: column; align-items: stretch; }
  .fb-controls.form-row .field, .fb-controls .fb-cell { flex: 1 1 auto; width: 100%; }
}

/* ── Tabs on a narrow screen ──────────────────────────────── */
/*
   Sign-Offs and Admin both carry seven tabs. They fit at desktop width and do
   not on a phone, where the strip would push the page sideways.

   SCOPED TO THE PHONE ON PURPOSE. Scrolling costs a pixel: a tab's selected
   underline is drawn with `margin-bottom: -1px` so it sits ON the strip's own
   border, and any overflow value other than `visible` clips that pixel. Wider
   screens keep the sharper underline; a phone trades one pixel of it for a page
   that does not scroll sideways.

   overflow-y must be stated. Setting one axis to something other than `visible`
   computes the other from `visible` to `auto`, so overflow-x alone gives the
   strip a VERTICAL scrollbar as soon as a count badge makes it a pixel taller.
*/
@media (max-width: 640px) {
  .tabs { overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
  .tabs::-webkit-scrollbar { display: none; }
  /* Without this a tab shrinks to fit rather than scrolling, and its label
     wraps to two lines inside its own button. */
  .tabs button { flex: 0 0 auto; white-space: nowrap; }
}

/* ── Email preview ────────────────────────────────────────── */
/*
   The rendered body of an email template, inside a sandboxed iframe (see the
   header of js/admin-templates.js for why it is an iframe and why its sandbox
   attribute must stay empty). A plain white sheet with the standard card border:
   the frame is standing in for a customer's inbox, so nothing of the portal's
   own typography is applied inside it, and the box around it is the only styling
   this pattern gets.

   The height is fixed rather than fitted to the content: measuring a sandboxed,
   opaque-origin document from the parent is impossible by design, and a frame
   that grows would need script inside it. It scrolls instead.
*/
.email-preview {
  display: block;
  width: 100%;
  height: 420px;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

/* ── Plan document ────────────────────────────────────────── */
/*
   The plan editor's table (js/plan.js). It is a plain .table carrying two rows
   the rest of the portal has no equivalent for, both taken from the spreadsheets
   the templates were transcribed from: the full-width PHASE BANNER the action
   plan draws between groups of tasks, and the TOTAL HOURS row the presales plan
   ends on. Each phase is its own <tbody>, so the banner is a real heading for
   the rows under it rather than a styled row that happens to sit above them.

   No new colour, radius or size enters the system here: the banner is the same
   off-white a card foot uses, and the rule between groups is the same --line
   every table edge already draws.
*/
/* DUFEU BLUE, not the off-white a card foot uses. A phase band in the same
   near-white as everything else read as a slightly bold task rather than as a
   division of the document, which is the one thing it has to do. Tinted rather
   than solid: white text fails WCAG AA on Dufeu Blue at about 2.4:1, so the band
   carries INDIGO ink on a light wash of the brand blue, which is the rule in
   CLAUDE.md and in brand/DESIGN.md. */
.plan-table .plan-phase th,
.plan-table .plan-phase td { background: rgba(108, 172, 228, 0.28); }
/* The phase name IS the rename control, so a Rename button beside it would say
   the same thing twice. It has to LOOK like the heading it replaced (a button
   with a border in a banner row reads as an action, not a title), so it inherits
   the band's type and only declares itself on hover and focus. It prints as
   plain text, which is what it is on paper. */
/* NOT SCOPED TO .plan-table, and that scoping was a real defect until
   2026-08-14. The Kickoff Board draws a `.phase-name` button inside a card head
   for any band its template did not seed (js/plan-kickoff.js), and that sits in
   `.kb-cards`, never in a table. With the prefix, none of these three rules
   reached it and the button fell back to raw browser chrome: a grey native
   button where a card heading belongs. Nothing else in the portal uses the
   class, so dropping the prefix costs nothing and is what stops the next
   surface hitting the same wall. */
.phase-name {
  padding: 2px 5px; margin-left: -5px;
  border: 0; border-radius: 5px; background: none;
  font: inherit; color: inherit; letter-spacing: inherit; text-align: left;
  cursor: text;
}
/* THE HOVER AND THE FOCUS RING ARE SCOPED TO THE BUTTON FORM, and the tag in the
   selector is load-bearing rather than tidiness.

   A plan table's phase banner stopped being a button on 2026-08-25: its name is
   plain text and the row carries a menu. It kept this class, because it is what
   makes a band's name look like a band's name, and so it kept a hover box that
   said "click me to edit" over something that does nothing at all. Neil spotted
   it: "if I put the cursor over the phase it puts a box around it like it's
   going to be edited". An affordance that lies is worse than no affordance.

   A Kickoff Plan's board still draws this class AS A BUTTON for a band its
   template did not seed (js/plan-kickoff.js), and there the hover is correct and
   has to stay, which is why this is scoped rather than deleted. The cursor rule
   above is deliberately left on both: on the span it says "these are words", and
   on the button it always did. */
button.phase-name:hover { background: var(--panel); box-shadow: inset 0 0 0 1px var(--line); }
button.phase-name:focus-visible { outline: 2px solid var(--dufeu-blue); outline-offset: 1px; }

/* A ROW'S NAME AS THE CONTROL THAT OPENS IT. A task's name opens the task, a
   plan template's name opens the template. An Edit button in an actions cell
   said the same thing a second time on every row, so the cell went with it.

   NOT SCOPED TO .plan-table, deliberately: it was, and the first table outside
   the plan editor to use it (the plan templates list in Admin) rendered every
   name with the browser's default button chrome, because the class matched
   nothing. `.name` gives it the weight the portal gives a record's name in a
   table; this strips the button surface and declares itself on hover. */
.row-name {
  padding: 2px 5px; margin-left: -5px;
  border: 0; border-radius: 5px; background: none;
  font: inherit; color: inherit; text-align: left; text-decoration: none;
  /* A text caret, because on a plan the name is edited IN PLACE: pressing it
     opens the row's own form. */
  cursor: text;
}
/* Except where the name is a LINK rather than a form opener. The plan templates
   list in Admin uses an <a> to the template's page, and a text caret over
   something that navigates says the wrong thing about what the click will do. */
a.row-name { cursor: pointer; }
/* And where pressing the name OPENS the record somewhere else (a dialog or an
   editor over the list) rather than editing it in place: a press, not a caret. */
.row-name.opens { cursor: pointer; }
/* THE HOVER SURFACE IS FOR CONTROLS ONLY, and the selector says so rather than
   relying on `.row-name` meaning "a control", which stopped being true on
   2026-08-14 when the row menus arrived and every list's name became plain text.
   The rule was unscoped, so a `<span class="row-name">` still drew a bordered
   box under the pointer: Neil, seeing it on Lessons Learnt, "it comes up with a
   box like it did before we could press but doesn't allow the click. Hovering
   should do nothing."
   `:where()` keeps the specificity at the bare class, so nothing that used to
   out-specify this rule starts losing to it. */
:where(a, button).row-name:hover,
.row-name.opens:hover { background: var(--panel); box-shadow: inset 0 0 0 1px var(--line); }
:where(a, button).row-name:focus-visible,
.row-name.opens:focus-visible { outline: 2px solid var(--dufeu-blue); outline-offset: 1px; }

/* A PAGE TITLE THAT OPENS ITS OWN RECORD. Same idiom again, at the top of a
   detail page: press the name to rename the thing. It has to read as the <h1>
   it sits inside, so it inherits everything and only shows a surface on hover.
   Not offered when the record is read-only, so there is no hover on something
   that cannot be changed. */
.title-edit {
  display: inline-block;
  padding: 1px 7px; margin-left: -7px;
  border: 0; border-radius: 7px; background: none;
  font: inherit; color: inherit; letter-spacing: inherit; text-align: left;
  cursor: text;
}
.title-edit:hover { background: var(--off-white); box-shadow: inset 0 0 0 1px var(--line); }
.title-edit:focus-visible { outline: 2px solid var(--dufeu-blue); outline-offset: 2px; }
.plan-table .plan-phase th {
  color: var(--text);
  font-family: var(--font-heading);
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: none;
  white-space: normal;
}
/* One group from the next. The last row of a <tbody> drops its own bottom
   border (see .table above), so the line belongs to the group that follows. */
.plan-table tbody + tbody tr:first-child th,
.plan-table tbody + tbody tr:first-child td { border-top: 1px solid var(--line); }
.plan-table .plan-totals th,
.plan-table .plan-totals td {
  border-top: 1px solid var(--line);
  color: var(--text);
  font-family: var(--font-body);
  font-size: 14px;
  font-weight: 700;
  letter-spacing: normal;
  text-transform: none;
}

/* A date OR words, which is how both spreadsheets actually fill a date column
   ("Ongoing", "TBC", "Week 2"). The mode select sits beside the two inputs and
   exactly one of them is on screen, which is the same either/or the stored pair
   allows: a date or text, never both. */
.plan-either { display: flex; align-items: center; gap: 8px; }
.plan-either select { flex: 0 0 auto; }
.plan-either input { flex: 1 1 auto; min-width: 0; }

/* THE TASKS CARD'S HEAD IS ITS ACTIONS AND NOTHING ELSE, since the "Tasks"
   heading came off it (see portal/plan.html for why). Add task and Add phase
   still live there, but js/plan.js empties #tasks-actions on a plan that takes
   no writes: a frozen plan, an archived one, or a viewer without the permission.
   That would leave a head with nothing in it still drawing 15px of padding and a
   rule across the card, which reads as a stray line above the table. So the head
   goes when the actions do, and comes back with them.
   :has() is already the idiom here, see .check:has(input:disabled) above. */
#plan-tasks-card > .card-head:has(> #tasks-actions:empty) { display: none; }

/* ── A border down the SIDES of a plan ─────────────────────

   Neil, 2026-08-26, having looked at the first attempt: "I mean more down the
   sides." So this is the outer edges only.

   THE FIRST ATTEMPT RULED EVERY COLUMN and has been taken out. It turned the
   plan into a grid, which is a different thing from giving the document an edge:
   a rule between Start and Finish says those two are separate, when what was
   wanted was for the plan to look like a bounded sheet rather than text running
   off into the page.

   ON THE TABLE, NOT THE CARD. The card already draws a rounded boundary around
   its whole self, heading included; this is the edge of the PLAN, so it starts
   at the column headings and ends at the last row.

   --line-mid is the ink the row rules already use (darkened on 2026-08-25), so
   the edge and the rules read as one document rather than as two weights. */
/* ONE PIXEL, THE ROW RULE WEIGHT, AND IT WENT ROUND THREE TIMES TO GET HERE.
   Worth the whole story, because the obvious fix is the one that was rejected.

   It started at 1px and Neil could not see it: .card-body.flush runs the table
   edge to edge, so on the STAFF PAGE the rule landed one pixel inside the card
   own hairline, at the same weight, and hid behind an edge already there.
   "Show me this border as I am not seeing it." He was right.

   So it went to 3px to be judged, and was: "it is grey and thicker now and
   completely wrong compared to the rest of the plan." Also right. Every other
   line in this table is a hairline, so a three pixel slab down each side reads
   as a frame somebody put AROUND the plan rather than as the edge OF it.

   THE ANSWER IS THAT THE STAFF PAGE IS NOT WHERE THIS EDGE DOES ITS WORK.
   Neil, when he first asked: "it was more the share links and export copies."
   The customer copy and the exported sheet have NO CARD, so there the edge is
   the only boundary the document has and one pixel reads perfectly well. On the
   staff page it sits against the card hairline and is quiet, and quiet is the
   correct outcome there: the card is already saying where the plan stops.

   So it matches the row rules exactly, in weight and in ink, which is what
   "the rest of the plan" means. Do not reach for thickness to make it visible
   on the staff page; that is the move that failed. If it ever needs more
   presence, take it darker (--line) rather than thicker. */
#tasks-body .table {
  border-left: 1px solid var(--line-mid);
  border-right: 1px solid var(--line-mid);
}

/* THE CUSTOMER'S COPY TAKES THE SAME EDGE, because it is the same document and
   the two must not read differently. Neil asked for this on the shared link and
   the export in particular, which are the two a customer actually holds. */
.planv-table {
  border-left: 1px solid var(--line-mid);
  border-right: 1px solid var(--line-mid);
}

/* ── A row marked Completed or In progress ─────────────────
   Neil, 2026-08-26: "Completed makes that line go green and In-progress makes
   that line go yellow."

   THE TINT IS ON THE <tr>, NOT ON ITS CELLS, and that is load-bearing rather
   than a style choice. `.plan-table tbody tr:hover > td { background: none; }`
   further down suppresses the hover wash every .table row inherits, and it is
   more specific than any sensible `.plan-table tr.<class> > td` rule, so a tint
   put on the cells would vanish the moment the pointer crossed the row. A cell
   with no background of its own simply shows the row's, so this survives both
   that rule and the focus ring, which is a box-shadow and paints over the top.

   THE PLAN'S EDGES AND RULES STILL READ. They are borders, drawn on the table
   and on the cells, and the tint is behind them: --line-mid keeps its full
   strength over either wash (a 1.5:1 step, which is what it already is against
   white), so a marked row is bounded exactly like the rows around it.

   THE WASH IS THE SECOND SIGNAL, NEVER THE ONLY ONE. Every marked row also
   carries a .tprog chip beside the task's name with the word on it. That is the
   pattern the Impact column already sets on this table, and it is what makes the
   mark readable to somebody who cannot separate the green from the yellow. See
   the two tokens for the measurements behind the alpha. */
.plan-table tr.task-done  { background: var(--plan-row-done); }
.plan-table tr.task-doing { background: var(--plan-row-doing); }

/* THE CUSTOMER'S COPY OF THE SAME DOCUMENT TAKES THE SAME TWO TINTS. Neil,
   2026-08-26, of the marks: "include on the export and share link as well." The
   shared link draws .planv-table (portal/plan-view.html), so the rule is stated
   for it rather than folded into the selector list above:
   test/planEditor.test.js pins those two lines character for character as the
   staff page's, and one shared list would leave each page's tint editable only
   through the other page's test.

   THE TOKENS ARE WHAT KEEPS THE TWO FROM DRIFTING, which is the point that
   matters: neither rule carries a colour of its own, so recolouring a marked
   row is one edit to --plan-row-done or --plan-row-doing and both documents
   follow. BLOCKED takes no tint here either, for the reason given above. */
.planv-table tr.task-done  { background: var(--plan-row-done); }
.planv-table tr.task-doing { background: var(--plan-row-doing); }

/* AND THE HOVER WASH EVERY .table ROW INHERITS IS TAKEN OFF THE MARKED ONES.
   `.table tbody tr:hover td` paints a pale blue ON THE CELLS, and a cell with a
   background of its own hides the row's, so a marked row lost its tint the
   moment the pointer crossed it. The staff table drops that wash from every row
   (.plan-table tbody tr:hover > td below); the customer's keeps it, because a
   document being read is no worse for showing which line the pointer is on, so
   only the two rows that have something to lose are excepted. */
.planv-table tr.task-done:hover > td,
.planv-table tr.task-doing:hover > td { background: none; }

/* The chip rides in the first cell, after the name, inside the same flex row the
   grip and the name share, so a long name wraps within its own column and the
   mark stays on the line the name starts on rather than dropping below it. */
.row-lead > .tprog { flex: 0 0 auto; margin-left: 8px; }

/* The impact key: A BOXED PANEL ABOVE THE PLAN.
   A LEGEND, NOT A SECTION. It was a card of its own below the plan, a
   two-column table of four rows, which gave a footnote the weight of the
   document and put it a scroll away from the column it explains. It now sits
   ABOVE the table, because a key is read before the thing it decodes and
   referred back to afterwards.

   A tinted box rather than a .card: no heading, no shadow, no border radius
   competing with the plan below it. Each entry keeps its own WORD as well as its
   colour, so the level is never colour alone (the same rule the chip in the
   table follows). */
/* SQUARE-ISH, not a banner. Across the full width it was one long strip that
   read as a rule between the head and the plan; stacked and held to a column it
   is a panel you take in at a glance and then leave alone. The chips line up
   under each other, which is what makes the four levels scannable as a scale. */
.impact-key {
  display: flex; flex-direction: column; align-items: stretch; gap: 6px;
  width: fit-content; max-width: min(100%, 440px);
  padding: 12px 15px;
  margin: 0 0 18px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--off-white);
  list-style: none;
  font-size: 12.5px; color: var(--text-mute);
}
.impact-key .impact-key-term {
  font-family: var(--font-heading); font-size: 11px; font-weight: 700;
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-mute);
  margin-bottom: 2px;
}
/* The chip column is fixed so the meanings start on the same left edge, which is
   what stops four ragged rows reading as four unrelated facts.
   WIDE ENOUGH FOR THE LONGEST WORD: at 62px "Medium" did not fit its column, so
   its bubble was pushed out of line with the other three. */
/* THE WORDS NEED AIR AFTER THE CHIP. At 10px the meaning started almost against
   its bubble and the four rows read as squashed rather than as a key. Neil,
   2026-08-25: "the impact key looks a little squished, the wording needs a bit
   more space from the key." 16px is the gap .page-head already uses, so no new
   spacing value enters the system for it.
   IT COSTS THE TEXT COLUMN 6px AND NOTHING ELSE. The chip column is fixed and
   the panel is already as wide as it is allowed to be (max-width: min(100%,
   440px)), so on a phone the meanings simply wrap one word earlier: nothing is
   pushed off the edge, because the second track is 1fr and cannot overflow.
   server/services/planPdf.js mirrors this as LEGEND_GAP so the drawn sheet keeps
   the same key; the two are meant to move together. */
.impact-key li { display: grid; grid-template-columns: 76px 1fr; align-items: center; gap: 16px; }
/* A grid item stretches to fill its track by default, which made each bubble a
   different shape from the same chip in the table and stretched the short words
   to the width of the longest. Each one sits at its own natural width, against
   the left edge of the column. */
.impact-key .status { justify-self: start; }
/* The chips are the .status pills the table already uses, one size down: this is
   a key, and a key that shouts louder than the rows it explains is the thing
   being fixed. */
.impact-key .status { font-size: 10.5px; padding: 2px 8px; }

/* ── A task row that can be acted on ──────────────────────── */
/*
   THE ROW IS THE CONTROL NOW. Its name used to be a button, and pressing it
   opened the task; the name is plain text again and the row's own menu carries
   Edit and Delete instead (right-click, the Menu key, Shift+F10, or Enter on
   the focused row).

   Which leaves the row having to SAY that it does something, with no word of
   instruction anywhere on the page to say it for us. Three signals, none of
   them a sentence:

     1. the grip already sitting at the left edge, which only a row that can be
        changed carries at all;
     2. a wash across the WHOLE row on hover, twice the strength of the one
        .table gives every row, so what lights up is the record rather than the
        cell under the pointer;
     3. the pointer itself, over the one cell the grip and the name share.
        `context-menu` is the platform's own mark for "this has a menu", and
        keeping it off the other cells leaves the text cursor over the dates and
        the hours somebody is selecting to copy.

   Only ever on tr[data-row="task"], which taskRow draws when editable() said
   so: a frozen plan, an archived one and a viewer without the permission get an
   ordinary row, with no wash, no cursor and no tab stop.
*/
.plan-table tr[data-row="task"] > td:first-child { cursor: context-menu; }
/* THE HOVER WASH HAS GONE, 2026-08-23. Neil: "Don't want items going grey when
   hover over them with the cursor. Only hover that needs to be like URL links."
   A row that merely sits there is not a control, and tinting it on approach made
   every record on the page answer the pointer as though it were one. What is
   left of signal 2 above is the grip and the cursor, which are both about the
   row that CAN be acted on rather than about the pointer being over it.

   AND THE ONE A PLAN ROW INHERITS GOES WITH IT. `.table tbody tr:hover td` gives
   every list in this portal a 5% wash, and a plan row is a `.table` row, so
   deleting the rule above alone would have left the tint on at a fifth of its
   strength: quieter, and the same thing Neil is objecting to. Suppressed for the
   plan table rather than removed at source, because `.table` also serves the
   projects, users, audit, sign-off and survey lists, and moving all of those is
   a decision of its own and not a side effect of this one.

   FOCUS IS NOT HOVER AND KEEPS ITS INDICATOR. The row is still a tab stop, and a
   tab stop nobody can see is worse than no tab stop. The ring is the brand ring
   every other control on this page draws; the FILL that used to sit under it has
   gone with the wash it was one step up from, because a tinted row is exactly
   what Neil is objecting to.
   THE INSET IS BELT AND BRACES: an outline on a row of a border-collapsed table
   is the one focus ring browsers have historically declined to paint, so the
   cells draw the same ring along the row's top and bottom edge. Where both are
   painted they land on the same two pixels. */
.plan-table tbody tr:hover > td { background: none; }
.plan-table tr[data-row="task"]:focus-visible { outline: 2px solid var(--dufeu-blue); outline-offset: -2px; }
.plan-table tr[data-row="task"]:focus-visible > td {
  box-shadow: inset 0 2px 0 0 var(--dufeu-blue), inset 0 -2px 0 0 var(--dufeu-blue);
}

/* ── Dragging a plan row ──────────────────────────────────── */
/*
   The grip: four dots to the left of the name, on every row that can move. It
   replaced an up and a down button on every single task, which was most of the
   actions cell on a document whose main activity is moving rows about.

   A REAL BUTTON, because it is also how a keyboard reorders (Up and Down on the
   focused grip). It is .no-print, along with the actions column, so none of this
   reaches the exported PDF.
*/
/* THE GRIP AND WHAT IT CARRIES, as two columns rather than two inline boxes.
   A name too long to sit beside the grip used to drop below it entirely, and
   .row-name's -5px margin then started it left of the grip above, so the column
   read as ragged. Here the grip is its own fixed column and the name wraps
   inside its own, so every line starts at the same x however long the name is.

   min-width: 0 on the second child because a flex item will not shrink below
   its content's intrinsic width without it, which is how a long unbroken word
   pushes a table column wider than the page. */
.row-lead { display: flex; align-items: flex-start; }
.row-lead > .grip { flex: 0 0 auto; }
.row-lead > :not(.grip) { min-width: 0; }

.grip {
  display: inline-flex; align-items: center; justify-content: center;
  width: 20px; height: 22px; margin-right: 7px; vertical-align: middle;
  padding: 0; border: 0; border-radius: 5px;
  background: none; color: var(--text-mute); cursor: grab;
}
.grip:hover { background: var(--off-white); color: var(--text); }
.grip:active { cursor: grabbing; }
.grip:focus-visible { outline: 2px solid var(--dufeu-blue); outline-offset: 1px; }
/* The row being carried. Faded rather than hidden, so its old position is still
   readable while the indicator shows where it would land. */
.plan-table tr.dragging td,
.plan-table tr.dragging th { opacity: 0.4; }
/* Where it would land. A line, not a highlighted row: the drop goes BETWEEN two
   rows, and colouring a row says "into this one". */
.plan-table tr.drop-before td,
.plan-table tr.drop-before th { box-shadow: inset 0 2px 0 0 var(--dufeu-teal); }
.plan-table tr.drop-after td,
.plan-table tr.drop-after th { box-shadow: inset 0 -2px 0 0 var(--dufeu-teal); }
/* The same two states for a COLUMN, which moves sideways, so both the fade and
   the line are on the heading cell itself rather than on the row it sits in:
   a column drag dims the one column being carried, where the rules above dim a
   whole row, and the landing line is vertical because the drop goes BETWEEN two
   columns. Same width and same colour, so the two drags read as one idiom. */
.plan-table th.dragging { opacity: 0.4; }
.plan-table th.drop-left { box-shadow: inset 2px 0 0 0 var(--dufeu-teal); }
.plan-table th.drop-right { box-shadow: inset -2px 0 0 0 var(--dufeu-teal); }
/* A heading that can be dragged puts its grip in front of its own text, exactly
   as the first cell of a task row does. align-items is centre rather than the
   row-lead default of flex-start because a heading is one short line and a grip
   hanging from its top edge reads as misaligned. */
.plan-table thead th > .row-lead { align-items: center; }

/* ── Shared plans: the customer's page, and the printed sheet ─ */
/*
   ONE APPENDED BLOCK, and it owns two things:

     1. The customer's read-only plan page (/plan-view.html), which is otherwise
        built entirely from the components above: .main, .card, .table,
        .table-scroll, .page-head, .btn, .loading, .spinner, .muted, .prewrap.
        What is added here is only what those cannot express: the logo's
        clearspace, the phase band inside a table, and the impact chip.
     2. THE LANDSCAPE PRINT SHEET, which is what Ctrl+P on a plan produces.

        IT IS NO LONGER THE EXPORT. Export on both plan pages now asks the
        server for a PDF (server/services/planPdf.js), because what came out of
        a print dialogue depended on the reader's browser and printer driver,
        and the customer's copy is the one that matters. These rules stay
        because pressing Ctrl+P is a browser feature this application does not
        own, and a plan printed that way should still look like the plan.

        The one thing these rules cannot state is how TALL the sheet is, so this
        path is paginated A4 landscape and every rule here has to hold across a
        page break. The renderer takes its measurements from this block (they
        are quoted in it, unit by unit), which is what keeps the two documents
        reading as the same one.

   IT PRINTS THE SAME FROM BOTH SIDES. The rules below are written against
   markup that is generic (.table, .card, .no-print, .print-only) plus the
   .planv-* additions the customer's page carries, so the staff plan page prints
   through the same sheet without a stylesheet of its own. What a staff page has
   to do to join in is stated at "The contract" below.

   Every colour here is one already in this file. No new token enters the system
   for a printed page.
*/

/* ── The customer's page, on screen ───────────────────────── */

/* Logo clearspace on all four sides is at least the height of the lowercase 'd',
   which on the horizontal mark is roughly 40% of the logo's height. Setting this
   element's font-size TO the logo height makes the padding em-based, so the
   clearspace scales with the logo instead of being a pixel value that stops
   being right the first time somebody resizes it. */
.planv-brand {
  display: flex;
  align-items: center;
  max-width: 1180px;
  margin: 0 auto;
  /* == the logo height. 45px rather than a round 48 or 50 because the print
     block below sets this same rule to 12mm, which is about 45px at 96dpi: the
     screen letterhead and the printed one are now ONE size instead of two. */
  font-size: 45px;
  /* CLEARSPACE ON ALL FOUR SIDES, INCLUDING THE BOTTOM, and the bottom is
     the one that was wrong. It used to be `0.55em 0.55em 0`, leaving the gap
     under the mark to whatever happened to follow: 12px from .planv-main,
     10px from .cl-doc-head h1. At the old 30px those were borderline against
     the brand rule (clearspace at least the height of the lowercase d, about
     0.4em); at 45px the d is ~18px and both were under it.

     Owning the bottom here rather than relying on a sibling is also what
     stops this breaking again the next time somebody resizes the logo, which
     is the whole reason every value in this rule is em-based. The margins
     below collapse into this padding rather than adding to it, so the gap is
     0.5em, not 0.5em plus 12px. */
  padding: 0.55em 0.55em 0.5em;
}
.planv-brand img { height: 1em; width: auto; display: block; }

/* THE CUSTOMER'S GROUND IS PAPER, NOT THE PORTAL'S OFF-WHITE. Neil, 2026-08-25:
   "the exported copy goes on more of a white background with the impact chart
   darker greyish colour. Can we do this for shared links as well, for both
   pre-sales and project plans."

   The portal's --bg is --off-white (#F2F2F2), and the impact key is a panel of
   that SAME #F2F2F2 with a border, so on a shared link the key had nothing to
   stand out against. The rendered PDF has no such problem: it draws the page
   white (INK.page) and fills the key with --off-white (INK.offWhite), which is
   what makes the panel read as a darker grey block on that sheet.

   So this is the PDF's arrangement, not a new palette: the page becomes white
   and the key keeps the off-white it already had, which turns it into the darker
   grey Neil is describing. No colour is invented and no token is added; only
   which of two existing ones the page's ground uses changes.

   ONE PAGE, BOTH KINDS OF PLAN. Every share opens portal/plan-view.html
   (pathFor in server/services/planShares.js builds one URL and branches on
   nothing), so a pre-sales plan and a live project plan both land here and both
   get this. The staff pages keep the portal ground, because they ARE the
   application: this is the copy that is meant to look like the document.

   Scoped to a class on <body> rather than to the element below, because the
   ground belongs to the page: painting .planv-main white would leave the portal
   grey showing either side of it on a wide screen. */
body.planv-page { background: var(--panel); }

/* .main is normally a flex child of .shell, which this page has no use for: the
   customer sees a document, not the application shell. */
.planv-main { margin: 0 auto; padding-top: 12px; }

/* A plan can carry columns an administrator invented, so the widths are hints
   from the template rather than a fixed grid. Percentages, so a plan with four
   columns and a plan with nine both fill the sheet. */
.planv-table th[data-width="NARROW"] { width: 9%; }
.planv-table th[data-width="WIDE"]   { width: 24%; }
/* Tasks and notes are sentences, so a row's cells align at the top rather than
   floating in the middle of the tallest one. */
.planv-table td { vertical-align: top; }
.planv-num { text-align: right; font-variant-numeric: tabular-nums; }

/* The phase band: one row spanning the table, in the same off-white the empty
   states already use, so a phase reads as a section of the document rather than
   as a task somebody set in bold. */
.planv-phase th {
  /* The same Dufeu Blue wash as the editor's band, so a phase reads the same on
     screen, on the customer's shared page and on paper. Indigo ink on it, never
     white: white fails WCAG AA on Dufeu Blue at about 2.4:1. */
  background: rgba(108, 172, 228, 0.28);
  color: var(--text);
  font-size: 13px;
  text-transform: none;
  letter-spacing: 0;
  white-space: normal;
  border-bottom: 1px solid var(--line);
}
.planv-plan tfoot td {
  border-top: 1px solid var(--line);
  font-weight: 600;
  color: var(--text);
}

/* The impact chip, in the four levels of enum PlanImpact. The colours are the
   ones the badges above already use: green for none, and the warning and danger
   values for the two that mean somebody cannot work. */
/* inline-flex, NOT inline-block, since the chip gained a meter: the bars have to
   sit on the same baseline as the word rather than being laid out as a block
   element inside it. Caught by the chip catalogue on 2026-08-25, where the
   customer's impact chips drew as large circles because an unsized SVG in an
   inline-block chip with a pill radius expands to its natural size and takes the
   pill with it. */
.planv-impact {
  display: inline-flex;
  align-items: center;
  padding: 2px 9px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--line);
  background: var(--off-white);
  color: var(--grey-dark);
  font-size: 12px;
  font-weight: 600;
  white-space: nowrap;
}
/* THE CUSTOMER'S COPY NOW MATCHES OURS, which it did not before: this page had
   drifted to its own four colours, with LOW drawn in BLUE while the staff ramp
   drew it in yellow. The same document read as a different scale depending on
   who was looking at it. One ramp now, the staff one. */
.planv-impact[data-level="NONE"]   { background: #CAF3D9; border-color: #4ADE80; color: #0B3D1E; }
.planv-impact[data-level="LOW"]    { background: #FEF0B9; border-color: #FACC15; color: #3A2E00; }
.planv-impact[data-level="MEDIUM"] { background: #FEE6C4; border-color: #FB923C; color: #4A2200; }
.planv-impact[data-level="HIGH"]   { background: #FDD5D5; border-color: #F87171; color: #4C0F0F; }

/* The Location chip on the customer's copy. Same pill as .planv-impact beside it
   (this page has one chip geometry, and a second would read as a second kind of
   thing), and the three hues are the staff page's .loc- ones so a plan looks the
   same to us and to the customer. A location this list does not know keeps the
   base neutral fill, which is what the bare .planv-location is. */
.planv-location {
  display: inline-flex;
  align-items: center;
  padding: 2px 9px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--line);
  background: var(--off-white);
  color: var(--grey-dark);
  font-size: 12px;
  font-weight: 600;
  white-space: nowrap;
}
/* BOTH chips on this page, not just the location one. The impact meter was
   added at the same time and left unsized, which is what blew its pill out. */
.planv-location svg,
.planv-impact svg { width: 12px; height: 12px; flex: 0 0 auto; margin-right: 5px; }
/* The customer's copy takes the same floor, so their document reads as tidily as
   ours does. Both chips on that page, because both sit in a column. */
.planv-location, .planv-impact { min-width: 8em; }
.planv-location[data-level="REMOTE"]  { background: #C3EBFD; border-color: #38BDF8; color: #0B2534; }
.planv-location[data-level="ONSITE"]  { background: #FEEBC0; border-color: #FBBF24; color: #3A2A00; }
.planv-location[data-level="INHOUSE"] { background: #E4DCFD; border-color: #A78BFA; color: #241653; }

.planv-key .table td:first-child { width: 130px; }

.planv-foot { max-width: 1180px; margin: 22px auto 0; font-size: 13px; color: var(--text-mute); }
.planv-foot p { margin: 0 0 4px; }
/* WHEN THE LINK STOPS WORKING, at the foot of both customer surfaces and at the
   right edge of it. One line, and a fact rather than an instruction: the page
   states the date and says nothing about what expiry means.

   It is the last thing on the page and the least of it, which is what the right
   edge says: the document is read down the left. Both pages carry it in a
   `.planv-foot` of its own, OUTSIDE every state, so the same line sits in the
   same place whichever state the reader is looking at.

   `.no-print` in the markup, not a rule here: the expiry of a LINK has no
   business on a printed copy that will outlive it, and the paper is not the
   link. */
.planv-expiry { text-align: right; }

/* Shown ONLY on paper. The counterpart to .no-print above, and the hook a staff
   page uses to put the Dufeu mark and the customer's name on its own printout
   without carrying either on screen. */
.print-only { display: none; }

/* ── Comparing two versions of a plan ─────────────────────── */
/*
   portal/plan-diff.html only. Everything here is drawn from tokens already in
   this file: the four tints and inks are the ones the .status chips use, so no
   new colour enters the system for a page that otherwise reuses .card and
   .table as they are.

   COLOUR IS THE THIRD COPY OF THE FACT, NEVER THE FIRST. Every tag carries a
   sign (+, -, ~) and the word beside it, and every emphasised row carries a
   flag naming what moved. About one man in twelve cannot reliably separate the
   red from the green, and this is the page somebody reads to decide whether the
   hours changed, so the tint decorates a statement that already reads in
   monochrome.
*/

.diff-counts { margin-bottom: 18px; }

.diff-pickers { display: flex; flex-wrap: wrap; gap: 16px; }
.diff-pickers .field { flex: 1 1 260px; }

/* The tag. Same geometry as .badge, which is what it is a sibling of; it is not
   .badge itself because the sign needs a cell of its own so a column of tags
   lines up. */
.diff-tag {
  display: inline-flex; align-items: center; gap: 6px;
  height: 22px; padding: 0 9px; border-radius: var(--radius-pill);
  font-size: 12px; font-weight: 700; letter-spacing: 0.01em;
  border: 1px solid; white-space: nowrap;
}
.diff-sign { display: inline-block; min-width: 0.75em; text-align: center; font-weight: 700; }

.diff-added   { background: rgba(150, 212, 4, 0.20);   border-color: rgba(150, 212, 4, 0.60);   color: #47660A; }
.diff-removed { background: rgba(192, 57, 43, 0.10);   border-color: rgba(192, 57, 43, 0.40);   color: #A32E22; }
.diff-changed { background: rgba(108, 172, 228, 0.18); border-color: rgba(108, 172, 228, 0.55); color: #1F5C8B; }
.diff-moved,
/* The ink is status-autoclosed's, the tint family .badge.info's: the iris
   pairing status-planning wore before it went yellow (Neil, 2026-08-11). */
.diff-renamed { background: rgba(100, 119, 186, 0.16); border-color: rgba(100, 119, 186, 0.50); color: #4C5A93; }

/* One added or removed task, with the cells that were on it. */
.diff-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
.diff-entry-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 10px; }
.diff-entry-name { font-weight: 600; color: var(--text); }
.diff-entry-where { font-size: 13px; }

.diff-cells {
  list-style: none; margin: 6px 0 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: 4px 20px; font-size: 13px;
}
.diff-cells li { display: flex; gap: 6px; }
.diff-cell-label { color: var(--text-mute); }
.diff-cell-value { color: var(--text-dim); font-variant-numeric: tabular-nums; }
/* An hours or a date value on an added or removed task: the two the reader came
   for, so they are the two that are not grey. */
.diff-cells li.diff-drift .diff-cell-value { color: var(--text); font-weight: 700; }

/* One changed task, with its field by field table. */
.diff-task { padding: 16px 0; border-top: 1px solid var(--line-soft); }
.diff-task:first-child { padding-top: 0; border-top: 0; }
.diff-task .table-scroll { margin-top: 10px; }
.diff-fields th, .diff-fields td { padding: 8px 14px; }
.diff-fields td { font-variant-numeric: tabular-nums; }
.diff-fields tbody th {
  text-transform: none; letter-spacing: 0; font-size: 13px;
  color: var(--text-dim); white-space: normal;
}
.diff-fields tbody th .diff-field-label { font-weight: 600; }
.diff-fields tr.diff-drift td { color: var(--text); font-weight: 700; }

/* What kind of change this row is, in a word. The reason the tint above is
   never carrying the meaning on its own. */
/* Same 3.43:1 failure as the count pill above, and it matters more here: this
   flag exists precisely so the row tint is not carrying the meaning on its own
   (see the note above it), so a reader who cannot see the tint is the one person
   guaranteed to be reading these words. `--text-dim` takes it to 7.4:1. */
.diff-flag {
  display: inline-block; margin-left: 8px; padding: 0 7px;
  border-radius: var(--radius-pill); border: 1px solid var(--line);
  background: var(--off-white); color: var(--text-dim);
  font-size: 11px; font-weight: 700; letter-spacing: 0.02em; white-space: nowrap;
}
.diff-flag:empty { display: none; }

@media (max-width: 900px) {
  .diff-fields th, .diff-fields td { padding: 8px 10px; }
}

/* ── Pending approval: the sidebar badge and the banner ───── */
/*
   ONE FACT, TWO PLACES. A pre-sales plan waiting on somebody to approve its
   export shows as a count on the sidebar entry it belongs to (js/shell.js) and
   as a list of the requests themselves on the Projects page. Both are drawn
   only when there is something pending: there is no zero state here, so an
   approver with nothing waiting sees the app exactly as everybody else does.

   Nothing below introduces a colour or a radius: the badge is the Dufeu Blue
   fill and Indigo ink the current nav entry and the user avatar already use,
   and the banner is a .card with the amber edge .status-attention is drawn in.
*/

/* The empty aria-live host, which draws nothing until there is a count in it.
   margin-left: auto pushes the badge to the trailing edge of the link, past the
   label, so the icon and the words keep the alignment they have on every other
   entry. */
.nav-badge-host { margin-left: auto; display: inline-flex; align-items: center; }

.nav-badge {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 20px; height: 20px; padding: 0 6px;
  border-radius: var(--radius-pill);
  /* Indigo on Dufeu Blue, the pairing .nav a[aria-current] and .avatar use.
     White on Dufeu Blue is about 2.4:1 and fails AA. */
  background: var(--dufeu-blue); color: var(--dufeu-indigo);
  font-size: 11.5px; font-weight: 700; line-height: 1;
  font-variant-numeric: tabular-nums;
}
/* On the entry for the page you are already on, the LINK is Dufeu Blue, so the
   badge inverts to keep an edge against it. */
.nav a[aria-current="page"] .nav-badge {
  background: var(--dufeu-indigo); color: #FFFFFF;
}

/* The banner. A plain .card with the attention edge, so it reads as the one
   thing on the page addressed to the reader without becoming a component of its
   own. Colour is not the only signal: it carries a heading and the rows say
   what is waiting. */
.card.approvals { border-left: 3px solid rgba(178, 107, 0, 0.55); }

/* ── Plan versions: the neutral notice and the printed stamp ─ */
/*
   Added for the version stamp on the printable sheets. The re-issued HTML
   version page these began on is gone (a stored version regenerates as a
   server-rendered PDF now), and the stamp survives for the Ctrl+P sheet. Kept in one block at the end
   of the file rather than threaded through the sections above, because more than
   one piece of work is in this stylesheet at once.

   No new colour, radius or size enters the system here: the notice reuses the
   Dufeu Blue wash and Indigo ink the phase band and the nav already use, and the
   stamp is the printed sheet's own 8pt and existing line colour.
*/

/* A statement of fact, not a warning and not a success. "This is version 3,
   exported on 12 August" is neither, and .warn (amber, meaning somebody must
   act) said the wrong thing about a document that is simply history.
   Indigo ink on the blue wash, never white: white on Dufeu Blue is about 2.4:1
   and fails WCAG AA. */
.notice.info {
  background: rgba(108, 172, 228, 0.16);
  border-color: rgba(108, 172, 228, 0.55);
  color: var(--dufeu-indigo);
}

/* The stamp's own print rules are NOT here. They sit inside the printed sheet
   above, beside the .planv-foot rule, because a document's paper rules belong
   with the sheet that draws it rather than scattered down the file. test/planEditor.test.js
   finds that sheet by the A4 landscape page it sets, so a print rule added
   elsewhere no longer changes what that test is reading. One print block, one
   sheet, and now for the right reason. */


/* ── The plan's version line ──────────────────────────────── */
/*
   THE STATE AT THE HEAD OF VERSION HISTORY (versionStateHtml in js/plan.js):
   which version the customer holds, when it went, and whether the plan has
   been edited since. It began as a line under the plan's title and Neil had it
   moved into the dialog, which is where somebody goes to ask the question.

   A LINE, NOT A NOTICE. The page already carries two notices above the document
   (the frozen or archived state, and the export gate), and a third box for a
   fact that is true of every plan every day would be furniture. So this is set
   beside the subtitle in the page head, in the same muted ink, and the one .status
   chip in it carries the state. Nothing new enters the design system: the chip,
   the ink and the gap are all tokens above.

   :empty keeps an empty state from leaving a gap, in the dialog as on the page
   it came from.
*/
.plan-version {
  display: flex; align-items: center; flex-wrap: wrap;
  gap: 8px; margin-top: 8px;
  font-size: 13px; color: var(--text-mute);
}
.plan-version:empty { display: none; }


/* ── The plan's history dialog ────────────────────────────── */
/*
   The version and approval history on portal/plan.html (js/plan.js): one .table
   inside a Modal, so there is almost nothing to add here. Every row uses
   components this file already defines, the .status chips above for what
   happened and .mono for the numbers and dates, because a chip that means
   "current" in this dialog and a chip that means "signed off" in a list of
   sign-offs should look the same to somebody reading both.

   What a plain .table cannot express is the tick box that chooses two versions
   to compare. It is a real checkbox with an accessible name and nothing else:
   `accent-color` is the same Dufeu Teal .check uses in a form, so the two agree
   without this becoming a second component. The column is narrow because it
   holds one 18px control, and the Version column beside it holds a number.
*/
.history-pick-col { width: 34px; }

.history-pick {
  width: 18px; height: 18px;
  accent-color: var(--dufeu-teal);
  cursor: pointer;
}
/* Two are already ticked, so this one cannot be. Disabled is what assistive
   technology reports; the cursor is what says the same thing to a mouse. */
.history-pick:disabled { cursor: not-allowed; }


/* ── Customer delivery progress chips ─────────────────────── */
/*
   NOTHING DRAWS THESE AT PRESENT, and that is the one thing to know before
   reading the rest. The Progress column came off the customer's page on
   2026-08-26 (Neil: "Can we remove the progress part for now, please. I think
   that's a bigger future feature when I've got the fundamentals working"), so
   this block is dormant rather than dead: PlanTask.progress is still kept, the
   Autotask sync still writes it, and the staff side still shows it.

   IT IS NOT WHAT THE ROW MARK USES, and the two must not be confused. The
   customer's page draws a mark a member of staff set (see .planv-table
   tr.task-done above) with the staff page's own .tprog chip, so one word means
   one colour on both copies of a document. This block styles the COLUMN, which
   is a different feature: four states including NOT_STARTED, in a column of its
   own, fed by the Autotask pull rather than by a person.

   IT IS KEPT BECAUSE IT IS A CONTRACT, not because it is in use. The four looks
   here have to stay the same four the staff .status chips use for the same
   meanings, and test/statusChips.test.js reads this block to enforce exactly
   that. Deleting it would take the customer half of that comparison away, and
   the column is meant to come back properly. If it is still unused when that
   stops being true, delete it then.

   What it was, and will be again: the one extra column an APPROVED share link
   carries (js/plan-view.js), how far live delivery has got, per task. The pill
   is the same shape as
   .planv-impact above, and the four looks are the ones the staff .status chips
   already use for the same meanings (not started grey, in progress blue,
   complete green, blocked red), so a state means one colour everywhere it
   appears. Colour is never the only signal: the chip always carries its own
   words, and a task with no live progress draws no chip at all.

   COMPLETE MOVED FROM FILLED TEAL TO THE GREEN TINT on 2026-08-14, in step with
   `.status-complete`, because Neil recoloured the staff chips ("Completed
   (Green)") and this sheet is the SAME meaning on a customer's screen. Moving
   one without the other is exactly the drift the sentence above exists to stop,
   and `test/statusChips.test.js` fails if the two part company.
*/
.planv-progress {
  display: inline-block;
  padding: 2px 9px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--line);
  background: var(--off-white);
  color: var(--grey-dark);
  font-size: 12px;
  font-weight: 600;
  white-space: nowrap;
}
.planv-progress[data-state="NOT_STARTED"] { background: var(--off-white);          border-color: var(--line);                color: var(--grey-dark); }
.planv-progress[data-state="IN_PROGRESS"] { background: rgba(108, 172, 228, 0.18); border-color: rgba(108, 172, 228, 0.55); color: #1F5C8B; }
.planv-progress[data-state="COMPLETE"]    { background: rgba(150, 212, 4, 0.22);   border-color: rgba(150, 212, 4, 0.62);   color: #47660A; }
.planv-progress[data-state="BLOCKED"]     { background: rgba(192, 57, 43, 0.10);   border-color: rgba(192, 57, 43, 0.40);   color: #A32E22; }
/* On paper these ask for ink inside the ONE print sheet, which is now the last
   block in this file (beside .planv-impact): a second print block after it
   would become the one test/planExport.test.js reads as "the sheet". */


/* ── The inline wizard host (Wizard.mount) ────────────────── */
/*
   The same step engine js/wizard.js draws inside a Modal, mounted on a page
   instead (the Autotask push at portal/plan-push.html). Everything inside the
   steps is the wizard's own markup and the tokens above; what this block adds
   is the frame a Modal used to provide: the header line, the notice's place,
   and a footer row shaped exactly like .modal-foot so Back, Cancel and the
   primary sit where every dialog puts them.
*/
.wizard-page-head { margin-bottom: 12px; }
.wizard-page-head h2 { font-size: 17px; }
.wizard-page-head .sub { display: block; margin-top: 2px; font-size: 13px; color: var(--text-mute); }
.wizard-page-foot {
  display: flex; align-items: center; justify-content: flex-end; gap: 10px;
  flex-wrap: wrap;
  margin-top: 16px; padding-top: 12px;
  border-top: 1px solid var(--line-soft);
}
.wizard-page-foot .lead { display: flex; gap: 10px; margin-right: auto; }
.wizard-page-foot .spinner { margin-right: 2px; }

/* The push wizard's own facts: a heading per list, the one-line facts, and the
   result step's chips. Nothing new: type sizes and chips are tokens above. */
.push-h { margin: 14px 0 8px; font-size: 14px; }
.push-fact { margin: 6px 0; }
.push-list { margin: 6px 0 0 18px; padding: 0; }
.push-list li { margin: 2px 0; }
.push-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 10px; }
.push-retry { margin-top: 12px; }

/* HOW FAR THROUGH THE RUN IS, in the dialog that pops up while the push walks
   the plan (Neil, 2026-08-27: "a progress bar that pops up on screen to show
   the task / phase it's creating as it's going along so we know it's
   working").

   THE SAME SHAPE AS THE CHECKLIST'S PROGRESS ABOVE, and for the same reason:
   the words are the answer and the bar is the glance, so the words come first
   and read on their own. Stacked rather than in a row, because this one carries
   a second line, the name of the phase or task in flight, and a name is as long
   as somebody typed it. */
.push-prog { min-width: min(360px, 70vw); }
.push-prog-words { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
/* The row being created. Reserves its line whether or not there is a name, so
   the box does not jump a row taller and shorter as the run walks past an
   untitled task. */
.push-prog-now {
  margin: 2px 0 10px;
  min-height: 1.4em;
  font-size: 13px;
  color: var(--text-mute);
  overflow-wrap: anywhere;
}
.push-prog-track {
  height: 8px;
  border-radius: var(--radius-pill);
  background: var(--off-white);
  overflow: hidden;
}
.push-prog-fill {
  display: block;
  width: 0;
  height: 100%;
  background: var(--dufeu-teal);
  /* The width is set per event, so the movement is all the stylesheet owns: an
     item nudges the bar rather than snapping it. */
  transition: width 160ms ease-out;
}

/* THE TWO ENDINGS. The box stays open when the run is over (Neil, 2026-08-27:
   "be good if the box changes to completed when it has, with an option to press
   close so we know it's completed if someone leaves unattended"), so the bar has
   to say which ending it was.

   GREEN IS FOR A CLEAN RUN ONLY, the same rule the checklist's bar follows.
   A run that finished with some tasks failed keeps the working teal and says so
   in its title and its counts: green for "some of them" is the one thing a
   glance across a room must never read. */
.push-prog.is-complete .push-prog-fill { background: var(--dufeu-green); }
.push-prog.is-failed .push-prog-fill { background: var(--danger); }

@media (prefers-reduced-motion: reduce) {
  .push-prog-fill { transition: none; }
}

/* The Autotask dialog on portal/plan.html: section headings between its three
   tables, and the collapsed ignore memory. */
.at-h { margin: 14px 0 8px; font-size: 14px; }
.at-ignored { margin-top: 14px; }
.at-ignored summary { cursor: pointer; font-weight: 600; font-size: 13px; color: var(--text-mute); }
.at-ignored .table { margin-top: 8px; }

/* ── Project checklist ────────────────────────────────────── */
/*
   portal/checklist.html. Four blocks: the record as a LIST, a multi-select whose
   rows are NAMES, the chosen names as CHIPS, and the phases of tickable items.

   NOT A PLAN AND NOT THE KICKOFF PLAN. Neil, 2026-08-15: a checklist "has zero
   bearing on the project kickoff meeting DO NOT get them mixed up." Nothing here
   is scoped to `.plan-table` or `.plan-board`, and nothing there is reused by
   name: a checklist is a list of things somebody ticks.

   NO NEW TOKEN ENTERS THE SYSTEM FOR IT. Every colour, radius and font size
   below is one this file already declares, and two of the four blocks are
   mostly geometry over classes that already exist (`.check` for an item row,
   `.dd-wrap` / `.dd-btn` / `.dd-menu` for the picker). What is genuinely new is
   named for the SHAPE rather than for this page, so the next surface that wants
   a facts list, a name picker or a row of chips reuses it rather than starting
   a sixth variant.
*/

/* THE RECORD AS A LIST: label left, value right, one fact per row, a rule
   between. Neil's call, 2026-08-15, over the facts GRID (`.grid.cols-3`) this
   portal uses everywhere else.

   The grid is right for a block of read-only facts and wrong here, because two
   of these four rows carry a CONTROL rather than a value (a date box and a
   picker), and a control in a three-across auto-fit grid lands at a different
   width on every screen. A list gives every control the same right edge.

   The padding, the rule and the label type are the table's and the form's, not
   new: 12px/18px is `.table td`, the rule is `--line-soft` as every table row
   is, and 13px/600/--text-dim is `.field label`. */
.cl-record { margin: 0; }
.cl-fact {
  display: flex; align-items: center; flex-wrap: wrap;
  justify-content: space-between; gap: 12px 18px;
  padding: 12px 18px;
  border-bottom: 1px solid var(--line-soft);
}
.cl-record > .cl-fact:last-child { border-bottom: 0; }
.cl-fact > dt { flex: 0 0 auto; font-size: 13px; font-weight: 600; color: var(--text-dim); }
.cl-fact > dd {
  margin: 0; min-width: 0; flex: 1 1 220px;
  display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px;
  color: var(--text); font-weight: 600; text-align: right;
}
/* The picker is a `.dd-btn`, which is `width: 100%` because it was built for a
   grid cell. In a record row it is one of several things at the right edge, so
   it takes a column of its own rather than the whole line. */
.cl-fact .cl-pick { flex: 0 0 auto; width: 220px; }
/* The box that opens the calendar. Everything about how it LOOKS is the shared
   `.field input` rule (see the top of this file); the cursor is here because
   pressing this box anywhere opens the picker, which is Neil's ask and is not
   true of an ordinary text field. */
.cl-date { cursor: pointer; }
.cl-date:hover { border-color: var(--dufeu-blue); }

/* A MULTI-SELECT WHOSE ROWS ARE NAMES, AND WHICH THEREFORE HAS NO TICK-BOX
   COLUMN. Neil, 2026-08-15: "when press the name of the engineer it select, no
   checkbox there required, looks tidier."

   It sits inside the existing dropdown chrome (`.dd-wrap` / `.dd-btn` with its
   count pill / `.dd-menu`), so the trigger, the popover, the shadow and the
   count are not a second opinion about what a multi-select looks like. Only the
   ROW is new, and it is the `.menu-item` row's geometry with a tick where the
   box would have been: 44px for WCAG 2.2 target size, 7px radius, off-white on
   hover, 14px/600.

   THE TICK IS NOT THE ONLY MARK. A chosen name also darkens to full ink AND
   appears below as a chip, so the state survives a reader who cannot separate
   the glyph from the row. The row is a real button carrying
   role="menuitemcheckbox" and aria-checked, which is what a screen reader needs
   once the box is gone. */
.dd-name {
  display: flex; align-items: center; gap: 9px;
  width: 100%; min-height: 44px; padding: 4px 7px;
  background: none; border: 0; border-radius: 7px; cursor: pointer;
  font-family: inherit; font-size: 14px; font-weight: 600; color: var(--text-dim);
  text-align: left;
}
.dd-name:hover { background: var(--off-white); color: var(--text); }
.dd-name .tick { flex: 0 0 auto; width: 16px; height: 16px; color: var(--dufeu-teal); }
/* Hidden rather than absent, so every name starts on the same left edge whether
   or not it is chosen. `visibility` and not `display`, for the same reason. */
.dd-name[aria-checked="false"] .tick,
.dd-name:not([aria-checked]) .tick { visibility: hidden; }
.dd-name[aria-checked="true"] { color: var(--text); }
/* The row for somebody who is not in the app at all. Set apart from the names
   above it, because it is a different kind of thing: not one more name but the
   way to write one. */
.dd-name-custom {
  margin-top: 5px; padding-top: 9px; border-radius: 0;
  border-top: 1px solid var(--line-soft);
}
.dd-custom { display: flex; align-items: center; gap: 8px; padding: 2px 7px 4px; }
.dd-custom input {
  flex: 1 1 auto; min-width: 0; height: 31px; padding: 0 9px;
  font-family: inherit; font-size: 13.5px; color: var(--text);
  background: var(--panel); border: 1px solid var(--line); border-radius: 9px;
}
.dd-custom input:focus {
  outline: none; border-color: var(--dufeu-teal);
  box-shadow: 0 0 0 3px rgba(18, 118, 145, 0.15);
}

/* WHAT HAS BEEN CHOSEN, AS CHIPS. The `.picked` list above is the portal's other
   answer to the same question and it is deliberately not reused: that one is a
   LIST because each entry carries two facts (a name and an address) and a pill
   holding both is either truncated or enormous. A technician is one name, so a
   row per person would be a column of mostly empty width.

   Same pill geometry as `.badge` and `.planv-impact`, which is what a chip in
   this application looks like. The Remove button is 24px, the WCAG 2.2 target
   minimum, and it is `.no-print` in the markup: a document has no buttons on
   it, so the chips print as the words they are. */
.chips {
  display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px;
  list-style: none; margin: 0; padding: 0;
}
.chips:empty { display: none; }
.chip {
  display: inline-flex; align-items: center; gap: 2px;
  min-height: 28px; padding: 2px 4px 2px 11px;
  background: var(--off-white); border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  font-size: 13px; font-weight: 600; color: var(--text);
}
/* ── A person, as a chip ───────────────────────────────────
   Who did the job, on a checklist. Anywhere a NAME needs to be shown as a
   record rather than as text.

   THE INITIALS DISC IS THE ICON, and it is Neil's idea, 2026-08-25: "maybe have
   initials as the icon". It solves the problem the old technician chips had,
   which was that they were the one family in the portal drawn as a saturated
   fill with white text, and looked out of place beside everything else once the
   other families settled on a pale fill with a coloured edge.

   MOVING THE HUE ONTO THE DISC IS WHAT LETS THE CHIP JOIN THEM. The person still
   keeps their own colour (chipColour in js/checklist.js hashes the name, so the
   same person is the same colour everywhere), but it now lives in a 20px disc
   instead of across the whole pill, and the chip itself takes the house pale
   fill and coloured border.

   IT ALSO FIXES A CONTRAST COMPROMISE. White on the old fills ran from 5.0:1 to
   8.9:1, and Dufeu Iris had to be darkened to #5B6DAD specially to clear AA at
   all (the note on the old .chip-c3 explained why). The NAME no longer needs any
   of that: it is dark ink on a pale ground, so every one of the five clears
   13:1. The DISC still does, because a 10px initial on a saturated 20px circle
   is exactly the case that needs it, so its ink is chosen per fill and iris is
   still darkened to the same #5B6DAD (see the note on .person-c3 below).

   ANY NAME, NOT JUST OURS. The colour is hashed from the name rather than looked
   up against a user record, so somebody with no account (a customer at a kickoff
   meeting) gets a chip on the same terms. Nothing marks a customer as one: their
   organisation is a column of its own wherever they are listed, so saying it
   twice on the chip would be saying it twice. */
.person {
  display: inline-flex; align-items: center; gap: 7px;
  height: 26px; padding: 0 11px 0 3px; border-radius: var(--radius-pill);
  border: 1px solid; font-size: 13px; font-weight: 600; color: var(--text);
  white-space: nowrap;
}
.person .ini {
  width: 20px; height: 20px; border-radius: 50%;
  display: grid; place-items: center; flex: 0 0 auto;
  font-family: var(--font-heading); font-size: 10px; font-weight: 700;
  letter-spacing: 0.02em;
}
/* The five brand colours, in the sequence the extended gradient runs. The pale
   fill is the hue over white; the disc is the hue itself. */
.person-c0 { background: #E4F3FD; border-color: var(--dufeu-blue); }
.person-c0 .ini { background: var(--dufeu-blue); color: #10314A; }
.person-c1 { background: #EDFACE; border-color: var(--dufeu-green); }
.person-c1 .ini { background: var(--dufeu-green); color: #2B3D01; }
.person-c2 { background: #DCEEF3; border-color: var(--dufeu-teal); }
.person-c2 .ini { background: var(--dufeu-teal); color: #FFFFFF; }
.person-c3 { background: #E6E9F5; border-color: var(--dufeu-iris); }
/* Iris is the one exception to "the disc is the hue itself". White on the token
   value (#6477BA) is 4.30:1, which fails the 4.5:1 AA minimum for a 10px
   initial, so the DISC is darkened to #5B6DAD (4.96:1) while the border keeps
   the token. The mismatch is deliberate: the border carries no text, so it owes
   only the 3:1 a non-text boundary owes, and the two read as one colour at this
   size. Darkened here rather than on --dufeu-iris itself, because that token is
   also the pre-sales figures rail and the leave rows, neither of which needs
   it. */
.person-c3 .ini { background: #5B6DAD; color: #FFFFFF; }
.person-c4 { background: #E2F3F1; border-color: #6FC2BC; }
.person-c4 .ini { background: #6FC2BC; color: #17332F; }
/* The remove cross takes the chip's own ink, as it does on an address chip. */
.person .chip-remove { color: inherit; }

.chip-remove {
  display: inline-grid; place-items: center;
  width: 24px; height: 24px; padding: 0;
  background: none; border: 0; border-radius: var(--radius-pill);
  /* Inherits the chip ink so the cross is legible on all five fills, at the
     weight of a secondary control rather than at full strength. */
  color: inherit; opacity: 0.75; cursor: pointer;
}
.chip-remove:hover { background: rgba(255, 255, 255, 0.35); opacity: 1; }
/* Focus is drawn against the chip rather than the page, so it is visible on the
   dark fills as well as the light ones. */
.chip-remove:focus-visible { outline: 2px solid currentcolor; outline-offset: -2px; opacity: 1; }
.chip-remove svg { width: 12px; height: 12px; }
/* -- Addresses as a box, with the chips under it -----------
   What UI.chipsInput draws: `.chips-input` is the control, `.chips-field` is the
   bordered box somebody types into, and `.chips-field-list` sits BELOW it holding
   the committed addresses as .chip pills.

   THE CHIPS MOVED OUT OF THE BOX. Neil, 2026-08-27, having used it in production:
   "When the address is added goes as a bubble but below the text box." They used
   to sit inside it ahead of the caret, Outlook style, which made the box grow a
   row at a time as the list got longer. The entry is now one fixed line whatever
   the list does.

   SHORTER, NOT THE WHOLE DIALOG. Neil, same day: "the box is quite long... Review
   and make the box smaller in length." This REVERSES "make the box smaller in
   height but longer in length" (2026-08-25), which is what the full-width box
   came from. 320px is the portal's existing address-box cap (`.field.narrow
   input` above, and the customer gate), so the staff share box is the width every
   other address box in this application already is rather than a third size.

   THE CAP IS ON THE CONTROL, NOT THE BOX, so the chips underneath share the
   column with the box they belong to instead of running the full width of the
   dialog under a 320px field.

   THE BORDER LIVES ON THE BOX AND THE INPUT HAS NONE, and saying so was not
   enough: `.field input` two thousand lines above sets a border, a radius, a
   38px height and its own focus ring on EVERY input inside a `.field`, and beats
   a bare `.chips-field-entry` on specificity. So the box drew its border and the
   input drew a second one inside it, which is the "double borders" Neil reported.
   The entry's own rules are therefore stated at a specificity that wins outright,
   rather than relying on which rule comes later in this file.

   ONE ROW HIGH. min-height is one control's worth rather than the textarea's 84px
   floor, and with the chips gone from inside it the box is one row always. */
.chips-input { max-width: 320px; }
.chips-field {
  display: flex; align-items: center;
  min-height: 38px; padding: 4px 8px;
  background: var(--panel); border: 1px solid var(--line); border-radius: 9px;
  cursor: text;
}
.chips-field:focus-within {
  border-color: var(--dufeu-teal);
  box-shadow: 0 0 0 3px rgba(18, 118, 145, 0.15);
}
/* A row of pills that wraps, under the box. `display: none` when it is empty
   rather than a margin that collapses: an empty flex row with a top margin is
   still a gap under a box with nothing beneath it. */
.chips-field-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.chips-field-list:empty { display: none; }
/* An email address is longer than 320px more often than a person's name is, and
   the column is now 320px. Scoped to this list so the checklist's technician
   chips, which are short names in a wide row, keep breaking only between chips. */
.chips-field-list .chip { max-width: 100%; overflow-wrap: anywhere; }
/* The entry is the whole row now that nothing shares it.

   THE SELECTORS ARE DELIBERATELY HEAVY. `.field input` (0,2,0) is what draws the
   second border, so a bare `.chips-field-entry` (0,1,0) loses to it and the
   `.field .chips-field .chips-field-entry` form (0,3,0) is what actually wins.
   The unnested form is kept beside it for a caller that does not wrap the control
   in a `.field`. */
.chips-field .chips-field-entry,
.field .chips-field .chips-field-entry {
  flex: 1 1 auto; min-width: 0; width: 100%;
  height: 28px; padding: 0; margin: 0;
  font-family: inherit; font-size: 13.5px; color: var(--text);
  background: none; border: 0; border-radius: 0; outline: none;
}
/* And the focus ring with it: the box already draws one on :focus-within, and a
   second glow inside the first is the double border again in another form. */
.chips-field .chips-field-entry:focus,
.field .chips-field .chips-field-entry:focus {
  border: 0; outline: none; box-shadow: none;
}
/* A field-level refusal marks the BOX, since that is what carries the border
   somebody is looking at. */
.chips-field[data-invalid="true"] { border-color: var(--danger); }
.chips-field[data-invalid="true"]:focus-within {
  box-shadow: 0 0 0 3px rgba(192, 57, 43, 0.15);
}


/* AN ITEM IS A BOX AND WORDS, AND NOTHING ELSE. No status chip, no owner, no
   date, no duration: Neil, 2026-08-15, "no statuses like blocker required". The
   box IS the state, so a chip beside it would be a second answer to one
   question.

   The row is `.check`, the tick-box row this file already defines (44px, an 18px
   box, the teal accent), in both modes. In the Export view it is a <label>, so
   the whole row is the tick target. In the Build view it is a <div> and the
   words are the control that OPENS the item, which is the row-name-as-control
   idiom used everywhere a record's name is what you press to change it. */
.cl-items { list-style: none; margin: 0; padding: 0; }
.cl-items > li + li { border-top: 1px solid var(--line-soft); }
.cl-text { flex: 1 1 auto; min-width: 0; font-size: 14px; color: var(--text); }
button.cl-text {
  padding: 5px 7px; margin: 0;
  background: none; border: 0; border-radius: 7px;
  font-family: inherit; font-weight: 400; text-align: left; cursor: text;
}
button.cl-text:hover { background: var(--off-white); box-shadow: inset 0 0 0 1px var(--line); }

/* THE DOCUMENT'S OWN HEAD: the Dufeu mark on the LEFT, the title on the line
   BELOW it (Neil, 2026-08-15). It belongs to the document rather than to the
   application, so it is drawn in the Export view and on paper and nowhere else;
   the Build view has the ordinary `.page-head` instead.

   The mark itself is `.planv-brand`, the wrapper that owns the clearspace rule
   (at least the height of the lowercase `d`, em-based so it scales with the
   logo) and, in the print sheet, the print-color-adjust that keeps it full
   colour on paper. Nothing about the mark is restated here. */
.cl-doc-head { display: none; }
/* The document head belongs to the two states that ARE the document: the
   window somebody ticks off in, and the read-only view Open lands on. It is
   absent while editing, where the page head is the heading and a second title
   would just be a title printed twice. */
[data-mode="fill"] .cl-doc-head,
[data-mode="view"] .cl-doc-head { display: block; }
/* And the page heading gives way to it, the same swap the print sheet makes a
   few hundred lines up. Whichever state draws the document head has the
   checklist name in it; leaving the ordinary page head up as well printed the
   name twice on screen, one above the other. The print rule already knew this
   and the screen did not, so the preview disagreed with the paper it
   previews. */
[data-mode="fill"] #cl-name,
[data-mode="fill"] #cl-sub,
[data-mode="view"] #cl-name,
[data-mode="view"] #cl-sub { display: none; }

/* THE LINE WAITING TO BE TYPED INTO. It has to read as the next row of the
   list rather than as a form dropped underneath it, so it borrows the row
   height and the gap of a real item and puts a ghost where the box will be:
   what you type appears exactly where it will end up. Only ever drawn while
   editing, so there is no inert state to style. */
.cl-add-line .check { opacity: 0.85; }
.cl-box-ghost {
  width: 18px;
  height: 18px;
  flex: 0 0 18px;
  border: 1px dashed var(--line);
  border-radius: 4px;
}
.cl-new {
  flex: 1 1 auto;
  border: 0;
  border-bottom: 1px dashed var(--line);
  border-radius: 0;
  background: none;
  padding: 2px 0;
  font: inherit;
  color: inherit;
}
/* The dashes firm up into a real field once somebody is in it, which is the
   whole feedback this control gives: it is quiet until it is being used. */
.cl-new:focus {
  outline: none;
  border-bottom-style: solid;
  border-bottom-color: var(--dufeu-teal);
}
.cl-new::placeholder { color: var(--text-mute); }

/* THE DRAG HANDLE. Quiet until the row is under the cursor, because six dots
   beside every line on a forty-line sheet is a lot of furniture for a control
   most people use twice. It keeps its size the whole time so nothing reflows
   when it appears: only the ink changes. */
.cl-grip {
  flex: 0 0 20px;
  width: 20px;
  height: 20px;
  padding: 0;
  border: 0;
  background: none;
  color: transparent;
  cursor: grab;
  line-height: 0;
}
.cl-grip svg { width: 20px; height: 20px; }
.cl-phase:hover .cl-grip,
.cl-items li:hover .cl-grip { color: var(--text-mute); }
/* Never invisible to the keyboard: a control somebody has tabbed to has to be
   findable, whatever the mouse is doing. */
.cl-grip:focus-visible {
  color: var(--text-dim);
  outline: 2px solid var(--dufeu-teal);
  outline-offset: 2px;
}
.cl-grip:active { cursor: grabbing; }

/* The row being moved, and the row it would land on. Deliberately different:
   one is "this is what you have hold of" and the other is "this is where it
   goes", and a single style for both leaves somebody guessing which. */
.cl-dragging { opacity: 0.5; }
.cl-drop-target { outline: 2px dashed var(--dufeu-teal); outline-offset: 2px; }

/* THE NOTES BOX. Ten rows to start with, because a two-line box tells somebody
   to write two lines. Vertical resize only: sideways would break the column
   the rest of the sheet is set in.

   `overflow` IS WHAT MAKES `resize` REAL. The spec applies resize only to a box
   whose overflow is something other than `visible`, and `visible` is the
   default. This was a <textarea> once, where the UA sheet already sets overflow
   to auto, so the grip came for free; the moment it became a contenteditable
   div the grip silently went, with the resize declaration above still sitting
   here reading as though it worked. */
.cl-notes {
  width: 100%;
  min-height: 200px;
  resize: vertical;
  overflow: auto;
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--panel);
  font: inherit;
  color: inherit;
  line-height: 1.5;
}
.cl-notes:focus { outline: 2px solid var(--dufeu-teal); outline-offset: -1px; }
/* The same words when they cannot be typed into. `pre-wrap` keeps the line
   breaks somebody actually typed, which is the whole point of a notes box:
   collapsed to one paragraph it would say something different. */
.cl-notes-read { white-space: pre-wrap; margin: 0; }

/* THE FOUR SIZES, and they are the whole size vocabulary of a note.

   Named classes rather than inline font sizes, for one reason that matters:
   the server sanitiser allows a span to carry one of exactly these four class
   names and no style attribute at all, so a size that is not in this list
   cannot survive a save. The PDF renderer maps the same four to point sizes.

   `em` rather than `px`, so a note nested inside the printed sheet scales with
   the sheet rather than fighting it. */
.cl-fs-sm { font-size: 0.85em; }
.cl-fs-md { font-size: 1em; }
.cl-fs-lg { font-size: 1.3em; }
.cl-fs-xl { font-size: 1.7em; }

/* The formatting bar above the note. Quiet: it is a row of three letters and a
   picker, sitting on the card rather than in a panel of its own. */
.cl-notes-bar {
  display: flex;
  align-items: center;
  gap: 4px;
  margin: 0 0 8px;
}
.cl-mark {
  min-width: 32px;
  height: 32px;
  padding: 0 8px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--panel);
  color: var(--text);
  font-size: 14px;
  cursor: pointer;
}
.cl-mark:hover { background: var(--off-white); }
/* PRESSED IS A REAL STATE AND IT IS READ FROM THE CURSOR, so this styles
   aria-pressed rather than a class: the attribute is the truth and there is no
   second copy of it to fall out of step. */
.cl-mark[aria-pressed="true"] {
  background: var(--dufeu-teal);
  border-color: var(--dufeu-teal);
  color: #FFFFFF;
}
.cl-mark:focus-visible { outline: 2px solid var(--dufeu-teal); outline-offset: 2px; }
.cl-size-select {
  height: 32px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--panel);
  color: var(--text);
  font: inherit;
  font-size: 13px;
  padding: 0 6px;
}
/* DISABLED HAS TO LOOK DISABLED, and until this rule it did not: the block
   above states the colour, the border and the background, and an author rule
   beats the browser's greyed-out defaults on every one of them. Measured in
   Chrome, the ONLY difference between this picker enabled and disabled was
   `opacity: 0.7`, which comes from the user agent stylesheet, is not ours, and
   is not guaranteed by anything.
   That is not a cosmetic gap here. Disabling the control IS this application's
   way of saying an action exists but cannot happen right now (UI-CONVENTIONS.md:
   the portal removed the banners and the instructional sentences that used to
   say it in words), so a disabled control that reads as enabled leaves the
   refusal stated nowhere at all.
   THE SAME TREATMENT AS `.btn:disabled` above, value for value, rather than a
   second opinion about what disabled looks like. */
.cl-size-select:disabled { opacity: 0.5; cursor: not-allowed; }

/* THE PLACEHOLDER, which a contenteditable does not get for free. Drawn only
   when the box is genuinely empty: `:empty` alone would miss a box holding a
   stray <br>, which is what some browsers leave behind when the last character
   is deleted, so the JS keeps the box empty rather than the CSS guessing. */
.cl-notes[contenteditable]:empty::before {
  content: attr(data-placeholder);
  color: var(--text-mute);
}

/* BULLETS INSIDE A NOTE. The portal resets list styling globally (every other
   list in this application is a row of records, not prose), so a note has to
   ask for the discs back. Scoped to the note and its read-only twin so nothing
   else inherits it. */
.cl-notes ul,
.cl-notes-read ul {
  list-style: disc;
  margin: 4px 0;
  padding-left: 22px;
}
.cl-notes li,
.cl-notes-read li { margin: 2px 0; }

/* HOW FAR THROUGH THE SHEET IS. The words are the answer and the bar is the
   glance, so the words are first and always legible on their own: a bar that
   was the only statement of progress would be unreadable to anybody who could
   not see it, and unprintable. */
.cl-progress { display: flex; align-items: center; gap: 10px; }
.cl-progress-words { font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; }
.cl-progress-track {
  flex: 1 1 auto;
  max-width: 220px;
  height: 8px;
  border-radius: var(--radius-pill);
  background: var(--off-white);
  overflow: hidden;
}
.cl-progress-fill {
  display: block;
  height: 100%;
  background: var(--dufeu-teal);
  /* The width is set from the record, so the movement is the only thing the
     stylesheet owns: a tick nudges the bar rather than snapping it. */
  transition: width 160ms ease-out;
}
/* FINISHED IS A DIFFERENT COLOUR, and it is the brand green rather than a
   brighter teal, because "all of them" is the one state worth telling apart at
   a glance from across a plant room. */
.cl-progress.is-complete .cl-progress-fill { background: var(--dufeu-green); }

@media (prefers-reduced-motion: reduce) {
  .cl-progress-fill { transition: none; }
}

/* The saved stamp above the Actions menu, top right. */
.cl-head-right {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 6px;
}
.cl-doc-head .planv-brand { margin: 0; }
.cl-doc-head h1 { margin: 10px 0 0; }

/* ── The Kickoff Plan's board ─────────────────────────────── */
/*
   The one plan type that is not drawn as the banded table (js/plan-kickoff.js,
   and the note at the top of it for why). A kickoff note splits by what CHANGES:
   the facts settled before the meeting down the left, and the three things
   somebody types into while people talk as cards on the right.

   LAYOUT ONLY. Everything with a look of its own is already in this file and is
   used as it is: .card / .card-head / .card-body are the cards, .field and
   .facts are the rail's label-over-value idiom, .avatar is the initials disc the
   sidebar already draws, .status is the chip, .btn.small is Add, .empty is an
   empty card and .muted is an unset value. No colour, radius or face is
   introduced below: every value is a token declared at the top of this file.

   IT STACKS. The two columns are a grid that becomes one column under 900px, so
   a narrow window scrolls DOWN rather than sideways, and long words break inside
   their own box rather than widening it.
*/

/* ONE COLUMN, TOP TO BOTTOM. The facts sit across the top and the cards run
   underneath them at the full width of the page.

   IT WAS `minmax(240px, 320px) minmax(0, 1fr)` UNTIL 2026-08-23, a rail of facts
   down the left with the cards beside it, and that rail is what was squeezing the
   Meeting actions table. Neil, of that card: "The meeting actions need to be a
   single line ideally", and then, having worked out the cause himself: "Maybe we
   need to move the left box to make the others wider?"

   THE NUMBERS, because the fix was measured rather than guessed. On an ordinary
   laptop (a 1080px board, which is roughly a 1366px window) the rail took 320px
   and the four action columns were left with 248 / 132 / 124 / 200. With the
   rail gone the same four take 391 / 132 / 195 / 316: Assigned goes from 124px,
   which fits about twelve characters, to 195px.

   THE SAME MOVE THE DATA MIGRATION PLANNER MADE, and Neil approved it there for
   the same reason (commit dd21160): "The summary moves out of the 340px rail and
   up to full width. Five columns of figures in a rail reached for a horizontal
   scrollbar." Moving content out of the rail was the answer there and it is the
   answer here.

   AND THE 900px MEDIA QUERY HAS GONE WITH IT, deliberately rather than by
   oversight: it existed to collapse the two columns into one on a narrow window,
   and there is only one column now, at every width. A rule that restated the
   default would be a second layout to keep in step with the first. */
.plan-board {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 18px;
  align-items: start;
}

/* WHAT WAS SETTLED BEFORE THE MEETING, ACROSS THE TOP.

   THE RAIL USED TO STAY WITH THE READER, and this comment used to say why: the
   facts were `position: sticky` so they followed the scroll instead of leaving
   the top of the page. THAT IS GONE, and it is a real loss stated rather than
   dropped: scroll down to a long Q&A card now and the customer's name is no
   longer beside it.

   IT IS A SMALL LOSS AND HERE IS THE REASON IT WAS ACCEPTED. The two facts most
   worth having on screen are already on screen: the page head under the plan's
   title carries the customer and the project on every Kickoff Plan
   (`subtitle()` in js/plan.js), and the page head does not scroll away until the
   whole document does. The two that do scroll away are the meeting's date and
   the attendee list, neither of which anybody is checking mid-sentence: the
   attendees are in the room.

   IF IT IS EVER WANTED BACK, the honest form is a sticky one-LINE summary rather
   than a sticky card, because a card of four facts pinned to the top of a
   one-column layout would eat the height the cards under it need. That is a
   design decision rather than a CSS one, so it is not made here.

   FOUR FACTS, ONE UNDER THE OTHER, CLOSE TOGETHER. Neil, 2026-08-23, after
   seeing them across the box: "Change of plan, how about the 4 things one under
   the other but not much of a spacing.. The attendess going across the page?" So
   it is one track at every width. The fifth fact left in the same breath and is
   its own card now (plansCard in js/plan-kickoff.js), which is what leaves
   the four this stacks.

   IT WAS `repeat(auto-fit, minmax(200px, 1fr))` FOR A FEW HOURS on 2026-08-23,
   which laid the facts four across in one flat strip six times as wide as it was
   tall, and then briefly two columns of two. The two-column shape is what the
   attendees broke: three of these facts are one line each and the fourth is a
   table, so whichever column held it grew tall while its neighbour stayed short.
   One track gives the table the whole width of the card, which is what a table
   with a heading row needs.

   AND THERE IS NO BREAKPOINT, because one track needs none. The rule that used
   to collapse two columns into one on a narrow window went with the rail on
   2026-08-23 and nothing has replaced it: the box is a stack at 320px exactly as
   it is at 1400px, and the attendees table scrolls inside `.kb-table` rather
   than pushing the page sideways.

   8px, NOT THE 16px THIS BLOCK USED TO CARRY. "Not much of a spacing" is the
   whole point of the change, and the number that reads right is one that stays
   SMALLER than the 6px + label + value a `.field` is already made of: at 16px the
   four facts read as four separate things stacked up, at 8px they read as one
   block of facts with its lines just far enough apart to tell where each begins.
   Below 8px the gap between two facts stops being bigger than the gap inside one
   (`.field` is `gap: 6px`), and the labels start to look like they belong to the
   value above them. */
.kb-facts-card { min-width: 0; }
.kb-facts {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 8px;
  align-items: start;
}

/* A PLAN ON THIS PROJECT, as a block rather than a bare anchor, so a list of
   them reads as a list of records. The tint is --off-white, the same wash a
   hovered control takes. */
.kb-link {
  display: block; padding: 9px 11px;
  background: var(--off-white);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  font-size: 14px; font-weight: 600; color: var(--dufeu-teal);
  text-decoration: none; overflow-wrap: anywhere;
}
.kb-link:hover { border-color: var(--line); text-decoration: underline; }

/* THE PLANS SOMEBODY PUT ON THIS NOTE, ONE PER ROW. The card was one hand-picked
   link, then an automatic list of every plan on the project, and since
   2026-08-23 it is a hand-picked LIST: Neil, reversing the automatic one, "need
   the ability to remove plans from the kickoff if added by mistake". The row is
   the SAME shape a meeting action takes (.kb-action above): the lines on the
   left, a state chip on the right, so a reader learns one row shape on this
   board rather than two.

   THE ROW LAYOUT IS HERE AND THE TINT IS ON .kb-link. This block comes AFTER
   .kb-link deliberately: its `display: flex` has to win over that rule's
   `display: block`, and both are single classes, so order is the whole of what
   decides it.

   REMOVE SITS BESIDE THE ROW, NOT INSIDE IT, because the row is an anchor and a
   button inside an anchor is not a control anything can press. The list item is
   the flex line; the anchor takes the space and the button takes what it needs.
   `.kb-plan-here` HAS GONE with the row it styled: the plan you are on is not in
   the list any more (Neil: "pointless"), and a rule for a class nothing draws is
   worse than no rule. */
/* The Project Plans card itself. A HANDLE rather than a look: js/plan.js
   replaces this one card in place after an Add or a Remove instead of redrawing
   a board full of unsaved answers, and it has to find it whether the card is
   holding a list or the shared empty block. `min-width: 0` is the one thing it
   is worth saying here, so a long plan name cannot push the card wider than the
   column it sits in. */
.kb-plans-card { min-width: 0; }
.kb-plans { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.kb-plans > li { display: flex; align-items: center; gap: 8px; }
.kb-plans > li > .kb-plan { flex: 1 1 auto; min-width: 0; }
.kb-plan-actions { flex: 0 0 auto; margin: 0; }
.kb-plan { display: flex; align-items: center; gap: 12px; }
.kb-plan > .kb-lines { flex: 1 1 auto; }
.kb-plan > .status { flex: 0 0 auto; }
/* `.plan-board .name` is two classes and would otherwise hold the name at body
   ink inside an anchor, so a row you can open would not look like one. */
.kb-plan.kb-link .name { color: var(--dufeu-teal); }

/* `.kb-people` AND `.kb-person` HAVE GONE, 2026-08-23, with the list they drew.
   The attendees were a column of discs, then a wrapping row of them, and Neil,
   of the row: "Maybe attendees should be in a table, it looks messy!" They are
   now the same `.kb-table` the meeting actions are (attendeesTable in
   js/plan-kickoff.js and its twin in js/plan-view.js), which has columns by
   construction, so there is nothing left here to align. Nothing else in the
   portal drew a person that way, so the rules went rather than being left as
   orphans. */

.kb-cards { display: grid; gap: 18px; min-width: 0; }
.kb-cards .card + .card { margin-top: 0; }
/* An empty card on a board is one of three or four on a page, so the shared
   .empty block is given the room a small card has rather than the room a whole
   section has. Padding only: the mark, the heading and the ink are untouched. */
.kb-cards .empty { padding: 24px 16px; gap: 10px; }
.kb-cards .empty .mark { width: 34px; height: 34px; border-radius: var(--radius); }

/* Cancel and Save, the button row of the one in-page form the whole board
   becomes while it is being edited (UI-CONVENTIONS.md section 2: last row,
   primary rightmost, Cancel to its left). Nothing is drawn here while the board
   is being read. */
.kb-foot { display: flex; gap: 10px; justify-content: flex-end; }

/* THE EDITING VIEW. Layout only, again: every control inside is the shared
   `.field` block this file already defines, drawn by js/plan.js's own
   fieldMarkup, so a column looks the same on this board as it does in the task
   form. A row's fields flow into as many columns as its card is wide and fall to
   one in the rail, which is 240 to 320px and fits exactly one. */
.kb-rows { display: grid; gap: 12px; }
.kb-editrow {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: 10px 12px;
  padding: 10px 12px;
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
}
/* In the facts block the row IS the field, so it carries no box of its own: a
   border around a single labelled control inside a card would read as a second
   card. */
.kb-facts .kb-editrow { padding: 0; border: 0; }

/* A ROW THAT IS ONE COLUMN. The row above lays its boxes into as many tracks as
   its card is wide, which is right for an attendee (a name beside a company) and
   wrong for a paragraph: the project's purpose is one tall box and would
   otherwise sit in the first of four tracks at a quarter of the width. The Q&A
   pair stacks for the same reason: its answer belongs UNDER its question. */
.kb-editrow-stack { grid-template-columns: minmax(0, 1fr); }

/* THE LINE YOU FILL IN DURING THE MEETING, and it stays INSIDE this block on
   purpose: the board's own tripwire (test/planKickoffBoard.test.js) reads from
   this file's board header to the next section rule, so a `── ` header here
   would take every rule below it out from under the check that it invents no
   colour, no radius and no face of its own.

   NEIL, 2026-08-23, of the Q&A answer box and then of the whole actions card:
   "shorter in height, longer in length ... it's not a physical box ... something
   similar to the box we fill in for the checklist where it's just a line", and
   "the boxes are all over the place".

   IT IS THE CHECKLIST'S CONTROL, SAID FOR `.field`. `.cl-new` a few hundred
   lines up is the same shape: no sides, no top, a rule under the words that
   thickens into teal the moment somebody is in it, so the control is quiet until
   it is being used. What is different is only that these boxes live inside the
   shared `.field` block, which brings a 38px well, an 11px inset and a 9px
   radius with it.

   AND THE RULE IS SOLID, NOT DASHED. Neil, 2026-08-23, having looked at the
   dashes `.cl-new` uses: "I'm not keen on the line that has been put in for the
   Q&A", because dashes read as a form to be filled in, and a meeting note is not
   a form. Asked whether the actions card should keep the dashes or follow the
   Q&A, he chose FOLLOW, and the line an action is ADDED on is one of these rows
   now (see `.kb-addline` below), so there is one treatment rather than two. This
   is the one place the board deliberately
   parts company with the checklist's own line; the shape is still its.

   RADIUS ZERO IS NOT AN INVENTED RADIUS. A line has no corners, and leaving the
   `.field` radius in place would curve both ends of the rule upward by 9px.

   WHY IT IS A CLASS AND NOT THE BOARD'S DEFAULT. One card on this board is
   deliberately a big box to write a paragraph in, and a rule under five rows of
   prose is not a line, it is a box with its sides rubbed out. `.kb-editrow-prose`
   is the purpose row and takes none of this.

   ACCESSIBILITY, BECAUSE A LINE IS EASY TO GET WRONG HERE. Every one of these
   still carries its own `<label>`: the actions card hides its labels from the
   eye only, and the narrow-window rule at the foot of this block gives the words
   back the moment the row stops sitting under its headings.

   THE FOCUS STATE IS THE RULE ITSELF, AND A RING WOULD UNDO THE WHOLE CHANGE.
   An `outline` around a borderless control draws the rounded box this is here to
   remove, and it draws it on a mouse click as well as on Tab: a text control
   matches `:focus-visible` however it was focused, so "keyboard only" is not
   something CSS can ask for here. That was tried and reverted on 2026-08-23
   after looking at it.

   So it is the rule that answers: 1px solid var(--line) at rest, 2px solid
   var(--dufeu-teal) in focus. That is a change of WEIGHT and of COLOUR, so it
   does not rest on colour alone, and neither half is subtle: the rule doubles in
   thickness, and it goes from var(--line) (about 1.6:1 on the card, a quiet
   boundary) to var(--dufeu-teal) (about 5.2:1), which is a change of roughly
   3.2x in contrast against the same background. Both clear the 3:1 WCAG 2.2 SC
   1.4.11 asks of a control's boundary in its focused state.

   IT USED TO BE DASHED AT REST, which made the change of STYLE the third signal.
   That went on 2026-08-23 with the dashes (see the note above), so this was
   re-checked rather than assumed: measured in the browser at 100%, the resting
   rule is 1 device pixel of var(--line) and the focused rule is 2 of
   var(--dufeu-teal), which is the same doubling every other focus treatment in
   this file relies on. If it ever does read as too quiet, the answer is a
   thicker or darker RULE, not an outline: an outline draws a rounded box around
   a borderless control, on a plain mouse click as well as on Tab, which is the
   exact look this whole block exists to remove.
*/
.kb-line.kb-editrow { padding: 0; border: 0; }
.kb-line .field input,
.kb-line .field select,
.kb-line .field textarea {
  height: 30px;
  min-height: 0;
  padding: 3px 0 2px;
  background: none;
  border: 0;
  border-bottom: 1px solid var(--line);
  border-radius: 0;
}
/* A LINE AND NOTHING TO DRAG. Neil, 2026-08-23: "Also remove the ability to make
   it bigger, won't be required."

   AND THE GRIP IS GONE PROPERLY RATHER THAN LEFT DEAD. The note on `.cl-notes` a
   few hundred lines up records the opposite fault: a `resize` declaration that
   went on reading as though it worked after the element stopped being able to
   honour it. These are real `<textarea>`s, whose UA `overflow: auto` is what
   makes `resize` apply at all, so `none` here actually takes the grip away; the
   declaration is doing something and will keep doing it.

   WHAT IT COSTS, STATED BECAUSE IT IS REAL. A long answer now scrolls inside its
   own line instead of being pulled open. The caret stays in view while it is
   being typed, and the whole answer still prints and exports, because the sheet
   draws the saved words as a sentence rather than drawing the box (see
   `printable` in js/plan-kickoff.js). The 500 and 4000 character bounds are the
   columns' own and are unchanged. */
.kb-line .field textarea { line-height: 1.5; resize: none; }
/* AND NO SCROLLBAR CHROME ON A ONE-LINE BOX. Taking the grip away exposed the
   other half of a short textarea: the moment a note is longer than its line,
   Chrome draws a pair of arrows and a track inside a 30px box, which is more
   furniture than the box. The bar is hidden, NOT the overflow: the box still
   scrolls to the caret as somebody types, still scrolls on a wheel, and still
   scrolls when a selection is dragged through it, so nothing typed becomes
   unreachable.
   AND THE WHOLE NOTE IS NEVER ONLY HERE. The printed sheet and the exported PDF
   draw the saved words as a sentence rather than drawing the box, and the row's
   own menu still opens the full task form, so a long note has three places it
   can be read in full. */
.kb-line .field textarea { scrollbar-width: none; }
.kb-line .field textarea::-webkit-scrollbar { width: 0; height: 0; }
.kb-line .field input:focus,
.kb-line .field select:focus,
.kb-line .field textarea:focus {
  box-shadow: none;
  border-bottom: 2px solid var(--dufeu-teal);
  padding-bottom: 1px;
}
/* NO RING, AND NOT BY OVERSIGHT: see the note above. `.field input:focus`
   already sets `outline: none` for every box in the portal, so nothing here has
   to switch one off; what this must not do is switch one back ON. */

/* THE MEETING ACTIONS, AS THE TABLE THEY ARE READ AS. The editing view used to
   flow its four boxes into `auto-fit, minmax(190px, 1fr)`, which on a card this
   width is three tracks: Task, Due date and Assigned on one line and Notes
   alone on the next, inside a bordered well, three or four times down the card.
   That is the "all over the place" Neil saw. One set of tracks, held on the
   wrapper so the headings and every row under them cannot be given two
   different sets, and the four columns line up down the card exactly as they do
   in the reading view. */
/* AND THEN: "the meeting actions need to be a single line ideally, needs tidying
   up a little" (Neil, 2026-08-23). Measured before anything was changed, because
   the obvious reading of that is wrapping and the obvious reading was wrong: at
   every card width from 1262px down to 482px the row was already ONE grid line,
   30px tall, with the headings, the boxes and the Add line agreeing on the
   tracks to the pixel. It only stacks under the 760px rule below, which is a
   phone and is meant to.

   WHAT HE WAS ACTUALLY LOOKING AT IS FOUR RULES, NOT ONE. Each box drew its own
   underline and a 12px column gap sat between them, so one action was four short
   rules of four different lengths (208 / 132 / 121 / 208 on a laptop) with three
   breaks in it. The eye reads that as four boxes on a row, which is what it is.

   SO THE GAP WENT AND THE RULE JOINED UP: `column-gap: 0`, with a 12px inset on
   the text holding the columns apart instead, so one action drew as one unbroken
   line across the card, the way a ruled ledger is.

   AND THAT WENT ONE STEP TOO FAR (Neil, 2026-08-23, having looked at it): "Need
   a bit of a break within the lines for the actions, dates etc instead of one big
   line." Four fields under one continuous rule read as one field: there is
   nothing to say where the task ends and the date begins.

   SO THE BREAK IS BACK, AS A REAL GAP RATHER THAN AS FOUR RAGGED RULES. This is
   not the shape the note above describes ripping out. That was four underlines
   of four different lengths, each box drawing its own, with no shared tracks
   behind them; this is the SAME tracks, one line tall, one action per line, with
   a 12px hole punched in the rule at each column boundary. The eye gets a
   boundary and the row still reads as a row.

   12px, PICKED IN THE BROWSER AND NOT INVENTED. It is the number that was
   already there: the inset the gap replaced was 12px, so the words have not
   moved at all, and the space that was inside the rule is now a break in it. It
   is also the column gap `.kb-editrow` and `.kb-gridhead` already use outside
   this grid, so the card is spaced like the rest of the board rather than to a
   value of its own.

   ONE VALUE, HELD ON THE WRAPPER BESIDE THE TRACKS, and for the same reason: the
   headings, the rows and the line an action is added on all read it, so they
   cannot be given three different gaps and drift out of line with each other.
   That was a real defect once and it must not come back.

   AND THE HEADLINE OUTWEIGHS THE ASIDE. Task and Notes used to be the same
   2.4fr, which said an action's name and the remark about it were equally the
   point. They are not: the task is the thing, the note is what was said about
   it. The proportions are unchanged by the gap: the 132px date column is fixed
   and the other three share what is left, exactly as before. Nothing is dropped,
   because all four are read and written here. */
.kb-cols {
  --kb-tracks: minmax(0, 2.6fr) 132px minmax(0, 1.3fr) minmax(0, 2.1fr);
  --kb-gap: 12px;
}
.kb-cols .kb-editrow,
.kb-cols .kb-gridhead { grid-template-columns: var(--kb-tracks); column-gap: var(--kb-gap); }
.kb-gridhead {
  display: grid;
  gap: 10px 12px;
  padding-bottom: 4px;
  font-size: 13px; font-weight: 600; color: var(--text-dim);
}

/* THE ADD IS THE NEXT LINE OF THE LIST. Neil, 2026-08-23: "Add action must sit
   on the same line." It sits flush with the first column of the rows above it,
   at the rows' own spacing, rather than as a control adrift in a block of its
   own underneath them.

   AND IT IS THE CHECKLIST'S CONTROL IN BOTH VIEWS NOW (2026-08-23).
   `.cl-add-line` in js/checklist.js is a live input that commits on Enter, so a
   line is added by typing on it, and the reading view of the actions card draws
   exactly that: minuting an action during a meeting must
   not go through Edit. The EDITING view keeps the button, because setting a
   document up before a meeting is a different act from minuting one during it.
   Both sit in the same place, at the same spacing, in the list's first column. */
.kb-addline { margin-top: 12px; }
/* IN THE ACTIONS GRID IT IS A LINE OF THE TABLE, so it takes the table's own
   tracks and lands under the Task column rather than starting at the card's edge
   and belonging to nothing. `justify-items: start` so the editing view's BUTTON
   keeps its own width instead of being stretched to a column; the input asks for
   the whole column back below. */
.kb-cols .kb-addline {
  display: grid;
  grid-template-columns: var(--kb-tracks);
  /* The rows' own gap, read from the wrapper, so the line an action is added on
     breaks between its columns exactly where the rows above it do. */
  gap: 10px var(--kb-gap);
  align-items: center;
}
/* THE EDITING VIEW'S BUTTON keeps its own width. It is a control, not a column. */
.kb-cols .kb-addline .btn { justify-self: start; }
/* THE READING VIEW'S LINE IS THE WHOLE ROW (2026-08-23). Neil: "Add action needs
   to have the date etc as well not just add action and then change the date."
   It was one box spanning every track, because the only thing it asked for was
   the name; it is now the same four fields the rows above it draw, so it takes
   the tracks the rule above already gives it and needs nothing more.

   `.kb-newline` WENT WITH IT, and it is not a loss. It was a bare input carrying
   a hand-copy of the `.kb-line` treatment, held in step with the real one by a
   test because nothing else in the file would have noticed if one moved. The add
   line is a `.kb-line` now, so the two rules are the same rule.

   A LINE THAT IS BUSY SAVING WHAT WAS TYPED ON IT IS NOT A LINE TO TYPE ON. The
   press holds every box on it until the row exists, so they say so rather than
   looking idle. */
.kb-line .field input:disabled,
.kb-line .field select:disabled,
.kb-line .field textarea:disabled { color: var(--text-mute); cursor: default; }
.kb-line .field input::placeholder,
.kb-line .field textarea::placeholder { color: var(--text-mute); }

/* A NARROW WINDOW STACKS THE ROW, AND THE WORDS COME BACK WITH IT. A stacked
   row is no longer under the headings, so the headings go and each box says
   what it is again. `.sr-only` is undone rather than fought: the label is a real
   one in both states and only ever moves between being read and being read out. */
@media (max-width: 760px) {
  .kb-cols .kb-editrow { grid-template-columns: minmax(0, 1fr); }
  .kb-gridhead { display: none; }
  .kb-cols .field label.sr-only {
    position: static; width: auto; height: auto; margin: 0;
    /* `clip` IS DEPRECATED AND IS STILL THE ONE THAT UNDOES THIS. `.sr-only`
       above hides the label with `clip: rect(0, 0, 0, 0)` and no `clip-path`,
       so `clip-path: none` here would undo nothing and the label would come
       back one pixel tall. The property goes from both rules together or from
       neither. */
    /* stylelint-disable-next-line property-no-deprecated */
    overflow: visible; clip: auto; white-space: normal;
  }
}

/* The Q&A card's Answer box before there is a question to answer. `.field` sets
   its own display, so the browser's own rule for [hidden] does not reach it. */
.kb-editrow .field[hidden] { display: none; }

/* THE MEETING ACTIONS TABLE, which is the shared .table in a card that already
   has padding of its own, so the cells give theirs back at the two edges rather
   than insetting the document twice. It scrolls sideways inside its own box on a
   narrow window instead of widening the card. */
/* THE BOARD'S TABLES, READ AS WRITING RATHER THAN AS A GRID. Neil, 2026-08-23:
   "In terms of the tables I think you need to think more microsoft loop layout
   and redesign this a little." A LITTLE is the whole instruction: the shared
   `.table` already has no vertical rules, no zebra and no box around it, so what
   was left between it and that reference is the loud heading, the missing row
   response, and the vertical rhythm.

   THE HEADING STOPS ANNOUNCING ITSELF. The portal's `.table th` is uppercase at
   0.03em tracking, which reads as a database column heading; here it goes back
   to the words as written, one weight lighter, still small and still muted. It
   labels the column, it does not head a report. Scoped to `.kb-table`, so no
   other table in the portal moves: that is a bigger decision than this one and
   UI-CONVENTIONS.md section 6 governs it.

   TIGHTER DOWN, THE SAME ACROSS. 7px of vertical padding against 12px
   horizontal, so ten attendees read as ten lines of a document rather than ten
   rows of a spreadsheet, and the first and last columns stay flush with the card
   (below) so the table has no edge of its own to see.

   EVERY COLOUR AND EVERY RADIUS HERE IS A TOKEN. The board's own tripwire in
   test/planKickoffBoard.test.js forbids anything else, and nothing in this
   direction needed a value the palette did not already have. */
.kb-table { overflow-x: auto; min-width: 0; }
.kb-table .table th {
  text-transform: none; letter-spacing: normal;
  font-size: 12.5px; font-weight: 500;
}
.kb-table .table th { padding: 6px 12px; }
.kb-table .table td { padding: 7px 12px; }
.kb-table .table th:first-child, .kb-table .table td:first-child { padding-left: 0; }
.kb-table .table th:last-child, .kb-table .table td:last-child { padding-right: 0; }

/* THE ROOM, WITH AN EDGE ROUND IT. Neil, 2026-08-23: "I think attendes needs to
   be more tables like with a boarder around it and the names of the people /
   company to make it stand out a little."

   THIS CUTS AGAINST THE LOOP STEER THE BOARD'S TABLES WERE JUST MOVED FOR, and
   that is the point of it rather than an oversight: the attendees are the one
   fact in the block that is a SET OF PEOPLE rather than a line of text, sitting
   under three one-line facts, and with no edge of its own it read as more of the
   same. It is the ATTENDEES table alone. The meeting actions and the Q&A keep
   the borderless treatment, so the board still has one table idiom with one
   deliberate exception rather than two competing ones.

   THE BORDER IS ON THE WRAPPER, NOT ON THE TABLE. `.table` is
   `border-collapse: collapse`, which discards a radius, and the wrapper is
   already the box that scrolls sideways on a narrow window, so the edge and the
   clipping are the same box.

   AND THE CELLS TAKE THEIR PADDING BACK. `.kb-table` pulls the first and last
   columns flush with the card so the table has no edge to see; here there IS an
   edge, and words against it would sit on the line.

   READABLE AT TEN. Nothing here touches the 7px row rhythm the block above sets,
   which is what keeps ten people to ten lines rather than ten rows. */
.kb-attendees {
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
}
.kb-attendees .table th:first-child, .kb-attendees .table td:first-child { padding-left: 12px; }
.kb-attendees .table th:last-child, .kb-attendees .table td:last-child { padding-right: 12px; }
.kb-attendees .table th { padding-top: 9px; padding-bottom: 9px; color: var(--text-dim); }
.kb-attendees .table td { padding-top: 9px; padding-bottom: 9px; }
/* THE TWO WORDS ON THE ROW, GIVEN THEIR WEIGHT. `.name` is already the board's
   own 14px semibold; the organisation was body-dim, which is the ink a note
   takes, so an attendee read as a name with an aside after it rather than as two
   columns of one record. */
.kb-attendees .table td .name { font-weight: 700; }
.kb-org { font-weight: 500; color: var(--text); }

.kb-list { list-style: none; display: grid; gap: 10px; margin: 0; padding: 0; }

/* The name over its secondary lines, in one box, so a name that wraps does not
   start under its own metadata. */
.kb-lines { display: grid; gap: 3px; min-width: 0; }
.kb-metas { display: flex; flex-wrap: wrap; gap: 4px 12px; }
.kb-meta { font-size: 12.5px; color: var(--text-mute); overflow-wrap: anywhere; }
.plan-board .name { font-size: 14px; font-weight: 600; color: var(--text); overflow-wrap: anywhere; }

/* A block of the project's purpose. Prose, so it is spaced as prose and carries
   no rule of its own; .prewrap keeps the line breaks somebody typed. */
.kb-prose + .kb-prose { margin-top: 12px; }
.kb-prose p { margin: 0; }
.kb-prose p + p { margin-top: 6px; }

/* A MEETING ACTION, WEARING ITS STATE ON ITS LEFT EDGE. The chip beside it
   carries the word, so the colour is never the only signal; what the edge buys
   is a column of actions that can be scanned for the one that is blocked without
   reading any of them. The four edges are the tokens the matching .status chips
   are built from, so recolouring a chip and recolouring an edge stay one
   decision. */
.kb-action {
  display: flex; align-items: flex-start; gap: 12px;
  padding: 10px 12px;
  border: 1px solid var(--line-soft);
  border-left: 3px solid var(--line);
  border-radius: var(--radius-sm);
}
.kb-action > .kb-lines { flex: 1 1 auto; }
.kb-action > .status { flex: 0 0 auto; }
.kb-edge-open     { border-left-color: var(--line); }
.kb-edge-progress { border-left-color: var(--dufeu-blue); }
.kb-edge-done     { border-left-color: var(--dufeu-green); }
.kb-edge-blocked  { border-left-color: var(--danger); }

/* A QUESTION AND ITS ANSWER, as one pair rather than two rows: the answer is
   indented under the question by the rule it hangs off, which is what makes an
   unanswered one read as a question with nothing under it rather than as a row
   with an empty cell.

   AND THE PAIRS ARE NUMBERED. Neil, 2026-08-23: "Q&A - Need bullet points or
   numbers like 1. 2. 3. for the questions." An ORDERED LIST with the browser's
   own markers, so the number is the row's POSITION and nothing else: deleting
   question 2 renumbers the rest with no write, the number cannot be selected or
   copied as part of the question, and a screen reader announces the position
   from the list itself. Nothing reaches the database, and js/plan-view.js draws
   the same markup for the customer's copy.

   `.kb-qa-body` EXISTS BECAUSE A MARKER DOES. The pair was laid out by putting
   `display: grid` on the list item, and a list item laid out as a grid is no
   longer a list item, so it draws no marker at all. The grid moves inside and
   the item keeps its own display.

   THE MARKER COVERS THE PAIR, which is the point of it: it is drawn against the
   whole item, and the item is the question AND its answer, so `3.` names both
   rather than heading a column of numbers beside a column of answers. */
.kb-qas { list-style: decimal outside; margin: 0; padding-left: 26px; }
.kb-qas > li + li { margin-top: 10px; }
.kb-qas > li::marker { color: var(--text-mute); font-size: 13px; font-weight: 600; }
.kb-qa {
  padding-left: 12px;
  border-left: 2px solid var(--line-soft);
}
.kb-qa-body { display: grid; gap: 4px; }
.kb-q { font-size: 14px; font-weight: 600; color: var(--text); }
.kb-a { font-size: 13.5px; color: var(--text-dim); }

/* THE SAME NUMBERS WHILE THE QUESTIONS ARE BEING WRITTEN, so the card reads the
   same way in both modes. A counter rather than an `<ol>`, because this list is
   the row of boxes js/plan.js appends into (addBoardRow) and a list item is not
   what it appends. It renumbers itself on every add, so nothing is stored here
   either. */
.kb-rows-qa { counter-reset: kb-question; }
.kb-rows-qa > .kb-editrow { counter-increment: kb-question; position: relative; padding-left: 26px; }
.kb-rows-qa > .kb-editrow::before {
  content: counter(kb-question) ".";
  position: absolute; left: 0; top: 7px;
  color: var(--text-mute); font-size: 13px; font-weight: 600;
}

/* THE ANSWER BOX THAT LIVES IN THE READING VIEW, autosaved as somebody types
   (js/plan-kickoff.js answerBox, js/plan.js queueLive). The control inside it is
   the portal's own .field textarea, handed over by js/plan.js's one renderer, so
   nothing about the control itself is written twice.

   AND IT IS A LINE, NOT A BOX (2026-08-23). The holder carries `.kb-line` as
   well, which is where the whole of its shape now comes from: the rule under the
   words, the radius it gives back, the focus state, the 30px height and the grip
   it no longer has. Neil, of the 40px well this used to draw: "shorter in height,
   longer in length ... it's not a physical box", and then "remove the ability to
   make it bigger, won't be required."

   ONLY THE INK IS THIS CARD'S OWN. A .field textarea is 14px, which is a form;
   these sit under questions in a list that can run to a dozen, so the answer
   takes the answer's own size. One declaration, and it is the only thing about
   this box that `.kb-line` does not already say. */
.kb-answer .field textarea { font-size: 13.5px; }

/* A board row is a tab stop wherever the plan still takes writes, because that
   is how a keyboard reaches its menu (Shift+F10, the Menu key, or Enter). The
   ring is the one the rest of the portal draws.

   ONE PROMISE PER BOARD, and until 2026-08-14 there were two. The Meeting
   actions card is a real `.plan-table`, so its rows also picked up the plan
   editor's own row wash and `cursor: context-menu` from the block above, while
   the attendee, purpose and question rows beside them, which carry the same
   `data-row="task"` and open the same menu, got neither. Same board, same act,
   two different answers to the pointer. The plan editor's pair is suppressed
   INSIDE the board so every row there says the same thing.

   AND THAT SAME THING IS `NOTHING` AGAIN, 2026-08-23. Neil: "Don't want items
   going grey when hover over them with the cursor. Only hover that needs to be
   like URL links." For a few hours the board tinted a hovered row `--off-white`,
   which is what he is describing; it has gone, and so has the focus TINT that
   went with it. A row is not a link and does not answer the pointer.

   WHAT IS STILL SUPPRESSED HERE, and has to be, is the wash a row inherits from
   somewhere else. The two board cards that are real tables (`.kb-table`) pick up
   `.table tbody tr:hover td` and `cursor: context-menu` from blocks above, which
   the attendee row and the question beside them do not, so without these the
   board would be back to two different answers to the pointer. Suppressed inside
   the board rather than removed at source: `.table` serves every list in the
   portal and moving it is a bigger decision than this one.

   FOCUS KEEPS A REAL INDICATOR, because the row is a tab stop and that is how a
   keyboard reaches its menu. The ring alone, with nothing behind it: it is the
   same brand ring the grip, the phase name and every other control here draw,
   and on a board row it is an ordinary element that paints one properly. */
.plan-board [data-row="task"] { cursor: default; }
.plan-board .plan-table tr[data-row="task"] > td:first-child { cursor: default; }
.plan-board .table tbody tr:hover td,
.plan-board .plan-table tr[data-row="task"]:hover > td { background: none; }
/* One ring, not two. The board's rows draw their own (below), and a board table
   row would otherwise wear the plan editor's inset ring inside it. */
.plan-board .plan-table tr[data-row="task"]:focus-visible > td { box-shadow: none; }
.plan-board [data-row="task"]:focus-visible {
  outline: 2px solid var(--dufeu-blue); outline-offset: 2px; border-radius: var(--radius-sm);
}

/* ── One record, as a page ────────────────────────────────── */
/*
   portal/signoff-request.html and portal/survey-response.html. The records that
   used to open as a Modal over their list, with an Edit form and an Audit trail
   as two further Modals inside them (Neil, 2026-08-14: "Less boxes, cleaner
   feel", and then "Yes, same as sign-offs" for the survey half). Everything on
   both is the design system already: .page-head, .card, .grid.cols-3, .field,
   .form-row, .table. Two rules is the whole of what a page had to add, and the
   survey page shares them rather than restating them: the two surfaces are the
   same shape and a second copy would be a second thing to keep in step.

   THE BUTTON ROW OF AN IN-PAGE CARD FORM. UI-CONVENTIONS.md section 2: "In-page
   card forms keep their button row as the LAST row of the form: primary
   rightmost, Cancel to its left, full-size `btn` both." The Edit card is the
   first form drawn to that rule, so the alignment lives here rather than in the
   page. Scoped to this surface deliberately: the older in-page forms put their
   primary first and left-aligned, and quietly reversing all of them is a change
   in its own right, not a side effect of this one. */
.signoff-record .form-actions,
.survey-record .form-actions {
  display: flex; justify-content: flex-end; align-items: center;
  gap: 10px; margin-top: 16px;
}

/* THE TWO CARDS THE LIST CAN LINK STRAIGHT TO, #edit and #trail. Both are drawn
   after the page loads, so js/signoff-request.js and js/survey-response.js do
   the scroll the browser already tried and missed; this only keeps the card off
   the very top edge when it lands. */
.signoff-record #edit,
.signoff-record #trail,
.survey-record #edit,
.survey-record #trail { scroll-margin-top: 20px; }

/* ── The printed sheet ────────────────────────────────────── */
/*
   THIS IS THE LAST BLOCK IN THE FILE, AND THAT IS THE WHOLE POINT. A media
   query adds NO specificity, so a print rule only beats the screen rule it
   restates by coming after it. For most of this file's life the sheet sat in
   the middle, with 840 lines of ordinary screen CSS after it, and six of its
   rules were dead on arrival: the chip reset, the chip row's alignment, the
   kickoff rail, the checklist document head, and two checklist borders were all
   present, all readable, and all inert. Two of the five technician chips were
   printing white on white because of it (the browser drops the fill, the white
   ink stays, and the reset that would have put the ink back never applied).

   So: NOTHING GOES BELOW THIS BLOCK. New screen rules go above it. That makes
   the whole class of bug impossible rather than fixing the six instances, and
   test/printSheet.test.js parses the sheet and fails if any selector in it is
   re-declared afterwards.

   THE CONTRACT, so a page other than /plan-view.html prints through this sheet
   without needing rules of its own:
     - put every control (toolbars, buttons, filters, the row of actions) inside
       something carrying `.no-print`;
     - draw the plan as a `.table` with a real <thead>, so the column headings
       repeat on every sheet;
     - group each phase and its tasks in their own <tbody>, so the two are asked
       to stay together;
     - if the page has no logo on screen, put one in a `.print-only` element at
       the top of <body>.

   @page IS DOCUMENT-WIDE AND CANNOT BE SCOPED TO A CLASS. The landscape
   declaration below therefore applies to ANY portal page somebody prints, and
   that is a stated choice rather than an oversight: the portal is a table-heavy
   application whose only other printing rule is the one hiding the sidebar, so
   the pages that are not plans are better off landscape too.

   THE ONE EXCEPTION IS THE CHECKLIST, and it arrived by the upgrade this note
   always pointed at: a NAMED page. A checklist is one narrow column of tick
   boxes, its rendered PDF is A4 portrait (server/services/checklistPdf.js is
   210mm by 297mm), and a sheet that came off Ctrl+P landscape and the same
   sheet that came off Export portrait are two different documents. The named
   page below is scoped by `html:has(#cl-main)`, so it reaches nothing but
   portal/checklist.html. No @supports wrapper, because both halves fail safe on
   their own: a browser that does not know named pages drops the `@page
   clPortrait` rule, a browser that does not know `page:` or `:has()` drops the
   selector, and either way the page is landscape, which is exactly where this
   started.
*/
@media print {
  /* Landscape, because a plan is a wide table: the spreadsheets these documents
     replace were landscape for the same reason. The bottom margin leaves room
     for the page number the browser prints into it. */
  /* ZERO MARGIN, AND THE MARGIN MOVED ONTO THE PAGE ITSELF.
     This is what stops the browser printing the URL, the page title, the date
     and "1/2" over the customer's document. Those are the browser's own
     header and footer: they are drawn INSIDE the @page margin, they are on by
     default, and no stylesheet can switch them off. What a stylesheet CAN do is
     leave them nowhere to go, and Chrome (and Edge, and Safari) omit them when
     the page margin is none.

     The real margin is then padding on <body>, which the content honours and the
     browser chrome cannot use. The measurements are the ones that were in @page,
     so the printed sheet is unchanged apart from what has gone from it.

     NOT A COMPLETE FIX, AND DELIBERATELY THE CHEAP ONE. It depends on browser
     behaviour rather than a standard, and somebody who has explicitly ticked
     "Headers and footers" in the print dialogue will still get them; Firefox
     honours the setting over the margin. That is one of the reasons Export
     stopped being the print dialogue: a document drawn by
     server/services/planPdf.js has no browser chrome to keep out in the first
     place. Anyone who reaches this sheet reached it through Ctrl+P.

     A4 LANDSCAPE, AND PAGINATED. A long plan breaks onto further sheets inside
     the padded content area (the sheet is 210mm tall, the body keeps 12mm top
     and 14mm bottom, and break-inside moves a whole row rather than cutting
     through one), which is exactly what the rendered PDF avoids: it draws the
     document as one page as tall as the plan, breaking onto further sheets
     with the column headings repeated only past the PDF page-size ceiling. The width is the same 297mm in
     both, and the renderer states it in millimetres because there is no reading
     "A4 landscape" back out of CSSOM. test/planExport.test.js compares them. */
  @page {
    size: A4 landscape;
    margin: 0;
  }
  /* The checklist, portrait, matching its own rendered PDF. Named on <html> so
     the name propagates to every box on the page (`page` is `auto` everywhere
     else, which means "take the parent's"), rather than on #cl-main, where the
     unnamed body box ahead of it would open the document landscape and force a
     break at the boundary. The margin stays the general rule's zero. */
  @page clPortrait {
    size: A4 portrait;
  }
  html:has(#cl-main) {
    /* THE CASE IS LOAD-BEARING. A page name is a custom identifier, not a
       keyword, so it is case-SENSITIVE: lower-cased to `clportrait` this stops
       matching the `@page clPortrait` above and the checklist silently prints
       landscape again. Stylelint's value-keyword-case does not know the
       difference and offers exactly that as a fix, so it is turned off for
       this one declaration rather than obeyed. (`size: A4` above is reported
       too and is left alone: page-size keywords ARE case-insensitive, so
       nothing there is at risk, and the literal `A4 landscape` is what
       test/planEditor.test.js and test/planExport.test.js read back out of
       this file.) */
    /* stylelint-disable-next-line value-keyword-case */
    page: clPortrait;
  }
  body {
    padding: 12mm 10mm 14mm;
  }

  /* 100vh IS THE PAGE, ONCE THERE IS PAPER. .shell asks for a viewport-tall
     column so the sidebar reaches the bottom of a short screen; in paged media
     that resolves against the page box, so the shell alone fills the sheet and
     the 26mm of padding above and below it then spills onto a second one. A
     one-line plan was printing on two pages before this. */
  .shell { min-height: 0; }

  html, body {
    background: #FFFFFF;
    /* Points, not pixels, for paper. 9pt keeps a nine-column plan on one sheet
       across without the browser shrinking it to fit. */
    font-size: 9pt;
  }

  .print-only { display: block; }

  /* THE DUFEU MARK, TOP LEFT, which is where the original spreadsheets put it.

     IN THE FLOW, NOT A RUNNING HEADER, and that is a decision made against a
     rendered PDF rather than a preference. A repeating header needs
     `position: fixed`, whose behaviour in paged media is not something to build
     a customer-facing document on: Chrome lays a fixed element out against the
     page's CONTENT box, so `top: 0` draws it over the first line of the plan,
     and pulling it up into the margin with a negative offset put it at the FOOT
     of every sheet instead. The CSS that would do this properly is the @page
     margin boxes (`@page { @top-left { ... } }`), which no shipping browser
     implements. So the mark is printed once, at the top of the first sheet,
     where a document's letterhead belongs and where nothing can move it.

     Clearspace stays em-based against the printed logo height, so it scales with
     it: 0.5em is comfortably past the 0.4em height of the lowercase 'd'. */
  .planv-brand,
  .print-only.planv-brand {
    margin: 0 0 0.5em;
    padding: 0;
    font-size: 12mm;      /* == the printed logo height */
    max-width: none;
  }
  .planv-brand img {
    height: 1em;
    /* The mark is full colour and must stay so on paper: browsers strip
       backgrounds and colours from print by default to save ink. */
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
  }

  /* THE DOCUMENT'S OWN HEADING, on paper only: the customer's company over the
     project (or pursuit) the plan is for. The plan's NAME is deliberately not
     on the printed sheet (Neil's rule for the exported document, 2026-08), so
     the two on-screen titles and their subtitle lines are lifted off it here,
     by id, rather than hiding .page-head for every page that prints. The
     heading itself matches the rendered PDF's (headerLines in
     server/services/planPdf.js): the h1 treatment for the company, the muted
     13px line for the project, and the same 4mm below the heading that
     .page-head keeps. A plan with no project line prints one line, never
     a blank second one. */
  /* #sa-name and #sa-parent are the staff Data Migration Plan's own head
     (portal/archive.html), and they come off the sheet for the same reason
     #plan-name does: the item's name is our filing, and the printed sheet is
     headed by the document type and the project, which the .planv-doc-head
     block above it carries. */
  #plan-name, #plan-sub, #pv-name, #pv-for, #sa-name, #sa-parent { display: none; }
  .planv-doc-head { margin: 0 0 4mm; }
  .planv-doc-head h1 { margin: 0; }
  .planv-doc-head p { margin: 4px 0 0; font-size: 13px; }
  .planv-doc-head p:empty { display: none; }
  /* The on-screen head that follows is now empty on paper (or, on an APPROVED
     customer sheet, one version line), so it gives up its own 4mm rather than
     doubling the gap the heading above already keeps. */
  .planv-doc-head + .page-head { margin-bottom: 0; }

  .planv-main, .planv-foot { max-width: none; margin: 0; padding: 0; }

  /* A document, not a set of cards. The borders and shadows are screen
     furniture and become clutter the moment they are printed. */
  .card, .planv-plan, .planv-key {
    border: 0;
    border-radius: 0;
    box-shadow: none;
    background: transparent;
    margin: 0 0 6mm;
  }
  .card + .card { margin-top: 0; }
  .card-head { padding: 0 0 2mm; border-bottom: 0; }
  .card-body { padding: 0; }
  /* On screen a wide table scrolls inside its own box. On paper there is nothing
     to scroll, and a clipped table would silently lose columns. */
  .table-scroll { overflow: visible; }

  /* THE KICKOFF PLAN'S BOARD, ON PAPER. The `position: static` reset that used
     to be here has gone with the rail it was resetting (2026-08-23): the facts
     were sticky on screen, and left sticky on paper some engines repeat them on
     every sheet, which printed the attendees three times. There is no sticky
     anything on this board now, so there is nothing to undo, and a rule for a
     class nothing draws is worse than no rule. The arrangement still prints as
     the document: facts across the top, cards under them, exactly as on screen. */
  .plan-board .card, .kb-cards .card { break-inside: avoid; page-break-inside: avoid; }

  .page-head { margin-bottom: 4mm; }

  /* THE COLUMN HEADINGS REPEAT ON EVERY SHEET. `table-header-group` is the
     default for <thead>, and it is written out because it is load-bearing here:
     a plan that runs to three pages must not have two pages of unlabelled
     numbers. */
  .table { font-size: 9pt; }
  .table thead { display: table-header-group; }
  /* tfoot defaults to repeating too, which would print "Total hours" at the
     bottom of every sheet. The totals belong once, at the end. */
  .table tfoot { display: table-row-group; }
  .table th { padding: 2mm 3mm; color: var(--text); border-bottom: 0.4mm solid var(--line); }
  .table td { padding: 2mm 3mm; border-bottom: 0.2mm solid var(--line-soft); }
  /* A row's own state is not part of the document. Whatever the pointer happens
     to be over, and whatever the keyboard happens to be on, prints as an
     ordinary row. */
  .table tbody tr:hover td { background: transparent; }
  /* THE ROW WASH ITSELF HAS GONE FROM THE SCREEN (2026-08-23, Neil: "Don't want
     items going grey when hover over them with the cursor"), so the two rules
     that used to undo it here have gone with it rather than being left behind
     describing something the stylesheet no longer does. `.table` above still
     draws one for every other list in the portal, which is why that one stays.
     What is left to undo is FOCUS, which is still indicated on screen and is
     still not part of the document: whatever the keyboard happens to be on
     prints as an ordinary row. */
  .plan-table tr[data-row="task"]:focus-visible { outline: 0; }
  .plan-table tr[data-row="task"]:focus-visible > td { box-shadow: none; }
  .plan-board [data-row="task"]:focus-visible { outline: 0; }

  /* A PHASE AND ITS TASKS STAY TOGETHER WHERE THEY CAN. `avoid` is a request,
     not a guarantee: a phase longer than a sheet has to break somewhere, and the
     browser then breaks it rather than leaving a page blank. `break-after:
     avoid` on the band is the half that matters most, because a phase heading
     alone at the foot of a page is the worst of the two outcomes. */
  .planv-group, .table tbody { break-inside: avoid; page-break-inside: avoid; }
  .table tr { break-inside: avoid; page-break-inside: avoid; }
  .planv-phase { break-after: avoid; page-break-after: avoid; }
  /* The staff plan still reaches this sheet through Ctrl+P (Export is the
     server-rendered PDF now), so its band has to ask for ink too.
     Browsers strip backgrounds from print by default, and a
     phase division that vanishes on paper is exactly the thing the colour was
     added to fix. */
  .plan-table .plan-phase th,
  .plan-table .plan-phase td {
    background: rgba(108, 172, 228, 0.28);
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
  }
  .planv-phase th {
    background: rgba(108, 172, 228, 0.28);
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
  }

  /* The impact chips carry meaning in their colour, so they are the one thing on
     the sheet that asks for ink. The border survives on its own if a printer or
     a policy refuses the fill. */
  .planv-impact,
  .planv-location,
  .planv-progress {
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
  }

  /* AND SO DOES A ROW SOMEBODY MARKED, on the staff sheet Ctrl+P produces. A
     green line that prints white is the failure mode the chip beside the task's
     name is there to cover, so the paper is not WRONG without this, only poorer:
     the word survives either way. Asking for the ink means screen and sheet say
     the same thing rather than two things.
     The chip itself is a .status pill and asks for its own ink beside the
     others, for the same reason the impact chips do. */
  .plan-table tr.task-done,
  .plan-table tr.task-doing,
  .tprog {
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
  }

  /* AND ON THE CUSTOMER'S SHEET, which is the one Ctrl+P produces from a shared
     link. Its own selector rather than a fourth line above, for the reason given
     beside the screen rules: the staff pair is pinned character for character by
     test/planEditor.test.js. The chip is a .tprog and has asked for its ink
     already. */
  .planv-table tr.task-done,
  .planv-table tr.task-doing {
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
  }

  /* The key belongs with the plan it explains, and it is short enough to always
     fit on one sheet. */
  .planv-key { break-inside: avoid; page-break-inside: avoid; }

  /* NOTHING THAT IS A CONTROL REACHES THE PAPER. This block used to BE the
     export, so every button on the page was landing
     in the customer's PDF: Edit on every row, Add task, Add phase, the grip.
     Each of them carries .no-print, which the rule at the top of this file
     already hides, and this is the belt to that braces: an actions cell added
     later without the class still does not print, because a plan is a document
     and a document has no buttons on it.

     display: none on a <td> and its <th> takes the whole column out, which is
     what is wanted: the remaining columns spread across the sheet rather than
     leaving a blank strip where the controls were. */
  .plan-table td.actions,
  .plan-table th:last-child:has(.sr-only),
  .grip,
  .menu-wrap,
  .backlink { display: none !important; }

  /* The key is above the plan now, so it prints there. It is short, so it stays
     with the first sheet rather than being orphaned. */
  .impact-key {
    break-inside: avoid; page-break-inside: avoid;
    border: 0.2mm solid var(--line);
    background: transparent;
    padding: 2mm 3mm;
    margin: 0 0 4mm;
    font-size: 8pt;
  }
  /* The chips carry meaning in their colour, so they ask for ink. Browsers strip
     backgrounds from print by default.

     THE KEY AND THE ROWS IT EXPLAINS, not just the key. This used to ink
     `.impact-key .status` alone, which printed a coloured key above a table
     whose impact cells were all the same white: the legend explained a coding
     the document did not use. Targeted by the impact classes rather than by
     where they sit, so a chip added somewhere new is coloured without anybody
     remembering to add a selector. */
  .impact-none,
  .impact-low,
  .impact-medium,
  .impact-high {
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
  }

  .planv-foot { margin-top: 4mm; font-size: 8pt; }
  /* THE EXPIRY IS NOT RULED ON HERE ANY MORE, and its absence is the point: the
     line carries `.no-print` in the markup on both customer surfaces, which the
     rule at the top of this block already hides. It was an id rule, which
     covered the plan page alone and would have needed a second one the day
     another surface grew the same line. The reason it does not print is
     unchanged: the expiry is a fact about the LINK, and the paper is not the
     link. */

  /* ── Plan versions: which document this is ───────────────── */
  /*
     WHICH VERSION SOMEBODY IS HOLDING, on the paper itself. The sheet above
     deliberately leaves the browser's own header and footer nowhere to go, so a
     printed plan carries no date and no title of its own. That is right for the
     customer's document and wrong for its provenance, so the version number and
     the export date are written INTO the document, at the foot, where a
     document's reference line belongs. Both are .print-only: on screen the page
     already says the same thing.

     Two hosts, one rule: #plan-stamp is the staff plan page's element
     (portal/plan.html, filled in by the export call in js/plan.js) and
     .planv-stamp is the customer sheet's (portal/plan-view.html), so
     the reference line reads the same whichever sheet it came off. Nothing new
     enters the design system: 8pt and --line are the printed sheet's own.
  */
  .planv-stamp,
  #plan-stamp {
    margin: 4mm 0 0;
    padding-top: 2mm;
    border-top: 0.2mm solid var(--line);
    font-size: 8pt;
    color: var(--text-mute);
  }

  /* A TICK HAS TO SURVIVE THE PRINTER, and by default it does not: browsers
     strip backgrounds and accent colours from print to save ink, which turns a
     ticked box into an empty one. An empty box on a checklist somebody has
     worked through is not a smaller document, it is a WRONG one, so this is the
     third thing in this file allowed to ask for ink (the brand mark and the
     impact chips are the other two).

     Both boxes, because the two modes draw the row differently and a sheet
     printed from the Build view has to say the same thing as one printed from
     the Export view. */
  .check input[type="checkbox"],
  .cl-items input[type="checkbox"] {
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
  }

  /* DISABLED IS A PERMISSION, AND PAPER HAS NO PERMISSIONS. A checklist opens
     read-only, so js/checklist.js draws every box `disabled`, and the rule up
     the file greys the whole row for it. That is right on screen, where grey
     says "not yours to tick", and wrong on paper, where it says nothing and
     prints an entire sheet in --text-mute. Full ink, and the same sheet
     whichever view it was printed from. */
  .check:has(input:disabled) {
    color: var(--text);
  }

  /* HOW FAR THROUGH IT IS DOES NOT GO ON THE PAPER. js/checklist.js states the
     rule and applied it to the rendered PDF only: a count either repeats what
     the boxes below already say, or contradicts them once the sheet is finished
     with a pen, and it is a number that can go stale on a document that cannot
     be updated. Ctrl+P now says the same as Export. The row is a `.cl-fact`
     with its own "Progress" label, so the label goes with it rather than
     standing over an empty cell; without :has() the label survives on its own,
     which is the smaller of the two wrongs. */
  .cl-progress { display: none; }
  .cl-fact:has(.cl-progress) { display: none; }

  /* THE NOTES PRINT WHOLE, HOWEVER SMALL THE BOX WAS DRAGGED. Now that the
     notes box can be resized again, the height somebody dragged it to is an
     inline style, and an inline style is the only thing in this file worth an
     !important: a box scrolled on screen would otherwise print its first eight
     lines and silently drop the rest. A scrollbar is a screen affordance; paper
     has no scroll, so it gets the whole note. */
  .cl-notes {
    height: auto !important;
    max-height: none !important;
    overflow: visible;
  }

  /* The document's head prints whichever view somebody pressed Ctrl+P in, and
     the on-screen title and its number come off, exactly as the plan sheet lifts
     #plan-name and #plan-sub off. Otherwise the name would be on the paper
     twice. */
  .cl-doc-head { display: block; margin: 0 0 4mm; }
  .cl-doc-head h1 { margin: 0; }
  #cl-name, #cl-sub { display: none; }

  /* AND THE PAGE HEAD ITSELF COMES OFF, now that it sits ABOVE the document
     head rather than below it (2026-08-21, so the back link is top left like
     every other detail page). Every child of it was already print-hidden: the
     back link by the rule a few hundred lines up, the title and its number by
     the rule directly above, the stamp and the actions by `.no-print`. What was
     left was an empty flex row still spending the 4mm bottom margin
     `.page-head` keeps on paper, which would have opened the sheet on a blank
     strip above the letterhead. Scoped by id rather than hiding `.page-head`
     for every page that prints, which is the same reason the plan sheet gives
     for lifting #plan-name off by name. */
  #cl-main > .page-head { display: none; }

  /* Millimetres for paper, matching the rest of the print sheet. */
  .cl-fact { padding: 2mm 0; border-bottom: 0.2mm solid var(--line-soft); }
  .cl-items > li + li { border-top: 0.2mm solid var(--line-soft); }
  /* A row on paper has nothing to press, so it gives back the height it was
     keeping for a finger. */
  .cl-items .check { min-height: 0; }
  /* The chips print as the words they are: a frame around a name is screen
     furniture and becomes clutter on paper. */
  .chips { justify-content: flex-start; }
  /* THE COLOURS COME OFF ON PAPER. `.chip` alone is enough now: the five
     variants are the same one-class specificity as this and the sheet is the
     last block in the file, so it wins on order. It did not, when the sheet sat
     above them, and this rule had to name .chip-c0 to .chip-c4 to have any
     effect at all. Naming them was the compensation, not the fix, and it is
     gone with the reason for it.

     The ink is set back to the body colour along with the fill, because
     white-on-teal text becomes white-on-nothing the moment a browser drops the
     background, which is what browsers do to print by default. Two of the five
     technician chips were printing invisible for exactly that reason. */
  .chip {
    background: transparent;
    border: 0;
    padding: 0 8px 0 0;
    color: var(--text);
  }
}
