/* =============================================================================
   Impact design system

   Every value here comes from the design source rather than being invented
   alongside it, so a colour that looks close but isn't is a bug, not a taste
   difference.

   The only stylesheet of ours. It used to share the job with style.css and
   custom.css, which carried a second palette of their own (--app-*) and
   overrode this one on every page that loaded all three; those are folded in
   here now, onto these tokens, and there is one palette again.

   Loaded after Bootstrap on purpose: the shell and the rebuilt screens are
   written against these tokens, while the pages not yet redesigned still lean
   on Bootstrap underneath — see "Bootstrap bridge" at the bottom.
   ============================================================================= */

/* Light — the default, and the only palette written out in full colour terms.
   Everything below refers to these names, so the dark theme is a redefinition
   rather than a second stylesheet to keep in step. */
:root {
	/* The size of ordinary reading text, and of the navigation. Components that
	   are plain text at the page's own size take this rather than a pixel value,
	   so the whole interface can be scaled in one place. */
	/* The type scale, in rem so it follows the browser's own text size. 12px is the
	   floor — nothing is smaller — and the steps above the base are about 1.125x
	   apart. A component takes a token rather than a pixel value, so the whole
	   interface can be rescaled here. */
	--ip-font-xs:       0.75rem;    /* footnotes, meta, tags */
	--ip-font-sm:       0.875rem;   /* subtitles, compact controls */
	--ip-font-base:     1rem;       /* body text, buttons, navigation */
	--ip-font-h3:       1.125rem;   /* card and section titles */
	--ip-font-h2:       1.25rem;    /* modal, flyout and settings titles */
	--ip-font-h1:       1.5rem;     /* page and sign-in titles */
	/* Icons beside text, all in em so they follow the text's own size: a nudge or a
	   stroke in pixels is right for one size and wrong for every other. */
	--ip-icon-nudge:    0.07em;     /* lowercase sits below its line's middle */
	--ip-icon-scale:    0.875em;    /* a glyph fills its whole box; text does not */
	--ip-icon-stroke:   0.05em;     /* thickens the plus to match 600-weight text */
	/* Where anything that sticks while the page scrolls comes to rest: a page's
	   padding below the top bar, so it keeps the air it had before scrolling.
	   One value for table headers, side panes and the settings menu, or two
	   pinned things side by side stop at different heights. --ip-topbar-h is
	   the bar's measured height, set by table-layout.js. */
	--ip-stick-top:     calc(var(--ip-topbar-h, 62px) + 16px);
	--ip-bg:            #f6f7f7;
	/* The page itself, behind the shell — see "Background". The same as --ip-bg
	   in this theme; darker than it in the dark one. */
	--ip-canvas:        #f6f7f7;
	--ip-surface:       #ffffff;
	--ip-surface-sunk:  #fafbfb;
	--ip-header-bg:     rgba(246, 247, 247, 0.72);
	--ip-ink:           #16191b;
	--ip-ink-soft:      #2c3134;
	--ip-label:         #4b5563;
	--ip-muted:         #6b7280;
	--ip-faint:         #9aa1a0;
	--ip-nav-fg:        #3d4548;
	--ip-placeholder:   #a3aaa8;

	--ip-border:        #e6e8e7;
	--ip-border-soft:   #eef0ef;
	--ip-border-input:  #dfe3e1;
	--ip-box-border:    #c9cfcd;
	--ip-rule:          #f2f4f3;

	--ip-teal:          #0f766e;
	/* The accent as a bare triple, for Bootstrap's rgba(var(--bs-primary-rgb), …). */
	--ip-teal-rgb:      15, 118, 110;
	--ip-teal-dark:     #0b5951;
	--ip-teal-pale:     #8fbfb9;
	--ip-mint:          #ecfdf5;
	--ip-mint-deep:     #d7f0ec;
	--ip-mint-tint:     #eef2f1;

	--ip-amber:         #b45309;
	--ip-amber-bg:      #fef3c7;
	--ip-amber-fg:      #92400e;
	--ip-blue:          #1d4ed8;
	--ip-blue-bg:       #eff6ff;
	--ip-violet:        #5b21b6;
	--ip-violet-bg:     #f5f3ff;
	--ip-pink:          #9d2463;
	--ip-pink-bg:       #fdf2f8;

	--ip-danger:        #b42318;
	--ip-danger-dark:   #912018;
	--ip-danger-bg:     #fee4e2;
	--ip-danger-border: #f0c2bd;

	--ip-track:         #f0f2f1;
	--ip-hatch-a:       #f2f4f3;
	--ip-hatch-b:       #eaeeec;
	--ip-hatch-fg:      #8b9391;

	/* On the accent, on a dark toast — the colour that sits on a filled
	   surface, which is not the page background in either theme. */
	--ip-on-accent:     #ffffff;

	/* The ground an uploaded logo sits on. Deliberately does not flip with the
	   theme: a logo is artwork someone else drew for a light background, and
	   tinting it — with the accent, or with a dark surface — is editing their
	   mark rather than displaying it. */
	--ip-logo-ground:   #ffffff;

	/* FIRST's own program colours, and the text that sits on each. Like the
	   logo ground, these do not flip with the theme: they are FIRST's marks,
	   not our palette, and a team is recognised by them. The only place they
	   are named — see "Programs" for the one rule that applies them.
	   All four take dark text: white on the FRC blue and the FTC orange came to
	   about 3:1 and 2.7:1 at tag size, short of the 4.5:1 small text needs. */
	--ip-frc:           #009cd7;
	--ip-frc-fg:        #222222;
	--ip-ftc:           #f57e25;
	--ip-ftc-fg:        #222222;
	--ip-fll:           #ffc107;
	--ip-fll-fg:        #222222;
	--ip-fgc:           #00a651;
	--ip-fgc-fg:        #222222;
	--ip-input-bg:      #ffffff;
	--ip-scrim:         rgba(16, 25, 27, 0.42);

	/* Laid over a pill that carries its own status colour, to darken it on
	   hover without knowing which colour that is. It inverts by theme: on a
	   dark surface "darker" reads as damage, so there it lightens instead. */
	--ip-veil-hover:    rgba(0, 0, 0, 0.05);

	/* The keyboard focus ring. Resolved on :root against whichever teal the
	   theme sets there, so one declaration covers both. */
	--ip-focus:         var(--ip-teal);

	--ip-scrollbar:     rgba(0, 0, 0, 0.28);

	/* The loading shimmer: a base and the sheen that travels over it. The sheen
	   darkens on a light page and lightens on a dark one — a white shimmer on a
	   white card is an invisible placeholder. */
	--ip-skeleton-base:  rgba(0, 0, 0, 0.06);
	--ip-skeleton-sheen: rgba(0, 0, 0, 0.10);

	--ip-shadow-1:      rgba(16, 24, 40, 0.04);
	--ip-shadow-2:      rgba(16, 24, 40, 0.06);
	--ip-shadow-3:      rgba(16, 24, 40, 0.10);
	--ip-shadow-4:      rgba(16, 24, 40, 0.28);

	--ip-shadow-sm:     0 1px 2px var(--ip-shadow-1);
	--ip-shadow:        0 1px 3px var(--ip-shadow-2);
	--ip-shadow-pop:    0 8px 24px var(--ip-shadow-3);


	/* One family, site-wide. These two used to feed Helvetica Neue and IBM Plex
	   Mono to dozens of selectors (stat values, dates, counts) that outrank the
	   base rule, so the numeric UI rendered in a different typeface from
	   everything around it. */
	--ip-sans: "Manrope", sans-serif;
	--ip-mono: "Manrope", sans-serif;
}

/* Dark.
   The accent lightens rather than keeping its light-theme value: #0f766e on
   #0e1112 is close to unreadable, so the teal moves up the scale and the
   "soft" tints become deep washes instead of near-whites. */
:root[data-theme="dark"] {
	--ip-bg:            #0e1112;
	--ip-canvas:        #080808;
	--ip-surface:       #171b1d;
	--ip-surface-sunk:  #1c2123;
	--ip-header-bg:     rgba(14, 17, 18, 0.72);
	--ip-ink:           #eef1f0;
	--ip-ink-soft:      #dee3e2;
	--ip-label:         #b3bcba;
	--ip-muted:         #95a09e;
	--ip-faint:         #78837f;
	--ip-nav-fg:        #c3cbc9;
	--ip-placeholder:   #5f6a68;

	--ip-border:        #2b3235;
	--ip-border-soft:   #232a2c;
	--ip-border-input:  #363f42;
	--ip-box-border:    #4a5457;
	--ip-rule:          #202628;

	--ip-teal:          #3fbfae;
	--ip-teal-rgb:      63, 191, 174;
	--ip-teal-dark:     #63d3c3;
	--ip-teal-pale:     #2f6f68;
	--ip-mint:          #14312d;
	--ip-mint-deep:     #1b453f;
	--ip-mint-tint:     #1d3733;

	--ip-amber:         #e0a355;
	--ip-amber-bg:      #35270f;
	--ip-amber-fg:      #f0c07a;
	--ip-blue:          #93b4ff;
	--ip-blue-bg:       #17233d;
	--ip-violet:        #c0aefc;
	--ip-violet-bg:     #241c40;
	--ip-pink:          #f0a2c6;
	--ip-pink-bg:       #37162a;

	--ip-danger:        #f5837a;
	--ip-danger-dark:   #ffa7a0;
	--ip-danger-bg:     #3d1714;
	--ip-danger-border: #5e2721;

	--ip-track:         #262d2f;
	--ip-hatch-a:       #1c2123;
	--ip-hatch-b:       #232a2c;
	--ip-hatch-fg:      #78837f;

	/* The accent is light in this theme, so text on it has to be dark. */
	--ip-on-accent:     #06231f;
	--ip-input-bg:      #111516;
	--ip-scrim:         rgba(0, 0, 0, 0.62);
	--ip-veil-hover:    rgba(255, 255, 255, 0.09);
	--ip-scrollbar:     rgba(255, 255, 255, 0.30);

	--ip-skeleton-base:  rgba(255, 255, 255, 0.07);
	--ip-skeleton-sheen: rgba(255, 255, 255, 0.12);

	/* The background texture, which only this theme has — see "Background".
	   Triples where a gradient needs the colour at several strengths. */
	--ip-texture-line:  rgba(255, 255, 255, 0.08);
	--ip-glow:          20, 184, 166;
	--ip-glow-violet:   80, 30, 140;
	--ip-vignette:      8, 8, 8;

	--ip-shadow-1:      rgba(0, 0, 0, 0.30);
	--ip-shadow-2:      rgba(0, 0, 0, 0.40);
	--ip-shadow-3:      rgba(0, 0, 0, 0.50);
	--ip-shadow-4:      rgba(0, 0, 0, 0.60);

	color-scheme: dark;
}

/* -----------------------------------------------------------------------------
   Base
   -------------------------------------------------------------------------- */

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

html, body {
	height: 100%;
	margin: 0;
	padding: 0;
	color: var(--ip-ink);
	font-family: var(--ip-sans);
	font-size: var(--ip-font-base);
	-webkit-font-smoothing: antialiased;
	scroll-behavior: smooth;
	transition: background 180ms ease;
}
/* The shell scrolls #app-main, not the page — see "Shell". The signed-out
   pages hand scrolling back to the viewport (.minimal-page-body). */
body { display: flex; flex-direction: column; overflow: hidden; scrollbar-width: thin; }

/* The page colour is the root's, and <body> is transparent over it. That is
   what lets the dark theme's glows show: they sit at z-index -1, which paints
   them above the root's background but beneath any in-flow block's — so an
   opaque <body> covered them completely. */
html { background: var(--ip-canvas); }
body { background-color: transparent; }

/* || BACKGROUND
   The grid and the glows belong to the dark theme only. They are what the page
   is *made of* there — a flat near-black reads as unfinished — but the same
   treatment on a light ground reads as dirt on the screen, so the light theme
   is deliberately plain and the texture is scoped rather than faded. */
:root[data-theme="dark"] body {
	background-image:
		linear-gradient(var(--ip-texture-line) 1px, transparent 1px),
		linear-gradient(90deg, var(--ip-texture-line) 1px, transparent 1px);
	background-size: 50px 50px;
	background-attachment: fixed;
}
:root[data-theme="dark"] body::before {
	content: '';
	position: fixed;
	bottom: -10%;
	left: 50%;
	transform: translateX(-50%);
	width: 120%;
	height: 70%;
	/* The accent's hue, so the one glow on the page is the same colour as
	   everything else that carries meaning. */
	background: radial-gradient(ellipse at bottom center,
		rgba(var(--ip-glow), 0.16) 0%,
		rgba(var(--ip-glow), 0.03) 40%,
		transparent 70%
	);
	pointer-events: none;
	z-index: -1;
}
:root[data-theme="dark"] body::after {
	content: '';
	position: fixed;
	inset: 0;
	background:
		radial-gradient(ellipse at 15% 20%, rgba(var(--ip-glow), 0.04) 0%, transparent 50%),
		radial-gradient(ellipse at 85% 80%, rgba(var(--ip-glow-violet), 0.03) 0%, transparent 50%),
		radial-gradient(circle at 50% 50%, rgba(var(--ip-vignette), 0) 0%, rgba(var(--ip-vignette), 0.5) 55%, rgba(var(--ip-vignette), 0.92) 100%);
	pointer-events: none;
	z-index: -1;
}

::-webkit-scrollbar { width: 5px; height: 1px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background-color: var(--ip-scrollbar); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background-color: var(--ip-ink); }
::-webkit-scrollbar-corner { background: transparent; }

/* The open list of a <select> is painted by the browser from the options' own
   colours, not the control's, so they carry them explicitly. */
select option,
select optgroup { background-color: var(--ip-surface) !important; color: var(--ip-ink) !important; }

input[type="search"] { user-select: none; }

body a { color: var(--ip-teal); text-decoration: none; }
body a:hover { color: var(--ip-teal-dark); text-decoration: none; }

/* One focus ring for everything reached by Tab. Left to the browser, a button,
   a pill and a sidebar link each drew a different one, and some drew a ring the
   dark theme swallowed. `:where` keeps it at zero specificity, so a control
   that needs another shape — a tile over a photo — still overrides it. Text
   inputs keep their own mint ring on :focus, which shows on a click too. */
:where(a, button, summary, [tabindex], [role="button"]):focus-visible {
	outline: 2px solid var(--ip-focus);
	outline-offset: 2px;
}
/* Bootstrap's own controls draw a shadow ring and switch the outline off with
   more specificity than the rule above, so they are given the same ring here. */
.nav-link:focus-visible,
.dropdown-item:focus-visible,
.btn:focus-visible,
.btn-close:focus-visible {
	box-shadow: none;
	outline: 2px solid var(--ip-focus);
	outline-offset: 2px;
}
.dropdown-item:focus-visible { outline-offset: -2px; }

/* Icon glyphs default to inheriting whatever text-size surrounds them —
   which is how the theme toggle's icon ended up rendering at 13px, sized for
   a "Inspire · 1 yr" label rather than a legible standalone glyph. When an
   icon IS the entire visible content of a control, it needs a size of its
   own rather than borrowing ambient text-size, so it can't silently drift
   with whatever button classes happen to surround it. Deliberately larger
   icons (gallery nav arrows, the sidebar hamburger, tile-editor glyphs) keep
   their own component-scoped rules and are unaffected — none of them carry
   .btn or .gal-close, so this cannot override an intentional size. */
.btn > .bi:only-child,
.gal-close > .bi {
	font-size: var(--ip-font-base);
	/* Without this, the icon's inherited line-height (1.5 × 16px = 24px) can
	   exceed the button's own line-height and stretch the button taller than
	   its siblings — exactly the kind of mismatch this rule exists to end. */
	line-height: 1;
}

input, select, textarea, button { font: inherit; color: inherit; }

/* Form controls do not inherit the page's colours, so they are set explicitly
   or a dark theme leaves white boxes punched through it. */
input, select, textarea {
	background: var(--ip-input-bg);
	color: var(--ip-ink);
	border-color: var(--ip-border-input);
}
/* The picker glyphs are drawn by the browser and follow `color-scheme`, which
   the dark block sets — so they are left alone. An invert used to sit here as
   well, and in dark it undid the browser's own light glyph, leaving the date
   field's calendar dark on dark while the time field beside it was fine. */
input::-webkit-calendar-picker-indicator { cursor: pointer; }
::placeholder { color: var(--ip-placeholder); opacity: 1; }

@keyframes ip-rise-in {
	from { opacity: 0; transform: translateY(6px); }
	to   { opacity: 1; transform: none; }
}

.ip-rise { animation: ip-rise-in 220ms ease both; }

/* A page arriving over its own skeleton (navigation.js marks it) is not
   entering — its structure has been on screen since the click. Rising in from
   transparent there would blank it for a frame and read as a reload. */
#main-content[data-swapped] .ip-rise { animation: none; }

/* The page crossing — navigation.js adds these. Out is quicker than in: the
   old page only has to get out of the way, and it does so while the next one
   is still being fetched. The transition lives on the leaving state alone, so
   coming back in is the animation's job and the two never fight. */
#main-content.is-leaving {
	opacity: 0;
	transition: opacity 140ms ease-out;
}
#main-content.is-entering { animation: app-page-in 200ms ease-out both; }

@keyframes app-page-in {
	from { opacity: 0; }
	to   { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
	#main-content.is-leaving { transition: none; }
	#main-content.is-entering { animation: none; }
	/* Things that travel into place arrive instead. Opacity changes stay where
	   they carry meaning; a slide or a rise never does. */
	.ip-rise,
	.ip-modal__panel,
	.ip-flyout,
	.ip-flyout__scrim { animation: none; }
	html { scroll-behavior: auto; }
}


.ip-mono { font-family: var(--ip-mono); font-variant-numeric: tabular-nums; }

/* -----------------------------------------------------------------------------
   Shell
   -------------------------------------------------------------------------- */

/* #app-main is the column that scrolls. Its scrollbar's room is kept whether or
   not the page is long enough to need it, so moving from a short page to a
   long one does not narrow the content and shift everything sideways. */
#app-main { scrollbar-gutter: stable; }

/* The shell's top bar — Header.php's #app-topbar.
 *
 * Sticky against the scrolling content column rather than fixed to the window,
 * so it takes the column's width without knowing the sidebar's, and needs no
 * spacer under it. Translucent over what scrolls beneath it, which is what
 * --ip-header-bg is for.
 *
 * Drawn once and kept: navigation rewrites the title and the window control in
 * place, and the controls beside them never leave the screen. */
.app-topbar {
	position: sticky;
	top: 0;
	/* Above the page, and below everything that covers the page. The order, on
	   Bootstrap's scale since its dialogs share the screen with ours:
	     1020  this bar (Bootstrap's "sticky"; the team menu is a child of the
	           bar and rides on its stacking context)
	     1040  .ip-flyout scrim and sheet
	     1045  the sidebar as a slide-over (Bootstrap offcanvas)
	     1050  Bootstrap's modal backdrop
	     1055  dialogs: form-modal (Bootstrap) and .ip-modal
	     1060  .fm-suggest-panel, over the dialog it belongs to
	     1065  .gal-overlay
	   Anything that covers the page must sit above 1020, or this bar stays
	   bright and clickable over its backdrop. */
	z-index: 1020;
	display: flex;
	align-items: center;
	flex-wrap: wrap;
	gap: 8px 14px;
	min-height: 62px;
	padding: 10px 16px;
	background: var(--ip-header-bg);
	/* Thin enough to see the page moving under it, blurred and saturated enough
	   that what shows through is colour and movement rather than legible text
	   competing with the title. At 90% opaque the old 8px blur was invisible —
	   the cost of a backdrop filter for none of the effect. */
	backdrop-filter: blur(20px) saturate(180%);
	-webkit-backdrop-filter: blur(20px) saturate(180%);
	/* Only while something is scrolled under it (impact-shell.js). At the top
	   of a page there is nothing to divide the bar from. */
	border-bottom: 1px solid transparent;
	transition: border-color 160ms ease;
	flex-shrink: 0;
}
.app-topbar.is-scrolled { border-bottom-color: var(--ip-border-soft); }
/* Asked for less see-through: the bar becomes the solid ground it sits on. */
@media (prefers-reduced-transparency: reduce) {
	.app-topbar { background: var(--ip-bg); backdrop-filter: none; -webkit-backdrop-filter: none; }
}
@media (prefers-contrast: more) {
	.app-topbar { background: var(--ip-bg); backdrop-filter: none; -webkit-backdrop-filter: none; border-bottom-color: var(--ip-border); }
}
.app-topbar__menu {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 36px;
	height: 36px;
	margin-left: -6px;
	border: 0;
	border-radius: 8px;
	background: transparent;
	color: inherit;
	font-size: var(--ip-font-h1);
}
/* The heading is as tall as a title plus a subtitle whether or not there is a
   subtitle, so the bar never changes height between pages — and the content is
   centred in it, so a title with no subtitle sits mid-bar rather than at the top
   of a gap left for words that are not coming. */
.app-topbar__heading {
	flex: 1 1 200px;
	min-width: 0;
	min-height: 46px;
	display: flex;
	flex-direction: column;
	justify-content: center;
}
.app-topbar__title {
	margin: 0;
	font-size: var(--ip-font-h1);
	font-weight: 600;
	letter-spacing: -0.015em;
	line-height: 1.3;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
}
/* An empty one is dropped (below) and the heading's own min-height holds the
   bar's height instead. Hiding it without that made the bar a few pixels
   shorter on those pages, so moving between a page with a subtitle and one
   without nudged everything below the bar up and down. */
.app-topbar__sub {
	margin-top: 1px;
	min-height: 1.4em;
	font-size: var(--ip-font-sm);
	line-height: 1.4;
	color: var(--ip-muted);
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
}
.app-topbar__sub:empty { display: none; }
/* A title or subtitle that is still on its way, for a page not visited yet in
   this tab — the bar keeps its height, so nothing below it moves when the words
   arrive. */
.app-topbar__title .skeleton { height: 0.9em; width: 11rem; vertical-align: -0.05em; }
.app-topbar__sub .skeleton { height: 0.8em; width: 16rem; max-width: 70%; }
.app-topbar__tools {
	display: flex;
	align-items: center;
	flex-wrap: wrap;
	gap: 8px;
	margin-left: auto;
}
/* The bar's three controls — the award window (.ip-seg), the team switcher
   (.ip-btn) and the theme toggle (.ip-theme-toggle) — are one height, 34px,
   and one family. They were three Bootstrap buttons: the window control
   btn-outline-primary, which the bridge coloured teal, and the other two
   btn-outline-secondary, which it never mapped, so they sat in Bootstrap's
   own grey beside it, at 40, 32 and 36px. Teal is now spent only on the
   window that is selected. */
.app-topbar__tools .ip-seg { height: 34px; align-items: stretch; }
.app-topbar__tools .ip-seg__btn { display: inline-flex; align-items: center; padding: 0 11px; }
.app-topbar__tools .app-topbar__team {
	height: 34px;
	padding: 0 10px 0 8px;
	gap: 8px;
	max-width: 260px;
	/* The window control's and the theme toggle's border, not an input's. */
	border-color: var(--ip-border);
}
/* The award window's labels — the long one, and a short one for a phone. */
.ip-seg__short { display: none; }

/* A phone. The bar was three rows — title, award window, team and theme — and
   at 157px a fifth of the screen, kept there while everything else scrolled.
   The tools now share one row under the title. */
@media (max-width: 575.98px) {
	.app-topbar { padding: 8px 12px; gap: 6px 10px; }
	.app-topbar__tools { flex-wrap: nowrap; width: 100%; margin-left: 0; }
	.app-topbar__tools > :last-child { margin-left: auto; }
	.ip-seg__long { display: none; }
	.ip-seg__short { display: inline; }
}

.app-topbar__team-name {
	color: var(--ip-muted);
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
	min-width: 0;
}

#main-content { padding-bottom: 40px; }

/* The sidebar. Bootstrap's offcanvas below lg, a fixed column above it; the
   shape, the sizes and the transitions are the ones the app has always had,
   and the colours are the palette's. */
#sidebar {
	display: grid !important;
	grid-template-rows: auto 1fr auto;
	overflow: hidden;
	height: 100dvh;
	user-select: none;
	background-color: var(--ip-surface) !important;
	border-color: var(--ip-border) !important;
	color: var(--ip-nav-fg);
}
#sidebar.offcanvas,
#sidebar.offcanvas-lg {
	height: 100svh !important;
	max-height: 100svh !important;
}
#sidebar > ul {
	min-height: 0;
	overflow-y: auto;
	overflow-x: hidden;
}
#sidebar .nav-link {
	font-size: var(--ip-font-base);
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
	color: var(--ip-nav-fg) !important;
}
/* The ellipsis goes on the label itself: on the link it never fired, the text
   being inside a span that would sooner widen the row than shrink. Every list
   here is flex-nowrap (Header.php) for the same reason — Bootstrap's .nav wraps,
   and a wrapping column is as wide as its widest row, so "Industry connections"
   pushed every nested row past its heading's edge and into the scrollbar. */
#sidebar .nav-link > .flex-grow-1 {
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
}
/* The leading icon, dropped a pixel: lowercase text sits below the middle of its
   line box, so an icon centred on the row reads as riding high beside it. */
#sidebar .nav-link > .bi:first-child {
	position: relative;
	top: var(--ip-icon-nudge);
	/* One box for every icon, whatever the glyph's own width: they differ by a
	   few pixels, which left the labels beside them ragged. */
	flex: none;
	width: 1.25em;
	text-align: center;
	font-size: var(--ip-font-base);
	line-height: 1;
}
/* The section headings are <button>s, which the browser paints with its own
   control background. Reset here rather than with Bootstrap's .bg-transparent,
   whose !important also beat the active rule below — an open section then
   showed no highlight at all. */
#sidebar button.nav-link {
	background-color: transparent;
	cursor: pointer;
}
#sidebar .nav-link:hover,
#sidebar button.nav-link:hover {
	color: var(--ip-ink) !important;
}
#sidebar .nav-link.active {
	background-color: var(--ip-teal);
	color: var(--ip-on-accent) !important;
}
/* A section heading holding the current page is tinted, not filled. Filled, it
   was a second solid accent pill directly above the page's own — two "you are
   here"s, with the brighter of them on the heading rather than the page. Teal
   text still marks the section when it is closed and the page is out of sight. */
#sidebar button.nav-link.active {
	background-color: var(--ip-mint);
	color: var(--ip-teal) !important;
	font-weight: 600;
}
#sidebar button.nav-link.active:hover { background-color: var(--ip-mint-deep); }

/* The roll-up count beside a nav entry. Mono, being a figure. It used to be a
   Bootstrap .badge, which on the active pill inverted into a solid block of
   on-accent — near-black on the dark theme's light teal, the heaviest thing in
   the sidebar for the least important number on it. */
/* Both centred as boxes rather than set on the text baseline: an inline-block
   on the baseline carried the descender's room under it, and sat visibly low
   against the label and the chevron. */
.nav-count { display: inline-flex; align-items: center; align-self: center; }
.nav-count__n {
	position: relative;
	top: var(--ip-icon-nudge);
	display: inline-flex;
	align-items: center;
	height: 19px;
	margin-left: 8px;
	padding: 0 7px;
	border-radius: 999px;
	background: var(--ip-rule);
	color: var(--ip-muted);
	font-family: var(--ip-mono);
	font-size: var(--ip-font-xs);
	font-weight: 600;
	font-variant-numeric: tabular-nums;
	line-height: 1;
}
/* On a pill it takes the pill's own text colour, washed, so it reads as part
   of the entry rather than a patch stuck on it. */
#sidebar .nav-link.active .nav-count__n {
	background: color-mix(in srgb, currentColor 16%, transparent);
	color: inherit;
}

/* Collapsed, the chevron points down; open, it flips up. It takes the
   heading's own colour rather than a fixed one — a fixed ink stayed light on
   the dark theme's teal pill while the label beside it turned dark. */
[data-bs-toggle="collapse"] .sidebar-chevron {
	/* Centred on its own box like the leading icon, so it rides the label's middle. */
	display: inline-flex;
	align-items: center;
	line-height: 1;
	position: relative;
	top: var(--ip-icon-nudge);
	color: currentColor;
	opacity: 0.55;
	transition: transform 0.18s ease, opacity 0.18s ease;
	font-size: 0.85em;
	transform: rotate(0deg);
}
[data-bs-toggle="collapse"][aria-expanded="true"] .sidebar-chevron {
	transform: rotate(180deg);
	opacity: 0.85;
}
@media (prefers-reduced-motion: reduce) {
	[data-bs-toggle="collapse"] .sidebar-chevron { transition: none; }
}
/* Every other arrow in the app: the sidebar's chevron (`bi-chevron-down`), and
   `.is-up` turns it the way an open sidebar group turns its own. Never a
   Unicode triangle or arrow — those are drawn from whatever font has the
   glyph, at that font's weight and size, and match nothing around them. */
.ip-chevron {
	display: inline-block;
	line-height: 1;
	transition: transform 0.18s ease;
}
.ip-chevron.is-up { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) {
	.ip-chevron { transition: none; }
}
/* Less room on the right, where a nested row has no chevron to keep clear of:
   indented, the longest label is otherwise a few pixels short of fitting, and
   lost its last letters to an ellipsis whenever the scrollbar showed. */
#sidebar ul ul .nav-link { font-size: var(--ip-font-base); padding-right: 0.5rem; }

/* The organisation's name, held to two lines. A long one — "Southern Cross
   Robotics & Engineering Collective" — ran to four, and pushed the whole nav
   down by the lines it took. The full name is in its title. */
.sidebar-brand-name {
	font-size: var(--ip-font-h2);
	display: -webkit-box;
	-webkit-box-orient: vertical;
	-webkit-line-clamp: 2;
	line-clamp: 2;
	overflow: hidden;
	overflow-wrap: anywhere;
}

/* Scrolls by itself when an open section is taller than the room left — the
   brand above and Log activity below stay put. */
.sidebar-nav-wrapper {
	flex: 1;
	min-height: 0;
	overflow-y: auto;
	overflow-x: hidden;
	/* Fades at whichever end has more menu past it (impact-shell.js sets the
	   attributes). A mask reads only alpha, so the opaque stop can be any
	   opaque token; the lengths are registered so the fades ease in and out
	   rather than snapping. */
	--ip-fade-top: 0px;
	--ip-fade-bottom: 0px;
	mask-image: linear-gradient(to bottom,
		transparent 0,
		var(--ip-surface) var(--ip-fade-top),
		var(--ip-surface) calc(100% - var(--ip-fade-bottom)),
		transparent 100%);
	transition: --ip-fade-top 160ms ease-out, --ip-fade-bottom 160ms ease-out;
}
.sidebar-nav-wrapper[data-more-above] { --ip-fade-top: 28px; }
.sidebar-nav-wrapper[data-more-below] { --ip-fade-bottom: 28px; }
@property --ip-fade-top    { syntax: '<length>'; inherits: false; initial-value: 0px; }
@property --ip-fade-bottom { syntax: '<length>'; inherits: false; initial-value: 0px; }

/* The account toggle and the settings gear at the foot of the sidebar. One
   rule for both so they cannot drift apart in colour. Their heights are matched
   by the row stretching them (see Header.php); the gear's width is the toggle's
   44px — a 28px avatar inside py-2 — so it reads as a square beside it. */
.sidebar-foot-btn {
	background: var(--ip-veil-hover);
	transition: background-color 0.15s ease, color 0.15s ease;
}
.sidebar-foot-btn:hover {
	color: var(--ip-ink) !important;
}
.sidebar-foot-btn--square { width: 44px; }
.sidebar-foot-btn.is-active {
	background: var(--ip-mint-tint);
	color: var(--ip-teal) !important;
}

.account-item {
	font-size: var(--ip-font-base);
	transition: background 0.15s ease;
	cursor: pointer;
}
/* Sign out. Bootstrap's .text-danger was its own red rather than ours. */
.account-item.account-item--danger,
.account-item.account-item--danger:hover { color: var(--ip-danger); }

/* Taller tap targets where the sidebar is a slide-over. */
@media (max-width: 991.98px) {
	#sidebar .nav-link { padding-top: 0.6rem; padding-bottom: 0.6rem; }
}

/* The progress line along the top while a page is on its way. */
#nav-progress {
	position: fixed;
	top: 0;
	left: 0;
	height: 3px;
	width: 0%;
	z-index: 9999;
	background: var(--ip-teal);
	transition: width 200ms ease, opacity 300ms ease;
	opacity: 0;
	pointer-events: none;
}
/* A full-page load that follows a fade-out (app.js) starts hidden, so the new
   page fades in rather than flashing. The whole shell on a way in from a
   signed-out page, which had none — see shellParts() in app.js. Never <body>:
   the background is drawn on it and stays put — see pageParts(). */
html.full-page-transition-pending #main-content,
html.full-page-transition-pending body > .minimal-page-main,
html.full-page-transition-pending body > .ip-help { opacity: 0; }
html.full-page-transition-needs-sidebar-fade #sidebar,
html.full-page-transition-needs-sidebar-fade #app-topbar { opacity: 0; }

/* The corner the toasts stack in is always on screen, empty or not, and above
   the page. So it lets clicks through and only a toast itself takes one; before
   this, the empty box swallowed every click in the bottom-right corner — the
   entry flyout's Edit button and the signed-out pages' Help button among them. */
.app-toasts { pointer-events: none; }
.app-toasts .toast { pointer-events: auto; }

/* The toast — showToast() in bootstrap-helpers.js. Bootstrap's .toast is kept
   on it for show/hide and autohide; the look is ours. A raised surface like a
   menu, with the tone carried by the icon alone, so a run of them stacked in
   the corner reads as one family rather than a column of coloured headers. */
.ip-toast {
	display: flex;
	align-items: flex-start;
	gap: 10px;
	width: 340px;
	max-width: calc(100vw - 32px);
	padding: 12px 12px 12px 14px;
	background: var(--ip-surface);
	border: 1px solid var(--ip-border);
	border-radius: 12px;
	box-shadow: var(--ip-shadow-pop);
	color: var(--ip-ink);
	font-size: var(--ip-font-sm);
}
.ip-toast__icon { font-size: var(--ip-font-base); line-height: 1.25; flex: 0 0 auto; }
.ip-toast--success .ip-toast__icon { color: var(--ip-teal); }
.ip-toast--error   .ip-toast__icon { color: var(--ip-danger); }
.ip-toast--warning .ip-toast__icon { color: var(--ip-amber); }
.ip-toast--info    .ip-toast__icon { color: var(--ip-blue); }
.ip-toast__text { flex: 1 1 auto; min-width: 0; }
.ip-toast__title { font-weight: 600; line-height: 1.35; }
.ip-toast__body { color: var(--ip-label); line-height: 1.45; margin-top: 1px; overflow-wrap: anywhere; }
.ip-toast__close { flex: 0 0 auto; width: 0.6em; height: 0.6em; margin: 4px 0 0 4px; }

/* Theme toggle */
.ip-theme-toggle {
	background: var(--ip-surface);
	border: 1px solid var(--ip-border);
	border-radius: 999px;
	width: 34px;
	height: 34px;
	display: grid;
	place-items: center;
	cursor: pointer;
	font-size: var(--ip-font-sm);
	color: var(--ip-label);
	flex: 0 0 34px;
	padding: 0;
}

/* -----------------------------------------------------------------------------
   Surfaces
   -------------------------------------------------------------------------- */

.ip-card {
	background: var(--ip-surface);
	border: 1px solid var(--ip-border);
	border-radius: 12px;
	padding: 18px;
	box-shadow: var(--ip-shadow-sm);
}
.ip-card--lg { border-radius: 14px; box-shadow: var(--ip-shadow); }
/* `clip`, not `hidden`: both round the corners off what is inside, but `hidden`
   also makes the card a scroll container, and a table header inside would
   stick to the card — which never scrolls — instead of to the page.
   The edge is drawn over the card rather than as its border. A clip stops at
   the padding box, so nothing inside could ever paint over a real border —
   and a stuck table header has to, to cut the card off cleanly where it sits
   (see .ip-table__head[data-stuck]). No padding: content runs to the card's
   edge and the edge is drawn over it. A 1px inset here once left a ring of the
   card's own white between the edge and any tinted band inside — a table
   header, a month group — which read as a doubled border. */
.ip-card--flush { padding: 0; border: 0; overflow: clip; position: relative; }
.ip-card--flush::after {
	content: '';
	position: absolute;
	inset: 0;
	border: 1px solid var(--ip-border);
	border-radius: inherit;
	pointer-events: none;
	z-index: 1;
}
.ip-card--flush.ip-card--sunk::after { border-color: var(--ip-border-soft); }
.ip-card--sunk { background: var(--ip-surface-sunk); border-color: var(--ip-border-soft); }

.ip-card__head {
	padding: 16px 18px;
	border-bottom: 1px solid var(--ip-border-soft);
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 12px;
}
.ip-card__title { font-size: var(--ip-font-h3); font-weight: 600; }
.ip-card__meta { font-size: var(--ip-font-xs); color: var(--ip-muted); line-height: 1.5; text-wrap: pretty; }
.ip-card__title + .ip-card__meta { margin-top: 3px; }

/* A label over a value or a group, in sentence case like everything else that
   is read. Capitals are kept for column headings (.ip-table__head), which
   name a column rather than say something; as a label they were decoration,
   and coloured they shouted. */
.ip-eyebrow {
	font-size: var(--ip-font-xs);
	font-weight: 600;
	color: var(--ip-muted);
}

.ip-rule { height: 1px; background: var(--ip-border-soft); margin: 16px 0; }

.ip-stack   { display: flex; flex-direction: column; gap: 16px; }
.ip-stack-sm { display: flex; flex-direction: column; gap: 8px; }
.ip-row     { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }

.ip-dropzone {
	border: 1.5px dashed var(--ip-border-input);
	border-radius: 11px;
	padding: 26px;
	text-align: center;
	background: var(--ip-surface-sunk);
}
.ip-dropzone__hint {
	font-family: var(--ip-mono);
	font-variant-numeric: tabular-nums;
	font-size: var(--ip-font-xs);
	color: var(--ip-faint);
	letter-spacing: 0.03em;
	line-height: 1.6;
}

/* -----------------------------------------------------------------------------
   Controls
   -------------------------------------------------------------------------- */

/* Padding and font matched to .ip-input's, line for line, so a full-size button
   is never a different height from the field it sits beside — see the email
   row in settings/profile.php. */
.ip-btn {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	gap: 0.5em;
	background: var(--ip-surface);
	border: 1px solid var(--ip-border-input);
	border-radius: 9px;
	padding: 10px 15px;
	font-size: var(--ip-font-base);
	font-weight: 500;
	cursor: pointer;
	color: var(--ip-ink);
	text-decoration: none;
}
/* Bootstrap Icons nudge their glyph down by .125em to sit on a text baseline,
   which in a flex button reads as an icon hanging below the label's middle.
   Centred on the box instead, then dropped a pixel: lowercase text sits below the
   middle of its line box, so a truly centred icon reads as high. */
.ip-btn > .bi { display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.ip-btn > .bi::before { vertical-align: 0; }
.ip-btn > .bi { position: relative; top: var(--ip-icon-nudge); font-size: var(--ip-icon-scale); }
/* Every button's plus is this one glyph, so they match. It is drawn hairline-thin
   beside 600-weight text; a stroke of its own
   colour thickens it without growing it. */
.ip-btn > .bi-plus-lg::before { -webkit-text-stroke: var(--ip-icon-stroke) currentColor; }
.ip-btn:hover { color: var(--ip-ink); text-decoration: none; }

.ip-btn--primary {
	background: var(--ip-teal);
	color: var(--ip-on-accent);
	border-color: var(--ip-teal);
	font-weight: 600;
	box-shadow: var(--ip-shadow-sm);
}
.ip-btn--primary:hover { background: var(--ip-teal-dark); border-color: var(--ip-teal-dark); color: var(--ip-on-accent); }

/* A band of light that sweeps across the button once on hover — for the one
   button that sells something. Drawn from the accent's own ink colour so it
   follows both themes; resting position is off the left edge, so nothing shows
   until the pointer arrives. */
.ip-btn--shine { position: relative; overflow: hidden; isolation: isolate; }
.ip-btn--shine::after {
	content: '';
	position: absolute;
	top: 0;
	bottom: 0;
	left: 0;
	width: 45%;
	transform: translateX(-130%) skewX(-20deg);
	background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--ip-shine, var(--ip-on-accent)) 38%, transparent), transparent);
	pointer-events: none;
}
/* While the pointer stays, the sweep repeats every 3s: it crosses in the first
   1.4s and rests for the remainder. Leaving stops it where it is, off the
   button's edge or not — the resting transform above takes over at once. */
.ip-btn--shine:hover:not(:disabled)::after,
.ip-btn--shine:focus-visible::after {
	animation: ip-shine 3s ease-in-out infinite;
}
@keyframes ip-shine {
	0%   { transform: translateX(-130%) skewX(-20deg); }
	47%, 100% { transform: translateX(330%) skewX(-20deg); }
}
@media (prefers-reduced-motion: reduce) {
	.ip-btn--shine::after { display: none; }
}

.ip-btn[hidden],
.ip-tag[hidden] { display: none; }
/* The violet every upgrade prompt wears (.ip-plan-lock, the Upgrade tags), as a
   full-size button. Pairs with .ip-btn--shine, whose band takes the violet. */
.ip-btn--upgrade {
	--ip-shine: var(--ip-violet);
	background: var(--ip-violet-bg);
	color: var(--ip-violet);
	border-color: color-mix(in srgb, var(--ip-violet) 35%, transparent);
	font-weight: 600;
}
.ip-btn--upgrade:hover {
	background: var(--ip-violet-bg);
	color: var(--ip-violet);
	border-color: var(--ip-violet);
}

.ip-btn--block { width: 100%; }
.ip-btn--sm { padding: 6px 11px; font-size: var(--ip-font-sm); }
/* Transparent, so it takes the colour of the ground it sits on — a sunk footer, a
   tinted panel — rather than standing on it as a white chip. Everything else is
   the default button's: on a card the two are identical. Its border used to be
   one step fainter (--ip-border, not --ip-border-input), which beside a default
   button read as a mistake rather than as anything meant. */
.ip-btn--quiet { background: transparent; }
/* Outlined rather than filled: a destructive action should be findable, not the
   brightest thing on the panel competing with the action you actually came for. */
.ip-btn--danger { background: transparent; border-color: var(--ip-danger-border); color: var(--ip-danger); }
.ip-btn--danger:hover { background: var(--ip-danger-bg); border-color: var(--ip-danger); color: var(--ip-danger); }

.ip-btn--link {
	background: transparent;
	border: 0;
	color: var(--ip-muted);
	font-size: var(--ip-font-sm);
	padding: 0;
	text-decoration: underline;
}
.ip-btn--link:hover { background: transparent; color: var(--ip-ink); }
/* A quiet Remove beside an item. Written as a pair so it stays a link — the
   outlined danger button is for a destructive action that stands alone. */
.ip-btn--link.ip-btn--danger { color: var(--ip-danger); }
.ip-btn--link.ip-btn--danger:hover { background: transparent; color: var(--ip-danger-dark); }

/* The asterisk after a required field's label. */
.ip-req { color: var(--ip-danger); margin-left: 4px; }

.ip-btn:disabled { opacity: 0.55; cursor: not-allowed; }

/* Segmented control — the award-window and variant switchers */
.ip-seg {
	display: flex;
	position: relative;
	background: var(--ip-surface);
	border: 1px solid var(--ip-border);
	border-radius: 9px;
	padding: 3px;
	gap: 2px;
}
.ip-seg__btn {
	border: 0;
	border-radius: 6px;
	padding: 6px 11px;
	font-size: var(--ip-font-sm);
	font-weight: 500;
	cursor: pointer;
	background: transparent;
	color: var(--ip-label);
	white-space: nowrap;
	/* Above the pill, which is a sibling drawn behind it. */
	position: relative;
	z-index: 1;
	transition: color 200ms ease;
}
.ip-seg__btn.is-active { background: var(--ip-teal); color: var(--ip-on-accent); }

/* The pill that travels between options — sized and positioned from JS, since
   the options are as wide as their own labels. See seg.js.

   `has-thumb` is added by that module, and handing the background over is
   conditional on it: a control this never reached keeps the painted-in
   background above and looks exactly as it did before. */
.ip-seg.has-thumb .ip-seg__btn.is-active { background: transparent; }

.ip-seg__thumb {
	position: absolute;
	top: 0;
	left: 0;
	z-index: 0;
	border-radius: 6px;
	background: var(--ip-teal);
	pointer-events: none;
	opacity: 0;
}

/* Only once placed. The first placement is a jump from the corner, and it is
   the one movement that must not be animated. */
.ip-seg.is-ready .ip-seg__thumb {
	transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1),
	            width 300ms cubic-bezier(0.32, 0.72, 0, 1),
	            height 300ms cubic-bezier(0.32, 0.72, 0, 1),
	            opacity 140ms ease;
}

.ip-seg-label {
	font-size: var(--ip-font-xs);
	color: var(--ip-faint);
	font-weight: 600;
}

/* Filter chips */
.ip-chip {
	border: 1px solid var(--ip-border-input);
	background: var(--ip-surface);
	color: var(--ip-label);
	border-radius: 999px;
	padding: 6px 12px;
	font-size: var(--ip-font-xs);
	cursor: pointer;
	font-weight: 500;
	white-space: nowrap;
	transition: background-color 180ms ease, color 180ms ease, border-color 180ms ease;
}
.ip-chip.is-active { background: var(--ip-mint); color: var(--ip-teal); border-color: var(--ip-teal); }
.ip-chip--lg { padding: 7px 14px; font-size: var(--ip-font-sm); }

/* Read-only tag */
.ip-tag {
	display: inline-block;
	font-size: var(--ip-font-xs);
	font-weight: 500;
	padding: 3px 8px;
	border-radius: 999px;
	background: var(--ip-rule);
	color: var(--ip-muted);
	white-space: nowrap;
}
.ip-tag--teal   { background: var(--ip-mint);      color: var(--ip-teal); }
.ip-tag--blue   { background: var(--ip-blue-bg);   color: var(--ip-blue); }
.ip-tag--amber  { background: var(--ip-amber-bg);  color: var(--ip-amber-fg); }
.ip-tag--violet { background: var(--ip-violet-bg); color: var(--ip-violet); }
.ip-tag--pink   { background: var(--ip-pink-bg);   color: var(--ip-pink); }
/* A state that has gone wrong — an expired invitation, errors on a dialog tab.
   Not a sixth hue for categories: it means something needs seeing to. */
.ip-tag--danger { background: var(--ip-danger-bg); color: var(--ip-danger); }

/* A feature the plan does not include — a lock and "Upgrade" (planLock() in
   impact.js). Violet, the one hue no status uses, so it is never read as a
   state the record is in. */
/* Relative so its visually hidden label ("Locked, needs the Impact plan") is
   placed against the tag. Without a positioned ancestor it measured from the
   page: in a sidebar long enough to scroll, it hung below the window and made
   the whole document 109px taller than the screen. */
.ip-plan-lock { position: relative; display: inline-flex; align-items: center; gap: 4px; }
/* The icon's size and colour are set under "Plan gate", after the menus whose
   own icon rules would otherwise repaint it. */

/* A control in its locked form (lockControl()): muted, so it does not read as
   the action it names, with its pill. The button is what is clicked — through
   to the plans — so the pill inside it stays a tag. */
.ip-btn.is-locked { color: var(--ip-muted); }
.ip-btn.is-locked .ip-plan-lock { margin-left: 1px; }

/* Cycling status pill — a record's state, and the control that advances it.
   The third member of the tag/chip pair, and it has to announce itself as one:
   shaped like a tag, it was being read as one, and people did not discover that
   a status could be moved by clicking it. So it carries a ⟳, lifts and darkens
   under the cursor, and its title names the state the click will move it to.
   The colours stay inline because they belong to the state, not to the control. */
.ip-cycle {
	display: inline-flex;
	align-items: center;
	gap: 6px;
	border-radius: 999px;
	padding: 4px 9px 4px 11px;
	font-size: var(--ip-font-xs);
	font-weight: 600;
	white-space: nowrap;
	cursor: pointer;
	transition: box-shadow 0.12s ease, transform 0.12s ease;
}
/* An inset shadow rather than a background change: the pill's own background is
   set per state and this must not overwrite it. */
/* Only where a pointer can hover. A tap on a touch screen leaves :hover applied to
   whatever was tapped until the next tap elsewhere, so the lift stayed up. */
@media (hover: hover) {
	.ip-cycle:hover { box-shadow: inset 0 0 0 40px var(--ip-veil-hover); transform: translateY(-1px); }
}
.ip-cycle:active { transform: translateY(0); box-shadow: inset 0 0 0 40px var(--ip-veil-hover); }
.ip-cycle__glyph { font-size: var(--ip-font-xs); line-height: 1; opacity: 0.6; }

/* The line under a section header that spells the cycle out, for the state you
   cannot see from one pill: which order the states go in. */
.ip-cycle-hint {
	display: inline-flex;
	align-items: center;
	gap: 7px;
	margin-top: 9px;
	padding: 5px 10px 5px 8px;
	border: 1px dashed var(--ip-border-input);
	border-radius: 999px;
	font-size: var(--ip-font-xs);
	color: var(--ip-muted);
}
.ip-cycle-hint__glyph { font-size: var(--ip-font-xs); opacity: 0.7; }

@media (prefers-reduced-motion: reduce) {
	.ip-cycle { transition: none; }
	.ip-cycle:hover { transform: none; }

	/* The indicator still moves — it has to, it is the thing that marks the
	   current step — it simply arrives rather than travels. */
	.ip-steps__indicator,
	.ip-step,
	.ip-step__dot,
	.ip-seg__btn,
	.ip-seg.is-ready .ip-seg__thumb,
	.ip-chip { transition: none; }
	.ip-step-panel--enter { animation: none; }
}

/* Fields */
.ip-field { display: flex; flex-direction: column; gap: 6px; }
.ip-field__label { font-size: var(--ip-font-xs); font-weight: 600; color: var(--ip-label); }
.ip-field__hint  { font-size: var(--ip-font-xs); color: var(--ip-faint); }

.ip-input {
	border: 1px solid var(--ip-border-input);
	border-radius: 9px;
	padding: 10px 12px;
	font-size: var(--ip-font-sm);
	/* A full-size button's line, not this text's own: the button sets its label at
	   the base size, so a field at the smaller size came out 3px shorter than the
	   button beside it (see .ip-btn). The compact pair below share a size and
	   need no help. */
	line-height: calc(var(--ip-font-base) * 1.5);
	background: var(--ip-input-bg);
	width: 100%;
	color: var(--ip-ink);
}
.ip-input:focus {
	border-color: var(--ip-teal);
	outline: none;
	box-shadow: 0 0 0 3px var(--ip-mint);
	background: var(--ip-input-bg);
}
textarea.ip-input { resize: vertical; line-height: 1.5; }
/* A <select> keeps its arrow: the browser's own is dropped with the rest of its
   chrome, and this one is drawn in the label colour so it follows the theme. */
select.ip-input {
	appearance: none;
	padding-right: 34px;
	background-image:
		linear-gradient(45deg, transparent 50%, var(--ip-label) 50%),
		linear-gradient(135deg, var(--ip-label) 50%, transparent 50%);
	background-position: calc(100% - 17px) 50%, calc(100% - 12px) 50%;
	background-size: 5px 5px;
	background-repeat: no-repeat;
}
select.ip-input[multiple] { padding-right: 12px; background-image: none; }
.ip-input:disabled, .ip-input[readonly] { color: var(--ip-muted); background-color: var(--ip-surface-sunk); }
.ip-input[type="file"] { padding: 7px 10px; }
.ip-input.is-invalid { border-color: var(--ip-danger); }
.ip-input.is-invalid:focus { box-shadow: 0 0 0 3px var(--ip-danger-bg); }
.invalid-feedback { font-size: var(--ip-font-xs); color: var(--ip-danger); }
.ip-input--sm { padding: 6px 8px; font-size: var(--ip-font-sm); border-radius: 7px; line-height: 1.5; }
.ip-input--num { text-align: right; }

/* Search — an .ip-input with its glyph. Drawn by Toolbar::search() rather than
   written out, which is how every box ends up with the same glyph, width and
   placeholder punctuation. */
.ip-search { position: relative; flex: 1 1 220px; min-width: 200px; margin: 0; }
.ip-search > .bi {
	position: absolute;
	left: 12px;
	top: 50%;
	transform: translateY(-50%);
	font-size: var(--ip-font-sm);
	color: var(--ip-faint);
	pointer-events: none;
}
.ip-search > .ip-input { padding-left: 34px; }

/* Toolbar — what sits above a list: search, filters, a count, the list's own
   action. Worn with .ip-card for the surface. Each list page used to build this
   row itself, and no two agreed on padding, surface or where the button went.
   Stretched rather than centred, so the search box — sized to match the chips
   and .ip-btn--sm actions beside it — cannot drift a pixel off them. */
.ip-toolbar {
	display: flex;
	flex-wrap: wrap;
	align-items: stretch;
	gap: 10px;
	padding: 12px 14px;
}
.ip-toolbar__filters { display: flex; flex-wrap: wrap; gap: 6px; }
/* On a phone the chips are one row that scrolls sideways. Wrapped, ten of them
   took five rows, and the first entry of the list started two-thirds of the way
   down the screen. Full-bleed to the card's edges so the row visibly runs on. */
@media (max-width: 640px) {
	.ip-toolbar__filters {
		flex-wrap: nowrap;
		flex-basis: 100%;
		overflow-x: auto;
		margin-inline: -14px;
		padding-inline: 14px;
		scrollbar-width: none;
		overscroll-behavior-x: contain;
	}
	.ip-toolbar__filters::-webkit-scrollbar { display: none; }
	.ip-toolbar__filters > * { flex: none; }
}
/* Centred against its own line, unlike the rest of the row: the toolbar
   stretches its items to the height of a control, and a line of text stretched
   that way sits at the top of the box, level with nothing beside it. */
.ip-toolbar__meta { align-self: center; white-space: nowrap; font-size: var(--ip-font-xs); color: var(--ip-faint); font-variant-numeric: tabular-nums; }
/* Pushes itself, and anything after it, to the far end of the row. */
.ip-toolbar__end { margin-left: auto; }

/* Footnote — the quiet line under a list saying how it behaves. */
.ip-footnote { font-size: var(--ip-font-xs); color: var(--ip-faint); line-height: 1.55; }

/* Combo — a searchable multi-select.
   For a list too long to lay out as chips. Chips are right up to a dozen or so,
   where seeing every option *is* the interface; past that they become a wall to
   read rather than a set to choose from, and the answer is a search box. */
.ip-combo { position: relative; }

.ip-combo__control {
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	gap: 6px;
	border: 1px solid var(--ip-border-input);
	border-radius: 9px;
	background: var(--ip-input-bg);
	padding: 6px 8px;
	cursor: text;
}
.ip-combo__control:focus-within {
	border-color: var(--ip-teal);
	box-shadow: 0 0 0 3px var(--ip-mint);
}

/* The input keeps a minimum so it stays clickable once tokens wrap, and grows to
   take the rest of the row. */
.ip-combo__input {
	flex: 1;
	min-width: 120px;
	border: 0;
	outline: none;
	background: transparent;
	color: var(--ip-ink);
	font-size: var(--ip-font-sm);
	font-family: inherit;
	padding: 4px 2px;
}

.ip-combo__token {
	display: inline-flex;
	align-items: center;
	gap: 6px;
	background: var(--ip-mint);
	color: var(--ip-teal);
	border: 1px solid var(--ip-teal);
	border-radius: 999px;
	padding: 3px 4px 3px 10px;
	font-size: var(--ip-font-xs);
	font-weight: 500;
	white-space: nowrap;
}
/* The wrapper a picker redraws its tokens into. `contents` hands the tokens to
   the control's flex row, so its gap falls between them too — as a box of its
   own the wrapper took the gap, and the tokens inside it sat edge to edge. */
.ip-combo__tokens { display: contents; }
.ip-combo__token button {
	border: 0;
	background: transparent;
	color: inherit;
	cursor: pointer;
	font-size: var(--ip-font-sm);
	line-height: 1;
	padding: 2px 4px;
	border-radius: 999px;
}
.ip-combo__token button:hover { background: var(--ip-mint-deep); }
.ip-combo__token-sub { font-weight: 400; color: var(--ip-muted); }

.ip-combo__menu {
	position: absolute;
	z-index: 20;
	left: 0;
	right: 0;
	top: calc(100% + 4px);
	max-height: 232px;
	overflow-y: auto;
	overscroll-behavior: contain;
	background: var(--ip-surface);
	border: 1px solid var(--ip-border);
	border-radius: 10px;
	box-shadow: var(--ip-shadow-pop);
	padding: 4px;
}
.ip-combo__menu[hidden] { display: none; }

.ip-combo__option {
	display: flex;
	align-items: center;
	gap: 10px;
	width: 100%;
	text-align: left;
	border: 0;
	background: transparent;
	color: var(--ip-ink);
	border-radius: 7px;
	padding: 8px 10px;
	font-size: var(--ip-font-sm);
	cursor: pointer;
}
/* Highlight follows the keyboard, hover only mirrors it — two independently
   highlighted rows would leave no way to tell which Enter would pick. */
.ip-combo__option.is-active { background-image: linear-gradient(var(--ip-veil-hover), var(--ip-veil-hover)); }
.ip-combo__option.is-on { color: var(--ip-teal); font-weight: 600; }
.ip-combo__option__mark { width: 14px; flex: 0 0 14px; font-size: var(--ip-font-xs); }
.ip-combo__option__sub { margin-left: auto; font-size: var(--ip-font-xs); color: var(--ip-faint); }

.ip-combo__empty { padding: 12px 10px; font-size: var(--ip-font-sm); color: var(--ip-faint); }

/* The way out of an empty menu: create the thing that was searched for. Sits
   inside the menu because that is where the dead end is — a button elsewhere on
   the form is somewhere else to look at the moment of not finding something. */
.ip-combo__create {
	display: block;
	width: 100%;
	border: 0;
	border-top: 1px solid var(--ip-border-soft);
	background: transparent;
	padding: 10px;
	text-align: left;
	font-size: var(--ip-font-sm);
	font-weight: 500;
	color: var(--ip-teal);
	cursor: pointer;
}
.ip-combo__create:hover { background: var(--ip-mint); }

.ip-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.ip-grid-fit { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }

@media (max-width: 640px) {
	.ip-grid-2 { grid-template-columns: 1fr; }
}

/* Checkbox — square, teal when set */
.ip-check {
	width: 20px;
	height: 20px;
	flex: 0 0 20px;
	border-radius: 5px;
	border: 1.5px solid var(--ip-box-border);
	background: var(--ip-surface);
	color: var(--ip-on-accent);
	cursor: pointer;
	display: grid;
	place-items: center;
	font-size: var(--ip-font-xs);
	padding: 0;
}
.ip-check.is-on { border-color: var(--ip-teal); background: var(--ip-teal); }
.ip-check--sm { width: 17px; height: 17px; flex-basis: 17px; border-radius: 4px; font-size: var(--ip-font-xs); }

/* -----------------------------------------------------------------------------
   Data display
   -------------------------------------------------------------------------- */

.ip-avatar {
	width: 26px;
	height: 26px;
	flex: 0 0 26px;
	border-radius: 50%;
	background: var(--ip-mint-tint);
	color: var(--ip-teal);
	display: grid;
	place-items: center;
	font-size: var(--ip-font-xs);
	font-weight: 600;
}
.ip-avatar--lg { width: 28px; height: 28px; flex-basis: 28px; font-size: var(--ip-font-xs); }

/* KPI tiles */

/* Queried rather than the viewport: the sidebar is 252px wide above 900px and
   off-canvas below it, so a viewport breakpoint would name a column count the
   content area cannot actually hold. */
.ip-kpi-band { container-type: inline-size; }

.ip-kpis {
	display: grid;
	grid-template-columns: minmax(0, 1fr);
	gap: 14px;
}

/* The tiles arrive as one injected block. display:contents lifts them into the
   grid so a card sitting beside them can claim the cells the tiles leave. */
.ip-kpis__tiles { display: contents; }
.ip-kpis__tiles > .loading-pane { grid-column: 1 / -1; }

/* Six tiles: only at four columns is the second row half empty, so that is the
   only width where the companion card sits beside them rather than below. */
.ip-kpis__wide { grid-column: 1 / -1; }

@container (min-width: 460px) { .ip-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@container (min-width: 660px) { .ip-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@container (min-width: 880px) {
	.ip-kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); }
	.ip-kpis__wide { grid-column: span 2; }
}

.ip-kpi {
	background: var(--ip-surface);
	border: 1px solid var(--ip-border);
	border-radius: 12px;
	padding: 16px 16px 14px;
	box-shadow: var(--ip-shadow-sm);
}
.ip-kpi__label { font-size: var(--ip-font-xs); color: var(--ip-muted); font-weight: 500; }
.ip-kpi__value {
	font-size: var(--ip-font-h1);
	font-variant-numeric: tabular-nums;
	font-weight: 600;
	letter-spacing: -0.03em;
	margin-top: 6px;
	line-height: 1;
}
.ip-kpi__delta { font-size: var(--ip-font-xs); color: var(--ip-teal); margin-top: 7px; font-weight: 500; }

/* Compact stat tiles above a list */
/* The compact figures above a list: each a slim tile, its label and its figure
   on one line, so a row of them costs one line of height above the table they
   summarise rather than two.

   How many share a line is chosen from how many there are and how wide the row
   is, never left to wrapping: plain wrapping put four tiles three and one. So
   four go 4, then 2 × 2; five go 5, then 3 + 2; three go 3 or stack; anything
   stacks on a phone. Each tile is about 200px at its narrowest — below that the
   label wraps beside its figure. The row is the container, so a settings
   column and a full page each lay out by their own width. */
.ip-stats {
	container-type: inline-size;
	display: flex;
	flex-wrap: wrap;
	gap: 12px;
}
.ip-stat {
	--per: 1;
	flex: 0 0 auto;
	width: calc((100% - (var(--per) - 1) * 12px) / var(--per));
	display: flex;
	align-items: baseline;
	justify-content: space-between;
	gap: 12px;
	background: var(--ip-surface);
	border: 1px solid var(--ip-border);
	border-radius: 11px;
	padding: 10px 14px;
	min-width: 0;
}
.ip-stat__label { font-size: var(--ip-font-sm); color: var(--ip-muted); min-width: 0; }
/* Two 200px tiles and a gap. Three stack here rather than go two and one. */
@container (min-width: 412px) {
	.ip-stat { --per: 2; }
	.ip-stats:has(> .ip-stat:nth-of-type(3):last-of-type) > .ip-stat,
	.ip-stats:has(> .ip-stat:nth-of-type(5):last-of-type) > .ip-stat:last-of-type { --per: 1; }
}
/* Three. Five go three and two; four stay two by two. */
@container (min-width: 624px) {
	.ip-stats:has(> .ip-stat:nth-of-type(3):last-of-type) > .ip-stat,
	.ip-stats:has(> .ip-stat:nth-of-type(5):last-of-type) > .ip-stat:nth-of-type(-n+3) { --per: 3; }
	.ip-stats:has(> .ip-stat:nth-of-type(5):last-of-type) > .ip-stat:nth-of-type(n+4) { --per: 2; }
}
@container (min-width: 848px) {
	.ip-stats:has(> .ip-stat:nth-of-type(4):last-of-type) > .ip-stat { --per: 4; }
}
/* `:nth-of-type(n)` matches every tile; it is there to outweigh the three-and-
   two rules above, which carry one more pseudo-class than a bare `.ip-stat`. */
@container (min-width: 1048px) {
	.ip-stats:has(> .ip-stat:nth-of-type(5):last-of-type) > .ip-stat:nth-of-type(n) { --per: 5; }
}
/* A lone tile is the whole row at any width. Counted by type, not child:
   the loading placeholder puts a hidden status <span> ahead of the tiles. */
.ip-stats > .ip-stat:only-of-type { --per: 1; }
.ip-stat__value { font-size: var(--ip-font-h3); font-weight: 600; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; white-space: nowrap; }

/* A bare figure over its label, inside a card that is already the tile — the
   award plan's counts, the risk register's, the dashboard's pipeline. Three
   screens drew this by hand at 20, 22 and 24px with three label spacings, so
   the same kind of number changed size from page to page. */
.ip-figure__value {
	font-size: var(--ip-font-h1);
	font-weight: 600;
	letter-spacing: -0.02em;
	line-height: 1;
	font-variant-numeric: tabular-nums;
}
.ip-figure__label { font-size: var(--ip-font-xs); color: var(--ip-muted); margin-top: 6px; line-height: 1.35; }

/* A figure whose colour is its meaning: something overdue, something done. */
.ip-stat__value.is-danger, .ip-figure__value.is-danger { color: var(--ip-danger); }
.ip-stat__value.is-good,   .ip-figure__value.is-good   { color: var(--ip-teal); }

/* Column chart */
.ip-bars {
	display: flex;
	align-items: flex-end;
	gap: 8px;
	height: 190px;
	margin-top: 20px;
}
.ip-bars--sm { height: 120px; gap: 7px; margin-top: 14px; }
/* Many months: narrower gaps so the bars keep some width, and the few labels
   left are allowed to run past their own bar — they mark year starts, with
   empty bars either side to run into. */
.ip-bars--dense { gap: 3px; }
.ip-bars--dense .ip-bar__label { overflow: visible; }

.ip-bar {
	flex: 1;
	display: flex;
	flex-direction: column;
	align-items: center;
	gap: 8px;
	height: 100%;
	justify-content: flex-end;
	min-width: 0;
}
.ip-bar__value { font-size: var(--ip-font-xs); color: var(--ip-muted); font-family: var(--ip-mono); font-variant-numeric: tabular-nums; }
.ip-bar__fill {
	width: 100%;
	border-radius: 5px 5px 2px 2px;
	background: var(--ip-teal-pale);
	min-height: 4px;
}
.ip-bar__fill.is-peak { background: var(--ip-teal); }
.ip-bar__label { font-size: var(--ip-font-xs); color: var(--ip-faint); white-space: nowrap; }

/* Horizontal meter */
.ip-meter {
	height: 7px;
	background: var(--ip-track);
	border-radius: 4px;
	overflow: hidden;
}
.ip-meter__fill { height: 100%; border-radius: 4px; background: var(--ip-teal-pale); }
.ip-meter__fill.is-peak { background: var(--ip-teal); }

.ip-meter-row { margin-bottom: 13px; }
.ip-meter-row__head {
	display: flex;
	justify-content: space-between;
	font-size: var(--ip-font-sm);
	margin-bottom: 5px;
}
.ip-meter-row__label { font-weight: 500; }
.ip-meter-row__value { color: var(--ip-muted); font-family: var(--ip-mono); font-variant-numeric: tabular-nums; }

/* Key/value list */
.ip-kv { display: flex; justify-content: space-between; gap: 10px; font-size: var(--ip-font-sm); }
.ip-kv__key { color: var(--ip-muted); }
.ip-kv__val { font-weight: 600; text-align: right; }
.ip-kv__val--mono { font-family: var(--ip-mono); font-variant-numeric: tabular-nums; }

/* Grid tables — a table only in appearance, so a row can be a link and the
   column set can differ per screen without a colspan dance. */
.ip-table { overflow-x: auto; }
.ip-table__head,
.ip-table__row {
	display: grid;
	gap: 12px;
	align-items: center;
	padding: 12px 18px;
}
.ip-table__head {
	border-bottom: 1px solid var(--ip-border-soft);
	background: var(--ip-surface-sunk);
	font-size: var(--ip-font-xs);
	font-weight: 600;
	letter-spacing: 0.05em;
	text-transform: uppercase;
	color: var(--ip-faint);
	padding-top: 11px;
	padding-bottom: 11px;
}
.ip-table__row {
	border-bottom: 1px solid var(--ip-rule);
	font-size: var(--ip-font-sm);
	color: inherit;
	text-decoration: none;
}
/* A row that opens something is a <button>, so it is reachable by keyboard. */
button.ip-table__row {
	width: 100%;
	text-align: left;
	background: transparent;
	border: 0;
	border-bottom: 1px solid var(--ip-rule);
	font: inherit;
}
/* Every row lights under the pointer, so the eye can follow a wide row across
   its columns. Only the pointer cursor says the row can be opened. */
.ip-table__row:hover { text-decoration: none; color: inherit; }
.ip-table__row.is-clickable { cursor: pointer; }
/* Drawn inside the row: the table scrolls sideways, and a scroller clips
   anything painted outside its edges — the ring's left and right sides with it. */
.ip-table__row:focus-visible { outline-offset: -2px; background-image: linear-gradient(var(--ip-veil-hover), var(--ip-veil-hover)); }

/* A header that stays while a long list scrolls under it. Only on a table that
   fits its card — table-layout.js decides, and says why there — and only in a
   flush card, whose edges the table reaches.
   It stops a page's padding below the bar, not against it, so the list keeps
   the same air under the bar that it had before it scrolled. */
.ip-table[data-fits] { overflow-x: visible; }
/* Under the card's drawn edge (z 1) at rest, so the edge shows over it; above
   it once stuck, where it has to cover what has scrolled behind it — and then
   draws the edge itself, top and sides, since it now sits over the card's. */
.ip-card--flush .ip-table[data-fits] > .ip-table__head {
	position: sticky;
	top: var(--ip-stick-top);
	z-index: 0;
}
.ip-card--flush .ip-table[data-fits] > .ip-table__head[data-stuck] { z-index: 2; }
/* While it is stuck, the rows it has passed are still there above it, under
   the gap and the bar. The header carries its own cover for them: a band of
   page above it, and the card's rounded top corners drawn over its own square
   ones. Both are part of the header, so they move with it on the compositor —
   a clip set from a scroll handler trailed it by a frame, and a fast scroll
   showed rows sliding over the top edge. Only *becoming* stuck waits on
   script, and at that moment there are no rows above the header yet. */
.ip-table__head[data-stuck] {
	box-shadow:
		inset 0 1px 0 var(--ip-border),
		inset 1px 0 0 var(--ip-border),
		inset -1px 0 0 var(--ip-border);
}
.ip-table__head[data-stuck]::before,
.ip-table__head[data-stuck]::after {
	content: '';
	position: absolute;
	left: 0;
	right: 0;
	pointer-events: none;
}
.ip-table__head[data-stuck]::before {
	bottom: 100%;
	height: var(--ip-stick-top);
	background-color: var(--ip-canvas);
}
:root[data-theme="dark"] .ip-table__head[data-stuck]::before {
	/* The page's own grid, pinned to the viewport the way it is, so the band
	   lines up with what surrounds it instead of reading as a patch. */
	background-image:
		linear-gradient(var(--ip-texture-line) 1px, transparent 1px),
		linear-gradient(90deg, var(--ip-texture-line) 1px, transparent 1px);
	background-size: 50px 50px;
	background-attachment: fixed;
}
/* The corners: page outside the curve, the card's edge on it, the header's
   own fill inside. Same 12px radius as the card. */
.ip-table__head[data-stuck]::after {
	top: 0;
	height: 12px;
	background:
		radial-gradient(circle at 100% 100%, transparent 10.5px, var(--ip-border) 11px 11.5px, var(--ip-canvas) 12.5px) left top / 12px 12px no-repeat,
		radial-gradient(circle at 0 100%, transparent 10.5px, var(--ip-border) 11px 11.5px, var(--ip-canvas) 12.5px) right top / 12px 12px no-repeat;
}

/* On a phone a row is a card: what it is about across the top, and each other
   figure under its own column name, wrapping as the width allows. Scrolling a
   1000px grid sideways through a 360px window hid the name the moment you
   moved, and with it any idea which row you were reading. */
@media (max-width: 640px) {
	.ip-table__head { display: none; }
	.ip-table__row,
	.ip-table__group {
		/* The inline floor is the grid's. A stacked row has no columns to keep. */
		min-width: 0 !important;
	}
	.ip-table__row {
		display: flex;
		flex-wrap: wrap;
		align-items: flex-start;
		gap: 8px 18px;
		padding: 12px 16px;
	}
	.ip-table__row > * { min-width: 0; max-width: 100%; }
	.ip-table__row > .ip-cell-lead,
	.ip-table__row:not(:has(> .ip-cell-lead)) > :first-child { order: -1; flex: 1 0 100%; }
	.ip-table__row > [data-blank] { display: none; }
	.ip-table__row > .ip-meter { flex: 1 0 100%; align-self: center; }
	.ip-table__row .ip-cell-right { text-align: left; }
	.ip-table__row > [data-label]::before {
		content: attr(data-label);
		display: block;
		font-size: var(--ip-font-xs);
		font-weight: 600;
		letter-spacing: 0.05em;
		text-transform: uppercase;
		color: var(--ip-faint);
		margin-bottom: 2px;
	}
	/* The lead says what the row is; a heading over it would say "Name". The
	   meter is drawn by its fill, which a label would push aside. */
	.ip-table__row > .ip-cell-lead::before,
	.ip-table__row:not(:has(> .ip-cell-lead)) > :first-child::before,
	.ip-table__row > .ip-meter::before { content: none; }
}
.ip-table__note { padding: 12px 18px; font-size: var(--ip-font-xs); color: var(--ip-faint); }

/* A heading that sorts by its column. It looks like the heading it is — the
   arrow on the one in force is what says the rest can be clicked too. */
.ip-table__sort {
	background: transparent;
	border: 0;
	padding: 0;
	font: inherit;
	letter-spacing: inherit;
	text-transform: inherit;
	color: inherit;
	text-align: left;
	cursor: pointer;
	white-space: nowrap;
}
.ip-table__sort.ip-cell-right { text-align: right; }
.ip-table__sort:hover,
.ip-table__sort.is-on { color: var(--ip-ink); }


/* An entry waiting on a mentor. Bordered rather than sunk: it is an item of
   work in a list of work, not a passive read-out. */
.ip-approval {
	border: 1px solid var(--ip-border-soft);
	border-radius: 9px;
	padding: 11px 12px;
}
.ip-approval__title { font-size: var(--ip-font-sm); font-weight: 500; line-height: 1.4; }
.ip-approval__meta { font-size: var(--ip-font-xs); color: var(--ip-faint); margin-top: 3px; }

/* Month band. Sets its own min-width to match the rows it divides, so it spans
   the full scroll width of the table rather than stopping at the viewport. */
.ip-table__group {
	display: flex;
	align-items: baseline;
	gap: 10px;
	padding: 9px 18px;
	background: var(--ip-surface-sunk);
	border-top: 1px solid var(--ip-border-soft);
	border-bottom: 1px solid var(--ip-border-soft);
}
.ip-table__group-label { font-size: var(--ip-font-xs); font-weight: 600; letter-spacing: 0.03em; }
.ip-table__group-stat { font-size: var(--ip-font-xs); color: var(--ip-faint); font-family: var(--ip-mono); font-variant-numeric: tabular-nums; }

/* Pagination footer. The buttons and the count sit on one line so the count is
   read as the reason for the button rather than as a separate caption. */
.ip-table__foot {
	display: flex;
	align-items: center;
	gap: 10px;
	flex-wrap: wrap;
	padding: 12px 18px;
}
.ip-table__foot .ip-table__note { padding: 0; }
/* Under a grid of cards the footer is a row of its own, not one more cell. */
.ip-team-grid > .ip-table__foot { grid-column: 1 / -1; padding: 4px 0 0; }
.ip-table__count { font-family: var(--ip-mono); font-variant-numeric: tabular-nums; font-size: var(--ip-font-xs); color: var(--ip-faint); }

.ip-cell-strong {
	font-weight: 500;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
}
.ip-cell-sub { color: var(--ip-muted); font-size: var(--ip-font-xs); margin-top: 2px; }
.ip-cell-date { color: var(--ip-faint); font-family: var(--ip-mono); font-variant-numeric: tabular-nums; font-size: var(--ip-font-xs); }
.ip-cell-num { font-family: var(--ip-mono); font-variant-numeric: tabular-nums; }
.ip-cell-right { text-align: right; }
.ip-cell-muted { color: var(--ip-muted); }
/* A person: avatar, then the name, which truncates before the avatar squeezes. */
.ip-cell-who { display: flex; align-items: center; gap: 9px; min-width: 0; }
/* A list that has to stay one line so the rows stay aligned — four roles must
   not make a row four lines tall. Give it a title with the whole text. */
.ip-cell-clip {
	font-size: var(--ip-font-xs);
	color: var(--ip-muted);
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
}
/* table-layout.js scrolls the rest of a clipped cell into view under the
   pointer; an ellipsis on text that is moving would mark the wrong end. */
.ip-cell-strong.is-revealing,
.ip-cell-clip.is-revealing { text-overflow: clip; }

/* Timeline */
.ip-timeline__item { display: flex; gap: 12px; }
.ip-timeline__gutter {
	display: flex;
	flex-direction: column;
	align-items: center;
	flex: 0 0 10px;
}
.ip-timeline__dot {
	width: 8px;
	height: 8px;
	border-radius: 50%;
	background: var(--ip-teal);
	margin-top: 5px;
}
.ip-timeline__line { width: 1px; flex: 1; background: var(--ip-border); }
.ip-timeline__body { padding-bottom: 16px; flex: 1; }

/* Empty state — every list can be empty on a fresh organisation, and an empty
   grid with no explanation reads as a failed request. */
.ip-empty {
	padding: 34px 20px;
	text-align: center;
	color: var(--ip-faint);
	font-size: var(--ip-font-sm);
	line-height: 1.6;
}
.ip-empty__title { font-size: var(--ip-font-sm); font-weight: 600; color: var(--ip-muted); margin-bottom: 4px; }
.ip-empty__action { margin-top: 14px; }
/* In a grid of cards the message would take one cell and sit at the left like a
   stray caption; it spans the grid, and wears a card's surface so it reads as
   the answer rather than as text floating over the page. */
:is(.ip-team-grid, .ip-evidence-grid) > .ip-empty {
	grid-column: 1 / -1;
	background: var(--ip-surface);
	border: 1px solid var(--ip-border);
	border-radius: 12px;
}

/* Progress list — a short procedure the app carries out while you watch, one
   step at a time: pending, working (the dot turns), done (a tick) or failed.
   Not the logger's .ip-step tabs, which are places you can click back to; this
   only ever moves forward, and says so by filling in top to bottom. Each step's
   body holds whatever that step needs — a record to copy, a notice, a button. */
.ip-progress { display: flex; flex-direction: column; }
.ip-progress__item { display: flex; gap: 12px; }
.ip-progress__gutter { display: flex; flex-direction: column; align-items: center; flex: 0 0 22px; }
.ip-progress__dot {
	width: 22px;
	height: 22px;
	border-radius: 50%;
	background: var(--ip-track);
	color: var(--ip-faint);
	display: grid;
	place-items: center;
	font-size: var(--ip-font-xs);
	font-weight: 600;
	transition: background-color 240ms ease, color 240ms ease;
}
.ip-progress__line { width: 1px; flex: 1; background: var(--ip-border); margin: 4px 0; }
.ip-progress__body { flex: 1; min-width: 0; padding-bottom: 18px; }
.ip-progress__item:last-child .ip-progress__body { padding-bottom: 0; }
.ip-progress__title { font-size: var(--ip-font-sm); font-weight: 600; line-height: 22px; color: var(--ip-muted); }
.ip-progress__detail { margin-top: 8px; display: flex; flex-direction: column; gap: 10px; }

.ip-progress__item.is-current .ip-progress__title { color: var(--ip-ink); }
.ip-progress__item.is-current .ip-progress__dot { background: var(--ip-teal); color: var(--ip-on-accent); }
.ip-progress__item.is-done .ip-progress__title { color: var(--ip-ink-soft); }
.ip-progress__item.is-done .ip-progress__dot { background: var(--ip-mint); color: var(--ip-teal); }
.ip-progress__item.is-failed .ip-progress__dot { background: var(--ip-danger-bg); color: var(--ip-danger); }
/* Working: the dot becomes a turning ring, and its number goes, so it cannot
   be read as "step 2 of 4, idle". */
.ip-progress__item.is-working .ip-progress__dot {
	background: transparent;
	color: transparent;
	border: 2px solid var(--ip-mint-deep);
	border-top-color: var(--ip-teal);
	animation: ip-progress-turn 800ms linear infinite;
}
@keyframes ip-progress-turn { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
	.ip-progress__item.is-working .ip-progress__dot { animation-duration: 2400ms; }
}

/* A field another source is filling in — the add-team dialog's name and
   location while FIRST is asked about the number. The same turning ring as a
   working step, inside the box that will change, because a spinner in the
   dialog's footer says something is happening without saying where. Wrapped
   rather than drawn on the .ip-field so a hint under the input cannot move it. */
.ip-autofill { position: relative; display: block; }
.ip-autofill.is-busy > .ip-input { padding-right: 36px; }
.ip-autofill.is-busy::after {
	content: '';
	position: absolute;
	right: 12px;
	top: 50%;
	width: 14px;
	height: 14px;
	margin-top: -7px;
	border-radius: 50%;
	border: 2px solid var(--ip-mint-deep);
	border-top-color: var(--ip-teal);
	animation: ip-progress-turn 800ms linear infinite;
	pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
	.ip-autofill.is-busy::after { animation-duration: 2400ms; }
}

/* A DNS record to copy into someone else's control panel, one field a row. */
.ip-record { display: flex; flex-direction: column; gap: 6px; }
.ip-record__row { display: flex; align-items: center; gap: 10px; font-size: var(--ip-font-sm); }
.ip-record__key { flex: 0 0 56px; color: var(--ip-muted); }
.ip-record__val { flex: 1; min-width: 0; font-family: var(--ip-mono); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }

/* Notice — an inline result that has to stay on screen. A toast is wrong for
   anything the user still has to read and act on, and a dialog is too much for
   "that password was rejected". */
.ip-notice {
	border: 1px solid var(--ip-border);
	border-radius: 10px;
	padding: 11px 13px;
	font-size: var(--ip-font-sm);
	line-height: 1.55;
	background: var(--ip-surface-sunk);
	color: var(--ip-ink-soft);
}
.ip-notice--ok    { background: var(--ip-mint);      border-color: var(--ip-mint-deep);    color: var(--ip-teal); }
.ip-notice--error { background: var(--ip-danger-bg); border-color: var(--ip-danger-border); color: var(--ip-danger); }
.ip-notice--warn  { background: var(--ip-amber-bg);  border-color: var(--ip-amber-bg);     color: var(--ip-amber-fg); }
.ip-notice ul { margin: 0; padding-left: 18px; }

/* Flyout — the entry detail sheet.
   A sheet rather than a dialog because it is a *reading* surface opened from a
   row: it keeps the list behind it, so closing puts you back where you were
   rather than on a screen you have to find your place in again. Above the top
   bar (see .app-topbar for the order), and below Bootstrap's modal backdrop at
   1050: the dialog it launches (form-modal, 1055) opens over it and both stay
   open. */
.ip-flyout__scrim {
	position: fixed;
	inset: 0;
	background: var(--ip-scrim);
	z-index: 1040;
	animation: ip-fade-in 280ms ease backwards;
}
.ip-flyout__scrim.is-leaving { animation: ip-fade-out 220ms ease forwards; pointer-events: none; }

.ip-flyout {
	position: fixed;
	top: 0;
	right: 0;
	bottom: 0;
	width: 100%;
	max-width: 560px;
	background: var(--ip-bg);
	z-index: 1041;
	box-shadow: -16px 0 48px var(--ip-shadow-3);
	display: flex;
	flex-direction: column;
	/* The body scrolls up and down; sideways is the swipe that pushes the sheet
	   away (swipe-dismiss.js). */
	touch-action: pan-y;
	/* In from the edge and back out to it, on the same path. `backwards`, not
	   `both`: a fill that outlived the entrance would hold the sheet at rest and
	   override the offset a finger drags it to. */
	animation: ip-sheet-in 320ms cubic-bezier(0.32, 0.72, 0, 1) backwards;
}
/* Only a `to`: it starts from wherever the sheet is — at rest after the close
   button, or part-way off after a swipe — rather than snapping back to rest first. */
.ip-flyout.is-leaving { animation: ip-sheet-out 220ms cubic-bezier(0.4, 0, 1, 1) forwards; pointer-events: none; }
@keyframes ip-sheet-in  { from { transform: translateX(100%); } }
@keyframes ip-sheet-out { to   { transform: translateX(100%); } }
@keyframes ip-fade-in   { from { opacity: 0; } }
@keyframes ip-fade-out  { to   { opacity: 0; } }

/* Head and foot are fixed so the primary action stays reachable however long the
   body runs — an entry with twenty attendees must not bury its Edit button. */
.ip-flyout__head {
	padding: 20px 26px 16px;
	border-bottom: 1px solid var(--ip-border);
	background: var(--ip-surface);
	flex-shrink: 0;
}
.ip-flyout__title { font-size: var(--ip-font-h2); font-weight: 600; letter-spacing: -0.015em; margin-top: 10px; line-height: 1.3; }
.ip-flyout__sub   { font-size: var(--ip-font-sm); color: var(--ip-muted); margin-top: 6px; }
.ip-flyout__close {
	background: transparent;
	border: 1px solid var(--ip-border);
	border-radius: 8px;
	width: 30px;
	height: 30px;
	flex: 0 0 30px;
	display: grid;
	place-items: center;
	cursor: pointer;
	color: var(--ip-muted);
	font-size: var(--ip-font-sm);
}

.ip-flyout__body {
	flex: 1;
	min-height: 0;
	overflow-y: auto;
	/* Contained so reaching the end of the sheet does not start scrolling the
	   list behind it — the body is not scroll-locked, because the edit dialog
	   this launches restores `body { overflow }` when it closes and would
	   silently undo a lock set here. */
	overscroll-behavior: contain;
	padding: 20px 26px 26px;
	display: flex;
	flex-direction: column;
	gap: 16px;
}
/* Cards keep their content height and the body scrolls.
   Without this the sheet silently swallowed rows: `.ip-card--flush` carries
   `overflow: hidden`, which zeroes a flex item's automatic minimum size, so an
   over-tall body was resolved by crushing those cards instead of scrolling —
   a one-person crew list collapsed to just its heading. */
.ip-flyout__body > * { flex-shrink: 0; }
.ip-flyout__foot {
	padding: 14px 26px;
	border-top: 1px solid var(--ip-border);
	background: var(--ip-surface);
	display: flex;
	align-items: center;
	gap: 10px;
	flex-shrink: 0;
}
.ip-flyout__note { flex: 1; font-size: var(--ip-font-xs); color: var(--ip-faint); line-height: 1.4; }

/* A row inside a flyout card — crew, evidence, travel. */
.ip-flyout__row {
	display: flex;
	align-items: center;
	gap: 11px;
	padding: 11px 18px;
	border-top: 1px solid var(--ip-rule);
}

/* Modal — level with Bootstrap's own dialogs, which it stands beside. At 50 it
   sat under the top bar, and the bar stayed bright and clickable over the
   backdrop. */
.ip-modal {
	position: fixed;
	inset: 0;
	background: var(--ip-scrim);
	z-index: 1055;
	display: grid;
	place-items: center;
	padding: 24px;
	overflow-y: auto;
}
.ip-modal[hidden] { display: none; }

.ip-modal__panel {
	background: var(--ip-surface);
	border-radius: 16px;
	width: 100%;
	max-width: 520px;
	box-shadow: 0 24px 60px var(--ip-shadow-4);
	overflow: hidden;
	animation: ip-rise-in 200ms ease both;
}
.ip-modal__head { padding: 20px 24px 16px; border-bottom: 1px solid var(--ip-border-soft); }
.ip-modal__title { font-size: var(--ip-font-h2); font-weight: 600; letter-spacing: -0.01em; }
.ip-modal__sub { font-size: var(--ip-font-sm); color: var(--ip-muted); margin-top: 4px; line-height: 1.5; }
.ip-modal__body { padding: 22px 24px; display: flex; flex-direction: column; gap: 16px; }
.ip-modal__foot {
	padding: 14px 24px;
	border-top: 1px solid var(--ip-border-soft);
	background: var(--ip-surface-sunk);
	display: flex;
	align-items: center;
	gap: 10px;
}
.ip-modal__hint {
	flex: 1;
	display: flex;
	align-items: center;
	gap: 7px;
	font-size: var(--ip-font-xs);
	color: var(--ip-faint);
}
.ip-modal__hint.is-error { color: var(--ip-danger); }
/* Sized to the line it sits on rather than Bootstrap's default, and coloured
   by the hint so it turns red with the text when something failed. */
.ip-modal__hint .spinner-border {
	flex: 0 0 auto;
	width: 12px;
	height: 12px;
	border-width: 1.5px;
	color: inherit;
}

/* Program picker — the segmented control from the add-team dialog */
.ip-picker-row { display: flex; gap: 8px; }
.ip-picker-btn {
	flex: 1;
	border: 1.5px solid var(--ip-border-input);
	background: var(--ip-surface);
	color: var(--ip-muted);
	border-radius: 10px;
	padding: 10px;
	font-size: var(--ip-font-sm);
	font-weight: 600;
	cursor: pointer;
	font-family: var(--ip-mono);
	font-variant-numeric: tabular-nums;
}
.ip-picker-btn:hover { border-color: var(--ip-teal); }
/* The program's own colour when picked — from "Programs", like every tag. */
.ip-picker-btn.is-on { background: var(--prog); color: var(--prog-fg); border-color: var(--prog); }

/* -----------------------------------------------------------------------------
   Screen-specific compositions
   -------------------------------------------------------------------------- */

/* Signed-out pages — sign in, register, join, the two password pages, and the
   error page (AuthCard.php). One narrow card on the app's own background,
   labels above the fields like every other form here, and the wordmark closing
   the page (Wordmark.php). The viewport scrolls these, not an inner column. */
body.minimal-page-body { min-height: 100svh; overflow-y: auto; }

/* The viewport scrolls these pages — body's overflow is handed up to it — and
   scrollbar-gutter does not travel with it, so it is set on the root. Room for
   the scrollbar is kept either way, so a form that grows an error line does not
   push the card sideways. Scoped to these pages: the full shell scrolls
   #app-main instead, and a gutter here would be an empty strip. */
html:has(> body.minimal-page-body) { scrollbar-gutter: stable; }

/* flex-shrink: 0 because this is a flex item of <body>, and an explicit
   min-height replaces the automatic one that stops a flex item shrinking below
   its content. It shrank to the viewport instead, and on a short laptop screen
   the centred form spilled out of both ends — the top half above the page,
   where no scrolling reaches, taking the heading with it. */
.minimal-page-main {
	flex-shrink: 0;
	min-height: 100svh;
	display: flex;
	flex-direction: column;
	justify-content: center;
	/* The bottom clears the Help button pinned in the corner, so the last
	   line can always be scrolled out from under it. */
	padding: 48px 16px 80px;
}

.ip-auth { width: 100%; max-width: 400px; margin: 0 auto; }
.ip-auth__card { padding: 28px 28px 24px; }
.ip-auth__title { font-size: var(--ip-font-h1); font-weight: 600; letter-spacing: -0.02em; line-height: 1.25; margin: 0 0 18px; }
.ip-auth__sub { font-size: var(--ip-font-sm); color: var(--ip-muted); line-height: 1.5; margin: -12px 0 22px; }
.ip-auth__sub strong { color: var(--ip-ink); font-weight: 600; }
.ip-auth__label-row { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.ip-auth__label-row a { font-size: var(--ip-font-xs); }
.ip-auth__remember { display: flex; gap: 10px; align-items: flex-start; font-size: var(--ip-font-sm); cursor: pointer; margin: 0; }
.ip-auth__remember input { accent-color: var(--ip-teal); width: 16px; height: 16px; margin: 2px 0 0; flex: 0 0 16px; }
.ip-auth__hint { display: block; }
.ip-auth__alt { text-align: center; font-size: var(--ip-font-sm); color: var(--ip-muted); margin: 18px 0 0; }
.ip-auth .ip-input:disabled { color: var(--ip-muted); background: var(--ip-surface-sunk); }

/* A password box with its show/hide eye inside the right-hand end. The box
   keeps room for the eye whether or not it is drawn, so the text never runs
   under it and nothing shifts when the script shows it. */
.ip-auth__secret { position: relative; }
.ip-auth__secret .ip-input { padding-right: 42px; }
.ip-auth__reveal {
	position: absolute;
	top: 50%;
	right: 5px;
	transform: translateY(-50%);
	width: 32px;
	height: 32px;
	display: grid;
	place-items: center;
	padding: 0;
	border: 0;
	border-radius: 7px;
	background: transparent;
	color: var(--ip-muted);
	cursor: pointer;
}
.ip-auth__reveal:hover { color: var(--ip-ink); }
.ip-auth__reveal:focus-visible { color: var(--ip-ink); }
.ip-auth__reveal[hidden] { display: none; }

/* Lines sign-in.js writes under a password box — Caps Lock, and the confirm
   box not matching. Empty, and taking no room in the field's gap, until then. */
.ip-auth__caps { color: var(--ip-amber); }
.ip-auth__match { color: var(--ip-danger); }
.ip-auth__caps:empty,
.ip-auth__match:empty { display: none; }

.ip-wordmark {
	display: flex;
	flex-direction: column;
	align-items: center;
	gap: 8px;
	margin-top: 40px;
	font-size: var(--ip-font-sm);
	color: var(--ip-muted);
}
.ip-wordmark img { width: 240px; max-width: 70vw; }

/* Help, in the corner the toasts also use — below them, so a toast covers it
   rather than the other way round. */
.ip-help { position: fixed; right: 16px; bottom: 16px; z-index: 1050; box-shadow: var(--ip-shadow-sm); }

/* A phone: the card starts at the top rather than floating in the middle of a
   screen the keyboard is about to cover half of. */
@media (max-width: 500px) {
	.minimal-page-main { min-height: auto; justify-content: flex-start; padding-top: 24px; }
	.ip-auth__card { padding: 22px 18px 18px; }
}

/* API docs (/docs/api/v3) — Swagger UI inside a card, on the signed-out
   page frame since the docs are read without the app around them.

   Swagger draws in its own light palette, which in the dark theme left dark
   text on a dark card. Its colours are pointed at the tokens here rather than
   patched per element, so it follows the theme switch like everything else.
   The code samples keep Swagger's own dark block: they are dark in both. */
.minimal-page-main:has(.ip-docs) { justify-content: flex-start; }
.ip-docs { width: 100%; max-width: 1100px; margin: 0 auto; }
.ip-docs__head { margin-bottom: 18px; }
.ip-docs__title { font-size: var(--ip-font-h1); font-weight: 600; letter-spacing: -0.02em; margin: 0; }
.ip-docs__sub { font-size: var(--ip-font-sm); color: var(--ip-muted); line-height: 1.5; margin: 6px 0 0; }
.ip-docs__body { padding: 8px 20px 20px; overflow-x: auto; }

.ip-docs .swagger-ui,
.ip-docs .swagger-ui .info .title,
.ip-docs .swagger-ui .info p,
.ip-docs .swagger-ui .info li,
.ip-docs .swagger-ui .opblock-tag,
.ip-docs .swagger-ui .opblock .opblock-summary-description,
.ip-docs .swagger-ui .opblock .opblock-summary-path,
.ip-docs .swagger-ui .opblock .opblock-section-header h4,
.ip-docs .swagger-ui .opblock-description-wrapper p,
.ip-docs .swagger-ui .parameter__name,
.ip-docs .swagger-ui .parameter__type,
.ip-docs .swagger-ui .response-col_status,
.ip-docs .swagger-ui .response-col_description,
.ip-docs .swagger-ui .responses-inner h4,
.ip-docs .swagger-ui .responses-inner h5,
.ip-docs .swagger-ui table thead tr th,
.ip-docs .swagger-ui table thead tr td,
.ip-docs .swagger-ui .tab li,
.ip-docs .swagger-ui .model-title,
.ip-docs .swagger-ui .model,
.ip-docs .swagger-ui section.models h4,
.ip-docs .swagger-ui .prop-type,
.ip-docs .swagger-ui label,
.ip-docs .swagger-ui .btn { color: var(--ip-ink); font-family: var(--ip-sans); }
.ip-docs .swagger-ui .parameter__in,
.ip-docs .swagger-ui .prop-format,
.ip-docs .swagger-ui .renderedMarkdown p { color: var(--ip-muted); }
.ip-docs .swagger-ui .info { margin: 18px 0; }
.ip-docs .swagger-ui .scheme-container { background: transparent; box-shadow: none; padding: 0 0 12px; }
.ip-docs .swagger-ui .opblock-tag { border-bottom-color: var(--ip-border-soft); }
.ip-docs .swagger-ui .opblock .opblock-section-header { background: var(--ip-surface-sunk); box-shadow: none; }
.ip-docs .swagger-ui section.models { border-color: var(--ip-border); }
.ip-docs .swagger-ui section.models .model-container { background: var(--ip-surface-sunk); }
.ip-docs .swagger-ui .arrow,
.ip-docs .swagger-ui .expand-operation svg,
.ip-docs .swagger-ui .opblock-control-arrow { fill: var(--ip-muted); }
.ip-docs .swagger-ui input[type="text"],
.ip-docs .swagger-ui select,
.ip-docs .swagger-ui textarea {
	background: var(--ip-input-bg);
	color: var(--ip-ink);
	border: 1px solid var(--ip-border-input);
	border-radius: 7px;
}
.ip-docs .swagger-ui .btn { border-color: var(--ip-border-input); border-radius: 8px; box-shadow: none; }
.ip-docs .swagger-ui .btn.authorize { color: var(--ip-teal); border-color: var(--ip-teal); }
.ip-docs .swagger-ui .btn.authorize svg { fill: var(--ip-teal); }
.ip-docs .swagger-ui .btn.execute { background: var(--ip-teal); border-color: var(--ip-teal); color: var(--ip-on-accent); }

/* Time zone converter — the two clocks, with the swap between them. Stacks on a
   phone, where the swap turns to point down the page. */
.ip-tz { display: grid; grid-template-columns: 1fr auto 1fr; gap: 14px; align-items: stretch; }
.ip-tz__swap { align-self: center; }
.ip-tz__clock {
	font-family: var(--ip-mono);
	font-variant-numeric: tabular-nums;
	font-size: var(--ip-font-h1);
	font-weight: 600;
	letter-spacing: -0.02em;
	line-height: 1.1;
	color: var(--ip-ink);
}
.ip-tz__meta { font-size: var(--ip-font-xs); color: var(--ip-muted); min-height: 1.5em; }
@media (max-width: 720px) {
	.ip-tz { grid-template-columns: 1fr; }
	.ip-tz__swap { justify-self: center; }
	.ip-tz__swap .bi { transform: rotate(90deg); }
}

/* Dashboard */
/* Halves, and the same gap as the KPI band above, so the seam between these two
   cards lands exactly on the band's column 2/3 gutter. A 1.35fr/1fr split put it
   about 7% to the right of that, which reads as a near-miss rather than a
   choice — the eye tracks the vertical edge straight up and finds it moved. */
.ip-dash-split {
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));
	gap: 14px;
	align-items: stretch;
}
/* `minmax(0, …)` as above, not a bare 1fr: 1fr's floor is its content's width, and
   fourteen month labels laid end to end made the column 476px on a 390px phone —
   the page scrolled sideways and the hours on the right were cut off. */
@media (max-width: 900px) { .ip-dash-split { grid-template-columns: minmax(0, 1fr); } }

/* The two cards hold different amounts of content — a chart with a fixed bar
   height, a list of meters — so stretching them to match left a slab of empty
   card below the bars. Growing the chart's own body to fill the stretched
   card, instead, gives it the same presence as the meters beside it. */
.ip-dash-split > .ip-card { display: flex; flex-direction: column; }
/* min-width: 0 on both, or a flex item's minimum is its content's width — every
   bar's label laid end to end — and a long window pushes the chart out of its
   card and under the one beside it. */
.ip-dash-split #dash-months { flex: 1; display: flex; min-width: 0; }
.ip-dash-split #dash-months .ip-bars { flex: 1; height: auto; min-width: 0; }

/* min() so a phone narrower than one card gets one full-width column, not a
   340px card with its score and figures clipped off the right edge. */
.ip-award-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 1fr)); gap: 16px; }
.ip-award { padding: 20px; }
.ip-award__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.ip-award__name { font-size: var(--ip-font-h3); font-weight: 600; }
.ip-award__window { font-size: var(--ip-font-xs); color: var(--ip-muted); margin-top: 3px; }
.ip-award__score {
	font-size: var(--ip-font-h1);
	font-weight: 600;
	letter-spacing: -0.02em;
	line-height: 1;
	font-variant-numeric: tabular-nums;
	white-space: nowrap;
	color: var(--ip-amber);
}
.ip-award__score.is-good { color: var(--ip-teal); }
.ip-award__of { font-size: var(--ip-font-sm); font-weight: 500; letter-spacing: 0; color: var(--ip-faint); }
.ip-award__checks {
	display: flex;
	flex-direction: column;
	gap: 9px;
	margin-top: 16px;
	padding-top: 16px;
	border-top: 1px solid var(--ip-border-soft);
}
.ip-award__check { display: flex; align-items: flex-start; gap: 9px; font-size: var(--ip-font-sm); }
.ip-award__label { flex: 1; line-height: 1.45; }
.ip-award__stat { font-family: var(--ip-mono); font-variant-numeric: tabular-nums; font-size: var(--ip-font-xs); color: var(--ip-faint); white-space: nowrap; }
.ip-award__mark {
	width: 16px;
	height: 16px;
	flex: 0 0 16px;
	border-radius: 50%;
	display: grid;
	place-items: center;
	font-size: var(--ip-font-xs);
	font-weight: 700;
	margin-top: 1px;
	background: var(--ip-mint);
	color: var(--ip-teal);
}
.ip-award__mark.is-gap { background: var(--ip-amber-bg); color: var(--ip-amber-fg); }

/* -----------------------------------------------------------------------------
   Shared lists — the workspace's own drop-down vocabularies

   A rail of lists beside one editor, rather than a card per list: there are six
   of them, and stacking six cards would push seasons and accounts off the page.
   The rail wraps to a full-width strip on a narrow screen, which is what the
   `flex: 999` on the pane buys — the pane takes every spare pixel until the rail
   can no longer sit beside it, and then the two stack.
   -------------------------------------------------------------------------- */

.ip-lists { display: flex; flex-wrap: wrap; align-items: stretch; }

.ip-lists__rail {
	flex: 1 1 190px;
	max-width: 100%;
	border-right: 1px solid var(--ip-border-soft);
	border-bottom: 1px solid var(--ip-border-soft);
	background: var(--ip-surface-sunk);
	padding: 12px;
	display: flex;
	flex-direction: column;
	gap: 3px;
}
.ip-lists__tab {
	display: flex;
	align-items: center;
	gap: 8px;
	text-align: left;
	background: transparent;
	border: 1px solid transparent;
	color: var(--ip-label);
	border-radius: 9px;
	padding: 9px 11px;
	font-size: var(--ip-font-sm);
	font-family: inherit;
	cursor: pointer;
}
.ip-lists__tab.is-active {
	background: var(--ip-surface);
	border-color: var(--ip-border);
	color: var(--ip-ink);
	font-weight: 600;
}
.ip-lists__tab-label { flex: 1; min-width: 0; }
.ip-lists__tab-count { font-family: var(--ip-mono); font-variant-numeric: tabular-nums; font-size: var(--ip-font-xs); color: var(--ip-faint); }

.ip-lists__pane { flex: 999 1 340px; padding: 18px 20px 20px; min-width: 0; }
.ip-lists__where { font-size: var(--ip-font-xs); color: var(--ip-faint); margin-top: 6px; font-family: var(--ip-mono); font-variant-numeric: tabular-nums; }

.ip-lists__item {
	display: flex;
	align-items: flex-start;
	gap: 11px;
	flex-wrap: wrap;
	border: 1px solid var(--ip-border-soft);
	border-radius: 10px;
	padding: 9px 11px;
	background: var(--ip-surface);
}
/* Retired, not deleted — legible enough to read and put back, quiet enough to
   read as "not on offer". */
.ip-lists__item.is-off { opacity: 0.55; }

.ip-lists__move {
	background: transparent;
	border: 0;
	color: var(--ip-faint);
	font-size: var(--ip-font-xs);
	line-height: 1;
	cursor: pointer;
	padding: 2px;
}
.ip-lists__move:hover:not(:disabled) { color: var(--ip-ink); }
.ip-lists__move:disabled { opacity: 0.35; cursor: default; }

.ip-lists__swatch {
	width: 20px;
	height: 20px;
	flex: 0 0 20px;
	border-radius: 6px;
	border-width: 1.5px;
	border-style: solid;
	cursor: pointer;
	padding: 0;
}

/* Edited in place rather than through a dialog: renaming six lists of six is
   the whole task on this screen, and a modal each time is six clicks per edit. */
.ip-lists__edit {
	min-width: 0;
	border: 1px solid transparent;
	border-radius: 7px;
	padding: 5px 7px;
	background: transparent;
	color: var(--ip-ink);
	font-family: inherit;
	font-size: var(--ip-font-sm);
	font-weight: 500;
}
.ip-lists__edit:hover { border-color: var(--ip-border-soft); }
.ip-lists__edit:focus {
	border-color: var(--ip-border-input);
	background: var(--ip-surface-sunk);
	outline: none;
}
.ip-lists__edit--hint {
	width: 100%;
	padding-left: 35px;
	font-size: var(--ip-font-xs);
	font-weight: 400;
	color: var(--ip-muted);
}
.ip-lists__edit--hint::placeholder { color: var(--ip-placeholder); }

.ip-lists__uses { font-size: var(--ip-font-xs); font-family: var(--ip-mono); font-variant-numeric: tabular-nums; color: var(--ip-faint); white-space: nowrap; }

/* What a role lets somebody do, on the one list that carries capabilities. */
.ip-lists__cap {
	display: flex;
	align-items: center;
	gap: 4px;
	font-size: var(--ip-font-xs);
	color: var(--ip-muted);
	white-space: nowrap;
	cursor: pointer;
}
.ip-lists__cap input { margin: 0; cursor: pointer; }

.ip-lists__pill {
	border: 0;
	border-radius: 999px;
	padding: 4px 10px;
	font-size: var(--ip-font-xs);
	font-weight: 600;
	font-family: inherit;
	cursor: pointer;
	white-space: nowrap;
	background: var(--ip-mint);
	color: var(--ip-teal);
}
.ip-lists__pill.is-off { background: var(--ip-rule); color: var(--ip-muted); }
.ip-lists__pill:hover { box-shadow: inset 0 0 0 40px var(--ip-veil-hover); }

.ip-lists__remove {
	background: transparent;
	border: 0;
	color: var(--ip-faint);
	font-size: var(--ip-font-xs);
	cursor: pointer;
	padding: 4px;
}
.ip-lists__remove:hover { color: var(--ip-danger); }

.ip-lists__note {
	font-size: var(--ip-font-xs);
	color: var(--ip-faint);
	margin-top: 12px;
	line-height: 1.5;
	text-wrap: pretty;
}

@media (max-width: 640px) {
	.ip-lists__rail { border-right: 0; flex-direction: row; flex-wrap: wrap; }
	.ip-lists__tab { flex: 1 1 auto; }
}

/* -----------------------------------------------------------------------------
   Season plan

   The goals the Learning screen measures the roster against. Borrows the list
   editor's move buttons, pill and remove — same card, same gestures — and adds
   only what a goal has that a list value does not: the target and its skills.
   -------------------------------------------------------------------------- */

/* The body of the Teams and Season plan cards in settings. A second rule used to
   give it a surface, border and shadow of its own, from when these panels stood
   alone; inside the card that drew a card within a card. */
.ip-plan { padding: 18px 20px 20px; display: flex; flex-direction: column; gap: 14px; }
.ip-plan__list { display: flex; flex-direction: column; gap: 7px; }

.ip-plan__item {
	display: flex;
	align-items: flex-start;
	gap: 11px;
	flex-wrap: wrap;
	border: 1px solid var(--ip-border-soft);
	border-radius: 10px;
	padding: 10px 11px;
	background: var(--ip-surface);
}
.ip-plan__item.is-off { opacity: 0.55; }

/* The number reads first. It is the whole difference between a goal and a
   wish — "five members", not "members". */
.ip-plan__target {
	flex: 0 0 auto;
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	min-width: 46px;
	padding: 4px 6px;
	border-radius: 9px;
	background: var(--ip-surface-sunk);
	border: 1px solid var(--ip-border-soft);
}
.ip-plan__target-num {
	font-size: var(--ip-font-h2);
	font-weight: 650;
	line-height: 1.1;
	color: var(--ip-ink);
	font-family: var(--ip-mono);
	font-variant-numeric: tabular-nums;
}
.ip-plan__target-word { font-size: var(--ip-font-xs); color: var(--ip-faint); }

.ip-plan__title { font-size: var(--ip-font-sm); font-weight: 600; line-height: 1.35; text-wrap: pretty; }
.ip-plan__detail {
	font-size: var(--ip-font-xs);
	color: var(--ip-muted);
	margin-top: 3px;
	line-height: 1.5;
	text-wrap: pretty;
}

.ip-plan__skills { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }

/* Coloured inline from the workspace's palette, so this rule carries shape
   only — see DESIGN.md on why a literal colour below :root is a bug. */
.ip-plan__skill {
	font-size: var(--ip-font-xs);
	font-weight: 550;
	padding: 2px 8px;
	border-radius: 999px;
	white-space: nowrap;
}
.ip-plan__skill.is-off { text-decoration: line-through; opacity: 0.7; }

.ip-plan__none { font-size: var(--ip-font-xs); color: var(--ip-faint); font-style: italic; }

/* -----------------------------------------------------------------------------
   Linked evidence — activities attached to an award criterion

   The chip is a count on a sub-point; the row is one activity in the dialog
   behind it.
   -------------------------------------------------------------------------- */

.ip-evidence-chip {
	flex: 0 0 auto;
	min-width: 20px;
	padding: 1px 7px;
	border-radius: 999px;
	border: 1px solid var(--ip-mint-deep);
	background: var(--ip-mint);
	color: var(--ip-teal);
	font-family: var(--ip-mono);
	font-variant-numeric: tabular-nums;
	font-size: var(--ip-font-xs);
	line-height: 1.6;
	cursor: pointer;
}
.ip-evidence-chip:hover { background: var(--ip-mint-deep); }

.ip-evidence-row {
	display: flex;
	align-items: flex-start;
	gap: 12px;
	padding: 10px 12px;
	border: 1px solid var(--ip-border-soft);
	border-radius: 10px;
	background: var(--ip-surface);
	text-decoration: none;
	color: inherit;
}
.ip-evidence-row:hover { border-color: var(--ip-mint-deep); }

.ip-evidence-row__date { flex: 0 0 auto; font-size: var(--ip-font-xs); color: var(--ip-faint); padding-top: 2px; }
.ip-evidence-row__name { display: block; font-size: var(--ip-font-sm); font-weight: 600; line-height: 1.4; }
.ip-evidence-row__note {
	font-size: var(--ip-font-xs);
	color: var(--ip-muted);
	margin-top: 3px;
	line-height: 1.45;
	/* Two lines, then cut: a dialog listing twenty activities is a list, and a
	   paragraph on each one stops it being scannable. */
	display: -webkit-box;
	-webkit-line-clamp: 2;
	-webkit-box-orient: vertical;
	overflow: hidden;
}
.ip-evidence-row__subs { display: block; font-size: var(--ip-font-xs); color: var(--ip-faint); margin-top: 5px; line-height: 1.45; }

/* -----------------------------------------------------------------------------
   Approval queue
   -------------------------------------------------------------------------- */

/* Sticky at the left edge, not the top: the row below is 900px wide and scrolls
   sideways, and a header that travelled with it would carry the filters off. */
.ip-approvals__head {
	position: sticky;
	left: 0;
	display: flex;
	align-items: center;
	gap: 12px;
	flex-wrap: wrap;
	padding: 16px 18px;
	border-bottom: 1px solid var(--ip-border-soft);
	background: var(--ip-surface);
}

.ip-approvals__row {
	display: grid;
	grid-template-columns: minmax(220px, 1fr) 150px 130px 96px 250px;
	min-width: 900px;
	gap: 12px;
	align-items: center;
	padding: 13px 18px;
	border-bottom: 1px solid var(--ip-rule);
	font-size: var(--ip-font-sm);
}
.ip-approvals__row--head {
	padding: 11px 18px;
	border-bottom: 1px solid var(--ip-border-soft);
	background: var(--ip-surface-sunk);
	font-size: var(--ip-font-xs);
	font-weight: 600;
	letter-spacing: 0.05em;
	text-transform: uppercase;
	color: var(--ip-faint);
}
.ip-approvals__row.is-clickable { cursor: pointer; }

/* The question a mentor sent back, shown on the row rather than behind a click:
   it is the reason the entry is still here. */
.ip-approvals__asked {
	font-size: var(--ip-font-xs);
	color: var(--ip-amber);
	margin-top: 5px;
	line-height: 1.45;
	text-wrap: pretty;
}

.ip-approvals__filter {
	border: 1px solid var(--ip-border-input);
	background: var(--ip-surface);
	color: var(--ip-label);
	border-radius: 7px;
	padding: 6px 12px;
	font-size: var(--ip-font-xs);
	font-weight: 500;
	font-family: inherit;
	cursor: pointer;
}
.ip-approvals__filter.is-active {
	background: var(--ip-mint);
	border-color: var(--ip-teal);
	color: var(--ip-teal);
}

.ip-approvals__empty {
	padding: 26px 18px;
	text-align: center;
	font-size: var(--ip-font-sm);
	color: var(--ip-faint);
	line-height: 1.6;
}

/* Gaps and strengths — one row each, marked like the award checks above them,
   so the same ! and ✓ mean the same thing twice on one screen. */
.ip-next {
	display: flex;
	align-items: center;
	gap: 12px;
	padding: 12px 18px;
	border-top: 1px solid var(--ip-border-soft);
}
.ip-next:first-child { border-top: 0; }
/* The mark shares a grid with the text rather than the row, so it sits on the
   title's first line whether the row is centred on a taller button or not. */
.ip-next__text {
	flex: 1 1 auto;
	min-width: 0;
	display: grid;
	grid-template-columns: 16px minmax(0, 1fr);
	column-gap: 12px;
	align-items: start;
}
.ip-next__text > .ip-award__mark { margin-top: 2px; }
.ip-next__title { font-size: var(--ip-font-sm); font-weight: 500; line-height: 1.4; }
.ip-next__detail { grid-column: 2; font-size: var(--ip-font-xs); color: var(--ip-muted); margin-top: 2px; }
.ip-next > .ip-btn { flex: 0 0 auto; }

.ip-program-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-top: 16px; }
.ip-program {
	border: 1px solid var(--ip-border-soft);
	border-radius: 10px;
	padding: 14px;
	background: var(--ip-surface-sunk);
}
.ip-program__count { font-size: var(--ip-font-h1); font-weight: 600; letter-spacing: -0.02em; }
.ip-program__name { font-size: var(--ip-font-sm); font-weight: 600; }
.ip-program__detail { font-size: var(--ip-font-xs); color: var(--ip-muted); margin-top: 6px; line-height: 1.5; }

/* Log activity */
.ip-log-layout { display: flex; flex-wrap: wrap; gap: 18px; align-items: flex-start; }
.ip-log-main { flex: 1 1 470px; min-width: 0; }
.ip-log-rail { flex: 1 1 272px; min-width: 0; max-width: 296px; }

.ip-steps {
	display: flex;
	position: relative;
	border-bottom: 1px solid var(--ip-border-soft);
	padding: 0 8px;
}

/* One underline that travels, rather than a border lit on whichever step is
   current. The movement is what says "you advanced" — a border swapping colour
   in place says only that something changed, and leaves the eye to work out
   which direction it went.

   Every step is `flex: 1`, so one step is the track divided by the count and
   translateX(index * 100%) — a percentage of the indicator's own width — lands
   on step `index` exactly, whatever the count. Both are set from JS; the
   fallbacks keep it parked under step one if they never are.

   `bottom: 0` puts it in the last 2px of the step, which is where the current
   step's own bottom border used to sit, so nothing shifts. */
.ip-steps__indicator {
	position: absolute;
	left: 8px;
	bottom: 0;
	height: 2px;
	width: calc((100% - 16px) / var(--ip-step-count, 1));
	background: var(--ip-teal);
	border-radius: 2px;
	transform: translateX(calc(var(--ip-step-index, 0) * 100%));
	transition: transform 320ms cubic-bezier(0.32, 0.72, 0, 1);
}

.ip-step {
	/* Equal by construction — the indicator above is placed by arithmetic on that.
	   Without the zero floor a long label ("Impact/Inspire") took a wider share and
	   every step after it drifted out from under its underline. */
	flex: 1 1 0;
	min-width: 0;
	background: transparent;
	border: 0;
	/* Transparent, and kept: the indicator above draws the visible line, but the
	   border still has to hold the 2px the layout was built around. */
	border-bottom: 2px solid transparent;
	padding: 14px 6px 12px;
	cursor: pointer;
	display: flex;
	flex-direction: column;
	align-items: center;
	gap: 6px;
	color: var(--ip-muted);
	transition: color 200ms ease;
}
.ip-step.is-current { color: var(--ip-teal); }
.ip-step__dot {
	width: 22px;
	height: 22px;
	border-radius: 50%;
	background: var(--ip-track);
	color: var(--ip-faint);
	display: grid;
	place-items: center;
	font-size: var(--ip-font-xs);
	font-weight: 600;
	transition: background-color 240ms ease, color 240ms ease, transform 240ms ease;
}
.ip-step.is-current .ip-step__dot {
	background: var(--ip-teal);
	color: var(--ip-on-accent);
	transform: scale(1.09);
}
.ip-step.is-done .ip-step__dot { background: var(--ip-mint); color: var(--ip-teal); }
.ip-step__label { font-size: var(--ip-font-xs); font-weight: 500; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.ip-step-panel { padding: 26px 26px 22px; min-height: 430px; }

/* Applied only when the step actually changes — `render()` also runs on every
   field edit, and a panel that faded in each time somebody ticked a box would
   be noise rather than feedback. See `paintSteps()` in log.js. */
@keyframes ip-step-enter {
	from { opacity: 0; transform: translateY(5px); }
	to   { opacity: 1; transform: none; }
}
.ip-step-panel--enter { animation: ip-step-enter 240ms ease both; }
.ip-step-panel__title { font-size: var(--ip-font-h2); font-weight: 600; letter-spacing: -0.01em; }
.ip-step-panel__sub { font-size: var(--ip-font-sm); color: var(--ip-muted); margin-top: 4px; line-height: 1.5; }

.ip-step-foot {
	padding: 14px 20px;
	border-top: 1px solid var(--ip-border-soft);
	background: var(--ip-surface-sunk);
	display: flex;
	align-items: center;
	gap: 10px;
}
.ip-step-foot__progress { flex: 1; font-size: var(--ip-font-xs); color: var(--ip-faint); }

.ip-type-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 12px; margin-top: 22px; }
.ip-type {
	text-align: left;
	background: var(--ip-surface);
	border: 1.5px solid var(--ip-border);
	border-radius: 11px;
	padding: 15px;
	cursor: pointer;
	display: flex;
	gap: 12px;
	align-items: flex-start;
}
.ip-type:hover { border-color: var(--ip-teal); }
.ip-type.is-on { background: var(--ip-mint); border-color: var(--ip-teal); }
.ip-type__glyph {
	width: 32px;
	height: 32px;
	flex: 0 0 32px;
	border-radius: 9px;
	background: var(--ip-rule);
	color: var(--ip-muted);
	display: grid;
	place-items: center;
	font-size: var(--ip-font-base);
	line-height: 1;
}
.ip-type.is-on .ip-type__glyph { background: var(--ip-mint-deep); color: var(--ip-teal); }
.ip-type__name { font-size: var(--ip-font-sm); font-weight: 600; }
.ip-type__hint { font-size: var(--ip-font-xs); color: var(--ip-muted); line-height: 1.45; }

/* The same types as pills, for the one-page variant */
.ip-type-pill {
	display: flex;
	align-items: center;
	gap: 8px;
	background: var(--ip-surface);
	border: 1.5px solid var(--ip-border);
	border-radius: 999px;
	padding: 8px 14px;
	cursor: pointer;
	font-size: var(--ip-font-sm);
	font-weight: 500;
}
.ip-type-pill:hover { border-color: var(--ip-teal); }
.ip-type-pill.is-on { background: var(--ip-mint); border-color: var(--ip-teal); }
.ip-type-pill__glyph { font-family: var(--ip-mono); font-variant-numeric: tabular-nums; font-size: var(--ip-font-xs); color: var(--ip-muted); }
.ip-type-pill.is-on .ip-type-pill__glyph { color: var(--ip-teal); }

.ip-member-row {
	display: flex;
	align-items: center;
	gap: 12px;
	border: 1px solid var(--ip-border);
	background: var(--ip-surface);
	border-radius: 10px;
	padding: 9px 12px;
}
.ip-member-row.is-on { border-color: var(--ip-teal); background: var(--ip-mint); }
.ip-member-row__name { flex: 1; font-size: var(--ip-font-sm); font-weight: 500; }
.ip-member-row__role { font-size: var(--ip-font-xs); color: var(--ip-faint); }

/* Attendance grid — a person per row, a day per column.
   Used where an activity runs over several days and the roster differs between
   them, which the single-tick member list cannot express. The columns are fixed
   width and the whole thing scrolls sideways rather than shrinking: a tick
   target that narrows with every extra day stops being tappable at four. */
/* The scroll box. Deliberately NOT a grid: the rows are the grids, and making
   this one too would auto-place each row into the column tracks and lay the
   whole table out on a single line. */
.ip-attend {
	overflow: auto;
	max-height: 340px;
	border: 1px solid var(--ip-border);
	border-radius: 10px;
	background: var(--ip-surface);
}
/* Sized to its content so every row is the same width and they scroll as one;
   `min-width: 100%` keeps the borders full-bleed when the days fit. */
.ip-attend__table {
	width: max-content;
	min-width: 100%;
}
.ip-attend__row {
	display: grid;
	grid-template-columns: var(--attend-cols);
	align-items: center;
	gap: 12px;
	padding: 7px 12px;
	border-top: 1px solid var(--ip-border-soft);
}
.ip-attend__row:first-child { border-top: 0; }
.ip-attend__row.is-on { background: var(--ip-mint); }
/* Sticky vertically only — it scrolls sideways with the days it labels. */
.ip-attend__row--head {
	background: var(--ip-rule);
	position: sticky;
	top: 0;
	z-index: 1;
}
.ip-attend__hrs { text-align: right; }
.ip-attend__label {
	font-size: var(--ip-font-xs);
	font-weight: 600;
	color: var(--ip-faint);
}
.ip-attend__name {
	font-size: var(--ip-font-sm);
	font-weight: 500;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
}
.ip-attend__day { display: flex; flex-direction: column; align-items: center; line-height: 1.25; }
.ip-attend__daynum { font-size: var(--ip-font-xs); font-weight: 600; color: var(--ip-muted); }
.ip-attend__date { font-size: var(--ip-font-xs); color: var(--ip-faint); font-family: var(--ip-mono); font-variant-numeric: tabular-nums; }

/* The cell is the control: a whole tappable square rather than a checkbox with
   a label beside it, because the label is the column heading. */
.ip-attend__cell {
	width: 30px;
	height: 30px;
	margin: 0 auto;
	border-radius: 8px;
	border: 1.5px solid var(--ip-box-border);
	background: var(--ip-surface);
	color: var(--ip-faint);
	font-size: var(--ip-font-xs);
	cursor: pointer;
	display: grid;
	place-items: center;
	padding: 0;
}
.ip-attend__cell.is-on {
	border-color: var(--ip-teal);
	background: var(--ip-teal);
	color: var(--ip-on-accent);
}
.ip-attend__cell:disabled { opacity: 0.4; cursor: default; }
.ip-attend__note { font-size: var(--ip-font-xs); color: var(--ip-faint); margin-top: 8px; }

/* The square at the head of a file row — a preview when the file is a picture,
   its glyph when it is not.

   Fixed 1:1 and `flex: none` so both branches occupy the identical box: the
   names beside them line up down the column, and a list of mixed types has no
   ragged left edge. `overflow: hidden` with `object-fit: cover` crops a
   photograph of any aspect ratio to the square rather than letterboxing it. */
.ip-file-thumb {
	position: relative;
	flex: none;
	width: 32px;
	height: 32px;
	border-radius: 6px;
	overflow: hidden;
	background: var(--ip-mint);
	color: var(--ip-teal);
	display: grid;
	place-items: center;
	font-size: var(--ip-font-base);
}

.ip-file-thumb img {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	object-fit: cover;
	display: block;
	/* Held back until the bytes are there, so the shimmer underneath is what
	   shows rather than a half-painted thumbnail. `.gal-thumb img` does the
	   same, for the same reason. */
	opacity: 0;
	transition: opacity 0.2s ease;
}

/* The skeleton fills the frame and covers the glyph while the bytes are on
   their way. `skeleton-image` inherits the border radius, so it takes the box's
   own corners rather than looking pasted into it. Its fade-out on the way back
   out is shared with the other two frames — see the `.skeleton` rules. */
.ip-file-thumb .skeleton { position: absolute; inset: 0; display: block; }

.ip-file-thumb.is-ready img { opacity: 1; }

/* No fade on the glyph, and none wanted: at the moment this lands the shimmer
   above it is still fully opaque, so it goes while nothing can see it. What
   shows through the crossfade is the frame's own mint, which is the surface the
   rest of the thumbnail is anyway.

   Removed rather than left underneath, for the reason the shared rules give:
   the picture settling over it may have transparent parts, and a glyph inside
   somebody's logo is worse than no glyph at all. */
.ip-file-thumb.is-ready > i { display: none; }

/* One-page variant sections. The card brings no padding of its own: the head
   row and the body carry it, so a section's number sits the same distance
   from the edge whether it is the fixed first one or a toggle. */
.ip-section { overflow: hidden; padding: 0; }
.ip-section__toggle,
.ip-section__head {
	width: 100%;
	display: flex;
	align-items: center;
	gap: 12px;
	background: transparent;
	border: 0;
	padding: 16px 20px;
	text-align: left;
}
.ip-section__toggle { cursor: pointer; }
.ip-section__mark {
	width: 22px;
	height: 22px;
	border-radius: 50%;
	background: var(--ip-track);
	color: var(--ip-faint);
	display: grid;
	place-items: center;
	font-size: var(--ip-font-xs);
	font-weight: 600;
}
.ip-section__mark.is-done { background: var(--ip-mint); color: var(--ip-teal); }
.ip-section__title { flex: 1; font-size: var(--ip-font-sm); font-weight: 600; }
.ip-section__summary { font-size: var(--ip-font-xs); color: var(--ip-muted); font-family: var(--ip-mono); font-variant-numeric: tabular-nums; }
.ip-section__caret { font-size: var(--ip-font-xs); color: var(--ip-faint); }
.ip-section__body { padding: 0 20px 20px; }

/* Collapse — anything that opens and closes in place. The grid row runs from
   0fr to 1fr, which the browser can tween where it cannot tween height to
   `auto`; the inner box clips what is not yet shown. The body stays in the
   page while closed so there is something to animate from — mark it `inert`
   then, or Tab walks into fields nobody can see. A body added only when it
   opens appears in a single frame: the cut this exists to replace. */
.ip-collapse {
	display: grid;
	grid-template-rows: 0fr;
	transition: grid-template-rows 220ms cubic-bezier(0.2, 0, 0, 1);
}
.ip-collapse.is-open { grid-template-rows: 1fr; }
.ip-collapse__inner {
	min-height: 0;
	overflow: hidden;
	opacity: 0;
	transition: opacity 160ms ease-out;
}
.ip-collapse.is-open > .ip-collapse__inner { opacity: 1; transition-delay: 40ms; }
@media (prefers-reduced-motion: reduce) {
	.ip-collapse, .ip-collapse__inner { transition: none; }
}

.ip-summary-rail__headline { font-size: var(--ip-font-base); font-weight: 600; margin-top: 10px; line-height: 1.35; }
.ip-summary-rail__tip {
	background: var(--ip-bg);
	border-radius: 9px;
	padding: 12px;
	font-size: var(--ip-font-xs);
	color: var(--ip-label);
	line-height: 1.55;
}

/* Teams */
.ip-teams-layout { display: flex; flex-wrap: wrap; gap: 18px; align-items: flex-start; }
.ip-teams-main { flex: 1 1 440px; min-width: 0; }
.ip-teams-rail { flex: 1 1 330px; min-width: 0; max-width: 400px; }
.ip-people-rail { flex: 1 1 300px; min-width: 0; max-width: 330px; }

/* The rail's cap is for sitting beside the list. Once the two have wrapped —
   below both bases and the gap, about 788px of content — the rail is the full
   width under the list, and a capped one stopped short of the list's right
   edge. Queried against the layout, not the viewport: the sidebar comes and
   goes at 900px, so the viewport says nothing about the room here. */
.ip-teams-layout { container-type: inline-size; }
@container (max-width: 787px) {
	.ip-teams-rail,
	.ip-people-rail { max-width: none; }
}

/* Side panes stay in view while the list beside them scrolls. They held a
   selected record's detail, or the summary of what is being built, and
   scrolled away with the page — so picking a tile far down the evidence
   grid changed a pane nobody could see any more.
   Pinned where a table header pins (--ip-stick-top), and capped to the room
   left below it less the same gap again, scrolling inside itself when taller. Only
   while beside the list: wrapped underneath it, a pinned pane would sit over
   the list it is meant to follow. The wrap points are each layout's two
   flex bases plus the gap, queried against the layout for the same reason
   as the rail caps above. */
.ip-teams-rail,
.ip-people-rail,
.ip-log-rail,
.ip-report-side {
	/* 4px above the line, because the pane's box starts 4px above its card
	   (the shadow room below): pinned at the line itself, the card sat 4px
	   lower than the list beside it, at rest and stuck alike. */
	position: sticky;
	top: calc(var(--ip-stick-top) - 4px);
	max-height: calc(100dvh - var(--ip-stick-top) - 8px);
	overflow-y: auto;
	overscroll-behavior: contain;
	scrollbar-width: thin;
	/* Room for the cards' shadows, which fall below them and which the
	   scrolling box would otherwise clip, given back so the pane still lines
	   up. Top and bottom only: room at the sides changes the pane's outer
	   width, and with it the width at which the layout wraps. */
	padding-block: 4px;
	margin-block: -4px;
}
@container (max-width: 787px) {
	.ip-teams-rail,
	.ip-people-rail { position: static; max-height: none; overflow: visible; }
}
.ip-log-layout,
.ip-report-layout { container-type: inline-size; }
@container (max-width: 759px) {
	.ip-log-rail { position: static; max-height: none; overflow: visible; }
}
@container (max-width: 763px) {
	.ip-report-side { position: static; max-height: none; overflow: visible; }
}

.ip-team-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 12px; }
/* A tile that selects into the rail, like .ip-evidence — so its edge, hover and
   selected ring are the evidence tile's, not a form's choice card's. */
.ip-team-card {
	text-align: left;
	background: var(--ip-surface);
	border: 1px solid var(--ip-border);
	border-radius: 12px;
	padding: 16px;
	cursor: pointer;
	box-shadow: var(--ip-shadow-sm);
	display: flex;
	flex-direction: column;
	gap: 10px;
}
.ip-team-card:hover { border-color: var(--ip-teal); }
/* The ring is what tells the selected card from the one under the pointer. */
.ip-team-card.is-on {
	border-color: var(--ip-teal);
	box-shadow: 0 0 0 1px var(--ip-teal), var(--ip-shadow-sm);
}
.ip-team-card__name { font-size: var(--ip-font-base); font-weight: 600; letter-spacing: -0.01em; }
.ip-team-card__help { font-size: var(--ip-font-xs); color: var(--ip-muted); line-height: 1.5; }
.ip-team-card__stats {
	display: flex;
	gap: 14px;
	font-size: var(--ip-font-xs);
	color: var(--ip-faint);
	font-family: var(--ip-mono);
	font-variant-numeric: tabular-nums;
	/* Down to the foot: a long name in the same row stretches every card in
	   it, and the figures should still line up across the row. */
	margin-top: auto;
	padding-top: 2px;
}

/* Programs — the one place a program is matched to its colour. Everything that
   shows a program (the .ip-prog tag, the add-team picker) reads --prog and
   --prog-fg from here rather than naming a colour itself, so changing a
   program's colour is one line in the token block. */
.ip-prog--FRC, .ip-picker-btn.is-FRC { --prog: var(--ip-frc); --prog-fg: var(--ip-frc-fg); }
.ip-prog--FTC, .ip-picker-btn.is-FTC { --prog: var(--ip-ftc); --prog-fg: var(--ip-ftc-fg); }
.ip-prog--FLL, .ip-picker-btn.is-FLL { --prog: var(--ip-fll); --prog-fg: var(--ip-fll-fg); }
.ip-prog--FGC, .ip-picker-btn.is-FGC { --prog: var(--ip-fgc); --prog-fg: var(--ip-fgc-fg); }

.ip-prog {
	display: inline-block;
	font-family: var(--ip-mono);
	font-variant-numeric: tabular-nums;
	font-size: var(--ip-font-xs);
	font-weight: 600;
	line-height: 1.3;
	border-radius: 6px;
	padding: 3px 7px;
	white-space: nowrap;
	/* One line, cut rather than spilling: a team named "The Gearheads of Greater
	   Western Sydney Grammar School" ran past the edge of the sheet it sat in.
	   But never shrunk by its row: in a flex row overflow:hidden lets it give way
	   first, so "FTC" became "F…" beside a long team name. max-width still cuts
	   the long case. */
	flex-shrink: 0;
	max-width: 100%;
	overflow: hidden;
	text-overflow: ellipsis;
	vertical-align: bottom;
	background: var(--prog, var(--ip-rule));
	color: var(--prog-fg, var(--ip-muted));
}

/* -----------------------------------------------------------------------------
   Team switcher — which of our teams you are looking at

   Header, top right. Sits beside the award-window control on the pages that
   have one, so it sheds the name and then the number as the viewport narrows
   rather than pushing anything off.
   -------------------------------------------------------------------------- */

/* The picture FIRST publishes — on the Settings row, the team cards and the
   rail. White behind it for the same reason the org logo gets one: these are
   logos drawn for paper. An explicit box because it renders from a data: URI,
   and a broken image must not collapse the row it sits in. `pixelated` because
   the originals are 40x40: smoothing a tiny logo upward turns it into a smear.
   One rule, with --lg after it: there used to be two, and the later one's size
   beat --lg, so the rail's "large" avatar was drawn at 30px. */
.ip-team-avatar {
	flex: 0 0 auto;
	width: 30px;
	height: 30px;
	object-fit: contain;
	image-rendering: pixelated;
	border-radius: 7px;
	background: var(--ip-logo-ground);
	border: 1px solid var(--ip-border-soft);
	padding: 2px;
}
.ip-team-avatar--lg { width: 40px; height: 40px; border-radius: 8px; }

.ip-mini-stats { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; margin: 18px 0; }
.ip-mini-stat {
	background: var(--ip-surface-sunk);
	border: 1px solid var(--ip-border-soft);
	border-radius: 9px;
	padding: 11px;
}
.ip-mini-stat__value { font-size: var(--ip-font-h2); font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.ip-mini-stat__label { font-size: var(--ip-font-xs); color: var(--ip-muted); margin-top: 2px; }

/* Evidence */
.ip-evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
/* A button rather than a link: the tile selects into the rail, and the rail
   carries the link that opens the file. Hence the reset — a button brings its
   own font, padding and centred text. */
.ip-evidence {
	background: var(--ip-surface);
	border: 1px solid var(--ip-border);
	border-radius: 12px;
	overflow: hidden;
	box-shadow: var(--ip-shadow-sm);
	display: flex;
	flex-direction: column;
	color: inherit;
	text-decoration: none;
	font: inherit;
	text-align: left;
	padding: 0;
	width: 100%;
	cursor: pointer;
}
.ip-evidence:hover { border-color: var(--ip-teal); color: inherit; text-decoration: none; }
.ip-evidence.is-selected {
	border-color: var(--ip-teal);
	box-shadow: 0 0 0 1px var(--ip-teal), var(--ip-shadow-sm);
}
.ip-evidence__preview {
	height: 128px;
	background: repeating-linear-gradient(135deg, var(--ip-hatch-a) 0 8px, var(--ip-hatch-b) 8px 16px);
	display: grid;
	place-items: center;
	border-bottom: 1px solid var(--ip-border-soft);
	overflow: hidden;
}
.ip-evidence__preview img { width: 100%; height: 100%; object-fit: cover; }
/* The tile's shimmer stays until the picture's bytes land — the skeleton page
   draws the tile, but without this the frame went blank between the data
   arriving and the image arriving. Same hand-over as `.ip-file-thumb`. */
.ip-evidence__preview { position: relative; }
.ip-evidence__preview img { opacity: 0; transition: opacity 0.2s ease; }
.ip-evidence__preview.is-ready img { opacity: 1; }
.ip-evidence__preview .skeleton { position: absolute; inset: 0; display: block; }
.ip-evidence__kind {
	font-family: var(--ip-mono);
	font-variant-numeric: tabular-nums;
	font-size: var(--ip-font-xs);
	color: var(--ip-hatch-fg);
	letter-spacing: 0.04em;
}
.ip-evidence__body { padding: 14px; display: flex; flex-direction: column; gap: 7px; flex: 1; }
.ip-evidence__title { font-size: var(--ip-font-sm); font-weight: 600; line-height: 1.35; }
.ip-evidence__from { font-size: var(--ip-font-xs); color: var(--ip-muted); line-height: 1.5; flex: 1; }
.ip-evidence__link {
	font-size: var(--ip-font-xs);
	color: var(--ip-faint);
	border-top: 1px solid var(--ip-rule);
	padding-top: 8px;
	margin-top: 4px;
}

/* -----------------------------------------------------------------------------
   Linking an activity to award criteria — the log form's last step
   -------------------------------------------------------------------------- */

.ip-link-block {
	border-top: 1px solid var(--ip-border-soft);
	padding-top: 18px;
	margin-top: 2px;
}

.ip-link-row {
	display: flex;
	align-items: flex-start;
	gap: 12px;
	border: 1px solid var(--ip-border-soft);
	border-radius: 10px;
	padding: 11px 13px;
	background: var(--ip-surface);
}
.ip-link-row.is-on { border-color: var(--ip-teal); background: var(--ip-mint); }

/* Carries the award's own colour, straight off the criteria file — the one
   place a literal colour is right, because it is data rather than styling. */
.ip-link-row__stripe { width: 3px; align-self: stretch; border-radius: 2px; flex: 0 0 3px; }

.ip-link-row__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.ip-link-row__award { font-size: var(--ip-font-xs); font-weight: 600; }
.ip-link-row__text { font-size: var(--ip-font-sm); color: var(--ip-label); line-height: 1.45; text-wrap: pretty; }
.ip-link-row__tag {
	font-family: var(--ip-mono);
	font-variant-numeric: tabular-nums;
	font-size: var(--ip-font-xs);
	color: var(--ip-faint);
	border: 1px solid var(--ip-border-soft);
	border-radius: 4px;
	padding: 2px 5px;
	white-space: nowrap;
}

.ip-link-browse {
	margin-top: 12px;
	border: 1px solid var(--ip-border-soft);
	border-radius: 11px;
	padding: 13px;
	background: var(--ip-surface-sunk);
}

.ip-link-pick {
	display: flex;
	align-items: flex-start;
	gap: 10px;
	text-align: left;
	width: 100%;
	background: var(--ip-surface);
	border: 1px solid var(--ip-border-soft);
	border-radius: 9px;
	padding: 9px 11px;
	font: inherit;
	color: inherit;
	cursor: pointer;
}
.ip-link-pick:hover { border-color: var(--ip-teal); }
.ip-link-pick.is-on { border-color: var(--ip-teal); background: var(--ip-mint); }

.ip-link-pick__mark {
	width: 16px;
	height: 16px;
	flex: 0 0 16px;
	margin-top: 1px;
	border-radius: 5px;
	border: 1.5px solid var(--ip-border-input);
	display: grid;
	place-items: center;
	font-size: var(--ip-font-xs);
	font-weight: 700;
}
.ip-link-pick.is-on .ip-link-pick__mark {
	background: var(--ip-teal);
	border-color: var(--ip-teal);
	color: var(--ip-on-accent);
}
.ip-link-pick__text { flex: 1; min-width: 0; font-size: var(--ip-font-sm); color: var(--ip-label); line-height: 1.45; text-wrap: pretty; }

/* Report builder */
.ip-report-layout { display: flex; flex-wrap: wrap; gap: 18px; align-items: flex-start; }
.ip-report-side { flex: 1 1 276px; min-width: 0; max-width: 300px; }
.ip-report-main { flex: 1 1 470px; min-width: 0; }

.ip-output {
	display: flex;
	align-items: flex-start;
	gap: 9px;
	text-align: left;
	background: var(--ip-surface);
	border: 1.5px solid var(--ip-border);
	border-radius: 10px;
	padding: 11px 12px;
	cursor: pointer;
	width: 100%;
}
.ip-output:hover { border-color: var(--ip-teal); }
.ip-output.is-on { background: var(--ip-mint); border-color: var(--ip-teal); }
.ip-output__glyph { font-family: var(--ip-mono); font-variant-numeric: tabular-nums; font-size: var(--ip-font-xs); color: var(--ip-teal); margin-top: 1px; }
.ip-output__name { font-size: var(--ip-font-sm); font-weight: 600; }
.ip-output__hint { font-size: var(--ip-font-xs); color: var(--ip-muted); line-height: 1.45; }

.ip-section-toggle {
	display: flex;
	align-items: center;
	gap: 9px;
	background: transparent;
	border: 0;
	padding: 8px 6px;
	border-radius: 7px;
	cursor: pointer;
	text-align: left;
	width: 100%;
}
.ip-section-toggle__label { font-size: var(--ip-font-sm); flex: 1; }
.ip-section-toggle.is-off .ip-section-toggle__label { color: var(--ip-faint); }
.ip-section-toggle__words { font-size: var(--ip-font-xs); color: var(--ip-faint); font-family: var(--ip-mono); font-variant-numeric: tabular-nums; }

.ip-doc { padding: 34px 40px 48px; max-width: 780px; }
.ip-doc__kicker {
	font-family: var(--ip-mono);
	font-variant-numeric: tabular-nums;
	font-size: var(--ip-font-xs);
	letter-spacing: 0.1em;
	text-transform: uppercase;
	color: var(--ip-teal);
}
.ip-doc__title {
	font-size: var(--ip-font-h1);
	font-weight: 600;
	letter-spacing: -0.025em;
	margin-top: 10px;
	line-height: 1.15;
	text-wrap: pretty;
}
.ip-doc__meta { font-size: var(--ip-font-sm); color: var(--ip-muted); margin-top: 10px; }
.ip-doc__stats {
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
	gap: 12px;
	margin: 26px 0 30px;
	padding: 18px 0;
	border-top: 1px solid var(--ip-border);
	border-bottom: 1px solid var(--ip-border);
}
.ip-doc__stat-value { font-size: var(--ip-font-h1); font-weight: 600; letter-spacing: -0.025em; }
.ip-doc__stat-label { font-size: var(--ip-font-xs); color: var(--ip-muted); margin-top: 3px; line-height: 1.4; }
.ip-doc__body { display: flex; flex-direction: column; gap: 26px; }
.ip-doc__heading { font-size: var(--ip-font-base); font-weight: 600; letter-spacing: -0.01em; }
.ip-doc__text {
	font-size: var(--ip-font-sm);
	line-height: 1.68;
	color: var(--ip-ink-soft);
	margin-top: 9px;
	text-wrap: pretty;
}
.ip-doc__cite { margin-top: 14px; border-left: 2px solid var(--ip-teal); padding: 4px 0 4px 14px; }
.ip-doc__cite-text { font-size: var(--ip-font-sm); font-style: italic; color: var(--ip-ink-soft); line-height: 1.6; }
.ip-doc__cite-from { font-size: var(--ip-font-xs); color: var(--ip-faint); margin-top: 6px; }

@media (max-width: 640px) { .ip-doc { padding: 22px 18px 32px; } }

/* Plans & billing — /organisation/subscription

   One page for what the workspace is on, what each plan includes, and how it
   is paid for. Laid out against its own width rather than the viewport's: the
   sidebar takes 252px above 900px and nothing below, so a viewport breakpoint
   lands in the wrong place on one side of that line or the other. */
.ip-billing { container: billing / inline-size; }
/* The page shows and hides whole blocks — the plans while the payment form is open, the
   saving tag, the invoices — and several of them set their own display, which
   would otherwise beat the attribute. */
.ip-billing [hidden] { display: none; }

.ip-billing-overview {
	display: flex;
	align-items: flex-start;
	justify-content: space-between;
	gap: 16px;
	flex-wrap: wrap;
	padding: 20px 22px;
}
.ip-billing-overview__plan {
	display: flex;
	align-items: center;
	flex-wrap: wrap;
	gap: 10px;
	margin-top: 6px;
	font-size: var(--ip-font-h1);
	font-weight: 600;
	letter-spacing: -0.02em;
	line-height: 1.15;
}
.ip-billing-overview__line {
	margin-top: 6px;
	max-width: 68ch;
	font-size: var(--ip-font-sm);
	line-height: 1.55;
	color: var(--ip-muted);
	text-wrap: pretty;
}
.ip-billing-overview__actions { display: flex; gap: 8px; flex-wrap: wrap; }

/* Three facts under the plan, divided by rules rather than boxed: they are one
   read-out, not three cards. */
.ip-billing-facts {
	display: grid;
	grid-template-columns: repeat(3, minmax(0, 1fr));
	border-top: 1px solid var(--ip-border-soft);
}
.ip-billing-fact {
	display: flex;
	flex-direction: column;
	align-items: flex-start;
	gap: 3px;
	min-width: 0;
	padding: 14px 22px 16px;
}
.ip-billing-fact + .ip-billing-fact { border-left: 1px solid var(--ip-border-soft); }
.ip-billing-fact__label { font-size: var(--ip-font-xs); color: var(--ip-muted); }
.ip-billing-fact__value { font-size: var(--ip-font-base); font-weight: 600; letter-spacing: -0.01em; }
.ip-billing-fact__note { font-size: var(--ip-font-xs); color: var(--ip-muted); }
.ip-billing-fact .ip-btn--link { margin-top: 4px; font-size: var(--ip-font-xs); }

.ip-billing-overview__foot {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 12px;
	flex-wrap: wrap;
	padding: 12px 22px;
	border-top: 1px solid var(--ip-border-soft);
	background: var(--ip-surface-sunk);
	font-size: var(--ip-font-xs);
	line-height: 1.5;
	color: var(--ip-muted);
}
.ip-billing-overview__foot .ip-row { gap: 8px; }

.ip-billing-bar {
	display: flex;
	align-items: flex-end;
	justify-content: space-between;
	gap: 12px;
	flex-wrap: wrap;
}
.ip-billing-bar__title { font-size: var(--ip-font-base); font-weight: 600; }
.ip-billing-bar__meta { margin-top: 2px; font-size: var(--ip-font-xs); color: var(--ip-muted); }

/* The tiers, side by side. Each card spans two rows of this grid through
   subgrid, so the buying halves and the feature lists line up across cards
   even when one blurb runs a line longer than the other. */
.ip-tiers {
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));
	gap: 16px;
}
.ip-tier {
	display: grid;
	grid-row: span 2;
	grid-template-rows: subgrid;
	row-gap: 18px;
	padding: 24px;
	background: var(--ip-surface);
	border: 1.5px solid var(--ip-border);
	border-radius: 14px;
	box-shadow: var(--ip-shadow-sm);
}
.ip-tier.is-featured { border-color: var(--ip-teal); }
.ip-tier__buy { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.ip-tier__head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.ip-tier__name { font-size: var(--ip-font-sm); font-weight: 600; }
.ip-tier__price { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px; }
.ip-tier__amount { font-size: var(--ip-font-h1); font-weight: 600; letter-spacing: -0.03em; line-height: 1.1; }
.ip-tier__per { font-size: var(--ip-font-sm); color: var(--ip-muted); }
.ip-tier__sub { margin-top: 4px; font-size: var(--ip-font-xs); color: var(--ip-muted); }
.ip-tier__blurb { font-size: var(--ip-font-sm); line-height: 1.5; color: var(--ip-muted); text-wrap: pretty; }
/* Pinned to the foot of the buying half, so the two buttons share a line. */
.ip-tier__buy .ip-btn { margin-top: auto; }
.ip-tier__features {
	display: flex;
	flex-direction: column;
	gap: 9px;
	min-width: 0;
	padding-top: 16px;
	border-top: 1px solid var(--ip-border-soft);
}
.ip-tier__feature { display: flex; gap: 9px; font-size: var(--ip-font-sm); line-height: 1.45; }
.ip-tier__tick { color: var(--ip-teal); font-weight: 700; }

/* The payment form: our fields on the left, what is being bought on the right. */
.ip-billing-pay {
	display: grid;
	grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
	gap: 24px;
	padding: 22px;
}
.ip-billing-pay__fields { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.ip-billing-pay__summary {
	display: flex;
	flex-direction: column;
	gap: 10px;
	align-self: start;
	padding: 16px 18px;
	border: 1px solid var(--ip-border-soft);
	border-radius: 12px;
	background: var(--ip-surface-sunk);
	font-size: var(--ip-font-sm);
	color: var(--ip-muted);
}
.ip-billing-pay__line { display: flex; justify-content: space-between; gap: 12px; }
.ip-billing-pay__line strong { color: var(--ip-ink); font-weight: 600; }
.ip-billing-pay__total {
	padding-top: 10px;
	border-top: 1px solid var(--ip-border-soft);
	font-size: var(--ip-font-sm);
	color: var(--ip-ink);
}
.ip-billing-pay__foot {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 12px;
	flex-wrap: wrap;
	padding: 12px 22px;
	border-top: 1px solid var(--ip-border-soft);
	background: var(--ip-surface-sunk);
	font-size: var(--ip-font-xs);
	color: var(--ip-muted);
}

.ip-field__error { font-size: var(--ip-font-xs); color: var(--ip-danger); }

/* A Stripe Element standing in for .ip-input. The frame inside draws the text
   (its colours are handed over from these tokens in subscription.js); the box,
   and its focus and error states, are ours — Stripe sets these classes on the
   div it mounts into. The height matches a text input's line so the name field
   and the card fields sit as one set. */
.ip-input--stripe { min-height: 40px; }
.ip-input--stripe.StripeElement--focus {
	border-color: var(--ip-teal);
	box-shadow: 0 0 0 3px var(--ip-mint);
}
.ip-input--stripe.StripeElement--invalid,
.ip-input.is-invalid { border-color: var(--ip-danger); }

.ip-billing-notes {
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
	gap: 20px;
}
.ip-billing-note__title { font-size: var(--ip-font-sm); font-weight: 600; }
.ip-billing-note__body { margin-top: 6px; font-size: var(--ip-font-xs); line-height: 1.55; color: var(--ip-muted); }
.ip-billing-foot { font-size: var(--ip-font-xs); line-height: 1.55; color: var(--ip-faint); }

/* Wide enough for each card to put its features beside the price rather than
   under it, which is what stops two cards across 1600px reading as banners. */
@container billing (min-width: 1180px) {
	.ip-tier {
		grid-row: auto;
		grid-template-rows: none;
		grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
		column-gap: 28px;
	}
	.ip-tier__features {
		padding: 2px 0 0 24px;
		border-top: 0;
		border-left: 1px solid var(--ip-border-soft);
	}
}

@container billing (max-width: 620px) {
	.ip-tiers { grid-template-columns: minmax(0, 1fr); }
	.ip-billing-pay { grid-template-columns: minmax(0, 1fr); padding: 18px; }
	.ip-billing-pay__summary { order: -1; }
	.ip-billing-facts { grid-template-columns: minmax(0, 1fr); }
	.ip-billing-fact + .ip-billing-fact { border-left: 0; border-top: 1px solid var(--ip-border-soft); }
}

/* -----------------------------------------------------------------------------
   Settings

   The frame every /settings page sits in (SettingsLayout.php): the settings
   menu down the left and the page beside it. A menu of its own rather than
   entries in the sidebar, because the sidebar is for the work and setup
   visited once a term only crowded it.
   -------------------------------------------------------------------------- */

.ip-settings {
	display: grid;
	grid-template-columns: 210px minmax(0, 1fr);
	gap: 28px;
	align-items: start;
}

/* The search and the menu under it. Sticky, so the way to the next section —
   and which one you are in — does not scroll away with the content. It rests
   where everything else that sticks does: at 12px it slid up under the top bar. */
.ip-settings__menu {
	position: sticky;
	top: var(--ip-stick-top);
	display: flex;
	flex-direction: column;
	gap: 16px;
}
/* The whole width of the menu: in a toolbar the box shares a row and takes a
   flex share of it, here it has the row to itself. */
.ip-settings__search .ip-search { display: block; width: 100%; min-width: 0; }
.ip-settings__nav {
	display: flex;
	flex-direction: column;
	gap: 18px;
}

/* A setting found inside a section by the search, listed under its section's
   entry and indented to the entry's label, past its icon. */
.ip-settings__hit {
	display: block;
	padding: 5px 10px 5px 35px;
	border-radius: 8px;
	font-size: var(--ip-font-sm);
	color: var(--ip-muted);
	text-decoration: none;
}
.ip-settings__hit:hover { color: var(--ip-ink); }
/* The result the arrow keys are on — the hover look, since it is the same
   "this one" without a pointer. */
.ip-settings__link.is-cursor,
.ip-settings__hit.is-cursor { background: var(--ip-veil-hover); color: var(--ip-ink); }
.ip-settings__none { padding: 2px 10px; font-size: var(--ip-font-sm); color: var(--ip-faint); }

/* The setting a search result led to, picked out for a moment so the eye
   finds it in a section of several. An inset shadow rather than a background:
   it paints over whatever the element's own fill is and leaves it as it was. */
.ip-settings__found { animation: ip-settings-found 1.8s ease-out; }
@keyframes ip-settings-found {
	from { box-shadow: inset 0 0 0 100vmax var(--ip-mint); }
	to   { box-shadow: inset 0 0 0 100vmax transparent; }
}
.ip-settings__group { display: flex; flex-direction: column; gap: 2px; }
.ip-settings__group-label {
	padding: 0 10px 6px;
	font-size: var(--ip-font-xs);
	font-weight: 600;
	color: var(--ip-faint);
}
.ip-settings__link {
	display: flex;
	align-items: center;
	gap: 9px;
	padding: 7px 10px;
	border-radius: 8px;
	font-size: var(--ip-font-base);
	color: var(--ip-nav-fg);
	text-decoration: none;
}
.ip-settings__link:hover { color: var(--ip-ink); }
.ip-settings__link .bi { font-size: 1.05em; color: var(--ip-muted); }
.ip-settings__link.is-active { background: var(--ip-mint); color: var(--ip-teal); font-weight: 600; }
.ip-settings__link.is-active .bi { color: var(--ip-teal); }

/* -----------------------------------------------------------------------------
   Plan gate — a feature the plan leaves out (PlanGate.php, planLock())
   -------------------------------------------------------------------------- */

/* After the menus on purpose: their icon rules match the lock's glyph too,
   and would turn it grey, or teal on the page you are on. */
/* The upgrade sparkle, in the tag's own text size rather than a pixel value, and
   centred on its box: Bootstrap Icons otherwise lower the glyph to a text
   baseline, which in a pill reads as off-centre. */
.ip-plan-lock > .bi { display: inline-flex; align-items: center; justify-content: center; line-height: 1; font-size: 1em; color: inherit; margin: 0; }
.ip-plan-lock > .bi::before { vertical-align: 0; }

/* The icon-only form, at the end of a menu link. Round, so it reads as a mark
   on the link rather than a second, smaller label. */
.ip-plan-lock--icon {
	justify-content: center;
	width: 20px;
	height: 20px;
	padding: 0;
	margin-left: auto;
	flex: 0 0 20px;
}

/* The page under the cover: its own markup and skeletons, blurred and faded
   back, so it reads as the feature being there but out of reach. Tall enough
   to hold the card whatever the page's own height. */
.ip-gate { position: relative; min-height: 460px; }
.ip-gate__behind {
	filter: blur(5px);
	opacity: 0.55;
	pointer-events: none;
	user-select: none;
}
.ip-gate__cover {
	position: absolute;
	inset: 0;
	display: flex;
	justify-content: center;
	align-items: flex-start;
	padding: clamp(40px, 12vh, 120px) 16px 16px;
}
.ip-gate__card {
	width: 100%;
	max-width: 400px;
	padding: 28px 28px 24px;
	text-align: center;
	display: flex;
	flex-direction: column;
	align-items: center;
	gap: 10px;
}
.ip-gate__icon {
	width: 44px;
	height: 44px;
	border-radius: 999px;
	display: grid;
	place-items: center;
	background: var(--ip-violet-bg);
	color: var(--ip-violet);
	font-size: var(--ip-font-h3);
	margin-bottom: 4px;
}
/* Icons in fixed-size boxes — checks, close buttons, thumbnails, toggles, step
   dots. Bootstrap Icons lower every glyph by .125em to sit on a text baseline,
   and inherit the page's 1.5 line-height; in a box that is centred by flex or
   grid, both push the glyph off the middle. One rule for all of them rather than
   a fix per component, so the next box gets it by adding itself here. */
.ip-award__mark > .bi,
.ip-check > .bi,
.ip-flyout__close > .bi,
.ip-link-pick__mark > .bi,
.ip-section__mark > .bi,
.ip-setting__thumb > .bi,
.ip-theme-toggle > .bi,
.ip-type__glyph > .bi,
.ip-file-thumb > .bi,
.fm-tile-mark > .bi,
.fm-tile-toggle > .bi,
.gal-icon-btn > .bi,
.ip-avatar > .bi,
.ip-progress__dot > .bi,
.ip-step__dot > .bi,
.ip-section-toggle > .bi,
.ip-section__toggle > .bi,
.app-topbar__menu > .bi,
.sidebar-foot-btn > .bi,
.ip-seg__btn > .bi {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	line-height: 1;
}
.ip-award__mark > .bi::before,
.ip-check > .bi::before,
.ip-flyout__close > .bi::before,
.ip-link-pick__mark > .bi::before,
.ip-section__mark > .bi::before,
.ip-setting__thumb > .bi::before,
.ip-theme-toggle > .bi::before,
.ip-type__glyph > .bi::before,
.ip-file-thumb > .bi::before,
.fm-tile-mark > .bi::before,
.fm-tile-toggle > .bi::before,
.gal-icon-btn > .bi::before,
.ip-avatar > .bi::before,
.ip-progress__dot > .bi::before,
.ip-step__dot > .bi::before,
.ip-section-toggle > .bi::before,
.ip-section__toggle > .bi::before,
.app-topbar__menu > .bi::before,
.sidebar-foot-btn > .bi::before,
.ip-seg__btn > .bi::before {
	vertical-align: 0;
}

.bi[hidden] { display: none !important; }

/* Bootstrap Icons lower their glyph by .125em to sit on a text baseline, which in
   a circle just reads as off-centre. Remove the shift and the line-height slack. */
.ip-gate__icon > .bi { display: flex; line-height: 1; }
.ip-gate__icon > .bi::before { vertical-align: 0; }
.ip-gate__title { margin: 0; font-size: var(--ip-font-h2); font-weight: 600; letter-spacing: -0.01em; }
.ip-gate__body { margin: 0 0 6px; font-size: var(--ip-font-sm); line-height: 1.5; color: var(--ip-muted); text-wrap: pretty; }

/* Every section on one page (templates/settings/page.php). The gap between
   sections is several times the gap inside one, so where one setting ends and
   the next begins reads from the spacing alone — a rule between them would be
   one more line on a page that is mostly lines. */
.ip-settings__body { display: flex; flex-direction: column; gap: 56px; min-width: 0; }
.ip-settings__section {
	display: flex;
	flex-direction: column;
	gap: 16px;
	/* Where a jump from the menu puts the heading: clear of the top bar, at the
	   line everything sticky rests on. settings.js reads it back for the spy. */
	scroll-margin-top: var(--ip-stick-top);
}
/* Every section runs the full width of the column, like the rest of the app.
   No section sets a width of its own: five once capped themselves at 940px and
   three did not, so they stopped at different edges down the one page. */
.ip-settings__head { display: flex; flex-direction: column; gap: 4px; }
.ip-settings__title { margin: 0; font-size: var(--ip-font-h2); font-weight: 600; letter-spacing: -0.01em; }
.ip-settings__summary { font-size: var(--ip-font-sm); line-height: 1.5; color: var(--ip-muted); text-wrap: pretty; }

/* A setting, one per row: what it is on the left (with a line of explanation
   when it needs one), what it is set to on the right, and the button that
   changes it at the far edge — so a card of them reads down two straight
   columns rather than as a form. Worn inside an .ip-card--flush, under an
   .ip-card__head. The value is right-aligned against its button so the eye
   goes from the setting's name straight across to what to do about it. */
.ip-setting {
	display: flex;
	align-items: center;
	flex-wrap: wrap;
	gap: 8px 16px;
	padding: 14px 18px;
	min-height: 64px;
}
.ip-setting + .ip-setting { border-top: 1px solid var(--ip-border-soft); }
.ip-setting__main { flex: 1 1 180px; min-width: 0; }
.ip-setting__label { font-size: var(--ip-font-sm); font-weight: 600; color: var(--ip-ink); }
.ip-setting__hint {
	font-size: var(--ip-font-xs);
	line-height: 1.5;
	color: var(--ip-muted);
	margin-top: 3px;
	max-width: 60ch;
	text-wrap: pretty;
}
.ip-setting__value {
	display: flex;
	align-items: center;
	justify-content: flex-end;
	flex-wrap: wrap;
	gap: 6px;
	min-width: 0;
	max-width: 100%;
	font-size: var(--ip-font-sm);
	text-align: right;
	overflow-wrap: anywhere;
}
.ip-setting__value code { font-family: var(--ip-mono); font-variant-numeric: tabular-nums; font-size: var(--ip-font-sm); color: inherit; }
.ip-setting__actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
/* A value of more than one line — the live domain, and the one being
   connected under it — each line right-aligned on its own. */
.ip-setting__line {
	display: flex;
	align-items: center;
	justify-content: flex-end;
	flex-wrap: wrap;
	gap: 6px;
	width: 100%;
}
/* An image as the value — the workspace logo — kept small enough that its
   row is no taller than the rest. Still clickable, like the button beside it. */
.ip-setting__thumb {
	width: 44px;
	height: 44px;
	border: 1px solid var(--ip-border);
	border-radius: 10px;
	background: var(--ip-surface-sunk);
	display: grid;
	place-items: center;
	overflow: hidden;
	cursor: pointer;
	color: var(--ip-faint);
}
.ip-setting__thumb img { width: 100%; height: 100%; object-fit: contain; background: var(--ip-logo-ground); }

/* Below the width where the sidebar folds away, the menu becomes one row of
   tabs above the page: a 210px rail beside a phone-width page leaves the page
   nothing. The group labels go — the tabs read in order without them. */
@media (max-width: 900px) {
	.ip-settings {
		grid-template-columns: minmax(0, 1fr);
		gap: 18px;
		/* The height of the search and tab row above the page, which a
		   section's jump has to clear as well as the bar. settings.js keeps it
		   measured; this is the shape before it has. */
		--ip-settings-tabs: 100px;
	}
	.ip-settings__menu {
		/* Stuck straight under the bar rather than at the 16px gap below it the
		   rest of the app rests at: the block is the page's own heading strip
		   while it scrolls, and a gap would show the sections passing through
		   it. */
		top: var(--ip-topbar-h, 62px);
		z-index: 2;
		gap: 8px;
		padding-top: 8px;
		border-bottom: 1px solid var(--ip-border-soft);
		background-color: var(--ip-canvas);
	}
	.ip-settings__nav {
		/* Positioned, which the plan lock's hidden label needs — with no
		   positioned ancestor inside this scroller it measured from the page
		   instead, and scrolled 640px sideways into nothing. */
		position: relative;
		flex-direction: row;
		gap: 2px;
		overflow-x: auto;
		padding-bottom: 6px;
		scrollbar-width: thin;
	}
	/* A found setting is one more pill in the row, beside its section's tab. */
	.ip-settings__hit { padding: 7px 10px; white-space: nowrap; }
	.ip-settings__none { padding: 7px 10px; white-space: nowrap; }
	/* The page's own grid behind the block in the dark theme, pinned to the
	   viewport as the page's is, so it reads as page and not as a patch. */
	:root[data-theme="dark"] .ip-settings__menu {
		background-image:
			linear-gradient(var(--ip-texture-line) 1px, transparent 1px),
			linear-gradient(90deg, var(--ip-texture-line) 1px, transparent 1px);
		background-size: 50px 50px;
		background-attachment: fixed;
	}
	.ip-settings__group { flex-direction: row; gap: 2px; }
	.ip-settings__group-label { display: none; }
	.ip-settings__link { white-space: nowrap; }
	.ip-settings__section { scroll-margin-top: calc(var(--ip-topbar-h, 62px) + var(--ip-settings-tabs) + 16px); }
}

/* -----------------------------------------------------------------------------
   FullCalendar

   The widget exposes its own custom properties, so theming it is a matter of
   pointing those at ours rather than overriding its rules — which means it
   follows the light/dark switch for free.
   -------------------------------------------------------------------------- */

.fc {
	--fc-page-bg-color: transparent;
	--fc-border-color: var(--ip-border-soft);
	--fc-neutral-bg-color: var(--ip-surface-sunk);
	--fc-today-bg-color: var(--ip-mint);
	--fc-button-bg-color: var(--ip-surface);
	--fc-button-border-color: var(--ip-border-input);
	--fc-button-text-color: var(--ip-ink);
	--fc-button-hover-bg-color: var(--ip-rule);
	--fc-button-hover-border-color: var(--ip-border-input);
	--fc-button-active-bg-color: var(--ip-teal);
	--fc-button-active-border-color: var(--ip-teal);
	--fc-list-event-hover-bg-color: var(--ip-surface-sunk);

	font-family: var(--ip-sans);
	color: var(--ip-ink);
}

.fc .fc-toolbar-title { font-size: var(--ip-font-h2); font-weight: 600; letter-spacing: -0.01em; }
.fc .fc-button {
	border-radius: 8px;
	font-size: var(--ip-font-sm);
	font-weight: 500;
	padding: 6px 11px;
	box-shadow: none;
	text-transform: capitalize;
}
.fc .fc-button:focus { box-shadow: 0 0 0 3px var(--ip-mint); }
.fc .fc-button-primary:not(:disabled).fc-button-active { color: var(--ip-on-accent); }

.fc .fc-col-header-cell-cushion {
	font-size: var(--ip-font-xs);
	font-weight: 600;
	letter-spacing: 0.05em;
	text-transform: uppercase;
	color: var(--ip-faint);
	padding: 8px 4px;
	text-decoration: none;
}
.fc .fc-daygrid-day-number { font-size: var(--ip-font-xs); color: var(--ip-muted); text-decoration: none; }
.fc .fc-daygrid-day.fc-day-today .fc-daygrid-day-number { color: var(--ip-teal); font-weight: 600; }

.fc .fc-event {
	border-radius: 6px;
	border-width: 0 0 0 2px;
	font-size: var(--ip-font-xs);
	font-weight: 500;
	padding: 2px 6px;
	cursor: pointer;
}
.fc .fc-daygrid-more-link { font-size: var(--ip-font-xs); color: var(--ip-teal); }
.fc .fc-list-empty { background: transparent; color: var(--ip-faint); }
.fc-theme-standard .fc-list-day-cushion { background: var(--ip-surface-sunk); }

/* -----------------------------------------------------------------------------
   Suggestion panels — form-modal's lookup and suggest fields

   Without `position: fixed` here, every lookup dropdown in the app — teams,
   members, places, roles — ignored the coordinates JS had set on it and laid
   out in normal flow at the bottom of <body>, usually below the fold. The
   search was running and the rows existed; nobody could see them.
   -------------------------------------------------------------------------- */

/* The rest of form-modal's own pieces. The dialog around them is still a
   Bootstrap .modal for its behaviour — focus trap, static backdrop, stacking
   over the entry flyout — and drawn through the bridge's .modal-* rules to
   match .ip-modal; everything inside it is design-system markup. */
.fm-field > .ip-field__label,
.fm-field .col-12 > .ip-field__label,
[class*="col-"] > .ip-field__label { display: block; margin-bottom: 6px; }
.fm-field > .ip-field__hint { margin-top: 6px; }
.fm-sublabel { font-weight: 500; color: var(--ip-muted); margin: 8px 0 4px; display: block; }
.fm-static { font-size: var(--ip-font-sm); color: var(--ip-muted); padding: 10px 0; }
.fm-subform { padding: 14px; }
.fm-subform__title { font-size: var(--ip-font-sm); font-weight: 600; }
.fm-checklist { border: 1px solid var(--ip-border-input); border-radius: 9px; padding: 8px; }
.input-group > .ip-input { flex: 1 1 auto; width: 1%; min-width: 0; }

.fm-suggest { position: relative; }

/* Appended to <body> so a modal's overflow cannot clip it, positioned in JS
   against the input's viewport rect. The z-index clears Bootstrap's modal,
   which sits at 1055. */
.fm-suggest-panel {
	position: fixed;
	z-index: 1060;
	display: flex;
	flex-direction: column;
	overflow: hidden;
	border: 1px solid var(--ip-border);
	border-radius: 10px;
	/* Opaque, not translucent: this sits over form fields, and the text behind
	   it would otherwise show through the rows. */
	background: var(--ip-surface);
	box-shadow: var(--ip-shadow-pop);
}

/* JS sets the height in whole rows, so nothing is ever cut mid-row. */
.fm-suggest-list { overflow-y: auto; overscroll-behavior: contain; }

.fm-suggest-option {
	display: flex;
	align-items: baseline;
	gap: 8px;
	width: 100%;
	text-align: left;
	background: transparent;
	color: var(--ip-ink);
	border: 0 solid var(--ip-rule);
	border-bottom-width: 1px;
	padding: 9px 12px;
	font: inherit;
	cursor: pointer;
}
.fm-suggest-list .fm-suggest-option:last-child { border-bottom-width: 0; }

/* The name stays readable in full; the sub-label is what gives way. Both are
   held to one line so every row is the same height — which is what lets the
   panel end on a row boundary. */
.fm-suggest-main { flex: 0 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fm-suggest-sub {
	flex: 1 1 auto;
	min-width: 0;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
	font-size: var(--ip-font-xs);
	color: var(--ip-muted);
}

/* Keyboard and pointer highlight are the same thing here — `.active` is set by
   the arrow keys, and hover has to match it or the two disagree on screen. */
.fm-suggest-option:hover,
.fm-suggest-option.active { background-image: linear-gradient(var(--ip-veil-hover), var(--ip-veil-hover)); color: var(--ip-ink); }
.fm-suggest-option.active { box-shadow: inset 2px 0 0 var(--ip-teal); }

.fm-suggest-option[disabled],
.fm-suggest-option.is-disabled { opacity: 0.55; cursor: default; }

/* Required credit for the data provider — see places.js. */
.fm-suggest-credit {
	flex: 0 0 auto;
	padding: 6px 12px;
	border-top: 1px solid var(--ip-border-soft);
	font-size: var(--ip-font-xs);
	color: var(--ip-faint);
	text-align: right;
}
.fm-suggest-credit a { color: inherit; text-decoration: none; }
.fm-suggest-credit a:hover { text-decoration: underline; }

/* -----------------------------------------------------------------------------
   Placeholders, file tiles, the crop window and the gallery

   Styles the JavaScript depends on — without them, loading placeholders render
   as empty spans, file tiles as unstyled boxes, and the gallery overlay as a
   stack of unpositioned divs.

   The rgba(0,0,0,…) veils are left as they are on purpose — they sit over
   photographs, where a token that follows the theme would stop doing its job
   in light mode.
   -------------------------------------------------------------------------- */

/* || IMAGE CROP (form-modal "crop" field) */
/* The window is square, so the 1:1 ratio is a property of the layout rather than
   something the user can drag out of shape. */
.fm-crop-viewport {
	position:relative;
	width:100%;
	max-width:18rem;
	aspect-ratio:1 / 1;
	margin-inline:auto;
	overflow:hidden;
	border-radius:12px;
	border:1px solid var(--ip-border);
	background-color:var(--ip-surface-sunk);
	cursor:grab;
	touch-action:none;      /* panning is ours, not the browser's */
	user-select:none;
}
.fm-crop-viewport:active {cursor:grabbing;}
.fm-crop-viewport:focus-visible {outline:2px solid var(--ip-teal); outline-offset:2px;}
.fm-crop-viewport img {
	position:absolute;
	max-width:none;         /* undo the global img fluid rule, or panning fights it */
	user-select:none;
	-webkit-user-drag:none;
}

/* || LOADING PLACEHOLDERS */
/* A shimmer stand-in for a value that has not arrived yet. Give it a width so
   the layout does not jump when the real text lands on top of it. */
.skeleton {
	display:inline-block;
	min-width:3rem;
	width:8rem;
	height:1em;
	vertical-align:middle;
	border-radius:6px;
	background-color:var(--ip-skeleton-base);
	background-image:linear-gradient(90deg,
		transparent 0%,
		var(--ip-skeleton-sheen) 50%,
		transparent 100%);
	background-size:200% 100%;
	background-repeat:no-repeat;
	animation:skeleton-shimmer 1.5s ease-in-out infinite;
}
/* Handing a frame over to the picture that was loading into it.
 *
 * The shimmer fades out while the image fades in, so the two cross rather than
 * the shimmer being switched off and the image starting from nothing. All three
 * of these used `display:none` and got a visible flick of empty frame between
 * the two, because `display` is not a transitionable property and the swap
 * therefore happened in one frame however long the image's own fade was.
 *
 * `visibility` is what finally takes the placeholder out of play, delayed by
 * exactly the fade so it lands on the last frame of it. That delay is the whole
 * trick and it is why this is not simply `opacity: 0`: these three all warn, in
 * their own comments, that the shimmer must end up *gone* rather than covered,
 * since a PNG with transparency would otherwise let it carry on showing through
 * the picture. A placeholder left at zero opacity is still painted, still
 * composited, and still running its shimmer keyframes on every loaded thumbnail
 * on the screen. */
.gal-thumb .skeleton,
.fm-tile .skeleton,
.ip-file-thumb .skeleton,
.ip-evidence__preview .skeleton {
	transition:opacity 0.2s ease, visibility 0s linear 0.2s;
}
.gal-thumb-ready .skeleton,
.fm-tile-ready .skeleton,
.ip-file-thumb.is-ready .skeleton,
.ip-file-thumb.is-failed .skeleton,
.ip-evidence__preview.is-ready .skeleton,
.ip-evidence__preview.is-failed .skeleton {
	opacity:0;
	visibility:hidden;
}

.skeleton-block {display:block;}
.skeleton-circle {border-radius:50%; width:1em;}
/* Inside a table cell the width is a percentage of the column, so the floor has
   to be low enough not to blow out narrow columns. */
.skeleton-cell {min-width:1.25rem;}
/* Fills an image frame — logo box, avatar, thumbnail — so the placeholder is the
   shape of the thing that is arriving. */
.skeleton-image {width:100%; height:100%; min-width:0; border-radius:inherit;}
/* Placeholder rows should not react to the row hover/pointer styling that real
   rows carry — there is nothing to click yet. */
.skeleton-row td {cursor:default;}

/* Shaped placeholders — loading.js draws them, page-skeletons.js lays whole
 * pages out of them. Each borrows the metrics of the component it stands in
 * for (a row is an .ip-table__row's padding, a tile is an .ip-kpi), so the
 * real thing lands on top of it without the layout moving. Colour comes only
 * from .skeleton, which already answers to both themes. */
.sk-soft   { opacity: 0.6; }
.sk-gap    { margin-top: 8px; }
.sk-gap-lg { margin-top: 22px; }
.sk-grow   { flex: 1 1 auto; }
.sk-stack  { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.sk-line   { display: flex; align-items: center; gap: 10px; min-width: 0; }
.sk-between { justify-content: space-between; }
.sk-lines  { gap: 9px; }
.sk-list   { gap: 14px; }
.sk-kv     { gap: 11px; }
.sk-detail { gap: 20px; }
.sk-doc    { gap: 10px; }

.sk-table__head,
.sk-table__row {
	display: grid;
	gap: 12px;
	align-items: center;
	padding: 12px 18px;
}
.sk-table__head {
	padding-top: 13px;
	padding-bottom: 13px;
	background: var(--ip-surface-sunk);
	border-bottom: 1px solid var(--ip-border-soft);
}
.sk-table__row { min-height: 56px; border-bottom: 1px solid var(--ip-rule); }
.sk-table__row:last-child { border-bottom: 0; }

.sk-card { display: flex; flex-direction: column; padding: 16px; }

/* Plans & billing: the real overview sits in a flush card and pads itself, and a
   real plan is a 24px-padded tier, so the placeholders take the same insets —
   otherwise the bars touch the card's edge and the page grows when data lands. */
#billing-overview > .sk-lines { padding: 20px 22px; gap: 12px; }
.ip-tiers > .sk-card { padding: 24px; min-height: 230px; border-radius: 14px; }
.ip-tiers > .sk-card .sk-gap { margin-top: 18px; }
.skeleton.sk-tile__frame { width: 100%; height: auto; aspect-ratio: 4 / 3; border-radius: 0; }
.sk-tile__caption { padding: 12px 14px 14px; }

/* No 3rem floor: a column is narrower than that on a card of this width, and
   the bars would overflow into each other. */
.skeleton.sk-bar { width: 100%; min-width: 0; min-height: 4px; border-radius: 5px 5px 2px 2px; }
.skeleton.sk-chip   { display: inline-block; height: 26px; border-radius: 999px; }
.skeleton.sk-pill   { border-radius: 999px; }
.skeleton.sk-seg    { display: inline-block; height: 30px; border-radius: 9px; }
.skeleton.sk-input  { display: block; flex: 1 1 auto; height: 34px; border-radius: 8px; }
.skeleton.sk-button { display: inline-block; height: 32px; border-radius: 8px; }

.sk-heat {
	display: grid;
	grid-template-columns: repeat(5, minmax(0, 1fr));
	gap: 6px;
	margin-top: 16px;
}
.sk-heat .skeleton { display: block; width: auto; height: 44px; border-radius: 6px; }

.sk-cal__grid {
	display: grid;
	grid-template-columns: repeat(7, minmax(0, 1fr));
	margin-top: 16px;
	border-top: 1px solid var(--ip-border-soft);
	border-left: 1px solid var(--ip-border-soft);
}
.sk-cal__cell {
	display: flex;
	flex-direction: column;
	align-items: flex-end;
	gap: 6px;
	min-height: 92px;
	padding: 6px;
	border-right: 1px solid var(--ip-border-soft);
	border-bottom: 1px solid var(--ip-border-soft);
}

.sk-podium {
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
	gap: 14px;
}
.sk-steps {
	display: flex;
	gap: 22px;
	padding: 16px 24px;
	overflow: hidden;
	border-bottom: 1px solid var(--ip-border-soft);
}
.sk-fields {
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
	gap: 16px;
}
.sk-settings-link { padding: 7px 10px; }

@keyframes skeleton-shimmer {
	0%   {background-position:-100% 0;}
	100% {background-position: 200% 0;}
}

/* A travelling gradient is exactly the kind of thing motion sensitivity reacts
   to — keep the placeholder, drop the movement. Bootstrap already slows its own
   spinners under this query. */
@media (prefers-reduced-motion: reduce) {
	.skeleton { animation: none; background-image: none; }
}

.fm-tiles {display:grid; gap:8px;}
.fm-tile {
	position:relative;
	aspect-ratio:1 / 1;
	overflow:hidden;
	border-radius:10px;
	border:1px solid var(--ip-border);
	background-color:var(--ip-surface-sunk);
}
.fm-tile img {
	position:absolute;
	inset:0;
	width:100%;
	height:100%;
	object-fit:cover;      /* square tiles, whatever shape the photo is */
	display:block;
	/* Held back until the bytes are there, so the skeleton underneath is what shows
	   rather than a half-painted picture. */
	opacity:0;
	transition:opacity 0.2s ease;
	user-select:none;
	-webkit-user-drag:none;
}
.fm-tile-ready img {opacity:1;}
/* The skeleton's own fade-out, and why it ends up gone rather than merely
   transparent, is with the `.skeleton` rules above — one place for all three
   frames that use it. */
/* The whole tile is the button. It sits over the picture rather than around it so
   the focus ring traces the tile's own corners. */
.fm-tile-open {
	position:absolute;
	inset:0;
	display:flex;
	flex-direction:column;
	align-items:center;
	justify-content:center;
	gap:0.15rem;
	padding:0;
	border:0;
	border-radius:inherit;
	background-color:transparent;
	color:#fff;
	cursor:pointer;
}
.fm-tile-open:disabled {cursor:default;}
.fm-tile-open:focus-visible {outline:2px solid var(--ip-teal); outline-offset:-2px;}
.fm-tile-open:hover:not(:disabled) {background-color:rgba(0,0,0,0.25);}
/* A document in a picture field: nothing to show but what kind of thing it is. */
.fm-tile-doc {
	display:flex;
	align-items:center;
	justify-content:center;
	height:100%;
	font-size:var(--ip-font-h1);
	color:var(--ip-muted);
}
/* Corner controls sit above the tile's own button, or the click that removes a
   picture would open it instead. */
.fm-tile-toggle {
	position:absolute;
	top:4px;
	right:4px;
	z-index:2;
	display:flex;
	align-items:center;
	justify-content:center;
	width:22px;
	height:22px;
	padding:0;
	border:0;
	border-radius:50%;
	background-color:rgba(0,0,0,0.6);
	color:#fff;
	font-size: var(--ip-font-sm);
	line-height:1;
	opacity:0;
	transition:opacity 0.12s ease, background-color 0.12s ease;
}
/* Hidden until wanted on a pointer, but a touch screen has no hover to reveal it
   with and a keyboard has to be able to reach it. */
.fm-tile:hover .fm-tile-toggle,
.fm-tile-toggle:focus-visible,
.fm-tile-removed .fm-tile-toggle {opacity:1;}
@media (hover: none) {
	.fm-tile-toggle {opacity:1;}
}
.fm-tile-toggle:hover {background-color:var(--ip-danger);}
.fm-tile-removed .fm-tile-toggle:hover {background-color:var(--ip-blue);}
.fm-tile-toggle:focus-visible {outline:2px solid #fff; outline-offset:1px;}
/* Struck off, but still there to be put back — the list presentation says the same
   thing with a line through the name. Specific enough to win against the rule that
   reveals a loaded picture, which would otherwise put it back to full strength. */
.fm-tile-ready.fm-tile-removed img {opacity:0.25; filter:grayscale(1);}
.fm-tile-removed {border-style:dashed;}
.fm-tile-flag {
	position:absolute;
	bottom:4px;
	left:4px;
	padding:0.05rem 0.3rem;
	border-radius:5px;
	background-color:var(--ip-teal);
	color:#fff;
	font-size: var(--ip-font-xs);
	line-height:1.4;
}
/* The overflow tile. The picture behind it is dimmed hard enough that the count
   reads cleanly over anything, however bright the photo. */
.fm-tile-ready.fm-tile-more img {opacity:0.45; filter:brightness(0.5);}
.fm-tile-more .fm-tile-open {background-color:rgba(0,0,0,0.35);}
.fm-tile-more .fm-tile-open:hover {background-color:rgba(0,0,0,0.15);}
.fm-tile-more-count {font-size: var(--ip-font-sm); font-weight:600;}
.fm-tile-more-icon {font-size:var(--ip-font-h3);}

/* Upload progress, drawn over each picture as it goes up. Over, not instead of: a
   bar that cannot say which photo it belongs to is not worth the room it takes, and
   the whole point of showing this per file is being able to see which is which. */
.fm-tile-progress {
	position:absolute;
	inset:0;
	z-index:3;
	display:flex;
	align-items:center;
	justify-content:center;
	background-color:rgba(0,0,0,0.55);
	/* Invisible to the pointer, so the tile's own button underneath still works —
	   looking at a photo while it uploads costs nothing. */
	pointer-events:none;
	transition:background-color 0.2s ease;
}
/* Stored: out of the way, since the picture is the news now and not the ring. */
.fm-tile-progress[data-fm-state="done"] {background-color:rgba(0,0,0,0.15);}
/* A file already on its way cannot be called back, so the button that would try
   to goes away for as long as it is going. */
.fm-tile-uploading .fm-tile-toggle {display:none;}

/* Registered so it can be transitioned: an unregistered custom property is just a
   token to the engine, and stepping between two conic gradients is not something it
   would know how to do. Progress events arrive in whatever bursts the network gives
   them, and this is what turns those into something that moves evenly. */
@property --fm-progress {
	syntax:'<number>';
	inherits:false;
	initial-value:0;
}
/* The ring: a full disc of two colours with the middle covered over. */
.fm-tile-ring {
	--fm-progress:0;
	display:flex;
	align-items:center;
	justify-content:center;
	width:46px;
	height:46px;
	border-radius:50%;
	background:conic-gradient(
		var(--ip-teal) calc(var(--fm-progress) * 1%),
		rgba(255,255,255,0.25) 0
	);
	transition:--fm-progress 0.2s linear, background-color 0.2s ease;
}
.fm-tile-mark {
	display:flex;
	align-items:center;
	justify-content:center;
	width:36px;
	height:36px;
	border-radius:50%;
	background-color:rgba(0,0,0,0.82);
	color:#fff;
	font-size: var(--ip-font-xs);
	font-weight:600;
	line-height:1;
	/* Digits of one width, or the number jitters as it counts up. */
	font-variant-numeric:tabular-nums;
}
.fm-tile-mark .bi {font-size:var(--ip-font-base);}
.fm-tile-mark .spinner-border {width:1rem; height:1rem; border-width:2px;}
/* Finished, one way or the other: the ring is whole, and its colour carries which. */
.fm-tile-progress[data-fm-state="done"] .fm-tile-ring {background:var(--ip-teal);}
.fm-tile-progress[data-fm-state="done"] .fm-tile-mark {color:var(--ip-teal);}
.fm-tile-progress[data-fm-state="failed"] .fm-tile-ring {background:var(--ip-danger);}
.fm-tile-progress[data-fm-state="failed"] .fm-tile-mark {color:var(--ip-danger);}
.fm-tile-progress[data-fm-state="queued"] .fm-tile-mark {color:rgba(255,255,255,0.55);}

/* The overflow tile gets a bar along the bottom instead of a ring: the count it
   already carries sits exactly where the ring would go, and the count is the more
   useful of the two. It covers every file behind the tile at once. */
.fm-tile-bar {
	position:absolute;
	right:8px;
	bottom:8px;
	left:8px;
	z-index:3;
	height:4px;
	overflow:hidden;
	border-radius:2px;
	background-color:rgba(255,255,255,0.25);
	pointer-events:none;
}

/* The same bar for the list presentation, where documents are named rather than
   shown and there is no tile to draw over. */
.fm-file-bar {
	display:inline-block;
	width:5rem;
	height:4px;
	overflow:hidden;
	border-radius:2px;
	background-color:rgba(255,255,255,0.2);
	vertical-align:middle;
}
.fm-tile-bar > span,
.fm-file-bar > span {
	display:block;
	width:0;
	height:100%;
	background-color:var(--ip-teal);
	transition:width 0.2s linear;
}
.fm-tile-bar[data-fm-state="done"] > span,
.fm-file-bar[data-fm-state="done"] > span {background-color:var(--ip-teal);}
.fm-tile-bar[data-fm-state="failed"] > span,
.fm-file-bar[data-fm-state="failed"] > span {background-color:var(--ip-danger);}

/* || IMAGE GALLERY (gallery.js) */
/* Fixed to the viewport, and mounted inside the open dialog so Bootstrap counts it
   as part of the modal for focus purposes — see openGallery. .modal-dialog carries
   a transform and would capture `fixed`; the .modal element does not. */
.gal-overlay {
	position:fixed;
	inset:0;
	z-index:1065;
	display:flex;
	flex-direction:column;
	background-color:rgba(0,0,0,0.92);
	opacity:0;
	transition:opacity 0.26s ease;
	overscroll-behavior:contain;
	touch-action:pan-y;
}
.gal-overlay.gal-open {opacity:1;}
.gal-overlay:focus {outline:none;}
.gal-bar {
	flex:0 0 auto;
	display:flex;
	align-items:center;
	gap:0.75rem;
	padding:0.75rem 1rem;
	color:rgba(255,255,255,0.85);
	font-size: var(--ip-font-sm);
}
.gal-count {flex:0 0 auto; font-variant-numeric:tabular-nums;}
/* The name gives way rather than the counter — one is a fixed few characters, the
   other is a filename of any length at all. */
.gal-name {
	flex:1 1 auto;
	min-width:0;
	white-space:nowrap;
	overflow:hidden;
	text-overflow:ellipsis;
	color:rgba(255,255,255,0.55);
}
.gal-icon-btn {
	display:flex;
	align-items:center;
	justify-content:center;
	padding:0;
	border:0;
	border-radius:50%;
	background-color:rgba(255,255,255,0.08);
	color:#fff;
	transition:background-color 0.12s ease;
}
.gal-icon-btn:hover {background-color:rgba(255,255,255,0.2);}
.gal-icon-btn:focus-visible {outline:2px solid #fff; outline-offset:2px;}
.gal-close, .gal-remove {flex:0 0 auto; width:34px; height:34px;}
.gal-remove:hover {background-color:var(--ip-danger);}
.gal-stage {
	flex:1 1 auto;
	display:flex;
	align-items:center;
	gap:0.5rem;
	padding:0 0.5rem;
	min-height:0;               /* or the frame refuses to shrink and the strip is pushed off */
}
.gal-nav {width:44px; height:44px; flex:0 0 auto; font-size: var(--ip-font-h2);}
.gal-frame {
	position:relative;
	flex:1 1 auto;
	height:100%;
	min-width:0;
	display:flex;
	align-items:center;
	justify-content:center;
}
/* `inset` with auto margins rather than leaning on the flex container to place it —
   the static position of an absolute child of a flexbox is not somewhere to be
   clever. */
.gal-layer {
	position:absolute;
	inset:0;
	margin:auto;
	max-width:100%;
	max-height:100%;
	object-fit:contain;
	border-radius:6px;
	opacity:0;
	transition:opacity 0.26s ease, transform 0.26s ease;
	user-select:none;
	-webkit-user-drag:none;
}
/* Enters offset towards the side it came from, then settles into place. Compounded
   with .gal-layer so the settled state cannot lose to the base rule on source order. */
.gal-layer.gal-from-right {transform:translateX(24px);}
.gal-layer.gal-from-left {transform:translateX(-24px);}
.gal-layer.gal-settled {opacity:1; transform:translateX(0);}
@media (prefers-reduced-motion: reduce) {
	.gal-layer.gal-from-right, .gal-layer.gal-from-left {transform:none;}
}
.gal-spinner {
	position:absolute;
	width:2rem;
	height:2rem;
	opacity:0;
	pointer-events:none;
	transition:opacity 0.15s ease;
}
.gal-spinner-on {opacity:0.7;}
/* Struck off, the same treatment its tile gets — and compounded with .gal-settled,
   which would otherwise put it back to full strength on source order alone. */
.gal-frame-removed .gal-layer.gal-settled {opacity:0.3; filter:grayscale(1);}
/* Says what the dimming means. "Removed" while the picture is plainly still on
   screen needs the explaining. */
.gal-flag {
	position:absolute;
	bottom:0.5rem;
	padding:0.15rem 0.6rem;
	border-radius:999px;
	background-color:var(--ip-danger);
	color:#fff;
	font-size: var(--ip-font-xs);
	line-height:1.5;
	opacity:0;
	transition:opacity 0.15s ease;
	pointer-events:none;
}
.gal-frame-removed .gal-flag {opacity:1;}
.gal-strip {
	flex:0 0 auto;
	display:flex;
	gap:6px;
	padding:0.75rem 1rem;
	overflow-x:auto;
	overscroll-behavior-x:contain;
	scrollbar-width:thin;
}
.gal-thumb {
	position:relative;           /* the picture sits over the skeleton, not after it */
	flex:0 0 auto;
	width:56px;
	height:56px;
	padding:0;
	overflow:hidden;
	border:2px solid transparent;
	border-radius:8px;
	background-color:rgba(255,255,255,0.06);
	opacity:0.5;
	transition:opacity 0.12s ease, border-color 0.12s ease;
}
.gal-thumb img {
	position:absolute;
	inset:0;
	width:100%;
	height:100%;
	object-fit:cover;
	display:block;
	/* Held back until the bytes are there, so the shimmer underneath is what shows
	   rather than a half-painted thumbnail. */
	opacity:0;
	transition:opacity 0.2s ease;
}
/* Block, or the inline-block placeholder sits on a baseline and leaves the
   thumbnail's own background showing as a strip beneath it. */
.gal-thumb .skeleton {display:block;}
.gal-thumb-ready img {opacity:1;}
.gal-thumb:hover {opacity:0.85;}
.gal-thumb-on {opacity:1; border-color:var(--ip-teal);}
/* Marked for removal, so the strip shows which without having to walk them. */
.gal-thumb-off img {filter:grayscale(1); opacity:0.4;}
.gal-thumb-off {border-color:var(--ip-danger);}
.gal-thumb:focus-visible {outline:2px solid #fff; outline-offset:2px;}
/* Narrow screens: the side arrows cost width the picture needs more, and a swipe
   does the same job. */
@media (max-width: 575.98px) {
	.gal-nav {display:none;}
	.gal-thumb {width:44px; height:44px;}
}
/* … */

/* A clickable image frame — the logo box on the organisation page. */
.image-drop {cursor:pointer; transition:border-color 0.15s ease, background-color 0.15s ease;}
.image-drop:hover, .image-drop:focus-visible {border-color:var(--ip-teal) !important;}
.image-drop:focus-visible {outline:2px solid var(--ip-teal); outline-offset:2px;}

/* Centred spinner for a panel or table body that is still filling. */
.loading-pane {
	display:flex;
	align-items:center;
	justify-content:center;
	gap:0.6rem;
	padding:1.75rem 1rem;
	color:var(--ip-muted);
}
/* … */

/* -----------------------------------------------------------------------------
   Touch

   A finger is not a cursor. Where the pointer is coarse, a control is never
   smaller than 44px in the direction it is tapped — the size a fingertip can
   hit without aiming — and fields are set at 16px, because iOS zooms the whole
   page into any field whose text is smaller and leaves it zoomed after.
   Desktop keeps the denser sizes; this is a property of the pointer, not of
   the screen's width.
   -------------------------------------------------------------------------- */

@media (pointer: coarse) {
	:is(input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea) {
		font-size: var(--ip-font-base);
	}

	.ip-btn,
	.ip-chip,
	.ip-approvals__filter,
	.ip-settings__link,
	.ip-lists__tab { min-height: 44px; }
	.ip-chip { display: inline-flex; align-items: center; }

	.ip-theme-toggle,
	.ip-flyout__close,
	.app-topbar__menu { width: 44px; height: 44px; flex-basis: 44px; }
	.app-topbar__tools .ip-seg,
	.app-topbar__tools .app-topbar__team { height: 44px; }

	.ip-attend__cell { width: 44px; height: 44px; }
	.gal-close, .gal-remove { width: 44px; height: 44px; }

	/* Too small to grow without unsettling the row they sit in, so the area
	   that answers a tap grows instead of the mark. */
	/* The tile's toggle is already absolutely placed, which anchors its own. */
	.ip-check,
	.ip-lists__move,
	.ip-lists__remove { position: relative; }
	.ip-check::after,
	.fm-tile-toggle::after,
	.ip-lists__move::after,
	.ip-lists__remove::after {
		content: '';
		position: absolute;
		inset: 50% auto auto 50%;
		width: 44px;
		height: 44px;
		transform: translate(-50%, -50%);
	}
}

/* -----------------------------------------------------------------------------
   Hover

   One treatment for every neutral control and row: --ip-veil-hover laid over
   whatever the thing already is. Each of these used to pick a neighbouring
   surface token for its hover instead — --ip-rule for a button, --ip-bg for a
   quiet one, --ip-surface-sunk for a row — and those sit on different sides of
   a card in the dark theme: --ip-bg is darker than --ip-surface, --ip-rule and
   --ip-surface-sunk are lighter. So two buttons side by side moved in opposite
   directions under the pointer. The veil is the one token defined as "hover",
   and it inverts per theme on purpose.

   A wash over the background rather than a background, so it works on a solid
   button, a transparent one and a row alike, and Bootstrap's
   `bg-transparent !important` (the account menu, the sidebar foot) cannot
   cancel it. `:where` keeps it at zero weight: a control with a hover of its
   own — primary, danger, upgrade, a link — sets `background` and wins, and a
   selected chip or segment is excluded rather than washed.

   Not here, deliberately: tinted controls deepen their own tint (the evidence
   chip, a combo token), and choice cards outline in the accent (.ip-type and
   its kin). Each of those families is consistent within itself.
   -------------------------------------------------------------------------- */

@media (hover: hover) {
	:where(.ip-btn, .ip-theme-toggle, .app-topbar__menu, .ip-flyout__close, .ip-auth__reveal,
	       .sidebar-foot-btn, .account-item, .ip-section-toggle, .ip-settings__link,
	       .ip-table__row, .ip-approvals__row.is-clickable, .ip-evidence-row,
	       #sidebar .nav-link:not(.active),
	       .ip-chip:not(.is-active), .ip-approvals__filter:not(.is-active),
	       .ip-seg__btn:not(.is-active), .ip-lists__tab:not(.is-active)):hover {
		background-image: linear-gradient(var(--ip-veil-hover), var(--ip-veil-hover));
	}
}

/* -----------------------------------------------------------------------------
   Press

   After every component on purpose: a press has to win over the hover rule the
   same control carries, and those are written at the same weight.
   -------------------------------------------------------------------------- */

/* Pressed. Feedback lands on the press, not on the release that follows it, and
   it is the same small give everywhere so a control feels like one family. On a
   touch screen this is the only acknowledgement there is: hover never happens.
   `scale` rather than `transform`, so it composes with any transform a control
   already wears, and an instant press with a quick release — the press is the
   finger arriving, and that is not something to ease into. */
:is(.ip-btn, .ip-chip, .ip-theme-toggle, .ip-flyout__close, .app-topbar__menu,
    .ip-type, .ip-type-pill, .ip-output, .ip-team-card, .ip-picker-btn, .ip-attend__cell,
    .ip-check, .ip-evidence-chip, .ip-lists__pill, .ip-approvals__filter, .sidebar-foot-btn,
    .gal-icon-btn, .ip-auth__reveal):not(:disabled) {
	transition-property: scale, background-color, border-color, color, box-shadow, opacity;
	transition-duration: 160ms;
	transition-timing-function: ease-out;
}
:is(.ip-btn, .ip-chip, .ip-theme-toggle, .ip-flyout__close, .app-topbar__menu,
    .ip-picker-btn, .ip-attend__cell, .ip-check, .ip-evidence-chip, .ip-lists__pill,
    .ip-approvals__filter, .sidebar-foot-btn, .gal-icon-btn, .ip-auth__reveal):not(:disabled):active {
	scale: 0.96;
	transition-duration: 0s;
}
/* Large targets — cards and tiles — give less, or a whole card visibly shrinks. */
:is(.ip-type, .ip-type-pill, .ip-output, .ip-team-card):active { scale: 0.985; transition-duration: 0s; }
/* Rows and menu entries are the width of their container, where any scale reads
   as the layout wobbling; they take a second veil instead, a step past their
   hover in either theme. */
:is(.ip-settings__link, .ip-table__row.is-clickable, button.ip-table__row,
    .ip-approvals__row.is-clickable, .ip-lists__tab, .ip-combo__option, .fm-suggest-option,
    .ip-section-toggle, .ip-section__toggle, .ip-link-pick, .ip-evidence-row, .account-item,
    .dropdown-item):active {
	background-image: linear-gradient(var(--ip-veil-hover), var(--ip-veil-hover)),
	                  linear-gradient(var(--ip-veil-hover), var(--ip-veil-hover));
}
/* The segmented control's pill is a separate element behind the label and would
   not shrink with it, so the label dims instead — the platform's own answer. */
.ip-seg__btn:active { opacity: 0.6; }
#sidebar :is(.nav-link, button.nav-link):not(.active):active {
	background-image: linear-gradient(var(--ip-veil-hover), var(--ip-veil-hover)),
	                  linear-gradient(var(--ip-veil-hover), var(--ip-veil-hover));
}
#sidebar .nav-link.active:active { background-color: var(--ip-teal-dark); }

/* -----------------------------------------------------------------------------
   Bootstrap bridge

   The app ran on `data-bs-theme="dark"` before this redesign, and a lot of
   markup was written against it — `btn-outline-light` is white-on-transparent,
   which was right on a dark panel and is invisible on a white one.

   So rather than rewrite every call site at once, the Bootstrap classes that
   assumed a dark background were mapped onto this palette here. This is a
   bridge, not a design, and it shrinks as screens are rebuilt: the sign-in
   pages, the dialog engine's contents and the activity list have been, and
   the rules for cards, tables, form controls, text-bg-* badges and the light,
   secondary, success and danger buttons went with them. What is left is what
   something still uses — the dialogs' Bootstrap .modal shell, badges, and
   the variables under the toast's behaviour. Before
   adding a rule here, build the thing from ip-* instead.
   -------------------------------------------------------------------------- */

/* Bootstrap's own names, pointed at ours. Declared once rather than per theme:
   each --ip-* on the right resolves against whichever theme is in force on
   <html>, so one mapping covers both. Bootstrap's [data-bs-theme="dark"]
   carries the same specificity as :root, and this file loads after it, so
   these win in either theme. */
:root {
	--bs-primary:              var(--ip-teal);
	--bs-primary-rgb:          var(--ip-teal-rgb);
	--bs-link-color:           var(--ip-teal);
	--bs-link-color-rgb:       var(--ip-teal-rgb);
	--bs-link-hover-color:     var(--ip-teal-dark);
	--bs-link-hover-color-rgb: var(--ip-teal-rgb);

	--bs-body-bg:         var(--ip-canvas);
	--bs-body-color:      var(--ip-ink);
	--bs-emphasis-color:  var(--ip-ink);
	--bs-secondary-color: var(--ip-muted);
	--bs-secondary-bg:    var(--ip-surface-sunk);
	--bs-tertiary-bg:     var(--ip-surface-sunk);
	--bs-border-color:    var(--ip-border);

	/* Inline <code> — a domain, a record — was Bootstrap's pink, the one
	   colour on those screens from outside the palette. */
	--bs-code-color:      var(--ip-ink-soft);

	--bs-border-radius:     8px;
	--bs-border-radius-sm:  8px;
	--bs-border-radius-lg:  8px;
	--bs-border-radius-xl:  8px;
	--bs-border-radius-xxl: 8px;
	--bs-dropdown-item-border-radius: 5px;
}

/* Component variables have to be set *on the component*. Bootstrap declares
   --bs-card-bg inside the .card rule itself, not on :root, so a root-level
   override never reaches it. The same is true of every block below. */
.modal {
	--bs-modal-bg:                  var(--ip-surface);
	--bs-modal-border-color:        var(--ip-border);
	--bs-modal-header-border-color: var(--ip-border-soft);
	--bs-modal-footer-border-color: var(--ip-border-soft);
}
.dropdown-menu {
	--bs-dropdown-bg:                var(--ip-surface);
	--bs-dropdown-border-color:      var(--ip-border);
	--bs-dropdown-link-color:        var(--ip-ink);
	--bs-dropdown-link-hover-bg:     var(--ip-veil-hover);
	--bs-dropdown-link-hover-color:  var(--ip-ink);
	--bs-dropdown-link-active-bg:    var(--ip-teal);
	--bs-dropdown-link-active-color: var(--ip-on-accent);
	--bs-dropdown-header-color:      var(--ip-muted);
}
.offcanvas, .offcanvas-lg, .offcanvas-md, .offcanvas-xl {
	--bs-offcanvas-bg:           var(--ip-surface);
	--bs-offcanvas-border-color: var(--ip-border);
	--bs-offcanvas-color:        var(--ip-nav-fg);
}
.toast {
	--bs-toast-bg:           var(--ip-surface);
	--bs-toast-border-color: var(--ip-border);
	--bs-toast-header-bg:    var(--ip-surface-sunk);
	--bs-toast-color:        var(--ip-ink);
}

/* Dialogs */
.modal-content {
	border: 1px solid var(--ip-border);
	border-radius: 16px;
	box-shadow: 0 24px 60px var(--ip-shadow-4);
	background: var(--ip-surface);
	color: var(--ip-ink);
}
.modal-header, .modal-footer { border-color: var(--ip-border-soft); }
.modal-footer { background: var(--ip-surface-sunk); }
.modal-title { font-size: var(--ip-font-h2); font-weight: 600; letter-spacing: -0.01em; }
/* Full opacity: the scrim token is already translucent, and an opacity on top
   multiplied the two — a dialog dimmed the page to about a third of what the
   entry sheet does, though a dialog is the one that blocks.
   Set through Bootstrap's own variables, on the backdrop in every state: the
   colour used to be on `.show` alone, and closing drops `.show` before the
   fade-out, so for those 150ms the backdrop fell back to Bootstrap's solid
   black and the page flashed dark in both themes. */
.modal-backdrop {
	--bs-backdrop-bg: var(--ip-scrim);
	--bs-backdrop-opacity: 1;
}
/* A dialog body scrolls inside the viewport rather than pushing its own
   footer — and the Save button — off the bottom of the screen. */
.modal-body { overflow-y: auto; max-height: 70vh; }
/* The same small rise .ip-modal makes. The two look identical, so they arrive
   identically; this scaled up from the middle while that one rose. */
.modal.fade .modal-dialog { transform: translateY(6px); transition: transform 0.2s ease, opacity 0.15s ease; }
.modal.fade.show .modal-dialog { transform: none; }
@media (prefers-reduced-motion: reduce) {
	.modal.fade .modal-dialog { transform: none; }
}
.modal.modal-static .modal-dialog { transform: scale(1.02) !important; }

/* Buttons */
.btn {
	border-radius: 9px;
	font-size: var(--ip-font-sm);
	font-weight: 500;
	padding: 9px 15px;
}
.btn-sm { padding: 6px 11px; font-size: var(--ip-font-xs); border-radius: 8px; }
/* Nothing of ours is a .btn any more; Swagger UI's buttons on the API docs
   are, and these two rules are what still shape them. */

.nav-pills {
	--bs-nav-pills-link-active-color: var(--ip-on-accent);
	--bs-nav-pills-link-active-bg:    var(--ip-teal);
}
/* The dialog engine's section tabs and the sidebar's nav both build their
   buttons once and toggle `.active` on them, so the change has something to
   transition from. Bootstrap ships none of its own. */
.nav-pills .nav-link { transition: background-color 200ms ease, color 200ms ease; }
@media (prefers-reduced-motion: reduce) {
	.nav-pills .nav-link { transition: none; }
}

/* Form controls */
.input-group-text {
	background: var(--ip-bg);
	border-color: var(--ip-border-input);
	color: var(--ip-muted);
	border-radius: 9px;
}
.form-check-input:checked { background-color: var(--ip-teal); border-color: var(--ip-teal); }
.form-check-input:focus { border-color: var(--ip-teal); box-shadow: 0 0 0 3px var(--ip-mint); }

/* Badges — map Bootstrap's palette onto the tag tones.
   The base rule matters as much as the variants: a `.badge` with no
   `text-bg-*` inherits Bootstrap's white text, which is how the lookup field's
   chips ended up as an invisible label beside a floating remove button. */
.badge {
	font-weight: 500;
	border-radius: 999px;
	padding: 3px 9px;
	font-size: var(--ip-font-xs);
	background: var(--ip-rule);
	color: var(--ip-muted);
}
.badge .btn-close { --bs-btn-close-color: currentColor; opacity: 0.6; }
.badge .btn-close:hover { opacity: 1; }

.text-muted { color: var(--ip-muted) !important; }

/* -----------------------------------------------------------------------------
   Print — the report builder's whole point is a document somebody prints
   -------------------------------------------------------------------------- */

@media print {
	/* Paper has one theme. Someone reading the app in dark mode still expects a
	   printed report on white — and a page of near-black ink is a cartridge
	   nobody agreed to spend, so the light palette is forced back on. */
	:root, :root[data-theme="dark"] {
		--ip-bg:           #ffffff;
		--ip-canvas:       #ffffff;
		--ip-surface:      #ffffff;
		--ip-surface-sunk: #ffffff;
		--ip-input-bg:     #ffffff;
		--ip-ink:          #16191b;
		--ip-ink-soft:     #2c3134;
		--ip-label:        #4b5563;
		--ip-muted:        #6b7280;
		--ip-faint:        #9aa1a0;
		--ip-border:       #e6e8e7;
		--ip-border-soft:  #eef0ef;
		--ip-rule:         #f2f4f3;
		--ip-teal:         #0f766e;
		--ip-teal-pale:    #8fbfb9;
		--ip-track:        #f0f2f1;
		--ip-on-accent:    #ffffff;
		color-scheme: light;
	}

	#sidebar,
	#nav-progress,
	#toast-container,
	.app-topbar,
	.ip-report-side,
	.ip-card__head { display: none !important; }

	/* The grid and the glows are dark-theme texture; on paper they are toner. */
	body { background-image: none !important; }
	body::before, body::after { display: none !important; }

	#main-content,
	#app-main { overflow: visible !important; height: auto !important; }
	body { overflow: visible; }

	.ip-report-main { flex: 1 1 100%; max-width: none; }
	.ip-card, .ip-card--lg { border: 0; box-shadow: none; }
	.ip-card--flush::after { display: none; }
	.ip-doc { max-width: none; padding: 0; }
	body { background: #ffffff; }
}
