/* ec-colour-tokens:begin */
/* CHROME COLOUR TOKENS (Task 714, Phase 1; dev/theming-plan.md). Every colour of the application's own
   chrome is declared here and used as var(--ec-...) everywhere else. Values are exactly the literals the
   stylesheet carried before, so nothing changed on screen. A later dark set overrides these names and
   nothing else. Map symbology and paper are NOT chrome and stay literal: dev/scripts/chrome_colour_allow.json.
   dev/scripts/chrome_colour_check.php refuses a colour literal outside this block. */
:root {
	/* Surfaces and text */
	--ec-bg: #fff;
	--ec-ink: #333;
	--ec-ink-strong: #222;
	--ec-ink-muted: #555;
	--ec-ink-subtle: #666;
	--ec-ink-black: #000;
	/* Rules and borders */
	--ec-border: #ddd;
	--ec-border-strong: #ccc;
	/* Accent: links, outlined buttons, menu bar */
	--ec-accent: #0645ad;
	--ec-accent-hover: #1f58b5;
	--ec-accent-open: #05378a;
	--ec-accent-hover-bg: #eaf1fc;
	--ec-accent-open-bg: #d7e6fb;
	/* Brand blue: consent buttons, nav */
	--ec-brand: #1a6faf;
	--ec-brand-hover: #14588c;
	/* Notice and status */
	--ec-warn-bg: #fffbe6;
	--ec-warn-border: #a80;
	--ec-warn-ink: #c60;
	--ec-ok-ink: #267326;
	--ec-bad-ink: #c00;
	--ec-error-ink: #a00;
	--ec-error-bg: #fdecea;
	--ec-error-bg-soft: #fff0f0;
	--ec-neutral-ink: gray;
	/* Interaction: toolbar hover and pressed, table selection */
	--ec-hover-bg: #def;
	--ec-hover-border: #9bd;
	--ec-pressed-bg: #cfe3f7;
	--ec-pressed-border: #6aa6d8;
	--ec-select: #0b57d0;
	--ec-select-bg: #e8f0fe;
	--ec-select-bg-strong: #d3e3fd;
	/* Calculator forms and the debug grid */
	--ec-form-bg: #f7f9ff;
	--ec-form-border: #66c;
	--ec-debug-grid: blue;
	/* Neutral greys, named by value: one role each is a Phase 2 job */
	--ec-gray-444: #444;
	--ec-gray-767676: #767676;
	--ec-gray-777: #777;
	--ec-gray-888: #888;
	--ec-gray-999: #999;
	--ec-gray-aaa: #aaa;
	--ec-gray-bbb: #bbb;
	--ec-gray-d6d6d6: #d6d6d6;
	--ec-gray-dcdcdc: #dcdcdc;
	--ec-gray-e0e0e0: #e0e0e0;
	--ec-gray-e6e6e6: #e6e6e6;
	--ec-gray-e8e8e8: #e8e8e8;
	--ec-gray-eaeaea: #eaeaea;
	--ec-gray-ececec: #ececec;
	--ec-gray-eee: #eee;
	--ec-gray-f0f0f0: #f0f0f0;
	--ec-gray-f2f2f2: #f2f2f2;
	--ec-gray-f6f6f6: #f6f6f6;
	--ec-gray-f7f7f7: #f7f7f7;
	--ec-gray-fafafa: #fafafa;
	/* Other hues, named by value: role not yet assigned */
	--ec-hue-037: #037;
	--ec-hue-05a: #05a;
	--ec-hue-0b0080: #0b0080;
	--ec-hue-10304f: #10304f;
	--ec-hue-12211f: #12211f;
	--ec-hue-1565c0: #1565c0;
	--ec-hue-17406b: #17406b;
	--ec-hue-1976d2: #1976d2;
	--ec-hue-2a6ebb: #2a6ebb;
	--ec-hue-3c4043: #3c4043;
	--ec-hue-5a6570: #5a6570;
	--ec-hue-5f6368: #5f6368;
	--ec-hue-667: #667;
	--ec-hue-8ab: #8ab;
	--ec-hue-b00020: #b00020;
	--ec-hue-c0392b: #c0392b;
	--ec-hue-d99: #d99;
	--ec-hue-e8a33d: #e8a33d;
	--ec-hue-eae7e0: #eae7e0;
	--ec-hue-eef1f4: #eef1f4;
	--ec-hue-eef3fd: #eef3fd;
	--ec-hue-eef4ff: #eef4ff;
	--ec-hue-f4d4d4: #f4d4d4;
	--ec-hue-fdd: #fdd;
	--ec-hue-fff3cd: #fff3cd;
	--ec-hue-fff4e5: #fff4e5;
	/* Translucent colours (shadows, scrims, tints), named by RGB and alpha */
	--ec-a-0-0-0-12: rgba(0, 0, 0, .12);
	--ec-a-0-0-0-18: rgba(0, 0, 0, .18);
	--ec-a-0-0-0-2: rgba(0, 0, 0, .2);
	--ec-a-0-0-0-25: rgba(0, 0, 0, .25);
	--ec-a-0-0-0-3: rgba(0, 0, 0, .3);
	--ec-a-0-0-0-35: rgba(0, 0, 0, .35);
	--ec-a-0-0-0-4: rgba(0, 0, 0, .4);
	--ec-a-6-69-173-12: rgba(6, 69, 173, .12);
	--ec-a-6-69-173-35: rgba(6, 69, 173, .35);
	--ec-a-60-64-67-14: rgba(60, 64, 67, .14);
	--ec-a-60-64-67-5: rgba(60, 64, 67, .5);
	--ec-a-128-128-128-4: rgba(128, 128, 128, .4);
	--ec-a-128-128-128-45: rgba(128, 128, 128, .45);
	--ec-map-overlay-bg: rgba(255, 255, 255, .8);
	--ec-a-255-255-255-85: rgba(255, 255, 255, .85);
	--ec-a-255-255-255-9: rgba(255, 255, 255, .9);
	--ec-a-255-255-255-94: rgba(255, 255, 255, .94);
}
/* ec-colour-tokens:end */

/* ---------------------------------------------------------------------------------------------
   SELF-SUFFICIENCY BLOCK -- rules this suite needs that used to live only in the PARENT SITE.
   Added 2026-08-14 after dev.hawsedc.com's first deploy came up with no blue form backgrounds and
   no table borders.

   WHAT HAPPENED. Every page loads three stylesheets: bootstrap, this file, and `/hawsedc.css` --
   and that third one sits at the SITE ROOT and is NOT tracked by the engcalcs repository. It
   belongs to hawsedc.com. Deploy engcalcs on its own and it simply is not there, so `form`
   backgrounds and table borders vanish while everything else looks fine. 464 bytes, outside the
   repo, invisible to every check we have.

   WHY IT MATTERS BEYOND ONE DEV BOX: LibreEPANET.org (ROADMAP Task 306) is BY DEFINITION a
   standalone deploy of this suite on another domain. It would have walked into exactly this, and
   the symptom -- most of the page fine, some styling missing -- is the kind that gets blamed on a
   cache for a day before anyone looks at a 404.

   DUPLICATION IS DELIBERATE AND SAFE. On hawsedc.com `/hawsedc.css` still loads LAST, so its
   identical declarations win and nothing changes there. On a standalone deploy these are the only
   copy. Kept at the TOP of this file so every engcalcs rule below can still override them, which is
   how they behaved when they were in a stylesheet loaded before... nothing. Keep the VALUES in step
   with `/hawsedc.css` if that file is ever edited.
   --------------------------------------------------------------------------------------------- */
body { font-family: sans-serif; }
form {
	background: var(--ec-form-bg);
	border: 1px solid var(--ec-form-border);
	margin-bottom: 1px;
}
.left { float: left; margin: 0.5em; }
table, th, tr, td {
	border: 1px solid var(--ec-debug-grid);
	border-collapse: collapse;
	padding: 0px 2px 0px 2px;
}
/* "bare" tables and their immediate children show no border -- the layout tables the calculator
   forms are built from. Without this every calculator is a grid of blue boxes. */
table.bare, table.bare > tbody > tr, table.bare > tbody > tr > td { border-width: 0px; }

/* **INPUTS AND RESULTS START AT THE SAME TOP EDGE** (Tom, 2026-08-24: on a narrow screen they are
   "vertically aligned middle, and this is not good UX... both are initially visible from the top of
   the page"). A <td> is middle-aligned by the UA stylesheet, so the SHORTER of the two columns
   floats down the taller one -- and the narrower the window, the taller the Inputs column grows and
   the further Results sinks below the fold.

   Unconditional rather than behind the 640px breakpoint on purpose: the two columns are rarely the
   same height at ANY width, and top is the right answer whenever they differ. One rule beats a rule
   plus an exception. Scoped to `table.bare`'s own cells so it cannot reach a results table nested
   inside one. */
table.bare > tbody > tr > td { vertical-align: top; }

/* The calculator's input lines (ROADMAP Task 478, echoInputGrid() in lib/Calculators.lib.php).
   The DOM is COLUMN-MAJOR -- every input, then every label, then every unit select, then every X
   -- so Tab walks the input column instead of crossing each line sideways. Each cell carries its
   own `grid-row:N`, which puts it back on the line the user sees, so this must reproduce what the
   <table> above draws, to the pixel -- and does, wherever the form fits the window. Where it does
   not fit, one thing differs and cannot be made not to; echoInputGrid()'s docblock says which:

     - `auto` tracks, because an auto track sizes to max-content and shrinks toward min-content
       when the container is narrow, which is what a table's auto layout does. `max-content` would
       stop the labels wrapping on a narrow screen and turn the wrap into a horizontal scroll.
     - `justify-content: start`, or auto tracks would STRETCH to fill the cell and push the X
       column to the far right; a table shrink-wraps.
     - THE CELL STRETCHES AND ITS CONTENT CENTRES, which is why there is an inner `.ec-fg-in`
       and not just `align-items: center` on the grid. A <td> is middle-aligned (UA stylesheets
       put `vertical-align: middle` on the row group and a cell inherits it), but it also fills
       the row's height, and the bottom border is drawn on the FULL cell. Centring the grid items
       themselves would shrink every short cell to its own content, and on a line whose label
       runs to two -- Manning Trap's flow depth, with the solver control under it -- the blue
       rule would break into four pieces at four different heights. So the cell keeps the
       default `stretch`, draws the rule at the row's true bottom, and a flex centre inside it
       puts the content back where a table put it.
     - 1px blue rules, collapsed: the container draws the top and leading edges, every cell draws
       its own trailing and bottom edge, so no two cells ever double one line. `.ec-fg-input`
       draws NO trailing edge -- the input and its unit select shared one <td> before this and
       must not gain a rule between them.
     - LOGICAL properties throughout (block-start/inline-start), so ar/he/fa/ur/ps get the mirror
       image without a second rule. `grid-column: 1` is already the leading column in RTL.

   A hidden line is `.collapse` without `.show` on all four of its cells at once (the X is a
   multi-target collapse), which removes them from the grid entirely, so the row they shared
   collapses to zero height with no gap -- what `display: none` on a <tr> did. */
.ec-fieldgrid {
	display: grid;
	grid-template-columns: auto auto auto auto;
	justify-content: start;
	border-block-start: 1px solid var(--ec-debug-grid);
	border-inline-start: 1px solid var(--ec-debug-grid);
}
/* ONE CLASS, NO DESCENDANT COMBINATOR, and that is load-bearing: Bootstrap hides a collapsed line
   with `.collapse:not(.show) { display: none }`, specificity (0,2,0). A `.ec-fieldgrid > .ec-fg-cell`
   selector ties that and would win on source order, leaving every "hidden" line on screen. At
   (0,1,0) Bootstrap wins whatever order the two stylesheets load in. */
.ec-fg-cell {
	display: flex;
	align-items: center;
}
/* Written by echoLineMirrorScript() onto the other three cells of a line whose input cell a page's
   own script has hidden by id. AFTER .ec-fg-cell on purpose -- the two tie on specificity, so
   source order is what decides, and an inline style (which is what "View printable" writes) still
   outranks both. */
.ec-line-off { display: none; }
.ec-fieldgrid > .ec-fg-cell {
	padding: 0px 2px 0px 2px;
	border-block-end: 1px solid var(--ec-debug-grid);
	border-inline-end: 1px solid var(--ec-debug-grid);
}
.ec-fieldgrid > .ec-fg-cell > .ec-fg-in { min-width: 0; }
.ec-fieldgrid > .ec-fg-label { grid-column: 1; }
/* NO RULE AND NO PADDING AT THIS ONE SEAM. The input and its unit select shared a single <td>
   before Task 478, separated by one space character, so the column boundary between them must
   draw nothing and add nothing: echoInputGrid() puts a real no-break space at the end of the
   input cell instead, which is the same glyph advance in any font and any language. 2px + 2px of
   padding was the obvious substitute and it is measurably wrong -- it narrowed the whole form by
   1.1px and moved every unit select and every result on the page with it
   (dev/browser-pass/fieldgrid-layout.js measured it against the <table> version). */
.ec-fieldgrid > .ec-fg-input { grid-column: 2; border-inline-end: 0; padding-inline-end: 0; }
.ec-fieldgrid > .ec-fg-units { grid-column: 3; padding-inline-start: 0; }
.ec-fieldgrid > .ec-fg-x     { grid-column: 4; }

.input {
	width: 7em;
}
/* Wave 0 narrow-column mechanism (validated 2026-07-06): holds a results-table
   header as narrow as the old hard <br /> breaks did, so breaks can be removed
   from lang strings for clean translation. Wrap a header label in this span and
   the column stays fixed-width while long/other-language text wraps. Class-scoped
   so it never touches ip_/wi_ which share #CalcsTable. Default is deliberately
   NARROW for no-drop-down columns (nothing sets a width floor there, so keep them
   tight). Mid-word wrapping across the 26 languages is expected and accepted — do
   NOT widen to make English wrap prettily (that only helps English). Override per
   column with an inline width where a unit-select sets a wider floor. */
.ec-narrowcol {
	display: inline-block;
	width: 3.6em;
	overflow-wrap: break-word;
	white-space: normal;
	vertical-align: top;
}
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
	-webkit-appearance: none;
	margin: 0;
}

/* Firefox */
input[type=number] {
	-moz-appearance: textfield;
}

/* Opt back IN to the browser's native spinner arrows, per input (Tom, 2026-07-30, on the map-label
   decimal-places field). The two rules above strip the arrows from every number input in the suite
   -- right for a physical quantity, where the arrows are useless (a 1-unit step means nothing to a
   diameter or a flow) and only steal width from the number. It is wrong for a SMALL BOUNDED INTEGER
   like "decimal places, 0-4", where clicking up/down is the natural gesture and the arrows are what
   tell the user at a glance that this field is not a free-form number. Add .ec-spin to such a field.
   opacity:1 is not redundant: Chrome/Safari hide the spinner until the pointer is over the input, so
   without it the control is invisible exactly when the user is looking for it. */
input.ec-spin::-webkit-outer-spin-button,
input.ec-spin::-webkit-inner-spin-button {
	-webkit-appearance: auto;
	appearance: auto;
	opacity: 1;
	margin: 0;
}
input.ec-spin[type=number] {
	-moz-appearance: auto;
	appearance: auto;
}

.blink {
  animation: blinker 1s step-start 15;
}

@keyframes blinker {
  50% {
    opacity: 0;
  }
}

/* Validity/status check results (velocity checks, regime checks, DU quality,
   D50 range checks, head-loss checks, conveyance efficiency, etc.) */
.ec-status-ok {
	color: var(--ec-ok-ink);
}
.ec-status-info {
	color: steelblue;
}
.ec-status-warn {
	color: var(--ec-warn-ink);
}
.ec-status-bad {
	color: var(--ec-bad-ink);
}
.ec-status-neutral {
	color: var(--ec-neutral-ink);
}

/* Branched-Network per-row topology verdict. It sits in the ID or Upstream cell of the
   line that is actually wrong, under the input rather than beside it, so a narrow column
   does not have to grow to hold it. Replaced a page-level banner above the table (Tom,
   2026-09-06: "out of place and confusing"). */
.bpn-rowflag {
	display: block;
	color: var(--ec-warn-ink);
	font-size: 11px;
	white-space: nowrap;
}

/* THE SCALE BAR. The element's WIDTH is the measurement, set by refreshScaleBar(), and the bar is
   this rule's bottom border with two end ticks -- so there is one length on the screen and it is
   the one the number labels. Drawing a separate <svg> bar beside a sized box would be two lengths
   that have to agree. Ticks are box-shadow rather than pseudo-elements so the element stays a
   plain text node for anything measuring the strip's height. */
.lpn-scalebar {
	background: rgba(255, 255, 255, .8);
	padding: 2px 6px 1px;
	text-align: center;
	line-height: 1.3;
	border-bottom: 2px solid #333;
	border-left: 1px solid #333;
	border-right: 1px solid #333;
	box-sizing: border-box;
	white-space: nowrap;
	overflow: hidden;
}

/* The engine-difference notes inside #lpn_status, which expire two minutes after they are said
   (Tom, 2026-09-05: "Give it a timer, maybe 2 minutes and maybe fading if that's easy."). The
   transition is the whole of the fade; the span keeps its place in the flow while it runs, or the
   grievance button beside it would jump. `prefers-reduced-motion` gets the disappearance without
   the animation, which is the same outcome a second later. */
.lpn-status-notes {
	transition: opacity 0.8s linear;
}

/* The EPANET run report's own box (ROADMAP Task 570). The text is the engine's, column-aligned
   monospace, so it must NOT wrap -- `overflow-x` inside the box is what keeps the page itself
   from scrolling sideways. The Copy button sits on the title bar, clear of the close X. */
.lpn-rptbox-pre {
	margin: 0;
	font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
	font-size: 12px;
	line-height: 1.35;
	white-space: pre;
	overflow-x: auto;
}
.lpn-rptbox-copy {
	font-size: 11px;
	padding: 1px 6px;
}
/* **THE TITLE BAND BELONGS TO THE TITLE, THE DOCK ICONS AND THE X -- NEVER TO A BOX'S OWN BUTTONS**
   (Tom, 2026-10-04: the Full Report's Download and Print sat on its dock icons). One rule for every
   box: a button the box brings of its own goes in this row, the first thing in the body. Sticky, so
   a report of thousands of rows never scrolls its buttons away; a docked column can be 240 px wide,
   which has no room in the band for a title, four dock icons, the X and two more buttons, so
   "the middle of the band" was weighed and rejected. dev/lpn-spike/dock-title-band-harness.js. */
.lpn-box-tools {
	position: sticky; top: 0; z-index: 1; flex: none;
	display: flex; flex-wrap: wrap; gap: 6px; padding: 0 0 6px;
	background: var(--ec-bg);
}
@media (prefers-reduced-motion: reduce) {
	.lpn-status-notes { transition: none; }
}

/* The suite's one icon set (ROADMAP Task 231). Geometry lives once in lib/Icons.lib.php; this is
   the only place its size and alignment are decided.

   Sized in em, so an icon tracks whatever text it prefixes instead of fighting it at one fixed
   pixel size. stroke:currentColor (set on the element) plus no colour here means an icon is
   automatically right on a light row, a hover row, a disabled row and a dark print stylesheet --
   the property colour emoji could never have. vertical-align sits it on the text's optical
   centre; baseline alignment would hang it low next to a capital letter. */
.ec-icon {
	width: 1.05em;
	height: 1.05em;
	vertical-align: -0.16em;
	flex: none;
}
/* A control whose label is "<icon> Word" gets its gap from here rather than from a space character
   baked into every call site -- a literal space is unremovable and wrong in a narrow toolbar.
   margin-inline-end, not margin-right: in Arabic, Hebrew, Persian, Urdu and Pashto the icon sits
   to the RIGHT of its word and the gap has to follow it there. */
button > .ec-icon, a > .ec-icon, label > .ec-icon { margin-inline-end: 0.4em; }
/* Except inside the lpn menu's fixed-width icon column, where the cell already provides the gap
   and a margin on top of it would push every label off the column it is meant to line up with. */
.lpn-menu-icon > .ec-icon { margin-inline-end: 0; }

/* Hover-tip icons (⚠, ?) that carry a longer explanation in the title attribute */
.ec-tip {
	cursor: help;
	color: steelblue;
	font-size: 0.9em;
}

/* Second line of an input label carrying that field's inline solver control
   (solverControlHtml() in lib/Calculators.lib.php). Deliberately plain: no rule, no
   indent glyph -- the leading button is enough to mark the line as a control, and any
   directional decoration would have to be mirrored for the RTL languages. Line height
   is left alone so the Q input keeps its natural height. */
.ec-solverline {
	display: block;
	margin-top: 2px;
	font-weight: normal;
}

/* Whole-label wrapper: makes the entire label (not just the ? icon) the hover/tap
   target for the tip carried in .ec-tip's title attribute. */
.ec-help {
	cursor: help;
}

/* **A TOOLTIP MAY NEVER TAKE A CLICK.** Bootstrap 5.3 drops its own `pointer-events: none`, so a
   tip that is still up -- and one stays up for as long as its control keeps FOCUS, which a clicked
   toolbar button does -- silently swallows the first click on whatever it is lying over. Found on
   the Settings box: its drag band opens under the toolbar's tip, and the first attempt to drag the
   box did nothing at all. Our tips carry no controls (Bootstrap's `html` option is off), so there
   is nothing inside one to click. */
.tooltip {
	pointer-events: none;
}

/* **WIDE TIPS, NOT TALL ONES** (Tom, 2026-10-04, on Ida's audit). Bootstrap's 200 px cap turned a
   long tip into a column taller than half the window: 92 words stood 554 px high in a 900 px one.
   17rem, the happy medium Tom asked for between the old 200 px and a 22rem trial,
   makes the same tip under 400 px high. Capped at the viewport less a gutter, so a phone in
   tall mode never gets a tip wider than its screen. */
.tooltip {
	--bs-tooltip-max-width: min(17rem, calc(100vw - 2rem));
}

/* The welcome line under the page title (template_welcome). Set apart with italics
   rather than the >> ... << that used to be typed into all 27 language strings: those
   are not standard English or typography (Tom, 2026-07-27), they are directional
   decoration that would have to be mirrored for the RTL languages -- the same reason
   .ec-solverline above carries no indent glyph -- and they put presentation inside a
   translated string, where every translator had to copy them by hand. */
.ec-welcome {
	font-style: italic;
}

/* Scripts with no italic tradition, where the browser can only synthesize a slanted
   face and the result reads as a rendering fault rather than as emphasis: Arabic,
   Hebrew, Han, Ethiopic, Devanagari/Bengali, Khmer, Burmese. The line is already its
   own paragraph under the <h1>, so it stays legible with no substitute decoration. */
html[lang="am"] .ec-welcome,
html[lang="ar"] .ec-welcome,
html[lang="bn"] .ec-welcome,
html[lang="fa"] .ec-welcome,
html[lang="he"] .ec-welcome,
html[lang="hi"] .ec-welcome,
html[lang="km"] .ec-welcome,
html[lang="my"] .ec-welcome,
html[lang="ps"] .ec-welcome,
html[lang="ur"] .ec-welcome,
html[lang="zh"] .ec-welcome {
	font-style: normal;
}

/* Looped Pipe Network map editor (prefix lpn_, ROADMAP Task 146). Canvas mechanics ported
   from the validated dev/lpn-spike/canvas-spike.html -- see phase0-acceptance.md. */
/* --lpn-symf is the same factor WITHOUT the Task 705 maximum map size: the editing furniture
   (selection and pending rings, vertex grips, rubber band, marquee, the just-moved pulse) reads it,
   because those speak to the person at the mouse and must not shrink away with the drawing. */
/* --lpn-sym is the symbol-size factor (Settings > "Symbol size (relative to text)"), set on the
   SVG element by refreshSymbolSizes() in looped-network.js. Every stroke width below is written as
   its original fixed value times that factor, so the default of 1 renders exactly what shipped
   before the setting existed. Radii and the flow-arrow chevron are geometry ATTRIBUTES, not styles,
   so those are scaled in JS instead. */
/* PLAIN WHITE (Tom, 2026-08-10, very high priority). It was #f7f7f2 -- a faint warm tint that read
   as "paper" on its own and as a dirty smudge next to the white page around it, and that no longer
   matched anything now that a backdrop image can sit under the drawing.

   --lpn-map-bg backs a reservoir/pump map symbol's occlusion patch (.lpn-node-symbol-backdrop/
   .lpn-link-symbol-backdrop below, ROADMAP Task 146.10 follow-up, 2026-08-09), so it must equal
   whatever the canvas itself is painted -- keep it in step with the inline `background` on
   #lpn_canvas in Looped-Network.php. */
/* **THE BARE MAP SAYS `default`, AND BOTH THE OPEN HAND AND THE POINTER ARE GONE** (Tom,
   2026-09-13, twice in one day). First: *"I really think that it's very cute and all to have a
   'grab' cursor. But it's not right for professional work. We need solid efficiency."* Then, having
   seen `pointer` tried: *"Unfortunately pointer doesn't look very good on the map. We are left doing
   exactly what epanetjs did, default on the map and pointer on select or default everywhere,
   possibly with color change on select."*

   **`default` EVERYWHERE IS BUILT, WHICH IS THE SECOND OF THE TWO HE NAMED, AND THE REASON IS THAT
   THE FIRST WOULD REVERSE A RULING HE MADE FIVE DAYS EARLIER.** Every object on this map says
   `default` on his own instruction of 2026-09-08 (*"I prefer default over pointer at the labels and
   assets. It's more precise."*), so "pointer on select" means putting the pointer back on the
   labels and assets he had just taken it off. That is a reversal and not a tweak, so it is his to
   make deliberately rather than mine to infer. **The missing half is the colour change on select**,
   which he named both times and which is the thing that would carry the map/element distinction
   once no glyph does: ROADMAP Task 659.

   **WHAT SURVIVES FROM TASK 569 IS THE PROPERTY, NOT THE GLYPH.** That task fixed bare canvas
   reading `default` because nothing there carried a cursor rule at all, so the strip between a
   node's disc and its own label read pointer, default, move, and Tom reported it as a flicker at
   12 px. There was never a ring at 12 px; it is a gap, measured one pixel at a time by
   dev/browser-pass/specs/cursorflicker.js, and that spec still holds that bare map says the map's
   own cursor everywhere it shows through. What is superseded is the GLYPH twice over: `grab`,
   because this is not a web map, and then `pointer`, because he looked at it. */
#lpn_canvas { touch-action: none; cursor: default; --lpn-sym: 1; --lpn-symf: 1; --lpn-opacity: 1; --lpn-backdrop-opacity: 1; --lpn-backdrop-img-opacity: 1; --lpn-basemap-style: none; --lpn-map-bg: #fff; }
/* **AND NOTHING CHANGES WHILE THE MAP IS MOVING**, which is why there is no `#lpn_canvas.lpn-panning`
   rule here any more. It was `cursor: grabbing`, the closing half of the open hand, and it goes with
   the hand. `setPanning()` still writes the class on the one frame a pan really translates the map,
   so restoring a moving-state cursor is one rule here and no JavaScript at all.

   **DO NOT BRING BACK THE `*` HALF** (Tom, 2026-09-08: *"My cursor is never turning into a pointer
   even though select (pointer) mode is on... it's always a pan/drag mouse."*).
   `#lpn_canvas.lpn-panning *` is specificity (1,1,0) and every object cursor on this map is (0,1,0)
   -- .lpn-node, .lpn-link, .lpn-link-hit, .lpn-link-symbol-hit, .lpn-vhandle, .lpn-draglbl -- so
   while that class was on, NOTHING on the drawing could say what it was. `cursor` inherits, so a
   rule on the canvas alone already reaches every child that does not state one. */
/* Fading the BACKDROP is the counterpart of fading the symbols below: a busy, dark aerial or plan
   swallows a 0.5-wide pipe and a black flow arrow, and the standard answer everywhere else (AutoCAD
   image fade, QGIS layer transparency) is to knock the reference material back rather than thicken
   the drawing over it -- the drawing then still reads correctly on white and in print. */
/* The picture reads its own variable, which is the user's fade EXCEPT while a placement wizard is
   open, when looped-network.js's backdropImageOpacity() holds it at 50% or less. */
.lpn-backdrop { opacity: var(--lpn-backdrop-img-opacity, 1); }
/* The OpenStreetMap tile layer (ROADMAP Task 145). pointer-events:none because it is scenery: every
   pointer gesture on this page is resolved from the SVG's own coordinates and from the element under
   the cursor, and a tile that could become an event target would sit between the user and every node
   it happens to cover. It shares the backdrop's fade for the same reason the backdrop has one -- a
   dense city map swallows a thin pipe. */
.lpn-basemap { opacity: var(--lpn-backdrop-opacity, 1); filter: var(--lpn-basemap-style, none); pointer-events: none; }
/* Symbol opacity, for laying the network out over a backdrop image: applied to the two whole
   layers that hold symbols, so nodes/pipes/arrows/vertex handles fade together as one drawing
   rather than each fading independently and showing where they overlap. Labels, masks and leaders
   live in their own layers and are deliberately untouched. */
.lpn-symbols { opacity: var(--lpn-opacity, 1); }
/* A junction is a SOLID DOT, one colour, no stroke (Tom, 2026-08-09).
   Two reasons, both from looking at a real drawing. The old two-tone treatment -- #2a6 fill
   inside a heavy #144 ring -- stopped working once the node shrank: "the light green in the
   middle has shrunk to nearly nothing", so it read as a dark blob with a colour fringe rather
   than as a green node. And the ring was most of the size: a 1 unit stroke centred on the edge
   adds 0.5 all round, taking a 1.44 fill out to 2.44 overall, which against a 2.5 unit font's
   ~1.8 cap height is the "about 1.33" too-big he measured by eye. Dropping the stroke and
   sizing the fill alone (JUNCTION_R in looped-network.js) puts the whole dot at one cap height.

   BLACK since 2026-08-14 (Tom: "Black reservoirs, pumps, valves, and nodes, I suppose"), and the
   comment that used to sit here arguing for BLUE-NOT-GREEN has been removed rather than left to
   contradict the rule below -- a stylesheet that explains a colour it no longer uses is worse than
   one that explains nothing.
   The reasoning it made is the reasoning that retired it, which is the satisfying part: "every
   colour we spend on decoration now is one we cannot spend on meaning later". Blue was already the
   restrained choice (EPANET draws everything black; epanet-js draws the normal case in one blue and
   reserves colour for CONDITION). Going the last step to black spends nothing at all on the base
   state, which is what Task 327's colour-by-value view needs in order to mean something -- and it
   answers Tom's own complaint that EPANET makes it "practically an act of Congress to get a pipe to
   show as black while showing values from the model on the map".
   The one existing conditional style, .lpn-node-pending's red ring, still overrides stroke below
   and is unaffected -- which is exactly the point: condition still reads, now against nothing. *//* **A JUNCTION IS A SHADED RING, NOT A SOLID DOT** (Tom, 2026-09-02, stating the convention the
   symbol set follows: *"All the map node symbols are thematic shaded fill. The pump and valve
   symbols are solid fill."*). It was the one node type that did not follow it, and it is the most
   numerous thing on any map. The wash plus its own stroke is the same construction the reservoir
   and tank symbols use, so all three node types now read as one family.

   **BOTH PARTS MOVE ON `currentColor`,** which is why paintNodeColor() writes `color` here rather
   than `fill`: a ring coloured only on its fill keeps a black outline round a coloured middle, and
   a coloured asset takes ONE colour throughout. `color: #000` is the uncoloured state, so clearing
   the inline colour restores it exactly.

   Stroke width scales with `--lpn-sym` like every other world-unit stroke on this map; a fixed
   width would thicken as the drawing zooms out. `.lpn-node-pending`'s red ring still overrides
   stroke below, so the pending-link indicator is unaffected. */
.lpn-node {
	color: var(--lpn-map-ink);
	/* Fallback first, then the opaque tint -- see the backdrop note below for why a translucent
	   wash cannot be used here: a pipe passing under a junction would show through it. */
	fill: var(--lpn-map-bg, #fff);
	fill: color-mix(in srgb, currentColor var(--lpn-shade, 18%), var(--lpn-map-bg, #fff));
	stroke: currentColor; stroke-width: calc(0.14 * var(--lpn-sym, 0));
	cursor: pointer;
}
/* A reservoir now draws as a tank symbol, not this circle (ROADMAP Task 146.10) -- fill:none/
   stroke:none hides the circle itself; `pointer-events: visible` keeps its full disc
   clickable/draggable anyway (data-node, drag, hit-testing all still read THIS element, unchanged),
   same trick used for any UNPAINTED-but-clickable hit target. `.lpn-node-pending`'s ring below still
   overrides stroke, so the pending-link indicator still shows on a reservoir mid-pipe-draw.
   `visible`, not `all`: unpainted is not the same as invisible -- what the user sees here is the
   tank symbol drawn over this disc -- and `all` would also make the disc grabbable if anything ever
   set `visibility: hidden` on it. See the note on `.lpn-draglbl` for the defect that produced. */
/* **AND IT IS NOT A HIT TARGET, WHICH IS HALF OF THE HALO TOM TRACED** (2026-09-10). This circle is
   unpainted for a reservoir and a tank -- the symbol over it is the drawing -- and `pointer-events:
   visible` made its full disc answer the pointer REGARDLESS of `fill: none`, at the radius that
   circumscribes the symbol. So the round halo had two sources and moving `.lpn-node-hit` off a
   circle would have fixed only one. `.lpn-node-hit` carries the same `data-node` and is built first,
   so nothing that reads the dataset can tell the difference; `.lpn-node-pending`'s red ring, the
   selection ring and the value colour still draw on THIS element, which is all it is for now. */
.lpn-node-reservoir, .lpn-node-tank { fill: none; stroke: none; pointer-events: none; }
/* The tank/pump overlay symbols themselves (looped-network.js buildMapIconSvg()) -- purely
   visual, never a click target; the interactive element is always the plain node circle or link
   polyline underneath. `color` is the currentColor the icon paths read for both stroke and the
   reservoir's translucent water-fill path. */
.lpn-node-symbol, .lpn-link-symbol { pointer-events: none; }
/* BLACK since 2026-08-14 (Tom: "Let's turn pumps and reservoirs black now"). This is the same
   argument the blue was chosen under, now being cashed in: colour on this map is the budget we are
   saving for MEANING, and Task 327's colour-by-value view is what will spend it. A reservoir that is
   blue because reservoirs are watery is decoration, and decoration is what makes EPANET "practically
   an act of Congress to get a pipe to show as black while showing values from the model" (Tom,
   2026-08-14). Black base state, colour reserved for what the model says.
   THE TANK GOES WITH IT even though Tom named only pumps and reservoirs: it shares this rule, and a
   lone blue tank among black reservoirs would read as an oversight rather than a distinction --
   the two are told apart by SHAPE, which is the test every symbol here is held to. Easy to revert
   if that was not the intent. The junction dot is deliberately left blue for now; nobody asked. */
/* **BLUE AGAIN SINCE 2026-09-02, WHICH REVERSES THE BLACK ABOVE ON TOM'S OWN WORD** (*"the icons
   are very nice. But they are black"*, and *"the map is black too, but should be thematic blue"*).
   The 2026-08-14 argument is kept above because it is still a good argument and would otherwise be
   re-made: colour is the budget saved for MEANING, and a watery blue reservoir spends it on
   decoration. What changed is that the symbols are now a SET drawn to one convention, and the
   thematic blue is what makes them read as one -- so the hue is carrying the family, not the
   wateriness. Colour-by-value still overrides all of it through currentColor, so the budget is
   spent the moment the model has something to say. One token, so a future change is one edit. */
:root {
	--lpn-map-ink: #1a6faf;
	/* **THE PIPES TAKE THE SAME INK AS THE NODE OUTLINES** (Tom, 2026-09-02: *"Pipes same as node
	   outlines."*, settling the "let's try main/dark" of a minute earlier). ONE ink for the whole
	   drawing, so the network reads as a single object rather than as symbols sitting on a
	   differently-coloured web. A second weight was tried first and is not kept: the pipes are the
	   structure, and making them darker than the things they connect made them read as a separate
	   subject. Colour-by-value still overrides every one of these. */
}
.lpn-node-symbol-reservoir, .lpn-node-symbol-tank { color: var(--lpn-map-ink); }
/* THE SAME BLUE AS THE RESERVOIR, deliberately (Task 248). A tank and a reservoir are both
   water bodies, and colour here is decoration, not meaning -- the two are told apart by SHAPE
   (closed and tall against open and wide, lib/Icons.lib.php), which is what still works in
   greyscale and for a colour-blind reader. Giving the tank its own hue would spend a colour on
   a distinction the shape already carries, and colour is the budget this map is saving for
   CONDITION -- see the note above. */
.lpn-link-symbol-pump { color: var(--lpn-map-ink); }
/* Black with every other symbol (2026-08-14). It was the PIPE's own colour on the argument that a
   valve is a fitting in the line rather than a separate kind of thing -- that argument still holds
   and is now served better, since a black valve on a pipe stroke is a mark ON the line rather than
   a differently-coloured object sitting in it. Told apart from a pump by SHAPE (an angular bowtie
   against a circle), which is the test every symbol here is held to and the only one that survives
   greyscale, print, and a colour-blind reader. */
.lpn-link-symbol-valve { color: var(--lpn-map-ink); }
/* Opaque patch behind a symbol's own linework (prependSymbolBackdrop() in looped-network.js) so a
   pipe running under a reservoir's open tank or a pump's unfilled casing doesn't show through --
   the icon's own paths are untouched, this only paints underneath them. */
/* **THE PATCH IS THE SHADING NOW, AND ONE SHAPE DOES BOTH JOBS** (2026-09-02). It was opaque white,
   which hid the pipe underneath and left the symbol's inside empty. Tom, having looked: *"the nodes
   don't hide the links"* and *"Node outlines alone are colored. The inside is open, and I see
   links."* A translucent wash cannot fix that -- anything you can see the tint through, you can see
   the pipe through. `color-mix()` gives an OPAQUE colour that still reads as an 18% tint of the
   symbol's own hue, so it occludes exactly as the white patch did and carries the thematic shading
   at the same time. The plain `fill` before it is the fallback for a browser without color-mix:
   white and opaque, which is the old behaviour rather than a broken one.
   **NODES ARE SHADED, PUMP AND VALVE ARE SOLID**, which is Tom's own convention (2026-09-02: *"All
   the map node symbols are thematic shaded fill. The pump and valve symbols are solid fill."*). */
.lpn-node-symbol-backdrop, .lpn-link-symbol-backdrop { stroke: none; fill: var(--lpn-map-bg, #fff); }
/* **THE SHADE IS A VARIABLE, AND COLOUR-BY-VALUE TURNS IT OFF** (Tom, 2026-09-04: *"nodes that are
   15% from the source are not dark... Maybe the coloring is working, but it's been artistically
   faded. If so, let's remove that fading."*). He read it exactly right. 18% of a dark ramp colour
   over a white ground is a pale grey whatever the value is, so the low end of a ramp arrived
   looking like the middle of nothing -- and only the top of the ramp, where the ring itself is
   dark, read as coloured at all.
   The wash is the UNCOLOURED state and nothing else. paintNodeColor() writes --lpn-shade: 100% on a
   node it has given a value colour, so the fill becomes that colour outright, and clears the
   property when the colouring is switched off. One inline property beside the one it already
   writes, and the default here is what an uncoloured map keeps. */
.lpn-node-symbol-backdrop { fill: color-mix(in srgb, currentColor var(--lpn-shade, 18%), var(--lpn-map-bg, #fff)); }
.lpn-link-symbol-backdrop { fill: currentColor; }
/* **THE OUTLINE IS A HAIRLINE** (Tom, 2026-09-02: *"I'd like the ring on all node types to be
   thinner, like 1px, with the thematic blue shading."*). The icon paths carry stroke-width="2" as a
   presentation attribute for the toolbar, where the frame is 24px and 2 is right; stretched to a
   map symbol that same 2 reads as a heavy border round a small shape. A stylesheet rule beats a
   presentation attribute, so the map thins it without the toolbar noticing. */
.lpn-node-symbol { stroke-width: 1; }
/* stroke-width was 2 -- too wide relative to a 1.6-2.2 radius node (Tom, 2026-07-30), and it was
   also why the arrow read as absorbed into the pipe rather than a distinct mark. Then 0.5, which
   that correction overshot: a pipe network's PIPES are its primary content, and at 0.5 they were
   drawn lighter than the node outlines (1.0) sitting on top of them. 0.7 is Tom's "a little"
   rather than his "maybe double" -- heavier and easier to see over a busy backdrop, still lighter
   than a node's own outline so the original over-wide problem does not come back. The flow arrow
   is deliberately NOT thickened with it; see .lpn-arrow below. */
/* --lpn-lw, NOT --lpn-sym (Task 331, 2026-08-14). Pipe width is its own setting in screen pixels
   now that text, symbols and links are decoupled: a pipe network's PIPES are its primary content,
   and how heavy they are is a drawing decision in its own right rather than a consequence of how
   big the junction dots happen to be. The three rules below (stroke, closed-link dashes, override
   halo) are the link-weight family and all read --lpn-lw; every other stroke width on the map is
   symbol furniture and stays on --lpn-sym. The multipliers preserve the exact proportions the
   0.7-based versions had, so nothing about the drawing's appearance changed with the decoupling. */
/* BLACK (Tom, 2026-08-14, completing the sweep). The base drawing now spends NO colour at all --
   pipes, nodes, reservoirs, tanks, pumps and valves are one black line-work, and every colour on the
   map from here on means something: condition today (.lpn-node-pending's red, the override amber),
   value tomorrow (Task 327). This is the state Tom named as the thing EPANET makes hard: "a pipe to
   show as black while showing values from the model on the map". */
/* **`pointer` ON AN ASSET AND ON A LABEL; THE BARE MAP KEEPS `default`** (Tom, 2026-09-13, having
   tried the alternative: *"All default is a downgrade. Let's try map default and select pointer."*).
   That is epanet-js's arrangement and he named it as such. **It supersedes his own ruling of
   2026-09-09** (*"I prefer default over pointer at the labels and assets. It's more precise."*),
   which was measured against a map whose BARE canvas then said `grab`: with a hand on the
   background, an arrow on the objects was the contrast, and taking the hand away removed the
   contrast rather than the eye candy. The glyph on the object is now the only thing separating an
   object from the map, which is why the same reasoning lands the other way round.

   The old argument is kept because it is still TRUE and still the cost being paid: a finger's hot
   spot sits at the tip of a drawn hand about 20 px across, where an arrow tapers to nothing at its
   own hot spot, so the pointer hides more of a 7 px junction disc than the arrow did. Task 659's
   colour change is the way to buy the distinction back without the glyph, and it is the only thing
   that would justify returning these three rules to `default`.

   A placement tool keeps `crosshair`, which says what a PRESS will do rather than what is under the
   cursor, and that distinction was always worth a glyph of its own. */
/* **A SCALE FALLBACK IS ZERO, AND THAT IS DELIBERATE** (ROADMAP Task 624, Tom 2026-09-10).
   Every size below is a figure in SCREEN pixels divided by `state.s` and published by
   publishScaleSizes() as an inline custom property in WORLD units. The fallback beside each `var()`
   is therefore world units too -- and a world-unit fallback is only ever right at ONE scale. It used
   to read `var(--lpn-lw, 0.7)`, which is a sane pipe on an XY project at about 1 px per unit and
   **4,535 px of pipe on the lat/lon Net3 at 6,479 px per degree**: a canvas covered in one colour
   with nothing on it that can be reached. Tom hit exactly that, twice, and neither of us could
   reproduce it on demand -- it is a race against the first publish, not a broken commit.
   **The race is not fixed here; the AMPLIFIER is.** At zero, a missed publish draws a pipe too thin
   to see -- noticed, reported, recovered from by zooming -- instead of a page that looks destroyed.
   A fallback whose failure mode is worse than having no fallback is not a safety net.
   `--lpn-nhit`, `--lpn-symhit` and `--lpn-symhit-coarse` already read 0 for this reason; these five
   are the ones that were missed. Do not restore a non-zero number to any of them: there is no value
   that is both visible at 1 px per unit and harmless at 6,479. */
.lpn-link { stroke: var(--lpn-map-ink); stroke-width: var(--lpn-lw, 0); cursor: pointer; }
/* Black with its symbol (2026-08-14) -- the pump's polyline and its circle are one mark. */
.lpn-link-pump { stroke: var(--lpn-map-ink); }
/* No .lpn-link-valve rule: a valve's polyline is a pipe-coloured line by design -- see
   .lpn-link-symbol-valve above. */
/* A CLOSED link (ROADMAP Task 146.07). Dashed, because a closed pipe carries no water and would
   otherwise be indistinguishable from an open one -- an invisible cause for a network that will
   not solve, or that solves to a surprising answer. The dash lengths are in WORLD units and scale
   with --lpn-sym exactly as the stroke width above does, so the dashes keep their proportion to
   the line at every zoom and symbol size rather than dissolving into a dotted line when zoomed
   out. Colour is deliberately unchanged: a closed pipe is still the same pipe, and recolouring it
   would collide with the per-field label colours (lpnFieldColors). */
.lpn-link-closed {
	stroke-dasharray: calc(2.571 * var(--lpn-lw, 0)) calc(2 * var(--lpn-lw, 0));
}
/* Flow-direction arrow at each link's midpoint (Tom, 2026-07-30, matching EPANET) -- hidden
   until a solve result exists (see updateArrow() in looped-network.js). pointer-events:none so
   it never intercepts a click meant for the link/vertex underneath it.
   An open chevron (unfilled polyline), not a solid triangle: a filled shape in the link's own
   color read as absorbed into the (formerly thick) pipe stroke. Black and open protrudes above
   and below the line instead of blending into it. */
/* NOT multiplied by --lpn-sym, unlike every other stroke width here (Tom, 2026-07-30: "flow arrows
   are too wide… it seemed beautifully narrow before"). The arrow is the one symbol whose SHAPE is
   scaled by an SVG transform (updateArrow() applies scale(symbolFactor()) to the chevron), and an
   SVG transform scales the stroke along with the geometry -- so multiplying here as well squared
   the factor, and the line grew twice as fast as the mark it outlines.
   Left at 0.3 when .lpn-link went to 0.7 (Tom, 2026-07-30): he called this width "beautifully
   narrow", and what makes an arrow vanish into a dark aerial is its pure BLACK against a dark
   background, not its width -- fading the backdrop fixes that without coarsening the mark. */
/* **THE ARROW TAKES THE MAP'S OWN INK, and the thematic colour over it** (Task 671, Tom
   2026-09-15). It was `#000` while `.lpn-link` above was `var(--lpn-map-ink)`, so an arrow was
   already the wrong colour before any thematic map was switched on -- and with one on, every pipe
   changed and its chevrons stayed black. Same ruling as the vertex dot two rules down, which Tom
   made for the same reason on 2026-09-02. paintLinkColor() writes the thematic colour as an
   inline style and clears it with the empty string, so this is what a cleared field falls back to.
   Still an OPEN stroke and still not a filled triangle: a filled one reads as absorbed into the
   pipe, which is the whole reason it is drawn this way. */
.lpn-arrow { fill: none; stroke: var(--lpn-map-ink); stroke-width: 0.3; pointer-events: none; }
/* Filled the same color as .lpn-link (Tom, 2026-07-30: vertices should read as "on the pipe",
   not as a third node type) -- previously a hollow white circle with the pump's #a52 stroke,
   which had no relation to the link it belonged to and could be mistaken for a small pump. */
/* **THE VERTEX DOT TAKES THE MAP'S OWN INK** (Tom, 2026-09-02: *"Change vertex color too."*). It
   was black and a near-black stroke while everything around it went blue, which made a bend read as
   a different KIND of thing from the pipe it sits on. It is a mark on the pipe, so it is the pipe's
   colour. */
.lpn-vhandle {
	fill: var(--lpn-map-ink); stroke: var(--lpn-map-ink);
	stroke-width: calc(0.25 * var(--lpn-sym, 0)); cursor: pointer;
}
/* VERTICES MODE (Task 567): the bends of every pipe become GRIPS -- hollow, and a good deal bigger
   than the dot that merely says a bend is there. EPANET's own grip is a hollow square; a circle is
   what this map already draws, and hollow-plus-large is the half that carries the meaning ("this is
   a thing to take hold of"). The `r` here is an SVG2 geometry property, so a browser that does not
   honour it leaves the grip its ordinary size -- degraded, not broken, because the reach that makes
   a grip grabbable with a finger is nearestVertexNearScreen()'s and not this rule's.
   The whole mode is one class on the canvas, so nothing is created or destroyed to enter or leave
   it, and a bend added while it is on is styled by the same rule with no extra code. */
.lpn-vertexmode .lpn-vhandle {
	r: calc(1.5 * var(--lpn-symf, 0));
	fill: var(--lpn-map-bg, #fff);
	stroke-width: calc(0.4 * var(--lpn-symf, 0));
}
/* Every pipe's bends are legible while the mode is on, not only the selected one: Tom, 2026-09-01,
   *"We could tweak the behavior to enable vertices mode for all pipes if that's efficient."* It is
   -- they are all drawn already. */
/* The invisible grab band takes the mode's cursor too. Without it the 12 px ribbon around every
   pipe says `pointer` while the 2 px pipe inside it says `crosshair`, and the ribbon is what the
   pointer is actually over almost all of the time. */
.lpn-vertexmode .lpn-link, .lpn-vertexmode .lpn-link-hit { cursor: crosshair; }
/* The node's band takes the mode's cursor for the same reason the pipe's does: in vertices mode a
   press on a node is not an edit of the node, so a `pointer` there would promise one. */
.lpn-vertexmode .lpn-node-hit { cursor: crosshair; }
/* ---- Scenarios: the audit halo and the inactive element (ROADMAP Task 184) ----
   AN OUTLINE, NEVER A FILL, and that is the whole reason a link gets a second element rather than
   a restyle: the pipe's own stroke IS the pipe, so recolouring it to mark an override would
   collide with the closed-link dashes, the pump's colour, and every future result-driven styling
   this map spends colour on. A wider line UNDERNEATH composes with all of them.
   Amber rather than red: an override is a deliberate act, not a fault, and red is spent on
   condition here (.lpn-node-pending). Hidden until refreshScenarioMarks() adds .lpn-override,
   which is why every link can carry one at no visual cost. */
.lpn-link-halo { stroke: none; pointer-events: none; }
/* ---- THE GRAB BAND, AND IT IS NOT PART OF THE DRAWING (Tom's 2026-09-08 worklist) ----
   A pipe ships two pixels wide and an SVG hit test is exactly the drawn stroke, so before this the
   only way to reach a pipe with a mouse was to land on those two pixels. This is a wider,
   completely invisible stroke UNDER the pipe carrying the same data-link, so nothing about the map
   looks different and every existing dataset.link branch reads it as the pipe.

   `pointer-events: visibleStroke` and NOT `stroke`, which is the difference between the two that
   matters here: the bare values ignore `visibility` altogether, and an element that can be grabbed
   while it cannot be seen is exactly the defect dev/lpn-spike/hidden-label-grab-harness.js exists
   for. The `visible` prefix gates the hit on being visible; the `Stroke` half is what keeps the
   band a LINE rather than the closed polygon a bent pipe's own points would enclose. `stroke:
   none` would make the element unhittable by any value; `transparent` is a colour with no alpha,
   which is what is wanted. --lpn-hit is published by refreshSymbolSizes() as LPN_LINK_HIT_PX screen pixels
   converted to world units at the current zoom, so the band is the same size on the screen at
   every scale. Round caps and joins so a bend has no notch in its band. */
/* **THE BAND IS WHERE YOU MAY CLICK; IT IS NOT WHERE THE CURSOR CHANGES** (Tom, 2026-09-09:
   *"The cursor for the open map should be grab panning hand since that's all it can do. But it's a
   pointer finger. This matters because we want the cursor to clearly become a pointer when pointing
   is appropriate, not all over the map."*).

   These invisible bands are 12 SCREEN pixels wide at every zoom, so on a drawing whose pipes run
   20 px apart at the fit zoom there is no bare map left between them -- every gap is a band, and a
   band said `pointer`. That is the whole of his report: the map was a pointer finger nearly
   everywhere, and moving from a gap onto an actual node changed nothing, which is also why the
   cursor "does not want to change" in two different browsers. It is not sluggishness and it is not
   a race; it is area, the same answer the 23,821-point measurement gave for the previous round.

   `cursor: inherit` rather than a value: the band takes whatever the canvas is saying, so the
   6 px of pointer slop that makes a 0.7-wide pipe clickable survives untouched while the FEEDBACK
   moves onto the thing you can see. Nothing about hit testing changes -- `pointer-events` is
   unchanged on all three bands, and no finder reads a cursor. */
.lpn-link-hit {
	fill: none; stroke: transparent; stroke-width: var(--lpn-hit, 0);
	stroke-linecap: round; stroke-linejoin: round;
	pointer-events: visibleStroke; cursor: inherit;
}
/* **A FINGER KEEPS THE 12 px BAND; A POINTER GETS THE PIPE'S OWN WIDTH WITH A 3 px FLOOR** (Tom,
   2026-09-10, ruling on Task 618: *"No slop for nodes. Slop for labels and to enforce a lower limit
   of 3 px for link lines."*). Both numbers are published by publishScaleSizes() in world units at
   this zoom; `pointer: coarse` is the PRIMARY pointer, so a laptop with a touchscreen and a mouse
   is still a mouse. */
@media (pointer: coarse) {
	.lpn-link-hit { stroke-width: var(--lpn-hit-coarse, 0); }
}
/* A pump or valve is a symbol drawn at the midpoint of a link that is often only a unit or two
   long -- on Net3 it had to be zoomed into before it could be edited at all. This is a transparent
   square over the symbol's own box, in the LINKS layer so a node still wins over it. A `visible`
   family value rather than `all` for the reason above: `all` would hit a square nobody can see.

   **`visibleFill` AND NOT `visible`, WHICH IS THE FIX FOR THE DEFECT BELOW.** `visible` hit-tests
   the fill AND THE STROKE PERIMETER, and it does so regardless of the value of `stroke` -- so
   `stroke: none` buys nothing, and with no `stroke-width` declared the perimeter is the initial
   value, 1 USER UNIT. See .lpn-node-hit. */
/* **THE SHAPE IS THE SYMBOL'S OWN OUTLINE, AND THE SLOP IS A STROKE ROUND IT** (Task 618, Tom
   2026-09-10: *"There is no presence!"* of a pump volute, and the same of a valve). It was a square
   over the symbol's box, in the links layer under every node band, and on Net3 at zoom to fit the
   two end nodes' bands covered it completely -- the square was the right size and never won a
   single hit. It is a <path> inside the symbol's own group now, so it is drawn where the volute is
   drawn, rotated with it, and it claims only the ground the drawing covers.

   `visible` rather than `visibleFill` because the SLOP IS THE STROKE: the width is DECLARED, in the
   element's own units, by resizePumpSymbol() -- which is the whole difference from the 2026-09-09
   defect, where an undeclared width meant the initial 1 USER UNIT and 846 px of invisible reach on a
   geographic drawing. The fallback here is ZERO, so a missing publish shrinks the shape to bare ink
   instead of growing it to a wall. */
/* **AND IT SAYS `default`, BECAUSE IT IS NOW THE INK** (Tom, 2026-09-10, testing at 100 px symbols:
   *"Reservoir and Pump cursor is a grab except for a single pixel at its anchor point"*). These
   shapes wore `cursor: inherit` from the days when they were oversized invisible bands that had no
   business claiming the pointer -- the canvas's own `grab` reached them and that was correct, because
   what they covered was mostly map. Since Task 618 they ARE the drawn silhouette, and the drawn
   symbol beside them is `pointer-events: none`, so the topmost hit target over a reservoir, a tank,
   a pump or a valve is this shape and the reader was told the map was pannable while pointing at an
   asset. A junction and a pipe never showed it because `.lpn-node` and `.lpn-link` are hit targets
   in their own right and carry `default` themselves. */
.lpn-link-symbol-hit {
	fill: transparent; stroke: transparent; stroke-width: var(--lpn-symhit, 0);
	stroke-linejoin: round; stroke-linecap: round;
	pointer-events: visible; cursor: pointer;
}
/* A FINGER IS NOT A MOUSE (Task 618, and dev/toolbar-icons.md's 44 px argument). The shape is the
   same; the skirt is 12 px on each side instead of 2. `pointer: coarse` is the PRIMARY pointer, so
   a laptop with a touchscreen and a mouse is still a mouse -- the same reading .lpn-node-hit takes,
   and the two must agree or one device gets a node's rules and a pump's from different worlds. */
@media (pointer: coarse) {
	.lpn-link-symbol-hit { stroke-width: var(--lpn-symhit-coarse, 0); }
}
/* **AND THE NODE'S OWN BAND, which is the half Task 615 did not do** (Tom, 2026-09-08: *"I get no
   help from the mouse pointer. It's just a pan cross the entire time."*). A junction is 7 screen
   pixels of drawn disc and nothing else, so `pointer` reached 0.2% of the map -- measured on the
   example network at 23,821 sample points -- against 90.3% `grab` and 6.5% `move`, the last of
   which is every draggable label lying in the topmost layer. The full measurement, and why there is
   no race to find, is on LPN_NODE_HIT_PX in js/looped-network.js.

   A transparent CIRCLE rather than a stroke, because a node is a point and not a line. `r` is
   written in JS on every zoom, not by a var here, because it is the drawn radius PLUS the slop --
   a reservoir must not shrink to a junction's band.

   **`visibleFill`, AND THE WORD `visible` HERE WAS A REAL, MEASURED, USER-VISIBLE DEFECT**
   (Tom, 2026-09-09: *"mouse behavior is unpredictable and mysterious, and this makes usage and
   debugging confusing."*). `pointer-events: visible` hit-tests the fill AND THE STROKE PERIMETER,
   and unlike `visiblePainted` it ignores the VALUES of `fill` and `stroke` -- so `stroke: none`
   removes nothing from the hit region. Neither rule declared a `stroke-width`, so the perimeter
   was the initial value: **1 USER UNIT, which on this page is one world unit and not one pixel.**

   The excess reach is therefore exactly half the world scale, in screen pixels, and that is how
   it was measured rather than argued (dev/browser-pass/specs/nodehit.js): on the XY Net3 example
   at 23.01 px/unit a node's band answered document.elementFromPoint 11.47 px outside its own
   getBoundingClientRect, which is 23.01/2 to three figures and is invisible. On the SAME network
   as a geographic project the scale is 8,431 px/degree, so the perimeter was 4,215 screen pixels
   wide: ONE node's invisible disc answered 42% of a 1398x798 canvas, up to 846 px outside its own
   36 px box, and the bare <svg> never answered a single sample point anywhere on that canvas.
   .lpn-link-symbol-hit reached 913 px the moment this one was fixed, which is why both moved.

   `visibleFill` keeps everything the band was for: the `visible` prefix still gates the hit on the
   element being visible, which is what stops a hidden node being grabbed (see .lpn-draglbl), and
   the `Fill` half ignores `fill: transparent` exactly as `visible` did. It is .lpn-link-hit's own
   `visibleStroke` for a shape that is a disc rather than a line, and the 12 px of pointer slop in
   LPN_NODE_HIT_PX is untouched -- the reach that was fixed was never slop, it was a stroke.

   Not `stroke-width: 0`, which measures identically: it leaves the wrong declaration standing and
   makes the rule depend on a second property to be correct. */
/* **AND IT IS THE SYMBOL'S OWN OUTLINE SINCE 2026-09-10, NOT A CIRCLE ROUND IT** (Tom, with
   Thematic map on: *"I can visually trace the outline of the reservoir's halo, and if I am not
   mistaken, it is circular. Fix that."*, and *"Tank is not clean; it's a circle too."*). Every node
   type had one <circle> at nodeRadius() + 6 px, and nodeRadius() of a reservoir or a tank is the
   radius that CONTAINS the symbol -- so a triangle's band answered in all four corners it does not
   draw. A junction IS a disc, so its band was always WYSIWYG and is geometrically unchanged.

   THE SLOP IS THE STROKE, which is what lets one construction serve both: `pointer-events: visible`
   hit-tests the fill and the stroke, so the band is the drawn shape plus LPN_NODE_HIT_PX / 2 in
   every direction, round every corner. `stroke-width` is DECLARED -- syncNodeHit() publishes
   `--lpn-nhit` on the element in ITS OWN units -- and the fallback is ZERO, which fails to
   shrink-wrapped rather than to the initial 1 USER UNIT that produced 846 px of invisible reach on
   a geographic drawing (specs/nodehit.js). Not `visibleFill`, which would drop the slop entirely. */
/* **AND A POINTER GETS NO SLOP AT ALL, WHICH IS TOM'S OWN RULING** (2026-09-10, on Task 618: *"No
   slop for nodes. Slop for labels and to enforce a lower limit of 3 px for link lines."*). The band
   is then exactly the ink: a junction's 7 px disc, a reservoir's triangle, a tank's rectangle. The
   12 px skirt LPN_NODE_HIT_PX publishes is kept for a COARSE pointer only -- a finger cannot see
   what it is covering, and dev/toolbar-icons.md's 44 px argument still holds there. He did not rule
   on touch and this is the conservative reading of that silence.
   `pointer: coarse` is the PRIMARY pointer: a laptop with a touchscreen and a mouse is a mouse. */
/* **AND IT SAYS `default`, BECAUSE IT IS NOW THE INK** (Tom, 2026-09-10, testing at 100 px symbols:
   *"Reservoir and Pump cursor is a grab except for a single pixel at its anchor point"*). This shape
   wore `cursor: inherit` from the days when it was an oversized invisible band with no business
   claiming the pointer -- the canvas's own `grab` reached it and that was right, because what it
   covered was mostly map. Since Task 618 it IS the drawn silhouette, and the drawn vessel beside it
   is `pointer-events: none`, so over a reservoir or a tank the topmost hit target is this shape and
   the reader was told the map was pannable while pointing at an asset. A JUNCTION never showed it,
   which is why the report named only the vessels: `.lpn-node` is a hit target in its own right and
   carries `default` itself. */
.lpn-node-hit {
	fill: transparent; stroke: transparent; stroke-width: 0;
	/* THE BAND FOLLOWS THE DISC, always -- see .lpn-node's cursor. The band IS the ink's reach, so
	   a glyph change on the object that stopped here would make the cursor flicker at the exact
	   radius Task 569 was raised about. dev/lpn-spike/node-grab-band-harness.js asserts they agree
	   rather than asserting either value, so the next change of mind costs one line, not two. */
	stroke-linejoin: round; pointer-events: visible; cursor: pointer;
}
@media (pointer: coarse) {
	.lpn-node-hit { stroke-width: var(--lpn-nhit, 0); }
}
/* THE TAB STRIP WHILE A MODEL IS BEING PLACED (Tom's 2026-09-08 worklist). Switching projects mid-wizard
   damaged both documents, so switchToTab(), closeTab() and newProject() refuse and say why. This is
   the half that stops the reach: faded, and the cursor says the press will not do what it looks
   like. Deliberately NOT `pointer-events: none` -- the press has to land, or the sentence that
   explains the refusal is never said, and a control that silently does nothing teaches nothing. */
.lpn-tabs-locked { opacity: .55; }
.lpn-tabs-locked .lpn-tab, .lpn-tabs-locked .lpn-tab-btn { cursor: not-allowed; }
/* The same fade, for the two other doors out of a project while a model is being placed (Tom,
   2026-09-08: *"I think the File menu and Open toolbar also must be disabled just for consistency
   and intuition."*). Faded, never `disabled`: the press still lands and still explains itself,
   which is the tab strip's own rule three lines above. */
.lpn-ctl-locked { opacity: .55; cursor: not-allowed; }
/* ---- Scale-dependent visibility of GENERATED ANNOTATION (Task 331, 2026-08-14) ----
   Set by applyLabelVisibility() when the visible map is wider than settings.labelMaxWidth.

   THE LINE IS NOT "LABELS", IT IS "THINGS WE GENERATED TO BE READ" -- and the flow arrow proved it
   belongs on this side (Tom, 2026-08-14: "Arrows also should hide at hideable zoom levels"). An
   arrow is a symbol by construction and an annotation by purpose: nobody drew it, it exists to be
   read, and zoomed out it is noise on top of a network you are trying to see the shape of. So:

     HIDDEN  data labels and their leaders (.lpn-datalbl-part), flow arrows
     KEPT    the network itself -- nodes, pipes, pumps, valves, tanks -- and the user's own Text
             labels, which are authored content: they typed the words and chose the spot, and
             vanishing them under a threshold that never mentions Text labels would read as a bug.

   `visibility`, not `display`: a leader and an arrow each have their own show/hide logic and this
   must compose with them rather than overwrite them -- something already hidden for its own reasons
   stays hidden, and something visible is merely made invisible.

   **AND visibility:hidden ONLY DROPS POINTER EVENTS IF THE ELEMENT ASKED IT TO.** This paragraph
   used to end "so nothing suppressed here is still clickable", and that was false for two years'
   worth of SVG: `pointer-events` has two families, and the four names without a `visible` prefix
   (`painted`, `fill`, `stroke`, `all`) hit-test the element REGARDLESS of its visibility. See
   `.lpn-draglbl` below, which wore `all`.

   ONE CLASS, DECLARED AT THE BUILD SITE (Task 334, 2026-08-15). This used to be a list of four
   selectors naming each kind of annotation, and the extrema badge was missed out of it -- Tom
   caught it on screen the same day: "Extrema glyphs forgot to hide when zoomed out." A list in a
   stylesheet has to be remembered by whoever adds the next mark, months later, in another file.
   `annotationEl()` in js/looped-network.js applies this class where the element is created, where
   the author already knows what they are making. Add nothing here; build through that instead.

   The extrema mark itself is no longer even a member: Task 333 made it the number's own
   text-decoration, so it hides, moves and dies with the text. Prefer that where it is available. */
.lpn-labels-hidden .lpn-annotation { visibility: hidden; }
/* A USER'S OWN TEXT LABEL IS NOT ANNOTATION AND IS NOT IN THAT RULE -- it is authored content, and
   it hides on its OWN threshold, scaled by its own size (Task 340): a title block drawn at 3x
   survives to 3x the map width, exactly the way sheet lettering works. Per label, so it cannot be
   a descendant selector; applyLabelVisibility() adds this class to the ones that are over it. */
.lpn-lbl-hidden { visibility: hidden; }
/* ---- THE FIRE FLOW RING (ROADMAP Task 530) ----------------------------------------------------
   The result of the last fire flow sweep, on the junction's own circle. It paints a STROKE while
   the colour ramp paints the FILL, so a run and a pressure colouring can be read at the same time
   and neither has to be turned off to see the other.

   **THE DASH PATTERN CARRIES THE READING AS WELL AS THE COLOUR**, so Passing, Failing and Design
   issue are told apart without telling green from red -- and so a printed page in greyscale still
   says which is which. The three are: solid, dotted, dashed.

   FIRST of the four ring rules that follow, deliberately: override, selection and the pending mark
   are all statements about what you are DOING and each of them overrides this, which is a statement
   about what was calculated. */
.lpn-node.lpn-ff-pass { stroke: #2e7d32; stroke-width: calc(0.5 * var(--lpn-sym, 0)); }
.lpn-node.lpn-ff-fail { stroke: #d32f2f; stroke-width: calc(0.5 * var(--lpn-sym, 0)); stroke-dasharray: 0.6 0.6; }
.lpn-node.lpn-ff-design { stroke: #ef6c00; stroke-width: calc(0.5 * var(--lpn-sym, 0)); stroke-dasharray: 1.6 0.8; }
.lpn-node.lpn-ff-error { stroke: #888; stroke-width: calc(0.5 * var(--lpn-sym, 0)); stroke-dasharray: 0.3 0.9; }
.lpn-link-halo.lpn-override { stroke: #e8a33d; stroke-width: calc(2.857 * var(--lpn-lw, 0)); }
/* A node's own circle carries its halo as a ring: it already draws as a filled disc, so a stroke
   around it IS an outline. Placed BEFORE .lpn-node-pending below, so a node that is both
   overridden and mid-pipe-draw shows the pending ring -- that one is about what your next click
   does, and it is the more urgent of the two. */
.lpn-node.lpn-override { stroke: #e8a33d; stroke-width: calc(0.5 * var(--lpn-sym, 0)); }
/* SELECTED: the subject of the next command (ROADMAP Task 415, setSelection() in
   js/looped-network.js). Blue, and it is not spending the map's colour budget on the model: red
   says condition, amber says override, and both are statements about the DOCUMENT, while this one
   is a statement about the VIEW and disappears the moment you click elsewhere. Blue is what every
   editor uses for it, so it needs no legend.
   A pipe wears the mark on its halo -- the same wider line underneath that the override uses -- so
   it composes with the closed-link dashes and the pump's own colour rather than replacing them.
   Both rules sit AFTER their .lpn-override twin (selection is the thing you are acting on now) and
   BEFORE .lpn-node-pending (mid-pipe-draw is about what your next click does, which is more
   urgent still). */
.lpn-link-halo.lpn-selected { stroke: #1976d2; stroke-width: calc(2.857 * var(--lpn-lw, 0)); }
.lpn-node.lpn-selected { stroke: #1976d2; stroke-width: calc(0.5 * var(--lpn-symf, 0)); }
.lpn-lbl.lpn-selected { fill: #1976d2; }
/* HOVER PREVIEW, Select mode only (Ida's wishlist item 1): additive to the Task 618 cursor, which
   stays `default` over an object and over nothing on purpose -- nothing else on screen said what a
   click would hit. js/looped-network.js toggles this on the exact element selectionMarkEl() would
   mark, from the same hit-test selectFromHit() uses, so the preview can never promise a click that
   would not happen. `--lpn-hover-color` so a future theme can retune it without a second rule.
   Weaker than `.lpn-selected` on purpose (thinner stroke, a greyer blue) and `:not(.lpn-selected)`
   guarantees the selected look always wins when both classes land on the same element, regardless
   of rule order. */
:root { --lpn-hover-color: #78909e; }
.lpn-link-halo.lpn-hover:not(.lpn-selected) { stroke: var(--lpn-hover-color); stroke-width: calc(1.8 * var(--lpn-lw, 0)); }
.lpn-node.lpn-hover:not(.lpn-selected) { stroke: var(--lpn-hover-color); stroke-width: calc(0.32 * var(--lpn-symf, 0)); }
.lpn-lbl.lpn-hover:not(.lpn-selected) { fill: var(--lpn-hover-color); }
/* THE GO-TO MARK (Tom, 2026-09-28): the element a go-to arrived at while a selection set was
   standing, since a go-to no longer replaces the set (findGoTo() in js/looped-network.js). Orange,
   at the selection's weight, and it pulses three times so it is found; `:not(.lpn-selected)` so a
   selected element keeps the selected look. */
:root { --lpn-located-color: #e65100; }
@keyframes lpn-located-pulse { 0%, 100% { stroke-opacity: 1; } 50% { stroke-opacity: 0.25; } }
.lpn-link-halo.lpn-located:not(.lpn-selected) { stroke: var(--lpn-located-color); stroke-width: calc(2.857 * var(--lpn-lw, 0)); animation: lpn-located-pulse 0.6s ease-in-out 3; }
.lpn-node.lpn-located:not(.lpn-selected) { stroke: var(--lpn-located-color); stroke-width: calc(0.5 * var(--lpn-symf, 0)); animation: lpn-located-pulse 0.6s ease-in-out 3; }
.lpn-lbl.lpn-located:not(.lpn-selected) { fill: var(--lpn-located-color); }
.lpn-meter.lpn-located:not(.lpn-selected) { fill: var(--lpn-located-color); stroke: var(--lpn-located-color); }
/* NOT IN THIS NETWORK: drawn, but out of the model (see isActive() in js/looped-network.js). Faded
   rather than hidden, because the whole point of the inactive/active pair is to see both at once --
   "with the new loop vs. without" wants the proposal visible beside the network it would join. */
.lpn-inactive { opacity: 0.3; }
/* The override marker under a property row, and Base's value beside it. Indented and small so the
   row above stays the property and this reads as a note about it -- there is one of these under
   every editable field once you are inside a scenario, and at full weight they would outnumber and
   outshout the numbers they annotate. */
.lpn-ov-marker { margin-left: 1.5em; font-size: 0.85em; }
.lpn-ov-base { margin-left: 0.6em; color: var(--ec-ink-subtle); }
/* Feedback for the first-picked node of a pipe/pump (Tom, 2026-07-30: "otherwise there's no
   indication that anything is working") -- a wide red ring around the existing node fill/stroke,
   cleared by setPendingLinkFrom(null) in looped-network.js on completion or cancel. */
.lpn-node.lpn-node-pending { stroke: #d32f2f; stroke-width: calc(0.5 * var(--lpn-symf, 0)); }
/* Dashed line from the first-picked node to the live pointer, same context (add-pipe/add-pump
   mode) -- shown/hidden and repositioned entirely from JS (rubberBandEl in looped-network.js). */
.lpn-rubberband { stroke: #d32f2f; stroke-width: calc(0.4 * var(--lpn-symf, 0)); stroke-dasharray: 1.2 1; pointer-events: none; fill: none; }
/* The select-area marquee (Task 266). Blue rather than the rubber band's red, because the rubber
   band says "a pipe will be drawn here" and this says "these are the ones I mean" -- two shapes
   that both follow the pointer and must never be mistaken for each other. A faint wash inside it,
   so a lasso reads as an enclosure rather than as a scribble; pointer-events off, or the shape
   being drawn would swallow the hit tests of everything under it. */
.lpn-marquee {
	stroke: #0645ad; stroke-width: calc(0.35 * var(--lpn-symf, 0)); stroke-dasharray: 1.2 1;
	fill: #0645ad; fill-opacity: .08; pointer-events: none;
}
/* **THE DISCLOSURE TRIANGLE** (Task 266). It says "this control holds more than it shows", which
   is the only thing that makes press-again-to-cycle discoverable. A pseudo-element, so there is
   nothing in the DOM a pointer or a screen reader could take for a second control -- it is an
   indicator and not a target, which is exactly what the convention it copies means. */
/* **THE SELECT-AREA INSTRUCTION BUBBLE** (Task 266). It says what the NEXT click will do, so it
   changes mid-gesture and has to be readable at a glance against whatever the map is showing: a
   solid ground rather than the mode line's wash, and a border, because it sits over a drawing.
   **A PANEL SINCE 2026-09-08** (Tom: centred on the map, on top of the stack, draggable): fixed,
   so it escapes the overlay column it is written in, and pointer-events back ON for itself alone,
   because makePanelDraggable() needs the press -- its children stay inert so that press lands on
   the panel and not on a line of text. The z-index is the stack's own, set by raisePanel(). */
.lpn-area-hint {
	position: fixed; pointer-events: auto;
	font-size: 11px; line-height: 1.35; max-width: 22em;
	background: var(--ec-bg); color: var(--ec-hue-12211f);
	border: 1px solid var(--ec-accent); border-radius: 3px;
	padding: 6px 10px;
	box-shadow: 0 2px 6px var(--ec-a-0-0-0-3);
}
.lpn-area-hint > div { pointer-events: none; }
.lpn-area-hint-note { font-weight: bold; margin-bottom: 2px; }
/* The bubble's own "Show this" checkbox (Tom's 2026-09-08 worklist). A <label> rather than a <div>, so the
   rule above does not make it inert -- the checkbox is the one thing in this panel that must take
   a press, and the panel's own drag still works everywhere else. */
.lpn-area-hint-show {
	display: flex; align-items: center; gap: 4px;
	margin-top: 5px; opacity: .8; cursor: pointer;
}
/* "Hide these titles" -- the link that rides on the page description it hides (Tom's 2026-09-08 worklist).
   Sized well down from the <h2> it lives inside: it is an aside ON the heading, not a second
   heading. It needs no print rule of its own, because the headings it sits in are already
   d-print-none. */
.lpn-hide-titles { font-size: .6em; font-weight: normal; white-space: nowrap; }
/* A collapsible heading per type in the multi-properties box. Open by default and the reader
   closes what they are not using, never the other way round. */
.lpn-multi-section { margin: 0 0 .5em; }
.lpn-multi-section > summary { font-weight: bold; cursor: pointer; padding: 2px 0; }
.lpn-multi-note { margin: .2em 0 .4em; font-size: .9em; }
.lpn-tool-more { position: relative; }
.lpn-tool-more::after {
	content: ''; position: absolute; right: 2px; bottom: 2px;
	border: 3px solid transparent; border-right-color: currentColor; border-bottom-color: currentColor;
	opacity: .65; pointer-events: none;
}
/* No font-size here -- it must be set inline in world units at each text element (see the
   buildNodeEls/buildLabelEls comments in looped-network.js for why a screen-pixel-flavored
   CSS font-size is wrong inside this scaled SVG group). */
/* THE LEGIBILITY BACKING IS A HALO ON THE GLYPHS, NOT A RECT BEHIND THEM (Task 376, the way
   epanetjs does it). `paint-order: stroke fill` paints the stroke first and the fill on top, so a
   white stroke reads as an outline that follows the letters and merges between close characters --
   and it costs one declaration where the rect cost an element per label, a pad constant, a rotation
   kept in step with the text, and a rect left behind whenever a label was hidden.
   **WIDTH IS `em`, NOT --lpn-hair, AND THE DIFFERENCE MATTERS TWICE.** Both are screen-pixel
   constants here -- the font-size on each label is itself a pixel size divided by the scale -- so
   either one answers the defect the old world-unit pad had, where 0.4 world units reached 24px a
   side at working zoom and turned every pipe a label lay along into pale grey. But `em` also stays
   proportional to the LETTERING: a Text label at 3x gets a 3x halo instead of a hairline, and the
   clearance arithmetic in alignedLabelPlacement(), which is written in fractions of the font size,
   stays true at every text size rather than only at the shipped one.
   0.2em is 0.1em of white outside the glyph (~1.1px at the shipped 11px), a little tighter than the
   0.15-of-font-size pad the rect drew. Tighter on purpose: half the stroke falls INSIDE the glyph,
   where the fill paints over it everywhere except in a counter -- the enclosed hole of an e or an
   8 -- and a heavy halo closes those up. `stroke-linejoin: round` keeps a sharp corner from
   throwing a spike. */
.lpn-lbl {
	fill: #333; pointer-events: none; user-select: none;
	paint-order: stroke fill;
	stroke: #fff; stroke-width: 0.2em; stroke-linejoin: round;
}
/* pointer-events:none, found 2026-07-30 while verifying popup placement: labelsLayer draws ABOVE the
   symbol layers, so a leader line crossing a node swallowed the click meant for that node and the
   property popup simply never opened. A leader is not a thing you can click -- a label's own grab
   shape (.lpn-lbl-hit) is the drag/click target -- so it has no business in hit testing. (The extrema
   badge needed the same rule and no longer exists to need it: Task 333 made the mark a
   text-decoration inside the label's own text.) */
/* ONE SCREEN PIXEL, via --lpn-hair, not a fraction of the symbol size. A leader is a rule pointing
   at something, not a symbol, and scaling it off --lpn-sym made it 0.49px at the shipped symbol
   size and 0.14px if you turned symbols down -- which is why Tom reported "no leaders" while
   looking at them (2026-08-15). A leader that cannot be seen is worse than none: the label it
   belongs to then reads as floating free of the network. */
.lpn-leader { stroke: #000; stroke-width: var(--lpn-hair, 0); pointer-events: none; }
/* ---- CUSTOMERS: the meter and its service connector (ROADMAP Task 247) ----------------------
   THE STROKE WIDTH IS SET PER ELEMENT, NOT HERE, and that is the one thing about these two that
   is unlike every other symbol on this map. A meter is drawn to its real size (about 2 m across)
   while that is bigger than a legible dot and held at the dot beyond -- meterHalfWorld() in
   js/looped-network.js carries the rule and Tom's own figures -- so both its box and its
   connector carry a computed `stroke-width` attribute. A width declared here in --lpn-sym terms
   would be a second opinion about a size the script is already deciding.

   THE CONNECTOR IS NOT PICKABLE, exactly like a label's leader above it: a press on a service
   line means the meter, which is what a reader intends by pressing one. */
.lpn-service { stroke: #000; pointer-events: none; fill: none; }
.lpn-service-off { display: none; }
/* A SOLID DOT (Tom, 2026-09-17: "The map symbol is to be a solid dot."). It is drawn three to
   seven pixels across at the zooms this map is read at, where an outlined box is a grey smudge
   and a picture of a meter is nothing at all. The toolbar icon is the drawing; this is the mark. */
/* **AND IT SAYS `pointer`, BECAUSE IT IS AN OBJECT ON THE MAP** (Tom, 2026-09-18: *"Cursor does
   not change over customer editables."*). A customer wore no cursor rule at all, so the canvas's
   own `default` reached it and the one thing that separates an object from the map on this page --
   the glyph, since Task 659 -- was missing on the newest object here. `.lpn-node` and `.lpn-link`
   have said `pointer` since that task; this is the same sentence about the same kind of thing, and
   there is no third convention. The service connector needs none: it is `pointer-events: none`
   above, so the cursor over it is whatever is behind it. */
.lpn-meter { fill: #000; stroke: #000; cursor: pointer; }
/* A METER ATTACHED TO NOTHING LOOKS LIKE IT. Its demand is in no junction's total and therefore
   in no answer on the screen, so it is drawn hollow and dashed rather than merely drawn without a
   connector: an absent line is a thing you have to notice, an empty ring is a thing you can see. */
.lpn-meter-loose { fill: #fff; stroke-dasharray: 1.5 1.2; }
/* The preview dot of a half-placed meter, which is view state and not an element yet. The same
   red the rubber band it hangs off is, so the two read as one gesture in progress. */
.lpn-meter-pending { fill: none; stroke: #d32f2f; stroke-dasharray: 1.5 1.2; pointer-events: none; }
/* **A SERVICE CONNECTED EXACTLY TO A NODE USED TO SAY SO IN RED, AND THAT RULE IS RETIRED**
   (Task 247; Tom, 2026-09-25: "Red for node-connected Customers is a bad decision. Let's leave it
   black."). `.lpn-service-snapped` / `.lpn-meter-snapped` painted the connector and the dot the
   same red every gesture-in-progress mark on this map uses, so a SETTLED connection read as one
   still being made. A settled node connection now draws exactly like any other connected customer,
   in plain black; the Properties box, the Tables pane and Find name the junction in a "Connected
   to" row instead (renderCustomerFields(), paneCustomerCols() in js/looped-network.js). The two
   classes are gone rather than left inert, so they cannot be found and re-lit by mistake.
   `.lpn-meter-pending` above keeps its red: that is a gesture in progress, not a settled state. */
/* **A SELECTED CUSTOMER IS A HIGHLIGHTED DOT** (Tom, 2026-09-18: dragging one works "except that
   it's highlighted wrong (red circle instead of highlighted dot)"). It wore no mark of its own at
   all: the class was applied, and nothing here answered it, so the only thing that changed on
   screen when a customer was selected was the red ring appearing on its pipe -- which is what the
   ring was being read as. The selection blue every other selected thing on this map takes
   (.lpn-node.lpn-selected above), on the symbol itself.

   Two classes, so it outranks the loose white above it while a customer is selected, and that
   comes back the moment it is not. A customer connected to nothing keeps its dashes meanwhile,
   which is the half of that signal a reader is looking at anyway. */
.lpn-meter.lpn-selected { fill: #1976d2; stroke: #1976d2; }
/* Hover preview on a customer's own dot, the same weaker/greyer mark as the node/link/label rules
   above and for the same reason -- see the comment beside .lpn-node.lpn-hover. */
.lpn-meter.lpn-hover:not(.lpn-selected) { fill: var(--lpn-hover-color); stroke: var(--lpn-hover-color); }
/* THE CONNECTION GRIP: on the pipe, only while its customer is selected, and grabbable.

   **IT WAS RED, AND RED IS WHAT MADE IT UNREADABLE** (Tom, 2026-09-18: "The red circle at the pipe
   is confusing to me. It's not selectable."). Every other red mark on this map is a gesture in
   progress -- the rubber band, the pending meter dot -- so a permanent red ring beside a
   selected customer read as a warning about the drawing rather than as a control. It is a GRIP,
   and it takes the selection blue for the same reason the selected dot does: it exists because
   something is selected and it disappears with the selection.

   Hollow where the selected customer is solid, which is how the two grips tell themselves apart at
   a glance: the filled dot is the house and drags the house, the ring is the connection and drags
   the connection. */
/* **AND IT SAYS `pointer`, NOT `grab`** (Tom, 2026-09-18: *"drag is the wrong cursor for the
   connection point. That should be pointer to be true to the app paradigm."*). The open hand is
   this map's PANNING glyph and nothing else -- Task 569 took it off the bare canvas and Task 659
   gave `pointer` to every object -- so a hand on a control said "the map moves here", which is the
   one thing this grip does not do. Every other draggable thing on this page (a node, a vertex
   handle, a label) says `pointer` while it is draggable, so this is not a new rule; it is the grip
   rejoining the one that was already there. */
.lpn-custhandle { fill: #fff; stroke: #1976d2; cursor: pointer; }
/* A METER CARRIES NO LABEL ON THE MAP (Tom, 2026-09-17: "I don't think we want labels on
   customers. I didn't ask for them."). An account number used to be drawn beside every symbol as
   generated annotation; it is gone, and so is the rule that was here for it. The account number
   lives in the property box and in the Customers table, where it is read rather than glanced at. */
/* A LABEL THE USER HAS JUST PLACED SAYS SO, AND THEN STOPS SAYING IT (Tom, 2026-08-15, after
   discovering he had dragged a label without meaning to: "put a timed box or highlight on dragged
   labels for about a minute, maybe fading, maybe dashed, maybe animated so it's obviously
   temporary"). It reads as a notification and never as part of the drawing -- there is no button to
   press and nothing to clean up. */
/* **THE TEXT ITSELF CHANGES COLOUR. NO STROKE, NO BOX, NO NEW OBJECT** (Tom, 2026-08-15, after two
   wrong answers from me: "All we need is something simple like the leader or label changing color
   or blinking temporarily. No new object is needed.")
   He is right, and the simplification deletes the whole bug: a fill has no width, so there is no
   map-unit-versus-screen-pixel question to get wrong -- which is what produced a mark bigger than
   the screen and then a full-page orange starburst, from the same three lines, twice.
   RED, not the orange this started as (Tom: *"Red would be better. It looks orange or brown."*).
   Two quick blinks so it is noticed, then a fade that starts AT ONCE and runs the rest of the way.
   NO `forwards`: when the animation ends the property reverts to whatever the stylesheet says, so
   the label returns to its normal colour on its own and nothing has to clean up after it.

   **THE HOLD IS GONE, AND ITS ABSENCE IS THE POINT** (Tom, 2026-08-16: *"Start the long fade
   immediately. Start appears to be delayed through about half the duration."*). It held full red
   from 16% to 70% -- 24 of the 45 seconds -- and only then began to fade. A mark that does not
   change for half its life reads as a mark that is stuck, so the fade was doing none of the work it
   exists to do: saying "this is temporary" while you look at it.

   The fade segment is LINEAR while the blinks keep the default easing. A per-keyframe
   animation-timing-function governs the segment that STARTS at that keyframe, so this is the one
   place to say it. Linear matters here: eased, most of the colour change happens in the first few
   seconds and the tail is a long crawl through near-black, which is a hold by another name. */
.lpn-just-dragged { animation: lpn-just-dragged 45s ease-out; }
@keyframes lpn-just-dragged {
	0%   { fill: #d40000; }
	4%   { fill: #000; }
	8%   { fill: #d40000; }
	12%  { fill: #000; }
	16%  { fill: #d40000; animation-timing-function: linear; }
	100% { fill: #000; }
}
/* **THE SAME MARK, ON A NODE THAT MOVED** (Tom, 2026-09-01: *"we have no way of knowing when a
   junction moves. Some sort of a fading highlight like the labels have when they are moved would
   help. And this doesn't apply only to the phone, of course."*). ONE mechanism -- the same class,
   written by the same markJustDragged() -- with a second painting, because a dot is not a word.

   NOT `fill`, which is the label version's property and is wrong here twice over: the fill is where
   the colour ramp paints (paintNodeColor() writes it inline), so a 45-second red dot would be a
   pressure reading that is not true, and a reservoir's and a tank's own disc is `fill: none` with
   the symbol drawn over it, so a fill change would show nothing at all. A ring is what every other
   per-node condition on this map already draws -- selection, fire flow, an override, a pending link
   -- and it composes with the ramp instead of overwriting it.

   EVERY DECLARATION IS IN THE KEYFRAMES, NOTHING IN THE RULE, and that is deliberate: nothing ever
   removes the class, so a width or a paint-order declared in the rule would stay on the node for
   ever -- and it would then be the SELECTION ring, months later, that looked wrong. An animated
   property reverts the moment the animation ends. Same reason the label version has no `forwards`.

   `paint-order: stroke fill` is what makes it a HALO instead of a blot: a stroke is centred on the
   circle's edge, so half of a wide one is drawn over the dot. Painting the stroke first and the fill
   over it leaves only the outer half showing, and the node keeps its own colour -- the colour ramp,
   a reservoir's symbol, all of it -- while a red ring stands off it. `.lpn-lbl` uses the same
   property for the same reason, one paragraph up.

   3x, where every other ring on this map is 0.5x. Those say "this is selected", "this failed";
   this one has to be seen by somebody whose finger was ON it a second ago -- Tom, the same day:
   *"the blindness of tapping on a phone is dire."* A junction is drawn at radius 0.9, so a 3-wide
   ring stands 1.5 clear of it: about three times the dot, which is the point. `--lpn-sym` for the
   reason `.lpn-selected` uses it -- this map's user units are metres in one project and DEGREES in
   another, so a bare 3 is a mark bigger than the screen on a lat/lon map.

   Fading to a TRANSPARENT red rather than to black: the ring has to end at nothing, or the last
   frame of the fade would be a black ring that then vanishes when the animation lets go. */
.lpn-node.lpn-just-dragged { animation-name: lpn-just-moved-node; }
@keyframes lpn-just-moved-node {
	0%   { stroke: rgba(212, 0, 0, 1); stroke-width: calc(3 * var(--lpn-symf, 0)); paint-order: stroke fill; }
	4%   { stroke: rgba(212, 0, 0, 0); stroke-width: calc(3 * var(--lpn-symf, 0)); paint-order: stroke fill; }
	8%   { stroke: rgba(212, 0, 0, 1); stroke-width: calc(3 * var(--lpn-symf, 0)); paint-order: stroke fill; }
	12%  { stroke: rgba(212, 0, 0, 0); stroke-width: calc(3 * var(--lpn-symf, 0)); paint-order: stroke fill; }
	16%  { stroke: rgba(212, 0, 0, 1); stroke-width: calc(3 * var(--lpn-symf, 0)); paint-order: stroke fill; animation-timing-function: linear; }
	100% { stroke: rgba(212, 0, 0, 0); stroke-width: calc(3 * var(--lpn-symf, 0)); paint-order: stroke fill; }
}
/* BOTH of them, and the node one by name: `.lpn-node.lpn-just-dragged` above outranks the shorthand
   reset below, so a reader who honours prefers-reduced-motion would still get the blinking ring. */
@media (prefers-reduced-motion: reduce) {
	.lpn-just-dragged { animation: none; }
	.lpn-node.lpn-just-dragged { animation: none; }
}
/* The "you are in the middle of placing the background image" bar (ROADMAP Task 145 follow-up, Tom
   2026-08-16). Fixed to the bottom of the viewport rather than placed near the canvas: while a
   registration sequence runs, every normal interaction on the canvas is suppressed, so the message
   has to be somewhere the eye lands no matter where the user was clicking. High z-index for the same
   reason -- it must outrank the panels the sequence itself puts on screen.
   **DRESSED LIKE EVERY OTHER BOX ON THE PAGE** (Tom, 2026-10-04: *"It is in dark mode unlike
   everything else."*): the page background, the box ink, the one-pixel ink border and the box
   shadow #lpn_dialog and the Settings box wear. It was the one dark strip on a light page. */
.lpn-regmode-bar {
	position: fixed; left: 50%; transform: translateX(-50%); bottom: 1em;
	z-index: 3000; display: flex; align-items: center; gap: 0.75em;
	padding: 8px 12px;
	background: var(--ec-bg); color: var(--ec-ink); border: 1px solid var(--ec-ink);
	box-shadow: 2px 2px 6px var(--ec-a-0-0-0-3);
	/* max-content, as #lpn_dialog: placed at left: 50%, the bar otherwise shrank to the half of the
	   window to its right, and on a phone stood as a 195 px column over the map. */
	width: max-content; max-width: calc(100vw - 2em); box-sizing: border-box;
}
/* **ON A PHONE IT IS A BAND ALONG THE FOOT OF THE WINDOW** (Perry's review, 2026-10-04): full width
   less the gutters, small type, so the map above it, where the picking happens, stays clear. */
@media (max-width: 600px) {
	.lpn-regmode-bar {
		left: 8px; right: 8px; bottom: 8px; transform: none; width: auto; max-width: none;
		padding: 6px 8px; gap: 0.5em; font-size: 0.85em; line-height: 1.3;
	}
}
.lpn-regmode-bar button { cursor: pointer; }
@media print { .lpn-regmode-bar { display: none; } }
/* **A THING THE USER CANNOT SEE CANNOT BE GRABBED**, and this one keyword is what makes invisibility
   and un-grabbability the same fact instead of two facts that can disagree (Tom, 2026-09-01: *"a
   node label is present but not (yet?) visible, and when I go to pan, the label that I did not see
   drags off the screen."*).

   This rule said `pointer-events: all` and that is the whole defect. SVG's `pointer-events` has two
   families: `visiblePainted`/`visibleFill`/`visibleStroke`/`visible` require `visibility: visible`,
   while `painted`/`fill`/`stroke`/`all` deliberately IGNORE visibility. Four separate mechanisms
   hide a label -- `.lpn-labels-hidden` (thematic colouring and the georeferencing wizard),
   `.lpn-lbl-hidden` (a Text not in this scenario), `setLabelAssemblyHidden()`'s inline style (a node
   label dropped by the collision pass, a link label too short, crowded out or yielded) -- and every
   one of them left a fully grabbable, completely unpainted word lying on the map. MEASURED in
   Chromium: 100% of a hidden label's box still answered `elementsFromPoint()` under `all`, 0% under
   `visible`, and a SHOWN label's grabbable area is identical either way, so nothing is harder to
   pick up than it was.

   `visible` rather than `visiblePainted` because the two are not the same promise: `visiblePainted`
   would also drop the gaps between the glyphs, and a finger is not aimed at the inside of an "8".

   **AND THE PROMISE IS NOW KEPT BY A SHAPE RATHER THAN BY THE WORDS** (Tom, 2026-09-09: *"Label
   text answers hits up to 271 px: Yes! This is it! High priority task."*). An SVG text's hit
   geometry is laid out in its own LOCAL user units at LayoutUnit precision, and on a geographic
   project one of those is 132 screen pixels -- so `.lpn-draglbl` answered 271 px outside its own
   box, covered 58% of the canvas and blanketed every node band on it. `.lpn-lbl-hit` below is the
   target now, and `.lpn-lbl`'s own `pointer-events: none` is left to stand on the words. The gaps
   between the glyphs are still grabbable, because the shape is the label's own ROW BOXES. Full
   mechanism, and the three measurements that establish it is not the stroke width fixed the same
   day in `.lpn-node-hit`: syncLabelHit() in js/looped-network.js. */
/* **`pointer`, NOT `move`, AND THE REASON IS THE GLYPH'S SIZE** (Tom, 2026-09-09: *"a move cursor
   is being used to point at labels and assets. The problem with this is that the move cursor has a
   huge hit box. We need a cursor with an infinitesimal hitbox."*). A four-headed arrow is ~24 px of
   drawn cursor whose hot spot is at its centre, so on a map whose junction discs are 0.2% of the
   canvas the pointer covers the very thing it is aimed at. `pointer` and `crosshair` both aim from a
   1 px point and leave the target visible beside them.

   **THE CLASS IS WORN BY THE GRAB SHAPE AS WELL AS BY THE WORDS, AND THE CURSOR IS WHY.** A cursor
   is computed from whatever element answered the hit test, and since the words answer none it would
   be a dead declaration on the text alone. `.lpn-draglbl` says "this is a label you can take hold
   of", which is true of both halves; no `pointer-events` here at all, so `.lpn-lbl`'s own `none`
   stands on the text and `.lpn-lbl-hit`'s own value stands on the shape.

   **AND THE VALUE IS `default`** (Tom, 2026-09-09, on the corrected map: *"I prefer default over
   pointer at the labels and assets. It's more precise."*). An arrow tapers to nothing at its own hot
   spot; a finger's hot spot sits at the tip of a hand about 20 px across, which on this map covers
   the label it is aimed at. */
.lpn-draglbl { cursor: pointer; }
/* THE LABEL'S OWN GRAB SHAPE -- one transparent <path>, one subpath per ROW of the label, built and
   moved by syncLabelHit(). `visibleFill` for .lpn-node-hit's reason: `visible` would hit-test the
   stroke perimeter as well, which with no `stroke-width` declared is 1 USER UNIT, and on this map
   that is not one pixel. The `visible` prefix is what makes a label nobody can see un-grabbable. */
.lpn-lbl-hit { fill: transparent; stroke: none; pointer-events: visibleFill; }
/* Masking is switchable (Task 330, Tom 2026-08-14: "We want to be able to turn off and on
   background masking"). One class on the <svg>, written by applyMaskLabels() from the PROJECT's own
   setting -- masking is a property of how the sheet is meant to be read, not of the browser, so per
   Task 263 it is saved with the project like units and ID prefixes. Since Task 376 "off" is
   `stroke: none` on the text itself; there is no longer an element to hide. */
.lpn-masks-off .lpn-lbl { stroke: none; }
/* Pump curve entry inside the element property popup (Tom, 2026-07-30: "the table needs headings
   for Head and Flow"). Scoped rather than left to the suite's general results-table styling: this
   is a compact input grid inside a floating popup, not a page-width results table, so it must not
   stretch or inherit those borders. Headings left-aligned over their own input column; the row
   label column carries the point number. */
/* **THE TABLE AND ITS ROWS RESET THEIR OWN BORDER, NOT ONLY THEIR CELLS** (Task 553, Tom
   2026-08-28 of the demand editor: "There are two borders on the Demand Categories table, and it
   looks like a mistake."). It is: the suite-wide `table, th, tr, td { border: 1px solid blue }` at
   the top of this file names FOUR elements, and each of these editors reset only two of them. What
   he saw was the table's own blue frame outside the first row's blue rule -- two lines, 1px apart,
   round a compact input grid inside a floating popup.
   Written once for the two popup grids, whose own comments already claimed they "must not inherit
   those borders" and did. **`.lpn-pane-table` JOINED THE LIST ON 2026-09-19 and the note that kept
   it out is struck.** That note said nobody had called the blue wrong; Tom then did, in the same
   browser pass as the grip collision above: *"The blue border at the top is unbalanced by anything.
   Everything else is gray or black."* It was never a chosen colour -- it is the suite-wide default
   arriving at a grid whose own cells had been given #ddd, so one frame was blue and everything
   inside it was gray. */
.lpn-curve-table, .lpn-curve-table tr,
.lpn-demand-table, .lpn-demand-table tr,
.lpn-pane-table, .lpn-pane-table tr { border: 0; }
.lpn-curve-table { border-collapse: collapse; width: auto; margin: 2px 0; }
.lpn-curve-table th, .lpn-curve-table td { padding: 1px 4px 1px 0; text-align: left; font-weight: normal; border: 0; }
.lpn-curve-table thead th { font-weight: bold; }
.lpn-curve-table input { width: 7em; }
/* The demand-categories editor in the junction popup (ROADMAP Task 468). Same shape as the pump
   curve table directly above and for the same reason -- a compact input grid inside a floating
   popup, not a page-width results table -- so it borrows that table's rules and only sets what
   differs: three boxes of its own widths and a remove control.

   THE BOXES ARE NARROW ON PURPOSE. This table has three columns of input plus a button and the
   popup floats over the drawing, which on a phone is 360px wide; at these widths the row fits
   without the popup growing a horizontal scrollbar, and a demand of six significant figures still
   reads. The wrapper scrolls rather than the page if a translation makes a heading wider than its
   column -- a popup that pushes the map sideways is the failure to avoid. */
.lpn-demand-table { border-collapse: collapse; width: auto; margin: 2px 0; display: block; overflow-x: auto; }
.lpn-demand-table th, .lpn-demand-table td { padding: 1px 4px 1px 0; text-align: left; font-weight: normal; border: 0; }
.lpn-demand-table thead th { font-weight: bold; font-size: 0.9em; }
.lpn-demand-table input[type="number"] { width: 5em; }
.lpn-demand-table input[type="text"] { width: 7em; }
.lpn-demand-table select { max-width: 7em; }
/* THE METERS A JUNCTION CARRIES, LUMPED BEHIND ONE LINE (Tom, 2026-09-18). The summary is the
   count and the total, so it must read as one line of the box and not as a heading over an empty
   space; the table under it keeps the demand-table treatment it already had. */
.lpn-node-customers { margin: 2px 0; }
.lpn-node-customers-sum { cursor: pointer; }
.lpn-node-customers-count { font-weight: normal; }
/* A glyph, not a button: the same treatment the toolbar's icons get, so a row of data does not
   carry a row of push-button relief. It lights up under the pointer, which is where the "this is a
   control" signal belongs. */
.lpn-demand-del {
	background: none; border: 1px solid transparent; border-radius: 4px;
	font: inherit; line-height: 1; padding: 0 4px; cursor: pointer; color: var(--ec-error-ink);
}
.lpn-demand-del:hover { background: var(--ec-hue-fdd); border-color: var(--ec-hue-d99); }
.lpn-demand-add { font: inherit; margin: 2px 0; }
/* The Elevation field's DEM control in the node popup (Task 542). A button and, once it has been
   asked, one line saying what the terrain service reported — kept together so the sentence reads as
   belonging to the button rather than to the field below it. */
.lpn-elev-dem { margin: 2px 0 6px; }
.lpn-elev-dem button { font: inherit; }
.lpn-elev-dem .lpn-set-note { margin-top: 2px; }
/* **A TOOLBAR HOLDS ICONS, NOT BUTTONS** (Tom, 2026-08-20: "I have used the word 'Buttons'
   consistently. But in reality, toolbars have icons, not buttons. Change the style to be more like
   a normal toolbar; no 'button' paradigm."). So the chrome goes: no border, no background, no
   push-button relief until the pointer is on one -- which is how every toolbar the user already
   owns behaves, and what the menu bar directly above has always done (.lpn-menubar-item, whose
   hover colours these deliberately match: one strip of chrome, one treatment).

   THE LEADING CHECKMARK IS GONE WITH IT. `content: "\2713 "` in front of an icon shoved the
   drawing sideways and made a pressed tool a different width from an unpressed one; a toolbar says
   "this tool is active" by keeping the icon lit, so a pressed control is a filled well. That is
   not a reversal of Tom's checkmark: he asked for it when these were text buttons, and the mark he
   wanted was "a standard menu 'this is active' mark" -- which is what a menu ROW still gets.

   Bigger, too. An icon with a button around it is read as the button; an icon alone is the target,
   and 1.35em is the size at which a 24-unit drawing survives without one. */
#lpn_toolbar button:not(.lpn-transport-btn) {
	background: none; border: 1px solid transparent; border-radius: 4px;
	font: inherit; color: inherit; padding: 3px 5px; line-height: 1; cursor: pointer;
}
#lpn_toolbar button:not(.lpn-transport-btn):hover { background: var(--ec-hover-bg); border-color: var(--ec-hover-border); }
#lpn_toolbar button:not(.lpn-transport-btn)[aria-pressed="true"] { background: var(--ec-pressed-bg); border-color: var(--ec-pressed-border); }
#lpn_toolbar button:not(.lpn-transport-btn):disabled { opacity: .4; cursor: default; background: none; border-color: transparent; }
#lpn_toolbar button .ec-icon { width: 1.35em; height: 1.35em; vertical-align: -0.25em; }
/* **THE TRANSPORT IS THE ONE EXCEPTION, AND IT KEEPS ITS BUTTONS** (same message: "The Transport is
   the exception, of course; buttons are its paradigm"). Play, step back and step forward are the
   controls of a player, and a player's controls are buttons everywhere from a tape deck to a video
   site. They opt out by class rather than by position, so moving the group cannot silently restyle
   them. Calculate is NOT one of them -- it works the moments out rather than choosing which one you
   are looking at, which is the same reason it is not in the transport's own comment in
   js/lpn-time.js. */
/* **THE STRIP IS A REGION, AND ITS SECTIONS ARE DIVIDED** (Tom, 2026-08-21: "Don't toolbars
   usually have region dividers/borders? Top, sections, bottom as applicable?"). They do, and this
   one did not: a rule under the strip separates it from the tab strip and the map below, and a
   hairline between groups separates the sections from each other.

   This SUPERSEDES 2026-07-30's "a wider gap, not a line ... a divider line was more than needed",
   which was decided when every control on the strip was a word. Words separate themselves; a run
   of bare icons at one spacing does not, and 14 px of air between groups is not legible as a
   boundary when the icons inside a group are 6 px apart.

   Not on .lpn-toolbar-end: it is already pushed to the far edge by an auto margin, so a line
   beside it would mark a gap the eye has already read. */
#lpn_toolbar {
	display: flex; flex-wrap: wrap; align-items: center; row-gap: 4px;
	border-bottom: 1px solid var(--ec-gray-d6d6d6); padding-bottom: 4px; margin-bottom: 4px;
}
.lpn-toolbar-group { display: inline-flex; gap: 6px; margin-right: 12px; align-items: center; }
.lpn-toolbar-group + .lpn-toolbar-group:not(.lpn-toolbar-end) {
	border-left: 1px solid var(--ec-gray-d6d6d6); padding-left: 12px;
}
.lpn-transport-btn { font: inherit; cursor: pointer; padding: 2px 4px; }
/* **ONE PLAYER, NOT FIVE BOXES** (Ida, 2026-10-06, after Tom: "These controls are the largest thing on
   the chrome"). Measured: the player was the only set of boxed, filled controls on a strip of bare
   icons, so five boxes read as five objects. Joined edge to edge they read as one, 25 px narrower,
   still buttons ("buttons are its paradigm"). Siblings that are not transport buttons keep the
   group's ordinary 6 px spacing. */
#lpn_toolbar_run { gap: 0; }
#lpn_toolbar_run > * + * { margin-inline-start: 6px; }
#lpn_toolbar_run > .lpn-transport-btn { border-radius: 0; }
#lpn_toolbar_run > .lpn-transport-btn + .lpn-transport-btn { margin-inline-start: -1px; }
/* An icon-only button has no text for the icon's end margin to separate from; it only widens it. */
.lpn-transport-btn > .ec-icon { margin-inline-end: 0; }
/* Direction glyphs point along time, and time runs right to left in a right-to-left page, where the
   flex row has already put Restart at the right edge. Play and Pause are not direction-of-time marks. */
html[dir="rtl"] .lpn-transport-btn[data-icon="restart"] svg, html[dir="rtl"] .lpn-transport-btn[data-icon="step-back"] svg,
html[dir="rtl"] .lpn-transport-btn[data-icon="step-fwd"] svg, html[dir="rtl"] .lpn-transport-btn[data-icon="end"] svg { transform: scaleX(-1); }
/* The right-hand end of the strip: Find and the bottom-pane toggle (Task 434). Pushed there by an
   auto margin rather than placed at a fixed offset, so it stays at the edge at every window width
   and drops to its own line with the rest when the strip wraps. */
.lpn-toolbar-end { margin-left: auto; margin-right: 0; }
/* ---- Menu bar (ROADMAP Task 211) ----
   Above the toolbar, and holding every command on the page; the toolbar below is the high-use
   subset. Flat text buttons, because a menu bar that looks like a row of push-buttons reads as a
   second toolbar. */
/* **THE MOVABLE BOXES OUTRANK THE CHROME, AND THIS REVERSES A 2026-08-24 RULING ON PURPOSE.**
   That one was Tom's too -- "The menus and toolbars (level 1 menus) need a higher z-index than the
   boxes (Settings, Libraries, ...)" -- and it answered a real defect: a box dragged up the window
   swallowed the strip that had opened it, so the commands sat behind the thing they produced.
   **His ruling of 2026-09-02 is the opposite and supersedes it:** "The boxes that can drag up to the
   top of the page need to cover the menu icons and the project tabs. They need to cover everything...
   these boxes must be sky high, far above everything else, like zindex 1000."

   What changed in between is that a box no longer ARRIVES over the chrome by accident. It is dragged
   there, and it comes to the front when you touch it (raisePanel(), js/looped-network.js), so the box
   covering the menu is always the one you are holding. Do not restore the old order.

   The chrome keeps a z-index of its own so it still outranks the MAP and the two menu pop-ups can sit
   above it (31/32, set inline in Looped-Network.php, because a menu must cover the strip it hangs
   from). Everything movable now starts at 1200: above Bootstrap's fixed navbar (1030) and the toolbar
   (1080), below the registration bar (3000), which is deliberately over everything.
   `position: relative` is what makes z-index apply at all to a static block; it changes no layout. */
#lpn_menubar, #lpn_toolbar, #lpn_tabs { position: relative; z-index: 30; }
/* The floor for every box makePanelDraggable() wires. raisePanel() writes a higher number inline as
   boxes are opened and touched; this is only where the stack starts, and it is what puts an untouched
   box above the chrome. */
/* **THE WHOLE LADDER, IN ONE PLACE, because it was in five and that is how the tips got buried.**
   Everything below 1200 is page furniture; everything at or above it is something the user summoned.

     1200-1799  movable boxes. raisePanel() writes a number inline as they are opened and touched,
                and RENORMALISES at the ceiling -- a counter with no ceiling leaves nothing that can
                be reliably placed above the boxes, which is precisely how the tooltips were lost.
     1850/1851  the menu pop-up and its fly-out. Above the boxes: a menu must be readable even when
                a box has been dragged over the strip it hangs from.
     1860/1870  the modal dialog's backdrop and the dialog. Above the menus; modal means modal.
     1900       tooltips. Above everything summoned, which is Bootstrap's own order (its tooltip
                z-index outranks its modal) and is right: a "?" inside a dialog must be readable.
     3000       the registration bar, which outranks everything while a click sequence is running.

   These four ids carry their number INLINE in Looped-Network.php, not here. That was true of the
   boxes too until 2026-09-02, and it is why `.lpn-dragpanel { z-index: 1200 }` did nothing when it
   was first written: an inline style beats any stylesheet rule, so the boxes were still sitting at
   the 20/22/23 the markup gave them and only raisePanel()'s own inline write moved them. The boxes'
   inline values are gone now and this rule is their floor. */
.lpn-dragpanel { z-index: 1200; }
/* **AND THE TIPS GO ABOVE THE BOXES THEY BELONG TO** (Tom, 2026-09-02: *"Boxes are now in front of
   their own tips. Oops."*). Bootstrap paints a tooltip at its own $zindex-tooltip of 1080, which was
   above every box until the boxes moved to 1200 -- so a "?" inside a box explained itself behind it.
   1900 clears the panel band, which raisePanel() bounds at 1799 for exactly this reason: a counter
   with no ceiling leaves nothing that can be reliably placed on top of it. Still under the
   registration bar (3000). */
.tooltip { z-index: 1900; }
/* **A DIALOG MAY NOT GROW PAST THE WINDOW, AND ITS BUTTONS MAY NEVER LEAVE IT** (Tom, 2026-09-02:
   *"When the import message gets too long, the box runs off the screen and is not recoverable
   without a page reload."*). #lpn_dialog is `position: fixed; top: 20%` with no height bound, so a
   long import report -- and that report grows with how much of a file this page does not model,
   which is exactly the file a user most needs to read about -- pushed its own OK button below the
   viewport. Nothing scrolled: the page itself may not scroll (Task 432), the dialog had no
   overflow, and the button that dismisses it was the part that went missing. A modal you cannot
   dismiss is a lost session, which is why this is a defect and not a polish item.

   The BODY scrolls and the button bar does not, so the way out stays on screen no matter how long
   the message is. 60vh under a top of 20% leaves the buttons and the padding inside the window with
   room to spare, and a short dialog is unaffected because max-height only binds when it is
   exceeded. This is the same rule every other overlay here follows: wide and tall content scrolls
   inside its own container rather than moving the page. */
#lpn_dialog_body { max-height: 60vh; overflow-y: auto; overscroll-behavior: contain; }
/* **THE ONE QUESTION BOX** (Task 710, Tom 2026-10-04: *"The browser-style boxes aren't pretty. I
   think they all should be converted."*). Every former alert/confirm/prompt is askDialog() in
   js/looped-network.js, drawn in #lpn_dialog. A titled ask wears the same 40 px title band as every
   other box (.lpn-setbox-title), so the padding makes room for it. Narrower than the window on a
   phone in tall mode, never wider. The message keeps its own line breaks (consent paragraphs). */
/* `width: max-content` because a box placed at left: 50% only shrinks to fit the HALF of the window
   to its right: a long question on a phone was a 195 px column. Capped, it takes the room it needs. */
/* The padding lives HERE, not in the element's inline style: an inline `padding: 12px` outranked the
   titled rule below, and the title band lay over the first line of every question (Tom, 2026-10-04,
   Delete network). */
#lpn_dialog { width: max-content; max-width: min(34em, calc(100vw - 2em)); box-sizing: border-box; padding: 12px; }
#lpn_dialog.lpn-dialog-titled { padding-top: 51px; }
.lpn-dialog-msg { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.lpn-dialog-input { display: block; width: 100%; box-sizing: border-box; margin-top: 8px; padding: 3px 6px; font: inherit; }
textarea.lpn-dialog-input { min-height: 8em; font-family: monospace; }
/* **AND THE SUITE NAVBAR'S OWN DROPDOWNS RISE WHILE THEY ARE OPEN** (Tom, 2026-09-02: *"The
   English selector is behind the non-modal (non-hog) boxes."*). Every menu that hangs off that bar -- the language selector among them -- opened behind them. A child cannot climb out
   of its parent's stacking context, so the bar itself has to rise, and it may only do so WHILE a
   menu of its own is open: raising it permanently would put the suite chrome back over the boxes
   and reverse the ruling above. 1852 puts it just over the lpn menu fly-out at 1851, for the same
   reason that one is over the boxes -- a menu must be readable even when a box covers the strip it
   hangs from -- and still under the modal at 1860. `:has()` is what makes "while open" expressible
   in CSS at all; without it this needs a class toggled from JS on every dropdown in the suite.
   **`position: relative` is half the fix and the half that is easy to leave out:** this navbar
   carries no `fixed-top`, so it is statically positioned and a z-index on it does NOTHING on its
   own -- the same trap recorded above for #lpn_menubar. It changes no layout. */
nav.navbar:has(.dropdown-menu.show) { position: relative; z-index: 1852; }
#lpn_menubar { display: flex; gap: 2px; margin-bottom: 4px; }
/* **THE MENU-BAR ITEMS READ AS OUTLINED BUTTONS, in the suite's own accent blue** (#0645ad,
   already this page's one accent colour -- the profile HGL trace, the georeference tack, the
   current-cell outline in Tables all use it; nothing new is invented here). Tom, 2026-09-25,
   closing R-202/R-203: testers were slow to find the MENUS, never the toolbar; "promoting the
   menus and toolbar equally is counterproductive," and he asked for colour only -- no new icon,
   no size change, no layout change. So padding, gap and font are UNCHANGED from the flat-text
   rule above; only background, border and text colour move.

   **OUTLINED, NOT SOLID** (Tom, 2026-09-25, choosing between two previews: "I love the outlined
   version, and they are reminiscent of diazo prints (blueprints). I agree with leaving the
   toolbar black."). White fill, blue text and border -- the toolbar above stays black and gets no
   button paint of its own. `color: #0645ad` also repaints the icon, which draws with
   `stroke: currentColor` (see .ec-icon above) -- so the icon-only phone row (640px breakpoint,
   below) stays legible without a rule of its own. The three states only need to stay clearly
   distinct from each other, not fight for contrast against the white bar:
     default   #0645ad text/border on #fff  the accent blue as everywhere else on the page
     hover     #eaf1fc fill, #1f58b5 border/text -- "the pointer is here"
     open      #d7e6fb fill, #05378a border/text -- aria-expanded="true", set by
                         openMenu()/closeMenu() already, so no new JS wiring; the pressed/open
                         item reads as pushed IN, not lit up
   `border-radius` makes the rounded-rectangle Tom leaned toward; the border is the same colour as
   the text in every state (so the rule reads as one outlined shape, not a box with a stray ring),
   and both move together on hover and open. */
.lpn-menubar-item {
	background: var(--ec-bg); color: var(--ec-accent); border: 1px solid var(--ec-accent); border-radius: 5px;
	font: inherit; padding: 3px 10px; cursor: pointer;
}
.lpn-menubar-item:hover { background: var(--ec-accent-hover-bg); border-color: var(--ec-accent-hover); color: var(--ec-accent-hover); }
.lpn-menubar-item[aria-expanded="true"] { background: var(--ec-accent-open-bg); border-color: var(--ec-accent-open); color: var(--ec-accent-open); }
.lpn-menubar-item:focus-visible { outline: 2px solid var(--ec-accent); outline-offset: 2px; }
/* THE HOME MARK (Task 625). Now an ordinary .lpn-menubar-item button -- it became a MENU when Tom
   pointed out that the leftmost slot is the Apple-menu position and carries that expectation -- so
   it inherits the bar's own paint, hover and 640px collapse and needs none of its own. All that is
   left is a hair of space separating the product mark from the commands, without a rule: the bar
   deliberately has no dividers (see the note above #lpn_menubar). The anchor rules that used to be
   here went with the <a>. */
.lpn-menubar-mark { margin-right: 6px; }
/* The About box leads with the same mark that opened it (Task 625). Sized to the heading rather
   than to .ec-icon's 1.05em base, so the glyph and the name read as one lockup. */
.lpn-about-name { display: flex; align-items: center; gap: .45em; }
.lpn-about-name .ec-icon { width: 1.5em; height: 1.5em; flex: none; }
/* The colour mark is an <img>, not an inline glyph (Tom chose colour here, 2026-09-11). Sized to
   the heading like its mono predecessor; `flex: none` so a long site name cannot squash it. */
.lpn-about-name .lpn-about-mark { width: 1.5em; height: 1.5em; flex: none; }
.lpn-about-dedication a { color: inherit; text-decoration: underline dotted; text-underline-offset: .2em; }
.lpn-about-dedication a:hover, .lpn-about-dedication a:focus { text-decoration-style: solid; }
/* .lpn-menubar-item's own hover/open/focus rules moved up next to its base rule, above, when this
   branch (feat/menu-button) gave the menu bar its blue button paint -- kept together so the whole
   state set reads in one place instead of two. */
/* ---- Project tab strip (ROADMAP Task 211) ----
   Sits directly ON TOP OF THE MAP, below the toolbar (revised 2026-08-04 after seeing the first
   version rendered): with a real menu bar in the chrome above, the strip belongs against the thing
   it names, the way a PDF editor's document tabs and AutoCAD's layout tabs do. Tabs join the content
   below them the way tabs do everywhere: a bottom border on the strip, and the current tab punching
   a hole in it. */
#lpn_tabs {
	display: flex; align-items: flex-end; gap: 2px;
	border-bottom: 1px solid var(--ec-gray-999); margin-bottom: 6px; padding: 0 2px;
}
/* **overflow-y MUST BE STATED.** A box with `overflow-x: auto` and overflow-y left at `visible`
   computes the y axis to `auto` too (CSS Overflow 3), so the strip grew a vertical scrollbar at
   its right edge for content that is one row tall and can never overflow that way (Tom,
   2026-08-21). `hidden` is the fix; there is nothing above or below a tab to reach. */
.lpn-tabs-scroll { display: flex; align-items: flex-end; gap: 2px; overflow-x: auto; overflow-y: hidden; flex: 1 1 auto; }
.lpn-tab { display: inline-flex; align-items: stretch; background: var(--ec-gray-ececec); border: 1px solid var(--ec-gray-999); border-bottom: none; border-radius: 4px 4px 0 0; margin-bottom: -1px; }
/* The current tab is white and open at the bottom -- continuous with the page under it. */
.lpn-tab-current { background: var(--ec-bg); }
.lpn-tab-current .lpn-tab-name { font-weight: bold; }
.lpn-tab-name, .lpn-tab-caret, .lpn-tab-btn {
	background: none; border: 0; font: inherit; color: inherit; cursor: pointer; padding: 3px 8px; white-space: nowrap;
}
.lpn-tab-caret, .lpn-tab-x { padding: 3px 5px; border-left: 1px solid var(--ec-border-strong); background: none; border-top: 0; border-right: 0; border-bottom: 0; font: inherit; color: inherit; cursor: pointer; }
/* The [X] is on EVERY tab, not just the current one -- that is what the paradigm we are adopting
   does, and the whole return on adopting one is that nobody has to be taught it (Tom, 2026-08-04).
   Muted until hovered so a strip of tabs does not read as a row of close buttons. */
.lpn-tab-x { color: var(--ec-gray-777); }
.lpn-tab-x:hover { color: var(--ec-error-ink); background: var(--ec-hue-f4d4d4); }
.lpn-tab-btn { border: 1px solid transparent; align-self: center; }
.lpn-tab-name:hover, .lpn-tab-caret:hover, .lpn-tab-btn:hover { background: var(--ec-border); }
/* THE ASTERISK. Full strength means "there are changes this file does not have" -- something to go
   and do. Faded means "this project is in no file at all", a standing condition rather than a task.
   Both genuinely mean unsaved; only the salience differs (Tom, 2026-08-04). If the faded one reads
   as broken, delete the -faint rule and nothing else changes. */
.lpn-tab-star { font-weight: bold; margin-right: 2px; }
.lpn-tab-star-faint { font-weight: normal; opacity: 0.45; }
/* Narrow screens: only the CURRENT tab stays, and the vertical list behind the left-edge button is
   how you reach the others. A map page has no horizontal room to spare on a phone, and a strip that
   wraps to three lines above the map is worse than one button -- but hiding the current tab too
   would take away the one thing the strip exists to say, which is where you are. */
@media (max-width: 640px) {
	.lpn-tabs-scroll .lpn-tab:not(.lpn-tab-current) { display: none; }
}
.lpn-menu-row { display: block; width: 100%; text-align: start; background: none; border: 0; font: inherit; padding: 4px 12px 4px 8px; cursor: pointer; white-space: nowrap; }
.lpn-menu-row:hover:enabled { background: var(--ec-hover-bg); }
.lpn-menu-row:disabled { color: var(--ec-gray-999); cursor: default; }
/* Group label above the recent-file rows (Task 258). Indented to the icon column so it reads as a
   heading over those rows rather than as a row of its own that failed to get a glyph. */
.lpn-menu-heading { padding: 4px 12px 1px 8px; font-size: 0.8em; color: var(--ec-ink-subtle); white-space: nowrap; }
/* "There is more this way" on a row that opens a fly-out (Task 264). Pushed to the trailing edge so
   the arrow lines up down the menu regardless of label length; margin-inline-start, not left,
   because in the five RTL languages the fly-out opens on the other side. */
.lpn-menu-arrow { margin-inline-start: 1.5em; float: inline-end; color: var(--ec-ink-subtle); }
/* Reserved icon column for menu rows (Task 231). Fixed width and always present, even when the
   row has no icon, so one iconless row cannot ragged-edge the labels around it. */
.lpn-menu-icon { display: inline-block; width: 1.6em; text-align: center; }
/* The icon inherits the row's colour, so a disabled row greys its icon along with its word with
   no separate rule -- which is the whole reason these are stroked SVG and not colour emoji. */
/* Backdrop registration wizard (Task 146 Phase 2, ported from the spike): during a Scale/Position
   click sequence, regMode already suppresses real interaction in looped-network.js, but the
   per-element cursor:pointer/move rules would still fire on hover, visually implying "this is
   clickable/draggable" when it must be fully ignored. */
#lpn_canvas.regmode, #lpn_canvas.regmode * { cursor: crosshair !important; }
/* **PLACEMENT AND AREA PICKING AIM AT A COORDINATE, SO THEY SAY `crosshair`** (Tom, 2026-09-09:
   *"the placement and area picker (coordinates) a small crosshairs cursor. That gives us two
   precise pointers in the area of our network, both with an infinitesimally small (1px)
   hitbox."*). The three cursors this map now uses are one sentence each: the bare map is a hand
   because you may drag it, an object under the select tool is a `pointer` because pressing it acts
   on THAT object, and a tool that is about to put something at an (x, y) is a crosshair because the
   coordinate is what the gesture means.

   The `*` half is deliberate here and is not the trap `#lpn_canvas.lpn-panning *` was. That class
   was written by one handler and cleared by another, so a dropped drag left it on for ever and
   nothing on the map could say what it was; this one is written by setMode() alone, from the mode
   the toolbar is showing as pressed, and leaving the tool clears it. While a placement tool IS
   running, a `pointer` over a node would promise an edit the press is not going to perform -- the
   same argument .lpn-vertexmode already makes over links. No `!important` needed: (1,1,0) beats
   every object cursor's (0,1,0), which is what the regmode rule above predates. */
#lpn_canvas.lpn-placemode, #lpn_canvas.lpn-placemode * { cursor: crosshair; }
/* Node-mode target step: nodes ARE the valid click target here, so forcing crosshair over them
   removes the one affordance that actually matters. More specific than the rule above (extra
   class), so it wins the !important tie on specificity. */
#lpn_canvas.regmode.regmode-node .lpn-node, #lpn_canvas.regmode.regmode-node .lpn-node-hit { cursor: pointer !important; }
/* Popover dismiss control (Tom, 2026-07-31). Was a bottom "Close" button on all four lpn_
   popovers; in the Projects panel it sat directly beneath Open / Rename / Delete -- three buttons
   that all take a PROJECT as their object -- so "Close" read as "close the project" rather than
   "close this panel". A corner X carries no object at all, which is why it is the conventional
   answer, and the ambiguity was not really unique to that one panel.
   The translated word survives as title/aria-label, so the accessible name is still "Close" (and
   still translated) rather than a bare multiplication sign. Sized well past the ~44px touch-target
   guidance -- this page runs on phones, and a bare glyph at its natural type size would be a target
   only a few pixels across. #lpn_popup carries a matching 40px top padding so the button can never
   overlap its first row, which is what a smaller pad plus a full-size hit area would have done.
   ONLY #lpn_popup uses this now (Tom, 2026-08-13). Labels and Settings dropped their X and their
   40px pad: they hang under the toolbar button that opened them, so they are pull-downs, and a
   pull-down with a close button reads as a box that is pretending to be a menu. #lpn_popup is a
   real floating property sheet -- it opens at the point on the map you clicked -- so it keeps its X. */
.lpn-popover-x {
	position: absolute; top: 0; right: 0;
	width: 40px; height: 40px; line-height: 1;
	padding: 0; border: 0; background: transparent;
	font-size: 20px; color: var(--ec-ink); cursor: pointer;
}
.lpn-popover-x:hover, .lpn-popover-x:focus { background: var(--ec-gray-eee); color: var(--ec-ink-black); }
/* Popover fit-to-screen (Tom, 2026-07-31, testing on a phone: "everything right of the middle of
   the X is allowed to overflow off the right edge... I would have expected the right edge of the
   popup to align with the right edge of the screen"). The JS clamp in looped-network.js can only
   choose a LEFT edge; when a popover is wider than the viewport, every choice overflows and the
   clamp bottoms out at 4px, spilling the rest off-screen. Capping the width is what makes the clamp
   solvable -- it then lands at left:4px with the right edge 4px in from the far side, which is the
   alignment that was expected.
   Height is capped for the same reason and a worse symptom: a long panel (Settings, or a Projects
   list once a user owns several) taller than a phone screen would be cut off with no way to reach
   the rest -- the same "sized to content, not to screen" trap as the canvas height cap in
   applyMapHeight(). Scrolling lives on an inner body wrapper, not the popover itself, so the
   absolutely-positioned X stays pinned to the corner instead of scrolling away with the content.
   dvh repeats the vh rule for mobile browsers, where vh includes the retracting URL bar and
   overstates the room actually available. */
.lpn-popover { box-sizing: border-box; max-width: calc(100vw - 8px); max-height: calc(100vh - 8px); }
.lpn-popover-body { overflow: auto; max-height: calc(100vh - 56px); }
/* **AND THE PROPERTIES BOX RESIZES** (Tom, 2026-09-14: *"Element properties and group properties
   no longer resizeable."*). It is the same shell the Find and Settings boxes already wear, for the
   reason their own notes give: one set of box mechanics on this page rather than a second. This is
   Task 665's standard reaching the box a person opens most.

   **NO `width` HERE, WHICH IS THE DIFFERENCE FROM .lpn-findbox.** That box had to trade an inline
   `max-width` for a definite `width` because a cap cannot be dragged past. This one's only cap is
   `.lpn-popover`'s viewport bound, so it keeps sizing itself to whatever properties the element
   has -- a valve and a tank open at different widths and always did -- and a drag simply writes an
   inline size over that.

   `resize: both` needs a non-visible overflow to be drawn at all, and it costs nothing: the
   scrolling already lives on `.lpn-popover-body`, which takes the height a drag gave the box. The
   floor is a width, as it is on every other box here -- the property rows stop fitting first. */
.lpn-propbox { flex-direction: column; resize: both; overflow: hidden; min-width: min(17rem, 94vw); }
.lpn-propbox .lpn-popover-body { flex: 1 1 auto; min-height: 0; max-height: none; }
/* **PROPERTY AND CONDITION SHARE ONE LINE** (Tom, 2026-08-27, of the Find box on a phone). Two
   halves of one phrase -- "Diameter / greater than" -- each narrower than the sentence it belongs
   to, so pairing them costs nothing and buys back a whole line where lines are scarcest.
   `min-width: 0` is the load-bearing half: without it a flex item refuses to shrink below its
   content, and a long condition ("no open path to a source") would push the box wider than the
   phone instead of ellipsizing inside it. */
/* **AND FIND RESIZES** (Tom, 2026-09-04: *"Make lpn Find box resizeable."*). Same shell mechanics
   as the Settings box, for the reason that box's own note gives: one set of box mechanics on this
   page rather than a second. `resize: both` needs a non-visible overflow to be drawn at all, and it
   costs nothing here because the scrolling already lives on `.lpn-popover-body`.

   **THE 22rem MOVED OUT OF THE MARKUP AND BECAME A `width`, WHICH IS THE LOAD-BEARING CHANGE.** It
   was an INLINE `max-width`, and a cap cannot be dragged past. As a definite width it still opens at
   22rem, still refuses to be stretched by a long result -- which is what the cap was really for --
   and now gives way to a drag. resetPanelFill() hands the inline values back on every open, so
   nothing inline may state a size any more or a dragged box would be reset by its own reopening;
   toggleFindPopup() re-applies the remembered one instead, exactly as the Settings box does.

   The floor is a width for the same reason the Settings box's is: this box's own pull-downs stop
   fitting first, and everything below the floor is a sideways scrollbar. */
.lpn-findbox {
	width: min(22rem, calc(100vw - 8px));
	min-width: min(16rem, 94vw);
	flex-direction: column;
	resize: both;
	overflow: hidden;
}
.lpn-findbox .lpn-popover-body { flex: 1 1 auto; min-height: 0; max-height: none; }
.lpn-find-pair { display: flex; gap: 8px; align-items: flex-end; }
.lpn-find-pair > div { flex: 1 1 0; min-width: 0; }
.lpn-find-pair select { max-width: 100%; }
/* ONE ROW: the Find button, then the Filter in table button (ROADMAP Task 597; row layout, Tom
   2026-09-23; the table selector cut R-197, 2026-09-25). Both buttons keep their natural width. */
.lpn-find-filter { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin: 6px 0 0; }
/* The Find panel's query line (ROADMAP Task 540): the three pull-downs written out as one
   sentence, directly above the Find button -- and, since phase 2, the input that writes them back.
   Monospaced, because it is a query and its brackets and quotes have to be countable. Full width of
   the 22rem popover, so a query with an AND in it is readable without scrolling the box. */
.lpn-find-query {
	font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
	font-size: .85em;
	width: 100%;
	box-sizing: border-box;
	margin: 2px 0 2px;
}
/* Tom's own tip, under the input: it says the grammar is expandable. Quiet, because it is a
   standing note rather than an answer to anything the user just did. */
.lpn-find-hint {
	font-size: .8em;
	color: var(--ec-ink-subtle);
	margin: 0 0 4px;
}
/* A parse failure. It is the one message on this panel that means NOTHING WILL BE SEARCHED, so it
   is the one that is allowed to be loud. `display` is set from script -- empty means no error. */
.lpn-find-msg {
	font-size: .85em;
	color: var(--ec-error-ink);
	margin: 0 0 6px;
	overflow-wrap: break-word;
}
/* Where the three pull-downs used to be, when the typed query says more than they can. Indented and
   quiet: it explains an absence, and the query input under it is what the eye should go to. */
.lpn-find-aside {
	font-size: .85em;
	color: var(--ec-ink-muted);
	margin: 4px 0;
	overflow-wrap: break-word;
}
/* A STANDING BOX IS DRAGGABLE by its chrome -- the padded band around the body, which is the only
   part of it where the event target is the box element itself (see makePanelDraggable()). The
   cursor says so there, and the body puts it back, so a text input never shows a move cursor.
   THE STANDING BOXES ONLY. The menus share .lpn-popover but hang off a button and are dismissed by
   clicking away, so dragging one would be a gesture with nothing to return it. A box that stays put
   until it is closed is the kind that drags.
   `touch-action: none` is what lets a touch drag work at all -- without it the browser claims the
   gesture for scrolling before pointermove ever fires.

   **THE CLASS IS ADDED BY makePanelDraggable() ITSELF** (ROADMAP Task 562), so a box is made
   draggable and touch-draggable in ONE place and a seventh box cannot arrive half-wired. It was an
   id list until then, and the id list had drifted: six boxes were made draggable by script and
   only three were named here, so Find and the two fire-flow boxes could be dragged by a mouse and
   not by a finger -- and this comment named *Find* as one of its three while the selector beside it
   named *Library*. `dev/lpn-spike/panel-touch-harness.js` now asserts the two halves agree. */
.lpn-dragpanel { cursor: move; touch-action: none; }
/* **AND THE BODY TAKES ITS TOUCH GESTURES BACK, WHOLESALE** (2026-09-01). The rule above was
   written for the DRAG BAND -- the padded chrome, which is the only part of the box a drag can
   start on -- but it is written on the box, so it lands on everything inside it as well. The two
   Settings panes were given `touch-action` back by name, and the three other scrolling regions
   inside these boxes were not: the Find results, the fire flow report and its sideways-scrolling
   table, and the property popup's own body. The whole POINT of `.lpn-popover-body` is that it is
   the part that scrolls when there is more box than screen, so declaring it here rather than
   naming panes is the same move `.lpn-dragpanel` itself was: one seam, and a seventh box cannot
   arrive half-wired.
   `cursor: auto` on the same line, so a text input never shows a move cursor. */
.lpn-dragpanel .lpn-popover-body { cursor: auto; touch-action: auto; }
/* The two placement bars (File, Convert as and World map, Attach) are dragged by their step title
   only, so the rest of the bar keeps an ordinary cursor and its buttons their ordinary touch. */
.lpn-wizbar.lpn-dragpanel { cursor: auto; touch-action: auto; }
.lpn-wizbar .lpn-wizbar-grip { cursor: move; touch-action: none; user-select: none; }
/* The two Settings panes are nested scrollers INSIDE that body, so they say it for themselves --
   a nested scroll region is consulted on its own and is not covered by its ancestor's answer. */
.lpn-setbox-index, .lpn-setbox-content { touch-action: auto; }
/* ...and so are the fire flow report and the sideways scroll its ten-column table lives in. */
.lpn-ff-report, .lpn-ff-tablewrap { touch-action: auto; }
@supports (height: 100dvh) {
	.lpn-popover { max-height: calc(100dvh - 8px); }
	.lpn-popover-body { max-height: calc(100dvh - 56px); }
}

/* Task 244 -- the "Libre Software" mark beside the brand.

   It sits OUTSIDE .navbar-collapse (see lib/Menus.lib.php) so it never hides behind the
   hamburger. Tom, 2026-08-09: it should read "as an extension of the HawsEDC Calculators {}
   Libre Software, almost as one string" -- so it is styled as a quieter continuation of the
   brand, not as a nav destination. Deliberately NOT .nav-link: nav links carry Bootstrap's
   nav padding and hover affordance, which is what made it read as one menu item among many.

   NO SEPARATOR AND NO GAP -- Tom's second browser review, 2026-08-09, marked both on a
   screenshot: "I think A should be removed. And I think that there is no need for gap B.
   Can't they be like one string, separated only by a space?" A was a "|" glyph this rule used
   to draw; B was Bootstrap's own .navbar-brand margin-right: 1rem, which is why zeroing our
   own margin alone would not have closed it. Both are gone, leaving a single space, so the
   name and the claim read as one continuous phrase. They stay distinguishable by colour and
   by the GitHub mark rather than by punctuation.

   margin-inline-start rather than margin-left because 5 of the 27 languages are RTL -- the
   mark must land on the trailing side of the brand in both directions. */
/* The brand and the claim are ONE flex item. .navbar is display:flex with
   justify-content:space-between, so as three siblings (brand, link, hamburger) the free space
   was distributed BETWEEN them and the pair drifted apart as the window narrowed -- exactly
   backwards. As one item there is no gap to grow. align-items keeps the two baselines together
   now that the wrapper, not the navbar, is doing the aligning. */
.ec-brandgroup { display: inline-flex; align-items: baseline; min-width: 0; }
.ec-brandgroup > .navbar-brand { margin-inline-end: 0; }
.ec-nav-libre {
	display: inline-flex;
	align-items: center;
	margin-inline-start: 0.4rem;
	font-size: 0.85rem;
	white-space: nowrap;
	text-decoration: none;
	color: var(--ec-hue-5a6570);
}
.ec-nav-libre:hover, .ec-nav-libre:focus { color: var(--ec-brand); text-decoration: underline; }
.ec-nav-libre > .ec-icon { margin-inline-end: 0.3em; }

/* Narrow phones: the brand plus a long translation (bg "Свободен софтуер", id "Perangkat
   Lunak Bebas") plus the hamburger will not co-exist on one line. Drop the word and keep the
   GitHub mark, which is the part that makes the claim checkable; the full wording is still on
   About.php. The link keeps an accessible name because the icon is aria-hidden by design, so
   the text is hidden visually rather than removed from the accessibility tree. */
@media (max-width: 400px) {
	.ec-nav-libre { font-size: 0; }
	/* .ec-icon sizes itself in em, so the font-size: 0 above would collapse the mark to
	   nothing along with the word. Re-assert it in rem, which the parent cannot zero. */
	.ec-nav-libre > .ec-icon { width: 1.15rem; height: 1.15rem; margin-inline-end: 0; }
}

/* NARROW DESKTOP: the band just above the hamburger breakpoint (Tom, 2026-08-10, on a
   Looped-Network screenshot -- "Libre Software and the Hydraulics menu overlap each other, and
   HawsEDC Calculators; the Copy link wrap looks strange").

   Bootstrap's .navbar-expand-lg pins the expanded bar to `flex-wrap: nowrap`, so from 992px up
   to roughly 1150px -- where the brand group, both dropdowns, the Save-this-calculation form and
   the language picker together want more room than there is -- NOTHING yields. Flex shrinks the
   items instead, and because every one of them is white-space:nowrap text, they shrink their
   BOXES without shrinking their content: the words spill out and print on top of each other.
   Below 992px the problem disappears on its own, because the hamburger takes the menus away.

   The fix is to let the row wrap rather than collide. Wrapping is decided before shrinking, so
   whichever piece no longer fits drops to a second line at full width instead of being squeezed
   -- which also cures the Copy link button, whose icon and word were being crushed onto two
   lines by the same squeeze. Costs a little navbar height inside that ~150px band and nothing
   at all outside it. Deliberately NOT solved by truncating the brand: "HawsEDC Calculators" is
   the site's own name, and an ellipsis in it is worse than a taller bar. */
@media (min-width: 992px) {
	.navbar-expand-lg { flex-wrap: wrap; }
	.navbar-expand-lg > .navbar-collapse { flex-wrap: wrap; row-gap: 0.25rem; }
}
/* Belt for the same collision inside the brand group itself: it carries min-width: 0 (so the
   navbar may shrink it), which means its own two nowrap children would otherwise spill out of
   it. Wrapping puts "Libre Software" on a line under the brand instead. */
.ec-brandgroup { flex-wrap: wrap; }
/* The icon and the word are one label, never a two-line stack (Tom's mark C). */
#ec-copy-link-btn { white-space: nowrap; }

/* ============================================================================
   Consent banner and the legal footer links (ROADMAP Task 286)
   ============================================================================
   The one rule here that is a legal constraint rather than a taste call:
   .ec-consent-btn styles BOTH answers identically. Accepting and refusing must
   be equally easy and equally prominent -- same size, same weight, same colour,
   same row. A coloured "Accept" beside a grey "Reject" is the exact dark
   pattern this design exists to avoid, so never give one of them its own rule. */
.ec-consent {
	position: fixed;
	inset-inline: 0;
	bottom: 0;
	z-index: 1080; /* over Bootstrap's fixed navbar (1030) and its dropdowns (1000) */
	background: var(--ec-bg);
	border-top: 2px solid var(--ec-brand);
	box-shadow: 0 -2px 10px var(--ec-a-0-0-0-2);
	padding: 0.75rem 1rem;
	max-height: 70vh;
	overflow-y: auto;
}
/* Bootstrap's reboot gives [hidden] display:none, but the rule above sets no display, so this
   is belt and braces against a future display: flex here silently un-hiding the banner. */
.ec-consent[hidden] { display: none; }
.ec-consent-inner { max-width: 55rem; margin: 0 auto; }
.ec-consent-body { margin: 0 0 0.5rem; }
.ec-consent-current { margin: 0 0 0.5rem; font-style: italic; }
.ec-consent-current:empty { display: none; }
.ec-consent-actions {
	display: flex;
	flex-wrap: wrap;
	gap: 0.5rem;
	margin: 0 0 0.5rem;
}
.ec-consent-btn {
	flex: 1 1 12rem; /* equal share of the row, so neither answer is the bigger target */
	padding: 0.5rem 1rem;
	font: inherit;
	color: var(--ec-bg);
	background: var(--ec-brand);
	border: 1px solid var(--ec-brand);
	border-radius: 0.25rem;
	cursor: pointer;
}
.ec-consent-btn:hover, .ec-consent-btn:focus { background: var(--ec-brand-hover); border-color: var(--ec-brand-hover); }
.ec-consent-links, .ec-legal-links { margin: 0; font-size: 0.9rem; }
.ec-consent-links a, .ec-legal-links a { margin-inline-end: 1rem; }

/* The two English-authoritative legal pages (ROADMAP Task 286). Long prose, not a calculator:
   a reading measure rather than the full window width, and tables that scroll rather than
   squeeze on a phone. */
.ec-legal { max-width: 44rem; }
.ec-legal h2 { font-size: 1.25rem; margin-top: 1.5rem; }
.ec-legal h3 { font-size: 1.05rem; margin-top: 1rem; }
.ec-legal-table { display: block; overflow-x: auto; border-collapse: collapse; margin: 0.5rem 0 1rem; }
.ec-legal-table th, .ec-legal-table td { border: 1px solid var(--ec-border-strong); padding: 0.3rem 0.5rem; text-align: start; vertical-align: top; }

/* ---- The examples gallery (ROADMAP Task 314) --------------------------------------------------
   Sits over the map canvas when there is nothing drawn, and on demand from File > Open example.

   THE WRAPPER (#lpn_empty_hint) STAYS pointer-events:none AND THE PANEL TAKES THEM BACK. That is
   what lets the canvas behind the gallery still be panned in the gaps between the cards, so the
   page reads as a map tool at a glance rather than as a dialog that happens to be full-bleed --
   Tom's "usable middle": the canvas may be VISIBLE behind the gallery, empty, with no project. */
.lpn-examples {
	pointer-events: auto;
	max-width: 68rem;
	margin: 0 auto;
	padding: 1rem 1rem 1.5rem;
	text-align: center;
}
/* The welcome line above the gallery heading (Task 222). Quieter than the heading it sits over:
   the instruction is what the visitor has to act on, the greeting is what they read on the way. */
.lpn-examples-welcome { margin: 0 0 .35em; font-size: 1.05em; }
.lpn-examples-h { margin: 0 0 .15em; font-size: 1.35em; font-weight: 600; }
.lpn-examples-sub { margin: 0 0 .5em; opacity: .75; font-size: .95em; }
.lpn-examples-msg { margin: 3em auto; max-width: 30em; opacity: .7; }
/* ONE ROW WHEN THERE IS ROOM, wrapping down when there is not (Tom, 2026-08-14: "There is no
   guarantee that the examples will all fit... Wrapping degrades gracefully. So it might be better
   to put them all in one row initially").

   auto-fit with a small min is what buys both at once: at full width the six cards share one row,
   and on a narrower window the column count simply drops. The min is deliberately small enough
   that the CURRENT count fits across -- if the shelf grows past what one row can hold, this wraps
   to two rather than shrinking cards to illegibility, which is the right failure. Sub-categories
   and paging (in Tom's picture, not needed yet) drop straight into this without a rewrite. */
.lpn-examples-grid {
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
	gap: .7rem;
}
.lpn-example-card {
	display: flex;
	flex-direction: column;
	align-items: stretch;
	gap: .25em;
	padding: .5rem .5rem .6rem;
	border: 1px solid var(--ec-a-128-128-128-45);
	border-radius: 6px;
	background: var(--ec-a-255-255-255-94);
	color: var(--ec-ink-strong);
	font: inherit;
	text-align: left;
	cursor: pointer;
}
.lpn-example-card:hover, .lpn-example-card:focus-visible {
	border-color: var(--ec-hue-2a6ebb);
	box-shadow: 0 1px 6px var(--ec-a-0-0-0-18);
}
/* **THE DRAWING SITS ON WHITE PAPER, IN BOTH THEMES** (Tom, 2026-08-14: "The examples all have
   very dark backgrounds. It would be better for them to have white backgrounds with a nice
   border"). The thumbnail SVG has no background of its own, so before this it inherited the
   card's -- which the dark-theme rule below turned near-black, and a water network rendered white
   on black does not read as a drawing at all. A plan is a thing on paper; giving the thumbnail its
   own white ground and its own border makes that explicit instead of leaving it at the mercy of
   whatever is behind it. The ink is set here too, so `currentColor` inside the SVG resolves
   against the white rather than against the card. */
.lpn-example-thumb {
	display: block;
	width: 100%;
	height: 6rem;
	object-fit: contain;
	margin-bottom: .15em;
	padding: 2px;
	box-sizing: border-box;
	background: var(--ec-bg);
	border: 1px solid var(--ec-a-128-128-128-4);
	border-radius: 3px;
	color: var(--ec-hue-17406b);
}
.lpn-example-title { font-weight: 600; font-size: .92em; }
.lpn-example-desc { font-size: .8em; opacity: .8; line-height: 1.3; }
.lpn-example-meta { font-size: .75em; opacity: .6; }
/* Above the grid, not below it -- see renderExamplesGallery(). */
.lpn-examples-blank {
	display: inline-block;
	margin: 0 0 .9em;
	border: 0;
	background: none;
	padding: .3em;
	font: inherit;
	font-size: .9em;
	color: var(--ec-hue-17406b);
	text-decoration: underline;
	cursor: pointer;
}
@media (prefers-color-scheme: dark) {
	.lpn-example-card { background: rgba(38, 38, 38, .94); color: #eee; }
	.lpn-examples-blank { color: #8ab4e8; }
	/* The thumbnail keeps its white paper and its dark ink -- deliberately NOT themed. */
}
/* Printing a network should print the network, never the shop window. */
@media print { #lpn_empty_hint { display: none !important; } }
/* The gallery's legal row -- this page has no footer, so Privacy/Terms/Cookie settings sit here and
   in the Help menu (ROADMAP Task 314). Same position epanet-js uses in its own arrival panel. */
.lpn-examples-legal { margin: 1.1em 0 0; font-size: .78em; opacity: .7; }
.lpn-examples-legal a { margin: 0 .5em; }

/* ==== BEGIN: colour by value (ROADMAP Task 384) and the thematic map (Task 327) ==============
   Appended as one block on purpose -- nothing above is reformatted, and the whole feature can be
   read (or removed) in one place.

   The element colours themselves are NOT here. They are written as inline styles by
   refreshValueColors() in js/looped-network.js, because a value-driven colour is data, not a rule:
   an inline style is what the stylesheet's own black loses to, and clearing it restores that black
   exactly rather than needing a second rule to undo the first. */

/* TASK 327's THEMATIC MODE HAS NO RULE HERE ANY MORE, and that is the fix for Task 428.
   It used to be `.lpn-thematic .lpn-lbl { display: none }` -- and `.lpn-lbl` is worn by generated
   labels AND by the Text the user placed, so a selector could not tell authored content from
   annotation and the mode silently deleted the user's own notes from view. Tom, 2026-08-18:
   *"Turning off Text on 'no labels' is unexpected."*
   Suppression now goes through applyLabelVisibility() in js/looped-network.js, the ONE seam, which
   hides `.lpn-annotation` -- a membership annotationEl() declares at the build site, where the
   difference is actually known. Do not reinstate a rule here; a second path is the whole defect.
   What survives unchanged: the mode is still reversible because labelSettings is never written,
   and flow arrows still stay, direction not being a number. */

/* The colour key. Its own overlay, created by colorLegendEl() in js/looped-network.js and placed
   by applyColorLegendPosition() from settings.colorLegendPosition -- so top/bottom/left/right are
   deliberately absent here, exactly as they are for #lpn_labels_legend. Two legends need two
   corners; defaulting this one to the opposite corner from the labels legend is why.

   z-index:3, the SAME NUMBER #lpn_labels_legend carries inline in Looped-Network.php and for the
   same reason: this legend can land in the top-left corner too (a user's own choice of
   colorLegendPosition), and it must lose to the message-log column there exactly as the labels
   legend does -- see that div's own comment for the full stacking order. */
.lpn-color-legend {
	position: absolute;
	z-index: 3;
	font-size: 0.9em;
	line-height: 1.4;
	background: rgba(255, 255, 255, .85);
	padding: 4px 8px;
	pointer-events: none;
}
/* A swatch has to survive being white or very light on white paper, so it carries a hairline
   border of its own -- otherwise the top band of the gray ramp is an invisible row. */
.lpn-color-swatch {
	display: inline-block;
	flex: 0 0 auto;
	width: 1.4em;
	height: 0.85em;
	border: 1px solid rgba(0, 0, 0, .45);
}
/* The contour plot's support line under the node block (Task 600): what the colour between the
   nodes stands on. Narrow and small, because it is a sentence in a corner overlay. */
.lpn-contour-note {
	max-width: 18em;
	font-size: 0.85em;
	line-height: 1.3;
	margin-top: 0.3em;
	white-space: normal;
}
/* THE CONTOUR PLOT'S INK (Task 600): map symbology on the always-white paper, so literal colours by
   the allow-list's own rule. Lines are hairlines that stay one pixel at every zoom, every fifth level
   heavier (the index contour of a topographic map); labels carry a white halo so they read over any
   fill and any line; a wall at a pump or valve is a heavy dark stroke on a white casing, the
   retaining wall of a grading plan. A label's stroke width and font size are attributes in drawing
   units, set per zoom, so no rule here may name them. */
.lpn-contour-line {
	fill: none; stroke: #262626; stroke-opacity: .5; stroke-width: 1px;
	vector-effect: non-scaling-stroke; stroke-linejoin: round; stroke-linecap: round;
}
.lpn-contour-line.lpn-contour-index { stroke-opacity: .8; stroke-width: 1.6px; }
.lpn-contour-label {
	fill: #1c1c1c; stroke: #fff; stroke-opacity: .9; paint-order: stroke; stroke-linejoin: round;
	font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-weight: 600;
}
.lpn-contour-break-casing {
	fill: none; stroke: #fff; stroke-opacity: .9; stroke-width: 7px; stroke-linecap: round;
	vector-effect: non-scaling-stroke;
}
.lpn-contour-break {
	fill: none; stroke: #1c1c1c; stroke-width: 3.5px; stroke-linecap: round;
	vector-effect: non-scaling-stroke;
}
/* The contour box: the Find box's shell, a little wider for a name column and a number with its
   unit. */
.lpn-contourbox { width: min(24rem, calc(100vw - 8px)); --lpn-set-name: 8.5rem; --lpn-set-col: 13rem; }
.lpn-contourbox .lpn-set-row { margin: 7px 0; }
.lpn-contour-ctl { display: flex; gap: 4px; align-items: baseline; flex-wrap: wrap; min-width: 0; }
/* The colour scheme button draws its swatches absolutely, so as a flex item it has no width of its
   own to offer: it takes the row. */
.lpn-contour-ctl > .lpn-ramp-picker { flex: 1 1 8rem; min-width: 8rem; }
.lpn-contour-num { width: 5em; }
.lpn-contour-unit { font-size: .85em; opacity: .8; }
/* ==== END: colour by value ================================================================== */

/* The units strip is TWO GROUPS since Task 422: what you enter, and how answers are read. The
   heading is what makes the split legible, so it is quiet but not decorative -- without it the row
   reads as one long list with three quantities inexplicably repeated.

   EACH SELECTOR IS TWO LINES (Task 424, Tom twice): the name above, the control below. Side by
   side, a pair is as wide as its two halves added together and eleven of them made the widest
   thing in the Settings box; stacked, a pair is as wide as its LONGER half, and the strip wraps
   into two or three tidy rows in a box of sensible width. `align-items: end` so a name that wraps
   to two lines still leaves its select on the row's baseline with its neighbours. */
.lpn-units-group { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 4px 10px; }
.lpn-units-head { flex: 0 0 100%; font-weight: 600; opacity: .8; }
.lpn-units-item { display: flex; flex-direction: column; align-items: stretch; }
/* **THIS IS THE TREATMENT THE WHOLE BOX FOLLOWS, NOT AN EXCEPTION TO IT** (Tom, 2026-08-19: "the
   units labels are not uniform with the rest of the box. I like them (and I think they are great
   for saving width), but everything needs to be uniformly designed. Audit all and apply uniform
   styling", and 2026-08-18: "I like the styling of the Input units section"). So this rule is the
   REFERENCE: .lpn-set-row's own name below is written to match it, rather than this being pulled up
   to match the rows. */
.lpn-units-name { font-size: .85em; opacity: .8; line-height: 1.2; }
/* A select is as wide as its widest option unless told otherwise, and one long unit name would
   otherwise set the width of the whole column. It is the same 9rem the setting rows give a control,
   so the strip and the rows are one design. */
.lpn-units-item > select { max-width: 9rem; }
/* The derived Map coordinates line (Task 693): read, not chosen, so it is plain text at a select's height. */
.lpn-units-derived { max-width: 9rem; padding: 3px 2px; min-height: 1.6em; opacity: .8; }

/* ==== The profile view (ROADMAP Task 409) ====================================================
   A chart, not a map: it shares no symbol, scale or colour rule with the drawing. The suite's
   sketch convention applies all the same -- black for what is built, blue for water -- so the
   ground is a black line, the hydraulic grade line is blue, and the pressure between them is the
   same blue at low opacity. No shading, no borders beyond the axis frame, no dimension lines. */
/* The chart FILLS ITS TAB, IN BOTH AXES. Tom, 2026-08-18: "It should use the entire bottom pane,
   and its relative height and width should vary to fill the available height and width."

   **A FIXED viewBox CANNOT DO THAT and it is why the chart sat in a lake of white.** The viewBox
   was a fixed 560x340 and preserveAspectRatio's default ("xMidYMid meet") letterboxes it inside
   whatever box it is given -- so a wide pane got margins left and right and a tall one got them
   above and below, both of them growing as the pane grew. Stretching instead ("none") is worse: it
   distorts the text and the line weights.

   So the viewBox is MEASURED from the host and rebuilt at that aspect ratio (renderProfile()), and
   a ResizeObserver redraws it when the pane is dragged. The CSS's only job is to make the host a
   real box for that measurement to find. */
.lpn-profile-svg { display: block; width: 100%; height: 100%; }
.lpn-profile-frame { fill: none; stroke: var(--ec-ink); stroke-width: 1; }
.lpn-profile-grid { stroke: var(--ec-border); stroke-width: 1; }
.lpn-profile-axis { stroke: var(--ec-ink); stroke-width: 1; }
/* The vertical hairline at each node. Lighter than a gridline: it says WHERE a node is without
   competing with the two lines the drawing is about. */
.lpn-profile-station { stroke: var(--ec-gray-eee); stroke-width: 1; }
.lpn-profile-ground { fill: none; stroke: var(--ec-ink-black); stroke-width: 1.5; }
.lpn-profile-hgl { fill: none; stroke: var(--ec-accent); stroke-width: 1.5; }
.lpn-profile-band { fill: var(--ec-accent); fill-opacity: .12; stroke: none; }
.lpn-profile-dot { fill: var(--ec-ink-black); }
.lpn-profile-tick { font-size: 10px; fill: var(--ec-ink); }
.lpn-profile-nodeid { font-size: 10px; fill: var(--ec-ink); }
.lpn-profile-axistitle { font-size: 11px; fill: var(--ec-ink); }
/* The key, and the waypoint chips. Both are ordinary flow content under the chart. */
.lpn-profile-key { display: flex; flex-wrap: wrap; gap: .1em 1em; font-size: .85em; margin: .2em 0; }
.lpn-profile-key i { display: inline-block; width: 1.4em; height: .6em; margin-right: .35em; vertical-align: middle; }
.lpn-profile-key-ground { border-top: 2px solid var(--ec-ink-black); }
.lpn-profile-key-hgl { border-top: 2px solid var(--ec-accent); }
.lpn-profile-key-band { background: var(--ec-a-6-69-173-12); border: 1px solid var(--ec-a-6-69-173-35); }
.lpn-profile-heading { font-weight: bold; margin-bottom: .2em; }

/* ==== The bottom pane (ROADMAP Task 434) =====================================================
   One resizable panel under the map, carrying a tab per thing that is read while the map is
   edited. It is IN NORMAL FLOW below the canvas, which is the whole mechanism: applyMapHeight()
   measures `body.bottom - svg.bottom`, so the pane's height is subtracted from the map's by
   measurement and neither one has to be told about the other.

   `flex: column` with the body carrying the only explicit height -- the grip and the tab strip
   are whatever they need, and JS writes one number in one place. */
.lpn-pane { display: flex; flex-direction: column; border-top: 1px solid var(--ec-border-strong); background: var(--ec-bg); }
/* The drag handle IS the top edge, which is where a hand aims. Tall enough to hit with a mouse
   and a real amount of pointer slop; the ruled line inside says "grab me" without a glyph. */
.lpn-pane-grip {
	height: 8px; cursor: row-resize; background: var(--ec-gray-f2f2f2);
	border-bottom: 1px solid var(--ec-gray-e0e0e0); touch-action: none;
}
.lpn-pane-grip::after {
	content: ""; display: block; width: 3rem; height: 2px; margin: 3px auto 0;
	background: var(--ec-gray-bbb);
}
.lpn-pane-grip:hover { background: var(--ec-gray-e8e8e8); }
/* SEVEN TABS, AND THEY WRAP (Task 455). Tom chose wrapping over hiding five behind a type
   selector, so the strip is allowed to become two lines and the X stays pinned to the top-right
   rather than stretching down beside them. The pane's own height accounting reads the chrome by
   MEASUREMENT (paneChromeHeight), so a second line is subtracted from the window like anything
   else -- nothing here assumes one line, and nothing here scrolls sideways. */
.lpn-pane-head { display: flex; align-items: flex-start; gap: 4px; border-bottom: 1px solid var(--ec-border); }
.lpn-pane-strip {
	display: flex; flex-wrap: wrap; align-items: flex-start; gap: 2px 2px;
	flex: 1 1 auto; min-width: 0;
}
/* `display: contents` IS THE WHOLE OF TASK 488. The Print button and the seven tabs must wrap
   as ONE row of items, or the button holds a column and the tabs wrap only within the
   remainder -- which is what Tom saw: "it monopolises a column ... and the table tabs cannot
   wrap past it". Dissolving the tablist's box puts its tabs directly into .lpn-pane-strip's
   flow beside the button, so a second line starts at the pane's left edge and uses its full
   width. The ELEMENT stays, so role="tablist" still owns the tabs and nothing else -- which is
   why the button was not simply appended inside it. */
.lpn-pane-tabs { display: contents; }
/* A TAB, not a push-button: flat, with the selected one joined to the panel below it by having no
   bottom border of its own. Same reasoning as the menu bar's flat items -- a row of raised
   buttons here would read as a second toolbar. */
/* **AN INACTIVE TAB HAS TO LOOK LIKE A TAB** (Tom, 2026-09-21: *"It might be nice to develop the
   tab paradigm of the tables list across the top of the bottom pane. Currently, all the non-active
   tables are undecorated plain text, which doesn't really say 'I'm an inactive tab.'"*). They were
   transparent-bordered and background-less, so a row of six read as a row of six words.
   **RESTRAINED, BECAUSE THE STRIP IS ALREADY BUSY** (his own note, and Ida's standing one about the
   bottom pane): a hairline, a very light ground and the top two corners rounded -- the minimum that
   says "card behind the front one" -- and NOT a raised button, which would read as a second
   toolbar. The selected tab keeps the whole of its distinction: white, a darker edge, bold, and no
   bottom border, so it alone is joined to the panel below. */
.lpn-pane-tab {
	background: var(--ec-gray-f0f0f0); border: 1px solid var(--ec-gray-dcdcdc); border-bottom: 0; font: inherit;
	border-radius: 3px 3px 0 0; color: var(--ec-gray-444);
	padding: 3px 12px; cursor: pointer;
}
.lpn-pane-tab:hover { background: var(--ec-gray-f7f7f7); }
/* The tabs give up their side padding before they give up a line. A narrow window fits more of
   them per row, so the strip wraps to two lines later and to three never. */
@media (max-width: 60rem) { .lpn-pane-tab { padding: 3px 7px; } }
@media (max-width: 40rem) { .lpn-pane-tab { padding: 3px 5px; } }
.lpn-pane-tab[aria-selected="true"] { background: var(--ec-bg); border-color: var(--ec-gray-bbb); font-weight: bold; color: inherit; }
.lpn-pane-x {
	flex: 0 0 auto; align-self: flex-start; width: 2.2rem; height: 1.7rem; padding: 0; border: 0;
	background: transparent; font-size: 18px; line-height: 1; color: var(--ec-ink); cursor: pointer;
}
.lpn-pane-x:hover, .lpn-pane-x:focus { background: var(--ec-gray-eee); color: var(--ec-ink-black); }
/* The height JS writes lands here. Overflow is the panel's own business, never the page's: the
   root is `overflow: hidden` on this page (Task 432), so anything that does not fit must scroll
   INSIDE its tab. */
.lpn-pane-body { overflow: hidden; }
.lpn-pane-panel { display: none; height: 100%; box-sizing: border-box; padding: 6px 8px; }
.lpn-pane-panel.on { display: flex; }
/* The profile tab: its controls in a fixed left column, the chart taking everything else. A row
   of controls ABOVE the chart would spend the pane's scarcest dimension -- its height -- on
   things that are read once, leaving the drawing the same proof-of-concept size it had as a
   popover. */
.lpn-profile-panel { gap: 10px; align-items: stretch; }
.lpn-profile-controls { flex: 0 0 15rem; overflow: auto; font-size: .9em; }
/* `min-height: 0` matters as much as `min-width`: a flex item's default minimum is its CONTENT,
   and without this the chart could not be measured smaller than the SVG already drawn in it, so a
   pane dragged shorter would leave the chart overflowing rather than reflowing. */
#lpn_profile_chart, #lpn_ts_chart, #lpn_freq_chart, #lpn_sysflow_chart { flex: 1 1 auto; min-width: 0; min-height: 0; }
@media (max-width: 40rem) {
	.lpn-profile-panel { flex-direction: column; overflow: auto; }
	.lpn-profile-controls { flex: 0 0 auto; }
}
/* A tab whose content is a long list scrolls INSIDE itself -- the page may not scroll (Task 432),
   and a table of 800 junctions is exactly the content that would make it. `block`, not the flex
   row a panel gets by default, and said with enough specificity that it does not depend on which
   rule comes later in this file. */
.lpn-pane-panel.lpn-pane-scroll { overflow: auto; }
.lpn-pane-panel.lpn-pane-scroll.on { display: block; }
/* **A TABLE THAT IS NOT ON SHOW KEEPS ITS LAYOUT** (Tom, 2026-09-21, R-111: *"Switching to
   Junctions the first time and some subsequent times delayed about 3 seconds or more. This is the
   worst issue I found."*). `display: none` throws a panel's style and layout away, so every tab
   click re-derived both for ~1,300 form controls from nothing -- measured as the larger part of a
   switch once the table itself stopped being rebuilt. `content-visibility: hidden` is the property
   made for exactly this: the subtree is skipped for style, layout, paint and hit-testing while
   hidden, and its last rendering is KEPT, so showing it again costs only what changed. The panels
   are stacked in one box so a hidden one takes no room, and a hidden one sits BENEATH everything
   else in the pane with a z-index -- not `pointer-events` or `visibility`, which are inherited and
   would restyle every cell on every switch, undoing the point. `isolation` makes the pane body its
   own stacking context so "beneath" means beneath the Profile and Time series panels, which stay
   in normal flow, and never beneath the page. (Without it an empty positioned box lay over the
   Profile panel and ate its clicks.) Its scroll position survives the round trip too. */
.lpn-pane-body { position: relative; isolation: isolate; }
.lpn-pane-panel.lpn-pane-scroll {
	display: block; position: absolute; inset: 0; z-index: -1; content-visibility: hidden;
}
.lpn-pane-panel.lpn-pane-scroll.on { z-index: 1; content-visibility: visible; }
/* **NO TOP PADDING ON A SCROLLING PANEL, AND THAT IS THE WHOLE OF THE SLIVER FIX** (Tom,
   2026-08-23: "when you scroll a table, a tiny sliver of the data is visible scrolling above the
   headings. It's a little bit cool and a little bit embarrassing"). A sticky `top: 0` sticks to
   the scrollport's CONTENT edge, so a container padded 6px at the top leaves a 6px band above the
   heading row that the rows scroll through in plain sight -- measured at exactly 6.0px, with a row
   in it. The padding moves into the sticky heading itself below, so the table still opens with air
   above it and there is no band left for anything to show in. */
.lpn-pane-panel.lpn-pane-scroll { padding-top: 0; }
/* The tabular editors. Column width is king: headings stay narrow and cells stay tight. The
   heading row STICKS, because a table you scroll is a table whose headings you lose. */
/* **THE TABLE IS ITS OWN WIDTH, NOT THE PANEL'S** (2026-09-19, with the width relaxation further
   down). Letting a heading break at any character makes a column's MINIMUM one character wide,
   which is exactly what a drag needs -- and it also lets the browser squeeze every column toward
   that minimum the moment the table is wider than the pane, so seventeen columns arrived wrapped
   to two and three lines with room to spare. `max-content` says: lay each column out at the width
   it wants, and let the panel scroll, which is what it did before the relaxation. The floor is
   then the drag's business and nothing else's. */
.lpn-pane-table { border-collapse: collapse; font-size: .9em; width: max-content; }
/* **A FILTERED TABLE SAYS SO ABOVE ITS ROWS** (Task 597). Hidden rows with no visible cause is the
   one way a filter can mislead, so this line is not decoration and never scrolls away with the
   rows: it sits outside the table, in the panel, above the scrollport. The query is printed in the
   same monospace the Find panel writes it in, because it is the same string. */
.lpn-pane-filter {
	display: flex;
	gap: 8px;
	align-items: baseline;
	flex-wrap: wrap;
	font-size: .85em;
	margin: 0 0 6px;
}
.lpn-pane-filter span {
	font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
	overflow-wrap: anywhere;
}
.lpn-pane-filter-clear { font-size: 1em; }
/* **A ROW KEPT ONLY FOR HAVING BEEN EDITED** (Task 738): it no longer matches the filter and stays
   until Filter in table is pressed again, so it must not pass for a row that does match. Dimmed by
   opacity, which reads the same in both themes; the sign on its ID cell says why. */
.lpn-pane-table tr.lpn-pane-stale td { opacity: .5; }
/* The sign sits in a gutter the ID column reserves for as long as the table is filtered, so the
   first one to appear moves nothing (Perry's pre-review, 2026-09-30: the column widened 16 px and
   the table shifted sideways as the edit was left). */
.lpn-pane-table.lpn-pane-filtered tbody td.lpn-pane-col-id { position: relative; padding-inline-start: 1.3em; }
.lpn-pane-stale-mark { position: absolute; inset-inline-start: .2em; top: 50%; transform: translateY(-50%); line-height: 1; }
/* **THE FIRST COLUMN IS LEFT AND EVERY OTHER COLUMN IS CENTRED, HEADING AND CELL ALIKE** (Tom,
   2026-08-23: "Right is great justification for numbers. But in tables, center is safest for
   everything but the first column... Make the headings center and we will be back to where we
   started, focusing on the inputs as we should"). Right-aligning the figures was tried and
   overruled: it is right for a column of numerals in isolation and wrong here, where what the
   reader is looking at is the boxes they type in.
   THE CLASS COMES FROM THE COLUMN'S POSITION, not from what it holds, so a table that gains a
   column cannot acquire a second left one. The printed sheet aligns exactly as the screen does
   (R-215, 2026-09-24). */
/* **THE CELLS ABUT EACH OTHER, AS A SPREADSHEET'S DO** (Tom, 2026-09-06, Task 186 part 1: "cells
   abut each other like Google Sheets, in appearance and not only in behaviour"). What was there
   was two nested boxes per cell -- a padded <td> holding a bordered <input> -- so the grid a
   reader sees was the inputs' own borders with a gap around each. The cell keeps the rule now and
   the control inside it has none, which is the arrangement that makes a continuous grid: one line
   between two cells rather than two lines and a gutter. The padding moves inside the control, so
   the figures sit exactly where they did. */
.lpn-pane-table th, .lpn-pane-table td { padding: 1px 6px 1px 0; text-align: center; border: 0; }
/* **A REAL `border` AND A BOX-SHADOW ARE POSITIONED BY DIFFERENT MACHINERY, AND THAT IS THE 1PX
   MISALIGNMENT TOM REPORTED, MEASURED 2026-09-23 AGAINST HIS OWN COMBINATION** (125% Windows
   display scaling = device pixel ratio 1.25, scrollbar showing, 100% browser zoom). A `border`
   under `border-collapse`-style abutment is resolved once, by the table's own collapsed-border
   grid, shared by every row including the sticky header; the heading's divider was drawn instead
   as an `inset` `box-shadow` on the TH itself (see the block below), positioned from that cell's
   own box edge and NOT through that shared grid. The two pipelines agree in CSS-pixel geometry
   (`getBoundingClientRect` shows 0px difference at every ratio, which is why two earlier sessions
   could not find this) and disagree by 1-2 DEVICE pixels once the ratio is fractional, because a
   composited sticky layer's bounds are snapped to whole device pixels on their own schedule.
   Reproduced in headless Chromium at DPR 1.25, 1.5 and 2 alike, on Net3's 92-row Junctions table
   (a real vertical scrollbar, not merely a narrowed pane) -- `dev/lpn-spike/table-divider-align-
   harness.js`. **The fix is to stop mixing the two mechanisms**: the body cell's divider is now an
   `inset box-shadow` too, the same kind of paint as the heading's, so both are positioned by the
   same cell-box-edge arithmetic and inherit the same rounding whatever it is. */
.lpn-pane-table tbody td { padding: 0; border: 0; box-shadow: inset -1px 0 0 var(--ec-border), inset 0 -1px 0 var(--ec-border); }
.lpn-pane-table tbody td.lpn-pane-first { padding: 1px 6px 1px 4px; box-shadow: inset -1px 0 0 var(--ec-border), inset 0 -1px 0 var(--ec-border), inset 1px 0 0 var(--ec-border); }
html[dir="rtl"] .lpn-pane-table tbody td { box-shadow: inset 1px 0 0 var(--ec-border), inset 0 -1px 0 var(--ec-border); }
html[dir="rtl"] .lpn-pane-table tbody td.lpn-pane-first { box-shadow: inset 1px 0 0 var(--ec-border), inset 0 -1px 0 var(--ec-border), inset -1px 0 0 var(--ec-border); }
/* A pull-down, like an input, IS its cell and brings its own padding (R-356: the column's rule
   width counts the pull-down's own box, so a cell padding around it pushed the column 12px past
   the rule). */
.lpn-pane-table tbody td:not(.lpn-pane-first):not(:has(input)):not(:has(select)) { padding: 1px 6px; }
/* The control IS the cell: no border of its own, no background of its own, and the cell's own
   padding rather than the browser's. Its width is still the column's, so nothing Tom approved
   about column widths moves. */
.lpn-pane-table tbody td input {
	border: 0; background: transparent; padding: 1px 4px; margin: 0; border-radius: 0;
}
/* **THE CURRENT CELL IS A BORDER AND THE SELECTED RANGE IS A WASH, AND THE TWO NEVER MEAN THE
   SAME THING** (Tom, 2026-09-19, correcting his own mode spec: *"in Select mode they should be
   shaded blue and in Entry mode the single current cell should be merely border highlighted"*;
   *"a blue border highlight (double-wide inward) should indicate the current cell"*; and
   *"In Navigate mode, there should be no blue shading, since that's reserved for Select mode."*).
   So one cell is never washed -- moving the caret from cell to cell paints a border and nothing
   else -- and a wash on screen always means a RANGE somebody selected. js/looped-network.js
   decides which of the two a cell is wearing; this file only says what each looks like.
   The border is INSET so it does not push the row apart, and it is on the cell rather than on the
   control so a read-only cell can wear it too. */
/* **ONE CLASS SAYS WHICH CELL IS CURRENT, AND `:focus-within` NO LONGER GETS A VOTE** (Tom,
   2026-09-19: *"At column A there is a strange highlighting around the ID."*). This rule used to
   end `, td:focus, td:focus-within`, which meant ANY focused descendant made its cell wear the
   current-cell mark -- so clicking the ID button, whose whole job was to jump to the map, left
   that cell looking like the cell you were standing in. It was belt and braces from before the
   modes existed and it is now neither: `paneSelPaint()` paints `.lpn-pane-cur` from the focusin
   handler, so every cell the caret reaches has the class before the browser has finished the
   focus change.
   **AND IT WAS EXPENSIVE, WHICH IS A SECOND REASON AND AN INDEPENDENT ONE.** `:focus-within`
   matches up the ancestor chain, so every focus change invalidates style for the row and the
   table around it. Measured on a 92-junction Net3 with recalculation off: dropping it took one
   committed cell from 19.2 ms to 15.0 ms, about a fifth of what typing a value cost. */
.lpn-pane-table tbody td.lpn-pane-cur {
	outline: 2px solid var(--ec-accent); outline-offset: -2px;
}
.lpn-pane-table tbody td input:focus { outline: 0; }
/* A table waiting for Paste as new rows (Task 610): visibly armed until Ctrl+V or Esc. */
.lpn-pane-appending .lpn-pane-table { outline: 2px dashed var(--ec-accent); outline-offset: 2px; }
/* The selected RANGE. A wash rather than a border, so a rectangle of forty rows reads as one
   block; the current cell keeps its border on top of it. */
.lpn-pane-table tbody td.lpn-pane-sel { background: var(--ec-select-bg); }
/* A ROW WHOSE ELEMENT IS SELECTED ON THE MAP carries a heavy bar at its start (Tom, 2026-09-28),
   in the map's selection blue. An inset shadow on the row's first cell, so no column gets narrower. */
.lpn-pane-table tbody tr.lpn-mapsel td.lpn-pane-first { box-shadow: inset 5px 0 0 var(--ec-hue-1976d2), inset -1px 0 0 var(--ec-border), inset 0 -1px 0 var(--ec-border); }
html[dir="rtl"] .lpn-pane-table tbody tr.lpn-mapsel td.lpn-pane-first { box-shadow: inset -5px 0 0 var(--ec-hue-1976d2), inset 1px 0 0 var(--ec-border), inset 0 -1px 0 var(--ec-border); }
/* The Find list's rows wear the same bar (Shift+click builds the set from there). */
.lpn-find-row.lpn-mapsel { box-shadow: inset 5px 0 0 var(--ec-hue-1976d2); }
html[dir="rtl"] .lpn-find-row.lpn-mapsel { box-shadow: inset -5px 0 0 var(--ec-hue-1976d2); }
.lpn-find-hint { margin: 0 0 2px; font-size: .85em; color: var(--ec-ink-muted); }
/* **THE AUTOFILL DOT WAS REMOVED, NOT WIRED UP** (Tom: *"Autofill with the little square button is
   yet to come? At the moment it's non-functioning and non-clickable. If it were gone (once
   implemented) where autofill is not offered, that would be nice."*). Fill-down shipped since the
   dot was drawn -- Ctrl+D and the right-click menu's Fill down both do the same job the dot was
   meant for -- but drag-to-fill from the dot is a different interaction, not a smaller version of
   the same one: it needs its own drag state, row hit-testing as the pointer moves, autoscroll past
   the pane's edge, and the same one-undo-step guarantee Ctrl+D already gives, none of which the
   dot's CSS-only mark carries. Rather than leave a mark that promises a drag it cannot do, it is
   gone; Ctrl+D and the menu are the affordance until dragging is built as its own piece of work. */
/* **THE FILL HANDLE, BUILT** (Task 690's drag half). `position: relative` on the one cell carrying
   it, so the handle can be positioned within it without moving any other cell. `touch-action: none`
   keeps a finger drag on it from also scrolling the pane. Placed logically
   (`inset-inline-end`/`inset-block-end`), so an RTL reader's handle sits at the corner their own
   language calls trailing, not literally bottom-right. Flush (0, not -1px): the cell clips with
   `overflow: hidden`, and a pre-review measured the outer pixel row and column cut off. */
.lpn-pane-table tbody td.lpn-pane-fillcorner { position: relative; }
.lpn-pane-fillhandle {
	position: absolute; inset-inline-end: 0; inset-block-end: 0; width: 6px; height: 6px;
	background: var(--ec-accent); border: 1px solid var(--ec-bg); box-sizing: border-box; cursor: crosshair;
	touch-action: none; z-index: 3;
}
/* The dashed preview of where a drag would land, painted only on cells NOT already part of the
   source being copied from -- an outline rather than a wash, so it never reads as a second, live
   selection while the drag is still a proposal. */
.lpn-pane-table tbody td.lpn-pane-fillrange { outline: 1px dashed var(--ec-accent); outline-offset: -1px; }
/* start, not left: the first column is the leading one, and in an RTL language that is the
   right-hand edge. Same rule the Labels panel's affix headings follow (Task 435). */
.lpn-pane-table th.lpn-pane-first, .lpn-pane-table td.lpn-pane-first { text-align: start; }
/* **THE ID COLUMN IS CENTRED EVEN THOUGH IT LEADS** (Tom, 2026-09-27: *"Let's try making the ID
   column of every table centered horizontally; it's not printing beautifully yet."*). It carries
   `.lpn-pane-first` for its border and padding (it is still the leading column), but the general
   "first column reads start" rule above is for a customer's tag or a text object's words, not an
   id -- so this rule, keyed on the column and not the position, comes after it and wins the tie. */
.lpn-pane-table th.lpn-pane-col-id, .lpn-pane-table td.lpn-pane-col-id { text-align: center; }
/* padding-top, because the panel's own is gone; and every rule around a heading is an INSET SHADOW
   rather than a border -- under `border-collapse: collapse` the border belongs to the table, not to
   the cell, so it scrolls away from the heading that owns it.
   **THE HEADING SEPARATORS ARE THE BODY'S, IN THE BODY'S COLOUR AND WIDTH** (Tom, 2026-09-19:
   *"The heading separators are offset (misaligned) from the column separators about 2px"* and
   *"The headings vertical and horizontal borders are gray and wider than expected, while the table
   body borders are as expected."*). What he was seeing was the grip collision above -- a 7px gray
   band sitting 3px past the heading's trailing edge, which is neither the body's colour nor its
   width nor its position. With the grip drawing nothing again the headings had no separators at
   all, so they are drawn here instead: one line on the trailing inside edge of each heading, #ddd,
   1px, which is exactly what `tbody td` carries.

   **THE GRID IS CLOSED ON ALL FOUR SIDES, AND IT TOOK TWO MORE LINES** (Tom, 2026-09-19: *"Top
   border is missing."*). Turning off the suite-wide blue frame on `.lpn-pane-table` took the top
   edge away and nothing replaced it; the heading's LEADING edge went with it and nobody looked.
   The body was never affected -- `tbody td` carried a `border` on all four sides at the time, so
   left, right and bottom closed themselves -- which is why only the heading row had open edges.
   `inset 0 1px 0 #ddd` is the top for every heading and `inset 1px 0 0` the leading edge for the
   first one, both in the body's own #ddd; the bottom stays #ccc, being the rule that separates head
   from body rather than one cell from the next. **`tbody td`'s own `border` is gone since
   2026-09-23** (the 1px misalignment fix above), replaced by the equivalent two box-shadow insets
   plus, on `.lpn-pane-first`, a third for the leading edge -- so the grid is still closed on all
   four sides, by the same reasoning, through the new mechanism.

   **STICKINESS IS ON THE ROW AND NOT ON THE CELL, AND THAT IS WHAT MAKES A SEPARATOR RELIABLE**
   (Tom, 2026-09-19: *"Strange missing heading border between Tanks Mixing model and Mixing
   fraction."*). A `position: sticky` TABLE CELL is composited in a layer of its own whose bounds
   are snapped to whole device pixels, and where the cell's sub-pixel trailing edge rounds inward
   the last pixel column of that cell is simply not painted -- so whatever is drawn there goes with
   it. Measured in Chromium over 660 heading boundaries across five tables and eleven text sizes:
   with the cells sticky, separators vanish; with `position: static` on the cells, none does, at any
   size. It is a sub-pixel lottery that moves with the text size, the zoom and the data, which is
   why it took his column and two others here. **A border and a pseudo-element were both tried and
   both lose the same pixel** -- the mechanism is the layer, not the paint -- so the fix is to stop
   giving each heading cell a layer: `thead tr` sticks instead, one layer for the whole row, and
   each cell paints its own edges inside it.
   `position: relative` on the cell is then REQUIRED rather than tidy: the grip is absolutely
   positioned against its own heading, and with the cell unpositioned its containing block would
   become the sticky ROW, putting every grip on the right-hand end of the table. An earlier pass
   declared exactly this `relative` and broke the sticky heading, because back then it was
   overriding the `sticky` that lived here; it is safe now for the same reason it was not then. */
.lpn-pane-table thead tr { position: sticky; top: 0; z-index: 2; }
/* A long-press on a heading opens Hide/Show; iOS Safari shows its own copy callout instead unless
   told not to, as #lpn_canvas already is (Perry, 2026-09-23). */
.lpn-pane-table thead th { -webkit-touch-callout: none; }
/* Insurance beside the real fix (the missing preventDefault() on a selected heading's mousedown,
   js/looped-network.js paneStartColDrag's call site): a drag that overshoots into a neighbouring
   heading's padding must never leave the browser's own text selected behind it. The sort button was
   never the culprit -- a <button> already suppresses this -- but the heading cell itself was not
   covered, and neither was the sort glyph now living beside its label. */
.lpn-pane-table thead th, .lpn-pane-sort { user-select: none; }
/* **THE WHOLE CELL IS THE TARGET, DOWN TO THE PIXEL** (pre-review, 2026-09-25, second pass: Tom's
   "one cursor for a heading... that is the entire cell" was not yet true -- on a single-line
   heading the row is drawn as tall as the tallest WRAPPED heading beside it, so `.lpn-pane-sort`
   sat as a ~22px button centred in a cell that could be 70px+, and a press in the dead top/bottom
   padding or the left inset did nothing). **THE FIRST FIX TRIED WAS STRETCHING THE BUTTON ITSELF**
   (`height: 100%`) and measured wrong in real Chromium: a percentage height on a table cell's
   child resolves against the cell's SPECIFIED height, which for a row sized by its tallest
   sibling is `auto`, not a number -- CSS2.1 10.5, and confirmed by measuring the button at 28.6px
   tall inside a 91.3px cell. So the click, mousedown and drag-start listeners are wired on the
   `<th>` itself instead (below, after `grip`/`menuBtn`/`arrow` exist to except them by identity),
   which the browser DOES give the row's real height -- padding stays on the `<th>` exactly as it
   always was, and the button (`.lpn-pane-sort`) is sized to its own content, same as before. */
.lpn-pane-table thead th {
	position: relative; background: var(--ec-bg);
	padding-top: 6px; box-shadow: inset -1px 0 0 var(--ec-border), inset 0 1px 0 var(--ec-border), inset 0 -1px 0 var(--ec-border-strong);
}
.lpn-pane-table thead th:first-child {
	box-shadow: inset -1px 0 0 var(--ec-border), inset 1px 0 0 var(--ec-border), inset 0 1px 0 var(--ec-border), inset 0 -1px 0 var(--ec-border-strong);
}
/* A box-shadow offset is PHYSICAL and these edges are not: in Arabic, Farsi, Hebrew, Pashto and
   Urdu the trailing edge of a heading is its left one and the first column is on the right. Mirrored
   here rather than written with a logical property, because box-shadow has none. */
html[dir="rtl"] .lpn-pane-table thead th {
	box-shadow: inset 1px 0 0 var(--ec-border), inset 0 1px 0 var(--ec-border), inset 0 -1px 0 var(--ec-border-strong);
}
html[dir="rtl"] .lpn-pane-table thead th:first-child {
	box-shadow: inset 1px 0 0 var(--ec-border), inset -1px 0 0 var(--ec-border), inset 0 1px 0 var(--ec-border), inset 0 -1px 0 var(--ec-border-strong);
}
/* **SEVERAL HEADINGS, MARKED BEFORE THE RIGHT-CLICK THAT HIDES THEM** (Tom: *"can we select
   multiple heading cells to hide multiple columns at once?"*). The same wash the body cells use
   for their own selection (`.lpn-pane-table tbody td.lpn-pane-sel`), so a reader who already knows
   what a selected cell looks like recognises a selected heading without being told twice. */
/* **A SELECTED HEADING IS SOLID BLUE** (Tom, 2026-09-26, fifth pass: *"Turn an entire heading
   solid blue on select."*), as Google Sheets draws a selected column's letter: the cells beneath
   keep the light wash, the heading above them is the one solid block, and its text turns white.
   #0b57d0 against white text is 6.6:1. */
.lpn-pane-table thead th.lpn-pane-head-sel { background: var(--ec-select); color: var(--ec-bg); }
/* **A CONTROL DOES NOT INHERIT ITS CELL'S ALIGNMENT; IT HAS TO BE TOLD.** Every browser's own
   stylesheet aligns a form control's text for itself -- `start` on an <input>, centre on a
   <button> -- so a <td> that declares an alignment aligns the BOX and not the number inside the
   control standing in it. That is the whole of what Tom saw when the headings moved and the input
   cells did not: one class on both, one of them overruled from inside. `inherit` on every control
   in these tables hands the alignment back to the cell that owns it.
   .lpn-pane-num no longer carries an alignment of its own: on screen the position decides, and
   what "this column is figures" is still worth here is tabular figures. */
.lpn-pane-table th.lpn-pane-num, .lpn-pane-table td.lpn-pane-num { font-variant-numeric: tabular-nums; }
/* **A CELL STATING A RULE IS NOT A CELL HOLDING A VALUE** (Tom, 2026-09-08: *"center and middle
   uneditable look like a bug."*). Every other plain cell on these tables is a RESULT or an
   identity, and a number that reads like a number is right for both. This one says a word where the
   column's other rows hold a choice, so it has to look like a different kind of thing: italic and
   quieter, which is the ordinary typographic mark for "this is about the row, not in it". The tip
   on it is the property popup's own sentence, so there is one explanation for the rule and not
   two. */
.lpn-pane-table td.lpn-pane-stated { font-style: italic; color: var(--ec-ink-muted); }
/* **THE BOX WIDTH BELONGS TO THE COLUMN, AND THE COLUMN DECLARES IT** (Tom, 2026-08-23, over three
   rounds of review ending in "round the final widths to nice numbers always": Minor loss 3em,
   Roughness 6em, Length 4.2em, Diameter 3em, and 3.5em for the tank, reservoir and junction
   figures). Roughness is the wide one because a Darcy-Weisbach e is a small number with leading
   zeros. The number lives on the column descriptor in js/looped-network.js and arrives here as
   `--lpn-pane-col-w`; a column that names none keeps the 7em. A hand-kept CSS list keyed by column class was the alternative, and it goes
   silently wrong the day a column key is renamed or a table gains one.
   IT IS A CUSTOM PROPERTY AND NOT AN INLINE WIDTH, because an inline `style="width"` beats every
   stylesheet there is -- including the 360px widths below, which Tom has already approved and
   which this must not move. */
.lpn-pane-table input { width: var(--lpn-pane-col-w, 7em); text-align: inherit; }
/* **A COLUMN MAY BE DRAGGED NARROWER THAN THE THINGS IN IT** (Tom, 2026-09-19: *"They are
   constrained from causing any word breaks or selector truncations, and this may be unwelcome by
   users. Let's relax that constraint and see how it plays for the public. Let selectors and inputs
   be truncated and headings be wrapped to the character level."*). The constraint was never a rule
   anybody wrote -- it is the browser's: a table column cannot be laid out narrower than the widest
   MIN-CONTENT in it, and for a heading that is its longest word, for a <select> the widest option
   it offers. Three declarations answer all three:
     * the heading and its sort button break ANYWHERE, so a heading's min-content is one character;
     * a <select> takes the column's own width like an <input> does, which it never did -- nothing
       gave it one, so it sized itself to its widest option and held the column open;
     * `min-width: 0` on both, so the declared width is the floor rather than the content.
   Truncation is then the browser's own: an over-long option or value is clipped by its control.
   THIS IS A TRIAL AND HE SAID SO. If it reads badly in public, the thing to change is these three
   lines, not the drag.
   **BACK TO ONLY `.lpn-pane-tight` (a dragged column)** (Perry's real-Chrome pass on Tom's
   initial-width rule, 2026-09-27: widened to every heading for one build, which let the browser
   break EVERY word wherever it liked to fit a too-narrow estimate -- "Tag", "Closed", short words
   nowhere near the 8-character split threshold, broke too). Two separate fixes replace that one
   line: the width estimate itself is now measured against the heading's REAL font (see
   paneHeadFont() in js/looped-network.js), so an undragged column should no longer need to break
   at all; and where an 8+ letter word IS split, paneHeadingDisplayText() writes a soft hyphen
   (U+00AD) at the exact split point, which the browser treats as a legal break point on its own,
   with no `anywhere`/`break-word` needed. A DRAGGED column still gets both, because the reader
   chose that width and a heading that cannot shrink to fit it is the worse failure. */
.lpn-pane-table thead th.lpn-pane-tight,
.lpn-pane-table thead th.lpn-pane-tight .lpn-pane-sort { overflow-wrap: anywhere; word-break: break-word; }
.lpn-pane-table tbody td select {
	/* NO 7em FALLBACK HERE, and that is deliberate: an unset custom property with no fallback
	   computes to `auto`, so a column that declares no width leaves its pull-down exactly the
	   width it has always been. Only a column that names an em -- or that the reader has
	   dragged -- pulls its pull-down in with it. */
	width: var(--lpn-pane-col-w); max-width: 100%; min-width: 0;
	border: 0; background: transparent; padding: 1px 2px; margin: 0; font: inherit;
	text-align: inherit;
}
/* **A PULL-DOWN NO LONGER HOLDS ITS COLUMN OPEN TO ITS WIDEST CHOICE** (R-356, Tom 2026-09-27:
   the initial width counts present content *"not counting 'No....' selectors"*; his browser pass
   2026-09-28 found Pipe type, Fittings list, Speed pattern, Pump efficiency curve and Price
   pattern still one line wide because `min-width: max-content` here, from R-110, let every
   pull-down lay its column out at its widest option). The column's rule width already holds a
   CHOSEN value's own text plus the pull-down's arrow and padding (paneColContentEm() and
   paneSelBoxEm() in js/looped-network.js), so `min-width: 0` costs a chosen value nothing; an
   unset "No ..." pull-down is clipped to the column, which is what the rule asks. The rejected
   alternative, re-proposed easily: `max-content` for unset pull-downs only -- it would hold the
   column open to "No fittings list selected" and put the heading back on one line. */
.lpn-pane-table tbody td select.lpn-pane-dragged { min-width: 0; }
.lpn-pane-table tbody td input { min-width: 0; max-width: 100%; }
.lpn-pane-table tbody td { overflow: hidden; }
/* **A SELECTION HIGHLIGHTS CELLS, NOT CHARACTERS** (Tom, 2026-09-19: *"A selection should
   highlight cells, not characters. But I see characters highlighting as in Entry mode."*). Every
   editable cell here is an <input>, and dragging across one leaves the browser's own text
   selection inside it -- blue characters, under our own blue wash, saying something we did not
   mean. A cell that is not being typed in is `readOnly`, which is exactly the set of cells whose
   text must never look selected. A cell in ENTRY or EDIT is writable and keeps its caret and its
   native selection, which is what makes Ctrl+Z and the mouse work inside a value. */
.lpn-pane-table tbody td input[readonly] { user-select: none; }
/* **AND TAB'S OWN SELECT-ALL IS NEVER SEEN** (Tom, 2026-09-26, fifth pass: *"When I tab from cell
   to cell, the only indicator I should see is cell outline."*). Chromium selects the whole value of
   a text input it tabs into, `user-select: none` or not; the focusin handler in paneWireTable()
   collapses it, and this is the second half for the frame before that runs. */
.lpn-pane-table tbody td input[readonly]::selection { background: transparent; color: inherit; }
/* **EVERY ROW IS A WHOLE NUMBER OF PIXELS TALL, SO A SCROLL CAN LAND EXACTLY ON ONE** (Tom,
   2026-09-21, R-112: *"There is a slight calculation error that makes the top border of the top
   cell row not quite coincide with the bottom border of the headings, and vary in that from about
   0px to 3px (maybe 2px), as I arrow up or down."*). There was no calculation error to find:
   paneScrollTopFor() asks for exactly the row boundary, and the browser rounds `scrollTop` to a
   whole pixel. A row was 26.609 px tall at the default text size, so the boundary it asked for
   was a different fraction of a pixel on every row -- measured in Chromium as a gap cycling
   between -0.47 and +0.48 px while arrowing, which is the difference between the row's top rule
   hiding under the heading's bottom rule and standing a pixel clear of it. A row's height is its
   line height plus whole-pixel paddings and borders, so rounding the LINE HEIGHT up to a whole
   pixel makes every row, of one line or two, a whole number of pixels. The table's own top offset
   is part of every boundary too, which is one more reason the paste note above it had to go.
   1.35em is chosen to sit between the two system fonts' own "normal" -- about 1.5 on Linux and 1.33
   with Segoe UI -- so a row moves by a pixel or so either way and not by a line. `round()` is
   ignored by a browser too old to know it, which then behaves exactly as before. */
.lpn-pane-table tbody td, .lpn-pane-table tbody td input, .lpn-pane-table tbody td select { line-height: round(up, 1.35em, 1px); }
/* **NEITHER OF THESE IS A HYPERLINK, AND THEY BOTH LOOKED LIKE ONE** (Tom, 2026-09-19: *"we have
   blue text links"* on the sort headings, and the *"strange highlighting around the ID"*).
   The MARKUP was never wrong -- a sortable heading here is already a real `<button>` carrying
   `aria-sort` on the sorted column only, which is what the ARIA authoring practices ask for and
   what Excel, Sheets, Finder and Explorer all do. The PAINT was: the sort button turned #0645ad
   under the pointer, which is the browser's own visited-link colour, and the ID button was
   underlined at rest in the same blue. Ida's reading, 2026-09-19, is that this is a false promise
   rather than a style preference -- blue and underlined is the one vocabulary thirty years of the
   web has trained every reader to read as LEAVING, and a sort reorders what is already on screen.
   Worse, #0645ad had just been given a specific meaning in this very table: the current cell.
   So: a neutral hover for the sort heading, no underline anywhere, and that blue reserved for the
   one thing it now means. The ID's own control is a pin icon beside an ordinary editable cell --
   see paneTableRow() -- so it has no text to underline in the first place. */
.lpn-pane-sort, .lpn-pane-goto {
	background: none; border: 0; font: inherit; padding: 0; cursor: pointer; color: inherit;
	text-align: inherit;
}
/* **THE TEXT IS A NON-ENTITY** (Tom, 2026-09-26, third pass: *"The headings text is still acting
   like text... It should be a non-entity as far as the cursor and the display changes go. No
   hover shading, no cursor change."*). `cursor: inherit` so the button never overrides the `<th>`'s
   own uniform pointer with a button's native one, and no `:hover` rule at all -- the tint this
   heading used to gain under the pointer is gone, on purpose, not merely unstyled. `display:
   block; width: 100%` so the button (inline by default) spans the cell's own content width; no
   space is reserved for the "..." glyph or the sort arrow any more (both moved to `position:
   absolute`, overlaying the text at zero layout cost -- see their own rules). Padding stays off
   the button and on the `<th>` -- see that rule's own comment on why a click-area fix could not
   live here (percentage heights on a table cell's child do not resolve to the cell's real
   height). */
.lpn-pane-sort { font-weight: bold; display: block; width: 100%; cursor: inherit; }
/* **THE UNIT IS IGNORED BY THE WIDTH AND WRAPS INSIDE IT** (R-356: *"not counting units"*; Tom,
   2026-09-28, on "Vertices": *"its units are not wrapped in violation of the rule to ignore units
   in the wrapping calculation"*). An inline-block, so it stays on the label's last line only when
   the WHOLE unit fits there and otherwise starts a line of its own; `overflow-wrap: anywhere`, so
   a unit wider than the column ("(Lat/Lon|…)", "(% rise/run)") breaks inside the column instead
   of holding it open -- `anywhere`, unlike `break-word`, is counted by the browser when it sizes
   the column, which is the whole point. */
.lpn-pane-hunit { display: inline-block; overflow-wrap: anywhere; }
/* The browser's OWN default focus ring on a focused `<button>` is not "unstyled", it is a second
   ring drawn tight around the text -- exactly the thing point (1) objects to, just undoing the fix
   a different way. The `<th>`'s own `:has(:focus-visible)` rule is the only ring this table draws. */
.lpn-pane-sort:focus-visible { outline: none; }
/* The pin: the first column's way back to the map. Small, quiet, and it does not take the cell's
   text alignment with it -- it sits after the box, in its own ink. */
.lpn-pane-goto {
	display: inline-flex; align-items: center; margin-inline-start: 4px; padding: 1px;
	color: var(--ec-hue-667); vertical-align: middle; line-height: 0;
}
/* **ONE LINE, NOT TWO** (Tom, 2026-09-21: *"We have a gratuitous space waster at ID where the goto
   map icon (nice unsolicited touch!) is a line break below the ID number. Put on same line"*). The
   ID cell holds two inline boxes inside a 5em column, so the browser did the ordinary thing and
   wrapped the second one -- which doubles the height of EVERY row of EVERY table to carry one
   icon. `nowrap` on the cell keeps them together; the box beside it already has `max-width: 100%`
   and `min-width: 0`, so the input gives up its own width first and the pin stays in view. */
.lpn-pane-table tbody td.lpn-pane-idcell { white-space: nowrap; }
.lpn-pane-goto svg { width: 0.95em; height: 0.95em; }
.lpn-pane-goto:hover, .lpn-pane-goto:focus-visible { color: var(--ec-accent); }
/* A keyboard user must still see where they are. `:focus-visible` rather than `:focus`, so a mouse
   press does not leave a ring behind on a control whose whole job is to move the map. */
.lpn-pane-goto:focus-visible { outline: 2px solid var(--ec-accent); outline-offset: 1px; }
/* **THE RING BELONGS TO THE WHOLE HEADING, NEVER TO THE TEXT INSIDE IT** (Tom, 2026-09-26,
   fourth pass: *"I still see special highlighting on the text"* -- a screenshot of a dark
   rectangle drawn tight around the word "Longitude", which was `.lpn-pane-sort:focus-visible`'s
   own outline on the inner button. The text is a non-entity (see `.lpn-pane-sort`'s own comment);
   it draws nothing of its own, focus ring included. `:has(:focus-visible)` puts the ring on the
   `<th>` itself instead, whichever of its own controls (the heading button, the "..." glyph, the
   arrow) actually holds keyboard focus -- inset so it never adds a pixel to the cell's box, and
   still exactly what Tab reveals. Browsers without `:has()` simply show no ring here, the same
   graceful drop this file already uses it for elsewhere. */
/* **NO RING AT ALL, SINCE THE FIFTH PASS** (Tom, 2026-09-26: *"The blue outline for a selected
   (tabbed) heading isn't great, and it conflicts with the column dragging wide blue line."*). A
   heading that holds keyboard focus wears the light selection wash instead -- still exactly what
   Tab reveals, and nothing drawn round the text -- and a SELECTED heading stays solid blue whether
   it has focus or not. */
.lpn-pane-table thead th:has(:focus-visible) { outline: 0; background: var(--ec-select-bg); }
.lpn-pane-table thead th.lpn-pane-head-sel:has(:focus-visible) { background: var(--ec-select); }
/* THE RIGHT-CLICK MENU (Tom, 2026-09-21: Copy, Paste, Select in map, Delete). `position: fixed`
   because its `left`/`top` are the pointer's own client coordinates, which are already relative to
   the viewport and not to any scrolled ancestor -- the same reasoning the popup positioning already
   uses. A plain list of full-width buttons, not a `<ul>`: a menu item here is a button that runs an
   action and never a link, exactly the sort heading's own reasoning two rules up. */
.lpn-pane-ctxmenu {
	position: fixed; z-index: 1900; background: var(--ec-bg); border: 1px solid var(--ec-gray-999); border-radius: 3px;
	box-shadow: 0 2px 8px var(--ec-a-0-0-0-25); padding: 2px; min-width: 9em;
}
.lpn-pane-ctxmenu button {
	display: flex; justify-content: space-between; align-items: center; gap: 1.5em; width: 100%;
	text-align: left; background: none; border: 0; font: inherit;
	padding: 5px 10px; cursor: pointer; color: inherit; border-radius: 2px;
}
/* The keyboard equivalent, named beside its row the way a desktop menu does (Tom: "I did not know
   about Ctrl+D"). Quieter than the label, so the row still reads as one action first. */
.lpn-pane-ctxmenu-accel { color: var(--ec-gray-888); font-size: .85em; white-space: nowrap; }
.lpn-pane-ctxmenu button:hover, .lpn-pane-ctxmenu button:focus-visible { background: var(--ec-hue-eef1f4); outline: 0; }
/* THE DIVIDER BETWEEN TWO HEADINGS, which is where a spreadsheet user aims to resize a column
   (Tom's point (d), 2026-09-18). It sits on the trailing edge of its own heading, is as tall as the
   heading row, and is the ONLY thing there that takes a col-resize cursor -- so the gesture
   announces itself by the pointer changing, with no affordance drawn on a strip that is already
   busy. `position: relative` on the th is what it is positioned against; the heading keeps its own
   padding, so the grip overlaps nothing the reader is trying to read. A thin line appears under the
   pointer rather than always, for the same reason: six of these drawn permanently across a narrow
   pane read as a border nobody chose.

   **IT IS POSITIONED AGAINST ITS OWN HEADING, AND SINCE 2026-09-19 THAT COSTS A DECLARATION.**
   While the CELL was the sticky one it established a containing block for free, and an early pass
   that added `position: relative` for the purpose broke the sticky heading instead -- the same rule
   at the same specificity, overriding it. Stickiness has moved to `thead tr` (see the heading block
   above, and the missing separator that forced it), so the cell now DECLARES `position: relative`
   and the grip needs it: without it the containing block would be the sticky ROW, and
   `inset-inline-end: 0` would stack every grip on the right-hand end of the table.
   pane-harness.js still asserts the heading sticks.

   **IT IS `lpn-pane-colgrip` AND NOT `lpn-pane-grip`, AND THE RENAME IS THE WHOLE OF THREE OF
   TOM'S FINDINGS** (2026-09-19, browser pass: a blip on every column separator 2px below the top
   line, a gray line running 15px past the last column, heading separators offset 2px from the
   column ones, and heading borders gray and wider than the body's). `.lpn-pane-grip` was ALREADY
   TAKEN -- it is the bottom pane's own row-resize handle at the top of the panel, 35 lines up this
   file -- so every column grip inherited that handle's `background: #f2f2f2`, its
   `border-bottom: 1px solid #e0e0e0` and, worst, its `::after`: a 3rem x 2px #bbb bar centred 3px
   below the top. Seven pixels of grip cannot hold 48 pixels of bar, so it overflowed each side of
   every separator (the blips) and ran clear off the end of the last column (the line past the
   table). A name collision, drawing furniture nobody asked for, with no rule of its own to read. */
.lpn-pane-colgrip {
	/* **IT SITS INSIDE ITS OWN HEADING, NOT ASTRIDE THE BOUNDARY.** Straddling reads better on
	   paper and half of it was dead: while every heading was its own `sticky; z-index: 2` stacking
	   context, the NEXT heading painted over whatever spilled past the edge, so the outer three
	   pixels of this grip answered no pointer at all. Stickiness has since moved to the row, so a
	   straddling grip could now be made to work -- and it is deliberately not, because seven pixels
	   on the near side of the line is a target the reader has already learned, and the one thing
	   that must never happen is two grips fighting over the same pixel. */
	position: absolute; top: 0; inset-inline-end: 0; width: 7px; height: 100%;
	cursor: col-resize; user-select: none;
}
.lpn-pane-colgrip:hover { background: linear-gradient(to right, transparent 5px, var(--ec-hue-8ab) 5px, var(--ec-hue-8ab) 6px, transparent 6px); }
/* **A PLAIN CLICK NO LONGER SORTS -- IT SELECTS** (Tom, 2026-09-25, second pass: *"there is only
   one selection possible and one cursor for a heading... A click anywhere on the cell selects the
   column."*). The whole heading is one target, like a spreadsheet's own column letter: no text
   selection anywhere in it (`.lpn-pane-table thead th, .lpn-pane-sort` already declare
   `user-select: none` above), and the ordinary pointer everywhere except a heading that is part of
   a standing selection, which is the one that picks up and moves (his point (e)) and says so under
   the pointer. `grab` rather than `move`: the column is not going anywhere until it is picked up.
   **THE CURSOR IS ON THE `<th>`, NOT THE BUTTON** -- the click/mousedown listeners moved there
   (see the `<th>` rule's own comment on why), and a cursor declared only on the ~28px button would
   have left the rest of the cell showing whatever the browser's own default is, which is exactly
   the "not one cursor for the whole cell" Tom named. `.lpn-pane-colgrip`/`.lpn-pane-colmenu`/
   `.lpn-pane-sortarrow` are all more specific selectors than a bare `th`, so they keep their own
   cursor over this one without needing `!important`. */
.lpn-pane-table thead th { cursor: pointer; position: relative; }
.lpn-pane-table thead th.lpn-pane-head-sel { cursor: grab; }
.lpn-pane-table thead th.lpn-pane-head-sel:active { cursor: grabbing; }
/* **`cursor: inherit` ON THE BUTTON DOES NOT ACTUALLY INHERIT, MEASURED** (Tom, 2026-09-26, fourth
   pass, point (3): *"The grab cursor is a pointer on the heading text."*). `.lpn-pane-sort`
   declares `cursor: inherit` and the cascade order says it should win over its own earlier
   `cursor: pointer` -- and in a real Chromium it does not: a selected heading's `<th>` measured
   `grab` while its own text button measured `pointer`, the exact split Tom saw. Real, buttons are
   documented (and other browsers agree) to special-case an inherited `cursor` differently from a
   plain `<div>`; rather than chase the exact spec paragraph, these three rules just say the same
   thing `.lpn-pane-head-sel` above says, explicitly, for the one child that would not listen to
   `inherit`. `:active` needs the same explicit repeat for the same reason. */
.lpn-pane-table thead th.lpn-pane-head-sel .lpn-pane-sort { cursor: grab; }
.lpn-pane-table thead th.lpn-pane-head-sel:active .lpn-pane-sort { cursor: grabbing; }
/* **AND WHILE AN ACTUAL DRAG IS RUNNING, THE WHOLE PAGE STAYS "grabbing"** (Tom, 2026-09-26,
   fourth pass, point (2): *"I lose the grab cursor instead of pulling the column... I don't know
   where the column will end up."*). `:active` is a CSS state that Chromium (like other engines)
   drops the instant the pointer leaves the element that was pressed, even with the mouse button
   still down -- which is exactly "I lose the grab cursor... when I leave the column". CSS alone
   cannot hold a state across elements the pointer is no longer over; `paneStartColDrag()` sets this
   class on `<body>` for the life of the drag instead, and JS -- not `:active` -- is what takes it
   off again on `mouseup`. The universal child selector needs to OUTRANK every other cursor rule in
   this file for the duration, which is what earns the one `!important` in this stylesheet. */
.lpn-pane-col-dragging, .lpn-pane-col-dragging * { cursor: grabbing !important; }
/* **THE GHOST IS THE WHOLE COLUMN, SHADED** (Tom, 2026-09-26, fifth pass: *"Drag a column (not
   heading) shaded outline (see Google Sheets)."*). A translucent grey block from the heading to the
   foot of the visible table, carried sideways under the pointer; `paneStartColDrag()` sizes and
   places it from rects read once when the drag begins. `pointer-events: none` so it is never what
   a `mousemove` lands on. */
.lpn-pane-col-ghost {
	position: fixed; z-index: 2000; pointer-events: none; box-sizing: border-box;
	background: var(--ec-a-60-64-67-14); border: 1px solid var(--ec-a-60-64-67-5);
	box-shadow: 0 1px 4px var(--ec-a-0-0-0-18);
}
/* **THE DESTINATION IS A WIDE DARK LINE DOWN THE WHOLE DIVIDER** (fifth pass: *"Make a wide black
   or dark gray destination line on entire column (not heading) divider when middle of drag
   rectangle (not cursor) is between middle of two columns."*). Drawn only once the ghost's middle
   has passed a neighbour's middle; at the column's own two edges there is none, because dropping
   there does nothing. Dark grey rather than the page's blue, so it cannot be read as a selection. */
.lpn-pane-col-marker {
	position: fixed; z-index: 2001; pointer-events: none; width: 4px; background: var(--ec-hue-3c4043);
}
/* **AND NOTHING ELSE ON THE HEADINGS SHOWS WHILE A COLUMN IS IN THE HAND** (fifth pass: *"Suppress
   the menu and sort arrow during dragging."*) -- the sorted column's standing arrow included. */
.lpn-pane-col-dragging .lpn-pane-colmenu,
.lpn-pane-col-dragging .lpn-pane-sortarrow { visibility: hidden; }
/* **THE "..." COLUMN MENU, HIDDEN UNTIL HOVER, FOCUS OR TOUCH** (Tom, 2026-09-26, third pass,
   reversing the second pass's "always visible": *"Possibly the arrow and the menu can take up
   zero space and appear with 100% opacity over any heading text on hover. Try that."*). Opens the
   same menu the right-click/long-press does (`paneHeadMenuTrigger()`), so it is a second door onto
   one room rather than a second menu to keep in step. `position: absolute` for "no extra heading
   width": an out-of-flow box contributes nothing to the button's own min-content, so
   `.lpn-pane-sort` above declares `position: relative` for this (and the arrow below it) to sit
   against -- **OVERLAYING THE TEXT ON PURPOSE, NOT BESIDE IT**: no space is reserved for either
   glyph any more (the floated gutter spacer a pre-review round added is gone), which is what "zero
   space" means here.
   **PINNED TO THE TOP-RIGHT CORNER OF THE CELL, NOT ITS FULL HEIGHT, AND OPAQUE** (pre-review,
   2026-09-25: on a narrow column -- "Base demand (gpm)" wraps to four lines at its declared 3.5em
   -- a full-height, transparent glyph sat centred over the SECOND line of wrapped text, which read
   as a stray mark through the middle of "demand"). A small badge at the corner instead, the same
   background as the heading itself (`#fff`, matching `.lpn-pane-table thead th`) so it covers
   rather than blends with whatever text is under it, and short enough (1.1em) to never reach past
   the first line no matter how many lines the heading wraps to. */
.lpn-pane-colmenu {
	position: absolute; inset-inline-end: 1px; top: 2px; height: 1.1em; width: 15px;
	display: flex; align-items: center; justify-content: center;
	background: var(--ec-bg); border: 0; border-radius: 2px; padding: 0; margin: 0; font: inherit;
	font-weight: normal; color: var(--ec-hue-5f6368); cursor: pointer; opacity: 0;
}
/* The glyph itself, same reasoning as the sort arrow below: `content:` rather than a text node, so
   print.js's per-text-node line count never sees it. */
.lpn-pane-colmenu::after { content: '⋮'; }
/* **THE ARROW, ON EVERY HEADING, BELOW THE "..." GLYPH, WITH AIR BETWEEN THEM** (Tom, 2026-09-26,
   third pass: *"clicking the arrow sorts (ascending first, then toggles)"*; fifth pass: *"Put a
   little vertical space between the ellipsis menu and the sort arrow."*). Same corner-badge shape
   and `content:`-not-text-node reasoning as the menu glyph -- **EXCEPT the column that IS sorted**,
   whose arrow stays visible (`.lpn-pane-sortarrow-active`) so the table says which column is
   sorted, and which way, without being touched.
   **THE AIR IS PADDING, NOT A GAP**: the button starts where the ⋮ ends and its top 5px are
   transparent (`background-clip: content-box`), so the eye sees two separate marks while the
   pointer crosses from one to the other without ever leaving the pair -- a real gap would flash
   both out of sight halfway across it. `top` and the glyph's size are in the HEADING's em (the
   glyph's `.8em` is on `::after`, not the button), so the space holds at every text size. */
.lpn-pane-sortarrow {
	position: absolute; inset-inline-end: 1px; top: calc(2px + 1.1em); height: calc(5px + 1.05em);
	width: 15px; box-sizing: border-box; padding: 5px 0 0; margin: 0;
	display: flex; align-items: center; justify-content: center;
	background: var(--ec-bg); background-clip: content-box; border: 0; border-radius: 2px; font: inherit;
	font-weight: normal; color: var(--ec-hue-5f6368); cursor: pointer; opacity: 0;
}
.lpn-pane-sortarrow::after { content: '▲'; font-size: .8em; }
.lpn-pane-sortarrow-desc::after { content: '▼'; }
/* **SHOWN ONLY WHEN THE POINTER IS OVER THEIR OWN CORNER** (fifth pass: *"I think that the menu and
   arrow are too eager to show. Can we make them show only when the cursor is directly over their
   area of the cell?"*). Hovering the heading at large no longer reveals them; hovering either mark's
   own box reveals the pair (`:has()`; a browser without it reveals just the one under the pointer).
   The keyboard equivalent is `:focus-visible` inside the heading -- not `:focus-within`, which a
   plain click on the heading also satisfies, and which is how a click used to leave both showing.
   A touch screen never fires `:hover`, so `(hover: none)` shows them there unconditionally -- the
   one place a mouse and a finger are told apart in this file. */
.lpn-pane-colmenu:hover, .lpn-pane-sortarrow:hover,
.lpn-pane-table thead th:has(.lpn-pane-colmenu:hover, .lpn-pane-sortarrow:hover) .lpn-pane-colmenu,
.lpn-pane-table thead th:has(.lpn-pane-colmenu:hover, .lpn-pane-sortarrow:hover) .lpn-pane-sortarrow,
.lpn-pane-table thead th:has(:focus-visible) .lpn-pane-colmenu,
.lpn-pane-table thead th:has(:focus-visible) .lpn-pane-sortarrow,
.lpn-pane-sortarrow-active { opacity: 1; }
.lpn-pane-colmenu:hover, .lpn-pane-colmenu:focus-visible,
.lpn-pane-sortarrow:hover, .lpn-pane-sortarrow:focus-visible { color: var(--ec-select); outline: 0; }
/* On a solid-blue selected heading the badges turn blue with it, and their marks white. */
.lpn-pane-table thead th.lpn-pane-head-sel .lpn-pane-colmenu,
.lpn-pane-table thead th.lpn-pane-head-sel .lpn-pane-sortarrow { background-color: var(--ec-select); color: var(--ec-bg); }
.lpn-pane-table thead th.lpn-pane-head-sel .lpn-pane-colmenu:hover,
.lpn-pane-table thead th.lpn-pane-head-sel .lpn-pane-sortarrow:hover { color: var(--ec-select-bg-strong); }
/* A focused heading wears the light wash (above), and so do its badges, rather than two white
   patches on it. */
.lpn-pane-table thead th:not(.lpn-pane-head-sel):has(:focus-visible) .lpn-pane-colmenu,
.lpn-pane-table thead th:not(.lpn-pane-head-sel):has(:focus-visible) .lpn-pane-sortarrow { background-color: var(--ec-select-bg); }
@media (hover: none) { .lpn-pane-colmenu, .lpn-pane-sortarrow { opacity: 1; } }
/* The Manage columns dialog (openDialog(), #lpn_dialog): one row per column, a Show checkbox and
   two reorder buttons. Plain and compact -- this dialog is open for a few seconds, not a page of
   its own. **REWORKED TO A SELECTABLE LIST WITH BUTTONS OUTSIDE IT** (Tom, 2026-09-25, second
   pass: point (4)(b)(i)): a row no longer carries its own move arrows -- it carries a checkbox and
   a label, selectable by click/Ctrl+click/Shift+click like any other list, and four buttons beside
   the list move the whole selection as a block. */
.lpn-managecols-wrap { display: flex; gap: 10px; align-items: flex-start; }
.lpn-managecols-list {
	flex: 1 1 auto; max-height: 16em; overflow-y: auto; border: 1px solid var(--ec-border-strong); border-radius: 3px;
	user-select: none;
}
.lpn-managecols-list:focus-visible { outline: 2px solid var(--ec-accent); outline-offset: -2px; }
.lpn-managecols-row {
	display: flex; align-items: center; gap: 6px; padding: 3px 6px; border-bottom: 1px solid var(--ec-gray-eee);
	cursor: pointer;
}
.lpn-managecols-row:last-child { border-bottom: 0; }
.lpn-managecols-row-sel { background: var(--ec-select-bg-strong); }
.lpn-managecols-row-focus { box-shadow: inset 0 0 0 1px var(--ec-hue-667); }
.lpn-managecols-btns { display: flex; flex-direction: column; gap: 4px; flex: 0 0 auto; }
.lpn-managecols-btns button {
	background: none; border: 1px solid var(--ec-border-strong); border-radius: 2px; cursor: pointer;
	padding: 3px 8px; font: inherit; white-space: nowrap;
}
.lpn-managecols-btns button:disabled { color: var(--ec-gray-bbb); border-color: var(--ec-gray-eee); cursor: default; }
/* PRINTING ONE TABLE (Tom, 2026-08-21: "Make a way to print any table").
   The sheet is a STATIC COPY of the active table, built by paneBuildPrintable() and appended
   straight to <body> so that hiding "everything else" is one rule with no ancestor to fight. It
   is display:none on screen and never seen there; it exists only between the button and the
   browser's print dialog, and printPaneTable() takes it away again afterwards.

   The map's own printing is untouched: without .lpn-printing-table on the body nothing below
   applies, so Ctrl+P still prints the drawing exactly as it did. */
/* **THE TABLE COLUMNS AND KEYBOARD-SHORTCUTS NOTES ARE EACH A TWO-COLUMN TABLE, ONE ROW A
   GESTURE, THE ACTION THEN HOW TO DO IT** (Tom, 2026-09-26: reorganize the one list into two
   tables). A real <table> rather than a styled list, so it still reads as a table with no CSS at
   all -- in Find-in-page, in print, and to a screen reader. Narrow first column, full-width
   second, stacked to one column per phone width, matching the list rule it replaces. */
table.lpn-notes-table { border-collapse: collapse; margin: .25em 0 0; width: 100%; }
table.lpn-notes-table td { padding: 0 .75em .3em 0; vertical-align: top; }
table.lpn-notes-table td:first-child { font-weight: bold; width: 40%; }
@media (max-width: 480px) {
	table.lpn-notes-table, table.lpn-notes-table tbody, table.lpn-notes-table tr, table.lpn-notes-table td { display: block; width: auto; }
	table.lpn-notes-table tr { margin-bottom: .4em; }
	table.lpn-notes-table td { padding: 0; }
}
#lpn_print_area { display: none; }
@media print {
	body.lpn-printing-table > *:not(#lpn_print_area) { display: none !important; }
	body.lpn-printing-table #lpn_print_area { display: block !important; }
	#lpn_print_area h1 { font-size: 14pt; margin: 0 0 2pt; }
	#lpn_print_area h2 { font-size: 12pt; margin: 0 0 8pt; font-weight: normal; }
	/* The heading row repeats on every sheet -- a page 3 of figures with no headings on it is a
	   page of numbers nobody can read. Not sticky: sticky is a screen idea, and print needs the
	   table-header-group behaviour a <thead> already has. */
	/* **THE SAME WIDTH IT HAS ON SCREEN, WHICH IS ITS CONTENT'S** (Tom, 2026-08-21: "these are
	   very narrow tables, and they are expanded to fit a page"). `width: 100%` stretched a
	   four-column table across the sheet and put inches of white between a label and its
	   number. .lpn-pane-table carries no width at all on screen; the print copy now matches. */
	.lpn-print-table { border-collapse: collapse; font-size: 9pt; }
	/* **THE SHEET IS THE SCREEN, SCALED -- ALIGNMENT, BORDERS AND PADDING INCLUDED** (Tom,
	   2026-09-24, R-215: *"No heading borders, Widths seem to be trying, but not succeeding (tighter
	   fit on print than on screen), and the print horiz alignments are differen than the on-screen
	   alighments"*). Three print-only rules made the sheet a different table from the one on screen:
	   a names-left/figures-right split (the screen centres every column but the first), a heading
	   drawn with a bottom rule only, and padding in `pt`, which does not shrink when the sheet's font
	   does -- so a scaled-down sheet spent a larger share of every column on padding and its headings
	   broke mid-word where the screen's did not. Alignment is now the screen's own rules, which carry
	   no media condition; borders are the screen's grid drawn as real borders (a box-shadow is
	   background paint, which a browser drops when "Background graphics" is off); and padding is in
	   `em`, the screen's pixels divided by its 14.4px font, so it scales with everything else. The
	   `#lpn_print_area` prefix is what lets these beat the screen's cell rules, several of which
	   (`:not(:has(input))`, `:first-child`) out-rank a bare class pair.
	   Measured in real Chromium by dev/browser-pass/specs/print.js. */
	#lpn_print_area .lpn-print-table tbody td { padding: .07em .42em; }
	#lpn_print_area .lpn-print-table thead th { padding: .42em .42em .07em 0; }
	.lpn-print-table thead { display: table-header-group; }
	#lpn_print_area .lpn-print-table thead th {
		position: static; box-shadow: none; border: 1px solid #ddd; border-bottom-color: #ccc;
	}
	#lpn_print_area .lpn-print-table tbody td { box-shadow: none; border: 1px solid #ddd; }
	#lpn_print_area .lpn-print-table tbody td.lpn-pane-first { padding: .07em .42em .07em .28em; }
	.lpn-print-table tr { page-break-inside: avoid; }
	/* **ONE SCALE FACTOR FOR EVERY COLUMN, TAKEN FROM THE PAPER THE BROWSER IS ACTUALLY USING.**
	   panePrintWidths() gives each column its on-screen width in `em` and the table a font-size of
	   `min(<screen font>, 100cqw x <screen font / screen table width>)`: the screen's own size when
	   the sheet has room, and otherwise exactly the size at which the table's width is the sheet's.
	   `cqw` needs a container, which is this one. Every width, padding and letter is then in `em`, so
	   all of them shrink by the one factor and the wrapping is the screen's wrapping. */
	body.lpn-printing-table #lpn_print_area { container-type: inline-size; }
	.lpn-print-table.lpn-print-fixed { table-layout: fixed; }
	/* **TWO DEFENSES, NOT ONE** (R-366, Tom, 2026-09-27: *"Printing seems to always take a little
	   more room than on-screen... this happened to Latitude, Longitude, and Date installed"*; the
	   pre-reviewer's follow-up, 2026-09-28, on the first attempt at this comment, which removed
	   `anywhere` from `td` outright: forcing a value to roughly twice its column's width showed it
	   painting 133px into the NEXT column, unbroken -- trading a wrap for a silent overlap, which
	   reads worse). An honestly-sized value -- a coordinate, a date, sized on screen to its own
	   exact drawn width with no space to wrap at -- is now given MEASURED HEADROOM in
	   panePrintWidths() (js/looped-network.js, PANE_PRINT_HEADROOM) so it should never come
	   anywhere near this rule at all. `overflow-wrap: anywhere` stays here as the LAST RESORT for
	   the value that still does not fit -- a column dragged narrow and then pasted or typed into
	   with something longer than it was ever sized for -- so THAT failure is a wrap, which is
	   legible, rather than an overlap, which is not. A heading's own `anywhere` is doing a second,
	   older job too: paneHeadingDisplayText()'s soft hyphen is the only point it is normally allowed
	   to land on. */
	.lpn-print-table.lpn-print-fixed th, .lpn-print-table.lpn-print-fixed td { overflow-wrap: anywhere; }
	/* The letters are 3% smaller than the columns they sit in. A glyph's advance does not scale
	   exactly linearly at 5-8px, and a heading that exactly fills its column on screen (every one
	   does: the table is `max-content`) then wraps on paper for want of a fraction of a pixel.
	   **THIS IS NOT A CSS ROUNDING ERROR TO FIX ONCE; IT IS HOW PRINTING WORKS.** A real print job
	   (Ctrl+P, Save as PDF) is generated by Chromium in a SEPARATE rendering pass from the page the
	   reader is looking at -- lockedownseo.com's write-up on Chrome print preview divergence puts it
	   plainly: "the print preview generates" as its own event, not a snapshot of the live DOM's own
	   layout. This repo's own tooling already knew it could not promise byte-identical text metrics
	   between the two: dev/browser-pass/specs/print.js's header notes it verifies against
	   `emulateMedia('print')` because that is what Playwright offers, and the pre-reviewer's journal
	   (2026-09-23, confirming print widths) records the same tool's own limit in so many words --
	   `emulateMedia('print')` "does not paginate to a real sheet size the way page.pdf() or an
	   actual print dialog would." A build-and-verify pipeline that cannot promise its own harness
	   matches the real print pass has no business promising the pixel it measured on SCREEN survives
	   into that pass unchanged. The column widths are in the TABLE's em, so this moves no column. */
	#lpn_print_area .lpn-print-table.lpn-print-fixed th, #lpn_print_area .lpn-print-table.lpn-print-fixed td { font-size: .97em; }
}

/* ==== The Settings box (ROADMAP Task 441) ====================================================
   One box for everything that belongs to the whole project. A FLOATING BOX, not a pull-down: it
   drags by its chrome and closes by its X, like the property popup and Find.

   TWO PANES: an index that scrolls on its own, and a content pane that scrolls on its own. The
   box carries an explicit height, because two independently-scrolling columns need a container
   with a height to scroll INSIDE -- an auto-height box would simply grow and put the scrollbar on
   the page, which this page does not have (Task 432).

   The size is CSS, not JS: `min()` against the viewport is the clamp, so there is no measure-then-
   write pass to get wrong and the box re-fits a resized window by itself. JS writes only
   `display: flex` / `display: none` and a left/top.

   **LONGER AND LESS THAN HALF AS WIDE** (Tom, 2026-08-18, after using it: "It can be longer and
   narrower; I would say less than half as wide"). 60rem -> 29rem and 38rem -> 46rem. A settings box
   is read down a column, not across a page, and at 60rem the eye had to travel the width of the
   window to get from a name to its control -- while the map it configures was behind it.

   **AND 29rem WAS ONE STEP TOO FAR** (Tom, 2026-08-19: "we may ship the box too narrow"). 34rem.
   Measured: at 29rem the content pane is 316 px, of which the labels lists spend 186 on their four
   fixed columns, leaving ~120 px for a name like "Head loss gradient" -- three lines of it. 34rem
   gives that name ~200 px and costs the map 80 px it is not using. It is still the FIRST-TIME
   width only: setboxLayout remembers whatever the user drags. */
/* **AND IT RESIZES** (Tom, 2026-08-19: "The box is not sizable at all"). `resize: both` needs a
   non-visible overflow to appear at all, and it costs nothing here: the body is a flex column with
   `min-height: 0` and both panes scroll inside themselves, so nothing in the box depends on the
   width or the height it was given.

   **THE FLOOR IS A WIDTH, NOT A HEIGHT.** The index pane is a fixed 6rem, so narrowing the box
   squeezes only the content pane -- and at 25rem the index is already competing for the only width
   there is (see .lpn-setbox-index below). Narrower still and the sideways scrollbar this box was
   narrowed to get rid of comes back. What happens BETWEEN the shipped width and that floor is the
   container query under .lpn-set-row: the setting rows give up their second column and stack.

   **25rem, AND THE NUMBER WAS MEASURED RATHER THAN REASONED.** 24rem looked right on the arithmetic
   -- 384 px less 16 px of padding, 120 px of index and a 10 px gap leaves 238 for a labels list
   whose own floor is 13rem = 208 -- and dev/browser-pass/specs/visibility.js then measured the
   content pane overflowing by 3 px at exactly that width. The same three pixels, and the same
   lesson, as Task 435: the box model is not addable by hand. Height has no such floor -- a short
   box just scrolls -- so 18rem is only enough to keep the search field and a row of content in
   view. */
/* **34rem -> 26.965rem, PC ONLY (R-204; Tom, 2026-09-24): "the main pane could be 70% of what it
   now is ... It has a hard minimum that seems perfectly acceptable to me, and maybe we could use
   that as the initial default."** Measured in Chromium rather than argued, the same way 25rem
   above was: at the shipped 34rem the content pane is 410.4 px; the box's own 25rem floor squeezes
   it to 266.4 px (16.65rem) when the index (below) still took its OLD 6.6rem. 0.7 x 410.4 = 287.3 px
   (17.955rem), which is LARGER than that floor, so 70% is what ships -- the floor stays exactly
   where it was, only the STARTING width moves.

   26.965rem is 7.26rem of index (its own new figure, below) + 1.75rem of fixed overhead (16 px
   padding + 2 px border + the 10 px effective gap the divider's negative margins reduce to, all
   measured on the rule above's own numbers) + 17.955rem of content: 7.26 + 1.75 + 17.955 = 26.965.

   THE FIRST-TIME WIDTH ONLY, exactly as 34rem was: setboxLayout.w remembers whatever the user has
   already dragged, and this rule never fires against a stored width. PC only, the same viewport
   split every other PC/phone difference in this box uses -- @media (max-width: 640px) below
   overrides the index unconditionally and the box's own min(_, 94vw) already governs a phone, so
   this base rule only ever reaches a screen wider than that. */
.lpn-setbox {
	width: min(26.965rem, 94vw);
	height: min(46rem, 92vh);
	flex-direction: column;
	box-sizing: border-box;
	resize: both;
	overflow: hidden;
	min-width: min(25rem, 94vw);
	min-height: min(18rem, 92vh);
	max-width: 98vw;
	max-height: 96vh;
}
/* **THE TOUCH HALF OF `resize: both`** (Tom, 2026-08-23, on a phone: "I can't figure out how to
   resize Settings on a phone. Is that our fault?" -- it was). The browser's own grabber answers a
   mouse only: mobile Safari paints none at all and Chrome for Android paints one that a finger
   cannot drag. So the property above was, on a phone, a decoration.

   ONLY WHERE A COARSE POINTER EXISTS, so a mouse-only machine never sees this square and keeps the
   native widget it always had -- the desktop is unchanged by construction. `any-pointer`, not
   `pointer`: a touch laptop has a fine PRIMARY pointer and a finger as well, and that hand deserves
   the grabber too.

   28 px, not the 18 px LPN_RESIZE_CORNER reserves for the mouse: that is pointer slop for a cursor.
   Drawn as two diagonal rules, the way every resize corner in every toolkit is drawn, so it reads
   as a grabber without a word of text -- and therefore without a language key. */
.lpn-resize-grip { display: none; }
@media (any-pointer: coarse) {
	.lpn-resize-grip {
		display: block; position: absolute; right: 0; bottom: 0; width: 28px; height: 28px;
		z-index: 2; cursor: se-resize; touch-action: none;
		background:
			linear-gradient(315deg, transparent 0 8px, var(--ec-gray-999) 8px 10px, transparent 10px 13px, var(--ec-gray-999) 13px 15px, transparent 15px);
	}
}
/* **AND THE SAME THREE HEIGHTS IN dvh** (Tom, 2026-08-22: "The settings box and possibly other
   boxes needs to be height-limited for short screens"). It WAS limited -- to 96vh -- and on a phone
   that is the wrong 100%: vh counts the strip behind the retracting address bar, the same
   measurement error that put the map's own bottom out of reach. So the box opened at 92% of a
   height the reader did not have and its foot sat behind the browser's furniture.

   Keyed off the viewport HEIGHT, not the 640px width: a short window on a laptop is the same
   problem and is not a phone. AFTER the rule it overrides, because @supports adds no specificity
   and an equally specific rule later in the file would simply win. Nothing else is needed -- the
   body is a flex column with `min-height: 0` and both panes scroll inside themselves, so a capped
   box scrolls rather than spilling, and .lpn-libbox borrows this whole shell. */
@supports (height: 100dvh) {
	.lpn-setbox { height: min(46rem, 92dvh); min-height: min(18rem, 92dvh); max-height: 96dvh; }
}
/* **EVERY NON-HOGGING BOX WEARS THE SAME TITLE BAR** (Tom, 2026-09-09: *"Every non-hogging box has
   a drag bar or title bar. The title bar should have standard styling including a divider between
   it and the rest of the box. Properties has it, but standard styling needs to be applied to all
   non-hogging windows/boxes."* -- he named Settings, Libraries, Fire flow, Scenario comparison, the
   EPANET report and the Pump energy report).

   It was one line of styling here and a SECOND, box-specific rule (`#lpn_popup::before`) drawing
   the divider, so exactly one of the ten boxes had a bar you could see. The divider belongs to the
   title, not to the popup: every one of these boxes is `padding: 40px 8px 8px`, the 40 px band IS
   the drag surface, and `.lpn-setbox-title` is already inside all ten of them. So the band now
   states its own full width and its own foot, and the per-box rule is gone -- his hope that "the
   list of non-hoggers is neatly presented as an array" is answered by there being no list at all.

   `pointer-events: none` is load-bearing and survives the change: `makePanelDraggable()` starts a
   drag only when `e.target` is the panel itself, so a band that could become an event target would
   be a title bar you cannot drag the box by, which is the opposite of the point. `height` plus
   `box-sizing` rather than a bottom padding, so the rule lands at 39 px in every box whatever its
   title wraps to -- a two-word title and a six-word one draw the same bar. */
.lpn-setbox-title {
	position: absolute; top: 0; left: 0; right: 0; height: 39px; box-sizing: border-box;
	padding: 10px 12px 0; font-weight: bold; pointer-events: none;
	border-bottom: 1px solid var(--ec-border-strong); overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}

/* ---- THE CORNER BUTTONS AND DOCKING (ROADMAP Task 441) ---------------------------------------
   Tom, 2026-10-03: *"the conventional docking, hide, autohide, etc icon buttons at the upper right
   corner of non-hog (non-modal) boxes right before (next to) the exit X"*. One row per standing box,
   built by wireBoxDocking() in looped-network.js, sitting in the title band immediately left of the
   40 px X. The row ignores the pointer and its buttons take it, so the gaps between them still drag
   the box (makePanelDraggable() starts a drag only on the box itself). The title stops short of the
   row: `--lpn-corner-w` is written by the row's renderer, because how many buttons it shows depends
   on whether the box is docked. */
.lpn-box-corner {
	position: absolute; top: 0; right: 40px; height: 39px;
	display: flex; align-items: center; pointer-events: none;
}
.lpn-box-corner > * { pointer-events: auto; }
.lpn-has-corner > .lpn-setbox-title { padding-right: calc(40px + var(--lpn-corner-w, 0px)); }
.lpn-corner-btn {
	width: 28px; height: 39px; padding: 0; border: 0; background: transparent;
	color: var(--ec-ink); cursor: pointer;
	display: inline-flex; align-items: center; justify-content: center;
}
.lpn-corner-btn:hover, .lpn-corner-btn:focus-visible { background: var(--ec-gray-eee); color: var(--ec-ink-black); }
.lpn-corner-btn[aria-pressed="true"] { background: var(--ec-pressed-bg); }
.lpn-corner-help { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 39px; }
/* A DOCKED BOX'S PLACE OUTRANKS EVERY INLINE WRITE. The openers, caps and fits on this page all
   write a box's inline left/top/size; a docked box is placed by four custom properties instead, and
   `!important` here beats every one of those inline writes without any of them having to know. The
   floating geometry they go on writing is exactly what Float hands back. */
.lpn-dragpanel.lpn-docked {
	left: var(--lpn-dock-x) !important; top: var(--lpn-dock-y) !important;
	width: var(--lpn-dock-w) !important; height: var(--lpn-dock-h) !important;
	min-width: 0 !important; min-height: 0 !important; max-width: none !important; max-height: none !important;
	resize: none !important; box-shadow: none !important;
}
.lpn-docked > .lpn-resize-grip { display: none !important; }
.lpn-docked .lpn-popover-body { max-height: none; }
/* Auto-hide: a tucked box is still OPEN (its display is untouched, so every isOpen() still says so);
   it is only invisible and untouchable until its tab brings it out. */
.lpn-dragpanel.lpn-dock-collapsed { visibility: hidden !important; pointer-events: none !important; }
.lpn-dragpanel.lpn-dock-out { box-shadow: 2px 2px 8px var(--ec-a-0-0-0-3) !important; }
/* The strip an auto-hidden box tucks into, one per side, at the map's outer edge. Empty, it is
   nothing. Each tab is a button reading the box's own title, turned on its side. */
.lpn-dock-strip {
	position: fixed; z-index: 7; display: flex; flex-direction: column; gap: 4px;
	width: 24px; box-sizing: border-box; padding: 4px 0; overflow: hidden;
	background: var(--ec-gray-f2f2f2); border: 1px solid var(--ec-border-strong);
}
.lpn-dock-strip:empty { display: none; }
.lpn-dock-tab {
	writing-mode: vertical-rl; flex: 0 1 auto; min-height: 0; max-height: 14em;
	padding: 8px 2px; margin: 0 1px; border: 1px solid var(--ec-border-strong); border-radius: 3px;
	background: var(--ec-bg); color: var(--ec-ink); font-size: 12px; line-height: 1.2;
	white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer;
}
.lpn-dock-tab.lpn-dock-tab-drag { cursor: grabbing; box-shadow: 0 0 0 2px var(--ec-hover-border); }
.lpn-dock-tab:hover, .lpn-dock-tab:focus-visible, .lpn-dock-tab[aria-expanded="true"] {
	background: var(--ec-hover-bg); border-color: var(--ec-hover-border);
}
/* The column's inner edge: drag it to make the docked column wider or narrower. */
.lpn-dock-grip {
	position: fixed; z-index: 7; width: 8px; margin-left: -4px; cursor: col-resize; touch-action: none;
	display: none;
}
.lpn-dock-grip.lpn-dock-grip-live { display: block; }
.lpn-dock-grip:hover { background: var(--ec-gray-e8e8e8); }
/* The map gives up the docked columns as margins on its wrapper; the canvas is `width: 100%`, so it
   narrows, and every overlay inside the wrapper moves with it. Never on paper. */
.lpn-map-wrap { margin-left: var(--lpn-dock-ml, 0px); margin-right: var(--lpn-dock-mr, 0px); }
@media print { .lpn-map-wrap { margin-left: 0; margin-right: 0; } }

/* ---- THE NEW-PROJECT BOX (ROADMAP Task 477) --------------------------------------------------
   Four blocks stacked, each a question. Nothing here sets a width: the box's own max-width in
   Looped-Network.php does that, and the unit strip inside it wraps on its own (.lpn-units-group),
   which is the whole reason those eight selects were stacked name-over-control in Task 424. */
.lpn-new-block { margin: 0 0 10px; padding: 0; border: 0; }
.lpn-new-block > legend { padding: 0; font-size: 1em; font-weight: bold; }
.lpn-new-block > label { display: block; }
/* A radio and its words are one target, and the words are the big half of it. */
.lpn-new-block label > input[type="radio"] { margin-right: .4em; }
.lpn-new-block > input[type="text"] { width: 100%; box-sizing: border-box; padding: 3px 6px; }
/* The projection's NAME beside its ellipsis button (Tom, 2026-09-14). Text, not a control: the
   register's names run past 50 characters, so it wraps on its own line rather than stretching the
   button or pushing the row off a narrow box. */
.lpn-new-crs-name { margin-left: .5em; overflow-wrap: anywhere; }
.lpn-new-crs-name.lpn-dim { opacity: .55; }
#lpn_convas_panel .lpn-dim { opacity: .7; margin: 2px 0 4px; }
#lpn_convas_panel p.lpn-dim { font-size: .9em; max-width: 36rem; }
/* **THE LABEL COLUMN TAKES THE FLEXIBLE SPACE, THE OTHER TWO ARE FIXED-WIDTH** (R-217, Tom,
   2026-09-24: "All the rounding selectors are misaligned with each other and their label
   heading."). `justify-content: space-between` on a row whose first child sizes to its own text
   put a different amount of space in front of the select on every row -- Diameter, Depth, Flow and
   Head are not the same width -- so the select's left edge moved row to row and never lined up
   under its heading. Matching the header's own layout (label flexes, the two value columns are a
   fixed width) fixes both at once: every select's left edge sits at the same x, and so does every
   suffix box's. */
.lpn-convas-round-row { display: flex; align-items: center; gap: 8px; margin: 2px 0; }
.lpn-convas-round-row label { flex: 1 1 auto; }
.lpn-convas-round-row select { flex: 0 0 auto; width: 6.5rem; }
.lpn-convas-suffix { flex: 0 0 auto; width: 5rem; box-sizing: border-box; padding: 2px 4px; }
/* The two column headings over the rounding selects and the suffix boxes (Tom, 2026-09-23):
   same widths as the row cells below them, so the two columns line up. */
.lpn-convas-round-head { display: flex; align-items: flex-end; gap: 8px; font-size: .85em; margin: 2px 0; }
.lpn-convas-round-head span:first-child { flex: 1 1 auto; }
.lpn-convas-round-head .lpn-convas-round-col:nth-child(2) { width: 6.5rem; flex: 0 0 auto; }
.lpn-convas-round-head .lpn-convas-round-col:nth-child(3) { width: 5rem; flex: 0 0 auto; }
/* A disabled place field still has to READ as a question that belongs to the other choice, rather
   than as a broken control: greyed, and the pointer says so. */
.lpn-new-block > input[type="text"]:disabled { background: var(--ec-gray-f2f2f2); color: var(--ec-gray-777); cursor: not-allowed; }
.lpn-new-presets { margin: 4px 0 6px; display: flex; gap: 6px; }
/* Create first, and it is the one with weight -- it is what the box is for, and it is what Enter
   presses. NOT a coloured button beside a grey one for a consent-style pair; this is a create/cancel
   pair, where naming the default action is the convention rather than a dark pattern. */
.lpn-new-actions { display: flex; gap: 8px; align-items: center; }
.lpn-new-actions > button:first-child { font-weight: bold; }
.lpn-setbox-body { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.lpn-setbox-search { flex: 0 0 auto; padding-bottom: 6px; }
.lpn-setbox-search input { width: 100%; box-sizing: border-box; padding: 3px 6px; }
.lpn-setbox-panes { display: flex; flex: 1 1 auto; min-height: 0; gap: 10px; }
/* The index. Its own scrollbar, because it is as long as the content is and the two do not scroll
   together -- that non-coupling is the whole point of the paradigm.

   **6rem** (Tom, 2026-08-22: the index pane "width can be 80% of current on PC, and desperately
   needs that or narrower on phone") -- 0.8 x the 7.5rem it shipped at. The trade has always been
   the same one and it keeps going the same way: an index row may wrap, and a name on two lines is
   still a name, while a control squeezed to half its width is not still a control. So the 24 px
   goes to the content pane. The phone figure is narrower again and is measured rather than scaled;
   it is in the small-screen block at the foot of this file.

   **AND IT GAVE BACK A TENTH, 6.6rem** (Tom, 2026-08-23, having now used the narrowed box on a PC:
   "Settings.index 1.1"). The 0.8 above was decided sight-unseen; this is the correction after
   looking at it. PC ONLY -- the phone figure he asked about in the same breath went the other way
   and is unchanged at 4.5rem.

   **AND 10% WIDER AGAIN, 7.26rem** (R-204; Tom, 2026-09-24: "the index pane 10% wider than now").
   6.6 x 1.1 = 7.26. PC only, same as the step above -- the phone override at the foot of this file
   derives its own figure from ITS OWN 4.5rem and is untouched. */
.lpn-setbox-index {
	flex: 0 0 7.26rem; min-width: 0; overflow: auto;
	border-right: 1px solid var(--ec-border); padding-right: 6px; font-size: .9em;
}
.lpn-setbox-content { flex: 1 1 auto; min-width: 0; overflow: auto; font-size: .9em; }
/* **THE DIVIDER BETWEEN THE TWO PANES** (ROADMAP Task 576; Tom, 2026-09-04: *"It might be nice...
   to let the user drag the divider between the settings panes."*). A grab strip, not a line: the
   visible rule stays on .lpn-setbox-index's own border-right, so the drawing does not change and
   only the pointer target is new.

   **THE 10px BETWEEN THE PANES IS UNCHANGED, AND IT IS THE NEGATIVE MARGIN THAT KEEPS IT SO.** A
   third flex child earns a second `gap`, so index-to-content would have gone from 10px to 26px --
   and .lpn-setbox-panes' `gap` is not this rule's to change: the Libraries box borrows the same
   class and has no divider, and the phone rule at the foot of this file subtracts `2px` as its
   share of a 10px gap. `margin: 0 -8px` spends 16 of the 26 back, leaving 2 + 6 + 2 = the same 10,
   for every box and at every width. dev/lpn-spike/small-screen-harness.js measures that arithmetic
   and caught the first attempt, which changed the gap instead.

   `touch-action: none`, the rule dev/lpn-spike/panel-touch-harness.js exists to keep: without it
   the browser claims the drag for scrolling before `pointermove` ever fires, and the divider
   answers a mouse and not a finger.

   **A LINE AND A GRIP, NOT A DOUBLE LINE** (Tom, 2026-09-04, asked for the conventional depiction
   and guessed a double line; researched, and it is not). The double line is the Windows-95 3D
   groove -- a light rule beside a dark one -- and it belongs to a bevelled visual language nothing
   else on this page speaks. What every current design system describes instead is a SEPARATOR LINE
   plus a GRIP mark on the handle, emphasised on hover: Ant Design, Chakra, Telerik's Kendo, Nuxt,
   ServiceNow's Horizon and Balsamiq's own control guidelines all say the same thing in the same
   words. The separator line here is the index's own `border-right`, which was already drawn and is
   shared with the Libraries box; this adds the grip, four dots high, centred on the strip.

   Focusable, so the keyboard can move it (wireSetboxDivider handles the arrows); the focus ring is
   the browser's own, on a 6px-wide element, which is why the grip changes colour on :focus-visible
   as well -- an outline around a hairline is not a visible focus indicator. */
.lpn-setbox-divider {
	flex: 0 0 6px; align-self: stretch; cursor: col-resize; margin: 0 -8px;
	background: transparent; touch-action: none;
	display: flex; align-items: center; justify-content: center;
}
.lpn-setbox-divider::before {
	content: ""; display: block; width: 2px; height: 22px;
	background: repeating-linear-gradient(to bottom, var(--ec-gray-aaa) 0 2px, transparent 2px 5px);
}
/* `:focus`, not `:focus-visible`, and that is the load-bearing half of Tom's 2026-09-04 note. A
   pointer press focuses the divider so Home works from where the hand already is -- but
   `:focus-visible` is defined not to fire for a pointer, so with only that rule the divider would be
   listening and nothing on screen would say so. The grip going blue after a drag IS the message. */
.lpn-setbox-divider:hover::before, .lpn-setbox-divider:focus::before {
	background: repeating-linear-gradient(to bottom, var(--ec-accent) 0 2px, transparent 2px 5px);
}
/* **THE LAST SECTION MUST BE ABLE TO REACH THE TOP** (Tom, 2026-09-04: *"We are being bit by the
   bottom of the page when we select the Water Quality bookmark. I believe that the standard answer
   for this is to put a page worth of blank space at the end of the settings."* -- and it is). An
   index row scrolls its heading to the top of this scrollport, which the last heading simply cannot
   do: there is nothing under it to scroll past, so it stops wherever the content ends and the
   reader is left looking at a heading in the middle of the pane, wondering whether the click
   worked. The room is added AFTER the content rather than as padding on it, so nothing inside the
   box moves and no section gains a gap of its own; and it is `min-height` on an empty
   pseudo-element, so it costs nothing to a box that is already taller than its content.

   One viewport of the scrollport itself, not a fixed number of pixels: the box is resizable and on
   a phone it fills the screen, so any constant would be too much on one and too little on the
   other. `100%` of a flex scrollport resolves against its own height, which is exactly the measure
   wanted -- scroll the last heading to the top and the blank space is what sits below it. */
.lpn-setbox-content::after {
	content: ""; display: block; min-height: 100%;
}
/* An index row is flat text, like a menu row: it is a destination, not a command. The SUB rows are
   indented under their section, which is what makes the two levels readable at a glance. */
/* **AND IT WRAPS, WHICH A <button> DOES NOT DO BY ITSELF.** Measured at the 6rem pane: "Node
   symbology" reported a 94 px scrollWidth in a 65 px box -- the row was not wrapping at all, it was
   overflowing, and the index answered with the sideways scrollbar this box was narrowed to get rid
   of. `overflow-wrap: anywhere` covers the other half: "Visualization" is one word and no amount of
   wrapping breaks it, and a name broken mid-word is still a name (CLAUDE.md: mid-word wrap is
   acceptable, column width is king). */
.lpn-setbox-link {
	display: block; width: 100%; text-align: left; background: none; border: 0;
	font: inherit; padding: 2px 4px; cursor: pointer; color: inherit;
	white-space: normal; overflow-wrap: anywhere;
}
.lpn-setbox-link:hover, .lpn-setbox-link:focus { background: var(--ec-hue-eef4ff); color: var(--ec-accent); }
.lpn-setbox-link.lpn-setbox-link-sec { font-weight: bold; margin-top: 4px; }
.lpn-setbox-link.lpn-setbox-link-sub { padding-left: 16px; }
.lpn-setbox-link[aria-current="true"] { background: var(--ec-hue-eef4ff); font-weight: bold; }
.lpn-setbox-none { margin: 8px 4px; opacity: .8; }
/* A section heading STICKS to the top of the content pane while its section is being read, so a
   long scroll never leaves the reader wondering which section they are in. */
/* **IN rem, NOT em** (Tom, 2026-08-19: "The main headings are styled SMALLER than the
   sub-headings"). They were: this sits inside .lpn-setbox-content at .9em, so 1.05em came out at
   0.945rem, while every sub-heading below inherits the 1rem that .lpn-set-secbody re-anchors. An
   em-based size for a heading is a size relative to whatever it happens to be nested in, which is
   the wrong thing for a heading to be relative to. */
.lpn-set-head {
	/* **ABOVE THE COLOUR-BAND BOXES, NOT TIED WITH THEM** (Tom, 2026-08-21: the band inputs "cover
	   the main heading above them (Visualization) on scroll"). Those boxes are position:relative
	   z-index:1 so they can straddle their swatch join; at an equal z-index the later element in
	   the DOM wins, and a sticky heading that scrolls UNDER its own section's controls is not
	   sticky. 2 is the whole fix -- the heading is opaque already. */
	position: sticky; top: 0; z-index: 2; background: var(--ec-bg); margin: 0 0 4px;
	padding: 4px 0; font-size: 1.15rem; font-weight: bold; border-bottom: 1px solid var(--ec-border-strong);
}
.lpn-set-sec { margin-bottom: 14px; }
/* A sub-heading, with NO disclosure triangle: nothing here collapses (Tom, 2026-08-18: "No need
   ever to collapse; just scroll/jump to your section"), and a triangle on something that cannot
   disclose is a lie about the control.

   **WEIGHT 600 AT .8 OPACITY IS THE INPUT UNITS STRIP'S OWN TREATMENT** (.lpn-units-head), which is
   the styling Tom named as the one to converge on: "I like the styling of the Input units section."
   It also puts a whole step between a section heading and a sub-heading, where bold-on-bold left
   the two levels distinguishable only by size. */
.lpn-set-sub { margin-top: 8px; font-size: 1rem; font-weight: 600; opacity: .8; }
.lpn-set-subbody { margin-left: 8px; }
/* **WHERE A SETTING IS KEPT** (Task 739, Tom 2026-09-28: Autodesk's "Where it's stored"). One quiet
   marker, the same element on a sub-heading and on the odd row whose home differs from its heading's.
   Ink at low opacity and a smaller size, no colour, so it reads as a label and never as a control or
   a warning. It sits INSIDE the box body, never in the title bar. */
.lpn-saved { margin-inline-start: .6em; font-size: .8em; font-weight: 400; opacity: .7; white-space: nowrap; }
/* **ON A PHONE THE MARKER MAY WRAP.** nowrap kept "Saved in this browser" whole beside its heading,
   and at 360 px that pushed the Settings pane 23 px wide (settings-phone-width-browser-harness.js,
   Task 760, caught it when Task 739 merged). An inline-block capped at the line lets it drop under
   the heading and break inside itself as a last resort. */
@media (max-width: 600px) {
	.lpn-saved { display: inline-block; max-width: 100%; white-space: normal; }
}
.lpn-saved-row { display: block; margin-inline-start: 0; }
.lpn-managecols-width { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.lpn-managecols-width input { width: 6em; }
/* The two scope markers, "Project settings" and "Calculator settings". Quieter than a sub-heading
   because they label a SCOPE rather than name a place you can jump to. */
.lpn-set-group { margin-top: 10px; font-weight: 600; opacity: .8; text-transform: none; }
/* **ONE SMALL-TEXT TREATMENT FOR THE WHOLE BOX**, and it is .lpn-units-name's: .85em at .8 opacity.
   The notes under a sub-heading, the ramp acknowledgements, the name beside a unit select and --
   since Tom's 2026-08-19 audit -- the name of every setting row are one size and one opacity. A
   class rather than a style attribute so the next note added inherits the decision instead of
   copying a string; these were three sizes in three places, two of them inline in JS. */
.lpn-set-note, .lpn-rp-credit { font-size: .85em; opacity: .8; }
.lpn-set-note { margin: 2px 0 4px; }
.lpn-time-ovr-btn { background: none; border: 0; padding: 0; font: inherit; text-decoration: underline; cursor: pointer; }
/* Custom properties (ROADMAP Task 636): the DESIGN under Settings > Assets, as ONE LINE PER
   PROPERTY WITH AN EXPANDER -- Tom's own third option, 2026-09-13.

   **IT REPLACED A TWELVE-COLUMN TABLE, AND THE TABLE'S PROBLEM WAS MEASURED.** Nine truncated
   cells plus Edit and Remove need 721 px before a single property exists, against a Settings
   content pane of 410 px at the shipped box width on a 1200 px screen and 202 px at 360 px. So
   the pane scrolled sideways -- which dev/browser-pass/specs/labelcols.js has forbidden since
   Task 435 -- and scrolling right to read the high limit scrolled the KEY out of view, the one
   column deliberately left unabbreviated because it is the identity the row is filed under.

   **SO THERE IS NO TRUNCATION RULE HERE AT ALL ANY MORE, AND THAT IS THE GAIN.** A field under
   the expander is a `.lpn-set-row` like every other row in this box, so it inherits the container
   query that collapses to one column at 24rem instead of needing a phone layout of its own -- and
   nothing is cut to three letters in ENGLISH, which is what the old ellipsis rule existed to
   avoid and could only ever half-avoid. */
.lpn-cp-head { font-weight: 600; opacity: 1; }
.lpn-cp-item { margin: 2px 0; }
/* The key is the line, so it carries the weight; Remove sits after it and stays small. The
   summary's own `display` is deliberately left alone: the disclosure triangle is part of that
   element's default line box, and a `display: flex` summary loses the marker in every engine. */
.lpn-cp-summary { cursor: pointer; }
.lpn-cp-key { font-weight: 600; margin-right: .5em; overflow-wrap: anywhere; }
.lpn-cp-body { margin-left: 12px; }
.lpn-cp-remove { font-size: 1em; padding: 0 .4em; }
/* **A VALUE THAT BREAKS ITS OWN DESIGN IS FLAGGED IN PLACE AND NEVER CLEARED** (Tom, 2026-09-13:
   tightening a limit is a way of ASKING a question about the data). The outline says which cells
   answer the question; the value underneath it is untouched. */
.lpn-cp-bad { outline: 2px solid var(--ec-hue-c0392b); background: var(--ec-error-bg); }
/* A heading INSIDE a sub-heading's body -- the colour band limits, which name the field they belong
   to. Same weight as a sub-heading, not full bold, so it reads as one level further in. */
.lpn-set-minihead { margin-top: 6px; font-weight: 600; opacity: .8; }
/* The Settings box is where the Labels lists live now, and the affix/decimals/priority columns in
   them are the widest thing in it. Anchored at 1rem for the reason Task 435 found: the headings
   and the controls must share a font size or the columns walk. */
#lpn_settings_box .lpn-set-secbody { font-size: 1rem; }

/* ==== The right panel: EMPTY, and kept (ROADMAP Tasks 427, 434 and 441) ======================
   A FIXED OVERLAY over the right edge of the map, placed from the canvas's rect by
   positionRightPane(). It takes nothing away from the measured canvas height (Task 432) and gives
   nothing back, so the map's one measured number stays one number.

   Its width is the user's, dragged on the left edge and clamped in JS; the default is wide enough
   for the Labels rows, which are the tallest and widest thing it holds. */
.lpn-rpane {
	position: fixed; z-index: 6; display: flex; background: var(--ec-bg);
	border: 1px solid var(--ec-border-strong); box-shadow: -2px 0 6px var(--ec-a-0-0-0-12);
}
/* The grip IS the left edge, the mirror of the bottom pane's top edge, and the same 8px of
   pointer slop. */
.lpn-rpane-grip {
	flex: 0 0 8px; cursor: col-resize; background: var(--ec-gray-f2f2f2);
	border-right: 1px solid var(--ec-gray-e0e0e0); touch-action: none;
}
.lpn-rpane-grip::after {
	content: ""; display: block; width: 2px; height: 3rem; margin: 1rem auto 0; background: var(--ec-gray-bbb);
}
.lpn-rpane-grip:hover { background: var(--ec-gray-e8e8e8); }
.lpn-rpane { flex-direction: row; }
.lpn-rpane-inner { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; }
.lpn-rpane-head { display: flex; align-items: center; gap: 4px; border-bottom: 1px solid var(--ec-border); }
.lpn-rpane-title { flex: 1 1 auto; font-weight: bold; padding: 3px 8px; }
/* The body scrolls INSIDE the panel: the page may not scroll (Task 432), and the label lists are
   longer than any window this panel will ever be given. */
.lpn-rpane-body { flex: 1 1 auto; overflow: auto; padding: 6px 8px; font-size: .9em; }
/* What the panel says while it holds nothing. */
.lpn-rpane-empty { margin: 0; opacity: .8; }
/* THE ROW SHAPE FOR EVERY SETTING IN THE BOX, AND IT IS ONE GRID OF TWO TRACKS: a name that takes
   the slack and wraps, and a CONTROL COLUMN of one fixed width. `min-width: 0` on the name is what
   lets it wrap instead of forcing the row wider than the pane -- Tom, 2026-08-18: "A few things can
   wrap."

   **THE COLUMN IS WHAT MAKES THE BOX UNIFORM** (Tom, 2026-08-19: "everything needs to be uniformly
   designed. Audit all and apply uniform styling"). As a flex row, each control simply followed its
   name to the right EDGE, so a 13 px checkbox started 131 px right of the 144 px input above it and
   nothing in the box shared an x. Now every control -- checkbox, number, select, ramp picker -- is
   placed at the START of the same column, so one vertical line runs down the whole box and a
   control's WIDTH is free to say what it holds.

   **ALIGNED ON THE FIRST BASELINE, NOT CENTRED** (Tom, 2026-08-19: "The labels checkboxes need to
   stay vertically aligned with their other inputs even if their label wraps below them; checkbox
   even with inputs"). `align-items: center` centred each control on the WHOLE name, so a name that
   wrapped to two lines dropped its checkbox half a line and it no longer sat beside the words it
   answers. A baseline is the first line's, whatever the name does below it.

   It used to be called .lpn-rp-row, after the right pane it was written for, and the colour ramp
   strip borrowed the class for its background: `> :first-child { flex: 1 1 auto }` then made the
   FIRST SWATCH take every spare pixel, which is what Tom saw. A row shape and a strip are not the
   same thing and no longer share a name. */
#lpn_settings_box {
	/* The control column, and the box a number sits in at its left edge. Stated once; every rule
	   below reads them, so there is one place to change either. */
	--lpn-set-ctl: 9rem;
	/* The COLUMN a control sits in, which is not the same length as the control: it also has to
	   hold a box PLUS A BUTTON on one line ("Apply to all" beside a 4-character ID prefix measures
	   190 px in English). A select still stops at --lpn-set-ctl, so the controls themselves are the
	   one width they have always been. */
	--lpn-set-col: 12.5rem;
	/* **AND THE NAME COLUMN HAS A CEILING** (Tom, 2026-08-20, third report: "ID prefixes: The inputs
	   and buttons are still floating right and wrapping paradoxically when the box is wide").
	   MEASURED: with the name track at `1fr` it took every pixel the box was widened by -- 238 px of
	   name at the shipped 34 rem, 974 px at 80 rem -- so the control column, a fixed 9 rem, was
	   dragged from x=1242 to x=1563 and sat hard against the right edge with a thousand px of blank
	   name column to its left, STILL wrapping its button underneath because its own track had never
	   grown at all. That is the paradox: `1fr` is a right-edge rule wearing a left-edge name. With a
	   ceiling the growth stops, the column stays where the reader last saw it, and the spare width
	   goes to nobody -- which is what a settings dialog does. */
	--lpn-set-name: 11.5rem;
	--lpn-set-num: 4.5rem;
	/* A colour band boundary, which is one number in a stack of at most six and needs no room for a
	   name -- Tom, 2026-08-20: "They are too wide. They could be about half as wide." Widened 25%
	   on his 2026-08-21 re-read: half was a shade too tight for a five-digit head in metres. */
	--lpn-set-numsm: 3.125rem;
}
.lpn-set-row {
	display: grid; grid-template-columns: minmax(0, var(--lpn-set-name, 11.5rem)) var(--lpn-set-col, 9rem);
	align-items: baseline; gap: 6px; margin: 3px 0;
}
.lpn-set-row > * { min-width: 0; }
/* A control narrower than its column sits at the column's LEFT edge -- that edge is the one x the
   whole box shares -- and never grows past it. */
.lpn-set-row > :nth-child(2) { justify-self: start; max-width: 100%; }
/* A name in a settings row may break mid-word rather than push the box sideways -- the same rule
   the labels lists carry below, and for the same reason. CLAUDE.md: mid-word wrap is acceptable.

   **AND IT IS DRAWN LIKE A UNIT SELECT'S NAME** (.lpn-units-name: .85em at .8 opacity), which is
   the treatment Tom named twice as the one he likes and the one to converge on. It was the only
   text in the box that named a control and did NOT wear it, which is what he was reading as "not
   uniform". The labels lists' own names are set to match in labelCheckbox(). */
.lpn-set-row > :first-child, .lpn-set-name { overflow-wrap: anywhere; font-size: .85em; opacity: .8; }
/* **A TRANSIENT MARK ON THE ROW A LINK JUST SENT YOU TO** (Tom's 2026-09-08 worklist). Tom asked for the
   Show page titles label "temporarily highlighted" when Hide these titles opens the box at it.
   TRANSIENT is the whole rule: the class is taken off on a timer or on the first press inside the
   box (clearPageTitlesFlash()), because a sticky mark on a settings row is state nobody asked for,
   still shining next week at somebody who never used the link. A background tint and an outline
   rather than a colour on the words: the row's name is already drawn at .8 opacity to match a unit
   select, and recolouring it would make it read as a different KIND of row. */
.lpn-set-row-flash {
	background: var(--ec-hue-fff3cd);
	outline: 2px solid var(--ec-hue-e8a33d);
	border-radius: 3px;
}
/* **AND IT REDUCES GRACEFULLY** (Tom, 2026-08-19: "the width does not reduce gracefully"). Below
   the width that holds a name beside a full control column, the row stacks -- name, then control
   under it. A CONTAINER query, not a media query: what decides this is how far the user dragged
   the box's own edge, and the window may be 2000 px wide while this pane is 250. Written on
   .lpn-set-row rather than on the box, because the box is the container's ANCESTOR and a container
   query cannot restyle the element it is measuring. */
.lpn-setbox-content { container-type: inline-size; container-name: lpnset; }
/* **THERE IS NO MIDDLE BAND ANY MORE** (Tom, 2026-08-20, FOURTH report: "At certain middle widths
   -- yes, paradoxically this doesn't happen at the narrowest, and it doesn't happen at the widest
   -- the buttons wrap under the inputs"). That is exactly what two breakpoints produced, and the
   arithmetic says why: between 17 and 23 rem the row kept TWO columns but shrank the control
   column to --lpn-set-ctl (9rem = 144px), and an ID prefix box plus "Apply to all" measures about
   190px. Below 17rem the row stacked and the group got the whole width, so it fit; above 23rem the
   column was 12.5rem and it fit. Only the band in between was too narrow to hold the pair and too
   wide to stop trying -- the paradox, and the reason three fixes aimed at the wide end all missed.

   So the band is deleted rather than retuned. A row either has room for a name beside a full-width
   control column, or it stacks; there is no width at which it has room for part of one. 24rem is
   the two tracks (11.5 + 12.5) plus their gap, i.e. the first width at which the two-column form
   is honest. A browser without container queries keeps the two columns, which is where this
   shipped. */
@container lpnset (max-width: 24rem) {
	.lpn-set-row { grid-template-columns: minmax(0, 1fr); }
}
.lpn-rp-credit { margin-top: 6px; }
/* **THE ACKNOWLEDGEMENT IS REACHABLE FROM THE PICKER IT IS ABOUT** (Tom, 2026-08-20: "Were we going
   to put a Credits link near the color pickers? Maybe under the Color scheme label?"). A POINTER,
   not a second copy: the licence fixes the wording, so there is still exactly one rendering of it,
   in the footer where nothing has to be read past it, and this line scrolls the reader to it and
   marks it for a moment. */
.lpn-set-creditlink { font-size: .85em; margin: 0 0 4px; }
.lpn-set-flash { outline: 2px solid var(--ec-select); outline-offset: 2px; }
/* **A NUMBER BOX IS AS WIDE AS THE NUMBERS IT HOLDS** (Tom, 2026-08-19: "Some inputs are
   gratuitously wide. Comprehensive list: Map appearance, New element prefixes and values, time,
   convergence"). Chrome sizes an `input[type=number]` from its own min/max, so the box shipped a
   144 px convergence tolerance beside a 78 px opacity beside a 62 px flip angle -- three widths for
   three-or-fewer digits, and a box wide enough for a sentence tells the reader to expect one. ONE
   width, here, for the number inputs and for the seven number-shaped TEXT boxes holding `24:00`
   alike. A box that declares its own `size` is left alone -- `size=4` on an ID prefix already says
   "four characters", which is this same rule stated by the author of the control.

   **A CHOICE FILLS THE COLUMN INSTEAD**, because a select is as wide as the words inside it and
   those vary with the language: a select sized to its content gave "Number of colors" 33 px and
   "Legend position" 124 px in the same column. The labels lists' own boxes carry explicit inline
   widths and are unaffected by either rule. The search field is a `search` and is outside the
   section bodies. */
#lpn_settings_box .lpn-set-secbody input[type="number"],
#lpn_settings_box .lpn-set-secbody input[type="text"]:not([size]),
#lpn_settings_box .lpn-set-secbody input.lpn-set-num { width: var(--lpn-set-num); box-sizing: border-box; }
#lpn_settings_box .lpn-set-secbody select { width: 100%; max-width: var(--lpn-set-ctl); box-sizing: border-box; }
/* **AN UNSET HYDRAULICS NUMBER SHOWS NOTHING AT ALL** (Tom, 2026-09-01). This rule used to restyle
   the placeholder -- darker and italic -- to stop an empty box reading as a DISABLED control, which
   was a true diagnosis of a real problem and the wrong fix: it made a number that is not in the
   document look more like one that is. He ruled the number out of the box entirely (*"There's no
   value, and the tip states the default"*), so there is no placeholder on these rows left to style
   and the default is appended to the row's tip instead. See hydNumberRow().

   Deliberately NOT a blanket `input::placeholder` rule for the page: the property popup's
   unitNumberFieldBlank() still shows one, and that one is a different animal -- a live per-node
   value (a reservoir head follows its own elevation), not a constant a tip could ever state. */
/* A control that is a BOX PLUS A BUTTON -- the ID prefixes' "Apply to all", the label view width's
   "Use current view" -- stays inside the column and wraps its button under its box, rather than
   growing left out of the column and taking that row's one x with it. */
.lpn-set-ctlgroup { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px; width: 100%; }
/* **HOW A VALUE IS ALIGNED INSIDE ITS CONTROL, once, for the whole box** (Tom, 2026-08-19: "Some
   inputs are right justified. Others are not. Standardize with an eye for design").

   THE RULE, and the next control follows it without asking:
     * A NUMBER is right-aligned, so digits line up down the column and 7 and 100 can be compared at
       a glance. That is `input[type="number"]`, plus `.lpn-set-num` for the number-shaped TEXT
       boxes -- the seven time fields hold `24:00`, which is a number to every reader and a string
       only to the parser.
     * WORDS ARE NOT. Text boxes (the Before/After affixes), selects, checkboxes and the ramp
       picker keep their natural start alignment, which is also what makes them read correctly in
       an RTL language.
     * ONE EXCEPTION, and it is about the widget rather than the value: a box that draws its own
       spinner arrows (`.ec-spin` -- the Decimals and Rank columns) has no free right edge, so its
       single digit is CENTRED under its centred heading instead. */
#lpn_settings_box .lpn-set-secbody input[type="number"],
#lpn_settings_box .lpn-set-secbody input.lpn-set-num { text-align: right; }
#lpn_settings_box .lpn-set-secbody input.ec-spin { text-align: center; }
/* The ramp strip: a full-width band whose boxes are placed by js/lpn-ramps.js's swatchBoxes(), each
   at x = index * width/n. `position: relative` is the frame those percentages are measured in, and
   border-box keeps each swatch's own hairline border inside its share instead of adding to it. */
.lpn-ramp-strip { position: relative; height: 0.9rem; margin: 4px 0; }
.lpn-ramp-strip .lpn-color-swatch { top: 0; height: 100%; width: auto; box-sizing: border-box; }
/* THE RAMP PICKER: a button showing one bar, and a popup that is a scrolling column of bars.
   Not a <select>, because a <select> cannot hold a picture -- Tom, 2026-08-18: "the dropdown has
   names instead of colors". `position: relative` here is what the popup is placed against, and
   it fills the row's control column, like a select, so the picker lines up with every control above
   and below it. */
/* A PICTURE HAS NO BASELINE, so it opts out of the row's baseline alignment and sits at the top of
   the row instead -- aligned on its first line like everything else, without a text baseline to be
   aligned by. */
.lpn-ramp-picker { position: relative; width: 100%; align-self: start; }
/* ...and "like a select" is now a rule rather than a coincidence: the control column is wider than
   a control since 2026-08-20, so the bar stops where a select stops instead of filling the column. */
#lpn_settings_box .lpn-ramp-picker { max-width: var(--lpn-set-ctl); }
.lpn-ramp-btn {
	display: block; width: 100%; padding: 2px 3px; background: var(--ec-bg);
	border: 1px solid var(--ec-gray-767676); border-radius: 3px; cursor: pointer;
}
.lpn-ramp-btn .lpn-ramp-strip { margin: 0; }
/* The list is taller than the panel and scrolls inside itself, for the same reason every other
   overlay here does: the page may not scroll (Task 432). 41 ramps is a column, not a menu. */
.lpn-ramp-pop {
	position: absolute; z-index: 20; top: 100%; right: 0; width: 15rem; max-height: 60vh;
	overflow-y: auto; padding: 4px 6px; background: var(--ec-bg); border: 1px solid var(--ec-gray-999);
	box-shadow: 0 4px 10px var(--ec-a-0-0-0-2);
}
/* A family heading and its abbreviated example. The example is quieter because it is a hint about
   the DATA, not a name to choose from -- the rows below carry no names at all. */
.lpn-ramp-fam { font-weight: bold; font-size: .85em; margin: 8px 0 2px; }
.lpn-ramp-fam:first-child { margin-top: 0; }
.lpn-ramp-fam-eg { font-weight: normal; opacity: .75; }
.lpn-ramp-opt { padding: 2px 3px; cursor: pointer; border: 1px solid transparent; }
.lpn-ramp-opt:hover, .lpn-ramp-opt:focus { border-color: var(--ec-select); background: var(--ec-hue-eef3fd); outline: none; }
.lpn-ramp-opt[aria-selected="true"] { border-color: var(--ec-ink); background: var(--ec-gray-f0f0f0); }
/* A break the user typed that this file will not accept: marked where it is, so no message has to
   carry a box number into 27 languages. The map is left exactly as it was, which the message says. */
.lpn-bad { outline: 2px solid var(--ec-hue-b00020); }
.lpn-color-msg { color: var(--ec-hue-b00020); font-size: .85em; margin: 2px 0; }
/* **THE BAND BOUNDARIES ARE A LEGEND, NOT A ROW OF BOXES** (Tom, 2026-08-20: "Color band boundaries
   inputs: They are too wide. They could be about half as wide. But the real problem is that they are
   not fitting in the width of the box, and we should make them vertical, one row for each color").

   ONE ROW PER COLOUR, and the swatches touch: with no row gap the first column is a continuous ramp
   in the same ascending order the numbers are typed in, lowest band at the top. A boundary is a LINE
   BETWEEN TWO BANDS -- there is one fewer of them than there are colours -- so its box is pushed
   half its own height down and STRADDLES that line, touching both bands it separates. Nothing has to
   be counted out, and the last band's row correctly has no box: nothing bounds it from below.
   `translateY` rather than a negative margin, so the rhythm the swatches are drawn on is exactly the
   rhythm the boxes are placed against. */
.lpn-color-breaks {
	display: grid; grid-template-columns: 1.4em var(--lpn-set-numsm, 2.5rem);
	column-gap: 6px; row-gap: 0; margin: 4px 0 10px; width: max-content;
}
/* Each band draws its own bottom hairline and inherits the one above it, so a stack of six is five
   single lines rather than five doubled ones. */
.lpn-color-breaks > .lpn-color-swatch {
	width: auto; height: auto; align-self: stretch; min-height: 2.1em; border-top-width: 0;
}
.lpn-color-breaks > .lpn-color-swatch:first-child { border-top-width: 1px; }
.lpn-color-breaks > input {
	align-self: end; transform: translateY(50%); position: relative; z-index: 1;
}
/* Wins over the box's own one-width-for-every-number rule ON PURPOSE, and only here: these boxes are
   a legend rather than a column of settings, and Tom asked for half. */
#lpn_settings_box .lpn-set-secbody .lpn-color-breaks > input.lpn-set-num { width: var(--lpn-set-numsm); }
/* The colour key is a WAY IN to the colour controls (Task 427), so it has to take a click -- the
   overlay stack is pointer-events:none by default and this one cell opts back in. */
.lpn-color-legend { pointer-events: auto; cursor: pointer; }

/* ==== THE SATELLITE TEASER (ROADMAP Task 452) ================================================
   Tom, 2026-08-22: *"Should there be a little 'satellite' teaser tile/button in the corner of the
   map like at Google Maps?"* It is a cell of #lpn_map_footer rather than a corner of its own -- see
   the markup comment in Looped-Network.php for why the corners were all spoken for.

   **THE PICTURE IS DRAWN HERE AND FETCHES NOTHING.** A real tile behind it would be a third-party
   request made before the user asked for one, which is the thing the whole basemap is opt-in to
   avoid. So is a hosted image of the earth: the drawing below is an inline SVG in this stylesheet,
   and nothing about it leaves the page.

   IT IS THE WORLD (Tom, 2026-08-23: *"For the tile/button, we should use something that looks like
   the world. Google uses an actual thumbnail of a tile nearby or something like that... we could use
   something like [Blue Marble]"*). What it replaced was two crossed gradients meant to read as
   fields and a road, and at 40px they read as a texture rather than as anywhere. A coastline cannot
   be faithful at 40px and would turn to mud; what carries is four masses in the right PLACES --
   North America upper left, South America below it, Eurasia across the top with Africa hanging from
   its middle, Australia lower right. Checked at 20 and 40px with
   `dev/scripts/icon_ascii_preview.php --geom=`, which is the only rasterizer this project has, and
   then looked at. The rounded corners and the 1px border are the other half of what epanet-js's own
   switcher does well: at 40px they are what separates a tile from the map behind it.

   THE PRESSED STATE IS THE OTHER SOURCE. When satellite images are already showing, the tile shows
   a pale street map, because a toggle's picture is what you get by pressing it --
   Google's own behaviour, and the one Tom is citing. `aria-pressed` carries the same fact for a
   reader who gets no picture at all. Neither picture asks the page theme anything: an ocean this
   dark and a land this green are both legible against a light page and a dark one, and the button
   paints its own ground either way. */
.lpn-basemap-teaser {
	pointer-events: auto;
	width: 40px; height: 40px; flex: 0 0 auto;
	padding: 0; cursor: pointer;
	border: 1px solid var(--ec-ink-subtle); border-radius: 4px;
	box-shadow: 0 1px 3px var(--ec-a-0-0-0-3);
	background-color: var(--ec-hue-10304f);
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%2310304f'/%3E%3Cg fill='%233d8b3d'%3E%3Cpath d='M1.8 3 L9.6 2.6 L8.8 5.8 L7.2 6.6 L6.4 9.2 L5 8.4 L3.4 6 L1.8 4.4 Z'/%3E%3Cpath d='M7 10 L10.6 9.4 L10.2 12.8 L8.8 15.6 L8 19 L7 15 L6.4 11.8 Z'/%3E%3Cpath d='M10.6 3.2 L22.6 2.6 L22.8 7.4 L18 8.8 L16.2 8.4 L16 10.6 L15 14 L13.6 16.8 L12.4 13 L12.2 9.2 L10.2 6.2 Z'/%3E%3Cpath d='M18.6 14.2 L22.6 13.6 L22.8 17.4 L19.4 18 Z'/%3E%3C/g%3E%3C/svg%3E");
	background-size: 100% 100%;
	background-repeat: no-repeat;
}
.lpn-basemap-teaser:hover, .lpn-basemap-teaser:focus { border-color: var(--ec-select); }

/* ==== The one-tap grievance link (ROADMAP Task 207, Rung 0) =================================
   QUIET IS THE SPECIFICATION, not a taste. This page is a full-window drawing surface, and a
   standing affordance that competes with the drawing is a worse defect than no affordance at
   all -- so it reads as one more small readout in the bottom strip until it is pointed at, and
   only then does it look like a control.

   It is a <button> and never an <a>: nothing navigates, one press posts one row and the label
   turns into the thank-you in place. Two homes, one class -- the bottom strip and the solver's
   amber diagnostic box -- so the background is transparent and it takes whatever it is sitting
   on rather than carrying a colour that is right in one place and wrong in the other.

   pointer-events:auto because both of its homes are inert overlays over the map. */
.lpn-wrong-btn {
	pointer-events: auto;
	background: none;
	border: 1px solid transparent;
	border-radius: 3px;
	padding: 0 4px;
	font: inherit;
	font-size: 11px;
	color: var(--ec-ink-muted);
	cursor: pointer;
}
.lpn-wrong-btn:hover, .lpn-wrong-btn:focus { border-color: var(--ec-gray-bbb); color: var(--ec-ink-strong); }
/* Once tapped it is no longer a control, and it must not keep looking like one: the label is the
   thank-you, so a hover border on it would invite a second press that posts nothing. */
.lpn-wrong-btn[disabled] { color: var(--ec-ink-muted); cursor: default; border-color: transparent; opacity: 1; }
/* ---- The message log button and list (ROADMAP Task 704) ----
   Moved 2026-09-21 into #lpn_map_overlay_tl, beside the mode hint it recalls (Tom: "the
   button/glyph must be where the messages appear"). **OPAQUE, not the translucent pill paint the
   mode text next to it carries** (Tom, R-157: "I discovered what is appearing behind the glyph. It
   is the text 'RIVER' from the model... It's a Text object, not a label."). A partial-alpha
   background over the map canvas lets whatever is drawn underneath bleed through the glyph itself,
   the same defect .lpn-msglog-panel was already given a solid background to avoid -- this button
   gets the same remedy. pointer-events:auto because the row it sits in is otherwise inert like
   every map overlay. */
.lpn-msglog-btn {
	pointer-events: auto;
	display: inline-flex; align-items: center;
	background: var(--ec-bg);
	border: 1px solid var(--ec-gray-bbb); border-radius: 3px;
	padding: 1px 4px; font: inherit; font-size: 11px; line-height: 1;
	color: var(--ec-ink-muted); cursor: pointer;
}
.lpn-msglog-btn:hover, .lpn-msglog-btn:focus { color: var(--ec-ink-strong); border-color: var(--ec-gray-888); }
/* HIGHLIGHTED WHILE A MESSAGE IS ON SCREEN (Tom: "it must appear and possibly highlight while a
   message displays"), toggled by markMsglogActive() in js/looped-network.js. Blue rather than
   amber or red -- both of those already mean "something is wrong" on this page (the diagnostic
   box, the warning banner), and this says only "something is being shown," which is also true of
   an ordinary save confirmation. */
.lpn-msglog-btn.lpn-msglog-active { background: var(--ec-hue-05a); border-color: var(--ec-hue-05a); color: var(--ec-bg); }
.lpn-msglog-btn.lpn-msglog-active:hover, .lpn-msglog-btn.lpn-msglog-active:focus { color: var(--ec-bg); border-color: var(--ec-hue-037); }
/* The list inside the dialog. Capped and scrolled rather than allowed to push the buttons off a
   short window: the modal is positioned at 20% from the top, so an unbounded list on a laptop
   takes the Close button with it. */
/* ---- The on-map message list (superseded the dialog above, Tom 2026-09-22, live on port 8099:
   "The alert paradigm is not a good UX ... User expects them to descend below the glyph, below
   the Mode status, in similar appearance that they originally had.") ----
   **CORRECTED 2026-09-22: this used to say "no rule for the panel itself here," relying on JS
   setting bare `display:flex`.** `display:flex` alone defaults to `flex-direction:row`, so the
   rows laid out SIDE BY SIDE and ran off the right edge of the map (Tom: "the simultaneous
   messages on open ... appear on one line instead of on three. This is a bug."). `flex-direction:
   column` belongs on the container that draws the list, not on the display toggle that only opens
   and closes it, so it is a CSS rule rather than one more thing openMessageLogPanel() has to set.
   **AND THE GAPS BETWEEN ROWS PAINTED NOTHING**, so a Text object on the map underneath (Tom: "I
   discovered what is appearing behind the glyph. It is the text 'RIVER' from the model.") showed
   through those gaps at full strength -- each row is its own translucent
   `rgba(255,255,255,.8)` pill, which is fine for ONE isolated readout but leaves the space BETWEEN
   pills fully transparent once there is more than one. `#lpn_msglog_panel` now paints a solid,
   fully opaque backing behind the whole stack, so nothing on the canvas can ever show through any
   part of this box, gaps included -- the same reasoning `background-color` gets on
   `.lpn-basemap-teaser` two rules up, applied to a list instead of a single tile. */
.lpn-msglog-panel {
	box-sizing: border-box;
	flex-direction: column;
	gap: 4px;
	background: var(--ec-bg);
	border-radius: 3px;
	padding: 4px;
}
.lpn-msglog-panel-row {
	display: flex; gap: 8px; align-items: baseline;
	background: var(--ec-map-overlay-bg);
	border: 1px solid var(--ec-gray-bbb); border-radius: 3px;
	padding: 2px 6px; font-size: 11px;
}
/* The age leads the row and is a fixed column, so thirty rows read as a column of times against a
   column of sentences rather than as ragged prose. It is the quieter of the two: the message is
   what the reader came back for. */
.lpn-msglog-panel-when { flex: none; color: var(--ec-ink-subtle); }
.lpn-msglog-panel-text { flex: 1 1 auto; min-width: 0; }
/* THE SECOND OF EXACTLY TWO LEVELS, painted the same amber #lpn_map_notice and #lpn_status
   already use for a warning -- the log says the same thing the map said; a marker WORD would be a
   word to translate and one more thing to get wrong in five right-to-left languages. */
.lpn-msglog-panel-warn { background: var(--ec-warn-bg); border-color: var(--ec-warn-border); }
/* The empty state, deliberately NOT .lpn-msglog-panel-row -- it is not itself a kept message. */
.lpn-msglog-panel-empty {
	background: var(--ec-map-overlay-bg);
	border: 1px solid var(--ec-gray-bbb); border-radius: 3px;
	padding: 2px 6px; font-size: 11px; color: var(--ec-ink-subtle);
}
/* "Your network is intact." (Task 647) wears the suite's NEUTRAL panel pair, the one above, not the
   #fffbe6/#a80 warning pair: it is reassurance, not a warning (Ida, R-227). Theming phase 1
   (Task 714) turns both into one token rather than two copies of the same two values. */
.lpn-offscreen-card {
	pointer-events: auto;
	background: var(--ec-map-overlay-bg);
	border: 1px solid var(--ec-gray-bbb); border-radius: 3px;
	padding: 10px 16px;
}
/* The closing privacy line, kept when the dialog it was written for was not -- see the JS comment
   on why. No background of its own: it is a caption under the pills, not one more of them. */
.lpn-msglog-panel-note { font-size: 0.85em; color: var(--ec-ink-subtle); padding: 2px 6px; }
/* THE CORNER PEEL, and it is the one part of epanet-js's own switcher worth taking (Tom,
   2026-08-23, pasting two screenshots of it: *"It's acceptable now. But see what I see at
   epanetjs"*). A tile that shows only the destination says what you get and not what you leave;
   the peeled corner says both at once, and at 40px the wedge is an 18px triangle -- measured, not
   guessed. It goes on the STREET-MAP picture, which is the one showing while satellite is on, so
   the imagery is what shows through the peel.

   WHAT WAS DELIBERATELY NOT TAKEN: their tile carries the mapbox wordmark baked into its bottom-
   left corner. Naming a control after its provider is promotion, and the attribution it discharges
   for them is already discharged here by #lpn_basemap_credit, which appears whenever a tile does. */
.lpn-basemap-teaser.lpn-basemap-teaser-on {
	background-color: var(--ec-hue-eae7e0);
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%23eae7e0'/%3E%3Cg stroke='%23c3bdb1' stroke-width='0.9' fill='none'%3E%3Cpath d='M0 6.5 L24 6.5'/%3E%3Cpath d='M0 13.5 L24 13.5'/%3E%3Cpath d='M0 19.5 L24 19.5'/%3E%3Cpath d='M5.5 0 L5.5 24'/%3E%3Cpath d='M12 0 L12 24'/%3E%3Cpath d='M18.5 0 L18.5 24'/%3E%3C/g%3E%3Cpath stroke='%23c3bdb1' stroke-width='1.4' fill='none' d='M0 10 L24 10.6'/%3E%3Cpath fill='%2310304f' d='M24 0 L24 11 L13 0 Z'/%3E%3Cpath fill='%232e7d32' d='M24 4 L24 9 L19 4 Z'/%3E%3Cpath stroke='%23fff' stroke-width='0.8' fill='none' d='M13 0 L24 11'/%3E%3C/svg%3E");
}

/* ==== The Mapbox wordmark in the satellite credit (ROADMAP Task 489) ========================
   MAPBOX'S LOGO IS A LICENCE TERM, NOT A COURTESY: *"Maps using Mapbox map designs, data or
   software usually must display the Mapbox logo and text attribution."* Raster tiles from
   api.mapbox.com/v4/mapbox.satellite are Mapbox-supplied data, so it applies to the satellite
   basemap. It rides in #lpn_basemap_credit's satellite set beside the three text links, which is
   where the strip already is; their docs put the logo bottom-left and the text bottom-right and
   allow either to be repositioned, so one strip carrying both is inside the terms and is one less
   thing to keep on screen.

   **EMBEDDED, NEVER FETCHED.** The mark is a data: URI here rather than an <img> pointing at
   api.mapbox.com, because a request to Mapbox from a page whose visitor has not turned satellite
   on is exactly what #lpn_basemap_teaser was built to avoid -- the teaser draws its own thumbnail
   for the same reason. The artwork is Mapbox's own, taken verbatim from mapbox-gl.css (the
   dark-on-light variant, which is the one for our translucent white strip); their terms forbid
   restyling it, so nothing here recolours it. 65px is the width their attribution page states,
   and 17px is the height that keeps the 88:23 artwork's aspect ratio exactly. */
.lpn-mapbox-logo {
	display: inline-block; vertical-align: middle;
	width: 65px; height: 17px;
	background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E");
	background-size: 100% 100%;
	background-repeat: no-repeat;
}

/* ==== The Labels panel's column headings (ROADMAP Task 435) ==================================
   The four column headings must sit over the controls they name. They did not, and border-box
   (the earlier pass) was only half the cause: BOOTSTRAP'S REBOOT GIVES EVERY FORM CONTROL
   `font-size: inherit`, so an <input> here resolves `em` against the body's 1rem, while
   columnHeadings() draws its heading row at `font-size: 0.85em` and its cells resolve the SAME
   declared `2.6em`/`3.2em` against 0.85rem. Each heading is then ~15% narrower than its control,
   the leftmost flex spacer absorbs the whole shortfall, and every heading slides RIGHT -- worst at
   the left, nearly right at the right, which is exactly the residual Tom reported.

   So the widths are restated in `rem`, which is the same absolute length the controls already
   render at (body font-size is Bootstrap's 1rem) and is immune to whatever font-size the row
   carries. `!important` is not decoration: looped-network.js sets these widths inline.
   The font-size anchor on the two containers is what makes the equality hold BY CONSTRUCTION
   rather than by the body happening to be 1rem.

   The selectors are the columns themselves: child 1 is the flexible name/spacer, children 2-7 are
   Before, After, Use units, 0.000 (decimals), Show and Drop, in all three lists and both row types
   (R-326..R-331 added Use units and Show and gave the customer list every column; R-346 moved Use
   units after After). `span:nth-child(2|3)` therefore matches only a heading cell (a field row
   carries an <input> there), and `span:nth-child(4|5)` matches a heading cell or the
   reserved-column spacer -- which needs the same treatment for the same reason and lands on the
   same 3.2rem it already had. Keep in step with labelCheckbox() and columnHeadings() in
   js/looped-network.js if a column is ever added. */
#lpn_labels_node_fields,
#lpn_labels_link_fields,
#lpn_labels_customer_fields { font-size: 1rem; }
#lpn_labels_node_fields > div > span:nth-child(2),
#lpn_labels_link_fields > div > span:nth-child(2),
#lpn_labels_customer_fields > div > span:nth-child(2),
#lpn_labels_node_fields > div > span:nth-child(3),
#lpn_labels_link_fields > div > span:nth-child(3),
#lpn_labels_customer_fields > div > span:nth-child(3) { width: 2.2rem !important; }
#lpn_labels_node_fields > div > span:nth-child(4),
#lpn_labels_link_fields > div > span:nth-child(4),
#lpn_labels_customer_fields > div > span:nth-child(4) { width: 1.85rem !important; }
#lpn_labels_node_fields > div > span:nth-child(5),
#lpn_labels_link_fields > div > span:nth-child(5),
#lpn_labels_customer_fields > div > span:nth-child(5),
#lpn_labels_node_fields > div > span:nth-child(6),
#lpn_labels_link_fields > div > span:nth-child(6),
#lpn_labels_customer_fields > div > span:nth-child(6),
#lpn_labels_node_fields > div > span:nth-child(7),
#lpn_labels_link_fields > div > span:nth-child(7),
#lpn_labels_customer_fields > div > span:nth-child(7) { width: 2.6rem !important; }
/* AND NOTHING IN A ROW MAY SHRINK. In the Labels pull-down the lists were as wide as they liked;
   in the Visibility panel (Task 427) they are inside a panel the user can drag narrow, and a flex
   item's default is to shrink. The heading cells are `flex: 0 0 auto` already, the <input> columns
   were not, so a narrow panel squeezed the boxes and left every heading standing 60 px to their
   right -- the exact defect Task 435 had just finished fixing, re-created by the move. The panel
   scrolls sideways instead: a column that cannot be read is worse than a scrollbar. */
#lpn_labels_node_fields > div > input,
#lpn_labels_link_fields > div > input,
#lpn_labels_customer_fields > div > input { flex-shrink: 0; }
/* **A HEADING WIDER THAN ITS COLUMN WRAPS INSIDE IT** (the phone's rule 7a, now everywhere): six
   columns at 15.3rem leave headings like "Before" and "Use units" little spare ink, and a longer
   translation must break inside its own column rather than paint over the next one. */
#lpn_labels_node_fields > div:first-child > span,
#lpn_labels_link_fields > div:first-child > span,
#lpn_labels_customer_fields > div:first-child > span { overflow-wrap: anywhere; }
/* **AND THE NAME MUST BE ABLE TO WRAP ANYWHERE.** A flex item's floor is its MIN-CONTENT width, so
   "Roughness, C" held its row 6 px wider than the list itself -- and a row wider than its container
   puts every one of its four controls 6 px right of the heading that names it, which is the Task
   435 defect arriving from the opposite direction. The heading row cannot do this (its name cell is
   empty), so the two disagree by exactly the overflow. Letting the longest name break mid-word costs
   a hyphen-less line break and guarantees the columns line up at ANY width, which is what the box
   became once it was halved. CLAUDE.md: mid-word wrap is acceptable; column width is king. */
#lpn_labels_node_fields > div > label,
#lpn_labels_link_fields > div > label,
#lpn_labels_customer_fields > div > label { min-width: 0; overflow-wrap: anywhere; }
/* A FLOOR ON THE WHOLE LIST, not `width: max-content` on each row. The name column is `flex: 1 1
   auto`, so it takes whatever the row has left -- which is only the same number on every row while
   every row is the same width. Sizing rows to their content instead gives each one a different
   name column and staggers all four headings again, worse than before. One floor for the list.

   **13rem, AND THAT NUMBER IS WHY THE SETTINGS BOX HAD A HORIZONTAL SCROLLER** (Tom, 2026-08-19:
   "It should not"). The floor was 18.5rem = 296 px. The content pane is 316 px, its sub-bodies are
   indented 8 px, and a browser that draws a CLASSIC 15 px scrollbar rather than an overlay one
   leaves 293 px -- three pixels short, permanently, on the one list every visitor opens. Nothing
   headless could see it: a headless Chromium's scrollbars are overlays and take no width, so the
   box measured clean while Tom looked straight at the scroller.
   dev/browser-pass/specs/labelcols.js now measures the pane with 56 px TAKEN AWAY, which asks the
   real question -- can this content shrink? -- instead of the accidental one.
   15.3rem is the six columns (2.2 + 1.85 + 2.2 + 3 x 2.6 = 14.05rem) and their five 4px gaps, which
   fits the squeezed pane's 252 px; below that the names break mid-word, which the rule above allows
   and which is the correct last resort. */
#lpn_labels_node_fields,
#lpn_labels_link_fields,
#lpn_labels_customer_fields { min-width: 15.3rem; }
/* **AND A CEILING ON THE NAME, so the columns stop walking right** (Tom, 2026-08-21: "the Labels
   columns always float right. It seems like there should be something like a maximum width for the
   checkbox and labels column. Map appearance and Time seem to do it right"). They do, and this is
   the same fix they got: the name is `flex: 1 1 auto`, so every pixel the panel is widened by went
   to the name and carried the four boxes out to the right edge with it. With a ceiling the name
   stops growing, the boxes stay where the reader last saw them, and the spare width goes to nobody
   -- which is what a settings dialog does.

   11.5rem is --lpn-set-name, the ceiling on the name column of every other row in this box, so a
   labels row and a settings row now share ONE x for their controls rather than each having its
   own. The heading row's lead cell takes it too, or the headings would stagger against the boxes
   they name -- the defect this list has been fixed for twice. */
#lpn_labels_node_fields > div > label,
#lpn_labels_link_fields > div > label,
#lpn_labels_customer_fields > div > label,
#lpn_labels_node_fields > div > span:first-child,
#lpn_labels_link_fields > div > span:first-child,
#lpn_labels_customer_fields > div > span:first-child { max-width: 11.5rem; }
/* **AND WHEN THERE IS NO ROOM, THE FOUR COLUMNS DROP AS A BLOCK -- THE NAME NEVER WRAPS INSTEAD**
   (Tom, 2026-08-24: "the labels are wrapping when what we want is for the four columns to wrap as a
   block under the checkbox and label. Like the phone.").

   The mid-word break the rules above allow is the LAST resort, and it had become the first: a name
   is what the reader scans down, and breaking "Roughness, e" across two lines to keep four number
   boxes on one is the wrong thing to protect. Below the same 24rem where .lpn-set-row gives up its
   second column, a field row wraps instead -- name on its own line, then its four columns beneath
   it, in the same order and at the same x as every other row's.

   `flex-basis: 100%` on the name is what forces the break, and it is why the row needs `flex-wrap`
   at all; the four columns stay direct children (the heading selectors above address them by
   position, and three separate fixes have gone into keeping headings and boxes on one x), so they
   move as a block because nothing else can fit beside them, not because they are boxed together.
   The heading row's lead cell is EMPTY, so it is removed rather than given a blank line of its own:
   its four headings then sit at the same left edge as the four boxes below them, which is the whole
   invariant this list is held to.

   The same container query as .lpn-set-row, deliberately -- one width at which this box changes
   shape, not two. A browser without container queries keeps today's behaviour, which is the safe
   direction. */
@container lpnset (max-width: 24rem) {
	#lpn_labels_node_fields > div,
	#lpn_labels_link_fields > div,
	#lpn_labels_customer_fields > div { flex-wrap: wrap; }
	/* `!important` is not decoration, for the reason stated above the heading widths: labelCheckbox()
	   sets `flex: 1 1 auto` INLINE on the name, and an inline shorthand carries a flex-basis of
	   `auto` that a stylesheet longhand cannot outrank. */
	#lpn_labels_node_fields > div > label,
	#lpn_labels_link_fields > div > label,
	#lpn_labels_customer_fields > div > label { flex-basis: 100% !important; max-width: 100%; }
	#lpn_labels_node_fields > div > span:first-child,
	#lpn_labels_link_fields > div > span:first-child,
	#lpn_labels_customer_fields > div > span:first-child { display: none; }
	/* **AND THE WHITE SPACE GOES ABOVE THE NAME, NEVER BELOW IT** (Tom, 2026-08-31, from a phone:
	   "the toggles and labels ... have a little gap below them from the row they belong with, but no
	   gap above them from the row they don't belong with. This would be good to reverse.").

	   Measured in Chromium at 360x740 before the fix: 6.00px between a name and its OWN four
	   columns, 0.00px between those columns and the NEXT row's name. Once a row wraps, the inline
	   `gap: 6px` labelCheckbox() writes becomes a row-gap as well as a column-gap, and the rows
	   themselves carry no margin -- so every pixel of separation in the list was sitting inside a
	   group and none of it between groups. Proximity then says the checkbox belongs to the boxes
	   ABOVE it, which is the opposite of what it controls.

	   The 6px is moved rather than added to: row-gap 0 inside the group, 6px margin between rows.
	   `!important` because the gap is an INLINE shorthand and a stylesheet longhand cannot outrank
	   one -- the same reason the flex and the widths in this block carry it. `column-gap` is
	   untouched, so nothing horizontal moves and no column widens.

	   Scoped to the two blocks where the row WRAPS. Where it does not -- the desktop box at its
	   normal width -- the name shares its line with its columns, there is no row-gap to reverse,
	   and the layout does not move. Measured at 1280x900 before and after: identical to the pixel.

	   `:not(:first-child)` rather than `div + div`, which is the same set of rows here and would
	   read better: the sibling combinator is the one selector shape
	   dev/lpn-spike/small-screen-harness.js cannot parse, so it would land in that file's
	   blind-spot report and this rule would go unasserted. */
	#lpn_labels_node_fields > div,
	#lpn_labels_link_fields > div,
	#lpn_labels_customer_fields > div { row-gap: 0 !important; }
	#lpn_labels_node_fields > div:not(:first-child),
	#lpn_labels_link_fields > div:not(:first-child),
	#lpn_labels_customer_fields > div:not(:first-child) { margin-top: 6px; }
}

/* ==== No window scrollbar on the map page (ROADMAP Task 432) =================================
   Tom, 2026-08-18: *"Our bottom controls bar should be the hard bottom of the page."* This is a
   full-window drawing surface; anything that scrolls the WINDOW moves the whole application, so
   the window does not scroll at all and the map footer is the bottom of the world.

   SCOPED BY WHAT ONLY THIS PAGE HAS. <body> carries no page class, and the other fifteen
   calculators are a form and an answer and MUST keep scrolling normally -- so the scope is the map
   canvas itself. A browser without `:has()` simply drops the rule and behaves as before, which is
   the safe direction.

   `overflow: hidden` on the root is what the viewport reads; body is left alone. The two lines
   below do different jobs and both are wanted:
     - applyMapHeight() already fits the canvas to the window, but it measures body's own box, and
       a bottom margin on the last in-flow block collapses OUT of that box and into the document's
       scroll height. That is one stubborn pixel this page can never see and never fix, and it is
       enough for a scrollbar. Zeroing it removes the overflow rather than hiding it.
     - `overflow: hidden` then guarantees the invariant instead of re-deriving it: it also kills the
       scrollbar during load, while the canvas is still wearing its 10000px curtain.

   THE COST, stated rather than discovered: below LPN_MAP_MIN the canvas is allowed to overflow on
   purpose (a window too short for a real map), and what overflows is now clipped instead of
   scrollable. That is the request. A panel that needs to scroll scrolls INSIDE itself --
   .lpn-popover-body already does. */
html:has(#lpn_canvas) form#formInput { margin-bottom: 0; }
html:has(#lpn_canvas) { overflow: hidden; }

/* THE RUN BOX (ROADMAP Task 450). Tom, 2026-08-19: "The Run button does nothing... It needs a box
   with a progress bar and completion report." It is built and appended to <body> by js/lpn-time.js,
   which is what keeps the whole feature out of js/looped-network.js and Looped-Network.php.

   BOTTOM LEFT, not bottom right: the right edge of the map is where .lpn-rpane lives, and a box
   that lands on top of the properties pane covers the numbers the run just produced. Fixed rather
   than placed near the button, because a run takes seconds and the eye will have moved.

   z-index joins the movable boxes at 1200 rather than sitting at its old 1085 (2026-09-02). It is
   made draggable by makePanelDraggable() like every other box, so a stack that left it below them
   meant the one box reporting a run in progress could be buried by a box opened over it -- and at
   equal specificity the later rule wins, so its own 1085 was quietly beating .lpn-dragpanel's 1200.
   raisePanel() writes a higher number inline as boxes are touched; this is the floor. Still below
   the registration bar (3000), which must outrank everything while a click sequence is in
   progress. */
/* **BOTTOM RIGHT, NOT BOTTOM LEFT** (Tom, 2026-09-12: *"The run report obscures the status and
   scenario area."*). Bottom left is where the map's own footer overlay sits -- the scale, the
   scenario name, the mode line's neighbours -- and a 26rem fixed box dropped on top of them hid
   exactly the two readouts a person checks after a run. The bottom right holds only the basemap
   credit, one small line, and `bottom` clears it. */
.lpn-runbox {
	position: fixed; right: 1em; bottom: 2.2em; z-index: 1200;
	box-sizing: border-box; width: 26rem; max-width: calc(100vw - 2em);
	padding: 0.5em 0.75em 0.6em; border: 1px solid var(--ec-border-strong); border-radius: 4px;
	background: var(--ec-bg); box-shadow: 0 2px 8px var(--ec-a-0-0-0-2); font-size: 0.9em;
}
.lpn-runbox-head { display: flex; align-items: center; gap: 0.5em; }
.lpn-runbox-title { font-weight: bold; flex: 1 1 auto; }
/* The percentage is a bare number and a percent sign, so it needs no width reserved for a longer
   translation -- there is no translation. Tabular figures stop it jittering as it counts up. */
.lpn-runbox-pct { flex: 0 0 auto; font-variant-numeric: tabular-nums; color: var(--ec-ink-muted); }
.lpn-runbox-x {
	flex: 0 0 auto; width: 1.8em; height: 1.8em; line-height: 1;
	padding: 0; border: 0; background: transparent; font-size: 1.1em; color: var(--ec-ink); cursor: pointer;
}
.lpn-runbox-x:hover, .lpn-runbox-x:focus { background: var(--ec-gray-eee); color: var(--ec-ink-black); }
.lpn-runbox-msg { margin: 0.25em 0 0.4em; }
.lpn-runbox-bar { height: 0.5em; background: var(--ec-gray-e6e6e6); border-radius: 3px; overflow: hidden; }
/* No transition on the width. The fill is driven by EPANET's own clock at the end of each slice,
   so an animation would be a second, slower, made-up progress bar drawn over the real one. */
.lpn-runbox-fill { height: 100%; width: 0; background: var(--ec-hue-1976d2); }
/* THE DOOR TO THE REPORT, not the report (ROADMAP Task 570). The .rpt used to be a `<details>`
   with its own `<pre>` and its own Copy inside this PROGRESS box; it has a draggable, sizeable box
   of its own now (.lpn-rptbox-pre, above), and what is left here is one button that opens it.
   Quiet by default: it is an offer beside a progress bar, not a command competing with the box's
   close button. */
/* The off switch, quietest thing in the box: it is an offer, and it is permanent, so it must not
   compete with the close X for the press that means "not now". */
.lpn-runbox-hide {
	display: block; margin-top: 0.5em; font-size: .85em; color: var(--ec-ink-muted); cursor: pointer;
}
.lpn-runbox-hide input { margin-right: 0.15em; vertical-align: baseline; }
.lpn-runbox-reportbtn {
	margin-top: 0.4em; font: inherit; font-size: .85em;
	padding: 1px 8px; cursor: pointer; background: none; border: 1px solid var(--rule, var(--ec-gray-999));
	border-radius: 3px; color: inherit;
}
.lpn-runbox-reportbtn:hover, .lpn-runbox-reportbtn:focus { background: var(--ec-hue-eef3fd); }
/* ---- THE LIBRARIES BOX (ROADMAP Tasks 462 and 460) -------------------------------------------

   It borrows the Settings box's whole shell -- .lpn-popover for the size caps, .lpn-setbox for the
   drag band, the resize grabber and the two panes -- so there is exactly one box design on this
   page and a second one cannot drift from the first. What is different is only what these three
   editors need and a settings row does not: a series of numbers wide enough to read, a chart beside
   it, and a sentence that wants the width of the box.

   WIDER THAN SETTINGS, AND THAT IS THE WHOLE OVERRIDE. A settings row is a name and one small
   control; a control sentence is `LINK 335 OPEN IF NODE 1 BELOW 17.1` and a 24-point pattern is a
   line of numbers. Both are read left to right and neither wraps usefully, so the box opens at
   44rem against Settings' 34. Everything else -- the height, the minimums, the resize -- is
   inherited deliberately. */
.lpn-libbox { width: min(44rem, 94vw); }
/* ---- THE NOTES BOX (Tom, 2026-09-28) ------------------------------------------------------------
   It borrows .lpn-setbox's whole shell, same as the Library and Fire flow boxes above and below.
   Only the width is its own, kept at the 44rem the old centred popover opened with -- a column of
   defined terms reads better at Library's width than at Settings' narrower one. */
.lpn-notesbox { width: min(44rem, 94vw); }
/* ---- THE FIRE FLOW BOX (ROADMAP Task 530) -----------------------------------------------------
   It borrows .lpn-setbox's whole shell -- moveable by its chrome, resizeable by `resize: both` and
   by the touch grabber, capped to the viewport, body scrolling inside itself. Only the width is its
   own: two report tables side by side in the reading want more room than the Settings index needs,
   and less than the Library's three editors. */
.lpn-ffbox { width: min(52rem, 94vw); }
/* The Alternatives preview is one table of nine categories and three calculation options (Task
   755), so it opens wide enough to show them all without scrolling where the screen allows.
   Tom, 2026-10-04: "The box needs to be wider (like 1500 px) on PC." On a phone 94vw is unchanged. */
#lpn_alt_box { width: min(1500px, 94vw); }
/* ---- THE RUN'S OWN DIALOG (ROADMAP Task 530) --------------------------------------------------

   Progress, Stop, nothing else (Tom, 2026-08-30). It borrows .lpn-popover for the viewport caps and
   .lpn-setbox-title for the drag band, and it deliberately does NOT borrow .lpn-setbox: that shell
   opens at a fixed min(46rem, 92vh), which for a bar and a button would be a mostly-empty dialog
   covering the drawing it is reporting on. It is as tall as its own three lines.

   NARROW ON PURPOSE. On a phone this is most of the screen whatever we do, so what matters is that
   it is not ALSO tall: 22rem holds "Working: 47 of 225 junctions." on one line in English and wraps
   harmlessly in a longer language. */
.lpn-ffrunbox { width: min(22rem, 92vw); }
/* The determinate bar. Its own colour rather than a system accent, because the three verdict
   colours on this page are already spoken for and a bar in one of them would read as a verdict. */
.lpn-ff-bar {
	height: 14px; border: 1px solid var(--ec-gray-999); background: var(--ec-gray-f0f0f0); border-radius: 3px;
	overflow: hidden; margin: .2em 0 .6em;
}
.lpn-ff-bar-fill { height: 100%; width: 0; background: var(--ec-hue-1565c0); transition: width .15s linear; }
.lpn-ff-controls, .lpn-ff-report { flex: 0 0 auto; }
.lpn-ff-report { overflow: auto; min-height: 0; }
.lpn-ff-row { display: flex; align-items: baseline; gap: .5em; margin: .25em 0; }
.lpn-ff-row > span:first-child { flex: 1 1 auto; }
.lpn-ff-row > input, .lpn-ff-row > select { flex: 0 0 8rem; min-width: 0; }
.lpn-ff-unit { flex: 0 0 4rem; color: var(--ec-ink-muted); font-size: .9em; }
.lpn-ff-head { font-weight: bold; margin: .8em 0 .3em; }
.lpn-ff-note { margin: .3em 0; font-size: .9em; color: var(--ec-gray-444); }
.lpn-ff-summary { margin: .4em 0; font-weight: bold; }
.lpn-ff-buttons { margin: .6em 0; display: flex; gap: .5em; }
/* Column width is king (CLAUDE.md): the table is read in a box a reader may have made narrow, so
   the id column wraps rather than pushing the numbers off the edge. */
.lpn-ff-tablewrap { overflow-x: auto; }
.lpn-ff-table { width: 100%; border-collapse: collapse; font-size: .9em; }
/* The calculation options shown beside the alternatives (the Alternatives preview's Demand
   multiplier, Total run time and Hydraulic time step, Task 755): a divider before the first, every
   one centred, and a scenario's own value in a box narrow enough for `24:00` or `1.8`. */
.lpn-ff-table .lpn-alt-calcopt { border-inline-start: 2px solid var(--ec-gray-999); }
.lpn-ff-table .lpn-alt-opt { text-align: center; }
.lpn-alt-input { width: 4.5em; text-align: center; font: inherit; }
/* An id in the fire-flow table goes to its element (Tom, 2026-09-28: "can every node have a
   hyperlink to go to it?"): a button dressed as the link he asked for. */
.lpn-ff-goto { background: none; border: 0; padding: 0; margin: 0; font: inherit; color: var(--ec-accent); text-decoration: underline; cursor: pointer; text-align: inherit; }
.lpn-ff-goto:hover, .lpn-ff-goto:focus-visible { color: var(--ec-hue-0b0080); }
/* EVERY HEADING SORTS (Tom, 2026-09-28: "Sort Fire flow analysis columns."): the Tables pane's own
   arrow (.lpn-pane-sortarrow, below), in a gutter kept clear for it, shown on the sorted column and
   on a heading under the pointer or holding keyboard focus. */
.lpn-ff-table th.lpn-ff-sortable { position: relative; padding-inline-end: 18px; cursor: pointer; }
.lpn-ff-table th.lpn-ff-sortable .lpn-ff-sortarrow { top: 2px; }
.lpn-ff-table th.lpn-ff-sortable:hover .lpn-ff-sortarrow,
.lpn-ff-table th.lpn-ff-sortable:focus-within .lpn-ff-sortarrow { opacity: 1; }
/* COLUMN WIDTH IS KING, and ten columns in one table is where that stops being a slogan: the
   headings wrap mid-word rather than widening, and only the id and the two sentence-shaped cells
   are allowed to grow. */
.lpn-ff-table th { font-weight: bold; white-space: normal; }
/* The global `table, th, tr, td { border: 1px solid blue }` (top of this file) is the calculator
   forms' debugging grid; these tables want a hairline under each row and nothing else. */
.lpn-ff-table, .lpn-ff-table tr { border: 0; }
.lpn-ff-table th, .lpn-ff-table td { text-align: left; padding: .15em .4em; border: 0; border-bottom: 1px solid var(--ec-border); overflow-wrap: anywhere; }
.lpn-ff-table th { overflow-wrap: normal; }
/* A cell whose text is a short localized noun (the Full report's Type: Junction, Reservoir) is
   exactly as wide as that word in the current language, never broken: `anywhere` above would let
   auto layout squeeze the column to one letter ("Junctio|n"). Nothing is reserved beyond the word. */
.lpn-ff-table td.lpn-ff-fit { white-space: nowrap; overflow-wrap: normal; }
/* THE ALTERNATIVES PREVIEW (Tom, 2026-10-01: *"Alternatives columns are crazy."*): `anywhere` above
   shrinks every column's minimum to one character, so auto layout shared the width evenly and broke
   words ("Scen|ario", cell "Bas|e") beside unused padding. Here a column is exactly as wide as its
   longest heading word or its cell, and a table that cannot fit scrolls inside .lpn-ff-tablewrap.
   `width: 1%` on a heading makes the column take its minimum, which is what nowrap cells set; and
   `min-content` on the table stops those percentages stretching it to fill a wider box (Task 755). */
.lpn-ff-table.lpn-alt-table { width: min-content; }
.lpn-alt-table th { width: 1%; vertical-align: bottom; }
.lpn-alt-table td { white-space: nowrap; overflow-wrap: normal; }
.lpn-alt-table th.lpn-ff-sortable { padding-inline-end: .4em; }
.lpn-alt-table .lpn-ff-sortarrow { display: none; }
/* The criticality report has four columns, not ten, so its headings can afford to wrap between
   words: "Asset" and "Junctions cut off" never break inside a word. A table too wide for a phone
   scrolls sideways in .lpn-ff-tablewrap instead. */
.lpn-ff-table.lpn-crit-table th { overflow-wrap: normal; word-break: normal; }
/* The same three states as the rings, as a quiet tint on the row rather than as text colour: the
   verdict is already spelled out in the Result column, so this is a second channel and not the
   only one. The criticality report wears these same classes, by its own tiers. */
tr.lpn-ff-fail { background: var(--ec-error-bg); }
tr.lpn-ff-design { background: var(--ec-hue-fff4e5); }
tr.lpn-ff-error { background: var(--ec-gray-f2f2f2); }
/* **AND A NARROWER INDEX AGAIN** (Tom, 2026-08-22: "The Libraries box can have an Index pane width
   0.70 * current, at least in English"). 0.7 x the 7.5rem both boxes shipped at. It is a shorter
   list of shorter names than Settings' -- Patterns, Curves, Controls -- so it can give up more, and
   what it gives up goes to the pattern series and the control sentence, which are the two things
   this box exists to show whole. */
#lpn_library_box .lpn-setbox-index { flex-basis: 5.25rem; }
/* An entry is one pattern, one curve or one control: a block with a rule under it, so a list of
   them reads as a list of things rather than as a paragraph of controls. */
.lpn-lib-entry { padding: 6px 0 8px; border-bottom: 1px solid var(--ec-gray-eee); }
.lpn-lib-entry:last-child { border-bottom: 0; }
/* The head row: the thing's name, then its own delete, pushed to the right where a destructive
   command belongs and away from the fields the hand is aiming at. */
.lpn-lib-head { display: flex; align-items: center; gap: 6px; }
.lpn-lib-head .lpn-lib-id { width: 9em; }
.lpn-lib-del { margin-left: auto; }
/* A fitting's quantity is a small whole number; the fitting NAME beside it is what needs the room. */
.lpn-lib-qty { width: 5em; }
/* THE CURVE EDITOR'S TWO HEADER LINES (Tom, 2026-09-05: "put pump ID (with new ID label above it)
   and Description on row/line 1 and Type selector and Equation (for pump head) on row/line 2"),
   which is the shape of EPANET's own Curve Editor. Every control carries its name ABOVE it rather
   than beside it, so the two rows line up on their fields whatever the label's language does to its
   width -- a beside-label row is the one that breaks in German and in Arabic. align-items: flex-end
   so a one-line label and a two-line one still sit their boxes on the same baseline. */
.lpn-lib-curve-row, .lpn-lib-row { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; }
.lpn-lib-field { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.lpn-lib-field-grow { flex: 1 1 8em; }
.lpn-lib-fieldname { font-size: .9em; opacity: .8; }
/* The fitted equation is an ANSWER, not a box to type in: read-only, in the digits' own face so it
   lines up with the table under it, and it wraps rather than widening the panel. */
.lpn-lib-equation { font-family: monospace; font-size: .9em; padding: 2px 0; overflow-wrap: anywhere; }
/* THE SENTENCE AND THE SERIES BOTH WANT THE WIDTH. A control is one line of text and a pattern's
   multipliers are one line of numbers, and in both cases the useful thing is to see the whole of it
   at once -- which is the opposite of the settings box's capped number box, and the reason these
   are here rather than there. tabular-nums so a column of digits typed into the pattern field lines
   up with the chart above it. */
.lpn-lib-wide { width: 100%; box-sizing: border-box; font-family: inherit; }
.lpn-lib-values { font-variant-numeric: tabular-nums; }
/* The sparkline. A fixed height and a fluid width: it is a SHAPE check ("is this the daily curve I
   meant?"), not a chart to read values off, so it needs no axis and no numbers. */
.lpn-lib-spark { display: block; width: 100%; height: 46px; background: var(--ec-gray-fafafa); border: 1px solid var(--ec-border); }
/* A curve is read in two dimensions -- flow across, head up -- so it gets more height than a
   pattern, which is read only for its ups and downs along one axis. */
.lpn-lib-chart { height: 76px; }
.lpn-lib-note { font-size: .9em; opacity: .8; margin: 2px 0; }
.lpn-lib-verdict { font-size: .9em; }
/* The section a library entry list sits under. Its heading is not sticky -- unlike Settings, only
   one section is on screen at a time, so there is nothing to lose track of. */
.lpn-lib-sec { margin: 0; }
.lpn-lib-sec > h3 { margin: 0 0 4px; padding: 0 0 4px; font-size: 1.15rem; font-weight: bold; border-bottom: 1px solid var(--ec-border-strong); }

/* THE FIELDS AN INPUT UNIT DECIDES, ONE PER LINE (ROADMAP Task 425). Tom asked for a column rather
   than a comma list, and the reason is what the reader is doing with it: scanning for whether their
   own quantity is in the set, which is a column-reading job. Indented so the names read as items of
   the line above them rather than as more of the sentence. No bullets -- a marker per line would
   add three glyphs and no meaning to a list of five. */
.lpn-unit-fields { margin: 0.15em 0 0.7em 1.4em; }

/* Print preparation: the Printable version button. */
.engcalcs-print-tools > p { margin-bottom: 0.2em; }

/* ==== THE SMALL-SCREEN PRESENTATION PASS FOR THE MAP EDITOR (ROADMAP Task 486) ===============

   Tom, 2026-08-22, naming four concessions "for both crawlers and humans if they are on a
   mobile/small screen": hide page titles, hide or at least collapse the HawsEDC navbar, hide all
   the toolbar buttons except for the transport, and drop the menu bar to icons. His frame decides
   every judgement call below: *"the truth is that this is a PC app... For PC of course, but go
   ahead and try it on your phone."* So this is a DEFENSIBLE PHONE, not a phone-first redesign --
   the desktop layout above the breakpoint is untouched, byte for byte, and nothing here changes
   what any control does.

   THE BREAKPOINT IS 640px, AND IT IS THE ONE THIS PAGE ALREADY HAD. The project tab strip drops to
   the current tab alone at exactly this width, for exactly this reason ("A map page has no
   horizontal room to spare on a phone"). A second threshold would make the page reorganise itself
   twice on the way down, which is worse than either threshold being slightly wrong. It is a
   VIEWPORT, not a user agent: a phone is the case Tom named, but a narrow window on a laptop gets
   the same relief and no UA string has to be guessed at.

   SCOPED BY WHAT ONLY THIS PAGE HAS, the same way Task 432 scopes the no-scrollbar rule: <body>
   carries no page class, and the other fifteen calculators are a form and an answer whose titles
   and navbar must stay exactly as they are. A browser without `:has()` drops the rule and gets
   today's layout, which is the safe direction.

   WHAT IS DELIBERATELY NOT HIDDEN AT ANY WIDTH: #lpn_basemap_credit (tile attribution is a licence
   condition, not chrome) and every consent surface, which lives in the footer and is never
   selected here. */
@media (max-width: 640px) {

	/* 1 AND 2 ARE GONE WITH THE DIVORCE (Task 625, 2026-09-10). This block used to hide the h1,
	   the welcome line and the page description on a phone, and to shrink the suite navbar. The
	   app page emits none of those at any width now, so both rules styled nothing. What SURVIVES
	   below is everything that is the app's own chrome. */

	/* 3. THE TOOLBAR KEEPS THE TRANSPORT AND NOTHING ELSE. `.lpn-transport-btn` is the class
	   js/lpn-time.js puts on the three player controls and only on them, so this is the same line
	   the toolbar's own styling already draws between a player and an icon -- not a second, parallel
	   list of buttons that could drift from it.

	   EVERY BUTTON THIS HIDES HAS A MENU: Open/Save/Save as in File, the seven Insert tools and
	   Select/Undo/Find/Libraries/Delete in Insert and Edit, Zoom to fit in View, and
	   Settings/Profile/Tables/Run in Project. Select had none until Task 486 added the Edit row --
	   see openEditMenu(). The one control with no exact menu twin is the bottom-pane TOGGLE, and
	   the pane is still opened by Project > Tables or Project > Profile and closed by its own X.

	   THE TWO <select>s STAY. They are not buttons, and the step selector is the only control on
	   the page that says which moment is showing -- a transport you cannot read is not a transport.
	   Run is a button and is not a player control (js/lpn-time.js is explicit about that), so it
	   goes; on a project with auto-run on, which is the default, it is already hidden anyway.

	   The run group then has to give up the divider and the gap it wears as a MIDDLE group, or the
	   strip would open with a rule and 12px of air in front of its first control. */
	html:has(#lpn_canvas) #lpn_toolbar > .lpn-toolbar-group:not(#lpn_toolbar_run) { display: none; }
	html:has(#lpn_canvas) #lpn_toolbar_run button:not(.lpn-transport-btn) { display: none; }
	html:has(#lpn_canvas) #lpn_toolbar_run { border-left: 0; padding-left: 0; margin-right: 0; }
	/* Five buttons and two selectors are 333 px at the default gap; a 320 px window needs 15 back. */
	html:has(#lpn_canvas) #lpn_toolbar_run { gap: 2px; }
	html:has(#lpn_canvas) #lpn_toolbar_run .lpn-transport-btn { padding: 2px 3px; }

	/* 4. THE MENU BAR DROPS TO ICONS. The word is a <span class="lpn-menubar-word"> that
	   buildMenuBar() builds for this rule to reach; the accessible name is on the button as an
	   aria-label at every width, so what goes is the ink and not the name. Six icons instead of six
	   words is the difference between a menu bar that fits a phone and one that wraps to two lines
	   above a map. */
	html:has(#lpn_canvas) .lpn-menubar-word { display: none; }
	/* An icon alone in a box padded for a word reads as a gap with a drawing in it. */
	html:has(#lpn_canvas) .lpn-menubar-item { padding: 3px 6px; }

	/* **5. THE SETTINGS INDEX STAYS A NARROW SIDE COLUMN ON A PHONE, RESTORED 2026-08-29.**

	   **AND SINCE 2026-08-30 IT IS A TRANSFER, WHICH IS NOT THE SAME THING AS A RATIO** (Tom, having
	   used the box once the overrun was fixed: *"the main (left) pane only needs 75-80 percent of the
	   width it has... I think we should try giving the index pane 20% of the main pane's width"*, and
	   then, on being shown the other reading: *"No. I meant for you to rob 20% of the then-present
	   width of the main pane and give that to the index pane."*). **The index does not REACH 20% of
	   anything.** It keeps the width it already had and gains a fifth of the main pane on top; the
	   main pane keeps four fifths of what it had. Do not "simplify" this to a flat percentage -- a
	   flat 20% is the reading he rejected in those words.

	   The arithmetic, with P the panes' inner width and g the 10px gap:
	       index_now   = 4.5rem                        (the figure this rule carried before)
	       content_now = P - g - 4.5rem
	       index_new   = 4.5rem + 0.20 x content_now
	                   = 4.5rem + 0.2P - 0.2g - 0.9rem
	                   = 3.6rem + 0.2P - 2px
	   which is the calc below. A percentage flex-basis resolves against the flex container's inner
	   main size, and the gap is NOT deducted from it -- so the `- 2px` is 20% of the gap and is
	   exact rather than a fudge. `content_new = 0.8 x content_now` then falls out of the content
	   pane's own `flex: 1 1 auto`; it is not restated anywhere.

	   Measured in Chromium at 360x640, where P is 320.4 (94vw less 16px of padding and 2 of border):
	   the index goes 72 -> 119.7 and the main pane 238.4 -> 190.7, which is 0.800 of what it had.
	   **THE CONSEQUENCE, MEASURED RATHER THAN FEARED:** 4.5rem was chosen as the width at which the
	   CONTENT pane stopped being the constraint, and 190.7 is below it -- so the pane was re-measured
	   at the new width instead of being argued about. Exactly ONE thing in it overflows, by 17px, and
	   it is not a setting: it is the colour-ramp attribution footer, whose lines carry a bare URL
	   that no amount of pane width would have broken. It is given `overflow-wrap` at 5a below. Every
	   settings row, and the node symbology list that set the old floor, still fits.

	   **IT WAS TURNED INTO A HORIZONTAL TAB STRIP ON 2026-08-25 AND TOM REVERSED THAT** (2026-08-29:
	   *"It was good before with the right pane index. It's bad with top tabs."*). The strip was
	   argued for on the grounds that a phone should read whole words rather than wrapped ones; his
	   answer is that a scrolling row of tabs costs more than a wrapped word, because an index you
	   scroll sideways is an index you cannot see. **Do not re-propose the strip** -- it was built,
	   shipped, used and rejected.

	   **THE PROBLEM THE STRIP WAS SOLVING WAS NOT AN OPEN PROBLEM** (Tom, 2026-08-29: *"We already
	   accepted broken words. And we never discussed tabs across the top."*). Eight of the fourteen
	   labels split mid-word at 65 px -- "Visualiz/ation", "Node symbo/logy" -- and that had already
	   been weighed and accepted. Task 527 reopened a settled question, answered it with a layout
	   nobody had proposed to him, and shipped it. **The cost of a wrapped word was known; the cost of
	   a sideways-scrolling index was not, and it was worse.**

	   Recorded because the failure was one of PROCESS, not of taste: the alternatives are still on
	   file (abbreviating the section names -- new English strings and 26 translations each; or ~9 px
	   type so "Visualization" fits) and either would be a change to the WORDS, which is Tom's, not
	   to the layout, which is settled.

	   ONE RULE FOR BOTH BOXES, and it reaches the Libraries box's own `#lpn_library_box` override by
	   specificity rather than by accident: `:has()` takes the specificity of its argument, so
	   `html:has(#lpn_canvas) .lpn-setbox-index` is (1,1,1) against that rule's (1,1,0). Kept
	   deliberately -- the Libraries index is a SHORTER list of shorter names (Patterns, Curves,
	   Controls) than Settings', its box is the same 94vw on a phone, and what it gives up goes to the
	   pattern series and the control sentence, which are the two things that box exists to show
	   whole. A second phone figure there would be a second pane design.

	   **AND THE TRANSFER IS THE SAME EXPRESSION FOR BOTH, WHICH IS NOT A COINCIDENCE AND IS NOT AN
	   OVERSIGHT.** A transfer is derived from the pane's PRESENT width, and the Libraries index's
	   present width on a phone is this rule's 4.5rem -- its own 5.25rem is a DESKTOP figure, already
	   overridden here. So "a fifth of the main pane, moved across" is one calculation for both boxes,
	   and deriving a second one from 5.25rem would be transferring from a width that box does not
	   have at this size.

	   The 3.6rem and the 2px are the arithmetic above, already reduced; the 2px is a fifth of
	   `.lpn-setbox-panes`'s own 10px `gap`, restated because `calc()` cannot ask for it. */
	html:has(#lpn_canvas) .lpn-setbox-index { flex-basis: calc(3.6rem + 20% - 2px); }

	/* 5a. AND THE ONE THING THE NARROWER MAIN PANE NO LONGER FITS. Measured at 360px after the
	   transfer above: the content pane overflows by exactly 17px, and every pixel of it is the
	   colour-ramp attribution footer, whose credit lines end in a bare URL -- 208px of unbreakable
	   token in a 191px pane. The same answer the index rows got, and for the same reason: a broken
	   address is still an address, and the licence asks for the text to be readable, not unwrapped.
	   PHONE ONLY -- at every wider width it has always fitted on one line, which is how it reads
	   best. */
	html:has(#lpn_canvas) .lpn-rp-credit { overflow-wrap: anywhere; }

	/* 5b. THE BOTTOM-PANE TABLES' NUMBER BOXES (Tom, 2026-08-22: "The Tables in the bottom pane have
	   some columns, inputs I think, with artificially limited shrink that can in general be about
	   50% of current"). `.lpn-pane-table input { width: 7em }` is the only imposed width anywhere in
	   those tables -- every other column is a heading button, a plain cell or an ID link and is
	   already as narrow as its own content -- so it is the only thing there is to halve. 3.5em holds
	   five digits at this font, and an input scrolls its own content rather than truncating it, so a
	   longer number is typed and read normally in a shorter box.

	   BELOW THE BREAKPOINT ONLY. Tom's "in general" may well have meant at every width; the standing
	   rule is that the desktop layout does not move unless he asked for that layout to move, and
	   this item did not say so. Moving it out of this block is a one-line change if he confirms. */
	html:has(#lpn_canvas) .lpn-pane-table input { width: 3.5em; }

	/* 5c. AND THE PIPES TABLE'S TWO WIDEST HEADINGS COME IN WITH THEM (Tom, 2026-08-23: "Pipes is
	   the table with too much for a narrow screen, and it's not much more than a narrow screen can
	   handle... Scale the width of the Roughness input 0.75 and column 0.6, Minor loss Km input 0.5
	   and column 0.5 (approx.), just to help a little"). Ten columns of pipe measure 602px in a
	   360px panel; these two were 100.4px and 56.4px of it.

	   WRAP, NEVER ABBREVIATE. An abbreviated heading is a new English string and 26 translations of
	   it, and mid-word wrap in a column heading is already the house rule. `overflow-wrap: anywhere`
	   is what lets the declared width win: a table column can never be narrower than its own
	   min-content, and without it "Roughness" is one unbreakable 60px word.

	   MINOR LOSS STOPS AT 0.74 RATHER THAN THE 0.50 ASKED FOR, AND THE REASON IS VERTICAL. Its
	   heading is the one that has to break mid-word to get narrow, and every break is another line
	   in a heading row inside a pane 260px tall: at 0.50 the column is 31px and "Minor loss, k"
	   becomes SIX lines, 129.7px against the 86.4px "Head loss (ft H2O)" already costs. 2.9em is the
	   last width that still fits in four -- so the row is no taller than it was, and the 43px this
	   saves vertically is worth more than the 14px it gives up sideways. The input inside it is the
	   0.5 Tom asked for either way. Sideways scroll stays the fallback: this is a nudge, not a
	   re-layout, and the table is still wider than the phone. Measured on Elm Street Center at
	   360px: Roughness 100.4 -> 60.5, Minor loss 56.4 -> 41.8, the table 602 -> 547. */
	/* ROUND 3 (Tom, 2026-08-23) WIDENED BOTH AGAIN, and the two asks landed differently here.
	   Minor loss he scaled "1.25 to 1.5 on PC and phone", so the phone box takes the same 1.4 the
	   desktop one took: 2.19 -> 3em, and the column with it, 2.9 -> 3.7em. Widening that column
	   only REDUCES the heading lines the note above counted, so the vertical argument still holds.

	   ROUGHNESS STOPS AT THE PHONE DEFAULT RATHER THAN THE 2.5 ASKED FOR. He named no platform for
	   it, unlike Diameter's explicit "on PC", so it is meant to move here too -- but 2.6 x 2.5 is
	   6.5em, which is most of a 360px panel and would undo the phone ruling he made the day before
	   for the opposite reason. 3.5em is where the two meet: it is the width every other box in
	   these tables already has below the breakpoint, so Roughness stops being the one column
	   NARROWER than the default, and an input scrolls its own content, so a long e is still typed
	   and read normally. Say so if the full 2.5 is wanted on a phone as well. */
	/* ROUND 4 (Tom, 2026-08-25, from a phone) BUYS THE WORD BACK AND PAYS 28 px FOR IT. The Pipes
	   heading read "Roughnes/s, C"; the heading wants to break at its comma, "Roughness," over "C",
	   and could not, because `overflow-wrap: anywhere` lets a column be narrower than its own longest
	   word and round 3's 5em is exactly that.

	   THE PRICE IS NOT NEGOTIABLE AND IT WAS MEASURED, not reasoned: "Roughness," is 94.4 px at this
	   cell's own bold 14.4 px, so ANY layout that keeps the word whole makes this column 100.4 px --
	   which is what it measured before round 2 narrowed it. There is no width between 72 and 100 that
	   helps; 5.9em and 6.4em were both tried and both still split the word. So the choice is the word
	   or the 28 px, and Tom's 2026-08-25 ruling is the later one: he read "Roughnes/s" and called it a
	   defect. The table is 547 -> 575 px in a 360 px panel and was already scrolling sideways.

	   `break-word`, NOT the removal of the property: it keeps the last-resort split for a language
	   whose single word is longer than the panel, while putting the column's floor at the longest
	   word instead of at one character. The km column keeps `anywhere` -- its longest word is "Minor"
	   at ~34 px in a 47 px column, so it has never split and nothing there is being paid for. */
	html:has(#lpn_canvas) .lpn-pane-table .lpn-pane-col-roughness {
		overflow-wrap: break-word; width: 5em; min-width: 5em;
	}
	html:has(#lpn_canvas) .lpn-pane-table .lpn-pane-col-roughness input { width: 3.5em; }
	html:has(#lpn_canvas) .lpn-pane-table .lpn-pane-col-km {
		overflow-wrap: anywhere; width: 3.7em; min-width: 3.7em;
	}
	html:has(#lpn_canvas) .lpn-pane-table .lpn-pane-col-km input { width: 3em; }

	/* 6. THE SYMBOLOGY ROWS WRAP AS A GROUP (Tom, 2026-08-22, choosing between his own two ideas:
	   "Idea B is to wrap the four inputs for Before/After/Decimal/Drop together below the checkbox
	   and label, and this is a better balance of width savings. So for the first row we would have
	   them wrap gracefully to `checkbox ID\nbefore after decimal drop`"). Idea A -- the label first
	   and everything else wrapping under it individually -- he weighed and rejected.

	   THE FOUR STAY ONE GROUP because the label takes a whole line to itself (`flex-basis: 100%`)
	   and nothing else can share it. Wrapping them individually would let a row break between After
	   and Decimal, and a column that is on line two of one row and line one of the next is not a
	   column at all.

	   The name's 11.5rem ceiling goes with it: that ceiling exists to stop the four boxes walking
	   right as the box widens, and there is nothing to walk right of once they are on their own
	   line. `!important` on the flex for the same reason as the widths below: labelCheckbox() writes
	   `flex: 1 1 auto` inline, and an inline declaration beats a stylesheet one.

	   THE HEADING ROW'S LEAD CELL GOES, so the headings start at the left edge exactly where the
	   wrapped group does. `display: none` does not renumber :nth-child, so the width rules below --
	   and the ones above, which they override -- still count the lead as child 1. */
	html:has(#lpn_canvas) #lpn_labels_node_fields > div,
	html:has(#lpn_canvas) #lpn_labels_link_fields > div,
	html:has(#lpn_canvas) #lpn_labels_customer_fields > div { flex-wrap: wrap; }
	html:has(#lpn_canvas) #lpn_labels_node_fields > div > label,
	html:has(#lpn_canvas) #lpn_labels_link_fields > div > label,
	html:has(#lpn_canvas) #lpn_labels_customer_fields > div > label { flex: 1 1 100% !important; max-width: none; }
	html:has(#lpn_canvas) #lpn_labels_node_fields > div > span:first-child,
	html:has(#lpn_canvas) #lpn_labels_link_fields > div > span:first-child,
	html:has(#lpn_canvas) #lpn_labels_customer_fields > div > span:first-child { display: none; }

	/* 6a. **AND THE WHITE SPACE GOES ABOVE THE NAME, NEVER BELOW IT** (Tom, 2026-08-31, on a phone:
	   "the toggles and labels ... have a little gap below them from the row they belong with, but no
	   gap above them from the row they don't belong with. This would be good to reverse.").

	   Stated once beside the container-query copy of item 6 above, which carries the measurement and
	   the reasoning; it is repeated here for the same reason every other rule in item 6 is -- a
	   browser without container queries must still get the phone layout. Same declarations, so the
	   two firing together is idempotent. */
	html:has(#lpn_canvas) #lpn_labels_node_fields > div,
	html:has(#lpn_canvas) #lpn_labels_link_fields > div,
	html:has(#lpn_canvas) #lpn_labels_customer_fields > div { row-gap: 0 !important; }
	html:has(#lpn_canvas) #lpn_labels_node_fields > div:not(:first-child),
	html:has(#lpn_canvas) #lpn_labels_link_fields > div:not(:first-child),
	html:has(#lpn_canvas) #lpn_labels_customer_fields > div:not(:first-child) { margin-top: 6px; }

	/* 7. AND THE COLUMNS THEMSELVES SHRINK (Tom: "Let's get brave and use these shrink factors on
	   the column inputs: Before 0.8, After 0.4, Decimal 0.8"). Against the shipped 2.6rem affix and
	   3.2rem numeric columns that is 2.08 / 1.04 / 2.56 / 2.56rem. `!important` because
	   labelCheckbox() writes these widths inline on the <input>s; the heading <span>s are matched by
	   the same selectors so a heading can never come away from the box it names.

	   ON THE HEADINGS Tom offered three answers and this takes two of them: (a) the narrow widths
	   only on the small screen, and (c) live with the wrapping. Not (b), abbreviations -- four new
	   English strings and 104 translations for a phone-only cosmetic gain, against a house rule that
	   already says mid-word wrap in a column heading is acceptable and column width is king. */
	html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(2),
	html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(2),
	html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(2) { width: 2.08rem !important; }
	/* **EXCEPT After, WHICH CAME BACK TO 1.6rem** (Tom, 2026-08-23, after using the phone layout:
	   "After 2.0 (make it like Decimal and Drop)"). The two instructions do not agree and the
	   PARENTHESIS is the one to follow: 2.0 x 1.04 = 2.08rem would make After the WIDEST of the four
	   on a touch screen, where Decimal and Drop are 1.6rem -- the opposite of "like" them. So it
	   lands on their number.

	   1.6rem is ~26 px, which holds two characters of content once the input's own padding and
	   border are paid -- enough for a bare `ft` or `m`, and a longer affix scrolls inside its box
	   rather than being cut off. At 1.04rem it could not show one. Children 3 and 4 are Use units
	   and After since R-331; the tick column takes After's width, and its heading wraps inside it
	   (7a). Children 5-7 are Decimals, Show and Drop. */
	html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(3),
	html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(3),
	html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(3),
	html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(4),
	html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(4),
	html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(4) { width: 1.6rem !important; }
	html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(5),
	html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(5),
	html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(5),
	html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(6),
	html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(6),
	html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(6),
	html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(7),
	html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(7),
	html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(7) { width: 1.8rem !important; }
	/* **1.8rem, NOT 2.3** (Task 760). With the spinner a number box wants 2.3rem, but the six boxes and
	   their five 4px gaps then came to 215 px, plus the list's 8px indent, against the 201.6px content
	   pane a 360px window leaves, so the pane scrolled sideways on every non-touch narrow window (a
	   touch phone takes the 1.6rem rule below and never did). 2.08 + 1.6 + 1.6 + 3 x 1.8 = 10.68rem
	   and 20px of gaps is 191px, 199 with the indent. */
	/* 7a. **AND A HEADING THAT DOES NOT FIT ITS COLUMN MUST WRAP INSIDE IT, NOT SPILL OUT OF IT**
	   (ROADMAP Task 527). Tom, 2026-08-25, from a phone: the four headings read "BeforeAfter 0.000
	   Drop", touching each other and out of line with the boxes beneath them. The widths above were
	   applied and are exactly right -- measured at 360 px, every heading's BOX sits on its control's
	   box to a tenth of a pixel. What is wrong is the ink: "Before" is one unbreakable word about
	   40 px wide in a 33 px column, and with nothing to break it a span simply paints past its own
	   edge and into its neighbour.

	   So the wrapping the note above chose -- Tom's option (c), against (b) abbreviations -- was
	   never actually reachable, because a word with no break opportunity does not wrap. This is the
	   one property that gives it one. Mid-word wrap in a column heading is the house rule (CLAUDE.md:
	   column width is king), and it applies here in a way it does not to the Settings index above:
	   these are four columns of a table, not a list of destinations.

	   THE HEADING ROW ONLY, matched as the list's first child, so the inputs below keep the sizing
	   they have. */
	html:has(#lpn_canvas) #lpn_labels_node_fields > div:first-child > span,
	html:has(#lpn_canvas) #lpn_labels_link_fields > div:first-child > span,
	html:has(#lpn_canvas) #lpn_labels_customer_fields > div:first-child > span { overflow-wrap: anywhere; }

	/* The list's own floor comes down with them, or the box would still refuse to narrow past a
	   width the rows no longer need. 11.4rem is the six boxes (2.08 + 1.6 + 1.6 + 3 x 1.8 = 10.68rem)
	   plus their five 4px gaps; the name above them wraps between words. */
	html:has(#lpn_canvas) #lpn_labels_node_fields,
	html:has(#lpn_canvas) #lpn_labels_link_fields,
	html:has(#lpn_canvas) #lpn_labels_customer_fields { min-width: 11.4rem; }

	/* 7b. **AND THE NAME ON THAT FIRST LINE NEVER BREAKS MID-WORD** (Tom, 2026-08-23, drawing three
	   panels: the roomy desktop row, then the cramped one reading `Dem/and`, `Hea/d`, `Pres/sure`,
	   `Elev/ation`, then what he wants -- the checkbox and the whole name on line 1, the four inputs
	   on line 2).

	   The mid-word break is `overflow-wrap: anywhere` on the label, and that rule is RIGHT where it
	   was written: on the desktop the name shares its line with the four columns, and a name whose
	   min-content width exceeds its share pushes every column out of line with its heading (Task
	   435, twice). None of that survives item 6 above -- here the name is `flex: 1 1 100%` and owns
	   the whole row -- so the reason for breaking anywhere is gone and only the ugliness is left.

	   `normal`, NOT `nowrap`. Every English name fits its line at 360 px, which is the layout Tom
	   drew; the longest TRANSLATION does not -- "Gradijent gubitka tlačne visine" (hr, 31 characters;
	   ro and id are the same length) wants ~230 px against the ~205 px the content pane leaves at
	   that width. `nowrap` would answer that with the sideways scrollbar this box has twice been
	   narrowed to get rid of, on the one list every visitor opens. A break BETWEEN WORDS is not the
	   defect he objected to: `Gradijent gubitka` over `tlačne visine` is still whole words, and it
	   costs nothing in any language whose name already fits on the line. */
	html:has(#lpn_canvas) #lpn_labels_node_fields > div > label,
	html:has(#lpn_canvas) #lpn_labels_link_fields > div > label,
	html:has(#lpn_canvas) #lpn_labels_customer_fields > div > label {
		overflow-wrap: normal; word-break: normal;
	}

	/* 8. NO SPINNER ON A TOUCH SCREEN, AND THE WIDTH THAT PAID FOR IT COMES BACK (Tom: "since the
	   spinner is not available on touch screen, drop the spinner on touch screen and on touch screen
	   CSS only"). `.ec-spin` exists to put the native up/down arrows BACK on these two columns after
	   the suite strips them everywhere else, and a touch keyboard draws no arrows to put back -- so
	   on a coarse pointer the column is paying ~17 px for furniture that is not there.

	   **A MEDIA QUERY IS A VIEWPORT TEST, NOT A TOUCH TEST**, which is Tom's own caution, so the
	   touch half is asked as a touch question -- and asked INSIDE the 640px block on purpose. Nested
	   this way the desktop above the breakpoint is provably untouched at any pointer type, including
	   a touchscreen laptop and a tablet held in landscape. What it gets wrong is the narrow window
	   on a touch tablet: it keeps the wider boxes it does not need. That is the cheap direction.

	   1.6rem, NOT the 0.2 x 3.2rem = 0.64rem Tom named. 0.64rem is 10 px, which with an input's own
	   padding and border leaves about 4 px of content -- it cannot show ONE digit, and these two
	   columns are bounded at 32 and 99, so they must show TWO. 1.6rem is the honest floor for two
	   digits and is still half the shipped width. It is also, since item 7's correction, exactly what
	   After is -- which is what Tom asked for when he said to make After like Decimal and Drop, so
	   on a touch screen they are one width and only Before is wider. The floor is 11.4rem: 2.08 +
	   five at 1.6 = 10.08rem of boxes (Use units, After, Decimals, Show, Drop) plus five 4px gaps.
	   On a touch screen these three boxes are plain text boxes with a digit keypad anyway
	   (labelNumberBox(), R-330), so the spinner rules below only matter to a stray number box. */
	@media (hover: none) and (pointer: coarse) {
		html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(5),
		html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(5),
		html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(5),
		html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(6),
		html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(6),
		html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(6),
		html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(7),
		html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(7),
		html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(7) { width: 1.6rem !important; }
		html:has(#lpn_canvas) #lpn_labels_node_fields input.ec-spin,
		html:has(#lpn_canvas) #lpn_labels_link_fields input.ec-spin,
		html:has(#lpn_canvas) #lpn_labels_customer_fields input.ec-spin { appearance: textfield; -moz-appearance: textfield; }
		html:has(#lpn_canvas) #lpn_labels_node_fields input.ec-spin::-webkit-outer-spin-button,
		html:has(#lpn_canvas) #lpn_labels_node_fields input.ec-spin::-webkit-inner-spin-button,
		html:has(#lpn_canvas) #lpn_labels_link_fields input.ec-spin::-webkit-outer-spin-button,
		html:has(#lpn_canvas) #lpn_labels_customer_fields input.ec-spin::-webkit-outer-spin-button,
		html:has(#lpn_canvas) #lpn_labels_link_fields input.ec-spin::-webkit-inner-spin-button,
		html:has(#lpn_canvas) #lpn_labels_customer_fields input.ec-spin::-webkit-inner-spin-button {
			appearance: none; -webkit-appearance: none; margin: 0;
		}
		html:has(#lpn_canvas) #lpn_labels_node_fields,
		html:has(#lpn_canvas) #lpn_labels_link_fields,
		html:has(#lpn_canvas) #lpn_labels_customer_fields { min-width: 11.4rem; }

	}

	/* **AND BELOW 340px THE COLUMNS NARROW AGAIN** (Task 760): at 320px the pane is 170px, and 11.4rem
	   of columns is 182. Before 1.6, Use units and After 1.2, the three numbers 1.6 = 8.8rem and 20px of
	   gaps, 161px, 169 with the list's indent, for every pointer type. */
	@media (max-width: 340px) {
		html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(2),
		html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(2),
		html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(2) { width: 1.6rem !important; }
		html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(3),
		html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(3),
		html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(3),
		html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(4),
		html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(4),
		html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(4) { width: 1.2rem !important; }
		html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(5),
		html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(5),
		html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(5),
		html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(6),
		html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(6),
		html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(6),
		html:has(#lpn_canvas) #lpn_labels_node_fields > div > :nth-child(7),
		html:has(#lpn_canvas) #lpn_labels_link_fields > div > :nth-child(7),
		html:has(#lpn_canvas) #lpn_labels_customer_fields > div > :nth-child(7) { width: 1.6rem !important; }
		html:has(#lpn_canvas) #lpn_labels_node_fields,
		html:has(#lpn_canvas) #lpn_labels_link_fields,
		html:has(#lpn_canvas) #lpn_labels_customer_fields { min-width: 9.6rem; }
	}

	/* 5. THE TWO MAP OVERLAYS DO NOT GET TO EAT THE DRAWING (ROADMAP Task 524). Tom, 2026-08-25,
	   from a phone: the EPANET minor-loss note "is stuck open". It is not stuck -- #lpn_status is an
	   advisory overlay that stands until the next solve replaces it, which is harmless in a desktop
	   corner and is a quarter of the canvas on a phone, sitting on the network.

	   #lpn_mode_hint GOES, and it goes because on touch it is not merely large but WRONG: "Click an
	   asset... Drag to move... Double-click a pipe to add or remove a vertex" are mouse verbs, and
	   there is no double-click on a phone. Task 253's clean-map toggle already hides exactly this
	   element plus the coordinate readout, so a map without it is a state the page is known to
	   survive -- this is that state, arrived at by screen size instead of by a menu.

	   #lpn_status STAYS, because it is where a refusal to solve is announced and losing that would
	   be the worse failure. It is bounded instead:

	   - `max-width` goes UP, from the inline 60% to 92%. On a 400px phone 60% is 240px, and a
	     narrow box makes a long note TALLER -- the inline value was tuned for a desktop where 60%
	     is roomy, and it is exactly backwards here.
	   - `max-height` caps it at a third of the short side and lets it scroll, so no note of any
	     length can take more of the drawing than that. */
	html:has(#lpn_canvas) #lpn_mode_hint { display: none; }
	html:has(#lpn_canvas) #lpn_status {
		max-width: 92%;
		max-height: 33svh;
		overflow-y: auto;
	}

	/* 9. THE ON-MAP ZOOM CHIP GOES BELOW ITS OWN BREAKPOINT (ROADMAP Task 682; Ida, 2026-09-17):
	   the whole reason it exists is a visitor with no wheel and no pinch surface, and a
	   touchscreen supplies one of its own -- Tom, 2026-08-22: "on a phone, zoom in is the answer.
	   You can't see through your finger... A finger is not a mouse!" Hidden at the same width the
	   toolbar's non-transport groups already collapse at, not a new figure. */
	html:has(#lpn_canvas) #lpn_zoom_control { display: none; }
}

/* The on-map zoom chip's layout lives here, not inline, so the phone rule above can hide it: an
   inline display would beat any stylesheet rule (Perry, 2026-09-23). */
#lpn_zoom_control { display: flex; flex-direction: column; }

/* R-179 (Tom, 2026-09-23): "The + and - glyphs are not centered in their boxes." Each button had
   no layout of its own, so the icon fell back to being an ordinary inline glyph -- `button >
   .ec-icon`'s `margin-inline-end: 0.4em` (the gap it earns everywhere it prefixes a WORD) shoved
   it left of centre here, where there is no word to gap from, and `.ec-icon`'s
   `vertical-align: -0.16em` (tuned to sit on a line of TEXT's optical centre) had no baseline to
   sit on in an otherwise-empty button. Flex centring on the button answers both axes at once, and
   the margin is zeroed the same way the lpn menu's icon column already zeroes it for its own
   icon-only cells. */
#lpn_zoom_in, #lpn_zoom_out { display: flex; align-items: center; justify-content: center; }
#lpn_zoom_in > .ec-icon, #lpn_zoom_out > .ec-icon { margin-inline-end: 0; }

/* ---- The profile's path chooser (Tasks 433, 504) ---------------------------------------------
   The line that says what the next press does. It lives in the profile panel's control row, which
   is `font-size: .9em` already, and since Task 504 it is the ONLY thing in there that changes --
   the button that used to arm the gesture is the Profile button itself now. */
.lpn-profile-say:empty { display: none; }
.lpn-profile-say { margin: .25em 0 .4em; line-height: 1.3; }
/* The candidate route and the ring on the node under the pointer. `pointer-events: none` is already
   on the whole layer; this only exists so the dashes cannot be mistaken for the committed route by
   anyone reading the SVG, and so a future stylesheet has one name to reach for. */
.lpn-profile-ghost { pointer-events: none; }

/* ---- The profile has no side interface (Task 504) --------------------------------------------
   Tom, 2026-08-24: *"I still think we can ditch the entire side interface for the profile."* With
   the pull-downs, the chooser button and the waypoint chips gone, what stood in a 15rem column was
   four lines of TEXT holding a quarter of the pane's width away from the drawing it describes.

   So the column becomes a header ROW and the chart gets the whole width. The objection the old
   layout was built on -- that a row spends the pane's scarcest dimension, its height -- was right
   about a stack of controls and is wrong about one wrapping line of text: the title, the key and
   the commentary sit on one baseline and cost about 1.4em, where the column cost 15rem of width
   for the life of the pane.

   These rules come after the ones they replace, so the column declarations above are simply
   overridden rather than deleted -- the markup is unchanged and #lpn_profile_chart still flexes. */
.lpn-profile-panel { flex-direction: column; }
.lpn-profile-controls {
	flex: 0 0 auto;
	display: flex;
	flex-wrap: wrap;
	align-items: baseline;
	gap: .1em 1em;
	overflow: visible;
}
.lpn-profile-controls > * { margin: 0; }
/* The commentary line is the only thing left that CHANGES, so it is the one allowed to take the
   rest of the row -- and to be the item that wraps to a line of its own when the pane is narrow. */
/* #lpn_ts_form is the same row in the same panel (Task 599): two pull-downs, a button and one chip
   per plotted asset, wrapping on a narrow pane exactly as the profile's commentary does. */
#lpn_profile_form, #lpn_ts_form { flex: 1 1 16em; min-width: 12em; display: flex; flex-wrap: wrap;
	align-items: baseline; gap: .1em .6em; }
.lpn-profile-say { margin: 0; }
/* ---- The Edit door and the box behind it (Task 509) -----------------------------------------
   The panel's ONE control. It is a small button on the commentary line rather than anything larger,
   because the entry point is not the feature: the feature is the overlay it opens, and the line
   beside it still has to be the thing the eye reads first. */
.lpn-profile-edit {
	flex: 0 0 auto; font: inherit; font-size: .95em; padding: 1px 8px;
	border: 1px solid var(--ec-gray-bbb); border-radius: 3px; background: var(--ec-gray-f6f6f6); cursor: pointer;
}
.lpn-profile-edit:hover, .lpn-profile-edit:focus { background: var(--ec-gray-eaeaea); border-color: var(--ec-gray-888); }
/* **PRESSED MEANS THE PATH IS IN EDIT MODE** (Task 509) -- the drawing is now carrying handles, and
   a mode whose only evidence is on the map is a mode somebody gets stuck in. The toolbar's own
   pressed colours, not a new pair, because it is the same fact wearing the same clothes. */
.lpn-profile-edit[aria-pressed="true"] { background: var(--ec-pressed-bg); border-color: var(--ec-pressed-border); }
/* The grab handles themselves. A SOLID dot is a stop the reader chose -- drag it to move it, click
   it to take it off; a HOLLOW one is a node the route merely passes through, and dragging it makes
   it a stop. Told apart by fill rather than by hue, so the distinction survives a colour-blind
   reader and a grey print. Sized in the drawing's own units by the code, which is why nothing here
   sets a radius. */
.lpn-profile-handle { pointer-events: none; }
/* A waypoint, and the Remove-all beside it. Each chip removes ITSELF -- the × is part of the label
   rather than a second control inside the chip, so the whole chip is the target a hand can hit. */
.lpn-profile-chip {
	font: inherit; font-size: .9em; margin: 0 .25em .25em 0; padding: 1px 6px;
	border: 1px solid var(--ec-gray-bbb); border-radius: 10px; background: var(--ec-gray-f6f6f6); cursor: pointer;
}
.lpn-profile-chip:hover, .lpn-profile-chip:focus { background: var(--ec-gray-eaeaea); border-color: var(--ec-gray-888); }

/* ---- The time-series chart (Task 599) -------------------------------------------------------
   The panel, the control row and every piece of axis furniture are the PROFILE's -- .lpn-ts-panel
   is on the same element as .lpn-profile-panel, and the SVG uses .lpn-profile-svg, -frame, -grid,
   -axis, -tick and -axistitle unchanged. What is left here is only what a chart of N lines has and
   a chart of two fixed ones does not.

   **A SERIES COLOR IS AN INLINE ATTRIBUTE AND CANNOT BE A CLASS**, which is the one place this
   chart leaves the stylesheet: the number of lines is the number of assets somebody chose, so the
   color is assigned by INDEX in js/looped-network.js (LPN_TS_COLORS, ColorBrewer Dark2) and written
   onto the element. Everything about the line that is NOT its color is here, so a weight or a join
   is still changed in one place. */
.lpn-ts-line { fill: none; stroke-width: 1.5; stroke-linejoin: round; }
.lpn-ts-dot { stroke: none; }
/* Where the transport is parked. Dashed and grey on purpose: it is the one mark on the chart that
   is not a reading, so it must not read as a series. */
.lpn-ts-now { stroke: var(--ec-gray-888); stroke-width: 1; stroke-dasharray: 3, 3; }
/* The two pull-downs sit on the control row's baseline with the chips and the buttons, so they take
   the row's own type size rather than the browser's default form size. */
.lpn-ts-pick { font: inherit; font-size: .95em; max-width: 12em; }
/* The chip IS the key, so it carries its line's color. A dash rather than a block: the thing it
   names is a line, and a square would read as a filled band the way the profile's pressure key
   deliberately does. */
.lpn-ts-swatch {
	display: inline-block; width: 1.1em; height: 0; margin-right: .35em;
	border-top: 2px solid currentColor; vertical-align: middle;
}
/* ---- The graph at the foot of the Properties box (Task 637, revised) -------------------------
   Drawn by the same renderer as the Time series tab, so every mark above applies unchanged. The
   host has a FIXED, modest height and a fluid width: the box is narrow and on a phone it is most of
   the screen, so the chart takes the box's width and never more than about a third of a tall
   phone's height. The row above it is one label and one pull-down that may wrap. */
.lpn-pgraph { margin-top: .5em; padding-top: .4em; border-top: 1px solid var(--ec-border); }
.lpn-pgraph-row { display: flex; flex-wrap: wrap; align-items: center; gap: .25em .5em; }
.lpn-pgraph-row .lpn-ts-pick { max-width: 100%; }
.lpn-pgraph-chart { width: 100%; height: 150px; }
/* ---- The Calibration report (Task 601) -----------------------------------------------------------
   Built from the time-series chart's own pieces (.lpn-profile-svg and friends, the series colors,
   the chip-as-key) plus the pane's tab look for EPANET's three pages. A chart inside a box has no
   pane to take its height from, so it is given one; the SVG scales to the box's width. */
.lpn-calib-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin-bottom: 6px; }
.lpn-calib-tabs { display: flex; flex-wrap: wrap; gap: 2px; margin-top: 8px; border-bottom: 1px solid var(--ec-gray-bbb); }
.lpn-calib-page { padding-top: 8px; }
.lpn-calib-chart { height: 300px; min-width: 260px; }
.lpn-calib-legend { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.lpn-calib-legend .lpn-profile-chip { cursor: default; }
.lpn-calib-network td { font-weight: bold; }
.lpn-calib-diagonal { stroke-dasharray: 4, 3; }
.lpn-calib-point { opacity: .85; }
/* A measured value on the time-series chart: hollow, in its asset's color (an inline stroke, for
   the reason .lpn-ts-line gives), so a ring on top of a computed dot shows both. */
.lpn-calib-ring { fill: var(--ec-bg); stroke-width: 1.5; }
/* ---- The saved-path menu on the Profile tab (Task 510) ---------------------------------------
   An arrow ON the tab, not a second tab and not a toolbar button: Tom's placement, 2026-08-24, and
   it puts the list of names where the thing they name already lives.

   **IT IS THE PROJECT TAB'S CARET, NOT A THIRD TREATMENT** (Tom, 2026-08-25: *"The saved paths
   arrow is not designed right. It should be similar to the Project arrow and the Google Sheets tab
   arrow. It's too small and non-conforming to be discoverable."*). The first cut was .8em type in
   5px of padding, pulled 8px back INTO the tab's own padding -- a 17px mark that read as part of
   the word rather than as a control. .lpn-tab-caret above is this page's existing answer to
   exactly this problem on the project tabs, and it is the spreadsheet arrow Tom named, so the
   numbers here are its numbers: full type, the tab's own vertical padding, and a hairline rule
   separating it from the word instead of an overlap.

   **AND IT IS A TOUCH TARGET, which on THIS page needs saying.** The drawing surface is designed
   for a pointer and a 44px rule has no standing there; this is chrome, and a phone user has to hit
   it -- so the width floor rises to 44px below 40rem while the tabs beside it are giving padding
   up. It grows sideways rather than downward on purpose: the strip sits above a chart that has the
   least height to spare on a phone (Task 527), and a taller row would be paid for out of that. */
/* The arrow rides on its tab's own card, so a tab with one does not read as a tab plus a stray
   button -- the same reason .lpn-pane-tab-menu-current below matches the selected tab. */
.lpn-pane-tab-menu {
	background: var(--ec-gray-f0f0f0); border: 1px solid var(--ec-gray-dcdcdc); border-bottom: 0;
	border-inline-start: 1px solid var(--ec-border-strong); font: inherit;
	padding: 3px 8px; margin-inline-start: -2px; min-width: 2rem;
	cursor: pointer; color: var(--ec-ink);
}
.lpn-pane-tab-menu:hover, .lpn-pane-tab-menu:focus { background: var(--ec-border); }
/* The tab and its arrow, as one flex item of the strip, so a wrap can never strand the arrow. */
.lpn-pane-tab-pair { display: inline-flex; flex-wrap: nowrap; align-items: flex-start; }
/* Joined to the tab it belongs to, the way .lpn-tab-caret is joined to .lpn-tab-current: one
   selected tab carrying a caret, not a selected tab beside an unselected button. */
.lpn-pane-tab-menu-current { background: var(--ec-bg); border-color: var(--ec-gray-bbb); border-inline-start-color: var(--ec-border-strong); }
@media (max-width: 40rem) {
	.lpn-pane-tab-menu { min-width: 2.75rem; padding: 3px 12px; }
}
@media (max-width: 40rem) {
	/* The stacked layout already scrolls the panel; the header row must not also become a column
	   of four lines on a phone, where the chart has the least height to spare. */
	.lpn-profile-panel { overflow: auto; }
}

/* **A LONG PRESS MUST NOT BECOME A SELECTION OR A CALLOUT** (Task 504). The waypoint press is a
   held one, and a held finger on a phone is also the browser's own gesture for "select this text"
   or "show me a menu about this". `touch-action: none` above already stops the pan/zoom half; these
   two stop the other half. On the canvas rather than on the chooser, because there is nothing on
   this drawing a visitor selects as text in any mode. */
#lpn_canvas { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }


/* **THE ENGINE-WAIT PROGRESS BAR** (ROADMAP Task 608; Tom, 2026-09-19: "We must include the
   unknown in the progress bar. The progress bar can stall at the end if necessary. But it can't
   disappear prematurely."). Sits directly under #lpn_engine_banner and is written only by
   refreshEpanetBanner() in js/looped-network.js.

   TWO STATES, and the second one is the whole reason this is a bar and not a number. Where the
   transfer states a size, the fill is a width in percent. Where it states NONE -- a gzipped reply,
   or no content-length -- .lpn-engine-bar-unknown runs a slab back and forth, so the unknown is IN
   the bar rather than a bar that is missing. Neither state ever reaches the end on its own: the
   last tenth is reserved for the unmeasured tail after the bytes land. */
.lpn-engine-bar {
	max-width: 60%;
	height: 6px;
	margin: 0;
	background: var(--ec-a-255-255-255-9);
	border: 1px solid var(--ec-hue-05a);
	border-top: 0;
	overflow: hidden;
}
.lpn-engine-bar-fill {
	height: 100%;
	width: 0;
	background: var(--ec-hue-05a);
	transition: width .2s linear;
}
.lpn-engine-bar-unknown .lpn-engine-bar-fill {
	width: 30%;
	transition: none;
	animation: lpn-engine-bar-sweep 1.4s ease-in-out infinite;
}
@keyframes lpn-engine-bar-sweep {
	0%   { margin-left: 0; }
	50%  { margin-left: 70%; }
	100% { margin-left: 0; }
}
@media (prefers-reduced-motion: reduce) {
	/* A bar that cannot sweep still has to SAY it is working, so it pulses in place rather than
	   standing still, which would read as a stalled download instead of an unknown size. */
	.lpn-engine-bar-unknown .lpn-engine-bar-fill {
		animation: lpn-engine-bar-pulse 2s ease-in-out infinite;
	}
	@keyframes lpn-engine-bar-pulse {
		0%, 100% { opacity: 1; }
		50%      { opacity: .35; }
	}
}
.lpn-menu-row:focus-visible:enabled { background: var(--ec-hover-bg); }
/* KEYBOARD MODE (Task 748): a small boxed Latin letter at the corner of each menu-bar item, the
   key after Alt+Shift (Ctrl+Option on a Mac). Absolutely placed on the inline-end corner, so showing
   it reflows nothing and mirrors in right-to-left pages; never an underline, which fails in the
   scripts that have no underlinable letters. */
.lpn-menubar-item { position: relative; }
.lpn-kbdbadge { display: none; }
.lpn-kbdmode .lpn-kbdbadge {
	display: block; position: absolute; inset-inline-end: 0; inset-block-end: -7px; z-index: 2;
	min-width: 12px; padding: 0 2px; box-sizing: border-box; text-align: center;
	font: bold 10px/12px monospace; color: var(--ec-bg); background: var(--ec-accent-open);
	border: 1px solid var(--ec-bg); border-radius: 3px; pointer-events: none;
}
/* A menu row's own letter (Task 748): the same box, on the inline-start corner of the row's icon
   cell, so it covers no text and moves nothing when it appears. It is the row's label's own
   letter, or a digit where the label has none a keyboard types directly. Generated content, so the
   row's text stays its label; the "/ ''" alternative keeps it out of the accessible name. */
.lpn-menu-icon { position: relative; }
.lpn-kbdmode .lpn-menu-icon[data-mnemonic]::after {
	content: attr(data-mnemonic); content: attr(data-mnemonic) / '';
	display: block; position: absolute; inset-inline-start: -6px; inset-block-end: -3px;
	min-width: 12px; padding: 0 2px; box-sizing: border-box; text-align: center;
	font: bold 10px/12px monospace; color: var(--ec-bg); background: var(--ec-accent-open);
	border: 1px solid var(--ec-bg); border-radius: 3px; pointer-events: none;
}
/* The existing keyboard shortcut of a row's command, right-justified and muted, always shown, as
   every desktop menu does. Isolated, so a key name reads left to right inside an RTL row. */
.lpn-menu-row[data-hotkey]::after {
	content: attr(data-hotkey); content: attr(data-hotkey) / '';
	float: inline-end; margin-inline-start: 2.5em; color: var(--ec-ink-subtle); unicode-bidi: isolate;
}
.lpn-menu-row[data-pointer-only] { cursor: default; }
/* NO SHORTCUT KEYS ON A TOUCH SCREEN (Tom, 2026-10-04, on a phone: "Letters on menus have no use on
   phone. We assume that all phone users have seen this on PC."). A key name is a desktop habit;
   on a device with no hover and a coarse pointer it is noise in a row already short of room. */
@media (hover: none) and (pointer: coarse) {
	.lpn-menu-row[data-hotkey]::after { content: none; }
}
