/* style-panel.css — Phase 221 (STYLE-04 / FONT-01).
 *
 * All aesthetic styling for components/style-panel.jsx. The panel component
 * carries only layout and position numbers inline (left, top) plus fontFamily
 * on a font row so each family previews in its own face; everything you can
 * see — colours, borders, radii, spacing, type size and weight — lives here.
 * That split is not a preference: `no-inline-aesthetic-styles` is severity
 * ERROR and hard-fails tools/deploy.sh, and the Python audit at
 * tools/audit_inline_style_aesthetics.py --gate is a second, independent gate
 * on the same rule.
 *
 * DESIGN INTENT
 * -------------
 * The panel is not a new visual language. It is the canvas's card-options menu
 * with a different payload, and it deliberately reuses that surface's chrome —
 * the same elevated background, the same hairline border, the same soft drop
 * shadow, the same 13px row rhythm — so opening Style from that menu feels
 * like the menu continuing rather than a different product appearing.
 *
 * The one place it spends any boldness is the font list: each family renders
 * its own name in its own face, with a short sample line underneath. You are
 * choosing a typeface, so the list shows you typefaces instead of a column of
 * identical strings. The theme editor's font picker already works this way, so
 * this is the house pattern rather than a new idea, and it is the reason the
 * rest of this file stays as quiet as it does.
 *
 * PRIM-STYLE rules this file MUST satisfy (tools/audit_primitive_css.py --gate,
 * run by tests/plugin_spec/, which tools/deploy.sh gates via pytest):
 *   1. At least one var(--mt-*) / var(--m-*) consumption — satisfied many
 *      times below; every colour in this file is a token read.
 *   2. Zero hex literals. A hex here silently bypasses the theme system: the
 *      panel would look right on the default theme and wrong on every other.
 *   3. Zero variable declarations. Declaring a --mt-* here would shadow the
 *      theme.css value and break the theme-switch contract; token values are
 *      codegen-owned (tools/build_tokens.py from design-tokens/<theme>.json).
 *
 * Loaded BETWEEN utilities.css and theme.css — see index.html AND
 * dist-index.html.tmpl. It must be in BOTH: a <link> in only one leaves the
 * panel unstyled in production only, which is the worst place to find out
 * (feedback_manifold_dist_template_parity). Bump the ?v= cache-buster on edit,
 * or browsers serve the stale copy and the change appears not to have worked.
 */

/* === The popover shell ==================================================== *
 * position: fixed because the panel is portaled to <body> and placed at a
 * viewport point — the door it was opened from, or wherever it was last dragged
 * to. It must NOT inherit the scaled canvas transform, which would shift and
 * scale it. Mirrors .cb-card-menu.
 *
 * TICKET 146 MADE THIS A STRIP DOWN ONE EDGE FOR A DAY and ticket 150 put it
 * back, on Karl's own call: a dock spends 340px of canvas permanently to stop
 * the panel covering the card it is changing, and dragging spends nothing and
 * only when it is actually in the way. Nothing reserves room on #manifold-root
 * any more, and the canvas is the same width with the panel open as closed.
 * ------------------------------------------------------------------------- */

.msp-panel {
  /* HOW FAR THE REACH PICKER IS PULLED UP over the panel's own padding, named
   * once because two rules need the same number: the picker itself, which uses
   * it to sit flush while that padding scrolls through underneath, and the
   * By-setting rail, which has to stop at the picker's resting BOTTOM and so
   * needs the picker's height less this lift. A second copy of it — in the
   * other rule or, worse, read back out of the cascade in JS — is the copy
   * that goes stale the day the padding changes. */
  --msp-scope-lift: 6px;
  position: fixed;
  z-index: 51; /* one above .cb-card-menu so it opens over the menu it came from */
  display: flex;
  flex-direction: column;
  width: 300px;
  /* A BACKSTOP, NOT THE CAP. The real cap is an inline max-height written by
   * fitPanelBox in style-panel.jsx: the room actually left underneath the panel
   * at its current top, which is the only number that keeps its bottom edge on
   * screen. This rule cannot know where the panel sits, and on its own it let a
   * panel placed at top:400 on an 800px screen be 784px tall and end 384px
   * below the bottom edge — Karl reported exactly that on 2026-07-27, with Save
   * off screen and unreachable.
   *
   * It stays because it covers the frame before React has measured anything,
   * and it is safe to keep for one reason worth stating: the inline value is
   * `viewportHeight - top - 8` and `top` is never below 8, so the inline cap is
   * always the smaller of the two and this rule can never bind first.
   * tests/222-panel-fit.test.js asserts that relationship across every screen
   * size, so it stays true rather than merely being true today. */
  max-height: calc(100vh - 16px);
  /* The panel scrolls inside itself when an open group is taller than the room
   * the cap leaves it. The reach picker and the commit bar are sticky at its
   * two ends, so what scrolls is the rows between them and never the two
   * controls that decide what the rows are about and what happens to them. */
  overflow-y: auto;
  padding: 6px;
  background: var(--mt-bg-elev);
  border: 1px solid var(--mt-border-strong);
  border-radius: var(--cb-tb-radius, 8px);
  box-shadow: 0 8px 24px color-mix(in srgb, var(--mt-bg-deep) 50%, transparent);
}

/* The top line: what is about to change, and the handle that moves the panel
 * off whatever it is covering (ticket 150).
 *
 * THE WHOLE ROW IS THE HANDLE. The header is the only block in the panel with
 * nothing in it to press — every other row is a control, and a drag armed on a
 * control is a drag that eats the click. The grip glyph says so; the row is
 * what catches the pointer, because the glyph is 10px wide and the row is the
 * width of the panel.
 *
 * IT IS NOT STICKY, unlike the reach picker directly below it and the commit
 * bar at the far end. That is the right trade: a panel scrolled down is a panel
 * being worked in rather than moved, and pinning a third block at the top of a
 * 680px popover costs 30px of the rows on the screen where they are already
 * tightest. The two blocks that DO stay put are the ones a person touches
 * repeatedly while working — what a change reaches, and what happens to it. */
.msp-header {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 6px 8px 8px;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--m-text-muted);
}

.msp-header--grab {
  cursor: grab;
  /* A pointerdown that the browser may also read as the start of a scroll
   * never reaches the drag on a touch screen. This says the row is not a
   * scroller, so the gesture is ours from the first move. */
  touch-action: none;
  /* A drag across a header otherwise leaves the title selected and blue for
   * the rest of the open. preventDefault on the press covers the mouse; this
   * covers the cases it does not reach. */
  user-select: none;
}

.msp-header--grab:active {
  cursor: grabbing;
}

.msp-header-title {
  flex: 1 1 auto;
  min-width: 0;
}

/* THE GRIP. Six dots in two columns — the one drag affordance that needs no
 * words in any product, which is why it is drawn rather than labelled.
 *
 * Drawn in the box's own background rather than as a glyph: a character would
 * take the header's uppercase, its letter-spacing and whatever face the theme
 * puts on the panel, and would land on a different baseline in each one. Two
 * repeating radial gradients are the same three dots per column at every theme
 * and every text size. It is --m-text-muted at 55%, so it reads as texture
 * beside the title rather than as a second label competing with it. */
.msp-grip {
  flex: 0 0 auto;
  align-self: center;
  width: 10px;
  height: 14px;
  background-image:
    radial-gradient(circle at 1.5px 1.5px,
      color-mix(in srgb, var(--m-text-muted) 55%, transparent) 1.5px, transparent 1.6px),
    radial-gradient(circle at 1.5px 1.5px,
      color-mix(in srgb, var(--m-text-muted) 55%, transparent) 1.5px, transparent 1.6px);
  background-size: 3px 5px;
  background-position: 0 1px, 6px 1px;
  background-repeat: repeat-y;
}

/* === CHANGE: how far this change reaches (Phase 223, STYLE-02) ============ *
 * The first thing in the panel, because it is the first thing to decide. Four
 * choices since ticket 104 — The page, All cards, This tab, This card — and the
 * count stays on screen the whole time the tick-boxes below are being set.
 *
 * WHY THIS IS NOT THE OLD PICKER'S LOOK
 * -------------------------------------
 * The shipped .cb-apply-segment is mono, 11px, weight 700, uppercase, with
 * 0.04em tracking. Copying it here would be wrong twice over:
 *
 *   1. It would collide with the heading directly above it. .msp-sub-head is
 *      also 11px/600/uppercase/0.04em, so CHANGE sitting on top of
 *      THIS CARD · THIS TAB (7) · ALL CARDS (46) reads as two headings rather
 *      than as a heading and its control.
 *   2. This panel has no monospace anywhere. One mono control would read as
 *      pasted in from another product — which, until Phase 225 retires the old
 *      picker, it literally would have been.
 *
 * So the segments sit one step down from a row label: 11px, weight 400,
 * sentence case, no tracking. Weight 600 marks the chosen one, which is this
 * panel's own way of saying "this one" (.msp-family.is-selected,
 * .msp-saved-item.is-chosen).
 *
 * The ONE thing lifted from the old picker is its chosen segment's 13% accent
 * wash. Karl already reads that wash as "this segment is chosen" on a control
 * doing the same job, and a second way of saying the same thing is a second
 * thing to learn.
 * ------------------------------------------------------------------------- */

.msp-scope {
  display: flex;
  flex-direction: column;
  /* The one new divider in this phase. The block is a decision and the rows
   * below it are the details; one hairline says so. Same weight and token the
   * footer's own rule uses. */
  border-bottom: 1px solid var(--mt-border);
  padding-bottom: 6px;
  /* TICKET 146: IT STAYS PUT WHILE THE ROWS UNDER IT SCROLL, the same way the
   * commit bar does at the other end. It decides what everything below it is
   * about — one card, this tab, all cards, the page — and a panel scrolled down
   * to a long font list with the reach off the top is a panel that has stopped
   * saying what a change is going to touch. (Kept by 150 with the rest of that
   * ticket's scrolling behaviour; only the dock went.)
   *
   * Pulled up over the panel's own 6px padding, which scrolls through, and it
   * puts that padding back on its own top edge; without that there is a 6px
   * slot above the block showing the content scrolling behind it. The header
   * above it is not sticky and does scroll away: it names the panel, which is
   * not a fact that changes, and pinning two blocks at the top of a laptop-
   * height panel is 90px of the rows gone. */
  position: sticky;
  top: calc(-1 * var(--msp-scope-lift));
  z-index: 1;
  padding-top: var(--msp-scope-lift);
  background: var(--mt-bg-elev);
}

/* Identical to .msp-sub-head — same kind of thing, a
 * name over a group of settings, so it takes the same type rather than a new
 * one. */
.msp-scope-head {
  padding: 8px 8px 2px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--m-text-muted);
}

/* One bordered track, four segments sharing it, TWO BY TWO (ticket 104).
 *
 * WHY THE ROW BECAME A GRID. Three labels measured about 243px together and sat
 * on one line at the panel's 300px with room to spare. `The page` makes four,
 * and four measure past 300px before either count reaches three digits — so one
 * line means either an ellipsis on labels that are already two words, or a
 * panel that grows for the sake of a picker. Two rows of two costs one line of
 * height and keeps every label whole at every count.
 *
 * The order is the same in the grid as it is in the source and in the Tab
 * order: The page · All cards / This tab · This card, widest reach first.
 *
 * Inset 8px so its edges line up with the text of every row above and below. */
.msp-scope-segments {
  display: grid;
  grid-template-columns: 1fr 1fr;
  margin: 2px 8px 0;
  border: 1px solid var(--mt-border-strong);
  border-radius: var(--m-radius-sm, 2px);
  overflow: hidden;
}

/* Every segment is allowed to shrink and none may wrap. Half the track each,
 * which is 142px at the panel's 300px — the widest label (`All cards (999)`)
 * measures about 108px, so the ellipsis is a backstop for a font this was not
 * measured in, not the plan. */
.msp-scope-seg {
  min-width: 0;
  overflow: hidden;
  padding: 6px 8px;
  border: none;
  background: transparent;
  font-size: 11px;
  font-weight: 400;
  letter-spacing: normal;
  text-transform: none;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
  text-overflow: ellipsis;
  white-space: nowrap;
  cursor: pointer;
  transition: background 120ms ease-out, color 120ms ease-out;
}

/* The dividers inside the track: one down the middle and one across it. Drawn
 * on the left of the right-hand column and under the top row rather than as a
 * trailing border on every segment — a trailing border on the last of a row
 * doubles up with the track's own edge. */
.msp-scope-seg:nth-child(even) {
  border-left: 1px solid var(--mt-border);
}

.msp-scope-seg:nth-child(-n + 2) {
  border-bottom: 1px solid var(--mt-border);
}

.msp-scope-seg:hover {
  background: color-mix(in srgb, var(--mt-fg) 8%, transparent);
  color: var(--mt-fg);
}

/* Inset, because the segments sit flush inside a bordered track and an outset
 * ring would be drawn over its neighbour. */
.msp-scope-seg:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: -2px;
}

.msp-scope-seg.is-active {
  background: color-mix(in srgb, var(--mt-accent) 13%, transparent);
  color: var(--mt-accent);
  font-weight: 600;
}

/* A choice this card cannot make — every dashboard header card is on no tab, so
 * "this tab" and "all cards" have no honest answer for it. The same 35% of the
 * foreground every other greyed thing in this panel uses, with the reason as
 * visible text in the sub-note slot below. One grey, one pattern. */
.msp-scope-seg.is-disabled {
  color: color-mix(in srgb, var(--mt-fg) 35%, transparent);
  cursor: default;
}

.msp-scope-seg.is-disabled:hover {
  background: transparent;
  color: color-mix(in srgb, var(--mt-fg) 35%, transparent);
}

/* The explanatory tail of the block: what a wider choice will do, or why the
 * wider choices are unavailable. Same 11px muted wrapping slot as the palette
 * note and every greyed reason, one step tighter above because it hangs off the
 * control immediately over it. */
.msp-scope-note {
  padding: 2px 8px 6px;
  font-size: 11px;
  line-height: 1.4;
  color: var(--m-text-muted);
}

/* -------------------------------------------------------------------------
 * Ticket 144 — Find by: which way the list below is indexed. (The number is
 * written out rather than hashed because a `#` followed by three digits reads
 * as a colour literal to tools/audit_primitive_css.py, which fails the deploy
 * on one — the same reason every other ticket in this file is cited that way.)
 *
 * IT IS THE SAME CONTROL AS THE ONE ABOVE IT, ON PURPOSE. The reach picker is
 * the other thing in this panel that decides what the rows underneath are
 * about, and a bordered track with an accent wash on the chosen segment is
 * already how Karl reads that decision. A second idiom for the same kind of
 * choice, six lines below the first, would be a second thing to learn — which
 * is the complaint this feature answers, not a shape to repeat. So: same track,
 * same 11px segment type, same 13% wash, same inset focus ring.
 *
 * TWO DIFFERENCES, AND BOTH ARE THE CONTROL BEING SMALLER. One row rather than
 * two, because there are two positions and they fit; and no hairline under the
 * block, because the block above already drew the one line that separates the
 * decisions from the details, and a second rule six lines below it would cut
 * the panel into strips.
 * ------------------------------------------------------------------------- */

.msp-findby {
  display: flex;
  flex-direction: column;
}

/* The same name-over-a-group slot as .msp-scope-head, .msp-sub-head and
 * .msp-sub-head. Same kind of thing, same type. */
.msp-findby-head {
  padding: 8px 8px 2px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--m-text-muted);
}

/* One bordered track, two segments sharing it. Inset 8px so its edges line up
 * with the text of every row above and below, exactly as the reach picker's
 * track does. */
.msp-findby-segments {
  display: grid;
  grid-template-columns: 1fr 1fr;
  margin: 2px 8px 6px;
  border: 1px solid var(--mt-border-strong);
  border-radius: var(--m-radius-sm, 2px);
  overflow: hidden;
}

.msp-findby-seg {
  min-width: 0;
  overflow: hidden;
  padding: 6px 8px;
  border: none;
  background: transparent;
  font-size: 11px;
  font-weight: 400;
  letter-spacing: normal;
  text-transform: none;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
  text-overflow: ellipsis;
  white-space: nowrap;
  cursor: pointer;
  transition: background 120ms ease-out, color 120ms ease-out;
}

/* One divider, down the middle. Drawn on the left of the second segment rather
 * than as a trailing border on the first, which would double up with the
 * track's own edge. */
.msp-findby-seg + .msp-findby-seg {
  border-left: 1px solid var(--mt-border);
}

.msp-findby-seg:hover {
  background: color-mix(in srgb, var(--mt-fg) 8%, transparent);
  color: var(--mt-fg);
}

/* Inset, because the segments sit flush inside a bordered track and an outset
 * ring would be drawn over its neighbour. */
.msp-findby-seg:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: -2px;
}

.msp-findby-seg.is-active {
  background: color-mix(in srgb, var(--mt-accent) 13%, transparent);
  color: var(--mt-accent);
  font-weight: 600;
}

/* === The locked row ======================================================= *
 * `3 are locked   (Include) (Skip)` — shown only when there is something locked
 * to hold back, and never at This card scope. At zero locked cards there is no
 * row at all: a "0 are locked" placeholder is the kind of noise that makes the
 * lines that matter stop being read.
 *
 * The two actions are mini buttons rather than a second bordered track: they
 * take .msp-btn--small's padding, because that is the size an action inside a
 * settings row already is in this panel.
 * ------------------------------------------------------------------------- */

.msp-locked-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 6px 8px 0;
}

.msp-locked-label {
  flex: 1 1 auto;
  font-size: 11px;
  color: var(--m-text-muted);
}

.msp-locked-segments {
  display: flex;
  flex: 0 0 auto;
  gap: 2px;
}

.msp-locked-seg {
  padding: 3px 8px;
  border: 1px solid var(--mt-border-strong);
  border-radius: var(--m-radius-sm, 2px);
  background: transparent;
  font-size: 11px;
  font-weight: 400;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
  cursor: pointer;
  transition: background 120ms ease-out, color 120ms ease-out;
}

.msp-locked-seg:hover {
  background: color-mix(in srgb, var(--mt-fg) 8%, transparent);
  color: var(--mt-fg);
}

.msp-locked-seg:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: 1px;
}

/* The same "this one" the scope segments use. Skip is chosen when the row first
 * appears: a padlock that has to be un-chosen to take effect is not a padlock. */
.msp-locked-seg.is-active {
  border: 1px solid var(--mt-accent);
  background: color-mix(in srgb, var(--mt-accent) 13%, transparent);
  color: var(--mt-accent);
  font-weight: 600;
}

/* === The four element rows ================================================ */

.msp-element {
  display: flex;
  flex-direction: column;
}

.msp-element-row {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 28px;
  padding: 6px 8px;
  border-radius: var(--m-radius-sm, 2px);
  font-size: 13px;
  font-weight: 400;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
  background: transparent;
  cursor: pointer;
  transition: background 120ms ease-out, color 120ms ease-out;
}

.msp-element-row:hover {
  background: color-mix(in srgb, var(--mt-fg) 8%, transparent);
  color: var(--mt-fg);
}

.msp-element-row:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: -2px;
}

/* A row for an element this card cannot use yet. Greyed and non-interactive,
 * but never silent (D-06), in whichever shape the row is drawn in: an element
 * row carries `none here` beside its count, a rail stop the fuller sentence
 * under its name. Karl must never have to wonder whether a greyed row is
 * broken. A reason you have to hover to find does not answer that question. */
.msp-element-row.is-disabled {
  color: color-mix(in srgb, var(--mt-fg) 35%, transparent);
  cursor: default;
}

.msp-element-row.is-disabled:hover {
  background: transparent;
  color: color-mix(in srgb, var(--mt-fg) 35%, transparent);
}

/* The picked row, in either shape. The same 13% accent wash and 600 weight the
 * reach picker's chosen segment carries, because it is the same fact — this
 * one, of these five or seven — and Karl already reads that wash that way. A
 * second way of saying "this one" would be a second thing to learn.
 *
 * It matters more here than it did on the rail alone: under `By element` the
 * controls are a block below the whole list, so the wash is what ties the
 * `Change card edges` heading to the row it came from. */
.msp-element-row.is-chosen {
  background: color-mix(in srgb, var(--mt-accent) 13%, transparent);
  color: var(--mt-accent);
  font-weight: 600;
}

/* Hover has to stay legible on the chosen row: without this the rule above
 * wins on source order and pointing at the picked row does nothing at all. */
.msp-element-row.is-chosen:hover {
  background: color-mix(in srgb, var(--mt-accent) 20%, transparent);
  color: var(--mt-accent);
}

/* Why this row's count is a zero, on the row itself (ticket 153). It replaces the
 * sentence that used to sit under an element row: full width has room for the
 * count and the words on one line, and one line per row is what keeps seven
 * rows readable as an inventory. The rail keeps the sentence, because 84px of
 * column has room for nothing beside the name.
 *
 * The compact reason type — 11px, muted, the same slot .msp-saved-part-reason
 * and the mark legend sit in — so it reads as a fact about the row rather than
 * as something to press. Not .msp-element-reason's 12px: that one owns a line
 * of its own under the row, and this one shares a 28px line with a 13px label
 * and a count chip. */
.msp-element-none {
  font-size: 11px;
  color: var(--m-text-muted);
}

/* The rail's is the only shape that draws one, since ticket 153 — an element row
 * says `none here` beside its count instead. The padding is the rail's, in the
 * rail's own rule below; it used to be indented 32px here to clear a disclosure
 * triangle that no row draws any more. */
.msp-element-reason {
  font-size: 12px;
  color: var(--m-text-muted);
}

/* === The per-element mark: this part is the card's own (D-16) ============= *
 * A reserved 12px column at the RIGHT end of every element row, holding a 5px
 * dot when that part is the card's own and nothing when it is not.
 *
 * RESERVED ON EVERY ROW, MARKED OR NOT. The gear's check gutter says the same
 * thing in its own comment and it is the same reason: "the ✓ on the active row
 * and the blank on inactive rows occupy the same width, so every theme name
 * lines up at one indented x". Here it is the rows that must not shift as marks
 * come and go — a column that appeared only on marked rows would move every row
 * under it each time a part was put back.
 *
 * RIGHT-ALIGNED, NOT A LEFT GUTTER, despite the left gutter being the more
 * familiar mechanic. .msp-element-row already opens with a disclosure triangle
 * at a fixed inset, and inserting a gutter before it moves every shipped row. A
 * right column still gives the scannable vertical alignment that made the
 * mechanic worth borrowing. The mechanic transfers; the side does not.
 *
 * NEVER ACCENT, IN ANY STATE. This is STATUS, not interaction — accent is
 * reserved for things you can point at, and card-builder-helpers.css writes the
 * same rule down twice for the lock glyph and the z-badge. 60% here is one step
 * quieter than the canvas mark's 70%, because a 28px panel row is a much
 * smaller field than a card. Do not invent a third grey; 221's 35% stays the
 * only greyed-out value.
 *
 * --m-radius-full may not exist in every theme, so it carries its fallback the
 * same way var(--m-radius-sm, 2px) does everywhere else in this file.
 * ------------------------------------------------------------------------- */

.msp-element-mark {
  display: flex;
  flex: 0 0 12px;
  align-items: center;
  justify-content: flex-end;
}

.msp-element-mark-dot {
  width: 5px;
  height: 5px;
  border-radius: var(--m-radius-full, 9999px);
  background: color-mix(in srgb, var(--mt-fg) 60%, transparent);
}

/* Rendered only when at least one row is marked. A bare dot with a hover-only
 * explanation fails 221 D-06's standing rule, and a legend for an empty set is
 * clutter. Same 11px muted slot as .msp-saved-part-reason. */
/* issue 100 — the off-theme line and its way back, above the element rows.
 *
 * A ROW, not a banner. The sentence uses .msp-element-note verbatim — the same
 * size, colour and inset every other note in this panel uses — and the button is
 * the panel's existing small button. Nothing new was invented: a card that has
 * left the theme is a FACT to read, not an alarm, and giving it a colour of its
 * own would make three cards on a dashboard shout while the other 84 whisper.
 *
 * The note takes the free space and the button is pushed to the trailing edge on
 * its own baseline, which is why the two are aligned to `center` rather than
 * `baseline`: the sentence wraps to two lines on a narrow panel and a baseline
 * alignment would leave the button hanging off the first of them.
 *
 * The note's own bottom padding is dropped here — inside this row the spacing
 * belongs to the row, and keeping both would make the gap under the button
 * larger than the gap under any other note in the panel. */
.msp-offtheme {
  display: flex;
  gap: 8px;
  align-items: center;
  padding: 2px 8px 8px;
}

.msp-offtheme .msp-element-note {
  flex: 1;
  padding: 0;
}

.msp-offtheme .msp-btn {
  flex: 0 0 auto;
}

.msp-element-mark-legend {
  padding: 2px 8px 6px;
  font-size: 11px;
  line-height: 1.4;
  color: var(--m-text-muted);
}

/* The disclosure triangle, rotated when the row is open. */
.msp-tri {
  flex: 0 0 auto;
  fill: currentColor;
  transition: transform 120ms ease-out;
}

.msp-tri--open {
  transform: rotate(90deg);
}

.msp-element-label {
  flex: 1 1 auto;
}

/* === The By-setting rail (ticket 152) =========================================== *
 * Five group names down a narrow column with their counts under them, and the
 * chosen group's controls in the pane beside them. It replaces the accordion
 * under `By setting` only; `By element` keeps the disclosure list.
 *
 * WHAT IT BUYS, AND WHY THE ACCORDION COULD NOT. All five counts stay on screen
 * while you work in one of them. Open Frame in the accordion and the four
 * groups under it are pushed down by however tall Frame's controls are, off the
 * bottom of a panel that is already capped to the room under it — so "which
 * group has anything to change" and "change it" were never answerable at the
 * same moment. Beside a rail, the counts sit at a fixed offset whatever is
 * open, because the pane grows downward alongside the rail rather than through
 * it. That is complaint 1 of ticket 138, and the counts alone only half-answered it.
 *
 * THE WIDTH IS THE WHOLE DESIGN PROBLEM. The panel is 300px and stays 300px —
 * that number is load-bearing in eight other rules in this file (the reach
 * picker's 2x2 grid, the chip strip, the commit bar's two buttons), and a panel
 * that changed width when the toggle was flipped would move every one of them
 * for a layout decision that is not about them. So the rail is charged against
 * the controls: 84px for the names, leaving about 200px of pane. Every control
 * in this panel is a full-width row — a label over a slider, a select, a font
 * name — so they narrow rather than break, which is the one reason this fits at
 * all.
 *
 * IT IS STICKY, AND THE STRIP ABOVE IT IS NOT. Those look like opposite calls
 * on one question and they are not, because they cost opposite things. The
 * `Styles` strip is up to 150px of content stacked ABOVE the rows: pinning it
 * spends that 150px of the rows for as long as the panel is open, which is the
 * space this ticket exists to give back, and it is why Karl's second note on
 * ticket 152 argued against it. The rail is BESIDE the pane. Pinning it spends
 * nothing at all — no row loses a pixel — and it buys the only thing that
 * matters here, which is that the counts survive the scroll. Cost nothing, buy
 * the point of the ticket: that is not the same trade as the strip's, and
 * deciding them the same way would have been the mistake.
 *
 * The first build of this rail was not sticky, on the argument that the counts
 * are on screen when a group is opened, which is what the ticket's words ask
 * for. That was too literal. Frame's controls end about 100px below a capped
 * panel at 1280x800 — measured, in Karl's browser — so USING Frame means
 * scrolling, and the counts were gone at exactly the moment the question they
 * answer is being asked.
 * ------------------------------------------------------------------------- */

.msp-rail-split {
  display: flex;
  gap: 6px;
}

/* IT STICKS, AND THAT IS THE HALF OF THIS TICKET A STILL PICTURE CANNOT SHOW.
 * Frame's controls run about 100px past the bottom of a capped panel at
 * 1280x800 and Watermark's about 50px, so reaching them means scrolling — and a
 * rail that scrolled away with them would answer "which group has anything to
 * change" right up until you reached for the change, which is the moment the
 * question is actually being asked.
 *
 * `top` is a MEASURED number handed in from the component, not a constant here.
 * It cannot stick at 0: the reach picker above it is sticky too and paints its
 * own background, so a rail stopping at the top of the scroller would slide
 * underneath it and lose its first rows behind it. It has to stop at that
 * block's bottom edge — a height that changes with the reach, because `All
 * cards` adds a sentence about the cards that follow the page. See the effect
 * that measures it in style-panel.jsx. The 0 fallback is the frame before the
 * first measurement and reads as "not sticky", which is the old behaviour
 * rather than a broken one.
 *
 * align-self: flex-start is REQUIRED, not a preference. Stretched, the rail's
 * box is as tall as the pane — and a sticky box taller than the space it
 * scrolls in cannot stick at all, so on the two groups that need this most it
 * would silently do nothing. Which is why the hairline between the columns is
 * the PANE's border and no longer the rail's: the pane is full height and stays
 * put, so the divider runs the whole way down while the rail slides. On the
 * rail it would have travelled with it and stopped under `Fonts`.
 *
 * No background wash on the rail: it is part of the panel, not a chrome of its
 * own, and a second surface colour in a 300px popover reads as a second panel.
 * It does not need one to stay legible when stuck, because it never overlaps
 * the pane — they are two columns, not two layers. */
.msp-rail {
  display: flex;
  /* min-width: 0 is not decoration here. A flex item's automatic minimum size
   * is its MIN-CONTENT width, which overrides the basis — so without this the
   * rail takes whatever its widest unbreakable word needs (measured at 131px on
   * the deployed panel, against a basis of 84) and the pane is left with less
   * room than the rail. The basis is only authoritative once the automatic
   * minimum is switched off. */
  flex: 0 0 84px;
  min-width: 0;
  flex-direction: column;
  align-self: flex-start;
  position: sticky;
  /* The reach picker's resting bottom: its height, measured and handed in by
   * the component, less the lift it is pulled up by. Both halves come from the
   * one place that knows them — the height from the box, the lift from the rule
   * above that applies it. With no height yet the sum is negative and clamps to
   * 0, which is the not-yet-sticky frame before the first measurement. */
  top: max(0px, calc(var(--msp-scope-h, 0px) - var(--msp-scope-lift)));
}

/* The hairline is the same one the reach picker wears to separate a decision
 * from its details — here turned on its side, and carried by the pane for the
 * reason the rail's own comment gives.
 *
 * min-width: 0 is what lets a long select or font name shrink inside the pane
 * instead of pushing the rail off the panel's left edge — a flex item's default
 * min-width is its content, which in a 200px column is a horizontal scrollbar
 * on the whole panel. */
.msp-rail-pane {
  flex: 1 1 auto;
  min-width: 0;
  padding-left: 6px;
  border-left: 1px solid var(--mt-border);
}

/* A rail stop: the name on its own line, the count and the card's-own mark on
 * the next. Two rows of a grid rather than a wrapped flex row, so the count
 * always lands under the name and the mark always lands at the right edge —
 * including on a stop whose count is absent, which is a card whose reach nobody
 * could measure. */
.msp-element-row--rail {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 2px 4px;
  height: auto;
  padding: 6px 6px 6px 8px;
}

.msp-element-row--rail .msp-element-label {
  grid-column: 1 / -1;
  /* The five names are the index. They wrap rather than truncate: `Watermark`
   * is the longest at about 66px and fits, but a theme with a wider face must
   * show the whole word rather than `Waterma…` — an index you cannot read is
   * not an index. */
  overflow-wrap: break-word;
}

.msp-element-row--rail .msp-count {
  grid-column: 1;
  justify-self: start;
}

.msp-element-row--rail .msp-element-mark {
  grid-column: 2;
}

/* The chosen stop wears .msp-element-row.is-chosen with every other picked row
 * in this panel — one accent wash, defined once, up in the element-row block.
 * Ticket 152 stated it a second time here for the rail alone; ticket 153 gave the element
 * rows a picked state too, and two copies of one wash is the drift this whole
 * feature is against. */

/* A greyed group's reason, and since ticket 153 the rail is the only shape that
 * draws one — an element row says `none here` beside its count instead, on the
 * row's one line, which an 84px column has no room for. The sentence wraps to
 * three or four lines here, which
 * is why the rail is where the Watermark row's "This card has no background
 * logo." is read. It stays under the name it is about rather than moving into
 * the pane: a reason you have to press a row to read fails 221 D-06 exactly as
 * a hover tooltip does, and a greyed stop with no visible reason is the "is
 * this broken?" moment the rule exists to prevent. */
.msp-element--rail .msp-element-reason {
  padding: 0 6px 6px 8px;
  overflow-wrap: break-word;
}

/* === The By-element inventory (ticket 153) ================================ *
 * The counted list of things on the cards, whole, with the picked row's
 * controls in a block underneath it.
 *
 * WHAT IT BUYS, AND WHY THE DISCLOSURE COULD NOT. The controls used to render
 * INSIDE the picked row, so picking `card edges` pushed the six rows under it
 * down by however tall its controls are — out of a panel already capped to the
 * room beneath it. That is ticket 152's complaint with the nouns changed, and the
 * answer is the same shape: take the controls out of the list.
 *
 * IT IS NOT PINNED, WHICH IS THE OPPOSITE CALL FROM THE RAIL'S. Those look like
 * opposite answers to one question and they are not, because they cost opposite
 * things. The rail sits BESIDE its pane: pinning it spends no row a pixel. This
 * list sits ABOVE its block, so pinning seven rows spends about 200px of a
 * capped panel for as long as it is open — which is the room this ticket exists
 * to give back, and the same trade the `Styles` strip already lost.
 * ------------------------------------------------------------------------- */

.msp-element-list {
  display: flex;
  flex-direction: column;
}

/* The picked row's controls. The hairline is what stops the heading reading as
 * an eighth row of the list above it. */
.msp-element-pane {
  margin-top: 6px;
  border-top: 1px solid var(--mt-border);
}

/* The heading over them, in the panel's own head — the same 11px muted cap the
 * reach picker, `Find by` and the parts list all wear. A 300px panel with two
 * heading dialects is a second thing to learn, which is the complaint this
 * whole feature answers. */
.msp-element-pane-head {
  padding-top: 8px;
}

/* === The three font roles ================================================= */

.msp-fonts {
  display: flex;
  flex-direction: column;
  padding: 0 0 4px 8px;
}

.msp-role {
  display: flex;
  flex-direction: column;
}

/* Role row: the name on the left, what the card uses now on the right. The
 * current value is the answer to "what is this set to?", so it reads at a
 * glance without opening the list. */
.msp-role-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  padding: 6px 8px;
  border: none;
  border-radius: var(--m-radius-sm, 2px);
  background: transparent;
  font-size: 13px;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
  text-align: left;
  cursor: pointer;
  transition: background 120ms ease-out;
}

.msp-role-row:hover {
  background: color-mix(in srgb, var(--mt-fg) 8%, transparent);
}

.msp-role-row:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: -2px;
}

.msp-role-label {
  flex: 0 0 auto;
}

.msp-role-current {
  flex: 1 1 auto;
  overflow: hidden;
  font-size: 12px;
  color: var(--m-text-muted);
  text-align: right;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* === The family list ====================================================== *
 * The signature of this panel. Each family previews in its own face, so
 * picking a typeface is a direct comparison rather than reading a list of
 * names. Capped and scrollable so a long list of uploaded families cannot
 * grow the popover past the viewport.
 * ------------------------------------------------------------------------- */

.msp-family-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 220px;
  overflow-y: auto;
  padding: 2px 4px 6px;
}

.msp-family {
  display: flex;
  flex-direction: column;
  gap: 1px;
  padding: 6px 8px;
  border: 1px solid transparent;
  border-radius: var(--m-radius-sm, 2px);
  background: transparent;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
  text-align: left;
  cursor: pointer;
  transition: background 120ms ease-out, border-color 120ms ease-out;
}

.msp-family:hover {
  background: color-mix(in srgb, var(--mt-fg) 8%, transparent);
}

.msp-family:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: -2px;
}

.msp-family.is-selected {
  border: 1px solid var(--mt-accent);
  color: var(--mt-fg);
}

.msp-family-name {
  font-size: 13px;
}

/* Ticket 101 — the two groups inside a slot's list: the theme's fonts, then a font of
 * its own. A heading, not a divider: the difference between them is what the
 * whole ticket is about, and a hairline would say "there are two of these"
 * without saying which is which.
 *
 * Deliberately the same muted, small, uppercase treatment the panel already uses
 * for a group of settings, so the list gains a level of structure rather than a
 * second visual language. The first heading takes no top margin because it sits
 * directly under Follow the dashboard, which is already spaced. */
.msp-family-group {
  padding: 8px 8px 2px;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--m-text-muted);
}

/* The sentence under "A font of its own". It says what picking one DOES, which
 * is the fact a person needs before they pick rather than after — the same rule
 * the greyed element rows follow (D-06): a reason you have to go looking for
 * does not answer the question. */
.msp-family-group-note {
  padding: 0 8px 4px;
  font-size: 11px;
  line-height: 1.4;
  color: var(--m-text-muted);
}

/* The sample line. Deliberately includes digits: the Numbers role is the one
 * Karl changes most, and a sample with no digits in it says nothing about the
 * thing he is actually looking at. */
.msp-family-sample {
  font-size: 11px;
  color: var(--m-text-muted);
}

/* === The palette list ===================================================== *
 * Phase 222 (COLOR-01). Deliberately the family list again rather than a
 * second visual language — same 220px cap, same 2px gaps, same hover and
 * selected treatment — so the panel reads as one list of settings. Twelve rows
 * uncapped is about 364px and would push Save and Cancel off the bottom of a
 * laptop screen.
 *
 * The one difference from .msp-family: a palette row lays its name and its
 * sample out SIDE BY SIDE rather than stacked. A font's sample is a line of
 * text and wants the full width under its name; a palette's sample is a short
 * strip of colour, and stacking it would waste the row's width for nothing.
 * ------------------------------------------------------------------------- */

.msp-palette-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 220px;
  overflow-y: auto;
  padding: 2px 4px 6px;
}

.msp-palette {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 6px 8px;
  border: 1px solid transparent;
  border-radius: var(--m-radius-sm, 2px);
  background: transparent;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
  text-align: left;
  cursor: pointer;
  transition: background 120ms ease-out, border-color 120ms ease-out;
}

.msp-palette:hover {
  background: color-mix(in srgb, var(--mt-fg) 8%, transparent);
}

.msp-palette:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: -2px;
}

.msp-palette.is-selected {
  border: 1px solid var(--mt-accent);
  color: var(--mt-fg);
}

/* A saved theme that changes fonts and no colours. Picking it would repaint the
 * card in the palette it already has, so it is greyed — at the same 35% of the
 * foreground every other greyed thing in this panel uses, with its reason on
 * the line underneath. One grey, one pattern. */
.msp-palette.is-disabled {
  color: color-mix(in srgb, var(--mt-fg) 35%, transparent);
  cursor: default;
}

.msp-palette.is-disabled:hover {
  background: transparent;
}

.msp-palette-name {
  flex: 1 1 auto;
  overflow: hidden;
  font-size: 13px;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The five chips. Each one is the palette's real colour, so it is the one
 * place in this panel where a literal hex reaches the screen — and it arrives
 * as a custom property set on the element, never as a backgroundColor key in
 * an inline style object. That key is an error-severity lint failure that
 * aborts tools/deploy.sh and leaves yesterday's build live on both targets. */
.msp-swatches {
  display: flex;
  flex: 0 0 auto;
  gap: 2px;
}

.msp-swatch {
  display: inline-block;
  width: 14px;
  height: 14px;
  border: 1px solid color-mix(in srgb, var(--mt-fg) 25%, transparent);
  border-radius: var(--m-radius-sm, 2px);
  background: var(--msp-swatch-fill, var(--mt-accent));
}

/* The hairline above is not decoration. Without it, the background chip of a
 * palette that matches the dashboard's own background is an invisible gap in
 * the strip — worst on Deep Sea, which is what Karl runs, and on all three
 * light themes. */

.msp-palette-note {
  padding: 0 8px 6px;
  font-size: 11px;
  line-height: 1.4;
  color: var(--m-text-muted);
}

/* Something true about a control that WORKS — today, only the count of widgets
 * on this card still holding one of the three colours ticket 183 retired
 * (ADR 0009). A bare hash-and-number reads as a hex literal to
 * tools/audit_primitive_css.py, which fails the deploy on one, so this file
 * spells ticket numbers out.
 *
 * Same size, same inset and same rhythm as a greyed reason, and deliberately
 * one step brighter than one: a reason belongs to a dead control and can fade
 * into it, while this line sits under a live tick-box Karl is meant to use. If
 * the two looked identical, the sentence that stops Colors from reading as
 * broken would itself read as Colors being unavailable. */
.msp-element-note {
  padding: 0 8px 4px;
  font-size: 11px;
  line-height: 1.4;
  color: color-mix(in srgb, var(--mt-fg) 60%, transparent);
}

/* Under a tick-box the same line steps in to the label's own column, so it
 * reads as belonging to that box rather than to the group. Exactly the inset
 * .msp-saved-part-reason already uses, because they sit in the same slot. */
.msp-saved-parts .msp-element-note {
  padding: 0 0 2px 22px;
}

/* === Frame / Surface / Watermark settings ================================= *
 * Deliberately NOT a second visual language. Every setting row uses the same
 * shape the font roles already established — the setting's name on the left,
 * what it is currently set to on the right — so all four elements read as one
 * list of settings rather than four little dialects. The controls themselves
 * stay quiet: this panel spends its one moment of boldness on the font list,
 * and the live card is where you look to see what a change did.
 * ------------------------------------------------------------------------- */

.msp-section {
  display: flex;
  flex-direction: column;
}

/* Nested groups (a gradient's stops, the grid's settings) step in so the
 * hierarchy is legible without a box or a rule around them. */
.msp-sub {
  display: flex;
  flex-direction: column;
  padding-left: 8px;
}

/* A named group of settings inside a section: "Frame fill", "Grid". Same
 * treatment as the panel header so the two levels of heading agree. */
.msp-sub-head {
  padding: 8px 8px 2px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--m-text-muted);
}

.msp-field {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 4px 8px 6px;
}

.msp-field-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
}

.msp-field-label {
  flex: 0 0 auto;
  font-size: 13px;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
}

/* What this setting is currently set to. Same role, same treatment and same
 * side of the row as .msp-role-current in the font list. */
.msp-field-value {
  flex: 1 1 auto;
  overflow: hidden;
  font-size: 12px;
  color: var(--m-text-muted);
  text-align: right;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* One line of explanation under a setting whose name cannot carry the whole
 * idea (the frame's flip-direction toggle is the only one so far). */
.msp-field-hint {
  padding-left: 22px;
  font-size: 12px;
  color: var(--m-text-muted);
}

/* === Ticket 143: how far a control reaches ====================================== *
 *
 * The chip sits immediately after the name, so a row reads as one phrase —
 * `Headings 66` — rather than as a name at one end of the row and a number at
 * the other. `margin-inline-end: auto` is what keeps the current value on the
 * right where it has always been; without it the space-between on the head
 * would push the chip into the middle of the row.
 *
 * Tabular figures because these numbers sit in a column down the panel and
 * change as the reach changes. Proportional digits make that column jitter.
 *
 * A ZERO IS DRAWN AS A DIFFERENT KIND OF THING, NOT AS A DIMMER ONE. The live
 * chip is filled and accent-coloured; a zero loses the fill entirely and keeps
 * only a hairline. That is the same distinction the row it sits on is making —
 * this control has something to do, or it does not — said twice, because the
 * greying alone is a difference some people cannot see.
 *
 * There is no rule for "we do not know": that state renders no chip at all.
 * See StyleCount in style-panel.jsx. */
.msp-count {
  flex: 0 0 auto;
  margin-inline-end: auto;
  min-width: 20px;
  padding: 2px 5px;
  border: 1px solid color-mix(in srgb, var(--mt-accent) 30%, transparent);
  border-radius: var(--m-radius-sm, 2px);
  background: color-mix(in srgb, var(--mt-accent) 14%, transparent);
  color: var(--mt-accent);
  font-size: 10px;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  letter-spacing: 0.03em;
  line-height: 1.2;
  text-align: center;
}

.msp-count.is-zero {
  border-color: var(--mt-border);
  background: transparent;
  color: var(--m-text-muted);
}

/* A setting that can change nothing here. Greyed, its control switched off,
 * and never silent: the reason renders as visible text below it, the same
 * shape .msp-element-reason gives a greyed rail stop (221 D-06). A reason you
 * have to hover to find does not answer the question. */
.msp-field.is-dead .msp-field-label,
.msp-field.is-dead .msp-check-label {
  color: color-mix(in srgb, var(--mt-fg) 35%, transparent);
}

.msp-field-reason {
  padding-left: 22px;
  font-size: 12px;
  color: var(--m-text-muted);
}

.msp-role-row.is-dead {
  color: color-mix(in srgb, var(--mt-fg) 35%, transparent);
  cursor: default;
}

.msp-role-row.is-dead:hover {
  background: transparent;
}

.msp-range {
  width: 100%;
  height: 16px;
  margin: 0;
  accent-color: var(--mt-accent);
  background: transparent;
  cursor: pointer;
}

.msp-range:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: 2px;
}

.msp-select {
  width: 100%;
  padding: 4px 6px;
  border: 1px solid var(--mt-border-strong);
  border-radius: var(--m-radius-sm, 2px);
  background: var(--mt-bg);
  font-size: 12px;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
  cursor: pointer;
}

.msp-select:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: 1px;
}

.msp-check {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
  cursor: pointer;
}

.msp-check input {
  accent-color: var(--mt-accent);
  cursor: pointer;
}

/* #77: the tick boxes join the panel's own focus treatment.
 *
 * Every other keyboard stop in here already draws `2px solid var(--mt-accent)`
 * on :focus-visible — .msp-element-row, .msp-role-row, .msp-family, .msp-range,
 * .msp-select and seven more. The tick boxes were the only ones left wearing
 * the browser's default outline, which on a dark theme reads as a different
 * design language sitting in the middle of a carefully themed panel. Measured
 * on 8 of the panel's 29 keyboard stops (Frame, Surface, Watermark, Fonts,
 * Colors, Spacing, Content panel, Widget styling).
 *
 * One rule rather than eight, so a control added later inherits it.
 *
 * outline-offset is POSITIVE here, unlike the -2px the big rows use: those are
 * full-width rows with room to inset a ring, a ~13px checkbox is not. Inset on
 * something this small swallows the ring and lands exactly on the outcome #77
 * warns against — "do not close this by making the ring invisible. A focus ring
 * that cannot be seen is worse than the browser's."
 *
 * --mt-accent clears the WCAG 1.4.11 bar for a graphical control (3:1) against
 * the panel surface on all twelve shipped themes — checked 2026-08-06 against
 * design-tokens.generated.js. Thinnest is sentry at 3.11:1 against bgElev,
 * then soft-pastel 3.45 and cozy-neutral 3.50; voltagent is widest at 10.03.
 * Sentry has the least headroom, so if its accent is ever retuned, re-check
 * this before assuming the ring still reads. */
.msp-check input:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: 2px;
  border-radius: 2px;
}

/* Shrink-to-fit rather than fill, so the reach chip that follows it (ticket 143)
 * sits against the name instead of being pushed to the far edge of the row.
 * Nothing is lost: the whole row is a <label>, so the click target is the row
 * and never this span. */
.msp-check-label {
  flex: 0 1 auto;
}

.msp-color-row {
  display: flex;
  align-items: center;
  gap: 8px;
}

.msp-color-input {
  width: 44px;
  height: 24px;
  padding: 0;
  border: 1px solid var(--mt-border-strong);
  border-radius: var(--m-radius-sm, 2px);
  background: transparent;
  cursor: pointer;
}

.msp-color-input:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: 1px;
}

/* === Saving this card's look under a name (STYLE-05 / D-05) =============== *
 * The naming step sits inside the panel rather than floating over it, so a
 * click in it is a click inside the panel and the panel's own outside-press
 * dismiss does not fire. It reads as the panel's last row growing a little,
 * which is what it is — not a second window opening on top.
 *
 * Set apart from the settings above it by a hairline and a slightly recessed
 * background, the same two devices the footer already uses. No new visual
 * idea: the one place this panel spends any boldness is the font list, and a
 * second bold element would spend it twice.
 * ------------------------------------------------------------------------- */

.msp-saved-dialog {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 4px;
  padding: 8px;
  border-top: 1px solid var(--mt-border);
  background: color-mix(in srgb, var(--mt-bg-deep) 35%, transparent);
  border-radius: var(--m-radius-sm, 2px);
}

.msp-saved-dialog-head {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--m-text-muted);
}

.msp-saved-name {
  width: 100%;
  box-sizing: border-box;
  padding: 5px 8px;
  border: 1px solid var(--mt-border-strong);
  border-radius: var(--m-radius-sm, 2px);
  background: var(--mt-bg);
  font-size: 13px;
  color: var(--mt-fg);
}

.msp-saved-name:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: -1px;
}

/* A save that did not happen, said in one plain line. It is a statement of
 * fact in the interface's own voice, not an alert and not an apology, and it
 * stays put next to the box Karl is about to try again in. */
.msp-saved-message {
  font-size: 12px;
  line-height: 1.4;
  color: var(--mt-warn);
}

.msp-saved-dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 6px;
}

/* === The looks already saved ============================================== *
 * Listed by name with the date they were saved underneath. The date is not
 * decoration: Karl has two saved looks called "Gold Test" in his own backup
 * history, and the date is the only thing on screen that tells them apart. An
 * id would too, but an id means nothing to the person reading it.
 * ------------------------------------------------------------------------- */

/* Collapsed by default and set off from the four element rows by a hairline:
 * this panel's first job is changing THIS card, and the saved looks are the
 * second thing you come here for, not the first. */

.msp-saved-block {
  display: flex;
  flex-direction: column;
  margin-top: 4px;
  padding-top: 4px;
  border-top: 1px solid var(--mt-border);
}

/* Deliberately the same row rhythm and hover as an element row above, so the
 * panel reads as one list rather than two stacked ideas. */
.msp-saved-head {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 28px;
  padding: 6px 8px;
  border: none;
  border-radius: var(--m-radius-sm, 2px);
  background: transparent;
  font-size: 13px;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
  cursor: pointer;
  transition: background 120ms ease-out, color 120ms ease-out;
}

.msp-saved-head:hover {
  background: color-mix(in srgb, var(--mt-fg) 8%, transparent);
  color: var(--mt-fg);
}

.msp-saved-head:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: -2px;
}

/* === STYLES: the saved looks, as what they look like (ticket 151) ======== *
 *
 * The looks used to be a column of names and dates behind the disclosure
 * above. A name tells you nothing about a look and a date tells you nothing at
 * all, so they are now a strip of chips at the top of the panel, each wearing
 * its own edge, backdrop and text in miniature.
 *
 * THE STRIP IS CAPPED AND SCROLLS INSIDE ITSELF, and that ships with the strip
 * rather than after it. Karl has seven looks; with Current that is eight chips
 * in five rows — 123px, measured in his browser — and the cap is set just above
 * it so his library never scrolls. A library of thirty would be twenty-odd rows
 * and better than 500px of a panel that also has to hold the reach picker, the
 * Find-by toggle, the control list and Save, on a laptop screen where the whole
 * panel is capped at about 780px. The controls would go off the bottom and
 * there would be nothing on screen saying why.
 *
 * max-height, NEVER height. One saved look is two chips and one row, and the
 * strip is one row tall — a fixed height would reserve four rows of empty
 * space for everybody to pay for the thirty-look case.
 *
 * IT IS NOT position: sticky, AND THAT IS DECIDED (ticket 152, Karl's own
 * question on it). D-2 says the looks are "always visible", and what shipped
 * reads that as "no disclosure to open", which is what a strip in the flow
 * gives. Pinning it would put a THIRD sticky block at the top of a panel capped
 * at about 780px on a laptop, behind the reach picker that is already pinned
 * there — and it would spend up to 150px of the rows, which is the space the
 * rail beside it exists to give back. The panel title is not sticky for the
 * same reason and says so in its own rule.
 *
 * The rail beside the rows IS pinned, and that is not the opposite call: it
 * sits BESIDE the pane, so pinning it costs no rows at all, where pinning this
 * strip costs up to 150px of them. Same question, different price, different
 * answer. See the rail's own block for the trade written out.
 *
 * What made this question worth asking was that the counts scrolled away with
 * everything else, and the pinned rail is what answers that. If the strip still
 * reads as wanted overhead once the rail has been lived with, it is its own
 * change, measured.
 * ------------------------------------------------------------------------- */

.msp-styles-block {
  display: flex;
  flex-direction: column;
  padding-bottom: 6px;
  /* The same hairline the reach picker below wears, for the same reason: this
   * block is a decision and what follows it is the detail. It sits on the
   * block rather than on the strip so the empty-library sentence and any
   * failure line stay inside the rule with the chips they are about. */
  border-bottom: 1px solid var(--mt-border);
}

/* Same type as .msp-scope-head and .msp-sub-head — a name over a group. The
 * prototype's word, and the one this panel already uses for the thing itself. */
.msp-styles-head {
  padding: 8px 8px 4px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--m-text-muted);
}

.msp-styles {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  /* Five rows of chips at 26px plus their four 4px gaps. Measured against
   * Karl's own seven looks in his browser: at natural widths they pack into
   * about six rows, so the cap costs him one scroll and a library of thirty
   * — twenty-plus rows — cannot reach the controls underneath. */
  max-height: 150px;
  overflow-y: auto;
  padding: 0 2px;
}

.msp-saved-empty {
  padding: 6px 8px;
  font-size: 12px;
  line-height: 1.4;
  color: var(--m-text-muted);
}

/* THE CHIP. A pill, because a look is a thing you compare against its
 * neighbours rather than a value you set — every other control in this panel
 * is a square-cornered row, and the difference is the point.
 *
 * min-width:0 is what lets a long name ellipsis instead of pushing the rename
 * and delete controls out of the pill. */
.msp-saved-item,
.msp-style-current {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 5px;
  min-width: 0;
  padding: 3px 6px 3px 3px;
  border: 1px solid var(--mt-border);
  border-radius: 999px;
  background: transparent;
  text-align: left;
  cursor: pointer;
  transition: background 120ms ease-out, border-color 120ms ease-out;
}

/* `Current` is the card as it stands. It is drawn as a chip because it IS one
 * — the strip would otherwise start at whatever was saved and never show what
 * is on screen — and it is not a button because there is nothing to apply.
 * It deliberately does NOT answer to .msp-saved-item; see the note beside it
 * in style-panel.jsx. */
.msp-style-current {
  cursor: default;
}

/* The miniature: edge over backdrop over text, top to bottom, in the order
 * they sit on a card. Each band's colour arrives as --msp-chip-band, a
 * var(--mt-*) reference resolved per render in style-panel.jsx, so the whole
 * strip re-colours on a theme switch with nothing recomputed.
 *
 * A look that paints no edge gets `transparent` for its first band and the
 * panel shows through it. That is true of such a look; a colour invented there
 * would be the one band on the chip that is a guess. */
.msp-style-chip {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  width: 18px;
  height: 13px;
  border: 1px solid var(--mt-border-strong);
  border-radius: 3px;
  overflow: hidden;
}

.msp-style-chip i {
  display: block;
  flex: 1 1 0;
  background: var(--msp-chip-band, transparent);
}

.msp-saved-item:hover {
  background: color-mix(in srgb, var(--mt-fg) 8%, transparent);
  border-color: var(--mt-border-strong);
}

.msp-saved-item:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: -2px;
}

/* The look currently on the card. Marked, not shouted about — the card itself
 * is the real answer to "which one is on", and this is only the reminder of
 * which row produced it. */
.msp-saved-item.is-chosen,
.msp-style-current.is-chosen {
  background: color-mix(in srgb, var(--mt-accent) 15%, transparent);
  border-color: var(--mt-accent);
}

.msp-saved-item.is-chosen .msp-saved-item-name,
.msp-style-current.is-chosen .msp-saved-item-name {
  color: var(--mt-accent);
}

/* 11px, and every pixel of the difference from the panel's 13px row label is
 * spent on names. At 13px four of Karl's seven wrapped to `Gradient…` and
 * `GradientN…`; at 11px with the tighter pill all seven read whole and short
 * ones still pack two and three to a row. Measured in his browser, 2026-08-09. */
.msp-saved-item-name {
  font-size: 11px;
  color: var(--mt-fg);
}

/* The chip and the two things you can do to it. Two to a row at the panel's
 * 300px, which is what the chips were measured against — the pick target takes
 * the slack and the controls keep their natural width.
 *
 * min-width:0 on the pick target is what lets a long name truncate instead of
 * pushing the controls off the panel — without it a flex child refuses to
 * shrink below its own content. 225.1 fixed this exact wrap on the gear row;
 * do not let it back in here. */
.msp-saved-row {
  display: flex;
  align-items: center;
  gap: 1px;
  /* NATURAL WIDTH, WRAPPING — not a two-column grid.
   *
   * A forced half-width column truncated four of Karl's seven names to
   * `Gradient…`, `GradientN…`, `ReverseG…` and `RevGrad…`, and the first two of
   * those are the same stub for two different looks. Measured in his browser on
   * 2026-08-09. The swatches on his library are near-identical — every look
   * paints `fg → bg` — so the NAME is what tells one chip from another, and a
   * truncated name is a chip that cannot do its job.
   *
   * So a chip is as wide as its name: short ones pack two and three to a row,
   * long ones take a row of their own. max-width keeps the longest inside the
   * strip rather than forcing it to scroll sideways, and min-width:0 is what
   * lets that last resort truncate instead of pushing the rename and delete
   * controls off the panel. */
  flex: 0 1 auto;
  max-width: 100%;
  min-width: 0;
}

.msp-saved-row > .msp-saved-item,
.msp-saved-row > .msp-style-current {
  flex: 1 1 auto;
  min-width: 0;
}

.msp-saved-item-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100%;
}

/* Always visible, and that is the whole point of this phase rather than a
 * styling preference: these controls were findable, then were not. A control
 * revealed on hover is unfindable on a touch screen and undiscoverable
 * everywhere else. No opacity transition, no reveal-on-hover rule — the only
 * :hover here changes colour on something already on screen. */
.msp-saved-act {
  flex: 0 0 auto;
  padding: 3px 2px;
  background: none;
  border: none;
  color: var(--m-text-muted);
  cursor: pointer;
  font-size: 11px;
  line-height: 1;
}

.msp-saved-act:hover { color: var(--mt-fg); }
.msp-saved-act:disabled { cursor: default; opacity: 0.5; }

/* Renaming and the delete question each REPLACE their chip and take the whole
 * strip width. Two chips to a row leaves about 140px, which is not a box, a
 * Save and a cancel — and a chip that can still be picked while its name is
 * half-typed is a chip that can be picked into a state nobody chose. */
.msp-saved-row--editing,
.msp-saved-row--confirm {
  flex-basis: 100%;
}

.msp-saved-row--editing { gap: 4px; }
.msp-saved-row--editing > .msp-saved-name { flex: 1 1 auto; min-width: 0; }

/* The question. Its text takes the slack so both answers keep their natural
 * width and neither wraps at 300px. */
.msp-saved-row--confirm { gap: 4px; padding: 2px 0; }

.msp-saved-confirm-text {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 12px;
  color: var(--mt-fg);
}

/* --mt-warn is what .msp-saved-message already uses, so the answer that cannot
 * be taken back and the line that reports bad news speak with one colour. */
.msp-btn--danger { color: var(--mt-warn); }

/* The eight tick-boxes that decide which parts of a chosen look get put on this
 * card. A part the chosen look does not carry greys with its reason visible,
 * the same treatment a greyed element row gets (D-06) — one pattern, not two. */

/* The heading and the All / None pair above the boxes (D-15). It sits OUTSIDE
 * .msp-saved-parts rather than inside it so its text starts at the same 8px as
 * the checkboxes below — nested, it would inherit that group's own 8px and sit
 * indented from the column it is the heading for. The group keeps its
 * accessible label either way.
 *
 * Same padding, size, weight and tracking as .msp-sub-head, because it is the
 * same kind of thing: a name over a group of settings. Eight boxes is the point
 * at which that group needs one; four did not. */
/* All and None, and nothing else since ticket 151 moved the heading onto the
 * disclosure that opens this block. They line up with the left edge of the
 * boxes they act on; space-between was holding them against a heading that is
 * no longer beside them. */
.msp-parts-head {
  display: flex;
  align-items: baseline;
  justify-content: flex-start;
  gap: 8px;
  padding: 8px 8px 2px;
}

/* All and None. Never disabled: pressing All when everything is already ticked
 * does nothing, which is a quieter answer than a greyed button that leaves you
 * working out why it is greyed. */
.msp-parts-head-actions {
  display: flex;
  flex: 0 0 auto;
  gap: 4px;
}

.msp-saved-parts {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 4px 8px 6px;
}

.msp-saved-part.is-disabled {
  color: color-mix(in srgb, var(--mt-fg) 35%, transparent);
  cursor: default;
}

.msp-saved-part-reason {
  padding: 0 0 2px 22px;
  font-size: 11px;
  line-height: 1.4;
  color: var(--m-text-muted);
}

/* === Footer: three distinct actions (D-05) ================================ *
 * "Save as Style…" sits apart from Save/Cancel because it does a different
 * job — it captures this card's whole look under a name for use on other
 * cards. One button doing both would accumulate a named Style every time
 * Karl nudged one card's border.
 * ------------------------------------------------------------------------- */

.msp-footer {
  display: flex;
  align-items: center;
  gap: 6px;
  /* Ticket 146 needed `margin-top: auto` here, because a strip pinned top and bottom
   * is the height of the viewport whatever it carries and the bar would
   * otherwise render mid-panel with empty strip beneath it. A floating panel
   * shrinks to its content, so its bottom edge and the bar are the same line
   * and there is never any free space for an auto margin to eat. It went out
   * with the dock rather than being left behind resolving to zero. */
  margin-top: 4px;
  /* The panel's own 6px padding is scrolled through, so the bar is pulled down
   * over it and puts the padding back on its own bottom edge. Without that
   * there is a 6px slot under the bar through which the content scrolling
   * behind it is visible. */
  position: sticky;
  bottom: -6px;
  z-index: 1;
  padding: 8px 4px 8px;
  background: var(--mt-bg-elev);
  border-top: 1px solid var(--mt-border);
}

/* WHY THE COMMIT BAR IS PINNED TO THE BOTTOM OF THE PANEL (ticket 144).
 *
 * The panel scrolls inside itself once its content is taller than the room
 * under it, and until now the bar scrolled away with everything else. On a 1280
 * by 800 screen, with the panel opened near the bottom edge and Saved styles
 * expanded, Save sat exactly ON the fold — visible by nothing at all, and the
 * first thing that made the panel any taller put it under the edge. Ticket 144's
 * Find-by toggle is 59px, so it was that thing.
 *
 * Trimming the toggle would have bought back the same zero margin for the next
 * row somebody adds. This is 138's own requirement instead — the commit button
 * never scrolls away — and it holds at any scroll position and any content
 * height rather than at one that happened to fit.
 *
 * D-11's ordering is untouched: the way back still renders after this bar in
 * the DOM and still reads last on screen. The bar floats over it while the
 * panel is being scrolled and settles back above it at the end of the scroll,
 * which is where the way back lives.
 *
 * The background is not decoration. A sticky element with a transparent
 * background has the content it is floating over drawn straight through it.
 *
 * Ticket 155: the page reach is under this rule too. Its body renders the same
 * `.msp-footer` as the other three now — it went without one until then, which
 * left its Save scrolling off both ends of the panel. What it needs on top is
 * the theme editor's wash rather than this one; that rule is at the bottom of
 * this file with the rest of .msp-panel--page. */

.msp-footer-spacer {
  flex: 1 1 auto;
}

.msp-btn {
  padding: 5px 10px;
  border: 1px solid var(--mt-border-strong);
  border-radius: var(--m-radius-sm, 2px);
  background: transparent;
  font-size: 12px;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
  cursor: pointer;
  transition: background 120ms ease-out, color 120ms ease-out;
}

.msp-btn:hover {
  background: color-mix(in srgb, var(--mt-fg) 8%, transparent);
  color: var(--mt-fg);
}

.msp-btn:focus-visible {
  outline: 2px solid var(--mt-accent);
  outline-offset: 1px;
}

/* An action that is not built yet. Greyed and non-interactive, with its reason
 * on the control itself — never a button that looks live and does nothing. */
.msp-btn.is-disabled {
  color: color-mix(in srgb, var(--mt-fg) 35%, transparent);
  border: 1px solid color-mix(in srgb, var(--mt-border-strong) 50%, transparent);
  cursor: default;
}

.msp-btn.is-disabled:hover {
  background: transparent;
  color: color-mix(in srgb, var(--mt-fg) 35%, transparent);
}

/* The one inline action inside a settings row ("Use the theme color"). */
.msp-btn--small {
  padding: 3px 8px;
  font-size: 11px;
}

.msp-btn--primary {
  border: 1px solid var(--mt-accent);
  color: var(--mt-accent);
}

.msp-btn--primary:hover {
  background: color-mix(in srgb, var(--mt-accent) 15%, transparent);
  color: var(--mt-accent);
}

/* === GO BACK: the way back, kept apart from the way forward (D-11) ======== *
 * "Give this card that look" and "throw this card's look away" are opposite
 * actions, and putting the second one in the footer beside the first is how
 * somebody presses the wrong one. Three things keep them apart, and all three
 * are load-bearing rather than decorative:
 *
 *   BELOW the footer, behind its own rule — a second block of the same kind,
 *   which is why the border, the margin and the padding here match .msp-footer
 *   rather than being chosen. The two sit one above the other and have to read
 *   as two blocks of the same kind.
 *
 *   FULL WIDTH AND STACKED, not the footer's right-aligned row. A different
 *   shape is what the eye catches before it has read anything.
 *
 *   NEITHER IS THE PANEL'S PRIMARY, AND NOTHING HERE IS RED. Both keep plain
 *   .msp-btn. The panel's one primary action is Save, and a second accent
 *   button in the same 300px column makes neither of them the primary. Red is
 *   wrong for a different reason: a confirm stands in front of both wider
 *   choices, one Ctrl-Z takes the whole thing back and the strip afterwards
 *   carries an Undo, so a red button would say "this might not come back",
 *   which is false. --m-error appears nowhere in this phase.
 *
 * NOTHING IN HERE IS position: sticky OR position: fixed. The panel's own fixed
 * position plus the inline max-height fitPanelBox writes are what keep it on
 * screen; a sticky block inside a capped scroller pins over the content it
 * heads, and this block is the furthest thing down a 300px column that already
 * scrolls inside itself on a short screen.
 * ------------------------------------------------------------------------- */

.msp-goback {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 4px;
  padding: 6px 4px 2px;
  border-top: 1px solid var(--mt-border);
}

/* Identical to .msp-scope-head and .msp-sub-head — the
 * same kind of thing, a name over a group, so it takes the same type rather
 * than a new one. This phase introduces no new size anywhere. */
.msp-goback-head {
  padding: 8px 8px 2px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--m-text-muted);
}

/* `Based on Midnight · edited` (D-15). Same 11px slot as .msp-scope-note, and
 * the same truncation as the button below it for the same reason: the name is
 * user-supplied with no length cap, and a 60-character one would wrap the line
 * and shift everything under it.
 *
 * IT IS NOT MUTED, AND THAT IS THE POINT. The suffix below is, and the step
 * between the two is what says ` · edited` is a note ABOUT the name rather than
 * part of it. Take the colour off this rule and the two collapse into one
 * sentence that reads as a Style called "Midnight · edited". The 85% is
 * .msp-element-row's — the panel's ordinary text colour, not a new one. */
.msp-basedon {
  padding: 2px 8px 6px;
  font-size: 11px;
  line-height: 1.4;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.msp-basedon-edited {
  color: var(--m-text-muted);
}

/* Full width and stacked. The truncation is not fussiness: `Back to <name>`
 * carries a user-supplied Style name with no length cap on the save dialog, and
 * a 60-character name would wrap a stacked button to two lines and shift every
 * row under it — including, on a short screen, out of the panel's capped
 * scroller. It truncates instead. */
.msp-goback-btn {
  width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Why a greyed button is greyed, as VISIBLE TEXT under it — never a title,
 * never a tooltip. 221 D-06's standing rule: a reason you have to hover to find
 * does not answer the question. Same 11px muted wrapping slot as every other
 * reason in this panel. */
.msp-goback-reason {
  padding: 0 8px;
  font-size: 11px;
  line-height: 1.4;
  color: var(--m-text-muted);
}

/* BACKLOG-05. Not a reason — a report. The panel's refusal line and its
   "here is what just happened" line are two different jobs and they get two
   different treatments, because one line doing both is the whole defect.

   NO GREEN AND NO ACCENT, deliberately. Green means committed, and this change
   is NOT committed — it lives in a draft and dies on Cancel. A tick would push
   exactly the wrong way. The accent in this panel is reserved for selection and
   focus (.msp-family.is-selected, every focus ring, accent-color on the boxes)
   and spending it here would break that reservation.

   The one device is a CONTRAST STEP, and it is borrowed rather than invented:
   .msp-basedon already uses this exact 85% value to say "this line is ordinary
   text, not a muted note", against .msp-goback-reason's --m-text-muted. Same
   11px slot, same 1.4, same 8px column as every other line in this block. This
   phase introduces no new size and no new colour. */
.msp-goback-done {
  padding: 0 8px;
  font-size: 11px;
  line-height: 1.4;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
}

/* The nudge half. Weight and nothing else — the panel's own way of saying
   "this one" (.msp-goback-head, .manifold-gear-theme-row.is-previewing both
   use 600 alone). It is a separate span because it does a separate job:
   the sentence before it reports, this one asks. */
.msp-goback-done-cta {
  font-weight: 600;
}

/* #57 — the receipt under `Update <name>`. One colour step quieter than the
 * buttons above it, because it is a note about what just happened rather than
 * something to press; the Undo inside it is the only pressable part. Same
 * 11px slot and line-height as .msp-goback-reason, same --m-text-muted. */
.msp-update-receipt {
  display: flex;
  align-items: baseline;
  gap: 6px;
  margin-top: 4px;
  padding: 0 8px;
  font-size: 11px;
  line-height: 1.4;
  color: var(--m-text-muted);
}

/* #57 — an update or an undo that did not happen, in the block the press
 * happened in. The slot is .msp-goback-reason's exactly — 11px, 1.4, the same
 * 8px column every line in this block sits in — so it lands where the eye
 * already reads reasons rather than opening a second place for text.
 *
 * The one difference is the colour, and it is borrowed rather than invented:
 * --mt-warn is what .msp-saved-message already uses for a save that did not
 * happen. A failure in the naming step and a failure down here are the same
 * kind of news and must not look like two different kinds. Not red-on-a-panel,
 * not an icon, no border: it is a statement of fact in the interface's own
 * voice, and it stays put beside the button Karl is about to try again. */
.msp-update-message {
  margin-top: 4px;
  padding: 0 8px;
  font-size: 11px;
  line-height: 1.4;
  color: var(--mt-warn);
}

/* === THE PAGE, THE FOURTH REACH (ticket 104) ==================================== *
 * The body of this reach is manifold.jsx's — the same colour slots, font list,
 * ramp knobs and Scale slider the Customize modal held, in the same markup. The
 * rules here do two things and nothing else: fit that body into a 300px column,
 * and put the panel's own chrome on the same preview-proof palette the body
 * already uses.
 *
 * WHY THE CHROME HAS TO SWITCH PALETTES, AND WHY IT IS NOT A TASTE CALL.
 * THEME-04: previewing a colour repaints every --mt-* token live, which is the
 * whole point — you cannot judge a colour you have not applied. So the editor
 * declares its own fixed literal palette (--te-*, in themes/default/theme.css)
 * and builds every control from it, and a black-on-black preview leaves it
 * readable. This panel's shell is built from --mt-*. Left alone, previewing
 * a black bg would paint a black shell around a body that stayed legible, and
 * previewing a near-white bg would put --te-panel-fg's near-white text on it.
 * The shell joins the body's palette for exactly as long as the page reach is
 * open; the three card reaches are unchanged, because nothing they do repaints
 * the page underneath them.
 *
 * The --te-* values themselves are declared once, on .manifold-theme-editor and
 * .msp-panel--page together, in themes/default/theme.css. They are not repeated
 * here — a second copy of seven literal hexes is a second copy to drift.
 * ------------------------------------------------------------------------- */

/* Wider than the three card reaches, and the number is not a preference. The
 * body's widest row is the Text ramp — four notches, `Smaller · Default ·
 * Larger · Largest` — which needs about 320px of content to sit on one line;
 * below that it wraps to two and the five ramps stop reading as one control per
 * row. 340px minus the shell's 6px padding each side leaves 328. The panel
 * measures itself for placement (fitPanelBox reads the live rect), so nothing
 * else has to be told this number.
 *
 * A reach with a width of its own is possible again because the panel floats:
 * ticket 146 made every reach the strip's 340 because one strip cannot be two
 * widths, and 150 took the strip away. */
.msp-panel--page {
  width: 340px;
  background: var(--te-panel-bg);
  border-color: var(--te-panel-border);
}

.msp-panel--page .msp-header,
.msp-panel--page .msp-scope-head,
.msp-panel--page .msp-scope-note {
  color: var(--te-panel-dim);
}

.msp-panel--page .msp-scope {
  border-bottom-color: var(--te-panel-border-soft);
  /* The sticky reach picker paints its own background so the rows do not show
   * through it (ticket 146). This shell is the theme editor's palette rather than the
   * panel's, so the wash has to follow — a --mt-bg-elev block floating over a
   * --te-panel-bg body is the one place that rule would show its seams. */
  background: var(--te-panel-bg);
}

.msp-panel--page .msp-scope-segments {
  border-color: var(--te-panel-border);
}

.msp-panel--page .msp-scope-seg:nth-child(even),
.msp-panel--page .msp-scope-seg:nth-child(-n + 2) {
  border-color: var(--te-panel-border-soft);
}

.msp-panel--page .msp-scope-seg {
  color: var(--te-panel-fg);
}

.msp-panel--page .msp-scope-seg:hover {
  background: color-mix(in srgb, var(--te-panel-fg) 8%, transparent);
  color: var(--te-panel-fg);
}

/* The chosen segment keeps the 13% accent wash the three card reaches use —
 * same device, this panel's other palette. */
.msp-panel--page .msp-scope-seg.is-active {
  background: color-mix(in srgb, var(--te-panel-accent) 13%, transparent);
  color: var(--te-panel-accent);
}

/* A choice this open cannot make: editing is off, or there is no card. Same 35%
 * fade every greyed thing in this panel takes, with the reason as visible text
 * in the sub-note slot below. */
.msp-panel--page .msp-scope-seg.is-disabled,
.msp-panel--page .msp-scope-seg.is-disabled:hover {
  background: transparent;
  color: color-mix(in srgb, var(--te-panel-fg) 35%, transparent);
}

/* The body's own padding is written for the 380px panel it came from. Here the
 * panel supplies the 6px shell padding and the rows sit against it, so the 18px
 * sides come off — 36px of a 340px column is a tenth of the width of every
 * colour row and every font sample. */
.msp-page-reach .manifold-theme-editor-body {
  padding: 8px 2px;
}

/* THE PAGE REACH'S COMMIT BAR (ticket 155). The sticking comes from `.msp-footer`
 * above — one rule, one bar, four reaches. What changes here is the wash and
 * two hairlines, for the reason every other .msp-panel--page rule exists: the
 * shared bar paints --mt-bg-elev, and a sticky bar with the wrong background
 * has the rows it is floating over drawn straight through it.
 *
 * It stacks rather than sitting in one line, because it carries the
 * save-failure message as well as the buttons — a Save pressed from the bottom
 * of the panel must not report its failure two sections up the scroll. The
 * message is usually absent and the bar is then one row tall. */
.msp-panel--page .msp-footer {
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
  background: var(--te-panel-bg);
  border-top-color: var(--te-panel-border);
  /* THE 6px THE SHARED RULE PULLS DOWN, GIVEN BACK TO THE BAR HERE.
   *
   * `bottom: -6px` above pulls the bar over the panel's own 6px padding so
   * nothing scrolls through the slot underneath it. A sticky box cannot leave
   * its containing block, though, and the three card bars are children of the
   * panel while this one is the last child of the theme editor's body — whose
   * bottom edge IS this bar's resting bottom edge, so the overhang clamps to
   * nothing and the bar hopped 6px up at the very end of the scroll. Measured
   * on wildly: 1px of gap under the bar at every scroll position and 7.2px at
   * the last one.
   *
   * A negative bottom margin puts the bar's resting place 6px lower than its
   * container's content ends, which is the same relationship the card bars get
   * from the panel's padding — so the clamp allows the full pull and the bar
   * sits in one place from the first scroll position to the last. */
  margin-bottom: -6px;
}

.msp-panel--page .msp-footer-row {
  display: flex;
  align-items: center;
  gap: 8px;
}

/* The message the bar carries. Its size and its amber are theme.css's, one
 * copy; only the spacing changes, because the 6px top padding it has in the
 * body's flow is the bar's own gap here. Wrapped rather than clipped:
 * "Couldn't save: <the server's reason>" is the only place that reason is
 * ever shown. */
.msp-panel--page .manifold-theme-editor-error {
  padding: 0 2px;
  line-height: 1.4;
}

/* The Layout section's three controls came from a modal on the app shell, where
 * reading --mt-* was right: nothing was previewing a colour over them. In here
 * they are inside the live preview, and a preview of black-on-black would leave
 * these three the only unreadable thing in the panel — the exact failure the
 * --te-* palette exists to prevent, arriving through the one block that was
 * written before the palette applied to it. Same values, same shapes, the
 * palette this panel is already wearing. */
.msp-page-reach .manifold-canvas-settings-row {
  padding: 12px 2px;
  border-bottom-color: var(--te-panel-border-soft);
}

.msp-page-reach .manifold-canvas-settings-label {
  color: var(--te-panel-fg);
}

.msp-page-reach .manifold-canvas-settings-help {
  color: var(--te-panel-dim);
}

.msp-page-reach .manifold-canvas-settings-choice {
  border-color: var(--te-panel-border);
  color: var(--te-panel-dim);
}

.msp-page-reach .manifold-canvas-settings-choice:hover {
  border-color: var(--te-panel-accent);
  color: var(--te-panel-fg);
}

.msp-page-reach .manifold-canvas-settings-choice[data-selected="true"] {
  border-color: var(--te-panel-accent);
  color: var(--te-panel-accent);
  background: color-mix(in srgb, var(--te-panel-accent) 12%, transparent);
}

.msp-page-reach .manifold-canvas-settings-toggle input {
  accent-color: var(--te-panel-accent);
}

/* The gap box is a bare <input type="number"> — it inherits the page's input
 * chrome, which is the one thing in this section that is not a class of its
 * own. Given the palette explicitly so it does not stay the browser's white. */
.msp-page-reach .manifold-canvas-settings-row input[type="number"] {
  width: 72px;
  padding: 5px 8px;
  border: 1px solid var(--te-panel-border);
  border-radius: 4px;
  background: var(--te-panel-bg-elev);
  color: var(--te-panel-fg);
  font-family: inherit;
  font-size: 12px;
}

/* === All cards: what an empty reach means (ticket 104) ========================== *
 * `cards_theme_colors` holding nothing means the cards follow the page. It is
 * the state all 87 of Karl's cards are in, and until this block it looked
 * exactly like a missing feature.
 *
 * It takes the same shape as .msp-offtheme directly above it — the panel's one
 * existing "here is a fact about this card, and here is the one thing to do
 * about it" block — because it is the same kind of thing one reach wider. Same
 * 8px column, same 11px note, same small button. */
.msp-follow-note {
  padding: 6px 8px 8px;
  border-bottom: 1px solid var(--mt-border);
}

.msp-follow-head {
  font-size: 11px;
  font-weight: 600;
  color: color-mix(in srgb, var(--mt-fg) 85%, transparent);
}

.msp-follow-why {
  padding-top: 2px;
  font-size: 11px;
  line-height: 1.4;
  color: var(--m-text-muted);
}

.msp-follow-note .msp-btn {
  margin-top: 6px;
}

/* === Ticket 154 — the hover highlight ==================================== *
 * Point at a row and the things it would change light up on the canvas behind
 * the panel. Two languages, following the Find-by toggle: a spotlight under
 * `By setting`, a tinted wash and a count under `By element`.
 *
 * THE COLOUR IS NOT A NEW COLOUR. The wash, the rings and the label all wear
 * --mt-accent, the same token .msp-count prints the row's number in, because
 * the light and the number are one answer to one question and a second hue
 * would say they were two. The shade is the one thing that is not accent, and
 * what it is instead is argued at .msp-hl-shade below — read that before
 * changing it, because the obvious choice is the one that cannot work.
 *
 * IT IS DRAWN OVER EVERYTHING AND TOUCHES NOTHING. position: fixed for the same
 * reason the panel is (the canvas carries a scale transform, which would shift
 * and scale an overlay inside it), z-index one BELOW the panel so the row being
 * pointed at stays readable above its own light, and pointer-events: none, or a
 * full-viewport overlay makes the canvas dead for as long as it is up.
 * ------------------------------------------------------------------------- */

.msp-hl {
  position: fixed;
  inset: 0;
  z-index: 50; /* one below .msp-panel: the row stays readable over its light */
  pointer-events: none;
  animation: msp-hl-in 90ms ease-out;
}

@keyframes msp-hl-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

.msp-hl-svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* The shade, with a hole punched per affected element by .msp-hl-hole in the
 * mask above it. 55% of BLACK, and the black is the whole point: this started
 * out as --mt-bg-deep, which is the one colour guaranteed not to work, because
 * that token IS the page canvas — every theme's own comment says so. The shade
 * was painting the page in its own colour and could not be seen at all on
 * Karl's dashboard. A darkening reads on a light theme and on a dark one; a
 * tint in the background colour reads on neither. */
.msp-hl-shade {
  fill: color-mix(in srgb, black 55%, transparent);
}

/* AND A RING ROUND EACH HOLE, WHICH IS NOT DECORATION.
 *
 * The shade only says anything where it FALLS, and most rows here reach
 * something on every card — Frame reaches 70 elements across 14 cards — so
 * almost every card is a hole and the shade lands on the gaps between them,
 * which were the darkest part of the screen already. Measured on Karl's
 * dashboard: 70 holes drawn, nothing visible. The ring is what makes the answer
 * readable in that case, and it is the same accent the wash and the row's own
 * count chip wear, because all three are one answer to one question.
 *
 * Drawn OVER the shade rather than in the mask: a mask is one bit per pixel and
 * cannot carry a stroke. .msp-hl-hole stays the mask's rects and stays what the
 * count is checked against, so a ring is never mistaken for a hole. */
.msp-hl-ring {
  fill: none;
  stroke: color-mix(in srgb, var(--mt-accent) 70%, transparent);
  stroke-width: 1;
}

/* One washed element. The border does the work on a thin one — a run of text is
 * 14px tall and a 22% fill on its own barely registers — and the fill does it on
 * a card-sized one. */
.msp-hl-wash {
  position: absolute;
  border: 1px solid color-mix(in srgb, var(--mt-accent) 55%, transparent);
  border-radius: var(--m-radius-sm, 2px);
  background: color-mix(in srgb, var(--mt-accent) 22%, transparent);
}

/* The count, over the first washed element. Deliberately the .msp-count chip
 * with its fill turned solid: it is the row's own number, moved onto the page,
 * and it should look like the number it came from. Solid rather than tinted
 * because it lands on top of a wash and has to stay readable over one. */
.msp-hl-label {
  position: absolute;
  transform: translateY(-100%);
  margin-top: -3px;
  padding: 2px 6px;
  border-radius: var(--m-radius-sm, 2px);
  background: var(--mt-accent);
  color: var(--mt-bg-deep);
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  letter-spacing: 0.03em;
  line-height: 1.2;
  white-space: nowrap;
}

/* No room above the thing it names — the first washed element is the topmost
 * card on the tab and its edge starts at y=0 — so the label drops inside the
 * element's top edge instead of off the top of the screen. Same anchor, same
 * words, still attached to what it counts. */
.msp-hl-label--inside {
  transform: none;
  margin-top: 3px;
}

/* Someone who has asked their system to reduce motion should not get the
 * disclosure rotation or the hover fades. */
@media (prefers-reduced-motion: reduce) {
  .msp-hl {
    animation: none;
  }

  .msp-scope-seg,
  .msp-findby-seg,
  .msp-locked-seg,
  .msp-element-row,
  .msp-role-row,
  .msp-family,
  .msp-palette,
  .msp-btn,
  .msp-goback-btn,
  .msp-select,
  .msp-saved-head,
  .msp-saved-item,
  .msp-tri {
    transition: none;
  }
}
