/* ProDAQ License Manager — screen stylesheet.

   LIGHT THEME throughout, one palette, defined in tokens.css. Two treatments, split by screen
   (docs/07):

     Login + dashboard tiles  Expressive. A gradient stage, a frosted glass card, real depth.
     Every list and form      Flat, dense, fast. 40px rows, hairline borders, no motion.

   An admin tool where the data grid animates is charming on day one and hated by week two. The
   people using these screens read tables of 32-character machine ids; motion there is friction
   wearing a costume. So the personality lives on the two screens with no data on them — and, since
   the topbar rework, on the 56px band above all of them, which is chrome rather than data.

   This deliberately breaks ProDAQ's anti-glassmorphism rules. Those rules are right FOR PRODAQ — a
   Part 11 system a pharma auditor sits in front of. Nobody audits this. See docs/07 before
   "fixing" it, and before dragging these rules back into ProDAQ.

   Nothing on the login page moves: the animation was removed on request (docs/07). */

/* -- Palette aliases -----------------------------------------------------------------------------
   This file used to carry its own palette — a second set of hex values living beside tokens.css's,
   in a different vocabulary, with no rule saying which was the real one. They were not even the
   same design: the admin screens were violet-accented while the tokens said warm-paper teal, so the
   topbar's active-nav bar was a colour that appeared nowhere else in the app.

   The names are kept because ~200 declarations below and in the login block are written in them.
   The VALUES are gone: every one is now an alias onto the --c-* token of the same meaning. One
   place to change the brand, and the login page, the topbar, the modals and the tables all move
   together — which is the whole reason the topbar could be made to match the sign-in screen at all.

   Nothing new should be written against these. New rules name the --c-* token directly; this block
   exists so the older sheet did not have to be rewritten in one go to get one palette. */
:root {
  --bg:        var(--c-bg);
  --surface:   var(--c-surface-raised);
  --surface-2: var(--c-surface);
  --line:      var(--c-border);
  --line-soft: var(--c-border-soft);
  --text:      var(--c-text);
  --text-dim:  var(--c-text-muted);
  --accent:    var(--c-primary);
  --accent-2:  var(--c-primary-alt);

  /* Status, always paired with the word it colours. The locked --s-* set under this sheet's older
     names — "revoked" DELETES a customer's licence file, and the pill saying so has to be the same
     red as the "expired" text three columns over. */
  --ok:        var(--s-normal);
  --warn:      var(--s-alert);
  --danger:    var(--s-alarm);
  --hold:      var(--s-offline);
  --perpetual: #6D28D9;   /* violet: perpetual is not a status, it is the absence of an expiry */

  --radius:    var(--radius-card);
  --shadow-sm: var(--elev-sm);
  --shadow-lg: var(--elev-lg);

  --font-ui:   var(--font-body);
}

* { box-sizing: border-box; }

body {
  margin: 0;
  font-family: var(--font-ui);
  background: var(--bg);
  color: var(--text);
  font-size: 14px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

/* Every machine id, key, fingerprint and date. Not taste: a machine id is 32 hex characters a human
   compares against an email character by character, and tabular-nums keeps the columns aligned so
   the eye can scan down rather than re-reading each row. */
.mono, code {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  letter-spacing: .01em;
}

code {
  background: var(--surface-2);
  padding: .1em .35em;
  border-radius: 4px;
  font-size: .92em;
}

a { color: var(--accent); }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 3px;
}

.page-head { display: flex; align-items: baseline; gap: 1rem; margin-bottom: 1.25rem; }
.page-head h1 { font-size: 1.4rem; margin: 0; letter-spacing: -.01em; }
.page-head .button { margin-left: auto; }

/* More than one action in a heading row needs a box of its own. margin-left:auto on each member —
   which is what the rule above does for a lone legacy .button — pushes them APART, so two buttons
   end up at opposite ends of the row with the heading stranded between them. One auto margin on the
   group keeps them together at the right-hand end and lets gap set the space between them.
   Written against .btn (shell.css) rather than .button: every screen that grew a second action grew
   it after the migration. */
.page-head__actions { display: flex; align-items: center; gap: .6rem; margin-left: auto; }

/* A heading row inside a section — "Installations" with its Add button — is the same arrangement one
   size down, so it reuses .page-head. Only the h1 sizing above does not apply, and the bottom margin
   comes from the h2's own. */
.page-head h2 { margin-top: 0; }

/* Small caps rather than a smaller heading. A section head inside a card LABELS the block under it;
   set as a heading in its own right it competed with the page title in the band above, which is the
   one word on the screen that should win. */
h2 {
  font-size: .78rem;
  margin: 2rem 0 .75rem;
  color: var(--text-dim);
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
}
.muted { color: var(--text-dim); }

/* -- Flat, dense grid --------------------------------------------------------------------------- */
/* The frame — border, radius, and the clip that rounds the header row's corners — lives on
   .table-wrap, not here. A border on the TABLE scrolls away with the rows the moment the wrapper is
   scrolled, and overflow:hidden on the table made the table its own scroll box: a box that never
   scrolls, which left .grid th's position:sticky below with nothing to move against. */
.grid {
  width: 100%;
  border-collapse: collapse;
  background: var(--surface);
}

.grid th {
  position: sticky; top: 0; z-index: 1;
  text-align: left; font-weight: 700; font-size: .74rem;
  text-transform: uppercase; letter-spacing: .05em; color: var(--text-dim);
  background: var(--surface-2);
  padding: .55rem .7rem;
  /* An inset shadow, NOT a border-bottom. This th is position:sticky, and under
     border-collapse:collapse the border belongs to the TABLE rather than to the cell — so it
     scrolled away with the row and left the headings floating on the data. An inset shadow is
     painted by the cell itself and stays with it. UI rework section 3. */
  box-shadow: inset 0 -1px 0 var(--line);
}

/* Every listing table scrolls inside its own box, on BOTH axes. Sideways because a table wider than
   the viewport must not make the PAGE scroll sideways. Downwards because a table longer than the
   screen pushes everything under it off the bottom of the card — on a client that is the version
   history and the audit trail, and on the verify log it is 500 rows of one table between the filters
   and the end of the page.

   The cap is the smaller of two measures. 70vh keeps the box inside a short window; 44rem (about
   seventeen rows) stops it eating a tall one, which is what a bare vh cap would do to each of the
   three stacked tables on a client page. Under either, a short table is untouched: max-height only
   binds once there is more data than fits.

   The border and radius moved here from .grid (see the note there). They have to be on the element
   that scrolls, or the box loses its top edge as soon as it is scrolled — and with the clip here, the
   sticky header row finally has the right thing to stick to: the box it scrolls inside. */
.table-wrap {
  overflow: auto;
  max-height: min(70vh, 44rem);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

.grid td { padding: 0 .7rem; height: 40px; border-bottom: 1px solid var(--line-soft); }
.grid tbody tr:last-child td { border-bottom: none; }

/* No lift, no shadow, no transform. Deliberate — see the note at the top of this file. The tint is
   the shared selection token, so a hovered row here and an open item in the account menu are one
   colour rather than two nearly-equal greys. */
.grid tbody tr:hover { background: var(--c-selection); }

/* Right-aligned columns, and the selectors have to be THIS specific.
   `.grid__num`/`.grid__actions` alone are (0,1,0), and both table skins set alignment with a rule
   that beats them: `.grid th` (0,1,1) above, and `.data-table th, .data-table td` (0,1,1) in
   shell.css. So the bare class silently lost on every HEADING — and the loss was invisible, because
   the CELLS still looked right: numbers are right-aligned by `.grid__num` winning against nothing
   (`.grid td` sets no alignment), and the action buttons by `.row-actions` being a flex box with
   `justify-content: flex-end`. Neither of those is text-align, so the header sat at the left edge of
   its column while its content sat at the right edge, a third of a column away.
   Qualified by the table class as well as the element, so this is (0,2,1) and wins outright rather
   than depending on which stylesheet the browser read last — shell.css loads AFTER app.css, so a
   mere (0,1,1) here would still lose to `.data-table th`.
   `.data-table td.num`/`th.num` in shell.css already does the element-qualified version of this for
   its own numeric columns; this is the same fix for the two classes app.css owns. */
.grid th.grid__num, .grid td.grid__num,
.data-table th.grid__num, .data-table td.grid__num { text-align: right; }

/* And a number never wraps. "20 m" breaking after the digit puts a unit on its own line and stands
   the row at double height — which is what a narrow Est. column did the moment the table beside it
   was tight. There is nothing in a numeric cell a wrap could help. */
.grid th.grid__num, .grid td.grid__num,
.data-table th.grid__num, .data-table td.grid__num { white-space: nowrap; }

.grid th.grid__actions, .grid td.grid__actions,
.data-table th.grid__actions, .data-table td.grid__actions { text-align: right; }

/* And the actions column is SIZED TO ITS BUTTONS, never squeezed below them.
   `table-layout: auto` hands the leftover width out across all columns, so on a wide grid — the
   install-step catalog has eleven — this column got less than three 28px buttons and a gap need, and
   the flex box below wrapped them onto two lines. The row then stood at double height with a ragged
   block of icons in it, while the width it wanted had been handed to a text column that could have
   wrapped a word instead.
   `width: 1%` with `nowrap` inverts that: the column resolves to its MIN-CONTENT width, which for a
   non-wrapping flex row is the buttons plus their gaps, and every remaining pixel goes to the
   columns that can reflow. Same idiom as `.data-table th.mono` in shell.css, for the same reason.
   It only works paired with `flex-wrap: nowrap` on .row-actions: the min-content width of a WRAPPING
   flex container is its widest single item, so with wrap left on, the column would resolve to one
   button wide and wrap exactly as before.
   Element- and table-qualified like the alignment rule above it, and for the same reason — see the
   note there about (0,1,0) losing to `.grid th`. */
.grid th.grid__actions, .grid td.grid__actions,
.data-table th.grid__actions, .data-table td.grid__actions { width: 1%; white-space: nowrap; }

/* A TIMESTAMP COLUMN IS SIZED TO ITS TIMESTAMP, on one line.
   `table-layout: auto` hands the leftover width out across all columns, and "2026-08-31 14:23:05"
   has a break opportunity in the middle of it — so on a grid with a wide free-text column beside it
   (the audit trail's Detail, the verify log's) this column was squeezed to its longest WORD and the
   time wrapped under the date. The row then stood at double height with a ragged two-line block in
   the first column, while the width it wanted had been handed to a column that could have wrapped a
   word instead and lost nothing.

   `width: 1%` with `nowrap` inverts that: the column resolves to its MIN-CONTENT width, which for
   unwrappable text is the whole timestamp, and every remaining pixel goes to the columns that can
   reflow. Same idiom as `.grid__actions` above and `.data-table th.mono` in shell.css — this is that
   shell.css rule brought to the `.grid` skin, which never had one.

   NOT folded into `.mono`, deliberately. On a `.grid` that class also marks machine ids, IPs, file
   names and versions, and sizing all of those to min-content is a different decision made about four
   other columns at once. A class that says which column it marks is the one that can be read.

   Alignment is untouched: these cells keep the `text-align: left` every cell gets, because reading a
   date is a left-to-right act and a date column pushed right walks away from the row it belongs to.
   Element- and table-qualified like the two rules above it, for the same cascade reason. */
.grid th.grid__when, .grid td.grid__when,
.data-table th.grid__when, .data-table td.grid__when { width: 1%; white-space: nowrap; }

/* Two or three named actions at the right-hand end of a row. A flex box rather than the cell's
   text-align:right alone, because that only lines inline boxes up — it gives no control over the
   space between two buttons, and two .btn boxes butted together read as one wide control.

   Tighter padding than a standalone .btn: a grid row is 40px tall, and a full-size button inside one
   forces the row taller. Forty rows of that is what turns a scannable list into a scroll. */
.row-actions { display: flex; align-items: center; justify-content: flex-end; gap: .4rem; }
.row-actions .btn { padding: .18rem .5rem; }

/* WRAPS, since the client row grew to six actions — every lever on a client is on its row now, and
   six no-wrap buttons push a 900px-wide actions column onto a grid that then scrolls sideways to
   reach the thing you came to click. Wrapping costs a taller row on a narrow window and keeps every
   action reachable without a horizontal scroll; `white-space: nowrap` on the button itself is what
   stops a wrap happening INSIDE a label, which is the version of this that looks broken.

   Only where there are enough to need it: three or fewer stay on one line at any width this app is
   used at, so the rule is unconditional and simply never binds on those rows. */
/* NOWRAP, paired with the `width: 1%` on the cell above — the two only work together.
   Wrapping was the original behaviour and it is the wrong failure mode for this column: a row of
   icon buttons that folds onto a second line doubles the row height and reads as a rendering fault,
   and it does it silently at whatever viewport the table happens to get squeezed at. Overflowing
   instead is caught by `.table-wrap`'s own `overflow: auto`, which scrolls the grid sideways inside
   a card that stays put — a dense desktop grid that scrolls is ordinary; one with ragged two-line
   button stacks in one column is not. */
.row-actions { flex-wrap: nowrap; }
.row-actions .btn { white-space: nowrap; }

/* An action whose LABEL lives on `title`/`aria-label` and whose face is a single glyph. Seven
   labelled buttons ran the client row's actions column past half the grid; seven glyphs sit inside
   the row height they were already given.

   Square, and sized from the glyph rather than from a label that is not there: `.btn`'s asymmetric
   padding is measured for text beside the icon, so reusing it gives a wide box with the glyph
   floating left of centre. Equal padding on a `justify-content: center` flex box centres it.

   28px min rather than the 40px a touch target wants: this is a dense desktop grid at 40px rows,
   and the pointer that uses it is a mouse. It overrides the `.row-actions .btn` padding above and
   so must come after it.

   `gap: 0` because there is no second child to space away from — `.btn`'s gap would otherwise be
   counted into the box's content width on some engines and push the square off-square. */
.row-actions .btn.icon-btn {
  justify-content: center;
  gap: 0;
  padding: .28rem;
  min-width: 28px;
  min-height: 28px;
}

/* A word mid-sentence that opens a modal. It has to be a <button>, because it is an action and not a
   destination — an <a href> here would be a link the browser offers to open in a new tab, where the
   modal it means to open does not exist. Styled back down to look like the prose it sits in, which
   is the whole reason the pattern is worth a rule rather than a .btn dropped into a paragraph. */
.link-button {
  background: none; border: 0; padding: 0;
  font: inherit; color: var(--accent);
  cursor: pointer; text-decoration: underline;
}

.toolbar { margin-bottom: 1rem; }

/* -- Inputs: the login page's field, everywhere --------------------------------------------------
   A caption, a rule, and nothing else. This was the sign-in form's treatment alone and is now the
   app's: the boxed, filled, 7px-radius input that used to live here made signing in and using the
   thing you signed into look like two products.

   The box is not what makes a field legible — the caption above it and the rule under it are — and
   dropping the fill takes a couple of hundred hairline rectangles off a screen like the verify log's
   filter row. --field-* (tokens.css) carries the rule, so this and the form controls in shell.css
   cannot drift into two kinds of input.

   The focus indicator is the RULE, not a ring around the field: thickness, hue and area all change
   at once. See .login-input below, where the same three lines are commented in full — an outline
   here fires on an autofocused field and puts back the box the design exists to remove. */
.input {
  background: transparent;
  border: none;
  border-bottom: 1px solid var(--field-line);
  border-radius: 0;
  color: var(--text);
  padding: .42rem .1rem;
  font: inherit;
  width: 100%;
  max-width: 420px;
}
.input::placeholder { color: var(--field-placeholder); }
.input:hover { border-bottom-color: var(--field-line-hover); }
.input:focus,
.input:focus-visible {
  outline: none;
  border-bottom: 2px solid var(--accent);
  padding-bottom: calc(.42rem - 1px);   /* hold the baseline as the rule thickens */
  box-shadow: 0 6px 10px -8px var(--accent);
}
.input--search { max-width: 320px; }

/* -- Buttons -------------------------------------------------------------------------------------
   The legacy pair, kept for the three screens still on them; .btn in shell.css is what new work
   uses. Same radius and the same primary as .btn, so a page carrying one of each does not show it. */
.button {
  display: inline-block;
  background: var(--surface);
  border: 1px solid var(--line);
  color: var(--text);
  padding: .46rem .9rem;
  border-radius: var(--radius-control);
  font: inherit;
  text-decoration: none;
  cursor: pointer;
  box-shadow: var(--shadow-sm);
}
.button:hover { border-color: var(--text-dim); }
.button--primary {
  background: var(--accent); border-color: var(--accent); color: var(--c-primary-ink); font-weight: 600;
}
.button--primary:hover { filter: brightness(1.08); }
.button:disabled { opacity: .5; cursor: default; box-shadow: none; }

/* .form-grid moved to shell.css (UI rework section 3), and is deliberately NOT defined here any
   more. The two definitions were incompatible, not merely different: this one put the caption in a
   180px column BESIDE its field, ProDAQ's puts it ABOVE, in two equal columns capped at 720px. Same
   specificity, so whichever sheet loaded last would silently decide the layout of every form in the
   app. Deleting it is what makes the winner explicit rather than a property of the <link> order. */

/* A sentence under a field, or under a block on a bare page. 52ch is a READING MEASURE, and it is
   there for the second case: a `.field-note` sitting directly in a full-width `.content-card` has
   nothing else constraining it, and prose set across 1400px on a wide monitor is genuinely hard to
   read. Six of them on the generate-key and installation screens are exactly that. */
.field-note { color: var(--text-dim); font-size: .82rem; margin: .35rem 0 0; max-width: 52ch; }

/* ...but inside a form, THE FIELD IS THE MEASURE. The note is a <p> inside the <label>, so the grid
   column already sizes it — half a row for an ordinary field, the whole row for a `.span-2` one —
   and 52ch then cuts it short of the box it belongs to. On a full-width field in an `lg` modal that
   is a ~400px paragraph hanging under an ~810px input, which reads as a layout fault rather than as
   a measure: the eye pairs a caption, its box and its note by their left AND right edges, and this
   was the one of the three that stopped early.

   `none`, not a bigger ch value. Any fixed number is wrong at one of the two widths a form field
   can be, and wrong again in every modal size class — the column already knows the answer, so the
   fix is to stop arguing with it. The narrow field keeps a good measure for free (~48ch at half a
   modal row); the full-width one deliberately trades measure for alignment, which is the call this
   rule exists to record. */
.form-grid .field-note { max-width: none; }

.form-actions { display: flex; align-items: center; gap: 1rem; margin-top: 1.5rem; flex-wrap: wrap; }
.form-error { color: var(--danger); }

.facts {
  display: grid; grid-template-columns: 180px minmax(0, 1fr);
  gap: .55rem 1.25rem; margin: 0 0 1.5rem;
}
.facts dt { color: var(--text-dim); }
.facts dd { margin: 0; }
.facts__id { font-size: 1.05rem; letter-spacing: .06em; }
.facts__notes { white-space: pre-wrap; }

/* -- Status. Colour is locked and ALWAYS paired with the word (docs/07). ------------------------- */
.pill {
  display: inline-block;
  padding: .1rem .55rem;
  border-radius: 999px;
  font-size: .76rem;
  font-weight: 700;
  border: 1px solid currentColor;
  margin-left: .4rem;
}
/* The fills are each pill's own ink at 12% of white rather than five picked tints, so a status added
   later cannot land a shade off the four beside it, and every one sits the same distance from the
   card behind it. Border and text stay full strength — that is what carries the 4.5:1. */
.pill--active    { color: var(--ok);        background: color-mix(in srgb, var(--ok) 12%, #fff); }
.pill--suspended { color: var(--hold);      background: color-mix(in srgb, var(--hold) 12%, #fff); }
.pill--revoked   { color: var(--danger);    background: color-mix(in srgb, var(--danger) 12%, #fff); }
.pill--perpetual { color: var(--perpetual); background: color-mix(in srgb, var(--perpetual) 12%, #fff); }
.pill--muted     { color: var(--text-dim);  background: var(--surface-2); }
/* The register axis: is this row offered, or is it set aside? Both RED, on the Revoked ink.
   Inactive was slate for a day, sharing Suspended's "present but not in play" tone. Red was asked
   for and red is what a release's Inactive actually deserves — it answers 404 to the download, which
   is refusal, not a pause. The cost, stated because it is real: on a CLIENT an inactive row stops
   nothing at all (its installations keep running and keep renewing), so red overstates it there.
   The confirmation dialog is where that difference is spelled out, and it still is.
   Named separately from .pill--revoked and from each other even though all three now resolve to the
   same ink: a class called "revoked" on a release row would describe something a release cannot be,
   and the day any of them wants its own colour the rename would otherwise touch every call site.
   The WORD is what separates them — the rule this whole block is written to (docs/07). Colour is
   never the only carrier. */
.pill--inactive  { color: var(--danger);    background: color-mix(in srgb, var(--danger) 12%, #fff); }
.pill--deleted   { color: var(--danger);    background: color-mix(in srgb, var(--danger) 12%, #fff); }

/* The left margin above is for a pill trailing a word ("Active" beside a status). When the pill IS
   the cell, that margin knocks it out of line with the column above it. */
.grid td > .pill:first-child { margin-left: 0; }

.text-expiring { color: var(--warn); font-weight: 600; }
.text-expired  { color: var(--danger); font-weight: 600; }

/* -- The login page ------------------------------------------------------------------------------
   A gradient stage with floating glass shapes and one frosted card, from the second reference. The
   inputs are the first reference's: no boxes, a rule under each field, a letter-spaced submit.

   This block is now the app's REFERENCE rather than its exception. The gradient's three stops are
   --brand-stops in tokens.css, the topbar wears the same three at a shallower angle, and .input
   above is this page's field. Change a colour here by changing the TOKEN, never by editing a stop
   back into a literal — the topbar is the thing that then silently stops matching.

   CSS, not WebGL. That page renders BEFORE authentication, so it is the one page an unauthenticated
   caller can make the server serve. A 600KB bundle there is a slower first impression, more attack
   surface and another dependency to patch.

   Nothing here moves. */

.login-stage {
    min-height: 100vh;
    display: grid;
    place-items: center;
    padding: 2rem 1rem;
    position: relative;
    overflow: hidden;
    background: linear-gradient(43deg, var(--brand-stops));
}

/* Soft light pooling in two corners, so the flat gradient has somewhere to breathe. */
.login-aurora {
  position: absolute; inset: -20%;
  background:
    radial-gradient(38% 34% at 18% 22%, rgba(255,255,255,.30), transparent 70%),
    radial-gradient(42% 38% at 82% 78%, rgba(255,255,255,.18), transparent 72%);
  pointer-events: none;
}

/* Without this, a gradient this large bands visibly on 8-bit displays and the whole thing looks
   cheap. ~4% opacity is enough to break the banding and invisible as texture. */
.login-grain {
  position: absolute; inset: 0;
  opacity: .04;
  pointer-events: none;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>");
}

.login-deck { position: relative; z-index: 1; }

/* The floating squares from the second reference. Decorative and inert — aria-hidden in the markup,
   pointer-events off here, so they are invisible to a screen reader and cannot swallow a click on
   the form behind them. */
.float-shape {
  position: absolute;
  border-radius: 14px;
  background: rgba(255, 255, 255, .22);
  border: 1px solid rgba(255, 255, 255, .38);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  pointer-events: none;
}
.float-shape--1 { width: 100px; height: 100px; top: -46px;   left: -66px; }
.float-shape--2 { width: 64px; height: 64px; top: 74px;    left: -36px; opacity: .8; }
.float-shape--3 { width: 116px; height: 116px; top: -58px;  right: -58px; }
.float-shape--4 { width: 82px; height: 82px; bottom: 68px; right: -52px; opacity: .85; }
.float-shape--5 { width: 72px; height: 72px; bottom: -46px; left: 62px; opacity: .75; }

/* -- The ambient layer ---------------------------------------------------------------------------
   A second, larger scatter of the same glass square, spread across the whole stage rather than
   clustered on the card. .float-shape above is positioned against .login-deck, so those five can
   only ever sit within a card's reach of it; these are positioned against .login-stage in
   percentages, which is what puts glass in the corners of a 27" monitor as well as beside the form.

   Behind the card by z-index, fainter than the close-in five, and inert for the same two reasons:
   aria-hidden in the markup, pointer-events off here. The stage clips its overflow, so a shape that
   runs past an edge is cut rather than adding a scrollbar. */
.login-field {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}

.drift-shape {
  position: absolute;
  border-radius: 18px;
  background: rgba(255, 255, 255, .13);
  border: 1px solid rgba(255, 255, 255, .26);
  backdrop-filter: blur(5px);
  -webkit-backdrop-filter: blur(5px);
}

.drift-shape--1 { width: 150px; height: 150px; top: 7%;  left: 6%; }
.drift-shape--2 { width: 72px;  height: 72px;  top: 25%; left: 19%;  opacity: .85; }
.drift-shape--3 { width: 118px; height: 118px; top: 11%; right: 10%; opacity: .9; }
.drift-shape--4 { width: 54px;  height: 54px;  top: 33%; right: 5%;  opacity: .75; }
.drift-shape--5 { width: 96px;  height: 96px;  top: 52%; left: 3%;   opacity: .8; }
.drift-shape--6 { width: 132px; height: 132px; bottom: 9%; left: 14%; opacity: .85; }
.drift-shape--7 { width: 60px;  height: 60px;  bottom: 6%; left: 33%; opacity: .7; }
.drift-shape--8 { width: 108px; height: 108px; bottom: 14%; right: 15%; opacity: .85; }
.drift-shape--9 { width: 46px;  height: 46px;  bottom: 4%;  right: 33%; opacity: .7; }

/* Below this the stage is narrower than the shapes' clear zone, and they start sliding under the
   card — where they read as smudges through the frosted glass instead of as objects beside it.
   The close-in .float-shape five are laid out against the card and stay. */
@media (max-width: 980px) {
  .login-field { display: none; }
}

.login-card {
  position: relative;
  width: min(460px, 92vw);
  /* Deep bottom padding so the security print at the foot has room and does not crowd the button. */
  padding: 3rem 2.9rem 3.5rem;
  border-radius: 22px;

  /* Frosted and BRIGHT, not the reference's translucent-white-on-vivid. The reference reads
     beautifully, and its field labels sit at roughly 2:1 against the gradient behind them — which is
     the classic glassmorphism failure and something docs/07 makes a hard requirement against.
     0.72 is the compromise: opaque enough that the small dim label text clears 4.5:1 over the
     DARKEST part of the gradient, sheer enough to still read as glass.

     The glassiness comes from the blur, the saturation lift and the crisp white edge rather than
     from transparency. Going sheerer looked better and failed the labels; going more opaque passed
     and looked like a plain white panel. */
  background: rgba(255, 255, 255, .72);
  backdrop-filter: blur(30px) saturate(1.9);
  -webkit-backdrop-filter: blur(30px) saturate(1.9);
  border: 1px solid rgba(255, 255, 255, .8);
  box-shadow:
    0 30px 70px rgba(45, 10, 80, .32),
    inset 0 1px 0 rgba(255, 255, 255, .95),
    inset 0 -1px 0 rgba(255, 255, 255, .5);
  overflow: hidden;
}

/* A fixed highlight across the glass. It used to follow the pointer, which is what made it a
   holographic sheen; as a still it is just a light source, and that is the point now. */
.login-sheen {
  position: absolute;
  inset: -40%;
  pointer-events: none;
  background: linear-gradient(
    105deg,
    transparent 40%,
    rgba(255,255,255,.42) 48%,
    rgba(255,255,255,.18) 54%,
    transparent 64%);
}

/* Short enough to fit inside the card at every width it is used at. The card clips its overflow,
   so a string that runs long does not wrap — it gets sliced mid-character and reads as a rendering
   fault rather than as a security print. */
.login-security-print {
  position: absolute;
  bottom: .7rem; left: 1rem; right: 1rem;
  font-family: var(--font-mono);
  font-size: .5rem;
  letter-spacing: .12em;
  text-align: center;
  color: rgba(27, 32, 50, .14);
  pointer-events: none;
  user-select: none;
  white-space: nowrap;
  overflow: hidden;
}

.login-card__body { position: relative; z-index: 1; }

/* The heading is the logo now — the h1 is a wrapper, and .login-title__sub went with the text it
   styled. The type rules stay on the h1 anyway: they cost nothing and they are what the page falls
   back to if the image ever fails to load and the browser paints the alt text instead. */
.login-title {
  margin: 0;
  font-size: 1.7rem;
  line-height: 1.15;
  letter-spacing: -.015em;
  text-align: center;
  color: var(--text);
}

/* No plate here — the card is near-white and the navy lockup reads on it directly. Capped in BOTH
   units: 300px is the measure the card was drawn around, 78% keeps it off the padding on the 92vw
   card that a narrow phone gets. */
.login-logo {
  display: block;
  width: min(300px, 78%);
  height: auto;
  margin: 0 auto;
}
.login-lede {
  color: var(--text-dim);
  margin: .5rem 0 1.75rem;
  font-size: .88rem;
  text-align: center;
}

.login-form { display: grid; }

/* -- The first reference's inputs: a label, a rule, and nothing else. ----------------------------
   No box, no fill. The rule thickens and takes the accent on focus, which is the only state change
   on this page — and it has to be unmistakable, because a focus ring is where translucent surfaces
   go to die.

   Darker than --text-dim. These sit on a translucent card over a vivid gradient, and the dim grey
   that reads perfectly on a solid admin page drops close to 3:1 here. Small text is exactly where
   that is least forgivable. */
.login-label {
  font-size: .78rem;
  font-weight: 600;
  color: #3d4458;
  margin-top: 1.2rem;
  margin-bottom: .15rem;
}

/* THE RULE BELONGS TO THE ROW, NOT THE INPUT, and it has to now that an icon sits in front of the
   field: leave the border on the input and the underline starts after the icon, so the row reads as
   an icon standing beside a field rather than as one control. The row draws the line, :focus-within
   does what :focus used to, and .login-input is border-free — it is only ever used inside one of
   these.

   Nothing sets a width on the icon or the reveal button; the input's `flex: 1` takes the slack. So
   the user-name row and the password row — which carries a third child — end their rules at exactly
   the same x. */
.login-input-row {
  display: flex;
  align-items: center;
  gap: .55rem;
  border-bottom: 1px solid var(--field-line);
  padding-bottom: 1px;    /* the half of the baseline-hold below; see :focus-within */
}
.login-input-row:hover { border-bottom-color: var(--field-line-hover); }

/* The icon is decoration, and reads as it: dimmer than the value beside it, and it takes the accent
   along with the rule on focus so the whole row moves as one thing. */
.login-input-icon { color: var(--field-placeholder); flex-shrink: 0; }
.login-input-row:focus-within .login-input-icon { color: var(--accent); }

.login-input {
  background: transparent;
  border: none;
  color: var(--text);
  padding: .42rem .1rem;
  font: inherit;
  font-size: .95rem;
  flex: 1;
  /* A flex item's min-width defaults to auto, which lets a long value push the row wider than the
     card rather than scrolling inside it. */
  min-width: 0;
  border-radius: 0;
}
.login-input::placeholder { color: var(--field-placeholder); }
.login-input:focus, .login-input:focus-visible { outline: none; }

/* The focus indicator is the RULE, not a ring around the field.
   A box outline here would undo the whole point of an underline input — and it fires on the
   autofocused username field, so the page would load looking like a boxed form.
   What replaces it has to be unmistakable, so focus does three things at once: the rule doubles in
   thickness, takes the accent colour, and casts a soft accent glow. Thickness plus hue plus a
   change in area, which is what a focus indicator has to be. */
.login-input-row:focus-within {
  border-bottom: 2px solid var(--accent);
  padding-bottom: 0;      /* hold the baseline as the rule thickens */
  box-shadow: 0 6px 10px -8px var(--accent);
}

/* The password reveal. Icon-only and unlabelled by design — it sits on the rule, where a word would
   crowd the field — so the markup carries title and aria-label, as every icon-only control here
   does. BOTH glyphs are in the DOM and this class decides which is painted: the swap costs no
   layout, and there is no frame where the button is empty. */
.login-reveal {
  background: none;
  border: none;
  margin: 0;
  padding: .2rem .15rem;
  color: var(--field-placeholder);
  line-height: 1;
  display: flex;
  align-items: center;
  flex-shrink: 0;
  cursor: pointer;
  border-radius: 4px;
}
.login-reveal:hover { color: var(--accent); }
.login-reveal.is-revealed { color: var(--accent); }
.login-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.login-reveal .login-reveal__hide,
.login-reveal.is-revealed .login-reveal__show { display: none; }
.login-reveal.is-revealed .login-reveal__hide { display: inline-block; }

/* The first reference's submit: letter-spaced, uppercase, underlined. Not a filled button. */
.login-button {
  margin: 2rem auto 0;
  display: block;
  background: none;
  border: none;
  border-bottom: 2px solid var(--accent);
  color: var(--accent);
  font: inherit;
  font-size: .82rem;
  font-weight: 700;
  letter-spacing: .22em;
  text-transform: uppercase;
  padding: .35rem .8rem .5rem;
  cursor: pointer;
  border-radius: 0;
}
.login-button:hover { color: var(--accent-2); border-bottom-color: var(--accent-2); }
.login-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 3px; }

.login-error {
  color: #8c1d1d;
  background: rgba(255, 235, 235, .92);
  border: 1px solid rgba(198, 40, 40, .45);
  padding: .5rem .7rem;
  border-radius: 8px;
  margin: 0 0 .25rem;
  font-size: .84rem;
}

/* There is no prefers-reduced-motion block for this page, and that is not an omission: nothing on
   it moves, so there is nothing for that query to switch off. If motion ever comes back, the media
   query has to come back with it. */

.page-error { max-width: 40rem; margin: 15vh auto; padding: 0 1.5rem; }

/* -- Generate-key screen ------------------------------------------------------------------------ */
.input--narrow { max-width: 180px; }
.term-row { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.term-row .field-note { margin: 0; }

/* The key box. Monospace, wrapping, selectable — a 700-character base64url string that somebody
   copies, and occasionally has to eyeball against what a customer pasted back.

   The one BOXED field left in the app, and deliberately so. It is not an input: it is a rendered
   artefact somebody selects out of, and 700 characters with no box around them have no edge saying
   where the key ends — which on this screen is the difference between a key that verifies and a key
   that was copied one character short. */
.keybox {
  width: 100%;
  max-width: 900px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  color: var(--text);
  border-radius: var(--radius-card);
  padding: .75rem;
  font-size: .82rem;
  line-height: 1.45;
  word-break: break-all;
  resize: vertical;
}

/* An action that changes something hard to undo gets a visible boundary, not just a button among
   buttons. */
.danger-zone {
  border: 1px solid var(--line);
  border-left: 3px solid var(--warn);
  border-radius: var(--radius-card);
  padding: 1rem 1.25rem 1.25rem;
  margin: 1.5rem 0;
  background: var(--surface);
  box-shadow: var(--shadow-sm);
}
.danger-zone h2 { margin-top: 0; }

/* Revoke does not look like the other buttons, because it does not do what the other buttons do. */
.button--danger { border-color: var(--danger); color: var(--danger); background: var(--surface); }
.button--danger:hover { background: var(--danger); color: var(--c-primary-ink); }

.danger-zone--severe {
  border-left-color: var(--danger);
  background: color-mix(in srgb, var(--danger) 3%, var(--surface));
}

/* -- Dashboard tiles -----------------------------------------------------------------------------
   The restrained echo of the login page, and the one place in the app where a card is allowed to be
   expressive: these two numbers are the screen's headline, and both of them count a SILENT FAILURE
   — a key issued and never sent, a machine registered and never licensed. Nothing else tells you.

   What a tile is made of, and why:
     a glyph plate     the boxicon, on a tinted square, so the two tiles are told apart before a
                       word of either is read
     the number        2.4rem, tabular, the only thing on the screen set that large
     label + note      what the number counts, and what it means that it is not zero
     a top edge        3px of the brand gradient, revealed on hover

   NOTHING MOVES. The tile used to lift on a translateZ with a shadow under it; the animation was
   removed on request, the same way the login page's was. The hover cues it leaves behind — the
   brand edge and the accent border — are still there and still needed, because a tile is an
   <a href> and has to look like one; they simply apply at once instead of easing in.

   There is consequently no prefers-reduced-motion block for tiles any more. There is no motion
   left for it to reduce, and an empty override is a rule that reads as if it does something. */
.tiles {
  display: grid;
  /* 280px, per UI rework section 3 — wide enough that a tile holding a number and two lines of
     explanation does not wrap the label, and auto-fit so they fill the row at any viewport
     instead of huddling in the top-left of a 1900px screen. */
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 1rem;
  /* No `perspective`: it existed solely to give the hover's translateZ something to project
     against. With the lift gone it establishes a 3D context for nothing. */
  margin-bottom: 2rem;
}

.tile {
  position: relative;
  display: grid;
  /* The plate in a column of its own and the text in the second, so the note wraps under the label
     rather than under the glyph — which is what a plain flex row does to a two-line explanation. */
  grid-template-columns: auto minmax(0, 1fr);
  align-items: start;
  gap: .1rem 1rem;
  padding: 1.25rem 1.35rem;
  border-radius: var(--radius-panel);
  background: var(--surface);
  border: 1px solid var(--line);
  text-decoration: none;
  color: var(--text);
  overflow: hidden;          /* clips the ::before edge to the rounded corner */
  box-shadow: var(--shadow-sm);
}

/* The brand edge. Drawn as a pseudo-element rather than as a border-top, because a border changes
   the box: every tile on the row would shift 3px as one of them was hovered. Still true with the
   motion gone — a 3px border appearing on hover would jump the whole row by 3px, which is a bigger
   movement than the animation that was just removed. */
.tile::before {
  content: "";
  position: absolute; top: 0; left: 0; right: 0;
  height: 3px;
  background: linear-gradient(100deg, var(--brand-stops));
  opacity: 0;
}

/* Colour only. No lift and no shadow change — same rule the data grid's rows follow. */
.tile:hover {
  border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
}
.tile:hover::before { opacity: 1; }

/* The glyph plate spans both text rows, so it is centred against the number and its label together
   rather than sitting level with the top of a 2.4rem digit. */
.tile__icon {
  grid-row: 1 / span 2;
  /* align-items:start on the tile start-aligns every item, which would park the plate at the top
     of the two rows it spans instead of level with them. Only this one opts out. */
  align-self: center;
  display: flex; align-items: center; justify-content: center;
  width: 44px; height: 44px;
  border-radius: var(--radius-card);
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  color: var(--accent);
}
.tile__number { font-size: 2.4rem; line-height: 1.05; font-weight: 700; color: var(--accent); }
.tile__label { font-weight: 600; align-self: start; }
/* Full width under both columns: an explanation indented to clear a 44px plate reads as a caption
   for the plate rather than for the tile. */
.tile__note { grid-column: 1 / -1; color: var(--text-dim); font-size: .8rem; margin-top: .55rem; }

/* Not zero, and therefore something to do. The tile takes the alert colour throughout — rule, plate
   and number — because a warning that tints only the digit is one nobody sees from across a desk.
   Its brand edge stays off on hover: two accent colours on one card says neither. */
.tile--warn { border-left: 3px solid var(--warn); }
.tile--warn .tile__number { color: var(--warn); }
.tile--warn .tile__icon {
  background: color-mix(in srgb, var(--warn) 12%, transparent);
  color: var(--warn);
}
.tile--warn:hover { border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
.tile--warn:hover::before { opacity: 0; }

.toolbar--filters { display: flex; gap: .6rem; flex-wrap: wrap; align-items: center; }
.checkline { display: flex; align-items: center; gap: .4rem; color: var(--text-dim); }

/* =================================================================================================
   THE INSTALLER FEATURE (docs/11)

   Three patterns, and only three, because the rest of these screens are the grids, the facts lists,
   the modals and the pills every other screen already uses.
   ================================================================================================= */

/* -- A command, with its copy button. ------------------------------------------------------------
   The block a whole feature rests on: the reason SQLEXPR_x64_ENU.exe gets the wrong arguments at 6pm
   in a customer's server room is that somebody retyped 140 characters off a printout.

   `white-space: pre` and its own horizontal scroll, NOT `pre-wrap`. A wrapped command is a command
   somebody copies out of the middle of, and a soft break inside a /SAPWD="…" argument reads exactly
   like a real one. The block scrolls sideways instead — which is ugly on a 200-character line, and
   the ugliness is doing work: it says "this is one line". */
.command-block { margin: .75rem 0 1.1rem; max-width: 900px; }

.command-block__head {
  display: flex; align-items: center; justify-content: space-between;
  gap: .5rem; margin-bottom: .3rem;
}

/* "Detect", "Install", "Verify" name the box under them, so they are set as labels rather than as a
   line of body text the same size and weight as the command itself. Scoped to the block: elsewhere
   .field-caption is a form label and already sized by `.form-grid label`. */
.command-block__head .field-caption {
  font-size: .7rem;
  font-weight: 700;
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.command-block__text {
  margin: 0;
  padding: .7rem .8rem;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-card);
  font-size: .8rem;
  line-height: 1.5;
  white-space: pre;
  overflow-x: auto;
  user-select: all;
}

/* The copy button sits in the head, outside any .row-actions, so it needs the icon-btn box that
   `.row-actions .btn.icon-btn` would otherwise have given it. A bare .icon-btn renders at
   text-button width with the glyph off-centre — the same trap the grids have. */
.command-block__head .btn.icon-btn {
  justify-content: center; gap: 0;
  padding: .28rem; min-width: 28px; min-height: 28px;
}

.command-block__gaps { color: var(--s-alert); }
/* Two facts stacked in ONE cell, when the table cannot afford a column for the second.
   The checklist's Recorded cell is the case: a UTC stamp and the operator who typed it. Inline they
   fused into "2026-08-29 11:27vendor" — the stamp ends in a digit, the name begins with a letter,
   and nothing between them. Every other grid in the app gives the operator its own column; this
   table already has eight and the line break is the cheaper separator.
   `.grid td` is a fixed 40px row, so the height has to come off here or the second line clips. */
.grid td.cell-stack {
  height: auto;
  padding-top: .35rem; padding-bottom: .35rem;
  line-height: 1.35;
}
.grid td.cell-stack > span { display: block; }
.grid td.cell-stack .muted { font-size: .78rem; }
/* The stacked cell, CLAMPED. `.cell-stack` alone still lets its content ask for whatever width it
   wants, and a SHA-256 is 64 characters with no word boundary in them — as an unclamped cell it sets
   the width of the whole table the moment a file is stored. `anywhere` lowers the MIN-content width
   too, which is what stops the digest pushing the column wider than this. */
.grid td.cell-stack--clamp { max-width: 15rem; }
.grid td.cell-stack--clamp > span { overflow-wrap: anywhere; }
.grid td.cell-stack--clamp .muted { font-size: .72rem; }

/* The kit page's printable checklist, and COMMAND IS THE COLUMN THAT TAKES WHAT IS LEFT.

   Its Command cell holds a bare <pre>, and white-space:pre makes that pre contribute its LONGEST
   LINE as an intrinsic width — overflow-x:auto does not stop it ASKING, only what happens after it
   is refused. So a four-line PowerShell snippet took ~900px under `table-layout: auto` and the Step
   column beside it was crushed to about 100px, wrapping "SQL command-line utilities" down three
   lines.

   That was first fixed with `max-width: 46rem` on the pre, which fixed it from the wrong end: it
   stopped Step being crushed by stopping Command at 736px, so on a 1600px card every command
   scrolled sideways inside its own box with 200px of empty column beside it — the fault, from the
   other side.

   `table-layout: fixed` takes intrinsic width out of the argument altogether: the two narrow
   columns are STATED, Command is what is left, and the pre keeps its own sideways scroll inside a
   cell that now runs to the end of the table. Widths on the <th> only — under a fixed layout the
   first row decides the columns and the cells under it have no say. The Step number is what takes
   width back off Command, so raise it only against a real name that wraps.

   The two pen columns are `display: none` on screen, which removes their COLUMNS as well as their
   cells, so this is a three-column table here and a five-column one on paper. print.css puts the
   auto layout back for that: on 190mm of A4 "what is left" is a different sum, and the clamp has to
   go there too or the command is cut off with the rest of the sheet blank beside it. */
.kit-checklist { table-layout: fixed; }
.kit-checklist th:nth-child(1) { width: 4rem; }
.kit-checklist th:nth-child(2) { width: 26rem; }
.kit-checklist td .command-block__text { max-width: 100%; }

/* -- A TABLE THAT STANDS IN AIR INSIDE ITS CARD. --------------------------------------------------
   `.table-wrap` carries no margin of its own, deliberately: on a LIST page it is the card's only
   child and the card's padding is the whole of its spacing. On the kit page it is one block among
   several inside a section, where the paragraph above it contributes ~14px and nothing else does —
   so a bordered, scrolling box sat all but flush against the sentence introducing it and hard up
   against the card's own edge, which reads as a table that has overflowed its card rather than one
   placed in it.

   A modifier and not a change to `.table-wrap`: the frame rules in shell.css put a list page's
   grid AT the card's edges on purpose, and a margin there would break the full-height card. Kept
   small — this is breathing room, not a gutter — and print.css already zeroes every .table-wrap
   margin, so none of it reaches the paper. */
.table-wrap--inset { margin: .9rem .5rem 0; }



/* -- The run header: "11 of 16 done · 2 skipped · 1 failed". ------------------------------------
   A row of counts, not tiles. The dashboard's .tile is a 90px-tall card and six of them would be the
   whole first screen of a checklist somebody opened to read the checklist. */
.run-tally {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: .3rem 1.1rem;
  margin: 0 0 1rem;
}
.run-tally__count { font-family: var(--font-mono); font-weight: 700; font-size: 1.05rem; }
.run-tally__label { color: var(--text-dim); font-size: .8rem; }
.run-tally__item--failed .run-tally__count { color: var(--s-alarm); }
.run-tally__item--skipped .run-tally__count { color: var(--s-alert); }

/* -- The checklist's expandable step. ------------------------------------------------------------
   A native <details>, for the reason the account menu is one: it opens with no circuit and no
   script. The checklist is the screen most likely to be read on a laptop tethered to a phone in
   somebody's server room, and a disclosure that needs a websocket round-trip to open is one that
   does not open there.

   The marker is removed and the row is laid out by us; ::-webkit-details-marker is still needed for
   Safari, which ignores `list-style` on a summary. */
.step-detail { border-top: 1px solid var(--line); }
.step-detail > summary { cursor: pointer; list-style: none; padding: .2rem 0; color: var(--accent); font-size: .8rem; }
.step-detail > summary::-webkit-details-marker { display: none; }
.step-detail > summary::before { content: "▸ "; }
.step-detail[open] > summary::before { content: "▾ "; }
.step-detail__body { padding: .2rem 0 .8rem; }

/* THE BODY MUST NOT SIZE THE COLUMN IT IS IN. A <details> in a table cell contributes the width its
   open content WANTS to the column, and what is inside this one is a command block that wants 900px:
   opening one row therefore widened the Step column until Requirement, Est. and Actions scrolled out
   of sight, on a table whose whole point is the order of those rows. A max-width clamps an intrinsic
   contribution — that is the one property that does — and the command inside keeps its own sideways
   scroll, which is where a 200-character line was always going to end up.

   BOTH TABLES with an expandable step are helped by this: the profile editor's six columns in a pane,
   and the run checklist's EIGHT across the card. 28rem, not "as wide as looks nice": the clamp IS
   the column's minimum, so the number has to leave
   room for the five columns beside it in the narrowest pane this table is ever laid out in. */
.grid .step-detail__body { max-width: 28rem; }
.grid .step-detail__body .command-block { max-width: 100%; }

/* The per-site note. It is a <label> OUTSIDE any .form-grid, so nothing was stacking the caption
   above its box: the caption sat on the same line as a textarea at whatever width the UA picked
   from `cols`, which is the one control in the app that still looked like an unstyled form. */
.step-detail__body label { display: flex; flex-direction: column; gap: .25rem; font-size: .8125rem; }
.step-detail__body .field-caption {
  font-size: .7rem; font-weight: 700; letter-spacing: .07em;
  text-transform: uppercase; color: var(--text-dim);
}
.step-detail__body textarea {
  width: 100%;
  padding: .4rem .5rem;
  border: 1px solid var(--field-line);
  border-radius: var(--radius-control);
  background: var(--surface);
  color: inherit;
  font: inherit;
  line-height: 1.45;
  resize: vertical;
}
.step-detail__body textarea:hover { border-color: var(--field-line-hover); }
.step-detail__body textarea:focus,
.step-detail__body textarea:focus-visible {
  outline: none;
  border-color: var(--c-focus);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-focus) 18%, transparent);
}

/* -- The profile editor's two panes. -------------------------------------------------------------
   Checklist on the left, catalog on the right. `minmax(0, …)` on both tracks, not `1fr` alone: a
   grid track's default minimum is min-content, so the wide command text inside a <details> would
   push the left pane past the card rather than scrolling inside its own .table-wrap.

   THE RIGHT TRACK IS A FIXED RAIL, not a share. It was `2fr / 1fr`, which spent a third of a 1600px
   card on a column holding a short list of buttons — and on the common case, a profile with every
   step already on it, on ONE SENTENCE saying so — while the six-column table beside it scrolled
   inside two thirds of the width. The catalog needs the width of a step name and nothing more; the
   checklist takes everything left over, which is what a table of six columns actually wants. */
.pane-split {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 22rem);
  gap: 1.5rem;
  align-items: start;
}
/* STACKS BELOW 105rem, which looks like a strange number and is the measured one. The rail costs the
   checklist 352px plus a gap, and the table beside it wants about 965px once a row is expanded — so
   side by side only pays above roughly 1680px of window. Below that the split was costing the table
   more width than the catalog was using, and a six-column grid whose last three columns have
   scrolled out of sight is worse than a list of buttons sitting under it.

   It was 60rem, i.e. it only stacked on something close to a tablet, and every laptop in between got
   the squeezed version. */
@media (max-width: 105rem) { .pane-split { grid-template-columns: minmax(0, 1fr); } }

.pane { min-width: 0; }
.pane h3 { margin: 0 0 .5rem; font-size: .95rem; }

/* A pane's heading row: what the pane is, and on the right the count that says how full it is.
   The count is the number somebody opening a profile is actually after — "twelve of sixteen"
   answers "is anything missing?" without reading a single row. */
.pane__head { display: flex; align-items: baseline; gap: .6rem; margin: 0 0 .6rem; }
.pane__head h3 { margin: 0; }
/* The ASIDE pane, capped for the stacked case. Below the breakpoint both panes are full width, and a
   panel holding two buttons — with its count stranded 1000px away at the right of its own heading
   row — should not run the width of the card. The checklist pane is deliberately NOT capped: its
   count belongs at the right-hand end of the table it counts. */
.pane--aside > .pane__head,
.pane--aside > .panel { max-width: 40rem; }
.pane__count { margin-left: auto; color: var(--text-dim); font-size: .78rem; white-space: nowrap; }

/* The order box, which sits INSIDE a 40px grid row — the only input in the app that does. The
   underline every other field wears is unreadable there: a rule under a number, in a row of ruled
   cells, is a fourth horizontal line inside 40px. So this one keeps a box, and it is sized to what
   it holds. It was wearing `.input--narrow`, i.e. 180px, which put a text-field-width control in a
   column headed "Order" and pushed the four columns after it off the card.

   Element- and table-qualified for the reason the alignment classes are (see .grid th.grid__actions
   above): shell.css loads AFTER this sheet, so a bare class here loses to anything it says. */
.grid input.input--order {
  width: 4.25rem;
  padding: .2rem .45rem;
  text-align: right;
  border: 1px solid var(--field-line);
  border-radius: var(--radius-control);
  background: var(--surface);
  color: inherit;
  font: inherit;
}
.grid input.input--order:hover { border-color: var(--field-line-hover); }
.grid input.input--order:focus,
.grid input.input--order:focus-visible {
  outline: none;
  border-color: var(--c-focus);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-focus) 18%, transparent);
}

/* The site values, which are a form of a different shape from every other form in the app: seven or
   eight boxes holding a path, a port or an instance name, all short, all independent, and no field
   order that means anything. shell.css's `.form-grid` is two columns capped at 720px — the right
   answer for a modal and for a create form read top to bottom, and here it stacked eight one-line
   boxes down the left quarter of a 1600px card.

   DOUBLE-CLASSED (0,2,0) on purpose: shell.css loads after this sheet, so `.form-grid--values`
   alone would tie on specificity and lose. Same trap as `.grid th.grid__actions`.

   `auto-fit`, so the row closes up rather than leaving a hole when a profile has four tokens; the
   1100px cap is what keeps it three columns on a wide screen instead of a single spread-out row of
   six, which is a form nobody can read across. */
.form-grid.form-grid--values {
  max-width: 1100px;
  grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
}

/* The runner-result import, which is one WIDE box and one narrow alternative to it — so the 720px
   cap and two equal columns are both wrong for it. A paste target for a JSON document is the one
   field in this app that genuinely wants the card's width: at 720px a result wraps into a ribbon
   nobody can check before they press Read it, and the right two thirds of a 1600px card sat empty
   beside it.

   A RAIL, at the same 22rem `.detail-split` and `.pane-split` use, and for the same reason: the
   file picker is an ASIDE to the box beside it — the same bytes, arriving the other way — and it
   needs the width of a button and a file name, not a share of the card. Double-classed (0,2,0) on
   purpose: shell.css loads after this sheet, so `.form-grid--import` alone would tie and lose. Same
   trap as `.grid th.grid__actions` and `.form-grid--values` above.

   It states its own collapse for that same reason — shell.css's 640px one-column rule is (0,1,0) and
   this outranks it, so without the query below the rail would still be 22rem on a phone. It stacks
   at 72rem, where `.detail-split` stacks: under that width the paste box is worth more than the
   rail beside it. */
.form-grid.form-grid--import {
  max-width: none;
  grid-template-columns: minmax(0, 1fr) minmax(0, 22rem);
}
@media (max-width: 72rem) { .form-grid.form-grid--import { grid-template-columns: minmax(0, 1fr); } }

/* Capped, for the stacked case. In the rail the list is 320px wide by construction; stacked under
   the checklist it would otherwise run a row of full-width buttons across 1200px. */
.pane-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .4rem; max-width: 34rem; }
.pane-list li { display: flex; flex-direction: column; gap: .15rem; }
.pane-list .muted { font-size: .75rem; }

/* The two rows a dependency refusal is about. A tint rather than a border, because a border on one
   row of a grid shifts the rows under it by a pixel and the whole table appears to twitch. */
.row--offending > td { background: color-mix(in srgb, var(--s-alarm) 12%, transparent); }

/* -- Detail-page furniture: the band, the rail, the panel. ---------------------------------------
   Three pieces shared by the installer's two detail screens, written here rather than on either
   because the fault they fix was the same on both: a 1600px card holding ONE left-hand column.

   A <dl class="facts"> spends 180px on labels and then runs one short value per row down the page,
   so six scalars — a requirement, a category, a leg, an order, an estimate — became fourteen rows
   of mostly air. The paragraphs beside them had no measure at all and set across the full window.
   And the profile editor's table was squeezed into two thirds of the card while the pane beside it
   held a single sentence.

     .meta-strip    THE SCALARS, ACROSS. Label over value, as many columns as the card can hold.
                    Only for values of a few words: the band is legible because every cell in it is
                    scannable at a glance, and one sentence in there takes the whole row's height
                    and breaks that. Anything longer belongs in a section below.
     .detail-split  A main column and a RAIL. The rail carries what is true ABOUT the thing beside
                    it — a caveat, a rule, a list of names — never a second stream of content: it
                    stacks UNDER the main column below 72rem, so it has to still read as an aside
                    when it is no longer beside anything.
     .panel         A sub-surface inside the card: hairline and a soft fill, one step quieter than
                    the card's own edge. Enough to group a block; not enough to compete with it.

   `.prose` is the measure for a paragraph with no field to belong to. `.field-note`'s 52ch is right
   under a form field and cramped standing alone, and no measure at all is worse than either — the
   step page carried three explanations setting at about 180 characters a line. */
.meta-strip {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
  gap: .9rem 1.75rem;
  margin: 0 0 1.5rem;
  padding: .9rem 1.1rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-card);
  background: var(--surface-2);
}
.meta-strip__item { min-width: 0; }
/* For the ones whose value is a list rather than a word. Dropped at the narrow end, where two
   columns of anything is one column of wrapped words. */
.meta-strip__item--wide { grid-column: span 2; }
@media (max-width: 52rem) { .meta-strip__item--wide { grid-column: auto; } }
.meta-strip__label {
  display: block;
  margin-bottom: .2rem;
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--text-dim);
}
.meta-strip__value { display: flex; flex-wrap: wrap; align-items: center; gap: .3rem .5rem; }
/* .pill carries a left margin for the case it is written for — a badge AFTER some text. In the band
   the pill IS the value, and that margin reads as a stray indent under the label. */
.meta-strip__value > .pill:first-child { margin-left: 0; }
/* A machine id is 32 hex characters with no word boundary in them, and it is the one value on the
   Generate key screen a reader has to check CHARACTER BY CHARACTER against what the customer sent —
   a key signed against the wrong machine is issued, recorded and useless.
   Set as a chip rather than as running text: the box is what says "this is an identifier, compare
   it" where a bare mono run read as prose, and the extra tracking is what keeps 8 from B and 0 from
   D at a glance. Pair it with `.mono`, which brings the face and the tabular figures.
   `anywhere`, so a long id wraps inside its own cell instead of setting the width of the band. */
/* A band that follows a form carries the gap the form does not. `.form-grid` sets no bottom margin —
   it is normally the last thing in a card or a modal body — so the band's top edge landed flush on
   the underline of the field above it, and a picker sitting directly on a bordered box reads as one
   control with a second half rather than as two things. Matched to the band's own 1.5rem below, so
   it sits in even air. */
.form-grid + .meta-strip { margin-top: 1.5rem; }

/* A sentence about the BAND — the consequence of a fact in it, which is too long to be a cell (the
   band is legible only while every cell in it is scannable). The band carries the 1.5rem that
   separates it from the next section, so without this the sentence sits adrift halfway between the
   two rather than reading as the band's own footnote. The gap moves below the note instead. */
.meta-strip:has(+ .field-note) { margin-bottom: 0; }
.meta-strip + .field-note { margin: .5rem 0 1.5rem; }

.machine-id {
  display: inline-block;
  padding: .3rem .6rem;
  border: 1px solid var(--c-border);
  border-radius: var(--radius-control);
  /* Raised, so the chip lifts off the band's own fill rather than dissolving into it. */
  background: var(--c-surface-raised);
  color: var(--c-text);
  font-size: 1rem;
  letter-spacing: .09em;
  overflow-wrap: anywhere;
}

.detail-split {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 22rem);
  gap: 1.25rem 2rem;
  align-items: start;
}
@media (max-width: 72rem) { .detail-split { grid-template-columns: minmax(0, 1fr); } }

/* The same two tracks, SWAPPED: the aside leads and the main column follows. One call site — the
   Generate key screen's issued block, where the four scalars are the caption for the key beside
   them and the key is the wide thing. A modifier rather than a third shape, and it repeats the
   72rem stack because it comes after the base rule and would otherwise win inside it. */
.detail-split--rail-first { grid-template-columns: minmax(0, 22rem) minmax(0, 1fr); }
@media (max-width: 72rem) { .detail-split--rail-first { grid-template-columns: minmax(0, 1fr); } }

/* The key box carries a 900px measure for the case it is written for — a full-card block with
   nothing beside it. In the split its COLUMN is the measure, and a cap 100px short of the track
   leaves a ragged gutter between the key and the edge of the card. */
.issued-key .keybox { max-width: none; }
/* The buttons belong to the box above them, not to a form: 1.5rem of air under a textarea reads as
   a gap between two blocks. */
.issued-key .form-actions { margin-top: .85rem; }

.panel {
  min-width: 0;
  padding: .9rem 1.1rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-card);
  background: var(--surface-2);
}
.panel + .panel { margin-top: 1rem; }
.panel > :first-child { margin-top: 0; }
.panel > :last-child { margin-bottom: 0; }
.panel__title {
  margin: 0 0 .6rem;
  font-size: .7rem;
  font-weight: 700;
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--text-dim);
}
/* A fact list in a 22rem rail cannot spend 180px on its labels, so inside a panel the pairs stack.
   One component, one markup, two shapes — which is why this lives with the panel rather than as a
   modifier every call site has to remember to pass. */
.panel .facts { grid-template-columns: minmax(0, 1fr); gap: .1rem; margin: 0; }
.panel .facts dt { font-size: .72rem; letter-spacing: .05em; text-transform: uppercase; }
.panel .facts dd + dt { margin-top: .6rem; }
/* A SHA-256 is 64 unbreakable characters and the rail is 22rem. `anywhere` rather than `break-word`:
   it lowers the min-content width too, so the digest cannot push the panel wider than its track. */
.panel .facts dd { overflow-wrap: anywhere; }
.panel .field-note:first-child { margin-top: 0; }

.prose { max-width: 68ch; }
/* -- A NOTE WITH NO FIELD TO BELONG TO GETS A PAGE MEASURE, NOT A FIELD ONE. --------------------
   `.field-note` is 52ch and `.prose` is 68ch, and BOTH are measured in ch at the note's own small
   font — .82rem for a field note, so 52ch lands at roughly 300px. Under an input that is right: the
   note and its box share a left and a right edge and the eye pairs them. Standing alone on a card
   1550px wide it is a 300px column of grey against most of a screen of nothing, which does not read
   as a considered measure — it reads as a rendering fault, and it was on every detail page at once.

   THE MEASURE IS IN REM, NOT CH, and that is the fix rather than a bigger number. A ch measure
   shrinks with the font it is set in, so the smaller and greyer a note is — the notes most likely to
   be standing alone — the narrower it gets, which is exactly backwards. 74rem is ~1184px at the
   browser's 16px root: it fills a wide card, it caps on an ultrawide monitor, and it is nowhere near
   the ~270-character line that removing the limit altogether would produce (docs record that fault:
   the step page once carried three explanations setting at about 180 characters a line).

   STANDALONE IS DEFINED STRUCTURALLY, so no call site has to remember. A note inside a <label>, a
   .form-grid or a .panel is not a direct child of the card or of a section, so none of them match;
   a note that IS such a child has, by construction, no field above it. Scoped to `.content-card` so
   MODAL notes are untouched — a confirm dialog is ~40rem wide and its notes are correctly narrow,
   and most of the field-notes in this codebase live in one. */
.content-card > .field-note,
.content-card > .prose,
.content-card > section > .field-note,
.content-card > section > .prose {
  max-width: 74rem;
}


/* -- The catalog step page, below its band. -----------------------------------------------------
   .step-page is everything after the .meta-strip: four blocks that were stacked full width down a
   1400px card and each about 600px wide, so the right half of the page was empty from the first
   paragraph to the last note and the page ran past two screens on a monitor with room to spare.

   .step-page__pair puts the first two — the commands and the notes — beside each other. AUTO-FIT
   rather than a main-and-rail: neither is an aside, and a command must never be squeezed into a
   22rem track where `white-space: pre` turns it into a horizontal scrollbar with four characters
   showing. 30rem is the floor a command block still reads at; below two of those the pair is one
   column, which is the same trade .detail-split makes and needs no media query to state it.

   The two blocks under the pair stay full width on purpose. One of them already splits internally
   (what the kit must carry, against the naming rule beside it) and the other holds the stored-file
   grid; halving either just moves the scrollbar.

   The heading margins are trimmed HERE and not on the bare h2: 2rem above a small-caps label is
   right when a section is a full-width band with a table under it, and is a quarter of the pair's
   own height when four of them stack on one page. */
.step-page__pair {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(30rem, 1fr));
  gap: 0 2.5rem;
  align-items: start;
}
/* The band already sets the gap above whatever opens the body — usually the description. */
.step-page > :first-child { margin-top: 0; }
.step-page > section > h2 { margin-top: 1.5rem; }
.step-page__pair > section > h2 { margin-top: 1.25rem; }
/* The block is capped at 900px for a full-width card; in a pair column the column is the cap. */
.step-page .command-block { max-width: 100%; margin: .5rem 0 .85rem; }
.step-page .command-block:last-child { margin-bottom: 0; }
.step-page .detail-split { gap: 1rem 1.75rem; }
.step-page .panel { padding: .8rem 1rem; }
/* pre-wrap notes carry their own blank lines between paragraphs; the element's own margin on top of
   them is a second gap saying the same thing. */
.step-page .facts__notes { margin: 0; }

/* -- The profile editor, below its band. --------------------------------------------------------
   The same trim as .step-page, for the same reason and on the same page shape: a band, then blocks
   that were each a small-caps heading with 2rem of air above it. Two sections here rather than four,
   so the saving is less about the headings and more about what was above the value boxes — a
   four-line explanation running the width of the card, which is now the aside beside them.

   The value grid keeps its own three-column rule (.form-grid--values above); only the rhythm is
   tightened, and only inside this page — .form-grid's gap is every form in the app, including the
   modals, where var(--sp-4) is right. */
.profile-page > :first-child { margin-top: 0; }
.profile-page > section > h2 { margin-top: 1.5rem; }
.profile-page .form-grid--values { gap: .8rem 1.75rem; }
.profile-page .detail-split { gap: 1rem 2rem; }
/* The aside starts level with the first caption beside it, not .35rem below it. */
.profile-page .detail-split > .field-note { margin-top: 0; }

/* A short name worth COUNTING at a glance — a step's dependencies, either way round. As loose links
   they read as a sentence and three of them cannot be told from four; as chips the count is the
   shape of the row. */
.chip-row { display: flex; flex-wrap: wrap; gap: .35rem; }
.chip {
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  padding: .08rem .5rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface);
  font-size: .78rem;
  text-decoration: none;
}
a.chip:hover { border-color: var(--accent); }

/* -- A field that is not a <label>. ----------------------------------------------------------------
   `.form-grid label` is what gives a field its column layout, and almost every field is a label. The
   package field below cannot be: a label forwards a click ANYWHERE inside it to the control it names,
   and that one carries a hint and a file name as well as its control, so the file dialog would open
   on a click meant for the sentence underneath. This is the same field, without the forwarding. */
.form-grid .form-field {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  font-size: .8125rem;
  min-width: 0;
}

/* ...and the whole-row version of it. `span-2` is `.form-grid label.span-2` in shell.css — it
   reaches LABELS only — so a `.form-field` div wearing it sits in the left column at half width,
   which on the prerequisite upload put a half-width control and its four-line warning beside an
   empty right column. Stated here rather than by widening shell.css's selector: a field that is not
   a label is this sheet's invention, and the row it takes is part of that invention. */
.form-grid .form-field.span-2 { grid-column: 1 / -1; }

/* -- The package field (Versions.razor). -----------------------------------------------------------
   A bare <input type="file"> paints the platform's own control — a grey button and a grey "No file
   chosen", in the platform's font — inside a card where every other field is an underline in ours. It
   is also the only control in the app that says nothing at all until it is clicked.

   So the input keeps the work and loses the paint: it still carries `name` and still feeds the native
   multipart POST, but it is moved off-screen and a <label> wrapping it is what a click lands on. A
   label forwards a click to the control it names, which is the whole trick and needs no script.

   Off-screen and NOT display:none — a display:none control cannot be focused, and this one is still
   reached by Tab. `:focus-within` on the wrapper is what shows that focus, since the element actually
   holding it is invisible. The three overrides need `.form-grid .file-field .file-field__input` to
   outrank shell.css's `.form-grid input:not([type="checkbox"])`, which is (0,2,1); this is (0,3,0).

   Both selectors on the base rule are load-bearing, and `flex-direction` says why: this is a <label>
   inside `.form-grid`, so shell.css's `.form-grid label` — (0,1,1) — already claims it as a flex
   COLUMN, which would stack the button above the file name. `.file-field` alone is (0,1,0) and loses;
   `.form-grid .file-field` is (0,2,0) and wins. The direction is stated rather than left to the
   default for the same reason. */
.form-grid .file-field {
  position: relative;
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--sp-2);
  min-width: 0;
  padding: var(--sp-2) 2px;
  border-bottom: 1px solid var(--field-line);
  cursor: pointer;
}
.file-field:hover { border-bottom-color: var(--field-line-hover); }
.file-field:focus-within {
  border-bottom: 2px solid var(--c-focus);
  padding-bottom: calc(var(--sp-2) - 1px);   /* hold the baseline as the rule thickens */
  box-shadow: 0 6px 10px -8px var(--c-focus);
}
/* The same doubled red rule an invalid input wears, for the same reason: with no box left, the line
   under the field is the only place a state can be shown at rest. See shell.css §validation. */
.file-field.invalid {
  border-bottom: 2px solid var(--s-alarm);
  padding-bottom: calc(var(--sp-2) - 1px);
}
.file-field.invalid:focus-within {
  border-bottom-color: var(--s-alarm);
  box-shadow: 0 6px 10px -8px var(--s-alarm);
}
.form-grid .file-field .file-field__input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  border: none;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.file-field__btn {
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: .3rem;
  padding: .2rem .6rem;
  border: 1px solid var(--c-border);
  border-radius: var(--radius-control);
  background: var(--c-surface-raised);
  color: var(--c-text);
  font-size: .78rem;
  white-space: nowrap;
}
.file-field:hover .file-field__btn { border-color: var(--c-focus); }
/* One line, ellipsised, in the mono face the grid shows file names in. A build's name is routinely
   longer than half a modal, and wrapping it would grow the row every time a different zip is picked.
   `min-width: 0` on this and on `.file-field` above is what lets a flex item shrink far enough for
   the ellipsis to appear at all — without it the name pushes the field past its column instead. */
.file-field__name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: .78rem;
}
/* Nothing chosen reads as a placeholder, not as a value: same colour as an empty input's, and back in
   the body face so it cannot be mistaken for a file called "No file chosen". */
.file-field__name.is-empty { color: var(--field-placeholder); font-family: inherit; }

/* -- Paper-only cells. ----------------------------------------------------------------------------
   The Time and Initials columns on the kit page's checklist exist for a pen. An empty column on a
   SCREEN reads as a rendering fault, so they are display:none here and print.css turns them back on
   as table cells. `.no-print` is the mirror of it: chrome that is real on screen and noise on paper. */
.print-only { display: none; }
