/*
 * insite.css
 *
 * The stylesheet for pages rebuilt mobile-first. It is NOT an override sheet:
 * extranet-responsive.css exists to undo desktop rules on pages whose markup
 * we kept, and this exists for pages whose markup we replaced. A page uses one
 * or the other, never both.
 *
 * WHY LAYERS
 * extranet.css declares 20 ID selectors. `#filesgrid td` (specificity 1,0,1)
 * beats any class rule, which is why the override sheet is full of !important.
 * Cascade layers rank by layer first and specificity second, so a plain class
 * in `insite` beats an ID in `legacy` outright. Nothing here needs !important,
 * and nothing added here should.
 *
 * The `legacy` layer is declared but empty. The pages converted so far need
 * nothing from extranet.css, so importing it would cost a request for rules no
 * page uses. When a converted page does still lean on it, add exactly one line
 * below the layer statement and everything already ranks correctly:
 *
 *     @import url("/static/stylesheets/extranet.css") layer(legacy);
 *
 * (An @import must precede all rules, which is why the order below matters.)
 *
 * Browser support: cascade layers are Baseline 2022 -- Chrome 99, Safari 15.4,
 * Firefox 97. There is no build step and no tooling; this file is served as
 * written.
 *
 * PAGES
 *   .insite--login      extranet_login.html.j2
 *   .insite--launcher   extranet_userlinks.html.j2, extranet_menu.html.j2
 */

@layer legacy, insite;

@layer insite {

	/* ===================================================================
	 * Tokens
	 *
	 * Every colour is taken from an asset already in the repo rather than
	 * invented, so the page cannot drift away from the brand:
	 *   --ink    the black of the logo pill in /static/images/logo.png
	 *   --signal the yellow of its wordmark, also the lockdown button in
	 *            prohub_nodes.html.j2
	 *   --link   the portal's #000090 deepened enough to pass AA on white
	 *
	 * --slate replaces extranet.css's #999999 for headings, which measures
	 * 2.85:1 on white and fails the 3:1 WCAG AA floor even for large text.
	 * =================================================================== */
	:root {
		--ink:    #101010;
		--signal: #fed103;
		--paper:  #ffffff;
		--slate:  #4d5561;
		--rule:   #dde2e8;
		--link:   #14459c;
		--field:  #fafbfc;
		--sunk:   #f2f5f8;
		--focus:  #1a73e8;

		/* Semantic family. Four parts each -- a saturated stroke for the dot,
		   a pale ground, a mid border and a darkened text colour -- so the
		   three read as one family rather than three unrelated colours.

		   Separated by LIGHTNESS, not hue: relative luminance 0.11, 0.16 and
		   0.33. Red and orange converge under deuteranopia and protanopia,
		   so hue alone would not distinguish --bad from --warn for roughly
		   one man in twelve. Every pill carries a word as well.

		   --bad was already here as the alert-banner red (#c00000) and keeps
		   its value; --good and --warn were added 2026-08-20 in the same
		   register. Text clears 6:1 on its own ground in all three. */
		--bad:     #c00000;
		--bad-bg:  #fdf4f4;
		/* The whole-row wash, one step deeper than the pill tint above.
		   Deeper on purpose: a FIRE pill filled with --bad-bg sitting on a
		   row of --bad-bg is a pill with no fill, and that pill matters most
		   on exactly these rows. At #fbeaea the pill reads as a LIGHTER
		   lozenge on a tinted band -- the same 1.08:1 separation it has from
		   paper today, just the other way round. */
		--bad-row: #fbeaea;
		--bad-br:  #e6b4b4;
		--bad-tx:  #8a1010;

		--good:    #1a7f37;
		--good-bg: #f2f9f4;
		--good-br: #b7dcc2;
		--good-tx: #14612a;

		--warn:    #e08600;
		--warn-bg: #fff6e5;
		--warn-br: #f2ca85;
		--warn-tx: #8a5000;

		/* Type. Named because the filter bar and the count line are the only
		   places the two are chosen rather than inherited, and both pages
		   have to make the same choice. --mono is for figures that must line
		   up or read as machine output, never for prose. */
		--sans: Verdana, Geneva, Tahoma, sans-serif;
		--mono: ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Mono", Menlo,
		        Consolas, "Liberation Mono", monospace;

		/* Controls. Taken colour for colour from /mysites' filter bar, which
		   is the older of the two and the one users already know. --navy is
		   the Search button; it is deliberately not --link, because one is a
		   destination and the other re-runs a query. */
		--bar:          #f2f6fc;
		--bar-edge:     #ccd6e6;
		--bar-label:    #55606f;
		--control-edge: #99a6bb;
		--navy:         #17335c;
		--navy-hover:   #22467a;
		--navy-press:   #0f2340;
		/* A control that is present but cannot be used -- the pager's Previous
		   on page 1. Deliberately lighter than --slate: it must read as
		   unavailable, not merely as secondary text. */
		--dim:          #b5bcc7;

		/* Provenance beside a status: the "Last reported at ..." line. Between
		   --slate and --dim on purpose. It must not compete with the address
		   above it, but it is TEXT SOMEBODY READS -- --dim is 2.1:1 on white,
		   which is right for a control that cannot be used and wrong for a
		   timestamp that answers "how long has that been going on". This is
		   4.7:1, so it passes AA at 11px while still sitting back. */
		--muted:        #6b7484;

		/* One page width for the whole system. Was three copies of the same
		   idea with two different numbers -- 1280 on the launcher and 1600 on
		   the site table, which read as a mistake on a wide monitor rather
		   than as a decision. */
		--page-max: 1280px;

		--s1:  6px;
		--s2: 10px;
		--s3: 14px;
		--s4: 20px;
		--s5: 28px;
	}

	/* ===================================================================
	 * Page
	 *
	 * These pages do not load extranet.css, so anything the browser would
	 * otherwise default is set here explicitly. That is the whole reset --
	 * a full normalise would be more code than this needs.
	 * =================================================================== */
	html {
		box-sizing: border-box;
	}

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

	body {
		margin: 0;
		background: var(--paper);
		color: var(--ink);
		font-family: Verdana, Geneva, sans-serif;
		-webkit-text-size-adjust: 100%;
	}

	/* ===================================================================
	 * The shared page shell
	 *
	 * Every rebuilt page opens `<div class="insite insite--{page}">`. The
	 * shell owns the frame -- background, type, padding, the gap between
	 * blocks -- and the modifier owns whatever is specific to that page.
	 * =================================================================== */
	.insite {
		/* THE LABEL COLUMN, wherever a label sits beside its value -- the
		   fields on a device card and, since 2026-08-25, the facts on the
		   /prohubnodes unit card. Declared here rather than on .details
		   because two elements now read it and a number owned by one of
		   them is a number the other can silently drift away from.

		   MEASURED, not guessed: MAINTENANCE renders at 89.1px at this size
		   and weight and is the longest label any of these pages has
		   (INSTALLER is 67.5, LOCKDOWN 70.4). The first draft said 88px,
		   which wrapped it onto two lines.

		   Never `auto`. Auto would size to the widest label in THIS card, so
		   a card whose worst label is FIRE would indent its values
		   differently from the card below it, and a column that moves
		   between cards is not a column. */
		--labelCol: 94px;
		display: grid;
		gap: var(--s3);
		padding: var(--s4);
		width: 100%;
		max-width: var(--page-max);
		margin-inline: auto;
	}

	/* Logo left, signed-in user right. Wraps rather than squashing on a
	   narrow phone. */
	.insite__bar {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		justify-content: space-between;
		gap: var(--s2) var(--s4);
		padding-bottom: var(--s3);
		border-bottom: 1px solid var(--rule);
	}

	.insite__logo {
		display: block;
		width: 124px;
		height: auto;
	}

	/* extranet_header.html.j2 rendered this as color: white on a white
	   page, so it has never actually been visible. */
	.insite__user {
		font-size: 13px;
		color: var(--slate);
		overflow-wrap: anywhere;
	}

	/* Address and the way out, as one item.
	 *
	 * .insite__bar is `justify-content: space-between`, which spreads its
	 * children to the edges. Adding the logout form as a third child would
	 * strand the address in the middle of the bar, so the two are grouped
	 * here and the bar keeps the two items its rule was written for.
	 *
	 * baseline, not center: the address and the control are both text at the
	 * same size and should sit on one line, which centring would only
	 * approximate once the form's box is in the way. */
	.insite__account {
		display: flex;
		flex-wrap: wrap;
		align-items: baseline;
		gap: var(--s1) var(--s2);
	}

	.logout {
		margin: 0;
	}

	/* A submit styled as a link.
	 *
	 * It has to be a real submit: /extranetlogoff refuses GET, because a
	 * logout on a link is a logout any prefetcher or link checker can fire.
	 * But a filled button in the brand bar would read as the page's primary
	 * action, which logging out is not -- so it is stripped back to text.
	 *
	 * --link rather than --slate: this is the one thing in the bar that DOES
	 * something, and at 13px --slate would leave it looking like a second
	 * line of the address. The underline carries it for anyone who cannot
	 * separate the two by colour.
	 *
	 * min-height clears the 24px WCAG 2.5.8 target floor -- 13px text on its
	 * own is a 15px tap target on the phones these pages were rebuilt for.
	 * It is min-height and not padding so the baseline above still lands on
	 * the address's. */
	.logout__button {
		appearance: none;
		background: none;
		border: 0;
		padding: 0;
		min-height: 24px;
		font: inherit;
		font-size: 13px;
		color: var(--link);
		text-decoration: underline;
		cursor: pointer;
	}

	.logout__button:hover {
		color: var(--ink);
	}

	.logout__button:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 2px;
	}

	/* The page's h1, wherever it sits. */
	/* The page's content wrapper: one stack, and one query container.
	 *
	 * SPACING. Both list pages wrap everything their timed refresh replaces in
	 * this element, which made it a single grid item of .insite -- so .insite's
	 * gap applied around the whole block and never BETWEEN the heading, the
	 * filter bar, the pager and the list. They rendered hard against each
	 * other, which is also what made the top pager's own border read as a
	 * double rule under the table header. Repeating the stack here puts one
	 * gap between all of them.
	 *
	 * `gap: inherit` rather than a second copy of the numbers: .insite already
	 * decides what a gap is on this page, at both widths, and two places
	 * deciding one number is how they drift apart.
	 *
	 * THE CONTAINER. The filter bar needs to know whether the LIST is showing
	 * column headers, because that is what makes its sort control redundant.
	 * The list switches on .listShell's inline size at 960px, and .listShell
	 * is not an ancestor of the bar -- so the bar queries this element
	 * instead. The two thresholds line up exactly: both are full-width grid
	 * items with no padding of their own, so this content box and .listShell's
	 * inline size are the same number.
	 *
	 * A media query would NOT line up. At a 1000px viewport .insite's padding
	 * leaves 944px of content, so the list is still cards while `@media
	 * (min-width: 960px)` has already fired. */
	.insite__content {
		display: grid;
		gap: inherit;
		/* NAMED. Three nested containers exist on these pages -- this one,
		   .listShell inside it, and .row inside that -- and an unnamed
		   @container binds to the NEAREST ancestor container, not the one the
		   rule was written for. Naming every container and every query is
		   what stops a rule about the list silently starting to ask a card. */
		container: page / inline-size;
	}

	.insite__title {
		margin: 0;
		font-size: 28px;
		line-height: 1.15;
		font-weight: 700;
		letter-spacing: -0.01em;
		color: var(--ink);
	}

	/* The page explaining ITSELF -- how to read what is above it, not another
	   fact about the incident. Nothing set a size on these, so they rendered
	   at the browser's 16px default, identical to the broadcast messages they
	   sit under, and read as one more thing that had been sent to somebody.

	   All three shifts together, because any one alone is ambiguous: smaller
	   could be a caption, lighter could be a disabled state, italic alone
	   could be emphasis. Together they say "editorial".

	   ITALIC IS THE SAME SIGNAL AS .band__unsent, deliberately. Both mean
	   "these words were never broadcast" -- the one thing a reader of this
	   page must never get wrong -- so they are not two vocabularies that
	   happen to collide. Size and position keep them apart: a note sits
	   between sections at 13px, an unsent line inside a band at message size. */
	.insite__note {
		font-size: 13px;
		font-style: italic;
		color: var(--muted);
	}

	/* Documentation tables keep their shape and scroll sideways instead of
	   restacking -- column ORDER is what such a table documents. The page
	   body itself must never scroll sideways; this wrapper takes it. */
	.insite__scroller {
		overflow-x: auto;
		/* .insite is a grid; a grid item's min-width is auto, so without
		   this the table's min-content width leaks out and the whole PAGE
		   scrolls sideways -- the exact thing the wrapper exists to stop. */
		min-width: 0;
	}
	.insite__scroller .format {
		border-collapse: collapse;
		min-width: 640px;
	}
	.insite__scroller .format th,
	.insite__scroller .format td {
		border-bottom: 1px solid var(--rule);
		padding: var(--s2) var(--s3);
		text-align: left;
	}
	.insite__scroller .format th {
		font-size: 13px;
		letter-spacing: 0.02em;
		color: var(--slate);
	}

	/* Prose pages read at a measure, not at the page cap. min-width for
	   the same grid-item reason as the scroller: the table rides inside. */
	.insite--format .doc {
		max-width: 84ch;
		min-width: 0;
		margin-inline: auto;
	}
	.insite--format .doc h3 {
		margin: var(--s5) 0 var(--s2);
	}


	/* ═══════════════════════════════════════════════════════════════════
	   The Protection message composer (/protectionthreattype, ...new).
	   Migrated verbatim from the page's own style block on 2026-08-27 --
	   the channel-card mimicry (SMS bubble, app notification, email) is
	   deliberately NOT tokenised: those cards imitate the surfaces a
	   recipient sees, not this design system.
	   ═══════════════════════════════════════════════════════════════ */
	.composerCard {
		background: var(--paper);
		border: 1px solid var(--rule);
		border-radius: 6px;
		padding: 16px 20px;
		margin-bottom: 12px;
	}

	/* Two columns of fields, so the card stays one screenful and the
	   previews below get the full width they need for four cards each. */
	.composerColumns {
		display: flex;
		gap: 24px 40px;
		/* Wrap, not scroll: each column has a working minimum, so a
		   phone gets them stacked and a desktop gets them abreast. */
		flex-wrap: wrap;
	}

	.composerColumn {
		flex: 1 1 320px;
		min-width: 0;
	}

	.composerTable {
		width: 100%;
	}

	.composerLabel {
		color: var(--slate);
		font-size: 14px;
		padding: 8px 12px 8px 0;
		vertical-align: top;
		white-space: nowrap;
		width: 120px;
	}

	.composerHint {
		color: var(--slate);
		font-size: 12px;
		padding-top: 3px;
	}

	.composerHint--warn {
		color: var(--warn-tx);
		font-weight: bold;
	}

	/* The counter under each textarea. Grey is the resting state and comes
	   from .composerHint; these two are the escalations. Amber is advice,
	   red means the save handler will refuse it. */
	.composerCountNear {
		color: #a06000;
	}

	.composerCountOver {
		color: #cc0000;
		font-weight: bold;
	}

	.composerInput,
	.composerTextarea {
		border: 1px solid var(--slate);
		border-radius: 4px;
		padding: 6px 8px;
		font-family: inherit;
		font-size: 14px;
		color: #222222;
		background: #ffffff;
		/* Fill the column rather than a fixed pixel width, so the two
		   columns stay balanced whatever the wrapper is set to. */
		box-sizing: border-box;
		width: 100%;
	}

	.composerInput:focus,
	.composerTextarea:focus {
		border-color: var(--focus);
		outline: none;
	}

	.composerTextarea {
		resize: vertical;
	}

	/* ---------------------------------------------------------------- */
	/* Preview cards                                                    */
	/* ---------------------------------------------------------------- */
	/* Each is dressed as the surface it represents, because an admin
	   judging "is this the right wording" is judging it as a recipient
	   will read it, not as a row in a table. */

	/* One ROW per audience, four channels across it. Reading down a column
	   would compare SMS against an app notification, which is not a
	   comparison anybody needs; reading along a row shows one audience's
	   whole experience at once, which is the question being asked. */
	.previewRow {
		align-items: flex-start;
		display: flex;
		gap: 20px;
		margin-bottom: 10px;
		/* Four cards abreast where there is room; pairs, then single
		   file, as the screen narrows. */
		flex-wrap: wrap;
	}

	.previewSurface {
		background: #f4f4f4;
		border: 1px solid #dddddd;
		border-radius: 10px;
		flex: 1 1 220px;
		min-width: 0;
		padding: 12px;
	}

	.previewChannelName {
		color: #888888;
		font-size: 11px;
		letter-spacing: 0.5px;
		text-transform: uppercase;
		margin-bottom: 8px;
	}

	.previewNote {
		color: #999999;
		font-size: 11px;
		margin-top: 8px;
	}

	/* SMS / WhatsApp -------------------------------------------------- */
	.smsBubble {
		background: #e7f3d8;
		border-radius: 16px 16px 16px 4px;
		padding: 10px 14px;
		font-size: 14px;
		color: #111111;
		display: inline-block;
		max-width: 280px;
		word-wrap: break-word;
	}

	.smsTime {
		color: #999999;
		font-size: 11px;
		margin-top: 4px;
	}

	/* App notification ------------------------------------------------ */
	/* Mirrors protection-notification.page.html: the shield and banner at
	   the top, everything centred, the acknowledge button pinned at the
	   foot. The real dialog is full screen and mostly empty; the vertical
	   space is dropped here because the card has to sit beside three
	   others, and empty space is the one thing a preview can afford to
	   lose without misleading anybody. */
	.appNotification {
		background: #ffffff;
		border-radius: 12px;
		box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
		padding: 14px 12px 12px 12px;
		text-align: center;
	}

	.appNotificationBanner {
		color: #cc0000;
		font-size: 20px;
		font-weight: bold;
		letter-spacing: 1px;
		margin-bottom: 14px;
	}

	.appNotificationTitle {
		color: #333333;
		font-size: 15px;
		font-weight: bold;
	}

	.appNotificationBody {
		color: #333333;
		font-size: 14px;
		margin-top: 4px;
	}

	.appNotificationTime {
		color: #999999;
		font-size: 11px;
		margin-top: 6px;
	}

	/* Fireco yellow with near-black text -- `intent="primary"` in the app. */
	.appAcknowledge {
		background: #ffcc00;
		border-radius: 20px;
		color: #111111;
		font-size: 13px;
		font-weight: bold;
		letter-spacing: 0.5px;
		margin-top: 16px;
		padding: 9px 0;
	}

	/* Voice ----------------------------------------------------------- */
	.voiceQuote {
		color: #222222;
		font-size: 15px;
		font-style: italic;
	}

	/* Email ----------------------------------------------------------- */
	.emailSubjectLine {
		color: #111111;
		font-size: 16px;
		font-weight: bold;
		margin-bottom: 10px;
	}

	.emailMessage {
		background: #fdfdf3;
		border-radius: 10px;
		padding: 12px;
	}

	.emailSenderRow {
		align-items: center;
		display: flex;
		margin-bottom: 12px;
	}

	.emailAvatar {
		align-items: center;
		background: #f5a623;
		border-radius: 50%;
		color: #ffffff;
		display: flex;
		font-size: 16px;
		font-weight: bold;
		height: 32px;
		justify-content: center;
		margin-right: 10px;
		width: 32px;
	}

	.emailSender {
		color: #222222;
		font-size: 13px;
		font-weight: bold;
	}

	.emailRecipient {
		color: #777777;
		font-size: 12px;
	}

	/* Mirrors DMS.FormatEmailBodyHTML: the Fireco logo, the subject centred
	   and large, then the body left-aligned beneath it.
	   The logo already carries its own black pill -- the transparency is
	   around it -- so it needs no background of its own. */
	.emailLogo {
		display: block;
		margin-bottom: 12px;
		max-width: 150px;
	}

	.emailHeading {
		color: #000000;
		font-size: 20px;
		font-weight: bold;
		line-height: 1.2;
		text-align: center;
	}

	.emailBody {
		color: #222222;
		font-size: 14px;
		margin-top: 12px;
		text-align: left;
	}



	/* ═══════════════════════════════════════════════════════════════════
	   People & Roles (/protectionmembers).
	   Migrated from the page's own style block on 2026-08-27, minus the
	   two composer* rules the composer migration above already carries.
	   The audience tints (#ffe0b3 security, #ccffff staff, #ffffe6 the
	   subscriber base) are the estate's colours, shared with Roll Call --
	   deliberately NOT tokenised. The unreachable wash lost its
	   !important: extranet.css now sits in layer(legacy), below us.
	   ═══════════════════════════════════════════════════════════════ */
	/* The summary and the toolbar are NOT carded. They are the page's own
	   furniture rather than something sitting on it, and a border around
	   each one boxes the page into thirds before the table -- which is the
	   thing being read -- has been reached. */
	.memberSection {
		margin-bottom: 14px;
		/* A grid item's min-width is auto, and .insite__content is a grid:
		   without this, the summary table's min-content width rides through
		   the section and the whole PAGE scrolls sideways -- the scroller
		   inside can only clip what its parents let shrink. */
		min-width: 0;
	}



	/* 32px, and every control beside them repeats it. The toolbar is a row
	   of inputs, dropdowns and buttons, and one of them being two pixels
	   taller than the rest is the sort of thing that reads as "unfinished"
	   without the eye being able to say why. */
	.composerInput,
	.composerSelect {
		border: 1px solid var(--slate);
		border-radius: 4px;
		padding: 6px 8px;
		font-family: inherit;
		font-size: 14px;
		color: #222222;
		background: #ffffff;
		box-sizing: border-box;
		height: 32px;
	}

	.composerInput:focus,
	.composerSelect:focus {
		border-color: var(--focus);
		outline: none;
	}

	/* ------------------------------------------------------------------
	   Buttons
	   ------------------------------------------------------------------
	   The browser default is a 21px grey lozenge that sits a third of the
	   way up a 32px input. These match the inputs' height, border and
	   radius, and the two coloured variants mark the two things this page
	   is for: putting a person in, and putting a spreadsheet of them in.
	   ------------------------------------------------------------------ */
	.memberButton {
		height: 32px;
		padding: 0 14px;
		border: 1px solid var(--slate);
		border-radius: 4px;
		background: #f4f4f4;
		color: #222222;
		font-family: inherit;
		font-size: 14px;
		cursor: pointer;
		box-sizing: border-box;
		vertical-align: middle;
	}

	.memberButton:hover {
		background: #e8e8e8;
	}

	.memberButtonPrimary {
		border-color: transparent;
		background: var(--ink);
		color: var(--signal);
	}

	.memberButtonPrimary:hover {
		background: #2b2b2b;
	}

	.memberButtonSecondary {
		border-color: var(--rule);
		background: var(--paper);
		color: var(--ink);
	}

	.memberButtonSecondary:hover {
		background: var(--sunk);
	}

	.memberButton[disabled],
	.memberButton[disabled]:hover {
		cursor: not-allowed;
		opacity: 0.5;
	}

	.memberButtonPrimary[disabled]:hover {
		background: var(--ink);
	}

	.memberButtonSecondary[disabled]:hover {
		background: var(--paper);
	}

	/* ------------------------------------------------------------------
	   Checkbox dropdown
	   ------------------------------------------------------------------
	   A <select multiple> is the wrong control here: it takes four rows of
	   vertical space to show two options, it gives no hint that more than
	   one may be chosen, and ctrl-clicking to add a second is a thing
	   administrators do not know. This is the same state in a dropdown --
	   the ticks stay ticked when the panel closes, and the button says how
	   many are set.
	   ------------------------------------------------------------------ */
	.memberDropdown {
		position: relative;
		display: inline-block;
	}

	.memberDropdownButton {
		height: 32px;
		min-width: 200px;
		max-width: 260px;
		padding: 0 26px 0 8px;
		border: 1px solid var(--slate);
		border-radius: 4px;
		background: #ffffff;
		color: #222222;
		font-family: inherit;
		font-size: 14px;
		text-align: left;
		cursor: pointer;
		box-sizing: border-box;
		position: relative;
		overflow: hidden;
		white-space: nowrap;
		text-overflow: ellipsis;
	}

	.memberDropdownCaret {
		position: absolute;
		right: 8px;
		top: 0;
		line-height: 30px;
		color: #666666;
	}

	/* Absolutely positioned so that opening it does not push the table
	   down, and z-indexed above the table it overlaps. */
	.memberDropdownPanel {
		display: none;
		position: absolute;
		z-index: 20;
		top: 34px;
		left: 0;
		min-width: 100%;
		max-height: 260px;
		overflow-y: auto;
		padding: 4px 0;
		border: 1px solid var(--slate);
		border-radius: 4px;
		background: #ffffff;
		box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
		white-space: nowrap;
	}

	.memberDropdown.memberDropdownOpen .memberDropdownPanel {
		display: block;
	}

	.memberDropdownPanel label {
		display: block;
		padding: 4px 12px;
		font-size: 14px;
		color: #222222;
		cursor: pointer;
	}

	.memberDropdownPanel label:hover {
		background: var(--sunk);
	}

	.memberDropdownPanel input {
		margin-right: 6px;
	}

	/* The toolbar sits on one line at 1400px and wraps rather than
	   overflowing if the wrapper is ever narrowed. */
	.memberToolbar {
		display: flex;
		flex-wrap: wrap;
		align-items: flex-end;
		gap: 16px;
	}

	.memberToolbarField {
		display: flex;
		flex-direction: column;
		gap: 4px;
	}

	/* Eats the slack between the filters and the write controls, so the
	   two groups sit at opposite ends however wide the wrapper is. */
	.memberToolbarSpacer {
		flex: 1 1 auto;
		min-width: 20px;
	}

	.memberToolbarActions {
		display: flex;
		flex-direction: column;
		align-items: flex-end;
		gap: 4px;
		text-align: right;
	}

	.memberToolbarButtons {
		display: flex;
		gap: 8px;
	}

	/* A sortable heading. The arrow is a span rather than a background
	   image so the direction can be set without a second request. */
	th.memberSortable {
		cursor: pointer;
		user-select: none;
	}

	/* Somebody flagged Staff with no subscription receives NOTHING: the
	   staff audience is drawn from the subscriber list and the public
	   audience excludes staff. That is the one state on this page an
	   administrator has to be able to spot without reading a row, so it is
	   the one state that gets a colour. */
	tr.memberUnreachable {
		background-color: #ffe6e6;
	}

	.memberWarningGlyph {
		color: #cc0000;
		font-weight: bold;
	}

	/* The estate's two audience colours, the same ones Roll Call and the
	   incident pages use: #ffe0b3 wherever people-with-a-duty are counted,
	   #ccffff for staff. Since 2026-08-27 (Dave's call) the ROW wears the
	   audience too -- security outranking staff where somebody holds both
	   -- while the cell tints stay for the exact role, visible precisely
	   when a row's two roles differ.

	   A cell background paints over the row's, so a tinted role cell still
	   shows through an unreachable row. That is deliberate: a red row with
	   one cyan cell says "staff, and that is exactly the problem" -- and
	   tr.memberUnreachable's extra specificity is what keeps the fault
	   wash above both audience washes. */
	/* Class-only on purpose: tr.memberUnreachable is (0,1,1) and these are
	   (0,1,0), so the fault wash outranks the audience washes whatever the
	   source order says. */
	.memberRowSecurity {
		background-color: #ffe0b3;
	}

	.memberRowStaff {
		background-color: #ccffff;
	}

	td.memberRoleOnSecurity {
		background-color: #ffe0b3;
	}

	td.memberRoleOnStaff {
		background-color: #ccffff;
	}
	/* A pale wash of the Tests & Exercises category colour #546E7A. The exact
	   shade is settled at the visual check (spec section 2.3). */
	td.memberRoleOnTester {
		background-color: #cfd8dc;
	}

	/* The People-by-site role columns, data rows only -- the tfoot totals
	   row keeps its own band. And the Security Personnel table is security
	   business end to end. */
	.insite--people .reportTable td.sec,
	.insite--people #security_personnel_table tbody tr {
		background-color: #ffe0b3;
	}
	.insite--people .reportTable td.stf {
		background-color: #ccffff;
	}
	.insite--people .reportTable td.tst {
		background-color: #cfd8dc;
	}
	/* A site a test would be refused at, once testers exist elsewhere. The
	   same wash as an unreachable row: both mean "nobody here will hear it". */
	.insite--people .reportTable td.tstNone {
		background-color: #ffe6e6;
	}

	/* Three buttons on one line. Wrapping them made the Actions column two
	   rows tall on every row that had a PIN to reset, which pushed the
	   table's row height around as the data changed. */
	.memberActionCell {
		white-space: nowrap;
	}

	.memberActionButton {
		height: 24px;
		padding: 0 8px;
		font-size: 12px;
		margin: 0 1px;
	}

	/* ------------------------------------------------------------------
	   Groups panel
	   ------------------------------------------------------------------
	   Every name box here holds at most 30 characters, so it takes the
	   roles toolbar's 260px rather than the composer's width: 100% -- which
	   is what let the Rename button spill out of its cell and over the
	   detail switch. The first column then shrinks to its content: 1% is
	   the table-layout idiom for "no wider than it must be", and the cell
	   is already nowrap, so the box and its button stay on one line.
	   ------------------------------------------------------------------ */
	#group_panel .composerInput {
		width: 260px;
	}

	#group_table th:first-child,
	#group_table td:first-child {
		width: 1%;
	}

	#group_table .group_name {
		margin-right: var(--s1);
	}

	/* Breathing room between the table and the two cards under it, and
	   between the cards themselves. */
	#group_panel .controls {
		margin-top: var(--s4);
	}

	/* The composer sets .composerLabel to a 120px column; above a field it
	   should be as wide as its words ("Start with the same messages as"). */
	#group_panel .composerLabel {
		width: auto;
	}

	/* Sits under the table and only when the page actually contains one of
	   these rows, so it explains the red rather than warning about a state
	   nobody is in. */
	#member_unreachable_note {
		display: none;
		margin-top: 10px;
		padding: 8px 10px;
		border-left: 3px solid #cc0000;
		background: #ffe6e6;
		color: #990000;
		font-size: 14px;
	}

	/* Amber, not the red of #member_unreachable_note. That one reports a
	   fault -- somebody is receiving nothing. This one reports a state that
	   is perfectly legitimate and merely wants a moment's thought before
	   the administrator types over it. Same shape so they read as the same
	   kind of remark; different colour so they do not read as equally
	   urgent. */
	#edit_person_warning {
		display: none;
		margin-bottom: 12px;
		padding: 8px 10px;
		border-left: 3px solid #cc8800;
		background: #fff4e0;
		color: #8a5300;
		font-size: 14px;
	}

	.memberSiteRoleGrid {
		max-height: 240px;
		overflow-y: auto;
		border: 1px solid #cccccc;
		border-radius: 4px;
		padding: 6px;
	}

	/* The InSite skin over the three reportTables this page keeps. The
	   legacy layer still declares the class; these outrank it by layer --
	   but a layer only wins for properties it DECLARES, so every legacy
	   background and border is restated here, not merely accompanied. */
	.insite--people .reportTable {
		width: 100%;
		border: 0;
		border-collapse: collapse;
		background: none;
	}
	.insite--people .reportTable caption {
		text-align: left;
		font-weight: 700;
		font-size: 16px;
		color: var(--ink);
		background: none;
		padding-block: var(--s2);
	}
	.insite--people .reportTable th {
		font-size: 12px;
		letter-spacing: 0.04em;
		text-transform: uppercase;
		text-align: left;
		color: var(--slate);
		background: none;
		border: 0;
		border-bottom: 1px solid var(--rule);
		padding: var(--s2) var(--s3);
	}
	/* Headers align like their data: numbers right, the roles-and-actions
	   block of the live table centred, everything else left. */
	.insite--people .reportTable th.num,
	.insite--people .reportTable td.num {
		text-align: right;
	}
	.insite--people .reportTable th.mid,
	.insite--people .reportTable td.mid {
		text-align: center;
	}
	/* The one background left: the Distinct people totals row. */
	.insite--people .reportTable tfoot tr {
		background: var(--bar);
	}
	.insite--people .memberSortArrow {
		font-size: 11px;
		color: var(--slate);
	}
	/* The pagers earn their place: hidden until renderPager() finds more
	   than one page -- which at the 50-row default page size means more
	   than 50 rows. :not() rather than a hide-and-restate pair, because
	   the component's own `.insite .pager { display: grid }` sits LATER in
	   this file at equal specificity and would win the restatement war --
	   the extra pseudo-class outranks it whatever the source order, and
	   the shown state simply falls through to the component's rule. */
	.insite--people .pager:not(.pager--shown) {
		display: none;
	}

	/* Section headings -- what used to be the table captions. One rule, so
	   the three sections cannot drift apart in size. */
	.insite--people h2 {
		margin: 0 0 var(--s2);
		font-size: 18px;
	}
	.insite--people .reportTable td {
		border: 0;
		border-bottom: 1px solid var(--rule);
		padding: var(--s2) var(--s3);
	}
	/* The audience tints and the unreachable wash still paint their own
	   cells and rows -- they are data, not chrome, and they stay. */

	/* Says which site the preview is measured at. Held a picker beside the
	   explanation until the worst case became the only site shown; the row
	   survives it because the line still wraps on a phone. */
	.insite--composer .previewPickerRow {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		gap: var(--s2) var(--s4);
	}
	.insite--composer .previewPickerRow .composerHint {
		margin: 0;
	}

	/* Icon actions: one shape, three verbs, colour says which. Every one
	   carries a title for hover and an aria-label naming its object. */
	.iconlink {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 30px;
		height: 30px;
		border-radius: 6px;
		flex: none;
		/* Also worn by <button>s (the members row actions), so the UA
		   button chrome is reset here rather than per page. */
		border: 0;
		background: none;
		padding: 0;
		cursor: pointer;
	}
	.iconlink svg {
		width: 18px;
		height: 18px;
	}
	.iconlink:hover {
		background: var(--sunk);
	}
	.iconlink--edit   { color: var(--good); }
	.iconlink--remove { color: var(--bad); }
	.iconlink--add    { color: var(--link); }

	/* A category's colour, shown but never load-bearing for legibility. */
	.swatch {
		display: inline-block;
		width: 12px;
		height: 12px;
		border-radius: 50%;
		flex: none;
	}

	/* The small actions at the right of a zone heading. */
	.insite .zone__tools {
		margin-left: auto;
		display: inline-flex;
		gap: var(--s3);
		font-size: 13px;
	}

	/* One policy switch: the control, its name, and what it means. A label
	   element, so the whole row toggles the box. */
	/* The settings form is the stack its cards sit in.
	
	   .unit carries no margin of its own -- deliberately, so that spacing
	   between cards belongs to whatever holds them, which is how
	   .insite__content does it on the prohub pages. Here the holder is the
	   <form>, and it is a plain grid item, so two sections inside it butted
	   together with no gap at all until this rule.
	
	   `gap: inherit` rather than a repeated var(--s3): the cards are spaced by
	   whatever .insite is spacing everything else by, and one number moving
	   should move them together. Same reasoning, same spelling, as
	   .insite__content. */
	.insite--settings form {
		display: grid;
		gap: inherit;
	}

	.setting {
		display: grid;
		grid-template-columns: auto 1fr;
		column-gap: var(--s3);
		row-gap: var(--s1);
		align-items: start;
		padding-block: var(--s2);
	}
	.setting input[type="checkbox"] {
		margin-top: 3px;
		width: 18px;
		height: 18px;
		accent-color: var(--ink);
	}
	.setting__name {
		font-weight: 600;
	}
	.setting__what {
		grid-column: 2;
		color: var(--slate);
		font-size: 14px;
	}

	/* A warning that is the point of its section, not decoration. */
	.callout {
		border-left: 4px solid var(--signal);
		background: var(--sunk);
		padding: var(--s3) var(--s4);
	}

	/* Whose data a customer-scoped page is about, above its title.
	   Protection and Roll Call pages say WHOSE before WHAT. */
	.insite__context {
		color: var(--slate);
		font-size: 14px;
		font-weight: 600;
		text-transform: uppercase;
		letter-spacing: 0.04em;
		margin: 0;
	}

	/* The way back up, directly under the title.
	 *
	 * 13px and not bold: this is orientation, not an action. The title above
	 * it is 28px and the first card below is the content -- a back link that
	 * competed with either would be read as the thing you came for.
	 *
	 * NOT .factLink, which is a value inside a card and is bold. Same size
	 * on purpose; the weight is what separates them.
	 *
	 * min-height 24px keeps the tap target usable on a phone without
	 * padding that would make it look like a button.
	 */
	.insite .backLink,
	.insite .downloadLink {
		display: inline-flex;
		align-items: center;
		gap: 6px;
		min-height: 24px;
		margin-top: 6px;
		font-size: 13px;
		color: var(--link);
		text-decoration: none;
	}

	.insite .backLink:hover,
	.insite .backLink:focus-visible,
	.insite .downloadLink:hover,
	.insite .downloadLink:focus-visible {
		text-decoration: underline;
	}

	/* The back link with a page-level link opposite it, on one line.
	 *
	 * Event History's CSV download is the only user. It shares the back
	 * link's rule above -- same size, same weight -- because it acts on the
	 * whole page rather than on anything in it, and a button here would read
	 * as the thing you came for.
	 *
	 * baseline, so the two lines of 13px text sit level whatever their boxes
	 * do. Wraps on a narrow phone, dropping the download under the back link
	 * rather than squeezing either. */
	.insite__nav {
		display: flex;
		flex-wrap: wrap;
		align-items: baseline;
		justify-content: space-between;
		column-gap: var(--s4);
	}

	/* The view toggle, which sits under the back link and is NOT one: it
	 * moves sideways between two views of this same unit, where the back
	 * link moves up to the site. Given the button treatment (.ctrl in the
	 * markup) rather than the link treatment for exactly that reason --
	 * two plain links stacked read as a pair of ways out, and the classic
	 * view is a destination, not an exit.
	 *
	 * display:table so the box hugs its text; the .ctrl rules make it
	 * inline-flex, which stretches it across the column on a phone. */
	.insite .viewSwap {
		display: table;
		margin-top: 10px;
	}

	/* ===================================================================
	 * Login
	 * =================================================================== */

	/* 100dvh, not 100vh: on mobile Safari 100vh is the height the viewport
	   would be with the address bar hidden, so a 100vh page is always
	   slightly taller than the screen and always scrolls a little. The vh
	   line is the fallback for anything predating dvh. */
	.insite--login {
		min-height: 100vh;
		min-height: 100dvh;
		grid-template-rows: auto 1fr;
	}

	/* No signed-in user to separate from, so no rule and no gap. */
	.insite--login .insite__bar {
		padding-bottom: 0;
		border-bottom: 0;
	}

	.insite__panel {
		display: grid;
		gap: var(--s4);
		align-content: start;
	}

	/* The source photograph is 1200x592. A tall crop throws most of it away,
	   so on a phone it is a shallow cover band -- present, but costing about
	   a tenth of the screen it used to take. */
	.insite__photo {
		display: block;
		width: 100%;
		height: 88px;
		object-fit: cover;
		object-position: 50% 46%;
		border-radius: 10px;
		background: var(--field);
	}

	.insite__form {
		display: grid;
		gap: var(--s3);
		align-content: start;
	}

	/* Shown only when logonError() has something to say. */
	.insite__error {
		margin: 0;
		padding: var(--s3) var(--s4);
		border: 1px solid #e6b4b4;
		border-left: 4px solid #c00000;
		border-radius: 8px;
		background: #fdf4f4;
		color: #8a1010;
		font-size: 15px;
		line-height: 1.45;
	}

	/* ── Fields ─────────────────────────────────────────────────────── */
	.field {
		display: grid;
		gap: var(--s1);
	}

	.field__label {
		font-size: 13px;
		font-weight: 700;
		letter-spacing: 0.06em;
		text-transform: uppercase;
		color: var(--slate);
	}

	.field__input {
		width: 100%;
		height: 48px;
		padding: 0 var(--s3);
		font-family: inherit;
		/* 16px is a functional floor, not a taste one: below it iOS Safari
		   zooms the page when the field takes focus, which fights
		   width=device-width. Do not shrink it. */
		font-size: 16px;
		color: var(--ink);
		background: var(--field);
		border: 1px solid var(--rule);
		border-radius: 8px;
	}

	.field__input:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 1px;
		background: var(--paper);
	}

	.insite__forgot {
		justify-self: start;
		font-size: 14px;
		color: var(--link);
		text-decoration-thickness: 1px;
		text-underline-offset: 2px;
	}

	.insite__forgot:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 3px;
		border-radius: 3px;
	}

	/* ── Buttons ────────────────────────────────────────────────────────
	 * 52px tall, comfortably over the 44px iOS minimum and the 48dp
	 * Android one. Both buttons share a size so the pair reads as a pair.
	 * ──────────────────────────────────────────────────────────────── */
	.btn {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		gap: var(--s2);
		width: 100%;
		min-height: 52px;
		padding: 0 var(--s4);
		font-family: inherit;
		font-size: 16px;
		font-weight: 700;
		border-radius: 999px;
		border: 1px solid transparent;
		cursor: pointer;
	}

	.btn:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 3px;
	}

	/* The Fireco pill: the same black, the same yellow and the same fully
	   rounded geometry as the mark at the top of the page, so the screen
	   opens and closes on one shape. */
	.btn--primary {
		background: var(--ink);
		color: var(--signal);
	}

	.btn--primary:hover {
		background: #2b2b2b;
	}

	/* A button that sits IN the page rather than owning a form: auto width
	   and a working-page height. The bare .btn is sized for the login card,
	   where the button is the whole story. AFTER the other .btn rules on
	   purpose: same specificity, so source order is what lets width: auto
	   beat the base rule's width: 100%. */
	.btn--inline {
		width: auto;
		min-height: 36px;
		font-size: 14px;
		border-color: var(--rule);
		background: var(--paper);
	}
	.btn--inline:hover {
		background: var(--sunk);
	}
	.btn--inline.btn--primary {
		border-color: transparent;
		/* Restate the primary colours: .btn--inline sits later in source
		   than .btn--primary, so its paper background would win otherwise
		   and the page's one primary action would dress as a secondary. */
		background: var(--ink);
		color: var(--signal);
	}
	.btn--inline.btn--primary:hover {
		background: #2b2b2b;
	}

	/* Google's mark, colours and wording, in their permitted pill shape.
	   It was a 540x124 PNG, which could not scale crisply, took no keyboard
	   focus and had no accessible name. */
	.btn--google {
		background: var(--paper);
		color: #3c4043;
		border-color: #dadce0;
		font-weight: 600;
		font-family: system-ui, Roboto, Arial, sans-serif;
	}

	.btn--google:hover {
		background: #f7f8f8;
	}

	.btn--google svg {
		flex: 0 0 18px;
	}

	.insite__or {
		display: grid;
		grid-template-columns: 1fr auto 1fr;
		align-items: center;
		gap: var(--s3);
		margin: 0;
		font-size: 12px;
		font-weight: 700;
		letter-spacing: 0.09em;
		text-transform: uppercase;
		color: var(--slate);
	}

	.insite__or::before,
	.insite__or::after {
		content: "";
		height: 1px;
		background: var(--rule);
	}

	/* ===================================================================
	 * Sites -- /mysites
	 * =================================================================== */

	/* The InSite mark is 1000x354 and carries "Powered by Fireco" under the
	   wordmark, which stops being legible below about 170px. The Fireco pill
	   is a single word and sits happily at 124px. */
	.insite__logo--insite {
		width: 170px;
	}

	/* ===================================================================
	 * The list ladder -- /mysites and /siteinstallations
	 *
	 * ONE row component. Cards on a narrow container, a table row on a wide
	 * one, from identical markup:
	 *
	 *   .listShell     the query container
	 *   .list          the list itself
	 *   .list--cards   never becomes a table, whatever the width
	 *   .list--auto    may become a table, once there is room
	 *
	 * Three bands, on CONTAINER width:
	 *
	 *      < 680px   one column of cards
	 *   680-959px   two columns of cards  (a portrait tablet lives here)
	 *     >= 960px   .list--auto becomes a table; .list--cards stays two-up
	 *   .row           one site or one unit
	 *   .cell          one field; [data-label] supplies its label in card mode
	 *   .details       the fields below the title; display:contents when wide
	 *
	 * WHY CONTAINER QUERIES, NOT MEDIA QUERIES
	 * The switch depends on how much room the LIST has, not the window. That
	 * is also what lets the mockup and the page agree: a 360px pane renders
	 * cards on a 1400px monitor.
	 * Container queries are Baseline 2023 -- Chrome 105, Safari 16, Firefox
	 * 110. Cascade layers below are already Baseline 2022, so this sheet
	 * assumes a browser of that vintage either way.
	 *
	 * WHY TWO LIST CLASSES
	 * Row count decides which. At five rows or fewer a table is furniture --
	 * there is nothing to scan -- so the page emits .list--cards and the wide
	 * view stays cards, two-up. Above five it emits .list--auto. The
	 * threshold is the page's to apply; this sheet only provides both.
	 *
	 * See docs/superpowers/specs/2026-08-20-list-page-design-approach.md.
	 * =================================================================== */

	/* The QUERY CONTAINER is this wrapper, not the list.
	   An element cannot respond to its own container query -- @container
	   styles descendants of the container, never the container itself. With
	   container-type on .list, every rule targeting .list (the two-up card
	   grid, and `.list--auto { display: block }`) silently did nothing while
	   the rules targeting .row and .list__head worked, so the table looked
	   correct and the cards did not. One wrapper, and both work. */
	.insite .listShell {
		container: listShell / inline-size;
	}

	.insite .list {
		display: flex;
		flex-direction: column;
		gap: var(--s3);
	}

	.insite .list__head {
		display: none;
	}

	.insite .row {
		display: grid;
		grid-template-columns: 1fr auto;
		gap: var(--s2) var(--s3);
		align-items: start;
		/* Keep the card's own content packed at the TOP when the card is
		   stretched to match a taller neighbour. Without it the spare height
		   is shared out between the title, the details and the actions, and
		   the shorter card's fields drift apart instead of its box simply
		   being taller. */
		align-content: start;
		padding: var(--s4);
		background: var(--paper);
		border: 1px solid var(--rule);
		border-radius: 8px;
		/* The CARD is a container in its own right, so its fields can respond
		   to how wide THIS card is rather than how wide the list is. Those
		   are different numbers the moment the list goes two-up: at a 900px
		   shell each card is about 440px, and a query on the shell would
		   have to guess which of the two arrangements it was looking at.
		   See the two-column field layout below. */
		container: card / inline-size;
		/* Room above a row the site map sent you to, so its flash is not cut
		   off by the top of the viewport. Same as .unit below. */
		scroll-margin-top: var(--s3);
	}

	/* ---- the wash --------------------------------------------------------
	 * FIRE, ALERT and a unit that has only just gone quiet: the three top
	 * rungs of the sort, and about 4% of rows across the whole estate. That
	 * sparseness is the point -- ProHubUtilities.StatusIsUrgent() deliberately
	 * leaves out `fault` and `maintenance`, which are the standing backlog and
	 * would wash hundreds of rows into meaninglessness.
	 *
	 * The pill says WHAT, the wash says LOOK HERE. Both, because the wash
	 * cannot be read by someone who does not see red -- it is a finding aid
	 * layered over a label that already works on its own.
	 *
	 * Placed after `.insite .row` and matching its specificity, so source
	 * order decides. Table mode does not restate the background, so this
	 * carries in both modes from one rule.
	 * -------------------------------------------------------------------- */
	.insite .row--urgent {
		background: var(--bad-row);
		border-color: var(--bad-br);
	}

	/* The note steps up one shade on a washed row.
	 *
	 * --muted is 4.71:1 on paper: it clears AA with almost nothing spare, and
	 * ANY tint pushes it under (4.05:1 on the wash). --slate is the next step
	 * down and holds 6.48:1. This is the line that answers "how long has that
	 * been going on" -- the question a washed row exists to prompt -- so it is
	 * the last text on the page that can afford to go faint. */
	.insite .row--urgent .row__note {
		color: var(--slate);
	}

	.insite .cell {
		min-width: 0;
	}

	/* Title and status share the first line. Placed explicitly rather than by
	   source order, so the table can put status first without the card
	   following it. */
	.insite .cell--title  { grid-column: 1; grid-row: 1; }
	.insite .cell--status { grid-column: 2; grid-row: 1; justify-self: end; }

	/* A row with no status pill -- /siteinstallations units in table mode --
	   gives the title the whole line instead of leaving a gap. */
	.insite .row:not(:has(.cell--status)) .cell--title {
		grid-column: 1 / -1;
	}

	/* ---- the fields on a card -------------------------------------------
	 * ONE COLUMN OF FIELDS, each field a label beside its value.
	 *
	 * Until 2026-08-24 this was two columns of stacked pairs -- label above
	 * value, two pairs per line -- which puts the values at four different
	 * left edges and makes the eye hunt for each one. A card is read to
	 * answer "is anything wrong here", and that question is answered by
	 * scanning the VALUES. So the values get a column of their own and the
	 * labels get theirs, and after the second card you stop reading the
	 * labels at all.
	 *
	 * row-gap is 0 and the space between fields comes from each cell's own
	 * padding instead. That is what lets the divider at 430px be a single
	 * unbroken line rather than a column of dashes with a gap at every row.
	 * ------------------------------------------------------------------- */
	.insite .details {
		/* --labelCol is the FLOOR under the label column, not the label
		   column itself -- see the 40/60 split on .details > .cell below.
		   It is declared on .insite, because the unit card reads it too. */
		grid-column: 1 / -1;
		display: grid;
		grid-template-columns: 1fr;
		column-gap: var(--s3);
		row-gap: 0;
		margin-top: var(--s1);
		padding-top: var(--s1);
		border-top: 1px solid var(--rule);
	}

	.insite .details > .cell {
		display: grid;
		/* 40/60, with a floor.
		 *
		 * A fixed label column looks increasingly one-sided as the card
		 * grows: at the narrowest card it is a fair share of the width and by
		 * the widest it is a quarter of it, with the value stranded out on
		 * its own. A proportion keeps the two halves of a field looking like
		 * halves of one thing at every width.
		 *
		 * The floor is what stops the proportion becoming a bug. At the point
		 * the two-up arrangement starts, a half is 235px and its content box
		 * 221px after the clearance either side of the divider -- and 40% of
		 * 221 is 88.4px, half a pixel under what MAINTENANCE needs. minmax()
		 * takes whichever is larger, so the tightest case stays at the
		 * measured 94px and 40% takes over from about 249px of half.
		 *
		 * The 14px gap comes out of the value's share rather than the
		 * label's: folding it into the label track would mean flooring at
		 * 108px, which is 49% of the tightest half -- further from 40 than
		 * the gap ever puts the value. */
		grid-template-columns: minmax(var(--labelCol), 40%) 1fr;
		column-gap: var(--s3);
		/* Baseline, not top. The label is 10px and the values are 13px text,
		   pills with their own padding, and a 24px button -- aligning the
		   boxes would leave the two words on visibly different lines. This
		   aligns what is actually read. */
		align-items: baseline;
		/* Values are sized to their content, not stretched across the track:
		   a pill stretched to the full width of a card reads as a button. */
		justify-items: start;
		padding-block: 7px;
	}

	/* Everything in a cell belongs in the VALUE column. Cells with two
	   children -- lockdown's state and its button, an error's code and its
	   message -- would otherwise wrap the second one back under the label. */
	.insite .details > .cell > * {
		grid-column: 2;
	}

	/* An error takes the whole width. There is one field, and it is a
	   sentence.

	   Flat 94px rather than the 40% above, precisely BECAUSE it spans the
	   whole card: 40% of a full-width cell is a 200px gutter in front of a
	   five-letter label, with the sentence that matters pushed off to the
	   right of it. Nothing else on that card can fall out of line with it --
	   an error replaces every other field. */
	.insite .details > .cell--error {
		grid-column: 1 / -1;
		grid-template-columns: var(--labelCol) 1fr;
	}

	.insite .cell[data-label]::before {
		content: attr(data-label);
		display: block;
		margin-bottom: 3px;
		font-size: 10px;
		font-weight: 700;
		letter-spacing: 0.05em;
		text-transform: uppercase;
		color: var(--slate);
	}

	/* Beside its value rather than above it, so no gap underneath. */
	.insite .details > .cell[data-label]::before {
		grid-column: 1;
		margin-bottom: 0;
	}

	/* ---- two fields per line, once the card can hold them ----------------
	 * The width of the CARD -- not of the list, not of the window. A card is
	 * this wide in a single column on a large phone held sideways, and also
	 * as one half of a two-up pair on a tablet, and both should show the
	 * same thing. That is what .row being its own container buys.
	 *
	 * WHY 470 AND NOT THE 430 THAT WAS ASKED FOR
	 * 470 is the measured point at which nothing is squeezed, and the sum is
	 * worth keeping because every term in it is a real measurement:
	 *
	 *     94  the label column (MAINTENANCE, the longest label)
	 *   + 14  the gap after it
	 *   + 109 the widest value either page produces (the Maintenance pill;
	 *         LOCKED DOWN is 101 and DeviceError 103)
	 *   + 14  the clearance either side of the divider
	 *   = 231 per half, so 462 for the pair, and 470 to leave a few pixels
	 *     rather than sit exactly on the edge of a wrap.
	 *
	 * At 430 that pill is 16px wider than its track and crosses the bar. The
	 * card is about 512px wide at this point -- 470 is its CONTENT box,
	 * which is what a container query measures.
	 *
	 * label | value | bar | label | value. The bar is a border on the
	 * right-hand cell of each pair rather than a grid track, because a track
	 * has nothing in it to paint -- and because a border on the cell is
	 * exactly as tall as the row, which is what makes the separate rows join
	 * into one line.
	 *
	 * :nth-child(even) is the right-hand cell only while every child of
	 * .details is a cell, which is the contract of the row() macro.
	 * ------------------------------------------------------------------- */
	@container card (min-width: 470px) {
		.insite .details {
			grid-template-columns: 1fr 1fr;
			column-gap: 0;
		}

		.insite .details > .cell:nth-child(odd) {
			padding-right: var(--s3);
		}

		.insite .details > .cell:nth-child(even) {
			padding-left: var(--s3);
			border-left: 1px solid var(--rule);
		}

		/* A full-width field spans the bar, so it must not sit against it. */
		.insite .details > .cell--error {
			padding-right: 0;
			padding-left: 0;
			border-left: 0;
		}
	}

	.insite .row__actions {
		grid-column: 1 / -1;
		display: flex;
		flex-wrap: wrap;
		gap: var(--s2);
		padding-top: var(--s3);
		border-top: 1px solid var(--rule);
	}

	/* A device name is not a link. Its edit control is a jQuery dialog opened
	   by XHR, so the name has nothing to navigate to -- but it is still the
	   thing you read first, so it keeps the weight the link has and loses the
	   colour and the underline. */
	.insite .row__name {
		font-weight: 700;
	}

	/* SCOPED, on the same reasoning as .insite--nodes .cell--title further
	   down. .row__name is shared, and /prohubnodes seats it beside a 26px
	   thumbnail in a flex row where 15px would change the card's whole top
	   line -- a page nobody asked about. Keeping /prohubnodes out is the whole
	   job of this selector.

	   .insite--sites IS NOT UNIQUE TO /protectionsites -- /mysites carries it
	   too. That is deliberate rather than overlooked: /mysites titles its cards
	   with .row__link, so this rule reaches nothing there today, and on the day
	   it grows a name with nothing to link to, 15px is the size that name
	   should be. The scope is "the two site lists", not "this page".

	   15px/700 is exactly .row__link: the same treatment, minus the colour and
	   underline it would be wrong to wear with nothing to navigate to. Bare
	   text put the name at the 12px of the five values beneath it, so the card
	   opened with no heading at all. */
	.insite--sites .row__name {
		font-size: 15px;
	}

	.insite .row__link {
		font-size: 15px;
		font-weight: 700;
		color: var(--link);
		text-decoration: none;
	}

	.insite .row__link:hover,
	.insite .row__link:focus-visible {
		text-decoration: underline;
	}

	.insite .row__sub {
		display: block;
		margin-top: 2px;
		font-size: 11.5px;
		color: var(--slate);
	}

	/* When what the status pill says became true.

	   Dimmer and a shade smaller than .row__sub, because the sub-line is
	   IDENTITY -- the address, the serial -- and this is provenance. It is
	   the answer to "how long has that been going on", which is a question
	   you ask after reading the pill, not before.

	   Tabular figures so a column of timestamps lines up digit under digit;
	   the whole value of a date here is scanning several of them at once. */
	.insite .row__note {
		display: block;
		margin-top: 2px;
		font-size: 11px;
		font-variant-numeric: tabular-nums;
		color: var(--muted);
	}

	/* ===================================================================
	 * Device zones -- /prohubnodes
	 *
	 * The device tree, flattened. ONE zone per extender, headed by the
	 * extender that feeds it, NEVER indented.
	 *
	 * Depth reaches 15 in production -- fifteen Pro Extenders daisy-chained,
	 * 105 devices -- and nothing in the firmware or the extranet caps it. The
	 * old page spent 25px per level, which is 390px of left padding at the
	 * bottom of that chain. So NO rule in this section may scale with depth:
	 * the hierarchy is written into the zone header as an address
	 * ("3 hops - via Brighton > Hove") and costs no horizontal space at all.
	 * Pinned by test_a_zone_never_indents.
	 *
	 * The mesh is a chain rather than a bush -- 87% of extenders feed at most
	 * one further extender -- so reading the zones top to bottom IS walking
	 * the radio path outward from the hub. That is why they are never sorted.
	 *
	 * See docs/superpowers/specs/2026-08-24-prohubnodes-device-zones-design.md
	 * =================================================================== */
	/* BLOCK, never grid. Since 2026-09-04 a zone is a <details> -- the
	   extender's card, or the Devices fold inside the unit card -- and a
	   <details> with any display but block is a known way to break its closed
	   state. The gap between summary and table is .fold__body's. */
	.insite .zone {
		display: block;
	}

	/* The top of an extender card, and its fold's <summary>. It was a box
	   of its own over a separate table until 2026-09-04; now the card is the
	   box, so this draws nothing but its two lines. */
	.insite .zone__head {
		display: grid;
		gap: 3px;
	}

	/* The card's left edge carries the worst thing inside it, so a column
	   of cards can be scanned down without reading any of them. Colour ALONE
	   never says it -- the roll-up on the right says it in words too. On the
	   unit card the class reports the devices bound DIRECTLY to the hub. */
	.insite .unit.zone--warn { border-left: 3px solid var(--warn); }
	.insite .unit.zone--bad  { border-left: 3px solid var(--bad); }

	/* The links along a via path, and nothing else in the header, are blue:
	   they are the way back up the chain. */
	.insite .zone__path a {
		color: var(--link);
		text-decoration: none;
	}

	.insite .zone__path a:hover,
	.insite .zone__path a:focus-visible {
		text-decoration: underline;
	}

	/* ---- the fold's summary -----------------------------------------------
	 * No disclosure triangle: the chevron on the right is the affordance,
	 * and a triangle at the left of a header that starts with a name would
	 * read as a bullet. Both rules, because Safari ignores list-style here.
	 * ------------------------------------------------------------------- */
	.insite .fold__top {
		list-style: none;
	}

	.insite .fold__top::-webkit-details-marker {
		display: none;
	}

	.insite .fold__chev {
		flex: none;
		width: 18px;
		height: 18px;
		/* Hard right. When a roll-up precedes it, THAT carries the auto
		   margin and the chevron follows it; two auto margins would split
		   the slack and park the roll-up in the middle of the line. */
		margin-left: auto;
		color: var(--slate);
		transition: transform .15s ease-out;
	}

	.insite .zone__roll + .fold__chev {
		margin-left: 0;
	}

	.insite details[open] > .fold__top .fold__chev {
		transform: rotate(180deg);
	}

	/* The Devices band on the unit card: name left, roll-up and chevron
	   right. .band__top spreads its children; that is right for a name and
	   one control and wrong for three items, so the roll-up's own auto
	   margin does the pushing instead. */
	.insite .fold__top .band__top {
		justify-content: flex-start;
	}

	/* ---- the flash -----------------------------------------------------
	 * A card you were just sent to, by a tap on an extender row or a via
	 * path. One shot: prohub-devices.js takes the class off on animationend
	 * so the next tap can flash the same card again. Blue, because it is
	 * the link's colour and says "this is where that took you" rather than
	 * "something is wrong".
	 * ------------------------------------------------------------------- */
	.insite .flash {
		animation: nodeFlash 1.4s ease-out;
	}

	@keyframes nodeFlash {
		0%   { box-shadow: 0 0 0 4px var(--link); }
		100% { box-shadow: 0 0 0 4px transparent; }
	}

	/* Room above a card that was scrolled to, so the flash is not cut off
	   by the top of the viewport. */
	.insite .unit {
		scroll-margin-top: var(--s3);
	}

	/* ---- the ProHub Survey Kit band -------------------------------------
	 * What a survey kit gets INSTEAD of the site map, in the same region and
	 * therefore under the same poll: #sitemap is swapped on a markup diff, so
	 * a new reading repaints and an unchanged one does not.
	 *
	 * Shaped like .unit deliberately -- same ground, rule and radius -- so it
	 * reads as "a box of facts about this unit" rather than as a fifth kind
	 * of thing. What it does NOT borrow is .unit__fact's 9.5px-label,
	 * 12px-value grid: on every other card a reading is one of eight, and on
	 * this page it is the whole point. The figures are sized up accordingly.
	 * ------------------------------------------------------------------- */
	.insite .survey {
		display: grid;
		gap: var(--s2);
		padding: var(--s3);
		background: var(--sunk);
		border: 1px solid var(--rule);
		border-radius: 8px;
		/* A grid item of .insite__content, whose min-width is auto. Same
		   reason .sitemap carries this: without it a long timestamp grows the
		   item and the PAGE scrolls sideways. */
		min-width: 0;
	}

	.insite .survey__top {
		display: flex;
		flex-wrap: wrap;
		align-items: baseline;
		justify-content: space-between;
		gap: var(--s2);
	}

	.insite .survey__name {
		font-size: 13px;
		font-weight: 700;
		color: var(--ink);
	}

	/* When it last spoke, or that it has stopped. Muted, because it is the
	   caption on the figures and not a finding of its own. */
	.insite .survey__when {
		font-size: 12px;
		color: var(--muted);
	}

	/* SIDE BY SIDE AND CLOSE TOGETHER, at every width.
	 *
	 * They are a PAIR -- up and down over the same link -- and the reading is
	 * the comparison: a good beacon figure with a bad slave figure is a
	 * different diagnosis from two mediocre ones. So they must not stack on a
	 * phone, and they must not spread on a desktop either. Two 1fr columns did
	 * the second: at 1280px the down figure sat 400px from the up figure, at
	 * the horizontal centre of the page, and the pair stopped reading as a
	 * pair. A flex row sizes both to their content and keeps them adjacent
	 * whatever the page is doing.
	 *
	 * nowrap and row are the defaults and are left as defaults deliberately --
	 * declaring them would invite a later media query to "override" them,
	 * which is the change this must not accept. Two short pills fit side by
	 * side at 320px. */
	.insite .survey__readings {
		display: flex;
		gap: var(--s5);
	}

	.insite .survey__reading {
		display: grid;
		gap: var(--s1);
		justify-items: start;
		min-width: 0;
	}

	.insite .survey__reading b {
		font-size: 9.5px;
		font-weight: 700;
		letter-spacing: .07em;
		text-transform: uppercase;
		color: var(--muted);
	}

	/* Bigger than the 11px pill a device row carries. Someone is holding a
	   phone at arm's length in a corridor watching this number change; it is
	   the one reading on the page that is read at a distance. */
	.insite .survey__reading .pill {
		font-size: 15px;
	}

	/* ---- the site map ---------------------------------------------------
	 * A <details> fold above the attention strip holding three drawings of
	 * the unit, one per detail level; prohub-devices.js shows one. Geometry
	 * comes from company/prohub_site_map.py; these rules only colour it.
	 *
	 * Drawn for every unit EXCEPT a survey kit, which takes the region above.
	 * ------------------------------------------------------------------- */
	.insite .sitemap {
		display: block;
		/* A grid item of .insite__content. Its min-width is auto, so without
		   this it grows to the drawing's width and the box inside never
		   scrolls -- the page scrolls sideways instead. */
		min-width: 0;
	}

	.insite .map {
		/* NO display here. The three boxes rely on the `hidden` attribute,
		   and an author display beats the browser's [hidden] rule. */
		min-width: 0;
		overflow: auto;
		max-height: min(50vh, 800px);
		padding: var(--s2) var(--s1);
	}

	.insite .map__bar {
		display: flex;
		align-items: center;
		gap: var(--s2);
		font-size: 12px;
		color: var(--muted);
	}

	.insite .map__zoom {
		width: 30px;
		height: 30px;
		padding: 0;
		/* Longhands, not `font: 700 16px/1 inherit`. A CSS-wide keyword
		   cannot be one component of a shorthand; that declaration is
		   invalid as a whole and the button keeps the user-agent font. */
		font-family: inherit;
		font-size: 16px;
		font-weight: 700;
		line-height: 1;
		color: var(--ink);
		background: var(--paper);
		border: 1px solid var(--control-edge);
		border-radius: 6px;
		cursor: pointer;
	}

	.insite .map__zoom:disabled {
		opacity: .35;
		cursor: default;
	}

	.insite .map__zoom:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 2px;
	}

	.insite .map__word {
		/* Wide enough for "Overview", so the + button does not move when the
		   word changes. */
		min-width: 5.5em;
		font-weight: 700;
		color: var(--ink);
	}

	.insite .map__svg {
		display: block;
		font-family: inherit;
		/* Centre a drawing narrower than its box -- a small unit pinned to the
		   left of a wide page reads as a bug. An auto margin resolves to ZERO
		   once the drawing is wider than the box, so a big unit still starts
		   at the left edge and overflows right, where it stays scrollable.
		   Centring the box's contents with flex instead would push an
		   over-wide drawing off both edges and put its left half out of
		   reach in a left-to-right scroll container. */
		margin-inline: auto;
	}

	/* The keyboard's way past the drawing. Hidden the same way as .vh above
	   -- visually, never with display:none or [hidden], either of which would
	   take it out of the tab order it exists to be in -- and put back on
	   screen when it is focused. */
	.insite .map__skip {
		position: absolute;
		width: 1px;
		height: 1px;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
	}

	.insite .map__skip:focus {
		position: static;
		width: auto;
		height: auto;
		overflow: visible;
		clip-path: none;
		padding: var(--s1) var(--s2);
		font-weight: 700;
		color: var(--link);
		background: var(--paper);
		border: 1px solid var(--control-edge);
		border-radius: 6px;
	}

	.insite .map__svg a {
		cursor: pointer;
	}

	.insite .map__svg a:hover .map__label,
	.insite .map__svg a:focus-visible .map__label {
		fill: var(--link);
		text-decoration: underline;
	}

	.insite .map__edge {
		fill: none;
		stroke: var(--rule);
		stroke-width: 1.5;
	}

	.insite .map__dot {
		fill: var(--muted);
	}

	.insite .map__label {
		fill: var(--ink);
	}

	.insite .map__ring {
		fill: none;
		stroke-width: 3;
	}

	.insite .map__ring--warn {
		stroke: var(--warn);
	}

	.insite .map__ring--bad {
		stroke: var(--bad);
	}

	/* A one- or two-digit count in a disc: a glyph, not a label, so 9px
	   rather than the 11px floor the words keep. */
	.insite .map__badge text {
		fill: #fff;
		font-size: 9px;
		font-weight: 700;
	}

	.insite .map__badge--warn {
		fill: var(--warn);
	}

	.insite .map__badge--bad {
		fill: var(--bad);
	}

	/* "3 devices below", after an extender's type and serial. Same size as
	   the sub-line it sits in; the link above it is what is blue. */
	.insite .row__below {
		white-space: nowrap;
	}

	/* .row__link is 15px for the site lists, where the name is the card's
	   heading. Here it sits beside a 26px thumbnail at the same size as every
	   other device name; only the colour says it goes somewhere. */
	.insite--nodes .row__link {
		font-size: inherit;
	}

	.insite .zone__line {
		display: flex;
		flex-wrap: wrap;
		align-items: baseline;
		gap: var(--s1) var(--s2);
	}

	.insite .zone__name {
		font-size: 14px;
		font-weight: 700;
	}

	.insite .zone__type {
		font-size: 11px;
		color: var(--muted);
	}

	/* THE ADDRESS LINE. This is the whole idea: where the zone sits on the
	   radio path, written out rather than drawn. The extender names are place
	   names -- Brighton, Level 10, L2 Tower 5 -- so this is wayfinding, and a
	   sentence is the right shape for it. */
	.insite .zone__path {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		gap: 4px;
		font-size: 10.5px;
		color: var(--slate);
	}

	.insite .zone__path i,
	.insite .cell--address i {
		font-style: normal;
		color: var(--dim);
	}

	.insite .zone__hops,
	.insite .row__hops {
		padding: 0 5px;
		font-family: var(--mono);
		font-size: 9.5px;
		font-weight: 600;
		letter-spacing: .04em;
		text-transform: uppercase;
		color: var(--muted);
		background: var(--sunk);
		border: 1px solid var(--rule);
		border-radius: 3px;
	}

	.insite .zone__roll {
		margin-left: auto;
		font-size: 10.5px;
		color: var(--muted);
	}

	/* ---- the unit itself -------------------------------------------------
	 * The ProHub, above its devices: what it is, whether it is talking, and
	 * the three things you can do to it.
	 *
	 * A tinted card rather than a run of headings, because it is the ONE
	 * object on the page that is not a device and the eye should not have to
	 * work that out from the content.
	 * ------------------------------------------------------------------- */
	.insite .unit {
		display: grid;
		gap: var(--s2);
		padding: var(--s3);
		background: var(--sunk);
		border: 1px solid var(--rule);
		border-radius: 8px;
		/* NAMED, and queried by .unit__facts below.
		
		   A media query would answer a different question: .insite has padding
		   of its own, so at a 500px viewport this card's content box is about
		   460px and `@media (min-width: 470px)` has already fired. Same
		   mismatch the note on .insite__content describes. */
		container: unit / inline-size;
	}

	/* Identity left, the two commands that act on the unit right.
	
	   Two boxes rather than one flex run of five items, so they wrap
	   INDEPENDENTLY. The sub-line carries a type, a serial, a timestamp and a
	   timezone and is routinely the widest thing in the card; in one flex run
	   it pushes the buttons off the end of the line and they reappear under
	   the text, which is neither the top nor the right of anything. */
	.insite .unit__top {
		display: flex;
		/* NOWRAP, and the wrapping happens INSIDE .unit__ident instead.
		   With the row allowed to wrap, a phone put the identity on line one
		   and the two icons underneath it at the LEFT margin -- which is
		   neither the top nor the right of anything, and is the one width
		   where a title bar most needs to stay a title bar. */
		flex-wrap: nowrap;
		align-items: flex-start;
		justify-content: space-between;
		gap: var(--s2);
	}

	.insite .unit__ident {
		display: flex;
		flex-wrap: wrap;
		align-items: baseline;
		gap: var(--s1) var(--s2);
		/* Absorbs the slack, so the tools sit hard right at every width, and
		   shrinks below its content rather than pushing them off the card. */
		flex: 1 1 auto;
		min-width: 0;
	}

	/* An <a> to /prohubdetail. Coloured as INK rather than as a link: it is
	   the page's heading first and a destination second, and a blue H1 in a
	   card of grey facts reads as an alert. The underline on hover is what
	   says it can be pressed. */
	.insite .unit__name {
		font-size: 15px;
		font-weight: 700;
		color: var(--ink);
		text-decoration: none;
	}

	.insite a.unit__name:hover,
	.insite a.unit__name:focus-visible {
		text-decoration: underline;
	}

	.insite .unit__sub {
		font-size: 11px;
		color: var(--muted);
	}

	/* ---- the unit's own two commands -------------------------------------
	 * Refresh and the edit pencil, in the title bar as icons.
	 *
	 * They were text buttons in a row under the facts, beside Lockdown and
	 * the door timer -- which put four controls together that do two
	 * completely different things. Lockdown and the timer command the
	 * HARDWARE; these two act on the page and the record. Moving them to the
	 * title bar is the whole distinction, drawn in position rather than
	 * explained in words.
	 * ------------------------------------------------------------------- */
	.insite .unit__tools {
		display: flex;
		align-items: center;
		gap: 2px;
		flex: none;
	}

	/* An icon that is a button. Deliberately the same 30px box and the same
	   tinted hover as the pencil next to it: 2px apart, they read as one pair
	   of controls or as two unrelated ones, and there is no third option. */
	.insite .iconbtn {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 30px;
		height: 30px;
		padding: 0;
		background: none;
		border: 0;
		border-radius: 6px;
		color: var(--link);
		cursor: pointer;
		transition: background-color 0.12s ease, color 0.12s ease;
	}

	.insite .iconbtn svg {
		flex: none;
		width: 20px;
		height: 20px;
	}

	.insite .iconbtn:hover:not(:disabled) {
		background: var(--rule);
		color: var(--ink);
	}

	.insite .iconbtn:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 2px;
	}

	/* Already requested, and waiting for the unit to call in. Dimmed rather
	   than removed: a control that vanishes while you are looking at it reads
	   as a page fault, and .unit__note underneath says why it is grey. */
	.insite .iconbtn:disabled {
		color: var(--dim);
		cursor: default;
	}

	/* The pencil, sized to match the button beside it. .edit is otherwise a
	   glyph with a word after it, which is right in a list row and wrong in a
	   title bar. */
	.insite .unit__tools .edit {
		justify-content: center;
		width: 30px;
		height: 30px;
	}

	/* Why Refresh is grey. It was a <span> in the old button row; the button
	   has moved somewhere a sentence cannot follow it, so the sentence stays
	   where the eye lands after reading the card's name. */
	.insite .unit__note {
		margin: 0;
		font-size: 11px;
		color: var(--muted);
	}

	/* ---- the facts -------------------------------------------------------
	 * ONE COLUMN OF LABEL-BESIDE-VALUE ON A PHONE, exactly like the device
	 * cards below it, and for the same reason: a card is read by scanning the
	 * VALUES, and stacked pairs put them at four different left edges.
	 *
	 * The facts spread out again at 470px -- the same threshold .details uses
	 * to go two-up -- so the unit card and the cards under it change shape at
	 * one width rather than at two. Below it, row-gap is 0 and the space
	 * between fields is each field's own padding, which is what lets the
	 * hairline between them be an unbroken line.
	 * ------------------------------------------------------------------- */
	.insite .unit__facts {
		display: grid;
		grid-template-columns: 1fr;
		gap: 0;
		padding-top: var(--s1);
		border-top: 1px solid var(--rule);
	}

	/* A DIVIDER WITH NOTHING ABOVE IT IS NOT A DIVIDER.

	   Both .unit__facts and .band draw a hairline to separate themselves from
	   what precedes them -- the unit's title bar on /prohubnodes and
	   /prohubdetail, or the previous group where a card carries two.
	   /protectionevent opens its cards with one or the other, so the same rule
	   drew a stray bar a few pixels inside the card's own border, dividing the
	   top of the card from the top of the card.

	   :first-child rather than a page-specific override, because the fault is
	   in the POSITION and not in the page: any card that opens with either of
	   these wants this. Safe on the two prohub pages -- .unit__facts always
	   follows .unit__top there, and both .bands follow a .unit__facts.

	   `.unit >` IS LOAD-BEARING on the .band half, and a bare
	   `.band:first-child` would be a bug. The two bands of an incident row sit
	   in a .pair2, where the left one is also a first child -- stripping its
	   hairline would leave the two columns with different top edges, which is
	   worse than the stray bar this rule removes. */
	.insite .unit > .unit__facts:first-child,
	.insite .unit > .band:first-child {
		padding-top: 0;
		border-top: 0;
	}

	.insite .unit__fact {
		display: grid;
		grid-template-columns: minmax(var(--labelCol), 40%) 1fr;
		column-gap: var(--s3);
		/* Baseline, not top: the label is 9.5px and the values are 12px text
		   and 28px buttons, so aligning the BOXES would leave the two words
		   of one field on visibly different lines. */
		align-items: baseline;
		/* Values sized to their content. A button stretched across the value
		   track is a button that looks like the card's primary action. */
		justify-items: start;
		padding-block: 7px;
	}

	.insite .unit__fact + .unit__fact {
		border-top: 1px solid var(--rule);
	}

	.insite .unit__fact b {
		font-size: 9.5px;
		font-weight: 700;
		letter-spacing: .07em;
		text-transform: uppercase;
		color: var(--muted);
	}

	.insite .unit__fact span {
		font-size: 12px;
	}

	/* The two facts carrying a control rather than a reading. A row, because
	   lockdown is a WORD AND a button -- "LOCKED DOWN" beside EXIT -- and
	   they are one answer to one question. */
	.insite .unit__fact--act > span {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		gap: 6px;
	}

	/* Shorter than a page-level control. A 38px button in a row of 12px
	   readings makes the readings look like its caption. */
	.insite .unit__fact--act .ctrl {
		height: 28px;
		padding: 0 var(--s3);
		font-size: 12px;
	}

	/* The door timer is a destination wearing a control's box, so it must not
	   also wear a link's underline. */
	.insite .unit__fact--act a.ctrl {
		text-decoration: none;
	}

	/* A fact whose VALUE is a destination -- the device count on
	   /prohubdetail, which is the page's only route back to /prohubnodes.
	 *
	 * ITS OWN CLASS, not .row__link. That one is the TITLE LINK OF A LIST ROW,
	 * set at 15px and emitted by _partials/list.html.j2 for every row on
	 * /mysites and /siteinstallations. Borrowing it worked by coincidence and
	 * quietly tied this one link to the size of every row title on two other
	 * pages -- so "make the device count smaller" would have shrunk the site
	 * name on both list pages instead.
	 *
	 * 13px: one step above the 12px its neighbours use, which is enough to
	 * read as a link in a column of plain values without competing with the
	 * card's own name at 15px. */
	.insite .factLink {
		font-size: 13px;
		font-weight: 700;
		color: var(--link);
		text-decoration: none;
	}

	.insite .factLink:hover,
	.insite .factLink:focus-visible {
		text-decoration: underline;
	}

	@container unit (min-width: 470px) {
		.insite .unit__facts {
			/* auto-fit rather than a fixed count: an all-DMS unit has no
			   firmware, a survey unit has no lockdown and a unit with no door
			   hardware has no timer, so the number of facts is not constant. */
			grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
			gap: var(--s2);
			padding-top: var(--s2);
		}

		.insite .unit__fact {
			grid-template-columns: 1fr;
			gap: 2px;
			padding-block: 0;
		}

		.insite .unit__fact + .unit__fact {
			border-top: 0;
		}
	}

	/* ---- a titled band inside a card -------------------------------------
	 * A run of facts with a NAME and its own edit control: Settings inside
	 * the unit card, Trigger actions inside the relays card.
	 *
	 * The one new component on /prohubdetail, and it exists to stop two
	 * editable groups becoming two more cards. A card frame costs a border,
	 * a radius and roughly 120px of vertical space to say "these five
	 * numbers are also about this unit" -- which the card they already sit
	 * in has said.
	 *
	 * The hairline belongs to the BAND, not to the facts inside it, so
	 * .unit__facts has to give up the one it normally draws. Two rules for
	 * one line is how you get two lines.
	 * ------------------------------------------------------------------- */
	.insite .band {
		display: grid;
		gap: var(--s1);
		padding-top: var(--s2);
		border-top: 1px solid var(--rule);
	}

	.insite .band__top {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: var(--s2);
	}

	/* ---- a card that opens ----------------------------------------------
	 * A .unit whose content is a CONTROL rather than a finding. On
	 * /protectionevent the approved wording is the same text the message rows
	 * already carry, and only earns reading when a row reports that what went
	 * out no longer matches it -- so it sits behind a disclosure instead of
	 * competing, at full size, with the messages that actually went.
	 *
	 * <details> and not a scripted toggle: it opens with JavaScript off, it is
	 * keyboard-operable and announced as a disclosure with no ARIA of ours,
	 * and the browser's find-in-page opens it to reveal a match.
	 * ------------------------------------------------------------------- */
	/* .unit.fold, not .fold, so this beats .unit's `display: grid` on
	   SPECIFICITY (0,3,0 against 0,2,0) rather than on source order -- moving
	   either block would otherwise silently restore the grid. */
	.insite .unit.fold {
		/* BLOCK, and this is not cosmetic. Giving a <details> any display
		   other than block is a known way to break its closed state: several
		   engines stop hiding the non-summary children and the panel renders
		   permanently open, which is the exact failure the fold exists to
		   prevent. The grid moves to .fold__body, an ordinary div. */
		display: block;
	}

	.insite .fold__top {
		cursor: pointer;
		font-size: 13px;
		font-weight: 600;
		color: var(--ink);
	}

	.insite .fold__body {
		display: grid;
		gap: var(--s2);
		/* Only ever seen open, so this is the gap under the summary rather
		   than padding a closed card would carry. */
		padding-top: var(--s2);
	}

	/* Small caps rather than a heading size. It names a group of facts
	   inside a card whose own name is 15px; anything bigger competes with
	   the card for the role of "what am I looking at". */
	.insite .band__name {
		margin: 0;
		font-size: 10px;
		font-weight: 700;
		letter-spacing: .12em;
		text-transform: uppercase;
		color: var(--slate);
	}

	.insite .band .unit__facts {
		padding-top: 0;
		border-top: 0;
	}

	/* The band's pencil KEEPS ITS WORD, unlike the one in the title bar.
	   Two bare pencils in one card is a coin toss -- the title-bar one edits
	   the record, this one edits settings, and nothing about a pencil says
	   which. The title bar has position to explain itself; this does not. */
	.insite .band .edit {
		flex: none;
	}


	/* ---- the message pair -----------------------------------------------
	 * /protectionevent renders one (site, audience) row as two bands side by
	 * side: what that group was sent, and what ended it for them. The layout
	 * is .pair2's; these are the parts inside a band that the incident page
	 * needs and the unit pages do not.
	 * ------------------------------------------------------------------- */

	/* The context line is a different KIND of thing from the body under it --
	   composed by the system, per site and per audience, where the body is
	   admin-authored and the same everywhere. Weight rather than size says
	   so without turning it into a heading and competing with .band__name. */
	.insite .band__context {
		font-weight: 600;
	}

	/* What the operator added, kept visibly apart from the approved template.
	   Under an Act audited on communication procedure the two are separate
	   questions, and a reader must be able to tell at a glance which words
	   the sender chose. The rule on the left is the separation; the template
	   prefixes a "+" so it survives with styles off. */
	.insite .band__added {
		padding-left: var(--s2);
		border-left: 2px solid var(--rule);
		color: var(--slate);
	}

	/* Who and when. Quiet, because on most rows it is the least surprising
	   fact in the band -- but never absent: "Not recorded" is a fact too. */
	.insite .band__by {
		font-size: 12px;
		color: var(--slate);
	}

	/* The stored template and what actually went out disagreeing is an AUDIT
	   FINDING, not a rendering fault, so it is graded rather than dimmed. */
	.insite .band__warn {
		color: var(--bad-tx);
	}

	/* "Nobody was subscribed, so nothing was sent" is a CONFIGURATION, not a
	   failure -- a site whose people are all staff has no public audience by
	   design. Graded like .band__warn above would make an ordinary setup read
	   as an incident on the page that is meant to distinguish them, so it is
	   set quiet and italic: plainly not a message, plainly not an alarm. */
	.insite .band__unsent {
		color: var(--slate);
		font-style: italic;
	}


	/* ---- a command that interrupts the unit ------------------------------
	 * Restart, Reboot, and the two version updates. One name, one line of
	 * consequence, one button.
	 *
	 * The table this replaces said "Restart" four times per row: in the
	 * caption, on the button, as the request name and again in a sentence
	 * beginning "This will". What survives is the sentence -- Restart and
	 * Reboot are the one pair on this page nobody can tell apart from the
	 * button labels, which is exactly when a line of consequence earns its
	 * space.
	 * ------------------------------------------------------------------- */
	/* ---- the hazard frame ------------------------------------------------
	 * The Actions card, and ONLY the Actions card. Everything in it
	 * interrupts a live fire-safety device: a restart, a reboot, or a version
	 * change that takes the unit off-line while it installs.
	 *
	 * The confirmation dialog already asks before each one. This is the step
	 * before that -- it marks the box as somewhere to slow down, so the
	 * carefulness starts when the eye lands rather than after the click.
	 *
	 * BUILT FROM BACKGROUNDS, NOT border-image.
	 *
	 * border-image would be the obvious way to draw a striped border and it
	 * cannot be used here: an element with border-image ignores its
	 * border-radius entirely, so this card would render square-cornered
	 * beside the rounded Relays card it shares a row with in .pair2. Two
	 * background layers keep the radius and need no extra markup.
	 *
	 * Layer 1 is the white interior, sized to the padding box less the band
	 * on each side and centred, so the band shows as an even frame.
	 * Layer 2 is the stripes, filling the padding box behind it.
	 * The grey outline is a plain 1px border painted over both.
	 * ------------------------------------------------------------------- */
	.insite .unit--hazard {
		--band: 10px;

		background:
			linear-gradient(var(--paper), var(--paper))
				center / calc(100% - var(--band) * 2) calc(100% - var(--band) * 2)
				no-repeat,
			repeating-linear-gradient(45deg,
				var(--bad) 0 9px,
				var(--paper) 9px 18px);

		/* --slate, not --rule. Every other card on the page is edged in
		   --rule (#dde2e8), which is a hairline meant to separate a tinted
		   card from a white page -- against red-and-white stripes it simply
		   disappears, and the frame then bleeds into the page with nothing
		   containing it. --slate is the heading grey and holds the edge. */
		border: 2px solid var(--slate);
		/* The card's own padding PLUS the band, or the first line of text
		   would sit on the stripes. */
		padding: calc(var(--s3) + var(--band));
	}

	/* The band costs 20px of width on a phone, where the card is already at
	   the viewport edge. Thinner rather than dropped: the warning is the
	   point, and a 6px band still reads as hazard tape. */
	@container page (max-width: 480px) {
		.insite .unit--hazard { --band: 6px; }
	}

	/* Forced-colours mode replaces backgrounds wholesale, so the stripes
	   disappear and the card would lose its only marking. A heavier border
	   is the one thing that survives. */
	@media (forced-colors: active) {
		.insite .unit--hazard { border-width: 3px; }
	}

	.insite .acts {
		display: grid;
		gap: 0;
	}

	.insite .act {
		display: grid;
		grid-template-columns: 1fr auto;
		align-items: center;
		gap: var(--s2) var(--s3);
		padding-block: var(--s2);
	}

	.insite .act + .act {
		border-top: 1px solid var(--rule);
	}

	/* min-width: 0, or a long consequence line refuses to wrap and pushes
	   the button off the card -- the default min-width of a grid item is
	   auto, which is its content. */
	.insite .act__text {
		display: grid;
		gap: 2px;
		min-width: 0;
	}

	.insite .act__name {
		font-size: 12.5px;
		font-weight: 700;
	}

	.insite .act__what {
		font-size: 11px;
		line-height: 1.5;
		color: var(--muted);
	}

	/* ---- the three states of one command ---------------------------------
	 * Available, already with the unit, and unavailable.
	 *
	 * ALL THREE ARE 32px. A row that changes state must not change height:
	 * these repaint every few seconds from the server's request queue, and a
	 * row that grows when a request is accepted moves every row under it
	 * out from beneath the pointer.
	 * ------------------------------------------------------------------- */
	.insite .act .ctrl {
		height: 32px;
		padding: 0 var(--s3);
		font-size: 12.5px;
	}

	/* Already queued or running. Warn rather than ok: something is in flight
	   against a live unit, which is worth noticing without being a fault.

	   This is driven by the SERVER's request status, never by a local click
	   flag, so it survives a reload and a colleague looking at the same unit
	   sees it too. */
	.insite .act__busy {
		display: inline-flex;
		align-items: center;
		gap: var(--s2);
		height: 32px;
		padding: 0 var(--s3);
		font-size: 12px;
		font-weight: 700;
		color: var(--warn-tx);
		background: var(--warn-bg);
		border: 1px solid var(--warn-br);
		border-radius: 4px;
		white-space: nowrap;
	}

	.insite .spinner {
		flex: none;
		width: 13px;
		height: 13px;
		border-radius: 50%;
		border: 2px solid var(--warn-br);
		border-top-color: var(--warn);
		animation: firecoSpin 0.8s linear infinite;
	}

	@keyframes firecoSpin {
		to { transform: rotate(360deg); }
	}

	/* SLOWED, not stopped. A still spinner beside "Request in progress" says
	   the request has hung, which is a different and much worse claim than
	   the one the page is making. */
	@media (prefers-reduced-motion: reduce) {
		.insite .spinner {
			animation-duration: 3s;
		}

		/* A hold, not a fade: the outline appears, stays, and goes. Same
		   duration, so the script's animationend still fires and the class
		   still comes off. */
		.insite .flash {
			animation-timing-function: step-end;
		}

		.insite .fold__chev {
			transition: none;
		}
	}

	/* A command this user holds but the unit cannot take right now -- it is
	   disconnected, or an update is already running. Dimmed and inert rather
	   than removed: a control that vanishes while you are looking at it
	   reads as a page fault, and .act__what underneath says why it is grey.
	   Same reasoning as the disabled refresh icon above.

	   A right the user does not hold removes the whole row instead. That is
	   the template's decision, not this rule's. */
	.insite .act__off {
		display: inline-flex;
		align-items: center;
		height: 32px;
		padding: 0 var(--s3);
		font-size: 12px;
		font-weight: 700;
		color: var(--dim);
		border: 1px solid var(--rule);
		border-radius: 4px;
		white-space: nowrap;
	}


	/* ---- a unit that could not be read ----------------------------------
	 * The exception REPLACES the five readings; it is not a sixth. None of
	 * them could be read, and a card showing four dashes and an error has
	 * invented four facts.
	 * ------------------------------------------------------------------- */
	.insite .fault {
		display: grid;
		gap: 4px;
		padding: var(--s2) var(--s3);
		background: var(--bad-bg);
		border: 1px solid var(--bad-br);
		border-radius: 6px;
	}

	/* NOT uppercased, unlike every other small label on these pages.
	   This is a Python exception class name -- `ConnectionError`, `KeyError`
	   -- and uppercasing turns the one word an engineer greps for into
	   CONNECTIONERROR, which is measurably harder to read and no longer
	   matches anything. The camel case IS the word boundary. */
	.insite .fault__kind {
		font-size: 11px;
		font-weight: 700;
		color: var(--bad-tx);
	}

	/* Ink, not bad-tx. The type above is the label and this is the message
	   somebody has to read and act on; a paragraph of red is harder to read
	   than the same paragraph in black under a red label. */
	.insite .fault__msg {
		font-size: 12px;
		line-height: 1.55;
		color: var(--ink);
	}


	/* ---- two cards side by side ------------------------------------------
	 * Relays and Actions. The whole answer to "why do I scroll on a 4K
	 * monitor": the old page was eight tables in one 750px column.
	 *
	 * A CONTAINER QUERY, not a media query, and the difference is not
	 * academic. .insite has 20px of padding either side, so at a 740px
	 * VIEWPORT the content box is 700px -- `@media (min-width: 720px)` has
	 * already fired and would lay two 340px columns inside 700px of space.
	 * .insite__content is the element these actually live in and is what
	 * `page` names. Same reasoning as .listShell's 960px.
	 * ------------------------------------------------------------------- */
	.insite .pair2 {
		display: grid;
		gap: var(--s3);
		grid-template-columns: 1fr;
	}

	@container page (min-width: 720px) {
		.insite .pair2 {
			grid-template-columns: 1fr 1fr;
			/* start, not stretch: the two cards hold different numbers of
			   facts, and a Relays card stretched to the height of Actions
			   is a card with a hole in the bottom of it. */
			align-items: start;
		}
	}


	/* ---- a zone's own list ----------------------------------------------
	 * Below the table threshold the zone is a run of CARDS, each of which
	 * already draws its own edge. The zone header carries the boundary, so
	 * the cards only need to not touch it -- see the table-mode block for
	 * the edge that goes round the table, and for why it belongs there.
	 * ------------------------------------------------------------------- */
	.insite--nodes .zone .list {
		gap: var(--s2);
	}

	/* ---- product thumbnails ---------------------------------------------
	 * The old table gave every device a 60px product photograph. They stay,
	 * smaller: a photograph identifies hardware faster than a type name does,
	 * and the one distinction that reads at a glance is yellow for radio
	 * infrastructure against blue for a door device.
	 * ------------------------------------------------------------------- */
	/* SCOPED TO THIS PAGE. `.cell--title` is shared: on /mysites and
	   /siteinstallations it stacks a site name, its address and when it last
	   reported, and those are block-level children. Making the shared class a
	   flex row to seat a thumbnail put all three of them on one line on both
	   pages -- which is what shipped on 2026-08-24 and had to be backed out.
	   Only /prohubnodes has a thumbnail, so only /prohubnodes gets the flex. */
	.insite--nodes .cell--title {
		display: flex;
		align-items: flex-start;
		gap: var(--s2);
	}

	.insite .thumb {
		flex: none;
		width: 26px;
		height: 26px;
	}

	/* min-width: 0 so a long device name wraps inside the flex item rather
	   than forcing the row wider than its track. */
	.insite .titletext {
		min-width: 0;
	}

	/* ---- the door ---------------------------------------------------------
	 * The old page's door images, at row height: retained is the OPEN door,
	 * because a Dorgard holds doors open. Its own cell since 2026-09-04 --
	 * the status pill used to carry it as a passenger, and a weak radio spent
	 * the pill, so the door vanished. See deviceRow() in prohub_nodes.html.j2.
	 * ------------------------------------------------------------------- */
	/* Card mode makes the cell a label/value grid aligned on the BASELINE --
	   right for text and pills, wrong for a picture, whose baseline is its
	   bottom edge: the word "Door" sat on the floor of a 26px icon.

	   0-4-0 on purpose. The shared rule, `.insite .details > .cell`, is
	   0-3-0, and the first version of this was `.insite--nodes .cell--door`
	   at 0-2-0 -- which lost, silently, and shipped. Table mode's cell is
	   display: block, where this is inert. */
	.insite.insite--nodes .details > .cell--door {
		align-items: center;
	}

	/* Block, not inline: an inline image is set on the text baseline and
	   carries descender space beneath it, which in table mode drops the icon
	   a few pixels below the centre line every other cell sits on. */
	.insite .door {
		display: block;
		width: 21px;
		height: 26px;
	}

	/* ---- the address ON a row -------------------------------------------
	 * Hidden while grouped, shown when a filter flattens the list.
	 *
	 * The zone header has already said "via Brighton" for every row beneath
	 * it, so repeating it on each row is ink spent saying nothing. The moment
	 * the zones dissolve it becomes the only thing locating the device. Same
	 * markup either way -- a template branch here would mean the filtered view
	 * and the grouped view could drift apart.
	 * ------------------------------------------------------------------- */
	/* Not hidden HERE. Not rendered at all inside a zone -- see the
	   showAddress argument in prohub_nodes.html.j2. A `display: none` on
	   `.insite .zones .cell--via` is 0-3-0 and loses to table mode's
	   `.insite .list--auto .details > .cell` at 0-4-0, so the cells stayed
	   visible AND counted as grid items, putting two extra items into a
	   six-track row. Escalating the specificity would have worked and would
	   have had to be re-escalated the next time table mode grew a rule. Not
	   emitting them cannot lose that fight.

	   ONE LINE, NOT TWO COLUMNS. Until 2026-08-25 the address was a Via
	   column and a Hops column, and Via is the one string on this page with
	   no upper bound: fifteen hops is fourteen names, which wrapped inside
	   its track into a paragraph and made that row four times the height of
	   its neighbours. Widening the track only takes width from the readings.
	   Given the whole row it wraps at most once and pushes nothing.

	   `display: block` on purpose, in both modes. Card mode would otherwise
	   apply the label/value grid to a cell that has no label, stranding the
	   bare text of the path in the label column; table mode's reset sets
	   block anyway, so declaring it here means the two modes cannot disagree.
	   The padding and border resets are the ones .cell--error and
	   .cell--lastword also need: a full-width field spans the two-up
	   divider, so it must not sit against it. */
	.insite .details > .cell--address {
		grid-column: 1 / -1;
		display: block;
		padding-left: 0;
		padding-right: 0;
		border-left: 0;
		font-size: 10.5px;
		line-height: 1.6;
		color: var(--slate);
	}

	/* ---- a reading we cannot vouch for ----------------------------------
	 * EVERY figure on a missing device, dimmed together. Not some of them.
	 *
	 * What the page does today is the bug: powerSupplyVoltage() and
	 * deviceStateIndication() both bail out and return nothing once a device
	 * has no radio reception, but rssiBeacon() and rssiSlave() have no such
	 * guard. So half a dead device goes quiet and half carries on rendering at
	 * full strength -- and the half that carries on is the radio figures,
	 * which are exactly the ones that would have told you it was dead.
	 *
	 * Nothing is HIDDEN. An engineer chasing a fault wants to know whether the
	 * radio was already poor before the device vanished. It is just no longer
	 * presented as current.
	 * ------------------------------------------------------------------- */
	.insite .row--lost .details > .cell {
		color: var(--dim);
	}

	.insite .row--lost .details > .cell[data-label]::before {
		color: var(--dim);
	}

	/* A pill still reads as a pill, so it keeps its shape and loses its
	   insistence. Not display:none -- "Battery 7.41 V" is the last thing the
	   device said and it is evidence. */
	.insite .row--lost .details .pill {
		opacity: .55;
		filter: grayscale(1);
	}

	/* The line that says so.
	 *
	 * A CELL, not a <p>. Every child of .details must be a cell -- the wide-
	 * card divider is a border on :nth-child(even), so a wrapper slipped into
	 * the field list moves the bar onto the wrong side of every pair below it.
	 * Same full-width shape .cell--error already uses, and for the same
	 * reason: there is one field here and it is a sentence. */
	.insite .details > .cell--lastword {
		grid-column: 1 / -1;
		display: flex;
		flex-wrap: wrap;
		align-items: baseline;
		gap: 4px var(--s2);
		margin-top: var(--s1);
		padding-top: var(--s1);
		padding-left: 0;
		padding-right: 0;
		border-top: 1px dashed var(--bad-br);
		border-left: 0;
		font-size: 10.5px;
		color: var(--bad-tx);
	}


	.insite .cell--lastword b {
		font-weight: 700;
	}

	.insite .cell--lastword span {
		color: var(--muted);
	}


	/* ===================================================================
	 * Lockdown: a state and a control in one cell
	 * ===================================================================
	 * The cell that carries an ACTION as well as a reading, which is why no
	 * tenth column was needed for it -- /mysites has always stacked the two
	 * this way.
	 * =================================================================== */

	.insite .cell--lockdown {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 5px;
	}

	/* The state, as TEXT rather than a badge.
	 *
	 * A badge here sits directly above the button and reads as a second
	 * button -- something you could press, which "LOCKED DOWN" emphatically is
	 * not. Uppercase coloured text cannot be mistaken for a control.
	 *
	 * Colour carries urgency, not just difference: locked is the same red as
	 * every other bad state on the page, entering the same amber, exiting the
	 * same green. Nothing is wrong with a unit standing down. */
	.insite .lockState {
		font-size: 11px;
		font-weight: 700;
		letter-spacing: 0.06em;
		line-height: 1.25;
		white-space: nowrap;
	}
	.insite .lockState--locked   { color: var(--bad); }
	.insite .lockState--entering { color: var(--warn-tx); }
	.insite .lockState--exiting  { color: var(--good-tx); }

	/* The one control on this page that ACTS rather than navigates, and it
	 * wears the brand's lockdown pairing to say so -- the same black and
	 * signal yellow as /mysites and prohub_nodes.html.j2.
	 *
	 * One word. The column header already says "Lockdown", and in card mode
	 * so does the field label, so the button only has to carry the verb. The
	 * accessible name is the full sentence -- see the .vh span in the markup
	 * -- because "ENTER" read aloud out of context names nothing. */
	.insite .lockBtn {
		font-family: var(--sans);
		font-size: 11px;
		font-weight: 700;
		letter-spacing: 0.04em;
		line-height: 1;
		color: var(--signal);
		background: #000000;
		border: 1px solid #000000;
		border-radius: 7px;
		padding: 6px 11px;
		cursor: pointer;
	}
	.insite .lockBtn:hover { background: #1c1c1c; }
	.insite .lockBtn:active { background: #000000; transform: translateY(1px); }
	.insite .lockBtn:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 2px;
	}

	/* ===================================================================
	 * Confirmation dialog
	 * ===================================================================
	 * A native <dialog>. This page is deliberately jQuery-free, and pulling
	 * ~330KB of CDN JavaScript back in to draw one modal would undo that for
	 * a box the browser already provides -- modal, focus-trapped, Escape
	 * closes, background inert, all without a line of script.
	 * =================================================================== */

	/* Scoped under .insite like everything else here. The dialog sits INSIDE
	   .insite but outside #pagecontent -- outside the scope it would lose
	   .ctrl and .vh, which are both scoped, and the Cancel button would have
	   rendered as a bare browser button. */
	.insite dialog.confirm::backdrop {
		background: rgba(16, 22, 32, 0.55);
	}

	.insite dialog.confirm {
		border: 1px solid var(--rule);
		border-radius: 10px;
		padding: 0;
		max-width: min(92vw, 380px);
		color: var(--ink);
		background: var(--paper);
		box-shadow: 0 18px 48px rgba(16, 22, 32, 0.28);
	}

	.insite dialog.confirm .confirm__body {
		display: grid;
		gap: var(--s2);
		padding: var(--s3);
		font-family: var(--sans);
	}

	.insite dialog.confirm .confirm__head {
		margin: 0;
		font-size: 13px;
		font-weight: 700;
		letter-spacing: 0.08em;
		text-transform: uppercase;
		color: var(--slate);
	}

	/* The question, not a warning. The user pressed a button labelled ENTER;
	   this asks them to mean it, it does not tell them off for trying. */
	.insite dialog.confirm .confirm__what {
		margin: 0;
		font-size: 17px;
		font-weight: 700;
		line-height: 1.3;
	}

	/* WHICH unit. The whole point of confirming on a list page: the rows are
	   near-identical and the button that was pressed is no longer visible
	   once the modal is up. */
	.insite dialog.confirm .confirm__unit {
		margin: 0;
		font-size: 14px;
		color: var(--slate);
	}

	.insite dialog.confirm .confirm__actions {
		display: flex;
		justify-content: flex-end;
		align-items: center;
		gap: var(--s2);
		margin-top: var(--s1);
	}

	/* The version picker's one field: a caption over a full-width select.
	   Same box as the confirm partial, so the two read as one family. */
	.insite dialog.confirm .confirm__field {
		display: grid;
		gap: 6px;
		font-size: 12px;
		font-weight: 700;
		letter-spacing: 0.06em;
		text-transform: uppercase;
		color: var(--slate);
	}

	.insite dialog.confirm .confirm__field select {
		font: inherit;
		font-size: 15px;
		font-weight: 500;
		letter-spacing: 0;
		text-transform: none;
		color: var(--ink);
		background: var(--paper);
		border: 1px solid var(--rule);
		border-radius: 8px;
		padding: 8px 10px;
	}

	/* Bigger than the button in the row. This one commits. */
	.insite dialog.confirm .lockBtn--confirm {
		font-size: 13px;
		padding: 9px 18px;
	}

	/* The confirming half of a generic confirmation.
	 *
	 * RED, unlike lockdown's, and the difference is real. Lockdown is a
	 * reversible state that a building manager enters on purpose; Restart and
	 * Reboot interrupt a live fire-safety device and cannot be taken back.
	 * The dialog asks the same question either way -- the button says how
	 * much the answer costs.
	 *
	 * `.ctrl.ctrl--danger`, NOT `.ctrl--danger`, and this is not stylistic.
	 * The outline variant is written `.insite .ctrl--outline, .insite
	 * dialog.confirm .ctrl` -- that second selector matches EVERY control in
	 * this dialog, at 0-3-1. Written as `.insite dialog.confirm .ctrl--danger`
	 * this rule is also 0-3-1, so the two tie and SOURCE ORDER decides; the
	 * outline rule is declared later in the sheet and the confirm button
	 * would render with a white fill. Doubling the class makes it 0-4-1 and
	 * takes the outcome away from wherever anyone later moves either block.
	 * Cancel is a bare .ctrl and keeps the outline.
	 */
	.insite dialog.confirm .ctrl.ctrl--danger {
		font-size: 13px;
		padding: 9px 18px;
		background: var(--bad);
		border-color: var(--bad);
		color: var(--paper);
	}

	.insite dialog.confirm .ctrl.ctrl--danger:hover {
		background: var(--bad-tx);
		border-color: var(--bad-tx);
	}

	/* The version picker keeps Update DISABLED while the chosen version is
	   the installed one. Full red on a button that cannot be pressed read as
	   a working one. After :hover -- same specificity, so source order
	   decides, and a disabled button must not light up under the pointer. */
	.insite dialog.confirm .ctrl.ctrl--danger:disabled {
		background: var(--dim);
		border-color: var(--dim);
		cursor: default;
	}

	/* Identifiers only. Unit codes and version numbers are compared character
	   by character; a monospaced count line was decoration. */
	.insite .row__id {
		font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
		font-size: 11px;
		letter-spacing: 0.02em;
		color: var(--slate);
	}

	/* Two-up cards once the container is roomy. Five cards then read as three
	   rows of two, which is the whole reason the boundary sits at five. */
	/* ---- the two-up card band: 680px and up -----------------------------
	 * A portrait tablet -- an iPad mini is 768px -- is too narrow for a nine
	 * column table and too wide for a single column of cards. Between 680px
	 * and the table threshold BOTH list kinds render two-up.
	 *
	 * .list--auto is in this selector deliberately: a six-row list is cards
	 * here and a table only once there is room for one. The row count decides
	 * whether a table is ever appropriate; the width decides whether it fits.
	 *
	 * :not(.list--solo) -- a one-row list is never two-up. Without the
	 * exclusion the solo card becomes a two-column grid capped at 560px and
	 * the single card lands in one 280px column, the opposite of what the cap
	 * is for.
	 * ------------------------------------------------------------------- */
	@container listShell (min-width: 680px) {
		.insite .list--cards:not(.list--solo),
		.insite .list--auto {
			display: grid;
			grid-template-columns: 1fr 1fr;
			/* No align-items here, so grid's default `stretch` applies and
			   both cards in a row take the height of the taller one. A site
			   with a three-line address next to one with a one-line address
			   otherwise leaves a ragged gap under the shorter card, and the
			   pair stops reading as a row. */
		}
	}

	/* One row: hold the card to a readable measure. Sparseness is a desktop
	   problem -- a single card stretched across 1400px looks broken, and this
	   is the whole fix. */
	/* One row: hold the card to a readable measure, and never grid it.
	   Sparseness is a desktop problem -- a single card stretched across
	   1400px looks broken -- and this cap is the whole fix. 194 of the 322
	   accounts that can reach /mysites see exactly one site, so this is the
	   most common view in the system. */
	.insite .list--cards.list--solo {
		display: flex;
		max-width: 560px;
	}

	/* The solo card is the ONE card that is a flex item, and `container-type:
	   inline-size` on .row makes its max-content contribution zero -- a flex
	   base size of nothing, and a card 0px wide. Growing it into the 560px is
	   what the cap was always for, so this states it rather than relying on
	   the content to push the box out. */
	.insite .list--cards.list--solo .row {
		flex: 1;
	}

	/* ---- table mode ------------------------------------------------------
	 * Same markup. .details stops being a box and becomes display:contents,
	 * so its children become direct grid items of .row and line up as columns.
	 * --cols is set per page, on the list.
	 * -------------------------------------------------------------------- */
	/* MUST stay after the two-up block: `display: block` here is what takes
	   .list--auto back out of the card grid it joined at 680px. */
	@container listShell (min-width: 960px) {
		.insite .list--auto {
			display: block;
		}

		.insite .list--auto .list__head {
			display: grid;
			grid-template-columns: var(--cols);
			gap: var(--s3);
			padding: 0 var(--s3) var(--s1);
			border-bottom: 2px solid var(--rule);
		}

		.insite .list--auto .row {
			grid-template-columns: var(--cols);
			gap: var(--s3);
			align-items: center;
			padding: var(--s2) var(--s3);
			border: 0;
			border-bottom: 1px solid var(--rule);
			border-radius: 0;
		}

		/* ---- a zone's own table --------------------------------------
		 * Table mode gives .list--auto a bottom rule per row and nothing
		 * around the outside, which is right for /mysites -- one table
		 * filling the page. Stack sixteen of them under sixteen headings
		 * and each one needs an edge, or the zones run into each other
		 * and the header stops looking like it belongs to anything.
		 *
		 * INSIDE the query, and that is a FIX. This lived outside it, so
		 * it also framed the zone below 960px -- where .list--auto is a
		 * two-up grid of CARDS and the rule above has not yet stripped
		 * their own borders. Every zone on a tablet drew a box around a
		 * row of boxes. Same mistake as the pencil placement above: a
		 * rule written for the table, filed where the cards can read it.
		 *
		 * `overflow: hidden` is what clips the rows' square corners
		 * inside the rounded edge, and it only has corners to clip here.
		 * -------------------------------------------------------------- */
		.insite--nodes .zone .list--auto {
			border: 1px solid var(--rule);
			border-radius: 6px;
			overflow: hidden;
		}

		.insite--nodes .zone .list--auto .list__head {
			background: var(--field);
		}

		/* :last-child is 0-4-0 against the 0-3-0 row rule above, so it
		   wins on specificity rather than on source order. */
		.insite--nodes .zone .list--auto .row:last-child {
			border-bottom: 0;
		}

		.insite .list--auto .cell--title,
		.insite .list--auto .row:not(:has(.cell--status)) .cell--title {
			grid-column: auto;
			grid-row: auto;
		}

		/* Status leads the table but follows the title in a card. One
		   markup, two reading orders. */
		.insite .list--auto .cell--status {
			grid-column: auto;
			grid-row: auto;
			justify-self: start;
			order: -1;
		}

		/* Except on /prohubnodes, where State is a Dorgard-only property of
		   the device rather than the row's verdict, and reads after the name
		   it belongs to. The template already emits it in that order; this
		   undoes the pull above.

		   .insite.insite--nodes, both on the same element, so this is 0-4-0
		   against the 0-3-0 above and wins on specificity rather than on
		   source order -- which would otherwise have to be maintained inside
		   this media block by hand. */
		.insite.insite--nodes .list--auto .cell--status {
			order: 0;
		}

		/* The door icon at the size Dave asked for in a table (2026-09-04):
		   the row has the height for it, and a door drawn at the same 26px as
		   the product thumbnail beside it reads as a second thumbnail. The
		   card keeps the 21x26 the img attributes declare -- this rule is
		   INSIDE the query so it cannot reach the cards. 0-3-0 against the
		   0-2-0 of `.insite .door`, so it wins on specificity, not order. */
		.insite .list--auto .door {
			width: 28px;
			height: 36px;
		}

		.insite .list--auto .details {
			display: contents;
			margin: 0;
			padding: 0;
			border: 0;
		}

		/* A cell stops being a label-beside-value pair and becomes one table
		   cell: the column header is the label here, and the two-column grid
		   inside every cell would put a 88px hole in front of each value. */
		.insite .list--auto .details > .cell {
			display: block;
			padding: 0;
			border-left: 0;
		}

		/* ...except lockdown, which stacks a state over a button in both
		   modes and needs its flex column back -- the reset above is more
		   specific than the .cell--lockdown rule that grants it. */
		.insite .list--auto .details > .cell--lockdown {
			display: flex;
		}

		/* ---- an error uses the whole rest of the row --------------------
		 * A unit that failed to report has no readings, so every column from
		 * Fire rightwards is empty and the message is squeezed into a 76px
		 * track and wrapped over four lines. It is the one thing on that row
		 * worth reading, so it gets the room the readings were not using.
		 *
		 * `3 / -1` is "after Status and Installation, to the end". Both of
		 * those columns always exist on this page -- every unit row is
		 * rendered with a status token -- and this page has no edit column
		 * for the span to swallow.
		 *
		 * The code and the message go side by side rather than stacked: on
		 * one line they read as a sentence, and a row is one line high.
		 * ---------------------------------------------------------------- */
		.insite .list--auto .details > .cell--error {
			grid-column: 3 / -1;
			display: flex;
			flex-wrap: wrap;
			align-items: baseline;
			gap: 4px var(--s2);
		}

		.insite .list--auto .cell--error .row__sub {
			margin-top: 0;
		}

		.insite .list--auto .cell[data-label]::before {
			display: none;
		}

		/* ---- numeric columns ------------------------------------------
		 * TABLE MODE ONLY, and that restriction is the point. In card mode
		 * the label is a block sitting directly above its value; pushing the
		 * value to the far edge of the card would part it from the word that
		 * names it. A column only has an edge to align to once there is a
		 * column.
		 *
		 * The header needs its own rule rather than inheriting text-align:
		 * .list__sort is an inline-flex BUTTON, so it shrinks to its content
		 * and sits wherever the grid puts it. justify-self moves the item;
		 * text-align could only move text inside it, which is already as
		 * wide as it gets.
		 *
		 * Both halves or neither: a right-aligned column under a left-aligned
		 * header reads as a mistake, and is one.
		 * -------------------------------------------------------------- */
		.insite .list--auto .cell--right {
			text-align: right;
		}

		.insite .list--auto .list__sort--right,
		.insite .list--auto .list__label--right {
			justify-self: end;
		}

		/* The column header says "edit", so the word is redundant here. It
		   stays in the accessibility tree -- only the visual copy goes. */
		.insite .list--auto .cell--edit {
			justify-self: center;
		}

		/* A full-width line takes a row of its own, and the pencil must NOT
		   follow it there.
		 *
		 * .details is `display: contents` in table mode, so every cell is a
		 * grid item of the ROW. Auto-placement fills the first row with the
		 * columns, meets a cell spanning 1/-1, breaks to a new row for it --
		 * and then puts the edit pencil, which comes after it in source, on a
		 * THIRD row at column one. Which is exactly where it rendered: under
		 * the sentence, on the left, nowhere near the column its header is in.
		 *
		 * INSIDE the table-mode query, and that is a FIX. The .row--lost half
		 * of this rule lived outside it and therefore applied to cards too,
		 * where the row grid is title-and-status and `grid-row: 1` at the last
		 * column put the pencil on top of the status pill. Every missing
		 * device on a phone has been drawing "Edit" through its own Missing
		 * badge; it took a second row kind arriving for anyone to look.
		 *
		 * ONLY the pencil is placed. Auto-placement gets the full-width lines
		 * right on its own, in source order, however many there are -- and
		 * there can now be two, a filtered row carrying its address as well as
		 * the sentence. Pinning those would mean a rule per combination, and
		 * two rules of equal specificity disagreeing about a row that is both.
		 *
		 * The markup is not reordered instead, because markup order is the
		 * CARD's reading order -- the address and the sentence belong with the
		 * readings they qualify, and the pencil belongs at the end of a card. */
		.insite .list--auto .row--lost .cell--edit,
		.insite .list--auto .row--addr .cell--edit {
			grid-column: -2 / -1;
			grid-row: 1;
		}


		.insite .list--auto .cell--edit .edittext {
			position: absolute;
			width: 1px;
			height: 1px;
			overflow: hidden;
			clip-path: inset(50%);
			white-space: nowrap;
		}

		.insite .list--auto .row__link {
			font-size: 13px;
		}

		/* A heading is a CARD's affordance. In table mode the name is a cell
		   in a column under a caption that already names it, and a row of
		   15px titles beside 12px values reads as a list of headings rather
		   than as a table. Same step down, same threshold, same reason as
		   .row__link above -- and only where .row__name was made a heading in
		   the first place. */
		.insite--sites .list--auto .row__name {
			font-size: 13px;
		}
	}

	/* ---- sortable column headers ---------------------------------------- */
	/* A column that can be read but not ordered -- Fire and Alert are computed
	   per row with no SQL column behind them. Styled as the sort buttons but
	   without the affordance, because a header that looks clickable and is not
	   is worse than one that plainly is not. */
	.insite .list__label {
		display: inline-flex;
		align-items: center;
		padding: 4px 0;
		font-size: 10px;
		font-weight: 700;
		letter-spacing: 0.06em;
		text-transform: uppercase;
		color: var(--slate);
	}

	.insite .list__sort {
		display: inline-flex;
		align-items: center;
		gap: 4px;
		padding: 4px 0;
		font: inherit;
		font-size: 10px;
		font-weight: 700;
		letter-spacing: 0.06em;
		text-transform: uppercase;
		text-align: left;
		color: var(--slate);
		background: none;
		border: 0;
		cursor: pointer;
	}

	.insite .list__sort::after {
		content: "\2195";
		font-size: 11px;
		font-weight: 400;
		opacity: 0.45;
	}

	.insite .list__sort:hover {
		color: var(--link);
	}

	.insite .list__sort[aria-sort="ascending"],
	.insite .list__sort[aria-sort="descending"] {
		color: var(--link);
	}

	.insite .list__sort[aria-sort="ascending"]::after {
		content: "\25B2";
		font-size: 8px;
		opacity: 1;
	}

	.insite .list__sort[aria-sort="descending"]::after {
		content: "\25BC";
		font-size: 8px;
		opacity: 1;
	}

	/* ===================================================================
	 * Status pills
	 *
	 * A dot AND a word, never colour alone -- roughly one man in twelve
	 * cannot separate red from green, and a bare triangle does not say what
	 * is wrong.
	 *
	 * The three dots are separated by LIGHTNESS rather than hue: 0.11, 0.16
	 * and 0.33 relative luminance. Red and orange converge under
	 * deuteranopia and protanopia, so hue separation buys nothing there; a
	 * threefold lightness gap survives it. Text clears 6:1 on its own ground
	 * in all three.
	 *
	 * --signal stays reserved for lockdown. It is the one colour in the
	 * system that means ACT rather than NOTICE, and --warn is kept clear of
	 * it deliberately.
	 * =================================================================== */

	.insite .pill {
		display: inline-flex;
		align-items: center;
		gap: 5px;
		padding: 3px 9px 3px 7px;
		font-size: 11px;
		font-weight: 700;
		white-space: nowrap;
		border: 1px solid;
		border-radius: 999px;
	}

	.insite .pill::before {
		content: "";
		flex: none;
		width: 7px;
		height: 7px;
		border-radius: 50%;
	}

	/* OK WEARS NO BOX. Changed 2026-08-21.
	 *
	 * Most rows on both list pages are healthy, so a filled, bordered green
	 * chip was the page's most repeated shape -- and the eye tunes out what
	 * repeats. The two chips that matter had to compete with a hundred that
	 * did not. Dropping the box leaves the green dot and the green word,
	 * which is still a dot AND a word, and lets `warn` and `bad` be the only
	 * boxed things on the page.
	 *
	 * `background: none` + `border-color: transparent`, NOT `border: 0`.
	 * The pill keeps its 1px ring and its padding, so it occupies exactly the
	 * space a warn or bad pill does; remove the border and every ok pill
	 * shrinks by 2px, shifting its dot out of line with the coloured pills
	 * above and below it in the same column.
	 *
	 * Contrast goes UP, not down: the text was 7.08:1 on its own tint and is
	 * 7.57:1 on paper, the dot 4.75:1 -> 5.08:1. The tint was always the
	 * lighter partner in that pair.
	 */
	.insite .pill--ok      { color: var(--good-tx); background: none; border-color: transparent; }
	.insite .pill--ok::before   { background: var(--good); }
	.insite .pill--warn    { color: var(--warn-tx); background: var(--warn-bg); border-color: var(--warn-br); }
	.insite .pill--warn::before { background: var(--warn); }
	.insite .pill--bad     { color: var(--bad-tx);  background: var(--bad-bg);  border-color: var(--bad-br); }
	.insite .pill--bad::before  { background: var(--bad); }
	.insite .pill--none    { color: var(--slate);   background: var(--sunk);    border-color: var(--rule); }
	.insite .pill--none::before { background: var(--slate); }

	/* The unit count. A bare bold number, not a chip.
	 *
	 * It wore a bordered, tinted pill until 2026-08-21, which made a plain
	 * reading look like a control -- the row already carries two real ones
	 * (the lockdown button and the edit pencil) and a third box competing with
	 * them cost more than it explained. The column header says "Units"; the
	 * number only has to be findable.
	 *
	 * tabular-nums so 7 and 13 line up down the column: the point of the
	 * column is comparing them at a glance. */
	.insite .count {
		font-weight: 700;
		font-variant-numeric: tabular-nums;
	}

	.insite .dash {
		color: var(--slate);
	}

	/* Field values sit a step below body text. A site list is scanned rather
	   than read, and an installer name at full body size competes with the
	   site name it belongs to. */
	.insite .cell {
		font-size: 13px;
		line-height: 1.4;
	}

	/* ---- the edit control ------------------------------------------------
	 * A pencil in the table, where the column header carries the meaning, and
	 * a labelled link in a card, where there is no header to carry it. Same
	 * element either way: .edittext is visually hidden only in table mode.
	 *
	 * .vh holds the site name for a screen reader, so "Edit" is never
	 * announced without saying WHAT is being edited -- twenty-five identical
	 * "Edit" links is what that would otherwise be.
	 * -------------------------------------------------------------------- */
	/* Scoped to .insite, NOT to .cell--edit. The pencil is a component, and
	   the unit card on /prohubnodes carries one outside any list row. Nothing
	   else on the site uses the class, so widening it starts no fight -- but
	   note the WORD is still hidden only under .list--auto .cell--edit, which
	   is correct: outside a table there is room to say "Edit". */
	.insite .edit {
		display: inline-flex;
		align-items: center;
		gap: 6px;
		font-size: 12px;
		font-weight: 700;
		color: var(--link);
		text-decoration: none;
		/* Explicit, because a dialog-opening pencil is a <div> and a <div>
		   does not get the anchor's pointer for free. */
		cursor: pointer;
	}

	.insite .edit svg {
		flex: none;
		width: 20px;
		height: 20px;
		border-radius: 6px;
		transition: background-color 0.12s ease, color 0.12s ease;
	}

	/* In TABLE mode the word "Edit" is visually hidden, so underlining it is
	   feedback nobody can see -- the pencil has to answer for itself, or it
	   reads as an icon printed on the row rather than a thing you can press.
	 *
	 * `outline` rather than padding: it pads the tinted patch out past the
	 * 20px glyph without adding width to a cell in a nine-column grid, and it
	 * follows the border-radius. Nothing here changes layout, so the pencil
	 * cannot nudge the row on hover. */
	.insite .edit:hover svg,
	.insite .edit:focus-visible svg {
		background: var(--rule);
		outline: 4px solid var(--rule);
		color: var(--ink);
	}

	/* The word, where there is one. Card mode only in practice -- see above. */
	.insite .edit:hover .edittext,
	.insite .edit:focus-visible .edittext {
		text-decoration: underline;
	}

	.insite .edit:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 2px;
		border-radius: 6px;
	}

	.insite .vh {
		position: absolute;
		width: 1px;
		height: 1px;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
	}

	/* ===================================================================
	 * Page head and summary -- /mysites and /siteinstallations
	 *
	 * The heading, the one-line thesis under it, and the count line. Shared
	 * because both pages answer the same question before the list starts:
	 * "is anything wrong, and how much am I looking at?"
	 *
	 * The summary is usually a LINK -- clicking it applies the filter it
	 * describes -- so the strip and the filter bar are one mechanism rather
	 * than two things that have to be kept in agreement.
	 * =================================================================== */
	.insite .head {
		display: grid;
		gap: var(--s3);
	}

	.insite .headRow {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		justify-content: space-between;
		gap: var(--s2) var(--s3);
	}

	.insite .summary {
		display: grid;
		grid-template-columns: 22px 1fr;
		gap: 2px var(--s3);
		padding: var(--s3) var(--s4) var(--s3) var(--s3);
		border: 1px solid var(--rule);
		border-left: 4px solid var(--slate);
		border-radius: 10px;
		background: var(--field);
		color: inherit;
		text-decoration: none;
	}

	.insite .summary:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 2px;
	}

	.insite .summary--attention {
		border-color: var(--bad-br);
		border-left-color: var(--bad);
		background: var(--bad-bg);
	}

	.insite .summary--attention:hover {
		background: #fbecec;
	}

	.insite .summary__mark {
		grid-row: 1 / span 2;
		align-self: start;
		font-size: 15px;
		line-height: 1.3;
		color: var(--slate);
	}

	.insite .summary--attention .summary__mark {
		color: var(--bad);
	}

	.insite .summary__head {
		font-size: 16px;
		font-weight: 700;
		line-height: 1.3;
	}

	.insite .summary--attention .summary__head {
		color: var(--bad-tx);
	}

	.insite .summary__names {
		font-size: 13px;
		line-height: 1.5;
		color: var(--slate);
	}

	.insite .summary--attention .summary__names {
		color: #7a3a3a;
	}

	.insite .summary__go {
		grid-column: 2;
		margin-top: var(--s1);
		font-size: 13px;
		font-weight: 700;
		color: var(--link);
		text-decoration: underline;
		text-underline-offset: 3px;
	}

	.insite .summary--attention .summary__go {
		color: var(--bad-tx);
	}

	/* Uppercase and monospaced so it reads as instrumentation rather than as
	   a sentence -- it is the only text on the page that changes without the
	   user doing anything. */
	.insite .head__foot {
		margin: 0;
		font-family: var(--mono);
		font-size: 12px;
		letter-spacing: 0.06em;
		text-transform: uppercase;
		color: var(--slate);
	}

	/* ===================================================================
	 * Filter bar -- /mysites and /siteinstallations
	 *
	 * A GET form, always. Every control ends up in the query string, which
	 * is what makes the view bookmarkable AND what lets both pages' timed
	 * background refresh keep the filters: it re-requests location.search,
	 * which already carries them.
	 *
	 * Rendered only above each page's row-count threshold -- see section 3
	 * of the design brief. Most users never see it.
	 *
	 * Mobile-first: everything except search and sort collapses behind one
	 * button below 800px, and .controls__more becomes display: contents
	 * above it so the same markup is a single row on a desktop.
	 * =================================================================== */
	.insite .controls {
		display: grid;
		gap: var(--s2);
		padding: var(--s2) 12px;
		background: var(--bar);
		border: 1px solid var(--bar-edge);
		border-radius: 6px;
	}

	.insite .controls__row {
		display: flex;
		flex-wrap: wrap;
		align-items: flex-end;
		gap: var(--s2);
		min-width: 0;
	}

	.insite .controls__row > * {
		min-width: 0;
	}

	.insite .search {
		display: flex;
		flex: 1 1 100%;
		gap: var(--s1);
		min-width: 0;
	}

	/* The button keeps its label; the field gives up the room. */
	.insite .search .ctrl {
		flex: 0 0 auto;
	}

	.insite .search input {
		flex: 1 1 auto;
		min-width: 0;
		/* 38px, not the 44px iOS tap-target floor.
		   That floor is a MINIMUM for isolated targets, and these are not:
		   they sit in a labelled column with 10px between them, so the miss
		   distance is what protects them rather than the height. 44 across
		   six stacked controls made the bar the tallest thing on the page. */
		height: 38px;
		padding: 0 var(--s2);
		font-family: var(--sans);
		/* 16px floor: below it iOS Safari zooms the page on focus, which
		   fights width=device-width. Desktop drops it to 13px. */
		font-size: 16px;
		color: var(--ink);
		background: var(--paper);
		border: 1px solid var(--control-edge);
		border-radius: 4px;
	}

	.insite .search input:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 1px;
	}

	/* .ctrl, NOT .btn.
	   .btn is already taken -- it is the login page's full-width 52px pill,
	   defined above. The filter bar reused the name and inherited
	   `min-height: 52px` and `width: 100%` along with it, so Search rendered
	   52px tall in a row of 30px fields. Setting `height: 30px` did not fix
	   it and could not: min-height beats height, whatever the specificity.
	   Two different components, two names.

	   Every control also declares its OWN fill and border, and no rule here
	   matches both a field and the submit button. /mysites carried a long
	   note about why: it had one rule giving the fields their white fill
	   that also matched the submit button, at HIGHER specificity than the
	   button's own rule, so Search rendered white-on-white and picked up its
	   colours only on :hover. The fix shipped there was a
	   :not([type="submit"]); not sharing the rule at all is the fix that
	   does not start an arms race with the next field rule anyone adds. */
	.insite .ctrl {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		gap: var(--s2);
		height: 38px;
		padding: 0 var(--s4);
		font-family: var(--sans);
		font-size: 14px;
		font-weight: 700;
		border-radius: 4px;
		border: 1px solid transparent;
		cursor: pointer;
		white-space: nowrap;
		min-width: 0;
	}

	.insite .ctrl:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 2px;
	}

	/* The DEFAULT look: white, outlined, ink. Until 2026-08-24 the base class
	   set a transparent border and no fill, so anything wearing a bare .ctrl
	   -- Door timer and Refresh here, Cancel in every lockdown confirmation
	   -- rendered as the browser's own grey button in the middle of a
	   designed page. Both variants below declare their own fill and border,
	   so neither is affected. */
	.insite .ctrl--outline,
	.insite dialog.confirm .ctrl {
		background: var(--paper);
		border-color: var(--control-edge);
		color: var(--ink);
	}

	.insite .ctrl--outline:hover,
	.insite dialog.confirm .ctrl:hover {
		background: #f7f9fc;
	}

	/* A rounded rectangle, deliberately not the Fireco pill: the pill is the
	   brand's primary-action shape and this re-runs a query. */
	.insite .ctrl--search {
		background: var(--navy);
		border-color: var(--navy);
		color: var(--paper);
	}

	.insite .ctrl--search:hover  { background: var(--navy-hover); border-color: var(--navy-hover); }
	.insite .ctrl--search:active { background: var(--navy-press); border-color: var(--navy-press); }

	/* The phone's filter disclosure. */
	.insite .ctrl--filters {
		border-radius: 999px;
		background: var(--paper);
		border-color: var(--control-edge);
		color: var(--ink);
		font-weight: 400;
	}

	.insite .ctrl--filters:hover {
		background: #f7f9fc;
	}

	/* Signal yellow does exactly one job in this bar: "you have changed
	   something". It is the count of filters actually set, so a user who has
	   collapsed the controls can still tell the list is being narrowed. */
	.insite .ctrl__count {
		background: var(--signal);
		color: var(--ink);
		border-radius: 999px;
		padding: 1px 8px;
		font-size: 12px;
		font-weight: 700;
	}

	.insite .pick {
		display: flex;
		flex-direction: column;
		gap: 3px;
		/* A <select> sizes to its LONGEST OPTION and will not shrink below
		   that intrinsic width unless it is told to. 'Maintenance due' and
		   'Serial number' pushed the bar 4px past a 360px viewport, which is
		   horizontal scroll -- the exact defect this rebuild exists to
		   remove. Measured against clientWidth, not the window width: the
		   vertical scrollbar takes 15px, and comparing against the window
		   hides a 4px overflow completely. */
		min-width: 0;
		max-width: 100%;
		font-family: var(--sans);
		font-size: 12px;
		color: var(--bar-label);
	}

	.insite .pick select {
		min-width: 0;
		max-width: 100%;
		height: 38px;
		padding: 0 var(--s1);
		font-family: var(--sans);
		font-size: 14px;
		color: var(--ink);
		background: var(--paper);
		border: 1px solid var(--control-edge);
		border-radius: 4px;
	}

	.insite .pick select:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 1px;
	}

	/* Sort shares the Filters button's line and sits at the far end of it.
	   Below 800px those two are the only controls on screen once the panel is
	   shut, and a row with one button on the left and one control on the
	   right reads as a pair of choices rather than as a stack. */
	.insite .pick--sort {
		margin-left: auto;
	}

	/* Collapsed on a phone; the toggle adds .isOpen. On a desktop both this
	   and its inner wrapper are display: contents, so the picks become direct
	   flex items of the bar and sit inline.

	   THE SLIDE
	   `display: none` cannot be animated, so the panel is always displayed
	   and its HEIGHT is what changes: a one-row grid going 0fr -> 1fr, with
	   the inner wrapper clipping what does not fit yet. That pair is why
	   .controls__moreInner exists -- the technique needs exactly one child to
	   own the overflow, and this panel has five.

	   The negative margin cancels .controls__row's row-gap while the panel is
	   shut. `flex: 1 1 100%` puts the panel on a flex line of its own, and a
	   zero-height line still takes the 10px gap above it -- which read as the
	   bar being taller closed than the design intends. */
	.insite .controls__more {
		display: grid;
		grid-template-rows: 0fr;
		flex: 1 1 100%;
		margin-top: calc(-1 * var(--s2));
		/* visibility, and its delay, are not decoration.
		   `display: none` used to take the shut panel's five controls out of
		   the tab order for free. Clipping them to zero height does NOT: they
		   stay focusable, so a keyboard user tabs off the Filters button into
		   controls nobody can see. visibility: hidden removes them again, and
		   flipping it 220ms LATE lets the slide finish before they vanish. */
		visibility: hidden;
		transition: grid-template-rows 220ms ease,
		            margin-top 220ms ease,
		            visibility 0s linear 220ms;
	}

	.insite .controls.isOpen .controls__more {
		grid-template-rows: 1fr;
		margin-top: 0;
		visibility: visible;
		/* Opening is the other way round: visible at once, then slide. */
		transition: grid-template-rows 220ms ease,
		            margin-top 220ms ease,
		            visibility 0s linear 0s;
	}

	.insite .controls__moreInner {
		display: grid;
		gap: var(--s2);
		/* Both are required by the 0fr/1fr technique: overflow clips the
		   contents mid-slide, and min-height stops the grid item claiming its
		   own content height and refusing to shrink. */
		overflow: hidden;
		min-height: 0;
	}

	.insite .controls__moreInner select {
		width: 100%;
	}

	/* Last in the bar and quiet: an escape hatch, not an action. Hidden
	   entirely when nothing is set, so it cannot advertise a state the user
	   is not in.

	   A grid item of .controls, NOT a flex item of .controls__row -- which is
	   what it was, carrying `flex: 1 1 100%` to claim its own line. That
	   works while the row wraps and is destructive once it stops: above 800px
	   .controls__row is `nowrap`, so a 100% flex-basis simply took the width
	   and every other control collapsed to its minimum. The bar was already a
	   grid; putting the link on its own grid row is the whole fix, and it
	   needs no flex properties at either width. */
	.insite .controls__actions {
		font-size: 13px;
	}

	.insite .controls__actions a {
		color: var(--link);
	}

	/* ---- wide shell ------------------------------------------------------
	 * 800px because that is where .insite itself widens. The LIST switches
	 * on its own container at 680 and 960 -- deliberately separate numbers,
	 * because the bar only needs room to breathe and the list needs room for
	 * nine columns. Running both off one breakpoint is what put wrapped
	 * values in a 760px row.
	 * -------------------------------------------------------------------- */
	@media (min-width: 800px) {
		.insite .head {
			gap: var(--s4);
		}

		.insite .summary {
			grid-template-columns: 26px 1fr auto;
			align-items: center;
			padding: var(--s3) var(--s4);
		}

		.insite .summary__mark { grid-row: 1 / span 2; font-size: 18px; }
		.insite .summary__go   { grid-column: 3; grid-row: 1 / span 2; margin-top: 0; }

		.insite .controls__row  { flex-wrap: nowrap; }
		.insite .search         { flex: 0 1 380px; }
		.insite .ctrl--filters  { display: none; }
		.insite .pick--sort     { margin-left: auto; }

		/* No panel up here, so no slide: both wrappers dissolve and the picks
		   become flex items of the bar. margin-top and the transition go with
		   them -- an animation on an element that is display: contents has
		   nothing to animate, and leaving the negative margin behind would
		   pull the row up by 10px.

		   .isOpen is restated at its own specificity, and that is not
		   decoration. `open` is set server-side when the user arrived with a
		   filter set, so unlike the phone's toggle it is present at EVERY
		   width -- and .controls.isOpen .controls__more (0,3,0) outranks a
		   bare .controls__more (0,2,0) whatever media query it sits in. Omit
		   it and a filtered bar renders its dropdowns stacked on a desktop. */
		.insite .controls__more,
		.insite .controls.isOpen .controls__more,
		.insite .controls__moreInner {
			display: contents;
			margin-top: 0;
			/* visibility is inherited, so a wrapper left hidden here would
			   take its children with it even though the wrapper has no box. */
			visibility: visible;
			transition: none;
		}

		.insite .controls__moreInner select { width: auto; }

		/* Sort is the last thing in the bar up here, but it comes BEFORE the
		   panel in the markup -- because on a phone it shares the Filters
		   button's line, and tab order should follow what is on screen.
		   `order` moves it past the picks that the panel has just flattened
		   into this row. */
		.insite .pick--sort { order: 1; }

		/* Everything here is border-box from the reset above, which is what
		   makes a declared height the rendered height for inputs and selects
		   alike. 30px matches the rest of the portal's chrome. */
		.insite .search input,
		.insite .pick select,
		.insite .ctrl { height: 30px; font-size: 13px; }

		.insite .ctrl { padding: 0 16px; }

		/* Right-aligned under the controls, so it does not compete with the
		   row of live controls for attention. */
		.insite .controls__actions { text-align: right; }
	}

	/* Once the list is a table, its column headers ARE the sort control, and
	   two ways to do one thing is one too many.

	   BOTH conditions are needed and neither is sufficient. `--headers` says
	   the list has enough rows to become a table at all -- below six it stays
	   cards at every width and the headers never appear. The container query
	   says there is room for one. A filtered list of three rows on a wide
	   monitor satisfies the second and not the first, and would otherwise
	   lose its only way to reorder. */
	@container page (min-width: 960px) {
		.insite .controls--headers .pick--sort {
			display: none;
		}
	}

	/* ===================================================================
	 * Pager
	 *
	 * Rendered twice, above and below the list, so a user at the end of a
	 * 250-row page does not have to scroll back up to move on. Both bars show
	 * at every width: on a phone the scroll to reach the bottom one is long
	 * enough that the top bar is needed MORE, not less.
	 *
	 * Everything is addressed by CLASS and never by id: two of each control
	 * exist and a duplicate id would make the second copy unreachable.
	 *
	 * Three parts, always in this order:
	 *
	 *   .pager__count   "Showing 1-25 of 352 sites"   -- where you are
	 *   .pager__nav     "< Page 1 of 15 >"            -- how to move
	 *   .pager__size    "Per page [25]"               -- how much at a time
	 *
	 * The count comes first because it answers the question the user has
	 * before they reach for a control, and it is the part that must stay
	 * readable when the bar wraps. So it takes a row of its own below 800px
	 * and the two control groups share the next one -- rather than the three
	 * wrapping wherever they happen to run out of room, which put Previous
	 * and Next on separate lines with the count between them.
	 *
	 * Only /mysites paginates today -- /siteinstallations lists one site's
	 * units, and its measured ceiling is 50. It lives here rather than on
	 * that page because the bar it sits under is already shared.
	 * =================================================================== */
	.insite .pager {
		display: grid;
		grid-template-columns: auto 1fr;
		align-items: center;
		gap: var(--s2) var(--s3);
		padding: var(--s2) 2px;
		font-family: var(--sans);
		font-size: 12px;
		color: var(--bar-label);
	}

	/* Both bars dim together during a load, so the page cannot look
	   half-navigated. */
	/* ===================================================================
	 * Busy indicator
	 * ===================================================================
	 * Lived in an inline <style> on /mysites until 2026-08-24, which is
	 * precisely the thing prohub_nodes.html.j2's <head> warns about: an
	 * inline stylesheet is UNLAYERED and outranks every @layer, so the one
	 * page that had a busy indicator was also the one page whose version of
	 * it could not be overridden. Moved here so all three pages can share it
	 * -- see _partials/busy.html.j2.
	 *
	 * Fixed to the top right, and outside #pagecontent in the markup: the
	 * repaint replaces that element wholesale and would delete the spinner
	 * half way through the very load it is reporting on.
	 * =================================================================== */
	.insite .busy {
		display: none;
		position: fixed;
		top: 14px;
		right: 18px;
		z-index: 2000;
		align-items: center;
		gap: 9px;
		background-color: var(--navy);
		border-radius: 20px;
		color: var(--paper);
		font-family: var(--sans);
		font-size: 12px;
		padding: 7px 15px 7px 11px;
		box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
	}

	.insite .busy.isBusy {
		display: flex;
	}

	.insite .busy__spin {
		animation: firecoBusySpin 0.8s linear infinite;
		border: 3px solid rgba(255, 255, 255, 0.3);
		border-top-color: #ffffff;
		border-radius: 50%;
		height: 15px;
		width: 15px;
	}

	/* Inside the layer with everything else. A @keyframes name is GLOBAL
	   whatever layer declares it, so it is prefixed rather than called
	   `spin` -- extranet.css is still loaded on two of these pages. */
	@keyframes firecoBusySpin {
		to { transform: rotate(360deg); }
	}

	/* The lock. Applied for BOTH kinds of load: the background refresh, and a
	 * filter or sort submit. The controls dim so a second press during the
	 * navigation cannot queue a second request.
	 *
	 * .controls rather than a per-page form id, because all three pages build
	 * their bar from the same macro and the ids only differ by accident.
	 *
	 * .list__sort is in here because a sort header re-navigates exactly like
	 * a filter does. It was the one control that stayed live mid-request, so
	 * pressing it threw away the load already running -- and on a page whose
	 * rows are about to be replaced, the sort you asked for could arrive
	 * after the rows it was meant to order. */
	.insite .controls.isBusy,
	.insite .pager.isBusy,
	.insite .list__sort.isBusy {
		opacity: 0.5;
		pointer-events: none;
	}

	/* Its own line, above the controls. */
	.insite .pager__count {
		grid-column: 1 / -1;
	}

	.insite .pager__nav {
		display: flex;
		align-items: center;
		gap: var(--s2);
		justify-self: start;
	}

	/* Never wrapped. "Page 1 of" on one line and "15" on the next is the
	   defect this bar was rebuilt to remove, and it is the one string here
	   that grows -- "Page 12 of 141" on a large estate. */
	.insite .pager__page {
		white-space: nowrap;
	}

	.insite .pager__size {
		display: flex;
		align-items: center;
		gap: var(--s1);
		justify-self: end;
	}

	.insite .pager button {
		height: 30px;
		padding: 0 var(--s2);
		font-family: var(--sans);
		font-size: 12px;
		color: var(--ink);
		background: var(--paper);
		border: 1px solid var(--control-edge);
		border-radius: 4px;
		cursor: pointer;
	}

	.insite .pager button:disabled {
		color: var(--dim);
		border-color: var(--rule);
		cursor: default;
	}

	.insite .pager button:focus-visible,
	.insite .pager select:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 1px;
	}

	.insite .pager select {
		height: 30px;
		padding: 0 var(--s1);
		font-family: var(--sans);
		font-size: 12px;
		color: var(--ink);
		background: var(--paper);
		border: 1px solid var(--control-edge);
		border-radius: 4px;
	}

	/* Below 800px the buttons are their arrows alone -- "Previous" and "Next"
	   beside "Page 1 of 15" is the same fact said twice, and the words are
	   what pushed the bar into a third line.

	   Hidden VISUALLY, not removed: a button whose entire label is a
	   geometric-shape character announces as "black left-pointing triangle",
	   or as nothing at all. */
	.insite .pager__word {
		position: absolute;
		width: 1px;
		height: 1px;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
	}

	@media (min-width: 800px) {
		/* Count left, controls centred, page size right. */
		.insite .pager {
			grid-template-columns: 1fr auto 1fr;
		}

		.insite .pager__count { grid-column: 1; justify-self: start; }
		.insite .pager__nav   { grid-column: 2; justify-self: center; }
		.insite .pager__size  { grid-column: 3; }

		.insite .pager__word {
			position: static;
			width: auto;
			height: auto;
			clip-path: none;
		}

		.insite .pager button {
			min-width: 76px;
			padding: 0 var(--s3);
		}
	}

	/* ===================================================================
	 * Launcher -- /mylinks and /extranetmenu/<n>
	 * =================================================================== */

	/* --launcher, not --links: /mylinks and /extranetmenu/<n> are the same
	   page shape -- a grid of destination cards -- and differ only in where
	   the rows come from. A modifier per template would be two copies of
	   these rules waiting to drift apart.

	   The width cap it used to carry moved to .insite: every page wants the
	   same number, and the cap belongs on the page content rather than on the
	   grid alone -- capping only the grid would leave the brand bar running
	   the full width of a 1920px screen with the cards stopping short of it. */

	/* Column count is explicit rather than auto-fill.
	 *
	 * auto-fill was tidier -- one rule, no breakpoints -- but it cannot
	 * express either of the two things this grid needs. It has no ceiling,
	 * so at 1280px it fits four columns of 290px, narrower than the
	 * two-line descriptions want. And equal card heights are only wanted
	 * once there is more than one column: on a phone they would pad all
	 * eleven cards out to the tallest description for no benefit at all,
	 * since nothing sits beside them to line up with.
	 *
	 * These two queries are kept beside the component rather than with the
	 * page-level ones at the foot of the file, because they describe this
	 * grid and nothing else. 620px is where two columns first have room;
	 * 1000px is where three do without dropping below ~300px each.
	 */
	.shortcuts {
		display: grid;
		grid-template-columns: 1fr;
		gap: var(--s3);
		margin: 0;
		padding: 0;
		list-style: none;
	}

	/* grid-auto-rows: 1fr is what levels the cards. With no definite height
	   on the grid, equal fr rows all resolve to the tallest row's content,
	   and the default align-items: stretch then fills each card to its row.
	   Every row is implicit here -- there is no grid-template-rows -- so
	   this reaches all of them. */
	@media (min-width: 620px) {
		.shortcuts {
			grid-template-columns: repeat(2, 1fr);
			grid-auto-rows: 1fr;
		}
	}

	@media (min-width: 1000px) {
		.shortcuts {
			grid-template-columns: repeat(3, 1fr);
		}
	}

	/* The grid item is the <li>, not the <a> inside it. Without this the
	   <li> stretches to the row height and the card sits at its own content
	   height inside it, so levelling the rows changes nothing visible --
	   making the <li> a grid container is what passes the stretch through
	   to the card. */
	.shortcuts li {
		display: grid;
	}

	.shortcut {
		display: grid;
		grid-template-columns: 56px 1fr;
		grid-template-areas:
			"tile name"
			"tile desc";
		align-items: start;
		/* The card is stretched to its row height above, so pin its own rows
		   to the top; without this the name and description drift apart to
		   fill the slack. */
		align-content: start;
		gap: 2px var(--s3);
		min-height: 88px;
		padding: var(--s3);
		border: 1px solid var(--rule);
		border-radius: 12px;
		background: var(--paper);
		color: inherit;
		text-decoration: none;
	}

	.shortcut:hover {
		border-color: #b9c4d2;
		background: #fbfcfd;
	}

	.shortcut:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 2px;
	}

	/* The eleven icons are a globe, the word DMS in red, a badge, a headset,
	   a purple shield and more -- no shared palette, weight or silhouette.
	   A consistent tile is what makes them read as one set, and it costs
	   nothing: not one asset is touched. */
	.shortcut__tile {
		grid-area: tile;
		display: grid;
		place-items: center;
		width: 56px;
		height: 56px;
		border-radius: 12px;
		background: var(--field);
		border: 1px solid var(--rule);
		overflow: hidden;
	}

	.shortcut__tile img {
		display: block;
		width: 40px;
		height: 40px;
		object-fit: contain;
	}

	/* The whole card is the link, so the name does not need to be blue and
	   underlined as well. It was only blue because a <td> needed an <a>. */
	.shortcut__name {
		grid-area: name;
		align-self: center;
		font-size: 17px;
		font-weight: 700;
		line-height: 1.25;
		color: var(--ink);
	}

	.shortcut__desc {
		grid-area: desc;
		font-size: 14px;
		line-height: 1.45;
		color: var(--slate);
	}

	/* DefaultLink is the user type's main menu. For an administrator that is
	   Website Administration -- an area most users never see -- so it is set
	   apart by tone rather than by a badge: a badge would have to explain a
	   concept the reader does not have.

	   The tile inverts to white here so it stays distinct against the greyed
	   card instead of dissolving into it. */
	.shortcut--default {
		background: var(--sunk);
		border-color: #cdd6e0;
	}

	.shortcut--default:hover {
		background: #eaeff5;
	}

	.shortcut--default .shortcut__tile {
		background: var(--paper);
	}

	/* Replaces page_under_construction.png, which told the reader neither
	   what had happened nor what to do about it. */
	.empty {
		display: grid;
		gap: var(--s2);
		padding: var(--s5) var(--s4);
		border: 1px dashed var(--rule);
		border-radius: 12px;
		text-align: center;
	}

	.empty h2 {
		margin: 0;
		font-size: 18px;
		font-weight: 700;
	}

	.empty p {
		margin: 0;
		font-size: 14px;
		line-height: 1.5;
		color: var(--slate);
	}

	/* A way out, where the panel has one -- a filtered list that has emptied
	   because everything recovered. Sized to its words: .empty is a grid, so
	   a control left to stretch fills the dashed box and makes the whole
	   panel look pressable. */
	.empty .ctrl {
		justify-self: center;
		margin-top: var(--s2);
	}

	/* ===================================================================
	 * Short screens -- login only
	 *
	 * An iPhone SE has a 667px screen but gives Safari about 560px of
	 * viewport; the address and tab bars take the rest. Measured, the full
	 * login layout is 645px, so the Sign in with Google button would sit
	 * below the fold -- the bug this rebuild exists to fix.
	 *
	 * The photo band is the only element there that is decoration rather
	 * than task, so it is what goes. Scoped to login on purpose: /mylinks
	 * is a scrolling list and has nothing to gain from being squeezed.
	 * =================================================================== */
	@media (max-height: 680px) {
		.insite--login {
			gap: var(--s3);
			padding: var(--s3) var(--s4);
		}

		.insite--login .insite__logo  { width: 110px; }
		.insite--login .insite__photo { display: none; }
		.insite--login .insite__panel { gap: var(--s3); }
		.insite--login .insite__form  { gap: var(--s2); }
		.insite--login .insite__title { font-size: 24px; }
	}

	/* ===================================================================
	 * Wide
	 *
	 * MUST stay after the short-screen block. A short, wide desktop window
	 * matches both, and there the login photo is the left half of the card
	 * -- if the rule above won, the grid would lose a column. Source order
	 * decides it, so do not reorder these two blocks.
	 * =================================================================== */
	@media (min-width: 800px) {
		.insite {
			padding: var(--s5);
			gap: var(--s4);
		}

		.insite__logo {
			width: 160px;
		}

		.insite__title {
			font-size: 32px;
		}

		/* The form column is sized, the photo takes what is left.
		 *
		 * It was `46% 1fr`, which gave the form a 661px track to put a 460px
		 * form in -- 201px of white space hard against its right edge, because
		 * a grid item with a max-width still stretches from the start of its
		 * track. Sizing the track instead means there is no leftover to leave.
		 *
		 * minmax(0, 460px) rather than a flat 460px so the form can give ground
		 * when there is not enough to go round; minmax(320px, 1fr) so what it
		 * gives up cannot squeeze the photograph to a sliver. At the 1280px cap
		 * that resolves to 764px of photo and 460px of form.
		 *
		 * 460px is also why .insite__form no longer carries a max-width: two
		 * places deciding one number is how they drift apart.
		 */
		.insite--login .insite__panel {
			grid-template-columns: minmax(320px, 1fr) minmax(0, 460px);
			gap: 0;
			align-content: stretch;
			align-self: center;
			border: 1px solid var(--rule);
			border-radius: 16px;
			overflow: hidden;
		}

		.insite--login .insite__photo {
			display: block;
			height: 100%;
			min-height: 420px;
			border-radius: 0;
		}

		.insite--login .insite__form {
			gap: var(--s4);
			padding: var(--s5) clamp(28px, 4vw, 48px);
			align-content: center;
		}
	}

	/* ===================================================================
	 * Help
	 *
	 * The button lives inside .insite__account so .insite__bar keeps the
	 * two flex children its space-between rule was written for, and so the
	 * control never overlaps Log out.
	 * =================================================================== */

	/* align-self, because .insite__account is `align-items: baseline` and this
	   is the one child that cannot honour it. An inline-flex box takes its
	   baseline from its FIRST flex item; that item here is an <svg>, a replaced
	   element with no baseline, so the box falls back to its bottom margin
	   edge. The icon then hangs its BOTTOM off the 13px text baseline and sits
	   visibly high. Centring this one item leaves the address and Log out on
	   the shared baseline the rule was written for. */
	.help__open {
		display: inline-flex;
		align-items: center;
		align-self: center;
		color: var(--link);
		min-height: 24px;   /* WCAG 2.5.8 target floor, as .logout__button */
	}

	/* Sized on the glyph's own class rather than `.help__open svg`, because
	   test_partials_are_styled.py reads selectors literally and a modifier
	   suffix would not register the bare name the markup emits. */
	.help__glyph {
		width: 20px;
		height: 20px;
	}

	.help__open:hover {
		color: var(--ink);
	}

	.help__open:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 2px;
	}

	/* MOBILE FIRST: a full-width overlay. The right-hand panel is the
	   wide-screen case, below.

	   z-index sits above the 20 the list's overlapping control uses and well
	   below the 2000 of .busy, which must stay visible over everything. */
	.help__panel {
		display: grid;
		gap: var(--s3);
		align-content: start;
		position: fixed;
		inset: 0;
		z-index: 30;
		padding: var(--s4);
		overflow-y: auto;
		background: var(--paper);
		opacity: 1;
		translate: 0 0;
		transition:
			opacity 180ms ease,
			translate 180ms ease,
			display 180ms allow-discrete;
	}

	/* THE OPENING FRAME. The panel is display:none when shut, so it is not
	   rendered and a transition has no previous computed style to start from.
	   @starting-style supplies one. Without it only the CLOSE would animate --
	   the panel would appear already at full opacity and slide away neatly on
	   the way out, which reads as a bug rather than as a decision. */
	@starting-style {
		.help__panel {
			opacity: 0;
			translate: 0 12px;
		}
	}

	/* REQUIRED, and now doing two jobs. `display: grid` above overrides the
	   hidden attribute, so without the display:none here the panel renders
	   permanently open -- markup correct, page wrong, which is the failure
	   test_partials_are_styled.py exists for. Do not remove it when tidying.

	   The opacity and translate are the state the close transitions TO.
	   `allow-discrete` on display above holds display:grid until that
	   transition finishes, which is the only reason the panel can be seen
	   leaving at all. A browser that understands neither drops both
	   declarations and the panel snaps, exactly as it did before. */
	.help__panel[hidden] {
		display: none;
		opacity: 0;
		translate: 0 12px;
	}

	/* Title left, close right. The close is inside the panel rather than in
	   the bar because on a phone the panel covers the whole viewport -- the
	   "i" that opened it is behind the overlay, and Escape needs a keyboard. */
	.help__head {
		display: flex;
		align-items: start;
		justify-content: space-between;
		gap: var(--s3);
	}

	.help__close {
		appearance: none;
		background: none;
		border: 0;
		padding: 0;
		display: inline-flex;
		align-items: center;
		justify-content: center;
		flex: none;
		width: 32px;
		height: 32px;
		color: var(--slate);
		cursor: pointer;
	}

	.help__close:hover {
		color: var(--ink);
	}

	.help__close:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 2px;
	}

	.help__title:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 2px;
	}

	.help__body h2,
	.help__body h3 {
		margin-block: var(--s3) var(--s1);
	}

	.help__body :is(ul, ol) {
		padding-inline-start: var(--s4);
	}

	/* Every literal class the partial emits needs a rule here, or
	   test_partials_are_styled.py fails. .help__more is the one that is easy
	   to forget because it is just a link. */
	.help__more {
		display: inline-block;
		color: var(--link);
		margin-block-start: var(--s3);
	}

	.help__more:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 2px;
	}

	/* The standalone guide page (help_guide.html.j2). Not scanned by
	   test_partials_are_styled.py -- that reads _partials/ only -- so these
	   rules exist for the page to read well, not to satisfy a test. */

	/* The guide's measure, set ONCE on a container the way .insite--format .doc
	   sets its own, and for a reason worth recording.

	   The first attempt put `max-width: var(--guideMeasure)` on the h1, the
	   topics and the section heading together. Two things went wrong. First,
	   .insite is a grid, and an auto-width grid item with auto inline margins
	   does not stretch -- the margins win and the box falls back to fit-content
	   -- so the two SHORT items shrank to the width of their own text and sat
	   in the middle of the page, the section heading dragging its border-top in
	   with it. Second, and the reason a shared custom property cannot work
	   here at all: ch is relative to the font of the element it is computed on.
	   84ch on the 32px h1 is about twice 84ch on 16px body text, so the title
	   and the prose under it were measured against different rulers.

	   A container settles both. Its children are ordinary blocks that stretch
	   to its width, so every left edge agrees, and the ch resolves once,
	   against the body font. width: 100% for the grid-item reason above; the
	   grid and gap are inherited from what .insite was already giving these
	   elements as its own children, so the vertical rhythm does not change. */
	.help__doc {
		display: grid;
		gap: var(--s3);
		width: 100%;
		max-width: 84ch;
		margin-inline: auto;
	}

	/* The contents. Descendant selectors rather than a class per element: the
	   block is a handful of tags that exist nowhere else, and naming them all
	   would be four classes carrying no information the tag does not. */
	.help__contents {
		margin-block-end: var(--s5);
	}

	.help__contents h2 {
		margin-block: 0 var(--s2);
	}

	.help__contents p {
		margin-block: var(--s3) var(--s2);
		font-weight: 600;
	}

	.help__contents ul {
		margin: 0;
		padding: 0;
		list-style: none;
		display: grid;
		gap: var(--s1);
	}

	.help__contents a {
		color: var(--link);
	}

	.help__contents a:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 2px;
	}

	.help__topic {
		margin-block-end: var(--s5);
	}

	.help__topic :is(ul, ol) {
		padding-inline-start: var(--s4);
	}

	.help__section {
		margin-block: var(--s5) var(--s4);
		padding-block-start: var(--s3);
		border-top: 1px solid var(--rule);
	}

	/* 800px is one of the three breakpoints insite.css already uses
	   (620, 800, 1000). Do not introduce a fourth. */
	@media (min-width: 800px) {
		.help__panel {
			inset: 0 0 0 auto;
			width: min(28rem, 100%);
			border-left: 1px solid var(--rule);
		}

		/* The motion follows the shape. A full-screen overlay rises; a panel
		   attached to the right edge comes in from that edge, so the movement
		   says where it lives. Both halves have to be restated here -- the
		   opening frame and the closing state are separate rules and would
		   otherwise disagree about which way it travels. */
		@starting-style {
			.help__panel {
				translate: 16px 0;
			}
		}

		.help__panel[hidden] {
			translate: 16px 0;
		}
	}


	/* ===================================================================
	 * App Events -- /appevents
	 *
	 * The explorer's row is the shared .row plus ONE extra child: a
	 * <details> disclosure holding what a list cannot show -- the message,
	 * props and stack. It is a sibling of .details, never a child of it
	 * (every child of .details must be a cell, list.html.j2), and it spans
	 * the whole row in BOTH modes: the card grid is `1fr auto` and the
	 * table grid is --cols, and `1 / -1` covers either, so a stack trace
	 * gets the full width rather than one column's worth.
	 * =================================================================== */
	.insite--appevents .row__more {
		grid-column: 1 / -1;
		min-width: 0;
		font-size: 13px;
		color: var(--slate);
	}

	.insite--appevents .row__more > summary {
		cursor: pointer;
		width: max-content;
		color: var(--muted);
	}

	.insite--appevents .row__more > div {
		margin-top: var(--s1);
	}

	/* pre-wrap and anywhere: a minified stack is one enormous line, and
	   the page must not grow a horizontal scrollbar to show it. */
	.insite--appevents .row__more pre {
		margin: var(--s1) 0 0;
		padding: var(--s2);
		background: var(--sunk);
		border: 1px solid var(--rule);
		border-radius: 6px;
		font-family: var(--mono);
		font-size: 12px;
		white-space: pre-wrap;
		overflow-wrap: anywhere;
	}

	/* App Events has six high-value filters and a dozen precise identifiers.
	   Keep the first set visible in a flexible grid, and let the user reveal
	   the rest when needed. The shared filter bar remains unchanged for pages
	   with only a few controls. */
	.insite--appevents .controls__row {
		display: grid;
		grid-template-columns: minmax(0, 1fr) auto;
		align-items: start;
		gap: var(--s2) var(--s3);
	}

	.insite--appevents .appevents__primary-filters {
		display: grid;
		grid-template-columns: repeat(6, minmax(0, 1fr));
		gap: var(--s2);
		min-width: 0;
	}

	.insite--appevents .appevents__primary-filters .pick,
	.insite--appevents .appevents__advanced-grid .pick {
		min-width: 0;
	}

	.insite--appevents .appevents__primary-filters select,
	.insite--appevents .appevents__advanced-grid input {
		box-sizing: border-box;
		width: 100%;
		min-width: 0;
		height: 38px;
		padding: 0 var(--s2);
		font: 14px var(--sans);
		color: var(--ink);
		background: var(--paper);
		border: 1px solid var(--control-edge);
		border-radius: 4px;
	}

	.insite--appevents .appevents__primary-filters select:focus-visible,
	.insite--appevents .appevents__advanced-grid input:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 1px;
	}

	.insite--appevents .appevents__advanced-filters {
		grid-column: 1 / -1;
		min-width: 0;
		padding-top: var(--s1);
		border-top: 1px solid var(--bar-edge);
	}

	.insite--appevents .appevents__advanced-filters > summary {
		width: max-content;
		max-width: 100%;
		color: var(--link);
		font-size: 13px;
		font-weight: 700;
		cursor: pointer;
	}

	.insite--appevents .appevents__advanced-filters > summary:focus-visible {
		outline: 2px solid var(--focus);
		outline-offset: 3px;
	}

	.insite--appevents .appevents__advanced-filters > summary span {
		margin-left: var(--s1);
		color: var(--slate);
		font-weight: 400;
	}

	.insite--appevents .appevents__advanced-grid {
		display: grid;
		grid-template-columns: repeat(auto-fit, minmax(min(100%, 160px), 1fr));
		gap: var(--s2);
		padding-top: var(--s2);
	}

	.insite--appevents .appevents__apply {
		grid-column: 2;
		grid-row: 1;
		align-self: end;
		white-space: nowrap;
	}

	.insite--appevents .appevents__level {
		display: inline-block;
		margin-left: 5px;
		padding: 2px 7px;
		border: 1px solid var(--control-edge);
		border-radius: 999px;
		background: var(--field);
		color: var(--slate);
		font-size: 11px;
		font-weight: 700;
		line-height: 1.3;
		text-transform: uppercase;
		white-space: nowrap;
	}

	.insite--appevents .appevents__level--warn {
		border-color: var(--warn-br);
		background: var(--warn-bg);
		color: var(--warn-tx);
	}

	.insite--appevents .appevents__level--error {
		border-color: var(--bad-br);
		background: var(--bad-bg);
		color: var(--bad-tx);
	}

	.insite--appevents .details > .cell,
	.insite--appevents .cell--title {
		overflow-wrap: anywhere;
	}

	@container page (max-width: 720px) {
		.insite--appevents .appevents__primary-filters {
			grid-template-columns: repeat(3, minmax(0, 1fr));
		}
	}

	@container page (max-width: 460px) {
		.insite--appevents .controls__row {
			grid-template-columns: minmax(0, 1fr);
		}

		.insite--appevents .appevents__primary-filters {
			grid-template-columns: repeat(2, minmax(0, 1fr));
		}

		.insite--appevents .appevents__apply {
			grid-column: 1;
			grid-row: auto;
			justify-self: end;
		}
	}


	/* ===================================================================
	 * DMS Test Recipients -- /dmstestrecipients/<customerUUID>
	 *
	 * Mobile first: one card per tester, stacked; two-up once the PAGE
	 * container (.insite__content) is 720px wide. Each tester's units are a
	 * native <details>, so a 30-unit customer is one line per tester until
	 * opened. A visual design pass is planned separately; this is the
	 * working base. Spec: docs/superpowers/specs/2026-09-28-dms-test-mode-design.md
	 * =================================================================== */
	.insite--testrecipients .testers__customer { margin: 0; color: var(--slate); }
	.insite--testrecipients .testers__back { display: inline-block; margin-top: var(--s2); }
	.insite--testrecipients .testers__intro { margin: 0 0 var(--s3); color: var(--slate); font-size: 14px; line-height: 1.5; }
	.insite--testrecipients .testers__actions { margin-bottom: var(--s2); }
	.insite--testrecipients .testers__error,
	.insite dialog.confirm.tester__dialog .testers__error { margin: 0; min-height: 1em; color: var(--bad-tx); font-size: 14px; }

	.insite--testrecipients .testers { display: grid; gap: var(--s3); margin: 0; padding: 0; list-style: none; }
	@container page (min-width: 720px) {
		.insite--testrecipients .testers { grid-template-columns: 1fr 1fr; align-items: start; }
	}

	/* min-width: 0 on the GRID ITEM, or a long unit name stretches the card
	   off a phone screen instead of wrapping (insite-css-testing-traps). */
	.insite--testrecipients .tester { display: grid; gap: var(--s2); min-width: 0; padding: var(--s3); border: 1px solid var(--rule); border-radius: 10px; background: var(--paper); }
	.insite--testrecipients .tester__head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--s2); }
	.insite--testrecipients .tester__name { margin: 0; font-size: 17px; overflow-wrap: anywhere; }
	.insite--testrecipients .tester__tools { display: flex; gap: var(--s2); }
	.insite--testrecipients .tester__contact { margin: 0; color: var(--slate); font-size: 14px; overflow-wrap: anywhere; }
	.insite--testrecipients .tester__channels { display: flex; flex-wrap: wrap; gap: var(--s1); margin: 0; padding: 0; list-style: none; }
	.insite--testrecipients .tester__channel { padding: 2px 8px; border: 1px solid var(--rule); border-radius: 999px; font-size: 12px; font-weight: 700; }
	.insite--testrecipients .tester__channel--on { background: var(--ink); border-color: var(--ink); color: var(--paper); }
	.insite--testrecipients .tester__channel--off { color: var(--muted); text-decoration: line-through; }
	.insite--testrecipients .tester__units summary { cursor: pointer; font-weight: 600; }
	.insite--testrecipients .tester__bulk { display: flex; gap: var(--s2); margin: var(--s2) 0; }
	.insite--testrecipients .tester__unitlist { display: grid; gap: var(--s1); margin: 0; padding: 0; list-style: none; }
	.insite--testrecipients .tester__unit { display: flex; align-items: center; gap: var(--s2); min-width: 0; }
	.insite--testrecipients .tester__unit label { display: flex; flex: 1; align-items: center; gap: var(--s2); min-width: 0; }
	.insite--testrecipients .tester__unit input[type="checkbox"] { width: 20px; height: 20px; accent-color: var(--ink); }
	.insite--testrecipients .tester__unit--this { font-weight: 700; }
	.insite--testrecipients .tester__this { color: var(--slate); font-size: 12px; }

	/* The add/edit dialog wears dialog.confirm; these win on SPECIFICITY
	   (0-3-1 and up), not source order -- AGENTS.md §6. */
	.insite dialog.confirm.tester__dialog { max-width: min(92vw, 460px); }
	.insite dialog.confirm.tester__dialog .tester__channelset { display: grid; gap: var(--s1); margin: 0; padding: 0; border: 0; }
	.insite dialog.confirm.tester__dialog .ctrl.tester__save { background: var(--ink); border-color: var(--ink); color: var(--paper); }
	.insite dialog.confirm.tester__dialog .tester__hint { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.4; }


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