/* ---------------------------------------------------------------------------
   Design tokens, mirrored from the account app.

   SOURCE OF TRUTH: backend/resources/views/layouts/auth.blade.php, whose <style>
   block declares the same names. They are duplicated rather than shared because the
   two apps have no build step in common — the studio is plain PHP + CSS, the account
   app is Blade — and a token that only ONE of them can read is worse than a token
   written down twice. If you change a value there, change it here.

   The studio leads with --purple where the account app leads with --teal. That is
   deliberate and not a drift: purple is a colour of that palette, and the panel is an
   instrument sitting over live video, not a form. What is shared is the surface, the
   type and the geometry — which is what makes two screens look like one product.

   Names beyond the account app's set (--surface, --ok/--warn/--danger, --line) exist
   because the panel has parts a login card does not: floating menus over moving video,
   and states that must read at a glance from across a room.
   --------------------------------------------------------------------------- */
:root {
    /* --- shared with the account app, verbatim --- */
    --bg: #0a1420;
    --bg2: #0d2c40;
    --teal: #31c5da;
    --purple: #667eea;
    --warm: #f15a30;
    --text: #f6efe5;
    --muted: #9fb3c0;

    /* --- the studio's own, built from the above --- */
    /* The panel floats over live video, so its background is the account bg at an
       opacity that keeps text legible without hiding the picture behind it. Tried at 26%
       once: the stage came through beautifully and the labels over a bright preset were
       unreadable, which is the whole job. */
    --panel-bg: rgba(10, 20, 32, 0.88);
    /* The header is the one band that is not read THROUGH — it holds the preset name, the
       FPS and the menu, and it is the grip you drag the panel by. Opaque, and the darkest
       violet in the app: #381f68 with the alpha taken off, so it anchors the panel against
       the stage instead of glowing over it the way rgba(102,126,234,0.6) did. */
    --panel-header: #381f68;
    /* Menus and dialogs are NOT translucent: they are read, not looked through, and a
       dropdown over a strobing preset is unreadable at any alpha below opaque. */
    --surface: #10202f;
    --surface-2: #16293b;
    --line: rgba(255, 255, 255, 0.10);
    --panel-brd: rgba(102, 126, 234, 0.28);
    /* Dimmer than --muted, because a disabled row and a secondary label are different
       claims and the panel had them at #666 and #888 — close enough to be indistinguishable
       across a dark room, which is where this gets used. The account app has no disabled
       state to borrow, so this is derived from --muted rather than invented. */
    --disabled: rgba(159, 179, 192, 0.45);

    /* Status. --warm is the account app's; the other two are the panel's existing
       green/amber, kept because they already mean something to anyone using this. */
    --ok: #4caf50;
    --warn: #ffc800;
    --danger: #ff6464;

    /* Geometry, from the account card / button / input. */
    --r-lg: 16px;
    --r-md: 10px;
    --r-sm: 9px;

    /* One lift for everything that floats over the stage. Beyond the account app's set,
       for the reason given above: it has no floating menus over moving video. Every popover
       had its own — 0 4px 12px at .5 in two places, 0 8px 30px at .6 in another — so the
       same product had dialogs at three different heights off the page. */
    --shadow-pop: 0 8px 30px rgba(0, 0, 0, 0.6);

    /* Poppins is already served (index.php links fonts/poppins/poppins.css) and was
       already paying for itself in the watermark and the presentation effect — the
       panel simply never used it. Noto carries the Cyrillic and Devanagari that
       Poppins does not; system fonts finish the stack. */
    --font: 'Poppins', 'Noto Sans', 'Segoe UI', system-ui, sans-serif;
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

html,
body {
    overflow: hidden;
    font-family: var(--font);
    background: #000000;
}

#stage-backdrop {
    position: fixed;
    inset: 0;
    z-index: -2;
    background: #000000;
    pointer-events: none;
}

/* A DECODER, not a picture. The media effect renders through the compositor now, so this
   element is never seen — but it must stay laid out and un-hidden, because a display:none
   <video> can stop producing frames and every effect sampling it would go dark. The effect
   holds it at filter: opacity(0) instead.
   Kept at full size deliberately: shrinking it to 1px would cost nothing to composite, but
   browsers throttle decoding of effectively-invisible video, and the decoded frame is the
   whole point of keeping the element. object-fit went with the display path — framing is
   the layer shader's job now (the Fit / Zoom controls). */
#canvas-container {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: transparent;
    opacity: 1;
    cursor: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><circle cx="10" cy="10" r="4" fill="rgba(102, 126, 234, 0.8)" /><circle cx="10" cy="10" r="6" fill="none" stroke="rgba(102, 126, 234, 0.4)" stroke-width="1" /></svg>') 10 10, auto;
}

#controls {
    user-select: none;
    position: fixed;
    top: 0px;
    right: 0px;
    width: 240px;
    max-height: 100vh;
    background: var(--panel-bg);
    /* The account card sits on a blurred backdrop; the panel gets the same treatment for
       the same reason — it keeps the text legible over whatever the stage is doing
       without darkening the picture further. */
    backdrop-filter: blur(10px);
    color: var(--text);
    overflow: hidden;
    transition: all 0.3s ease;
    z-index: 1000;
    box-shadow: 0 0 15px rgba(102, 126, 234, 0.3);
    border: 1px solid var(--panel-brd);
    border-radius: var(--r-md);
    display: flex;
    flex-direction: column;
    opacity: 1;
    cursor: default;
}

#controls.collapsed {
    width: 36px;
    height: 36px;
    max-height: 36px;
    border-radius: 18px;
    /* No box/background when collapsed — just the floating logo. */
    background: transparent;
    box-shadow: none;
    border: none;
}
#controls.collapsed .controls-header {
    background: transparent;
}

#controls.collapsed .controls-header h2 {
    display: none;
}

/* Collapsed: hide everything but the logo (which lives in .toggle-icon), and hide
   the perf dot too (FPS shows once expanded). The toggle-icon is NOT hidden here —
   it's the logo, the one thing that should remain. */
#controls.collapsed .controls-header #perf-icon,
#controls.collapsed .controls-header #performance-indicator {
    display: none;
}

#controls.collapsed .controls-header #header-menu {
    display: none;
}

#controls.hidden {
    opacity: 0;
    pointer-events: none;
}

.controls-header {
    padding: 8px 12px;
    /* The containing block for #main-menu-buttons: the header spans the panel, the ☰ button
       does not, and a 190px menu hanging off a 26px button at either edge overflows one side
       or the other of a 240px panel. (The collapsed rule below already set this; it is
       unconditional now because the menu needs it while expanded.) */
    position: relative;
    background: var(--panel-header);
    cursor: pointer;
    display: flex;
    justify-content: space-between;
    align-items: center;
    user-select: none;
    flex-shrink: 0;
    /* One pixel inside the container's radius, so the header's corners sit on the border
       instead of cutting across it. */
    border-radius: calc(var(--r-md) - 1px) calc(var(--r-md) - 1px) 0 0;
    transition: all 0.3s ease;
}

#controls.collapsed .controls-header {
    position: relative;
    width: 36px;
    height: 36px;
    padding: 0;
    justify-content: center;
    border-radius: 0.25rem;
}

/* A hair lighter on hover — enough to say "this is the grip", not enough to reintroduce the
   glow. Same hue, lifted. */
.controls-header:hover {
    background: #472a80;
}

.controls-header h2 {
    font-size: 12px;
    margin: 0;
    transition: all 0.3s ease;
    font-weight: 500;
}

.toggle-icon {
    font-size: 14px;
    transition: transform 0.3s ease;
}

#controls.collapsed .toggle-icon {
    transform: rotate(0deg);
    font-size: 18px;
}

.controls-body {
    padding: 3px;
    flex: 1;
    overflow-y: auto;
    overflow-x: hidden;
}

#controls.collapsed .controls-body {
    display: none;
}

/* .user-info (the pinned "Logged in as …" footer) is gone — the username now rides on the
   Account chevron row inside the scrolling body, like every other section's status. */

.control-group {
    /* margin-bottom: 10px; */
}

.color-picker-group {
    display: flex;
    flex-direction: row;
    align-items: center;
    /* justify-content: space-between; */
    gap: 10px;
}

.color-picker-group label {
    margin-bottom: 0;
    white-space: nowrap;
}

.control-group label {
    user-select: none;
    display: block;
    margin: 4px 0;
    font-size: 11px;
    color: var(--muted);
    font-weight: 400;
}

.control-group h3 {
    font-size: 11px;
    margin: 8px 0 6px 0;
    color: #667eea;
    font-weight: 500;
}

select,
input[type="range"] {
    width: 100%;
    padding: 4px 6px;
    border: 1px solid var(--line);
    border-radius: 3px;
    background: var(--surface);
    color: var(--text);
    font-size: 11px;
}

select {
    cursor: pointer;
    /* The OPEN dropdown is drawn by the browser/OS, not by us, and it is the one part of a
       select our background never reaches. Firefox and Safari ignore the control's colours for
       that popup entirely and fall back to the system light theme — white text on white. This
       is the property they actually honour: it asks for the dark native widget. */
    color-scheme: dark;
}

/* Chrome (Windows especially) paints the open list from the option rules instead, so both are
   needed to cover every browser. Opaque on purpose: a translucent background here gets
   composited against whatever the popup sits on, which is not our panel. */
select option,
select optgroup {
    background-color: var(--surface);
    color: var(--text);
}

input[type="range"] {
    padding: 0;
    height: 4px;
}

.value-display {
    display: inline-block;
    margin-left: 6px;
    color: #667eea;
    font-weight: 500;
    font-size: 10px;
}

button {
    /* width: 100%; */
    padding: 6px 8px;
    background: rgba(102, 126, 234, 0.5);
    color: var(--text);
    border: none;
    border-radius: 3px;
    font-size: 10px;
    font-weight: 500;
    cursor: pointer;
    margin-top: 4px;
    transition: all 0.2s;
    box-shadow: none;
}

button:hover {
    background: rgba(102, 126, 234, 0.7);
}

button:active {
    background: rgba(102, 126, 234, 0.8);
}

/* Effect Instance Styles */
.effect-instance {
    background: rgba(255, 255, 255, 0.03);
    border: 1px solid rgba(102, 126, 234, 0.3);
    border-radius: 4px;
    margin-bottom: 6px;
    transition: all 0.2s;
}

/* Highlighted state for expanded effect */
.effect-instance.expanded {
    background: rgba(102, 126, 234, 0.08);
    border: 1px solid rgba(102, 126, 234, 0.5);
    box-shadow: 0 0 10px rgba(102, 126, 234, 0.3);
}

.effect-instance.expanded .effect-header {
    background: rgba(102, 126, 234, 0.25);
}

.effect-header {
    display: flex;
    align-items: center;
    padding: 5px 6px;
    cursor: pointer;
    background: rgba(102, 126, 234, 0.15);
    border-radius: 3px 3px 0 0;
    font-size: 10px;
    transition: background 0.2s;
}

/* The leading slot of a list-row header. .drag-handle is the draggable variant (effects);
   .header-glyph is the same box without the grab affordance, for rows that aren't sortable
   (modulators, whose slot holds the waveform icon). Sharing the metrics is what keeps the
   two lists on one left offset. */
.drag-handle,
.header-glyph {
    /* Fixed box, not shrink-to-fit: the ⋮⋮ glyph measures 8px and a 14px icon measures 14,
       which put the effect and modulator titles on different left offsets. One width puts
       every title in the same column. */
    width: 14px;
    flex: 0 0 14px;
    margin-right: 6px;
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0.5;
    user-select: none;
    font-size: 12px;
}

.drag-handle {
    cursor: grab;
}

/* Identifies the modulator rather than affording a drag, so it isn't faded like a handle. */
.header-glyph {
    opacity: 0.85;
}

.drag-handle:active {
    cursor: grabbing;
}

.effect-title {
    user-select: none;
    flex: 1;
    font-weight: 500;
    /* Truncate, never wrap. The header now also carries the layer badges
       (js/ui/layer-gutter.js); with wrapping on, a long name plus two badges pushed the row
       to two lines and shoved the remove button out of the 240px panel. */
    min-width: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Modulator styling - matches effect styling */
.modulator-instance {
    background: rgba(255, 255, 255, 0.03);
    border: 1px solid rgba(102, 126, 234, 0.3);
    border-radius: 4px;
    margin-bottom: 6px;
    transition: all 0.2s;
}

.modulator-instance.expanded {
    background: rgba(102, 126, 234, 0.08);
    border: 1px solid rgba(102, 126, 234, 0.5);
    box-shadow: 0 0 10px rgba(102, 126, 234, 0.3);
}

.modulator-instance.expanded .modulator-header {
    background: rgba(102, 126, 234, 0.25);
}

/* The binding section (js/modulators/modulator-manager.js) — the last section of the expanded
   modulator, carrying the three things that belong to ONE param's drive rather than to the
   wave: the window, the anchor and the Launchpad flag. A modulator row is shared by every
   param it drives, and those three differ per param (docs/MODULATION_DESIGN.md §4).

   Continuous with the controls above it: one rule to say a new section, no box, no second
   background. Pressing M is meant to open ONE thing, and a bordered card inside the panel
   would be the dialog again in a smaller frame. The heading names the param instead, which is
   the only ambiguity worth resolving. Only one is ever on screen. */
.mod-binding-footer {
    margin-top: 8px;
    padding-top: 8px;
    border-top: 1px solid rgba(102, 126, 234, 0.25);
    font-size: 10px;
}

.mod-binding-head {
    font-size: 10px;
    font-weight: 600;
    color: #667eea;
    margin-bottom: 6px;
}

.modulator-header {
    display: flex;
    align-items: center;
    padding: 5px 6px;
    cursor: pointer;
    background: rgba(102, 126, 234, 0.15);
    border-radius: 3px 3px 0 0;
    font-size: 10px;
    transition: background 0.2s;
}

.modulator-title {
    user-select: none;
    flex: 1;
    font-weight: 500;
}

.modulator-controls {
    background: rgba(0, 0, 0, 0.1);
    border-radius: 0 0 3px 3px;
}

.effect-actions {
    display: flex;
    gap: 3px;
}

/* ---- Layer gutter (js/ui/layer-gutter.js) --------------------------------------------
   A read-only picture of the compositor graph: an 18px strip left of the cards holding the
   'below' spine, a dot per layer, and a curve for every layer that reads a named source —
   solid for this frame, dashed for last frame. position:relative is what makes the SVG and
   every card's offsetTop share one coordinate space; the left padding that makes room for
   the strip is further down the file, where it can win on source order. */
#active-effects-list {
    position: relative;
}

.layer-gutter {
    position: absolute;
    /* Clear of the 3px chevron accent bar and the section's own 6px padding. */
    left: 6px;
    top: 0;
    /* The strip sits entirely in the list's padding, so nothing here can cover a card. The
       wires and dots re-enable pointer events for their own tooltips (see below). */
    pointer-events: none;
    overflow: visible;
}

.layer-gutter .gutter-spine {
    stroke: rgba(255, 255, 255, 0.3);
    stroke-width: 1;
    fill: none;
}

.layer-gutter .gutter-wire {
    stroke-width: 1.4;
    fill: none;
    opacity: 0.85;
    /* Hoverable for its <title>: what a wire MEANS (this frame vs last frame, whole stack vs
       one layer) is the part a picture alone cannot spell out. Safe because the gutter never
       overlaps a card. */
    pointer-events: stroke;
}

/* Last frame. The dash IS the distinction — a '@prev' wire may also run upward, which a
   same-frame wire never can. */
.layer-gutter .gutter-wire.prev {
    stroke-dasharray: 3 2.5;
}

.layer-gutter .gutter-dot {
    stroke-width: 1.4;
    pointer-events: auto;
}

/* Hollow = renders but never joins the stack, so the spine visibly passes through nothing. */
.layer-gutter .gutter-dot.pass {
    stroke-dasharray: 2 1.6;
}

/* The layer's identity colour is an inset bar on the header's left edge, written inline by
   layer-gutter.js because it is per-layer. Nothing to declare here — noted so the next person
   looking for a `.layer-chip` class knows where it went and why it is not in the flow. */

/* Only ever holds what DEPARTS from a plain stacked layer — an empty meta slot is the
   normal case and must take no width. */
.layer-meta {
    display: flex;
    align-items: center;
    gap: 2px;
    /* Never squeezed: the title truncates instead. A half-clipped "MU" is worse than a
       shorter name. */
    flex: 0 0 auto;
    margin-right: 4px;
    font-size: 8px;
    line-height: 1;
}

/* The common case is a plain stacked layer with nothing to say — it must cost no width. */
.layer-meta:empty {
    margin-right: 0;
}

.layer-badge {
    padding: 2px;
    border-radius: 2px;
    background: rgba(255, 255, 255, 0.12);
    color: #cfcfcf;
    letter-spacing: 0.03em;
    font-weight: 600;
}

.layer-badge.dim {
    background: rgba(255, 255, 255, 0.07);
    color: #9a9a9a;
}

.layer-badge.pass {
    background: rgba(255, 176, 60, 0.2);
    color: #ffb03c;
}

/* A source naming something that is not a layer in this stack. The compositor degrades it to
   'below' without complaint and draws no wire, so this is the only warning there is. */
.layer-badge.broken {
    background: rgba(224, 86, 110, 0.22);
    color: #ff8095;
}

.effect-actions button {
    /* 4px, not 6: the header now also carries the layer badges (blend / opacity / pass), and
       the 12px this gives back across three buttons is the difference between "Warp #4" and
       "War…" in a 240px panel. */
    padding: 0 4px;
    /* Fixed height so a button holding an emoji (⏸ 👁️) is no taller than one holding "×".
       Without it the effect header ran 6px taller than the modulator header purely because
       of what its buttons contained. */
    height: 18px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 9px;
    line-height: 1;
    border: none;
    background: rgba(255, 255, 255, 0.1);
    color: var(--text);
    cursor: pointer;
    border-radius: 2px;
    width: auto;
    margin: 0;
}

.effect-actions button:hover {
    background: rgba(255, 255, 255, 0.2);
}

.effect-controls {
    padding: 8px;
    font-size: 10px;
}

.sortable-ghost {
    opacity: 0.4;
    background: rgba(102, 126, 234, 0.5);
}

::-webkit-scrollbar {
    width: 4px;
}

::-webkit-scrollbar-track {
    background: var(--bg);
}

::-webkit-scrollbar-thumb {
    background: var(--surface-2);
    border-radius: 2px;
}

::-webkit-scrollbar-thumb:hover {
    background: var(--surface-2);
}

input[type="checkbox"] {
    width: 14px;
    height: 14px;
}

hr {
    border: none;
    border-top: 1px solid var(--line);
    margin: 8px 0;
}

/* Range control wrapper with editor button */
.range-control-wrapper {
    position: relative;
    display: flex;
    align-items: center;
    gap: 4px;
}

.range-control-wrapper input[type="range"] {
    flex: 1;
}

/* Out-of-range value ---------------------------------------------------------
   A stored value can sit outside its parameter range (the range was narrowed
   after the preset was saved). Rather than let the slider silently clamp what it
   shows — which loses the real value on the next touch — the control widens to
   include it and shades the stretch that lies outside. Position/width are derived
   from the value in van-ui.js, so this clears itself once the value is back. */
.range-out-of-bounds {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    height: 6px;
    border-radius: 3px;
    background: repeating-linear-gradient(
        135deg,
        rgba(241, 90, 48, 0.55) 0 5px,
        rgba(241, 90, 48, 0.18) 5px 10px
    );
    pointer-events: none;   /* the slider underneath must stay grabbable */
    z-index: 1;
}

.range-control-wrapper input[type="range"].out-of-range {
    position: relative;
    z-index: 2;             /* handle stays above the shading */
    accent-color: #f15a30;  /* the handle itself flags the anomaly too */
}

.range-editor-btn {
    /* background: rgba(102, 126, 234, 0.2); */
    /* border: 1px solid rgba(102, 126, 234, 0.4); */
    color: var(--muted);
    cursor: pointer;
    /* padding: 2px 4px; */
    width: 0.25rem;
    font-size: 10px;
    line-height: 1;
    border-radius: 2px;
    transition: all 0.2s;
    min-width: 18px;
    height: 18px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: none;
}

.range-editor-btn:hover {
    background: rgba(102, 126, 234, 0.4);
    /* border-color: rgba(102, 126, 234, 0.6); */
    color: var(--text);
}

/* Stepper on a select: one click moves to the previous / next option, so a mode change costs
   a single click instead of open-dropdown → find → click. The two buttons stack at the right
   edge, spinner-style, splitting the select's own height (align-items: stretch on the row) so
   the pair reads as one control and up/down sit a button-height apart. */
.select-stepper {
    display: flex;
    flex-direction: column;
    flex: 0 0 auto;
    width: 18px;
    gap: 1px;
}

.select-step {
    flex: 1 1 0;
    min-height: 0;
    padding: 0;
    /* The panel's generic button rule sets margin: 4px 0 0 — inside a 24px column that top
       margin left each half only 7.5px tall, smaller than the glyph it holds. */
    margin: 0;
    color: var(--muted);
    background: rgba(255, 255, 255, 0.06);
    border: 1px solid rgba(255, 255, 255, 0.15);
    border-radius: 3px;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: all 0.15s;
}

.select-step:hover {
    background: rgba(102, 126, 234, 0.4);
    color: var(--text);
}

.select-step:active {
    background: rgba(102, 126, 234, 0.7);
}


/* ---------------------------------------------------------------------------
   .app-popover — the one surface every floating panel sits on.

   The range editor, the effect picker and the modulate dialog each carried their own
   background, border, radius and shadow, and no two agreed: 2px borders against 1px,
   radius 8 against 4 against 8, three different shadows, and the modulate dialog on a
   hardcoded rgba(30,30,40,0.98) instead of --surface — translucent, which the token
   block above rules out for exactly this kind of surface ("they are read, not looked
   through, and a dropdown over a strobing preset is unreadable at any alpha below
   opaque").

   Listed as one rule over several selectors rather than a class the markup has to opt
   into, so nothing in index.php or the builders needs to change to be covered. Anything
   new can just take .app-popover.
   --------------------------------------------------------------------------- */
.app-popover,
.range-editor-popup,
.effect-dropdown,
/* The confirm/prompt dialogs and the notifications used to carry their own: rgba(20,20,30,.98)
   at radius 12 with a bespoke shadow, against the panel's --surface at --r-md. Three surfaces
   for one product, and the dialogs are the parts a user meets at the moment they are deciding
   something — the worst place for the app to look like a different app. The notification keeps
   its per-type tints below (those say what KIND of message it is); only its chrome joins. */
.confirm-box,
.prompt-box,
.notification {
    background: var(--surface);
    border: 1px solid var(--panel-brd);
    border-radius: var(--r-md);
    box-shadow: var(--shadow-pop);
    color: var(--text);
    /* A scroll inside a popover must not chain out to the page behind it. */
    overscroll-behavior: contain;
}

/* Range editor popup.
   FIXED, not absolute — but that is only half of it. This element ships inside #controls,
   which is position:fixed with overflow:hidden AND backdrop-filter: blur(10px); a
   backdrop-filter makes an element a containing block for FIXED descendants as well, so
   position:fixed on its own neither escaped the clip nor made the coordinates
   viewport-relative. window.placeRangeEditor() reparents it to <body> before placing it,
   which is what actually makes this rule mean what it says. */
.range-editor-popup {
    position: fixed;
    padding: 12px;
    width: 240px;
    display: none;
    z-index: 9999;
}

.range-editor-popup.active {
    display: block;
}

.range-editor-popup h3 {
    margin: 0 0 10px 0;
    color: #667eea;
    font-size: 12px;
}

.range-editor-popup label {
    display: block;
    margin: 8px 0 3px 0;
    font-size: 10px;
    color: var(--muted);
}

.range-editor-popup input[type="number"] {
    width: 100%;
    background: var(--bg);
    border: 1px solid var(--line);
    color: var(--text);
    padding: 5px;
    border-radius: 3px;
    font-size: 10px;
}

.range-editor-buttons {
    display: flex;
    gap: 8px;
    margin-top: 15px;
}

.range-editor-buttons button {
    flex: 1;
    padding: 8px;
    border: none;
    border-radius: 3px;
    cursor: pointer;
    font-size: 11px;
}

.range-editor-buttons .save-btn {
    background: #667eea;
    color: var(--text);
}

.range-editor-buttons .save-btn:hover {
    background: #5568d3;
}

.range-editor-buttons .cancel-btn {
    background: var(--surface-2);
    color: var(--text);
}

.range-editor-buttons .cancel-btn:hover {
    background: var(--surface-2);
}

/* Effect dropdown menu. Surface, border, radius and shadow come from .app-popover above. */
.effect-dropdown {
    position: fixed;
    z-index: 10000;
    min-width: 200px;
    /* max-height is set per-open by toggleEffectSelector() from the room left under
       (or over) the button — a CSS percentage measured the viewport instead, so the
       tail of the list fell off the bottom of the screen with no way to reach it. */
    overflow-y: auto;
}

/* Category band — sticky so you always know which group you're scrolling through. */
.effect-group-header {
    position: sticky;
    top: var(--filter-h, 33px); /* under the sticky filter input (measured on open) */
    z-index: 1;
    padding: 5px 12px;
    background: var(--surface-2);
    border-bottom: 1px solid var(--line);
    color: var(--muted);
    font-size: 9px;
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
}

.effect-option {
    padding: 10px 12px;
    font-size: 11px;
    color: var(--text);
    cursor: pointer;
    transition: background 0.2s;
    border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}

.effect-option:last-child {
    border-bottom: none;
}

.effect-option:hover {
    background: rgba(102, 126, 234, 0.3);
}

/* Keyboard cursor in the add-effect picker (arrow keys move it, Enter adds it). Brighter
   than :hover and carries a left bar, so when the pointer happens to rest on another row
   it is still obvious which one Enter will take. */
.effect-option.highlighted {
    background: rgba(102, 126, 234, 0.45);
    box-shadow: inset 3px 0 0 #667eea;
}

#add-effect-btn:hover #add-effect-label {
    display: none;
}

#add-effect-btn:hover #add-effect-icon::after {
    content: '↓';
    font-size: 10px;
}

/* Video panel source buttons — disk / library / one per camera. One class so the
   camera buttons are exactly the size of the other two (they used to be a second,
   smaller row); the device name lives in the tooltip, not the label. */
.video-source-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 3px;
    min-width: 34px;
    height: 26px;
    padding: 0 8px;
    border: none;
    border-radius: 3px;
    color: var(--text);
    font-size: 11px;
    font-weight: 600;
    line-height: 1;
    cursor: pointer;
}

.video-source-btn:hover {
    filter: brightness(1.2);
}

/* Sortable.js drag-and-drop styles */
.sortable-ghost {
    opacity: 0.4;
    background: rgba(102, 126, 234, 0.2);
    transform: scale(1.1);
}

.sortable-drag {
    opacity: 1;
    cursor: grabbing !important;
}

/* What a modulator drives, in its row header. Without it the section is a list of bare
   codes (M01, M02…) and you have to open each one to find the right one. Dim and small so
   the code stays the heading; it truncates rather than wrapping the row, and the full list
   is the title tooltip. */
.modulator-connections {
    margin-left: 6px;
    color: var(--muted);
    font-size: 9px;
    font-weight: 400;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.state-square {
    cursor: move;
    cursor: grab;
}

.state-square:active {
    cursor: grabbing;
}

/* Playing and queued are ONE effect with two weights: the outline. Not border — these squares
   are packed to a fixed row width and a border would resize them; an outline sits outside the
   box and cannot reflow the row.

   Queued is deliberately STILL. It used to pulse on its own 0.6s clock against playing's 0.8s,
   which is two competing rhythms — and with a queue of a dozen squares the whole panel crawled
   while the one square that actually matters stopped standing out. Motion is the scarcest
   signal here; spending it on the set as well as the one means spending it on neither.
   Weight and brightness carry "queued", motion carries "now".

   Also gone: the box-shadow glow and the outline-offset travel. The glow was a blurred paint
   animating on every queued square at once, the most expensive thing in this rule, and the
   moving offset pushed the ring away from the square. Both states now sit at offset 0, tight
   against the box, so a queued square that starts playing only thickens and brightens —
   nothing moves.

   The playing square also carried an inline `border: 2px solid white` and a white box-shadow
   from createStateSquare, entirely separate from these rules. Both are gone: the outline is
   the whole story now, and a border would resize the square where an outline cannot. */
.state-square.queued {
    outline: 1px solid #fff;
    outline-offset: 0;
}

/* Preset rows pack left. The row is pinned to exactly PRESETS_PER_ROW boxes wide, so a
   partial row must NOT justify space-between — that stretched three presets across the full
   width of six. (It was there to hold the per-collection trash against the right edge; the
   trash is gone, removal is a right-click on the square now.) Flex default is flex-start,
   so there is nothing to declare — and this is also what #collection-community always did,
   since neither selector above ever matched it. */

/* Live Output body: compact icon+label buttons in the same idiom as the collection-row
   buttons, replacing five stacked full-width blocks of the default blue `button` rule —
   which made the app's most operational menu look like a web form. Built in
   js/live-sync.js buildBar(); the id gives these rules the specificity to win over the
   generic button styling without !important. */
#live-sync-section-controls {
    flex-direction: column;
    gap: 4px;
}

#live-sync-section-controls .ls-row {
    display: flex;
    gap: 4px;
}

#live-sync-section-controls button {
    flex: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 5px;
    min-height: 24px;
    margin: 0;
    padding: 3px 8px;
    font-size: 10px;
    font-weight: 600;
    color: var(--muted);
    background: rgba(102, 126, 234, 0.14);
    border: 1px solid rgba(102, 126, 234, 0.28);
    border-radius: 3px;
}

#live-sync-section-controls button:hover {
    color: var(--text);
    background: rgba(102, 126, 234, 0.3);
    border-color: rgba(102, 126, 234, 0.5);
}

/* The two SHOW controls sit in the section header (js/live-sync.js): Push, and the state
   switch that used to be a read-only badge. The section body is collapsed by default, and the
   two gestures an operator repeats through a set — publish a look, step between Live and Hold
   — cannot live behind a disclosure triangle. Same family as the body's buttons, but sized to
   a 24px row and never stretched: this is a row of things, not a grid of equals. */
#live-sync-section-btn button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 4px;
    min-height: 18px;
    margin: 0;
    padding: 1px 6px;
    font-size: 9px;
    font-weight: 600;
    color: var(--muted);
    background: rgba(102, 126, 234, 0.14);
    border: 1px solid rgba(102, 126, 234, 0.28);
    border-radius: 3px;
    cursor: pointer;
}

#live-sync-section-btn button:hover {
    color: var(--text);
    background: rgba(102, 126, 234, 0.3);
    border-color: rgba(102, 126, 234, 0.5);
}

/* Wide enough that OFF, LIVE and HOLD do not shuffle the row as they replace each other. Its
   text colour is written inline per state (it is still the badge), which outranks the rules
   above — so hovering changes the fill and leaves the state colour alone. */
#live-sync-state-btn {
    min-width: 38px;
    letter-spacing: 0.04em;
}

/* The presentation effect renders text into a fullscreen div on document.body, above the
   WebGL canvas and outside it — so the master level cannot reach it from a shader. It reads
   the same number the output pass multiplies by, written once per change by the frame loop
   (js/main.js). Before this the fader dimmed the visuals and left the text at full brightness.
   The player animates its ITEMS' opacity on inner nodes, never this one, so the two do not
   fight. */
.presentation-overlay {
    opacity: var(--master-opacity, 1);
}

/* The live preview — the projection's own pixels, copied (js/live-preview.js).
   Deliberately plain: a bezel and a caption, because the one thing it must not do is look
   like a control. In CUE it sits under the buttons as the only honest answer to "what is on
   the projector right now", while the big canvas behind the panel is the cue you are
   building. Black background so a frame that has not arrived yet reads as black output
   rather than as a hole in the panel. */
#live-preview {
    margin-top: 2px;
}

#live-preview-canvas {
    display: block;
    width: 100%;
    background: #000;
    border: 1px solid rgba(102, 126, 234, 0.28);
    border-radius: 3px;
}

#live-preview-status {
    margin-top: 2px;
    font-size: 9px;
    color: var(--muted);
    text-align: right;
}

/* The MIRROR — the same copy as the thumbnail above, at cue size. With Mirror on, the local
   engine is stopped outright (js/main.js honours window.__visualsPaused) and this is the only
   picture in the window, so it stands exactly where #canvas-container stands: fixed, full
   bleed, behind the panel's z-index: 1000. */
#live-stage {
    position: fixed;
    inset: 0;
    z-index: 0;
    display: none;
    background: #000;
}

/* Only once this window has stopped rendering its own. With a cue of its own on screen the
   mirror would be a second picture arguing with it. */
html.no-visuals #live-stage {
    display: block;
}

#live-stage-canvas {
    display: block;
    width: 100%;
    height: 100%;
    /* Letterbox. The projector's aspect is the truth, and a stretched mirror lies about
       framing — which is one of the things you would open it to check. */
    object-fit: contain;
}

/* Silent while the output is healthy (`quiet` in js/live-preview.js): this is a picture, and a
   caption reading "live output" across it forever is furniture. It appears only to say
   something is wrong, so it is sized to be read at a glance from the back of a room. */
#live-stage-status {
    position: absolute;
    left: 12px;
    bottom: 12px;
    padding: 4px 8px;
    border-radius: var(--r-sm, 3px);
    background: rgba(0, 0, 0, 0.72);
    color: #ffb74d;
    font-size: 12px;
    pointer-events: none;
}

/* A switch in its ON state, header and body alike — the state switch when there is an output,
   Mirror when the cue picture comes from it, Off when there is nothing running. Blue fill
   rather than the warm LIVE accent, which is reserved for the one fact the operator scans the
   whole panel for: that something is going out. On the state switch the fill is all this
   contributes; its text keeps the state colour, written inline. */
#live-sync-group button.ls-armed {
    color: var(--text);
    background: rgba(102, 126, 234, 0.55);
    border-color: rgba(102, 126, 234, 0.8);
}

#live-sync-group button.ls-armed:hover {
    background: rgba(102, 126, 234, 0.75);
}

/* Nothing to act on. Push, Pull and Off all operate on an output window, so with none open they
   are dead — and a control that looks pressable and answers with silence teaches you to
   distrust the panel. Muted rather than hidden: the row keeps its shape, so the section does
   not reflow every time the output opens and closes. */
#live-sync-group button:disabled {
    opacity: 0.35;
    cursor: default;
    background: rgba(102, 126, 234, 0.07);
    border-color: rgba(102, 126, 234, 0.15);
}

#live-sync-group button:disabled:hover {
    background: rgba(102, 126, 234, 0.07);
    border-color: rgba(102, 126, 234, 0.15);
    color: var(--muted);
}

/* The collection holding the currently-playing preset.
   This replaces an opacity blink on the 10px ▶ caret (.collection-indicator-active): at a
   glance, in a list of 19 collections, a faintly pulsing grey triangle read as nothing —
   and when a collection is collapsed its blinking preset square is hidden, so this row is
   the only place that can say where the running preset lives.
   Styled with box-shadow only, never `background`: the row carries an inline background
   from renderStateSquares() that a class cannot override. The inset 200px spread lifts the
   row (box-shadow paints above the background, below the text), the ring makes a collapsed
   row findable, and both take the collection's own colour via --col-accent, set per row. */
.collection-header.collection-active {
    box-shadow:
        inset 3px 0 0 var(--col-accent, #667eea),
        inset 0 0 0 200px rgba(255, 255, 255, 0.09),
        0 0 0 1px var(--col-accent, #667eea),
        0 0 10px -2px var(--col-accent, #667eea);
}

/* The chevron, and the name beside it, are what move on a playing row.
   This replaces a separate equaliser badge: a third mark on a row that already carries an
   accent bar, a ring and a glow was one more thing to look at, and it pulsed while the two
   elements you actually read — "which collection is this" and "is it open" — sat still.
   Pulsing those two instead puts the motion on the content rather than beside it.

   Opacity only, no transform: the chevron and the name are different sizes, and scaling
   would make them breathe out of step. Both keep their own colour throughout — the name's
   colour IS which collection this is, which is the thing you are looking for when it plays. */
.collection-header.collection-active .collection-indicator,
.collection-header.collection-active .collection-name {
    animation: collectionPlayingPulse 1.4s ease-in-out infinite;
}

.collection-indicator {
    display: inline-flex;
    align-items: center;
    color: var(--muted);
    flex: 0 0 auto;
}

.collection-header.collection-active .collection-indicator {
    color: var(--col-accent, #667eea);
}

@keyframes collectionPlayingPulse {

    0%,
    100% {
        opacity: 1;
    }

    50% {
        opacity: 0.45;
    }
}

/* Playing video playlist item border blink animation */
@keyframes playlistItemBlink {

    0%,
    100% {
        border-left-color: #667eea;
    }

    50% {
        border-left-color: rgba(102, 126, 234, 0.3);
    }
}

.video-playlist-item-playing {
    animation: playlistItemBlink 1.5s ease-in-out infinite;
}

/* Active/playing state square: the same outline as .queued, twice the weight and blinking —
   never the fill colour. Only the opacity animates: the width and offset are constant, so a
   queued square that starts playing thickens in place instead of jumping. */
.state-square.playing-blink {
    animation: activeStateBlink 0.8s ease-in-out infinite;
}

@keyframes activeStateBlink {

    0%,
    100% {
        outline: 2px solid rgba(255, 255, 255, 0.95);
        outline-offset: 0;
    }

    50% {
        outline: 2px solid rgba(255, 255, 255, 0.15);
        outline-offset: 0;
    }
}

/* Mid-transition: the loudest and briefest of the three, and the last rule so it wins the
   outline on a square that is also playing or queued (all three are .state-square.X, so
   specificity is equal and source order decides).

   3px and 0.3s — thicker and roughly two and a half times faster than playing's 0.8s, which
   is what makes "changing right now" read as different from "playing" rather than as more of
   it. Same period the old JS setInterval used (150ms on, 150ms off), kept because the pace
   was right; what changed is that it drives an outline on the compositor's clock instead of
   an inline border on setInterval's. */
.state-square.transitioning {
    animation: transitionBlink 0.3s steps(1, end) infinite;
}

@keyframes transitionBlink {

    0% {
        outline: 3px solid rgba(255, 255, 255, 1);
        outline-offset: 0;
    }

    50% {
        outline: 3px solid transparent;
        outline-offset: 0;
    }
}

/* Video timeline range sliders */
.timeline-start::-webkit-slider-runnable-track,
.timeline-end::-webkit-slider-runnable-track {
    width: 100%;
    height: 4px;
    background: rgba(255, 255, 255, 0.2);
    border-radius: 2px;
}

.timeline-start::-webkit-slider-thumb,
.timeline-end::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 10px;
    height: 14px;
    background: #667eea;
    cursor: pointer;
    border-radius: 2px;
    margin-top: -5px;
}

.timeline-start::-moz-range-track,
.timeline-end::-moz-range-track {
    width: 100%;
    height: 4px;
    background: rgba(255, 255, 255, 0.2);
    border-radius: 2px;
}

.timeline-start::-moz-range-thumb,
.timeline-end::-moz-range-thumb {
    width: 10px;
    height: 14px;
    background: #667eea;
    cursor: pointer;
    border-radius: 2px;
    border: none;
}

/* Active geoglyph shape highlighting */
.shape-label.active-shape {
    background: rgba(102, 126, 234, 0.3);
    border-radius: 3px;
    padding: 2px 4px;
    margin: -2px -4px;
}

/* Notifications */
#notifications-container {
    position: fixed;
    top: 20px;
    right: 20px;
    z-index: 10000;
    display: flex;
    flex-direction: column;
    gap: 10px;
    pointer-events: none;
    max-width: 400px;
}

/* Surface, border, radius and shadow: the .app-popover rule. What is left is this element's
   own — its size, its entrance, and the tints below that say what kind of message it is. */
.notification {
    padding: 10px 12px;
    font-size: 12px;
    animation: slideIn 0.3s ease-out;
    pointer-events: auto;
    min-width: 250px;
}

.notification.success {
    border-color: rgba(76, 175, 80, 0.6);
    background: rgba(20, 40, 20, 0.95);
}

.notification.warning {
    border-color: rgba(255, 193, 7, 0.6);
    background: rgba(40, 35, 20, 0.95);
}

.notification.error {
    border-color: rgba(244, 67, 54, 0.6);
    background: rgba(40, 20, 20, 0.95);
}

.notification.info {
    border-color: rgba(33, 150, 243, 0.6);
    background: rgba(20, 30, 40, 0.95);
}

.notification-icon {
    font-size: 18px;
    margin-right: 8px;
    display: inline-block;
}

.notification-content {
    display: inline-block;
    vertical-align: middle;
}

.notification-title {
    font-weight: bold;
    margin-bottom: 4px;
}

.notification-message {
    font-size: 12px;
    opacity: 0.9;
    line-height: 1.4;
}

@keyframes slideIn {
    from {
        transform: translateX(400px);
        opacity: 0;
    }

    to {
        transform: translateX(0);
        opacity: 1;
    }
}

@keyframes slideOut {
    from {
        transform: translateX(0);
        opacity: 1;
    }

    to {
        transform: translateX(400px);
        opacity: 0;
    }
}

.notification.hiding {
    animation: slideOut 0.3s ease-in forwards;
}

/* Messages history (controls panel section) */
#messages-history {
    max-height: 220px;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 4px;
    background: rgba(255, 255, 255, 0.02);
    border-radius: 4px;
    padding: 4px;
}

.message-entry {
    display: flex;
    align-items: flex-start;
    gap: 6px;
    padding: 5px 7px;
    border-radius: 4px;
    border-left: 3px solid rgba(33, 150, 243, 0.6);
    background: rgba(20, 30, 40, 0.6);
    font-size: 11px;
    line-height: 1.35;
}

.message-entry.success {
    border-left-color: rgba(76, 175, 80, 0.8);
    background: rgba(20, 40, 20, 0.6);
}

.message-entry.warning {
    border-left-color: rgba(255, 193, 7, 0.8);
    background: rgba(40, 35, 20, 0.6);
}

.message-entry.error {
    border-left-color: rgba(244, 67, 54, 0.8);
    background: rgba(40, 20, 20, 0.6);
}

.message-entry.info {
    border-left-color: rgba(33, 150, 243, 0.8);
    background: rgba(20, 30, 40, 0.6);
}

.message-entry-icon {
    flex: 0 0 auto;
}

.message-entry-body {
    flex: 1 1 auto;
    min-width: 0;
}

.message-entry-title {
    font-weight: bold;
}

.message-entry-text {
    opacity: 0.85;
    word-break: break-word;
}

.message-entry-time {
    flex: 0 0 auto;
    opacity: 0.5;
    font-size: 9px;
    white-space: nowrap;
}

#messages-history-empty {
    opacity: 0.5;
    font-size: 11px;
    text-align: center;
    padding: 8px;
}

/* Confirmation and Prompt Dialogs */
/* Docked top-right over the controls, not centered over the canvas — a live
   performer must keep seeing the projection while confirming/renaming. */
#confirm-dialog,
#prompt-dialog {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 20000;
    display: flex;
    align-items: flex-start;
    justify-content: flex-end;
    padding: 64px 8px 8px;
}

/* Kept full-screen so the dialog stays modal (clicks don't fall through mid-decision),
   but transparent — no dimming, no blur — so the visual is untouched. */
.confirm-overlay,
.prompt-overlay {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: transparent;
    backdrop-filter: none;
}

/* Surface, border, radius and shadow come from the .app-popover rule above — see the note
   there. What stays here is what is this dialog's own: where it sits and how it arrives. */
.confirm-box,
.prompt-box {
    position: relative;
    padding: 14px;
    min-width: 240px;
    max-width: 320px;
    animation: dialogFadeIn 0.2s ease-out;
}

/* 12px, weight 600 — a section heading, which is what it is. It was 18px bold, nearly
   double the largest type anywhere else on screen. */
.confirm-title,
.prompt-title {
    font-size: 12px;
    font-weight: 600;
    color: #667eea;
    margin-bottom: 8px;
}

.confirm-message,
.prompt-message {
    font-size: 12px;
    color: var(--text);
    margin-bottom: 14px;
    line-height: 1.5;
    white-space: pre-line;
}

/* The same shape every other text field in the app has (see the select / range rule). */
.prompt-input {
    width: 100%;
    padding: 4px 6px;
    background: var(--surface-2);
    border: 1px solid var(--line);
    border-radius: 3px;
    color: var(--text);
    font-size: 12px;
    margin-bottom: 14px;
    outline: none;
}

.prompt-input:focus {
    border-color: rgba(102, 126, 234, 0.8);
}

.confirm-buttons,
.prompt-buttons {
    display: flex;
    gap: 10px;
    justify-content: flex-end;
}

/* OK keeps the app's own button (the global rule) and only takes a dialog's size; Cancel is
   the quiet outline the panel uses everywhere for a secondary action. They were a 14px grey
   pill and a 14px solid blue pill, which belonged to neither. */
.confirm-btn,
.prompt-btn {
    padding: 5px 14px;
    border-radius: 3px;
    font-size: 11px;
    margin: 0;
    cursor: pointer;
    transition: all 0.2s;
}

.confirm-cancel,
.prompt-cancel {
    background: rgba(102, 126, 234, 0.15);
    color: #667eea;
    border: 1px solid rgba(102, 126, 234, 0.3);
}

.confirm-cancel:hover,
.prompt-cancel:hover {
    background: rgba(102, 126, 234, 0.28);
    color: var(--text);
}

.confirm-ok,
.prompt-ok {
    /* rgba(102,126,234,0.5), from the button rule — named here only to add the border that
       keeps the two buttons the same height. */
    border: 1px solid transparent;
    color: var(--text);
}

@keyframes dialogFadeIn {
    from {
        transform: scale(0.9);
        opacity: 0;
    }

    to {
        transform: scale(1);
        opacity: 1;
    }
}

.performance-indicator {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 11px;
    pointer-events: none;
}

/* Fixed slot for the number: tabular figures + room for 3 digits, so the header
   stops reflowing every time the reading crosses 9 / 99. */
#perf-fps {
    display: inline-block;
    min-width: 3ch;
    text-align: right;
    font-variant-numeric: tabular-nums;
    font-feature-settings: "tnum";
}

.perf-icon {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: rgba(76, 175, 80, 1);
    display: inline-block;
    transition: background 0.3s ease;
}
/* Icons ----------------------------------------------------------------------
   Material Symbols, inlined as a sprite by index.php and referenced with <use>.
   currentColor means an icon simply takes the colour of the text it sits in, so
   the same symbol works in a panel, on a warning and inside a primary button
   without a variant per context. */
.ic {
    display: inline-block;
    vertical-align: -0.15em;      /* sits on the text baseline, not above it */
    flex: none;                   /* never squashed by a flex parent */
    fill: currentColor;
    pointer-events: none;         /* clicks belong to the button, not the glyph */
}
button .ic, a .ic { vertical-align: middle; }

/* Expanded section rows get an accent bar + tint so open vs collapsed reads at a
   glance. Uses inset box-shadow, not background: the rows set background inline and
   an inline value would beat a class rule. The big inset spread paints a uniform
   overlay on top of that inline background. */
/* Each section owns a colour via --chev-accent (R,G,B so one value feeds bar + tint),
   set on BOTH its header row and its body element so the whole section — header and
   the expanded controls under it — tints as one coloured block. */
#scenes-toggle,      #scenes-section-controls  { --chev-accent:  76, 175,  80; }  /* green  */
#collections-toggle, #states-container         { --chev-accent: 255, 152,   0; }  /* orange */
#effects-toggle,     #active-effects-list      { --chev-accent: 233,  30,  99; }  /* pink   */
#modulators-toggle,  #modulators-list          { --chev-accent:   0, 188, 212; }  /* cyan   */
#canvas-toggle,      #canvas-section-controls  { --chev-accent: 255, 193,   7; }  /* amber  */
/* Camera owns two bodies: the Random sub-panel and the view controls. Both carry the
   section colour so the whole camera block reads as one, however many are open. */
#camera-toggle,      #camera-section-controls,
#random-camera-controls                        { --chev-accent: 156,  39, 176; }  /* purple */
#audio-analyzer-btn, #audio-analyzer-controls  { --chev-accent:   3, 169, 244; }  /* blue   */
#live-sync-section-btn,
#live-sync-section-controls                    { --chev-accent: 244,  67,  54; }  /* red    */
#launchpad-btn,      #launchpad-controls       { --chev-accent: 255, 193,   7; }  /* amber  */
#account-toggle,     #account-section-controls { --chev-accent: 158, 158, 158; }  /* grey   */

/* Header row: accent bar always present, so the section's colour reads even when
   collapsed. Falls back to blue for any row without its own --chev-accent.

   This selector list is also the SINGLE source of the spacing between sections. Each row
   carried its own inline margin before, and the wrappers around Scenes and Modulators added
   more on top, so the panel ran 6 / 12 / 6 / 14 / 25 / 6 / 6 / 6 px between rows — an
   unevenness you could see but not name. One margin here, no inline ones, and a section
   added later inherits the rhythm instead of guessing at it. */
#scenes-toggle, #collections-toggle, #effects-toggle,
#modulators-toggle, #canvas-toggle, #camera-toggle,
#audio-analyzer-btn, #live-sync-section-btn, #launchpad-btn, #account-toggle {
    box-shadow: inset 3px 0 0 rgb(var(--chev-accent, 102, 126, 234));
    margin: 0 0 var(--section-gap, 6px);
}

/* Open header row: bar + a fuller tint over its inline background. */
.chev-open {
    box-shadow:
        inset 3px 0 0 rgb(var(--chev-accent, 102, 126, 234)),
        inset 0 0 0 200px rgba(var(--chev-accent, 102, 126, 234), 0.12);
}

/* Section bodies: same accent bar + a softer tint, so header and body read as one
   block. The top-left corner is squared and the top margin removed so the body's bar
   meets the header's as one straight line; only the bottom corners round off.
   Matched by id alone, NOT `header.chev-open + body` — a body is display:none while
   collapsed, so the guard bought nothing, and the adjacency silently failed for
   Camera, whose body is not its header's next sibling (#random-camera-controls sits
   between them). That is why the view buttons, transition duration and perspective
   had no accent while every other section did. */
#scenes-section-controls, #states-container, #active-effects-list, #modulators-list,
#canvas-section-controls, #camera-section-controls, #random-camera-controls,
#audio-analyzer-controls, #live-sync-section-controls, #launchpad-controls,
#account-section-controls {
    box-shadow: inset 3px 0 0 rgb(var(--chev-accent));
    background: rgba(var(--chev-accent), 0.06);
    border-radius: 0 0 4px 4px;
    padding: 6px;
    margin-top: 0;
    /* The gap to the NEXT section, taken over from the header while this body is showing.
       The header's margin does two jobs — separate the next section, and close up against
       its own body — and .chev-open below zeroes it for the second. Without this the first
       job went with it, so an OPEN section sat flush against the one after it while a
       closed one had 6px: spacing that changed as you clicked around. */
    margin-bottom: var(--section-gap, 6px);
}

/* The effect list alone carries the layer gutter (js/ui/layer-gutter.js) inside its left
   padding: the 6px above, plus an 18px strip for the spine, the dots and the source wires.
   It has to come AFTER the rule above — that one sets `padding` wholesale at the same
   specificity, so declaring it up with the rest of the gutter styles lost on source order
   and the cards sat on top of the wires. */
#active-effects-list {
    padding-left: 24px;
}

/* Open header row: drop the gap to its body and square its bottom corners so the
   left accent bar runs straight down into the body's bar with no break or rounding
   at the join. !important overrides the inline margin/border-radius the rows carry. */
#scenes-toggle.chev-open, #collections-toggle.chev-open, #effects-toggle.chev-open,
#modulators-toggle.chev-open, #canvas-toggle.chev-open, #camera-toggle.chev-open,
#audio-analyzer-btn.chev-open, #live-sync-section-btn.chev-open, #launchpad-btn.chev-open,
#account-toggle.chev-open {
    margin-bottom: 0 !important;
    border-bottom-left-radius: 0 !important;
    border-bottom-right-radius: 0 !important;
}

/* ---------------------------------------------------------------------------
   Hand-written sections read like an effect panel.

   Master, Playlist, Scene Camera and Audio Analyzer predate the generated controls, so each
   laid its parameters out by hand: a block label with the value trailing the text on the
   LEFT, 4px margins above and below every one of them, and whatever inline styles the row
   happened to be written with. An effect's row — built by VanUI.rangeControl — puts the name
   left, the value hard right, and the control immediately under it. Four sections, four
   rhythms, in one 240px column.

   So the effect's geometry is declared once, here, for those sections. Nothing is copied into
   the markup: a rule beats a hand-written row only if the row stops carrying inline styles,
   which is why the inline label styles came out of index.php in the same change.

   The value stays .value-display, which effects use too — that part never diverged.
   --------------------------------------------------------------------------- */
#canvas-section-controls label,
#scenes-section-controls label,
#camera-section-controls label,
#random-camera-controls label,
#audio-analyzer-controls label {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 6px;
    /* Margins are deliberately NOT set: `.control-group label` already gives every label in
       the panel 4px top and bottom, effects included, and that is the rhythm being matched.
       Overriding it here would be a fifth rhythm rather than the shared one. */
    /* These labels carry their name as a bare text node between newlines, not in a <span>
       the way a generated control does, so the indentation in the markup becomes part of an
       anonymous flex item and the name wrapped onto a second line — every row 17px taller
       than the effect row it is meant to match. */
    white-space: nowrap;
    /* Double-clicking the name resets the parameter (js/ui/label-reset.js), and a gesture
       that highlights a word on the way is a gesture that looks like it misfired. Same pair
       resetOnDblClick sets on a generated control's title. */
    user-select: none;
    cursor: default;
}

/* A checkbox row is not a parameter row: the box belongs against its text, not pushed to
   the far side of the panel by space-between. Same shape an effect's boolean control has. */
#canvas-section-controls label:has(input[type="checkbox"]),
#scenes-section-controls label:has(input[type="checkbox"]),
#camera-section-controls label:has(input[type="checkbox"]),
#random-camera-controls label:has(input[type="checkbox"]),
#audio-analyzer-controls label:has(input[type="checkbox"]) {
    justify-content: flex-start;
    gap: 6px;
    /* The whole row toggles the box, so it keeps the pointer the rule above turns off —
       exactly how VanUI.checkboxControl splits it: pointer on the label, default on the name. */
    cursor: pointer;
}

/* The control under its label. An effect's slider sits in a flex wrapper with no margin of
   its own, so the only gaps in a row are the label's 4px — the same is true here now, which
   is what makes the four sections scroll at the effect stack's rhythm rather than at three
   of their own. */
#canvas-section-controls > input[type="range"],
#canvas-section-controls > select,
#scenes-section-controls input[type="range"],
#scenes-section-controls > select,
#camera-section-controls input[type="range"],
#camera-section-controls > select,
#random-camera-controls input[type="range"],
#audio-analyzer-controls input[type="range"],
#audio-analyzer-controls > select {
    /* block, not the inline-block a range input defaults to: as an inline box it sat on a
       text line, and the newline between it and its label added a whole empty line box under
       every slider. An effect's slider avoids this by living inside a flex wrapper. */
    display: block;
    margin: 0;
}

/* The Audio Analyzer's readouts (level, energy, BPM, bands). Same surface an effect's
   section box uses — they were four black wells cut into a panel that has no other black
   surface anywhere, which read as holes rather than as grouped readings. */
.readout-box {
    background: rgba(255, 255, 255, 0.05);
    padding: 8px;
    border-radius: 4px;
    margin-bottom: 8px;
}

/* Buttons in these sections were the loudest thing in the panel: eight solid-blue camera
   views and a gradient Reset BPM, against the quiet outline buttons everything newer uses
   (#playlist-clear-btn, the effect header actions). Same idiom as those — the blue says
   "clickable", not "look at me". */
#camera-section-controls button,
#random-camera-controls button,
#audio-analyzer-controls button {
    background: rgba(102, 126, 234, 0.15);
    color: #667eea;
    border: 1px solid rgba(102, 126, 234, 0.3);
    border-radius: 3px;
    padding: 4px 6px;
    font-size: 10px;
    margin: 0;
}

#camera-section-controls button:hover,
#random-camera-controls button:hover,
#audio-analyzer-controls button:hover {
    background: rgba(102, 126, 234, 0.28);
    color: var(--text);
}

/* ---------------------------------------------------------------------------
   The playlist rows — the set, as a list you can read in order.

   Deliberately STILL, where the playing SQUARE blinks. The square is one mark among dozens
   scattered across the collections, and motion is what finds it; a row sits in a short
   ordered list where position already says where you are, so blinking it would spend the
   scarcest signal on something that does not need it — the same argument the queued squares
   lost their pulse to. Weight, a filled accent bar and white text carry "now" here.
   --------------------------------------------------------------------------- */
#playlist-rows {
    display: flex;
    flex-direction: column;
}

#playlist-empty {
    font-size: 9px;
    color: var(--muted);
    padding: 2px 4px;
}

.playlist-row {
    display: flex;
    align-items: center;
    gap: 3px;
    /* No vertical padding and no gap between rows: at 16px a row is already taller than its
       own text, and a set of a dozen has to stay readable without pushing the effect stack
       off the panel. The hover and playing backgrounds still land on a full-height band
       because the row is a flex box, not a line of text. */
    padding: 0 3px 0 0;
    height: 16px;
    border-radius: 2px;
    cursor: pointer;
    font-size: 10px;
    /* The accent bar is a transparent border rather than a border added on .playing: a border
       that appears on one row only would shift that row's contents 2px sideways as the
       playhead moved down the list. */
    border-left: 2px solid transparent;
}

.playlist-row:hover {
    background: rgba(255, 255, 255, 0.06);
}

.playlist-row.playing {
    background: rgba(102, 126, 234, 0.18);
    border-left-color: #fff;
}

.playlist-drag {
    color: var(--muted);
    cursor: grab;
    display: flex;
    flex: 0 0 auto;
    margin-left: 2px;
}

.playlist-drag:active {
    cursor: grabbing;
}

/* Same hex and colour as the preset square, so a row and a square read as the same preset —
   narrower, because a row has a name beside it to identify it and a square does not. */
.playlist-badge {
    flex: 0 0 24px;
    width: 24px;
    height: 12px;
    border-radius: 2px;
    font-size: 8px;
    font-weight: bold;
    display: flex;
    align-items: center;
    justify-content: center;
    user-select: none;
}

.playlist-name {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--muted);
}

.playlist-row.playing .playlist-name {
    color: #fff;
    font-weight: 600;
}

/* Hidden until the row is under the pointer: one × per row, always visible, turns a short
   list into a column of delete buttons. Focus counts as hover so it stays keyboard-reachable. */
.playlist-remove {
    flex: 0 0 auto;
    width: 12px;
    height: 12px;
    padding: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: none;
    border: none;
    border-radius: 2px;
    color: var(--muted);
    cursor: pointer;
    opacity: 0;
}

.playlist-row:hover .playlist-remove,
.playlist-remove:focus {
    opacity: 1;
}

.playlist-remove:hover {
    background: rgba(255, 90, 90, 0.2);
    color: #ff8a8a;
}

/* Loop and Clear, under the list because both are about the list rather than about
   transport: Loop is a property of it, Clear is what empties it. */
#playlist-actions {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 6px;
    margin-top: 4px;
    font-size: 9px;
    color: var(--muted);
}

#playlist-loop-label {
    display: flex;
    align-items: center;
    gap: 4px;
    cursor: pointer;
    user-select: none;
}

#playlist-loop {
    width: 11px;
    height: 11px;
    margin: 0;
    accent-color: #667eea;
    cursor: pointer;
}

/* Not styled as a danger button. It empties a playlist, which costs a few clicks to rebuild
   and destroys nothing saved — red here would cry wolf next to the × on every row, which
   does the same thing one item at a time. */
#playlist-clear-btn {
    font-size: 9px;
    padding: 1px 6px;
    background: rgba(102, 126, 234, 0.15);
    color: #667eea;
    border: 1px solid rgba(102, 126, 234, 0.3);
    border-radius: 3px;
    cursor: pointer;
}

#playlist-clear-btn:hover {
    background: rgba(102, 126, 234, 0.28);
}

/* Paused reads as amber — the show is up but the clock is not running, which is a state
   worth noticing across a dark room, unlike "playing", which is the normal case. */
#playlist-play-btn.paused {
    background: rgba(255, 190, 60, 0.2);
    color: #ffbe3c;
    border-color: rgba(255, 190, 60, 0.5);
}

/* ---------------------------------------------------------------------------
   Background mode (?background=1 — set in index.php).

   The studio rendered purely as decoration behind another page: the account app
   embeds it in an iframe behind the sign-in form. Everything a person could touch
   or read is removed, so nothing competes with the form on top of it and nothing
   invites a click that the iframe would swallow anyway. Only the canvas remains. */
html.background-mode #controls,
html.background-mode #notifications-container,
html.background-mode #effect-selector-dropdown,
html.background-mode #range-editor-popup,
html.background-mode #scene-help-popup {
    display: none !important;
}

html.background-mode,
html.background-mode body {
    overflow: hidden;
    cursor: default;
}

/* ---------------------------------------------------------------------------
   The media playlist's per-item trim controls (js/effects/media/playlist-ui.js).

   They read as ordinary effect parameters now: the label carries its value on the same line
   and the range sits underneath at full width, which is the shape makeRangeControl() gives
   every other slider in the panel. They used to be a cramped [Start:][———][0:00] row that
   matched nothing else on screen and left the value squeezed into 35px.

   Styled here rather than inline because the rules they need — the label metrics and
   .value-display — already exist for the panel, and a second inline copy of them is how two
   definitions of "a parameter row" start to drift.
   --------------------------------------------------------------------------- */
.video-timeline {
    padding: 4px 8px;
    background: rgba(0, 0, 0, 0.2);
    margin-top: 1px;
    border-radius: 3px;
}

.video-timeline label {
    user-select: none;
    display: block;
    margin: 4px 0 2px;
    font-size: 11px;
    color: var(--muted);
    font-weight: 400;
}

.video-timeline input[type="range"] {
    display: block;
    width: 100%;
    cursor: pointer;
}

/* The scrub bar: the trimmed segment, and where the playhead is inside it. Clickable, so it
   gets a pointer and enough height to hit — 8px was a readout nobody could aim at. */
.timeline-scrub {
    position: relative;
    height: 12px;
    background: rgba(255, 255, 255, 0.1);
    border-radius: 2px;
    margin: 6px 0 2px;
    cursor: pointer;
}

.timeline-scrub:hover {
    background: rgba(255, 255, 255, 0.16);
}

.timeline-range-indicator {
    position: absolute;
    height: 100%;
    background: rgba(102, 126, 234, 0.4);
    border-radius: 2px;
    pointer-events: none;
}

.timeline-playhead {
    position: absolute;
    width: 3px;
    height: 100%;
    background: #f00;
    pointer-events: none;
}

/* ---------------------------------------------------------------------------
   The eyedropper's pick mode (js/ui/color-sampler.js).

   A full-viewport catcher, so the click that samples cannot also land on a control, a preset
   square or the canvas's own pointer handling. Above the panel deliberately: while picking,
   the ONLY thing a click can mean is "this colour", and leaving the panel live would let a
   mis-aimed click change a parameter instead.
   --------------------------------------------------------------------------- */
#color-sampler-overlay {
    position: fixed;
    inset: 0;
    z-index: 10050;
    cursor: crosshair;
    /* No dimming. The whole point is to see the picture exactly as it is — a scrim would
       change the colours you are aiming at, which is the one thing this must not do. */
    background: transparent;
}

.color-sampler-hint {
    position: absolute;
    left: 50%;
    bottom: 24px;
    transform: translateX(-50%);
    padding: 6px 12px;
    border-radius: var(--r-md, 8px);
    background: rgba(0, 0, 0, 0.78);
    color: var(--text);
    font-size: 12px;
    white-space: nowrap;
    pointer-events: none;
}

/* Armed, while the overlay is waiting for a click. */
#controls button.sampling {
    background: rgba(102, 234, 162, 0.25);
    border-color: #66eaa2;
    color: #66eaa2;
}

/* ---------------------------------------------------------------------------
   Shader workshop (shader.php)
   Its own page, so it lays out with the viewport rather than floating over the
   stage: list | editor | preview, all full height. Nothing here leaks into the
   studio — every rule is under .shader-workshop.
   --------------------------------------------------------------------------- */
body.shader-workshop {
    margin: 0;
    height: 100vh;
    overflow: hidden;
    background: #14151a;
    color: #ddd;
    font-family: 'Poppins', system-ui, sans-serif;
}

#sw-app { display: flex; flex-direction: column; height: 100vh; }

#sw-bar {
    display: flex; align-items: center; gap: 12px;
    padding: 8px 14px; background: #1b1d24; border-bottom: 1px solid #2a2d36;
    flex: 0 0 auto;
}
#sw-logo { display: flex; align-items: center; opacity: .85; }
#sw-logo:hover { opacity: 1; }
#sw-bar strong { font-size: 13px; font-weight: 600; letter-spacing: .02em; }

#sw-menu { display: flex; gap: 6px; align-items: center; margin-left: 8px; }
#sw-menu button, #sw-studio {
    background: #2a2d36; color: #ccc; border: 1px solid #3a3e4a;
    padding: 5px 11px; border-radius: 4px; cursor: pointer; font-size: 12px;
    font-family: inherit;
}
#sw-menu button:hover, #sw-studio:hover { background: #343846; color: #fff; }
#sw-save { background: rgba(102, 126, 234, .75) !important; border-color: transparent !important; color: #fff !important; }
#sw-save:hover { background: rgba(102, 126, 234, .95) !important; }
#sw-recent {
    background: #2a2d36; color: #ccc; border: 1px solid #3a3e4a;
    padding: 5px 8px; border-radius: 4px; font-size: 12px; font-family: inherit; max-width: 170px;
}
#sw-recent:disabled { opacity: .45; }
#sw-studio { margin-left: 4px; }

.sw-count { margin-left: auto; font-size: 11px; color: #888; }
.sw-count.sw-full { color: #e9a; }

#sw-body { flex: 1 1 auto; display: flex; min-height: 0; }

#sw-side {
    flex: 0 0 240px; display: flex; flex-direction: column;
    background: #181a20; border-right: 1px solid #2a2d36; min-height: 0;
}
.sw-side-head {
    display: flex; justify-content: space-between; align-items: baseline;
    padding: 9px 12px; font-size: 11px; color: #999; border-bottom: 1px solid #23262e;
}
.sw-side-head .sw-hint { color: #666; font-size: 10px; cursor: help; }

#sw-list { flex: 1 1 auto; overflow-y: auto; padding: 6px 0; min-height: 0; }
.sw-row {
    display: flex; align-items: center; gap: 7px;
    padding: 5px 10px; font-size: 12px;
}
.sw-row:hover { background: #1f222a; }
.sw-row.sw-current { background: rgba(102, 126, 234, .14); }
.sw-row input[type="checkbox"] { flex: 0 0 auto; cursor: pointer; }
.sw-name {
    flex: 1 1 auto; text-align: left; background: none; border: none; color: #ddd;
    cursor: pointer; font: inherit; padding: 0; overflow: hidden; text-overflow: ellipsis;
    white-space: nowrap;
}
.sw-name:hover { color: #fff; text-decoration: underline; }
.sw-ref { display: none; }               /* the tooltip carries it; the row is narrow */
.sw-del {
    flex: 0 0 auto; background: none; border: none; color: #666; cursor: pointer;
    font-size: 15px; line-height: 1; padding: 0 2px;
}
.sw-del:hover { color: #e66; }
.sw-empty { padding: 10px 12px; color: #777; font-size: 11px; line-height: 1.6; }
.sw-empty a { color: #8a9cf0; }

.sw-ident { padding: 10px 12px; border-top: 1px solid #23262e; display: grid; gap: 3px; }
.sw-ident label { font-size: 10px; color: #888; }
.sw-ident input {
    background: #22252d; border: 1px solid #33374250; color: #ddd;
    border-radius: 3px; padding: 5px 7px; font-size: 12px; font-family: inherit;
    margin-bottom: 4px;
}
.sw-ident input:focus { outline: none; border-color: rgba(102, 126, 234, .8); }

#sw-editor-wrap { flex: 1 1 55%; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
#sw-editor { flex: 1 1 auto; min-height: 0; }

.sw-status {
    flex: 0 0 auto; padding: 7px 12px; font-size: 11px; line-height: 1.5;
    font-family: 'SF Mono', Consolas, monospace; color: #999;
    background: #16181e; border-top: 1px solid #2a2d36;
    white-space: pre-wrap; max-height: 88px; overflow-y: auto;
}
.sw-status.ok { color: #7c7; }
/* Compiled, but with something worth knowing — a non-ASCII byte that another GPU may refuse.
   Its own colour because it is neither a failure nor nothing: green would hide it, red would
   claim the shader is broken when it just ran. */
.sw-status.warn { color: #e2b24a; background: rgba(226, 178, 74, .07); }
.sw-status.err { color: #e88; background: rgba(230, 102, 102, .08); }

#sw-preview {
    flex: 1 1 45%; position: relative; min-width: 0; background: #000;
    border-left: 1px solid #2a2d36;
}
#sw-canvas { display: block; width: 100%; height: 100%; }
.sw-preview-note {
    position: absolute; left: 0; right: 0; bottom: 0;
    padding: 5px 10px; font-size: 10px; color: #888;
    background: linear-gradient(transparent, rgba(0, 0, 0, .75));
    pointer-events: none;
}

@media (max-width: 1100px) {
    #sw-body { flex-direction: column; }
    #sw-side { flex: 0 0 auto; max-height: 190px; border-right: none; border-bottom: 1px solid #2a2d36; }
    #sw-preview { flex: 0 0 240px; border-left: none; border-top: 1px solid #2a2d36; }
}


/* ---------------------------------------------------------------------------
   Launchpad setup dialog (js/midi/launchpad-settings.js)
   Same shape as Camera Setup: desk hardware, per machine, read rarely.
   --------------------------------------------------------------------------- */
#lps-overlay {
    position: fixed; inset: 0; z-index: 10050;
    background: rgba(0, 0, 0, .6);
    display: flex; align-items: center; justify-content: center;
}
.lps-modal {
    width: min(520px, 92vw); max-height: 84vh; display: flex; flex-direction: column;
    background: #1b1d24; border: 1px solid #333; border-radius: 6px;
    color: #ddd; font-size: 12px; box-shadow: 0 12px 40px rgba(0,0,0,.5);
}
.lps-head {
    display: flex; align-items: center; justify-content: space-between;
    padding: 10px 14px; border-bottom: 1px solid #2a2d36;
}
.lps-head strong { font-size: 13px; }
.lps-close { background: transparent; border: none; color: #aaa; font-size: 22px; line-height: 1; cursor: pointer; padding: 0 4px; }
.lps-intro { padding: 8px 14px; color: #888; font-size: 11px; line-height: 1.5; border-bottom: 1px solid #23262e; }
.lps-rows { flex: 1 1 auto; overflow-y: auto; padding: 6px 8px; min-height: 60px; }

.lps-row {
    display: flex; align-items: flex-start; gap: 9px;
    padding: 8px 8px; border-radius: 4px; cursor: pointer;
}
.lps-row:hover { background: #22252d; }
.lps-row.lps-current { background: rgba(102, 126, 234, .16); }
.lps-row input[type="radio"] { margin-top: 2px; flex: 0 0 auto; cursor: pointer; }
.lps-text { flex: 1 1 auto; min-width: 0; }
.lps-name { color: #ddd; overflow: hidden; text-overflow: ellipsis; }
.lps-note { color: #7d8390; font-size: 10px; margin-top: 2px; line-height: 1.45; word-break: break-word; }
.lps-live {
    flex: 0 0 auto; align-self: center; font-size: 9px; color: #31c5da;
    border: 1px solid rgba(49,197,218,.4); border-radius: 8px; padding: 1px 6px;
}
.lps-empty { padding: 10px 8px; color: #777; font-size: 11px; line-height: 1.6; }

.lps-status { padding: 6px 14px; font-size: 10px; color: #888; line-height: 1.5; min-height: 14px; }
.lps-status.lps-warn { color: #e2b24a; }

.lps-foot { display: flex; gap: 6px; padding: 10px 14px; border-top: 1px solid #2a2d36; }
.lps-foot button {
    background: #2a2d36; color: #ccc; border: 1px solid #3a3e4a;
    padding: 5px 12px; border-radius: 3px; cursor: pointer; font-size: 12px; font-family: inherit;
}
.lps-foot .lps-done { background: rgba(102,126,234,.7); border-color: transparent; color: #fff; }

/* Connection strip inside the Launchpad dialog. The control panel used to hold this; it is the
   only place it exists now, so it carries the state, the remedy and the device name. */
.lps-conn {
    display: flex; align-items: center; gap: 7px;
    padding: 9px 14px; border-bottom: 1px solid #23262e;
}
.lps-dot { font-size: 12px; color: var(--muted); }
.lps-state { font-size: 11px; font-weight: 600; color: var(--muted); }
.lps-conn button {
    background: #2a2d36; color: #ccc; border: 1px solid #3a3e4a;
    padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 11px; font-family: inherit;
}
.lps-conn button:hover:not(:disabled) { background: #343846; color: #fff; }
.lps-conn button:disabled { opacity: .45; cursor: default; }
.lps-conn .lps-connect { background: rgba(102,126,234,.7); border-color: transparent; color: #fff; }
.lps-hint {
    padding: 7px 14px; font-size: 10px; line-height: 1.5; color: #e2b24a;
    background: rgba(226,178,74,.07); border-bottom: 1px solid #23262e;
}
