/* ============================================================
   Responses — picker modal (messenger compose)
   ============================================================ */
.response-picker-list {
    display: flex;
    flex-direction: column;
    gap: 6px;
    max-height: 60vh;
    overflow-y: auto;
}
.response-picker-item {
    display: flex;
    flex-direction: column;
    gap: 4px;
    text-align: left;
    background: var(--color-bg-subtle, rgba(0,0,0,0.03));
    border: 1px solid var(--color-border);
    border-radius: 10px;
    padding: 10px 12px;
    cursor: pointer;
    transition: background 0.12s var(--ease-out), border-color 0.12s var(--ease-out), transform 100ms var(--ease-out);
    width: 100%;
}
.response-picker-item:hover  { background: var(--color-bg); border-color: var(--color-primary); }
.response-picker-item:active { transform: scale(0.99); }
.response-picker-title {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    font-weight: 600;
    font-size: var(--font-size-body);
    color: var(--color-text);
}
.response-picker-preview {
    font-size: var(--font-size-small);
    color: var(--color-text-light);
    line-height: 1.4;
    overflow: hidden;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
}
.response-att-badge {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-size: 11px;
    font-weight: 500;
    color: var(--color-text-light);
    background: var(--color-bg);
    border-radius: 999px;
    padding: 2px 8px;
}
.response-att-badge .fi { font-size: 11px; }

/* ============================================================
   Responses — admin list (Messenger Management section)
   ============================================================ */
.response-admin-row {
    display: flex;
    align-items: flex-start;
    gap: 14px;
    padding: 12px 0;
    border-bottom: 1px solid var(--color-border-subtle, rgba(0,0,0,0.06));
}
.response-admin-row:last-child { border-bottom: none; }
.response-admin-row-main { flex: 1; min-width: 0; }
.response-admin-row-reorder {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 2px;
    flex-shrink: 0;
    padding-top: 2px;
}
.response-reorder-btn {
    background: none;
    border: none;
    cursor: pointer;
    color: var(--color-text-light);
    width: 22px;
    height: 18px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    font-size: 12px;
    line-height: 1;
    transition: color 0.12s var(--ease-out);
}
.response-reorder-btn:hover:not(:disabled) { color: var(--color-primary); }
.response-reorder-btn:active:not(:disabled) {
    transform: scale(0.78);
    transition: transform 80ms var(--ease-out);
}
.response-reorder-btn:disabled { opacity: 0.25; cursor: default; }

/* Brief highlight pulse on the row the user just moved — paired with the
   FLIP slide animation in moveResponse(). 700ms total. */
@keyframes responseRowMovedPulse {
    0%   { background: rgba(76, 175, 145, 0); }
    25%  { background: rgba(76, 175, 145, 0.18); }
    100% { background: rgba(76, 175, 145, 0); }
}
.response-row-moved {
    animation: responseRowMovedPulse 700ms ease-out;
    border-radius: 8px;
}
.response-admin-row-title {
    font-weight: 600;
    font-size: var(--font-size-body);
    color: var(--color-text);
    margin-bottom: 2px;
}
.response-admin-row-preview {
    font-size: var(--font-size-small);
    color: var(--color-text-light);
    line-height: 1.4;
    margin-bottom: 4px;
    overflow: hidden;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
}
.response-admin-row-actions {
    display: flex;
    gap: 6px;
    flex-shrink: 0;
}

/* ============================================================
   Responses — auto-response cards (sub-sub-tab "Auto Response")
   ============================================================ */
.response-auto-card {
    background: var(--color-bg-subtle, rgba(0,0,0,0.03));
    border: 1px solid var(--color-border);
    border-radius: 10px;
    padding: 14px 16px;
    margin-bottom: 12px;
}
.response-auto-card:last-child { margin-bottom: 0; }
.response-auto-card-header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 10px;
    margin-bottom: 8px;
}
.response-auto-card-title {
    font-weight: 600;
    font-size: var(--font-size-body);
    color: var(--color-text);
    margin-bottom: 4px;
    display: flex;
    align-items: center;
    gap: 8px;
}
.response-auto-card-rule {
    font-size: var(--font-size-small);
    color: var(--color-text-light);
    line-height: 1.45;
    display: block;
}
.response-auto-card-rule .fi {
    color: var(--color-primary);
    margin-right: 6px;
    vertical-align: -1px;
}
.response-auto-card-rule strong {
    color: var(--color-text);
}
.response-auto-card-body {
    font-size: var(--font-size-small);
    color: var(--color-text-light);
    line-height: 1.45;
    padding-top: 8px;
    border-top: 1px dashed var(--color-border-subtle, rgba(0,0,0,0.06));
    overflow: hidden;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
}
.response-auto-badge {
    display: inline-flex;
    align-items: center;
    font-size: var(--font-size-small);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    padding: 2px 8px;
    border-radius: 999px;
}
.response-auto-badge--on  { background: var(--color-success-bg); color: var(--color-success); }
.response-auto-badge--off { background: var(--color-bg-subtle); color: var(--color-text-light); }

/* Firing-rule banner inside the editor modal — shown only when editing an auto row. */
.response-editor-rule-banner {
    display: flex;
    align-items: flex-start;
    gap: 10px;
    padding: 10px 14px;
    margin-bottom: 14px;
    background: var(--color-success-bg);
    border: 1px solid var(--color-success);
    border-radius: 8px;
    font-size: var(--font-size-small);
    color: var(--color-text);
    line-height: 1.45;
}
.response-editor-rule-banner .fi {
    flex-shrink: 0;
    margin-top: 3px;
    color: var(--color-primary);
}

/* ============================================================
   Responses — editor modal
   ============================================================ */
.response-chip-row {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    margin-bottom: 6px;
}
.response-placeholder-chip {
    background: var(--color-bg-subtle, rgba(0,0,0,0.04));
    border: 1px solid var(--color-border);
    border-radius: 999px;
    padding: 3px 10px;
    font-size: var(--font-size-small);
    font-family: var(--font-mono, monospace);
    color: var(--color-text);
    cursor: pointer;
    transition: background 0.12s var(--ease-out), border-color 0.12s var(--ease-out);
}
.response-placeholder-chip:hover {
    background: var(--color-bg);
    border-color: var(--color-primary);
    color: var(--color-primary);
}
.response-editor-preview {
    background: var(--color-bg-subtle, rgba(0,0,0,0.03));
    border: 1px dashed var(--color-border);
    border-radius: 8px;
    padding: 10px 12px;
    min-height: 56px;
    font-size: var(--font-size-body);
    color: var(--color-text);
    white-space: pre-wrap;
    line-height: 1.5;
}
#responseEditorBody {
    font-family: inherit;
    font-size: var(--font-size-body);
    line-height: 1.5;
}
.response-att-row {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 8px;
    border: 1px solid var(--color-border);
    border-radius: 8px;
    background: var(--color-bg-subtle, rgba(0,0,0,0.02));
}
.response-att-meta { flex: 1; min-width: 0; }
.response-att-name {
    font-size: var(--font-size-body);
    color: var(--color-text);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Reply preview */
.msgr-reply-preview {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 10px;
    margin-bottom: 6px;
    background: rgba(0,0,0,0.04);
    border-left: 3px solid var(--color-primary);
    border-radius: 4px;
    font-size: var(--font-size-label);
}
.msgr-reply-text { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--color-text-light); }
.msgr-reply-close { background: none; border: none; cursor: pointer; font-size: var(--font-size-body); color: var(--color-text-light); padding: 0 4px; }

/* Media preview */
.msgr-media-preview {
    display: flex;
    gap: 8px;
    padding: 6px 0;
    flex-wrap: wrap;
    /* Containing block for the FLIP lift in _msgrAnimateThumbRemoval — the
       exiting thumb goes position:absolute and is offset within this row. */
    position: relative;
}
.msgr-media-thumb {
    position: relative;
    width: 64px;
    height: 64px;
    border-radius: 8px;
    overflow: hidden;
    border: 1px solid var(--color-border);
}
/* Thumbnail grid shared by the Report a Bug page modal (js/admin-bug-board.js)
   and the Bug Reports review modal's build-notes attachments (reuses
   .msgr-media-thumb tiles). The older messenger /report modal that originated
   this class was removed 2026-07-27 along with its dedicated mobile
   full-screen layout rules. */
.msgr-report-images {
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
    margin-bottom: 10px;
}
.msgr-media-thumb img,
.msgr-media-thumb video { width: 100%; height: 100%; object-fit: cover; }
.msgr-media-thumb.msgr-media-file {
    width: auto;
    height: auto;
    padding: 6px 8px;
    font-size: var(--font-size-small);
    display: flex;
    align-items: center;
    gap: 4px;
}
.msgr-media-hint {
    font-size: var(--font-size-small);
    color: var(--color-text-light);
    align-self: center;
    margin-left: auto;
    white-space: nowrap;
    flex-shrink: 0;
}
.msgr-media-remove {
    position: absolute;
    top: 4px;
    right: 4px;
    background: rgba(0,0,0,0.55);
    color: #fff;
    border: 1.5px solid rgba(255,255,255,0.9);
    border-radius: 50%;
    width: 22px;
    height: 22px;
    font-size: 13px;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    line-height: 1;
    box-shadow: 0 1px 4px rgba(0,0,0,0.35);
    transition: background 0.15s ease, transform 0.15s ease;
}
.msgr-media-remove:hover { background: rgba(0,0,0,0.82); transform: scale(1.08); }
.msgr-media-remove:active { transform: scale(0.94); }

/* Document/video chip remove button - flows inline at right, not a corner badge */
.msgr-media-remove-inline {
    position: static;
    top: auto;
    right: auto;
    width: 20px;
    height: 20px;
    font-size: 11px;
    background: rgba(0,0,0,0.15);
    border: none;
    box-shadow: none;
    flex-shrink: 0;
}
.dark .msgr-media-remove-inline,
[data-theme="dark"] .msgr-media-remove-inline {
    background: rgba(255,255,255,0.15);
}

/* Edit inline */
.msgr-msg-editing {
    max-width: 100%;
    width: 100%;
}
.msgr-msg-editing .msgr-msg-bubble {
    background: var(--color-bg) !important;
    color: var(--color-text) !important;
    border: 1px solid var(--color-border);
}
.msgr-edit-input {
    width: 100%;
    padding: 6px 8px;
    border: 1px solid var(--color-primary);
    border-radius: 6px;
    font-size: var(--font-size-body);
    line-height: 1.5;
    color: var(--color-text);
    background: var(--color-bg);
    resize: none;
    height: auto;
    min-height: calc(1.5em + 12px);
    max-height: calc(10 * 1.5em + 12px);
    overflow-y: auto;
    font-family: inherit;
    box-sizing: border-box;
    /* Same field-sizing/JS-fallback split as .msgr-compose-input above; the
       inline edit textarea's manual resize in _msgrEditMessage is gated
       behind the same CSS.supports check. Ported from canopy commit
       ae488718. */
    field-sizing: content;
}
.msgr-edit-input:focus { outline: none; }
.msgr-edit-actions {
    display: flex;
    gap: 6px;
    margin-top: 3px;
    justify-content: flex-end;
}

/* ── Search Results ── */

/* min-height:0 so this actually scrolls inside the flex column: once the conversation
   section above sizes to content (.msgr-conv-list-searching), this panel is the only
   item absorbing the leftover space, and a flex item's default `min-height: auto`
   would let it grow past the container instead of scrolling. Ported from canopy. */
.msgr-search-results { overflow-y: auto; flex: 1; min-height: 0; }
.msgr-search-result {
    padding: 10px 14px;
    cursor: pointer;
    border-bottom: 1px solid var(--color-border);
    transition: background 0.15s;
}
.msgr-search-result:hover { background: var(--color-surface-hover, rgba(0,0,0,0.03)); }
.msgr-search-result-header { display: flex; justify-content: space-between; gap: 6px; }
.msgr-search-result-sender { font-weight: 600; font-size: var(--font-size-label); }
.msgr-search-result-time { font-size: var(--font-size-small); color: var(--color-text-light); }
.msgr-search-result-text { font-size: var(--font-size-label); color: var(--color-text); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.msgr-search-result-conv { font-size: var(--font-size-small); color: var(--color-text-light); margin-top: 2px; }
/* Two result sets (conversation-name matches vs message-content hits) render side by
   side while a search is active; this label is the "which section is this" marker
   the acceptance bar asks for, reused above both #messengerConvList (filtered) and
   #messengerSearchResults. Ported from canopy. */
.msgr-search-section-label {
    padding: 8px 14px 4px;
    font-size: var(--font-size-small);
    font-weight: 600;
    color: var(--color-text-light);
    text-transform: uppercase;
    letter-spacing: 0.02em;
}
.msgr-search-result-text mark,
.msgr-search-highlight {
    /* Self-contained: the mark paints BOTH its background and its text colour, so it never
       depends on the surface underneath. An opaque pair fixes every surface at once: light
       bubble, dark bubble, and the brand-coloured own bubble in both themes. */
    background: var(--color-search-mark-bg);
    color: var(--color-search-mark-text);
    border-radius: 3px;
    padding: 0 2px;
    /* The highlight is a swatch, not emphasis: keep the run's own weight/style so a match
       inside bold or italic text does not visually reflow. */
    font-weight: inherit;
    font-style: inherit;
}
.msgr-search-empty, .msgr-search-error {
    padding: 10px 14px;
    color: var(--color-text-light);
    font-size: var(--font-size-label);
}
.msgr-search-load-more {
    display: block;
    text-align: center;
    padding: 10px 14px;
    font-size: var(--font-size-label);
    color: var(--color-primary);
    cursor: pointer;
}
.msgr-search-load-more:hover { text-decoration: underline; }

/* A conversation-title hit is a different KIND of result than a message-content hit
   (#messengerSearchResults' .msgr-search-result rows): a subtle left accent bar plus a
   faint tint is enough to read as "matched by name" without competing with the mark
   highlight inside the title itself. Reuses --color-primary (already the accent colour
   for a left-border treatment elsewhere in this file, see .msgr-search-result-conv's
   sibling block above) and --color-surface-hover (the existing subtle-tint token used by
   every hover state in this file), so no new custom property is introduced. */
.msgr-search-conv-hit {
    border-left: 3px solid var(--color-primary);
    background: var(--color-surface-hover, rgba(0,0,0,0.02));
}

/* ── Modals: member picker, DM picker ── */

.msgr-member-picker, .msgr-dm-picker {
    max-height: 200px;
    overflow-y: auto;
    margin-top: 8px;
}
/* label.<class> specificity beats the global `.form-group label { display: block }`
   in style-01.css, which would otherwise collapse this flex row (checkbox stacks
   above the name instead of sitting beside it). */
label.msgr-member-pick-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 10px;
    cursor: pointer;
    font-size: var(--font-size-body);
    border-bottom: 1px solid var(--color-border);
}
.msgr-member-pick-item:last-child { border-bottom: none; }
.msgr-member-pick-item:hover { background: var(--color-surface-hover, rgba(0,0,0,0.03)); }
.msgr-member-pick-item input[type="checkbox"] { flex-shrink: 0; }
.msgr-member-role { font-size: var(--font-size-small); color: var(--color-text-light); margin-left: auto; }
.msgr-member-role--admin { color: var(--color-primary); }

/* Cross-org picker: one row per person, their org memberships tagged beneath the name */
.msgr-member-pick-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
.msgr-member-name { font-size: var(--font-size-body); }
.msgr-member-orgs { display: flex; flex-wrap: wrap; gap: 4px; }
.msgr-member-org-tag {
    font-size: var(--font-size-small);
    color: var(--color-text-light);
    background: var(--color-surface-hover, rgba(0,0,0,0.04));
    border: 1px solid var(--color-border);
    border-radius: 10px;
    padding: 1px 8px;
    line-height: 1.5;
    white-space: nowrap;
}

.msgr-dm-pick-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 10px 12px;
    cursor: pointer;
    font-size: var(--font-size-body);
    border-bottom: 1px solid var(--color-border);
}
.msgr-dm-pick-item:last-child { border-bottom: none; }
.msgr-dm-pick-item:hover { background: var(--color-surface-hover, rgba(0,0,0,0.03)); }

.msgr-selected-members {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    min-height: 28px;
}
.msgr-chip {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    background: var(--color-primary);
    color: var(--color-on-primary);
    padding: 4px 10px;
    border-radius: 12px;
    font-size: var(--font-size-label);
    font-weight: 500;
    cursor: pointer;
    user-select: none;
}
.msgr-chip-x {
    color: color-mix(in srgb, var(--color-on-primary) 65%, transparent);
    line-height: 1;
}

/* ── New Message modal animations ── */

/* FB1 - Choice button press feel */
.msgr-new-choice-btn {
    transition: background 0.15s var(--ease-out), border-color 0.15s var(--ease-out), transform 0.1s var(--ease-out);
}
.msgr-new-choice-btn:active { transform: scale(0.97); }

/* FB2 - DM / member list item press feel */
.msgr-dm-pick-item,
.msgr-member-pick-item {
    transition: background 0.1s var(--ease-out), transform 0.1s var(--ease-out);
}
.msgr-dm-pick-item:active,
.msgr-member-pick-item:active { transform: scale(0.99); }

/* FB3 - Row flash on checkbox check */
@keyframes msgrRowCheck {
    0%   { background: color-mix(in srgb, var(--color-primary) 14%, transparent); }
    100% { background: transparent; }
}
.msgr-row-checked { animation: msgrRowCheck 350ms var(--ease-out) forwards; }

/* SC1 - Chip enters with spring */
@keyframes msgrChipEnter {
    0%   { transform: scale(0); opacity: 0; }
    60%  { transform: scale(1.1); opacity: 1; }
    100% { transform: scale(1); opacity: 1; }
}
.msgr-chip-enter { animation: msgrChipEnter 220ms var(--ease-spring) forwards; }

/* SC2 - Chip exits */
@keyframes msgrChipExit {
    to { transform: scale(0); opacity: 0; }
}
.msgr-chip-exit {
    animation: msgrChipExit 150ms var(--ease-out) forwards;
    pointer-events: none;
}

/* SC3 - Selected members section rises in. Shared class (not a per-modal ID) so every
   member-picker modal (Create Group, Cross-Org Group, ...) gets the same fade-in AND the
   margin-bottom that keeps the chip row from sitting flush against the submit button below it. */
.msgr-selected-section {
    opacity: 0;
    transform: translateY(-6px);
    transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out);
    pointer-events: none;
    margin-bottom: 12px;
}
.msgr-selected-section.msgr-chips-visible {
    opacity: 1;
    transform: translateY(0);
    pointer-events: auto;
}

/* ── Messenger micro-animations (Disney pass) ── */

/* MA1 - Message bubble entrance - direction-aware spring pop */
@keyframes msgrMsgIn {
    from { opacity: 0; transform: translateY(18px); }
    to   { opacity: 1; transform: translateY(0); }
}
/* MA4 - Image materialises inside bubble 60ms after the shell arrives */
@keyframes msgrImgReveal {
    from { opacity: 0; }
    to   { opacity: 1; }
}
/* Outgoing: bubble rises from bottom-right (where you typed) */
.msgr-msg-out.msgr-msg-enter {
    animation: msgrMsgIn 300ms var(--ease-spring) both;
    transform-origin: bottom right;
}
/* Incoming: bubble rises from bottom-left (the other person's side) */
.msgr-msg-in.msgr-msg-enter {
    animation: msgrMsgIn 300ms var(--ease-spring) both;
    transform-origin: bottom left;
}
/* System messages: simple centre fade */
.msgr-msg-system.msgr-msg-enter {
    animation: msgrMsgIn 180ms var(--ease-out) both;
    transform-origin: center bottom;
}
/* Image inside a new bubble materialises 60ms after the shell.
   Skip pending placeholders (they own their shimmer) AND images mid reveal
   (.msgr-img-reveal) - in both cases the `animation` shorthand here would
   otherwise clobber the placeholder's / reveal's own choreography. */
.msgr-msg-enter .msgr-msg-img:not(.msgr-img-pending):not(.msgr-img-reveal) {
    animation: msgrImgReveal 200ms var(--ease-out) 60ms both;
}

/* Optimistic / sending state - dimmed until confirmed by DB */
.msgr-msg-sending { opacity: 0.55; }

/* MA-Upload - real-progress overlay (dark scrim + circular ring + live %) painted
   on the sending image. Driven by actual XHR upload-progress bytes (see
   _msgrUploadWithProgress), so the number is definitive — the sender watches the
   full file climb to 100% and can long-press → Delete a send that's crawling. */
.msgr-msg-sending .msgr-msg-media {
    border-radius: 10px; /* clip overlay corners to match image */
}
.msgr-upload-overlay {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 6px;
    background: rgba(0, 0, 0, 0.34);
    z-index: 3;
    pointer-events: none;
    transition: opacity 240ms ease-out;
}
.msgr-upload-overlay-done { opacity: 0; }
.msgr-upload-ring {
    width: 46px;
    height: 46px;
    transform: rotate(-90deg); /* start the arc at 12 o'clock */
}
.msgr-upload-ring-track { fill: none; stroke: rgba(255, 255, 255, 0.32); stroke-width: 4; }
.msgr-upload-ring-fill {
    fill: none;
    stroke: #fff;
    stroke-width: 4;
    stroke-linecap: round;
    transition: stroke-dashoffset 200ms ease-out;
}
.msgr-upload-pct {
    color: #fff;
    font-size: var(--font-size-small);
    font-weight: 700;
    letter-spacing: 0.2px;
    font-variant-numeric: tabular-nums;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
}

/* MA-Sent - spring pop on media wrapper when upload confirms delivery */
@keyframes msgrMediaSentPop {
    0%   { transform: scale(1); }
    40%  { transform: scale(1.025); }
    100% { transform: scale(1); }
}
.msgr-media-sent {
    animation: msgrMediaSentPop 380ms var(--ease-spring) forwards;
}

/* MA2 - Message delete: anticipation "gather" then dissolve into dust.
   GPU-only (transform + opacity) - the old filter:blur implosion was the jank
   source (filter repaints every frame, fights the 150 animating motes). */
@keyframes msgrMsgDisintegrate {
    0%   { opacity: 1; transform: scale(1)    translateY(0);    }
    22%  { opacity: 1; transform: scale(1.05) translateY(0);    }  /* gather / inhale */
    100% { opacity: 0; transform: scale(0.94) translateY(-8px); }  /* lift + fade with the dust */
}
.msgr-msg-exit { animation: msgrMsgDisintegrate 380ms var(--ease-out) forwards; pointer-events: none; }

/* MA2-collapse - once the bubble is dust, glide the now-empty row closed so the
   messages below slide up to fill the gap instead of snapping. A single-row
   layout transition is acceptable for a one-shot delete and far less jarring. */
.msgr-msg-collapse {
    transition: height 260ms var(--ease-out),
                margin 260ms var(--ease-out),
                padding 260ms var(--ease-out);
}

/* MA2-particle - motes lift off the bubble, rise + drift outward on a faint
   wind, then thin out and fade (Thanos ash). Per-mote delay + duration are set
   inline so the cloud disperses unevenly instead of all snapping at once. */
@keyframes msgrParticleFly {
    0%   { opacity: 1; transform: translate(0, 0) scale(1) rotate(0deg); }
    30%  { opacity: 1; }
    50%  { transform: translate(var(--pmx), var(--pmy)) scale(0.7) rotate(calc(var(--pr) * 0.4)); }
    100% { opacity: 0; transform: translate(var(--px), var(--py)) scale(0.12) rotate(var(--pr)); }
}
.msgr-particle {
    position: fixed;
    pointer-events: none;
    z-index: 9999;
    border-radius: 1px;
    will-change: transform, opacity;
    animation: msgrParticleFly 460ms var(--ease-out) both;
}

/* MA3 - Conversation / search item stagger entrance */
@keyframes msgrConvIn {
    from { opacity: 0; transform: translateY(6px); }
    to   { opacity: 1; transform: translateY(0); }
}
.msgr-conv-enter, .motion-list-enter { animation: msgrConvIn 200ms var(--ease-out) both; } /* .motion-list-enter = orion_motion 'list-stagger' */
.msgr-search-enter { animation: msgrConvIn 200ms var(--ease-out) both; }

/* MA4 - Reply preview bar slide in / out */
@keyframes msgrReplyIn {
    from { opacity: 0; transform: translateY(6px); }
    to   { opacity: 1; transform: translateY(0); }
}
@keyframes msgrReplyOut {
    from { opacity: 1; transform: translateY(0); }
    to   { opacity: 0; transform: translateY(6px); }
}
.msgr-reply-enter { animation: msgrReplyIn 180ms var(--ease-out) both; }
.msgr-reply-exit  { animation: msgrReplyOut 150ms var(--ease-out) forwards; pointer-events: none; }

/* MA5 - Typing indicator fade */
@keyframes msgrTypingIn {
    from { opacity: 0; transform: translateY(4px); }
    to   { opacity: 1; transform: translateY(0); }
}
.msgr-typing-enter { animation: msgrTypingIn 200ms var(--ease-out) both; }

/* MA7 - Read receipt tick pulse on change */
@keyframes msgrTickPulse {
    0%   { transform: scale(1); }
    50%  { transform: scale(1.3); }
    100% { transform: scale(1); }
}
.msgr-tick-pulse, .motion-tick-pulse { display: inline-block; animation: msgrTickPulse 300ms var(--ease-spring); } /* .motion-tick-pulse = orion_motion 'tick-pulse' */

/* MA8 - Unread badge spring pop-in */
@keyframes msgrBadgeIn {
    0%   { transform: scale(0); opacity: 0; }
    60%  { transform: scale(1.15); opacity: 1; }
    100% { transform: scale(1); opacity: 1; }
}
.msgr-badge-enter { animation: msgrBadgeIn 250ms var(--ease-spring) both; }

/* MA10 - Send button pop when content entered */
@keyframes msgrSendPop {
    0%   { transform: scale(0.85); }
    60%  { transform: scale(1.08); }
    100% { transform: scale(1); }
}
.msgr-send-pop { animation: msgrSendPop 200ms var(--ease-spring); }

/* MA11 - Media preview thumbnail entrance */
@keyframes msgrThumbIn {
    from { opacity: 0; transform: scale(0.9); }
    to   { opacity: 1; transform: scale(1); }
}
.msgr-thumb-enter { animation: msgrThumbIn 180ms var(--ease-out) both; }

/* MA11 - thumbnail exit (removal): the removed thumb shrinks + fades while its
   surviving siblings glide over to close the gap (FLIP in
   _msgrAnimateThumbRemoval). */
@keyframes msgrThumbOut {
    from { opacity: 1; transform: scale(1); }
    to   { opacity: 0; transform: scale(0.8); }
}
.msgr-thumb-exit { animation: msgrThumbOut 160ms var(--ease-out) both; }

/* MA9 — Mobile thread slide-in / slide-out
   Restored in v2.96 after L163 identified the real cause of the "two taps after
   swipe-back" bug (iOS WebKit click suppression, fixed via touchend delegation
   in admin-messenger.js — nothing animation-related). The animations were
   guilty by association during the long debug.

   Slide-out deliberately has NO `forwards` fill — at animation end, transform
   reverts to translateX(0) naturally, which lands in the same paint frame as
   onDone's conv-open removal (→ display:none). No flicker, no compositor layer
   cached at translateX(100%). */
@keyframes msgrThreadSlideIn {
    from { transform: translateX(100%); }
    to   { transform: translateX(0); }
}
@keyframes msgrThreadSlideOut {
    from { transform: translateX(0); }
    to   { transform: translateX(100%); }
}
/* Timing matches Telegram iOS exactly: its navigation push (open a chat) and pop
   (swipe/back) both run 0.5s with kCAMediaTimingFunctionSpring: a genuine spring,
   not a cubic ease. We reproduce that spring with a linear() easing sampled from a
   damped-spring simulation (~1.5% overshoot, the gently-damped iOS settle), and fall
   back to an iOS-style cubic-bezier on engines without linear() support. Both
   directions share one duration and one curve, exactly as Telegram does. */
/* The generic motion-swipe-* aliases (orion_motion 'swipe-spring' primitive)
   share this one declaration + linear() curve, so the spring is written once.
   Existing messenger JS keeps adding .msgr-thread-*; new uses add .motion-swipe-*. */
.msgr-thread-enter, .motion-swipe-enter { animation: msgrThreadSlideIn 500ms cubic-bezier(0.32, 0.72, 0, 1) both; }
.msgr-thread-exit,  .motion-swipe-exit  { animation: msgrThreadSlideOut 500ms cubic-bezier(0.32, 0.72, 0, 1); }
/* Messenger ↔ dashboard transition — a SAME-DOCUMENT fade + splash bridge that
   replaces the old cross-document ::view-transition (which did not animate
   reliably in the iOS webview). It matches the in-dashboard tab switch because it
   uses the SAME motion family: the panel-fade primitive's motionPanelFadeOut
   (300ms, var(--ease-out)), so leaving the messenger feels like changing tabs.
   OUT (messages.html): html.msgr-leaving fades the .app-container out over the
   #msgrReturnSplash painted behind it, then admin-messenger.js navigates on
   animationend. ARRIVE (dashboard.html): html.msgr-incoming neutralises the
   browser's default cross-document page-nav view-transition so it can't fade or
   nudge the splash on top of the fade that just played; the splash + the JS fade
   own the arrival. Mobile only — desktop is a plain navigation. */
@media (max-width: 600px) {
    /* Leave: messenger fades out to reveal the splash behind it. */
    html.msgr-leaving #msgrReturnSplash { z-index: 1; }   /* behind the fading messenger (overrides the 100000 arrival z-index) */
    html.msgr-leaving .app-container {
        position: relative;
        z-index: 2;
        animation: motionPanelFadeOut var(--dur-transition) var(--ease-out) forwards;
    }
    /* Arrive: suppress the default cross-doc page-nav VT (it would fade + nudge
       the splash on top of the fade). The splash + JS fade own the arrival. */
    html.msgr-incoming::view-transition-old(root),
    html.msgr-incoming::view-transition-new(root) { animation: none !important; }
}
@supports (animation-timing-function: linear(0, 1)) {
    .msgr-thread-enter, .motion-swipe-enter,
    .msgr-thread-exit,  .motion-swipe-exit { animation-timing-function: linear(0, 0.072, 0.228, 0.407, 0.574, 0.714, 0.822, 0.899, 0.951, 0.984, 1.003, 1.012, 1.015, 1.015, 1.013, 1.010, 1.007, 1.005, 1.003, 1.002, 1); }
}

/* ── End Messenger micro-animations ── */

/* ============================================================
   MOTION LIBRARY  (orion_motion canonical primitives)
   SoT registry: js/animations.js (ANIMATION_REGISTRY).
   Human index:   docs/animation-library.md.
   Parity guard:  /orion_audit_visual Cat 9g (keyframes <-> registry).
   Reduced motion is handled by the global @media block (search
   "prefers-reduced-motion") + the JS helper early-return; do NOT add a
   redundant block here.
   Wire these via playAnimation() / staggerAnimation() in js/animations.js;
   never set the inline `animation` shorthand (it would beat the @supports
   linear() upgrade on .motion-swipe-*).
   Reused primitives (swipe-spring, list-stagger, tick-pulse) live with their
   messenger declarations above via shared selector groups, NOT duplicated here.
   Add a primitive: new @keyframes + .motion-* class below (animate only
   transform/opacity/filter), a --dur-* token in :root if a new tier is needed,
   an ANIMATION_REGISTRY entry, one docs line, then run the Cat 9g parity check.
   ============================================================ */

/* panel-fade - view / tab swap. Add the class synchronously after inserting
   content; both-fill hides it from frame 0 so there is no flash-then-fade.
   The exit pair fades a leaving panel out; html.msgr-leaving .app-container
   (the messenger-to-dashboard exit) shares the motionPanelFadeOut keyframes. */
@keyframes motionPanelFade {
    from { opacity: 0; }
    to   { opacity: 1; }
}
@keyframes motionPanelFadeOut {
    from { opacity: 1; }
    to   { opacity: 0; }
}
.motion-panel-enter { animation: motionPanelFade var(--motion-dur, var(--dur-transition)) var(--ease-out) both; }
.motion-panel-exit  { animation: motionPanelFadeOut var(--motion-dur, var(--dur-transition)) var(--ease-out) both; }

/* card-enter - staggered card lift-in (via staggerAnimation) */
@keyframes motionCardEnter {
    from { opacity: 0; transform: translateY(12px) scale(0.99); }
    to   { opacity: 1; transform: translateY(0) scale(1); }
}
.motion-card-enter { animation: motionCardEnter var(--motion-dur, var(--dur-enter)) var(--ease-out) both; }

/* react-pop - emoji reaction / badge pop: up then settle back to rest */
@keyframes motionReactPop {
    0%   { opacity: 0; transform: scale(0); }
    60%  { opacity: 1; transform: scale(1.25); }
    100% { opacity: 1; transform: scale(1); }
}
.motion-react-pop { animation: motionReactPop var(--motion-dur, var(--dur-feedback)) var(--ease-spring); }

/* value-bump - a displayed number / total just changed */
@keyframes motionValueBump {
    0%   { transform: scale(1); }
    50%  { transform: scale(1.2); }
    100% { transform: scale(1); }
}
.motion-value-bump { display: inline-block; animation: motionValueBump var(--motion-dur, var(--dur-feedback)) var(--ease-spring); }

/* success-pop - rare celebratory confirm (use sparingly, never loop) */
@keyframes motionSuccessPop {
    0%   { opacity: 0; transform: scale(0.6); }
    60%  { opacity: 1; transform: scale(1.12); }
    100% { opacity: 1; transform: scale(1); }
}
.motion-success-pop { animation: motionSuccessPop var(--motion-dur, var(--dur-celebrate)) var(--ease-spring); }

/* press-give - CSS-only tactile press (no JS); add .motion-press to a control */
.motion-press { transition: transform var(--dur-micro) var(--ease-out); }
.motion-press:active { transform: scale(0.97); }

/* fly-to - parameterized launch -> land flight. The consumer positions the
   element at its DESTINATION (left/top, no own transform) and sets --fly-dx /
   --fly-dy (origin minus destination, px) + optional --fly-scale (launch
   scale). Generic: add-to-cart fly, reaction emoji -> pill, etc. */
@keyframes motionFlyTo {
    0%   { opacity: 1; transform: translate(var(--fly-dx, 0px), var(--fly-dy, 0px)) scale(var(--fly-scale, 1.5)); }
    100% { opacity: 1; transform: translate(0, 0) scale(1); }
}
.motion-fly-to { animation: motionFlyTo var(--motion-dur, var(--dur-transition)) var(--ease-out) both; }

/* expand-origin - shared-element FLIP: an element grows out of a source rect to
   fill the screen (lightbox open) and shrinks back into it on close, the way iOS
   expands a photo / video to fullscreen. Parameterized: the consumer measures the
   origin + final rects at runtime and sets --flip-dx / --flip-dy (origin centre
   minus final centre, px) + --flip-scale (cover-scale = max(originW/finalW,
   originH/finalH)). Reference driver: image-crop.js openLightbox/closeLightbox,
   which drives the SAME motion with an inline transition (it must read two live
   rects per direction + hand back to zoom/pan) rather than this class. */
@keyframes motionExpandOrigin {
    from { transform: translate(var(--flip-dx, 0px), var(--flip-dy, 0px)) scale(var(--flip-scale, 0.4)); }
    to   { transform: translate(0, 0) scale(1); }
}
.motion-expand-origin { animation: motionExpandOrigin var(--motion-dur, var(--dur-transition)) var(--ease-out) both; }

/* burst - celebratory particle. The consumer spawns N short-lived fixed nodes
   at a centre point and sets per-node --burst-dx / --burst-dy / --burst-rot.
   Like .msgr-particle (delete crumble) but radiating + celebratory. Restraint:
   gate at high frequency, keep subtle, never loop. */
@keyframes motionBurst {
    0%   { opacity: 1; transform: translate(-50%, -50%) scale(0.3); }
    100% { opacity: 0; transform: translate(calc(-50% + var(--burst-dx, 0px)), calc(-50% + var(--burst-dy, 0px))) scale(1) rotate(var(--burst-rot, 0deg)); }
}
.motion-burst {
    position: fixed;
    z-index: 9998;
    width: 7px;
    height: 7px;
    border-radius: 2px;
    pointer-events: none;
    will-change: transform, opacity;
    animation: motionBurst var(--motion-dur, var(--dur-celebrate)) var(--ease-out) both;
}

/* conic-ring - living-data progress ring (dossier frontier-26-techniques.md
   #11; ported from canopy commits a2a2922e/d03cf8e8). CSS-only: registering
   --p via @property is what makes it tween like any animatable value (an
   unregistered custom property is a string and cannot interpolate); the
   consumer sets el.style.setProperty('--p', frac) and the ring sweeps. No
   @supports fallback needed, on an engine without @property the
   conic-gradient still renders at the correct final angle, it just snaps
   instead of sweeping (graceful, not broken). Consumers keep the percentage
   label as a SIBLING element (never a masked descendant of
   .motion-conic-ring), so it stays visible for accessibility rather than
   being cut out by the ring's radial mask. HuoMa consumers: js/admin-leave.js
   leave-balance cards, js/admin-messenger-broadcast.js send progress. */
@property --p {
    syntax: '<number>';
    initial-value: 0;
    inherits: false;
}
.motion-conic-ring {
    position: absolute;
    inset: 0;
    border-radius: 50%;
    --p: 0;
    background: conic-gradient(var(--ring-color, var(--color-primary)) calc(var(--p) * 1turn), var(--color-border) 0);
    mask: radial-gradient(circle, transparent 55%, black 56%);
    -webkit-mask: radial-gradient(circle, transparent 55%, black 56%);
    transition: --p var(--motion-dur, var(--dur-transition)) var(--ease-out);
}

/* row-swap-flip - two-row reorder FLIP (move up/down chevrons). Ported from
   canopy 2026-08-11. Imperative two-element helper (rowSwapFlip in
   js/animations.js), NOT dispatched via playAnimation: the consumer swaps
   two adjacent rows in the DOM, then sets --swap-dy (each row's pre-swap
   top minus its post-swap top, px) and adds this class; the keyframe
   inverts from that offset back to 0 so the row reads as sliding to its
   new slot instead of snapping. First consumer: Manage CRM > Stages
   (js/admin-crm.js _crmStagesMove). */
@keyframes motionRowSwapFlip {
    from { transform: translateY(var(--swap-dy, 0px)); }
    to   { transform: translateY(0); }
}
.motion-row-swap-flip {
    animation: motionRowSwapFlip var(--motion-dur, var(--dur-enter)) cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
    will-change: transform;
}
/* ── End Motion Library ── */

.msgr-section-heading {
    font-size: var(--font-size-body);
    font-weight: 600;
    color: var(--color-text);
    margin: 0 0 8px;
}
.msgr-vis-hint {
    font-size: var(--font-size-label);
    color: var(--color-text-light);
    margin-top: 6px;
    margin-bottom: 0;
}
.msgr-my-note-wrapper {
    margin-top: 12px;
}
.msgr-member-list { }
.msgr-member-item {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 8px 0;
    min-height: 52px;
    font-size: var(--font-size-body);
}
.msgr-member-avatar {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 12px;
    font-weight: 700;
    color: #fff;
    flex-shrink: 0;
    letter-spacing: 0.02em;
}
.msgr-member-name {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.msgr-role-chip {
    font-size: 11px;
    font-weight: 600;
    padding: 3px 9px;
    border-radius: 20px;
    white-space: nowrap;
    flex-shrink: 0;
}
.msgr-role-chip--admin  { background: rgba(232, 105, 30,0.12); color: var(--color-primary); }
.msgr-role-chip--staff  { background: rgba(0,0,0,0.07); color: var(--color-text-light); }
.msgr-role-chip--vendor { background: rgba(180,100,20,0.1); color: #a05a10; }
[data-theme="dark"] .msgr-role-chip--staff  { background: rgba(255,255,255,0.1); }
[data-theme="dark"] .msgr-role-chip--vendor { color: #e0924a; background: rgba(180,100,20,0.2); }
.msgr-role-chip--bot { background: rgba(0,130,120,0.1); color: #0a8078; }
[data-theme="dark"] .msgr-role-chip--bot { background: rgba(0,180,160,0.15); color: #2ecfc3; }
.msgr-danger-zone {
    border-top: 1px solid var(--color-border);
    padding-top: 16px;
    margin-top: 24px;
}
.msgr-modal-section-label--danger { color: var(--color-danger) !important; }
.msgr-remove-btn {
    background: none;
    border: none;
    padding: 0;
    width: 44px;
    height: auto;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    color: var(--color-text-light);
    border-radius: 6px;
    transition: color 0.15s, background 0.15s;
    flex-shrink: 0;
    font-size: 16px;
    align-self: stretch;
}
.msgr-remove-btn:hover {
    color: var(--color-danger);
    background: rgba(var(--color-error-rgb, 180, 50, 50), 0.08);
}

/* ── Mobile Responsive ── */

@media (hover: none) {
    .msgr-group-photo-wrap .msgr-group-photo-overlay { opacity: 1; }
}

@media (max-width: 600px) {
    .msgr-root { flex-direction: column; min-height: 0; max-height: none; }
    /* Hide the desktop column on mobile; show the inline strip instead */
    #msgrSourceNav { display: none !important; }
    .msgr-source-nav-strip {
        display: flex;
        flex-direction: row;
        width: 100%;
        min-width: 0;
        height: 48px;
        min-height: 48px;
        border-right: none;
        border-bottom: 1px solid var(--color-border);
        padding: 6px 10px;
        gap: 6px;
        overflow-x: auto;
        overflow-y: hidden;
        scrollbar-width: none;
        -ms-overflow-style: none;
        -webkit-overflow-scrolling: touch;
        align-items: center;
    }
    .msgr-source-nav-strip::-webkit-scrollbar { display: none; }
    .msgr-source-nav-strip .msgr-source-nav-item {
        flex-direction: row;
        flex-shrink: 0;
        width: auto;
        padding: 5px 12px;
        border-radius: 20px;
        gap: 5px;
        border: 1px solid var(--color-border);
    }
    .msgr-source-nav-strip .msgr-source-nav-item::before { display: none; }
    .msgr-source-nav-strip .msgr-source-nav-item.is-active {
        background: var(--item-color, var(--color-primary));
        color: #fff;
        border-color: transparent;
    }
    .msgr-source-nav-strip .msgr-source-nav-icon { font-size: 14px; }
    .msgr-source-nav-strip .msgr-source-nav-label { font-size: 13px; }
    /* The Mine/All owner filter reuses .ann-seg, whose mobile rule forces a 44px
       touch target; that reads far taller than the ~30px folder chips directly
       above it. Match the chips: same 5px vertical padding and 13px type, pill
       radius on the container. Scoped to this control only, the announcements
       page keeps its 44px segments. */
    #msgrOwnerFilterControl { border-radius: 20px; }
    #msgrOwnerFilterControl .ann-seg { min-height: 0; padding: 5px 12px; font-size: 13px; }
    /* Reorder mode on the horizontal strip: the Done pill sits inline at the end
       (row layout, not the vertical-rail column). Dragging keeps the pill shape. */
    .msgr-source-nav-strip .msgr-reorder-done {
        flex-direction: row;
        flex-shrink: 0;
        margin: 0;
        padding: 5px 14px;
        border-radius: 20px;
        font-size: 13px;
        gap: 5px;
    }
    .msgr-source-nav-strip .msgr-reorder-done i { font-size: 13px; }
    .msgr-source-nav-strip .msgr-source-dragging { border-radius: 20px; }
    .msgr-sidebar { width: 100%; min-width: 0; border-right: none; border-bottom: 1px solid var(--color-border); max-height: none; min-height: 200px; }
    .msgr-thread { display: none; width: 100%; min-height: 0; }
    .msgr-root.conv-open .msgr-sidebar { display: none; }
    .msgr-root.conv-open .msgr-thread { display: flex; position: fixed; top: 0; left: 0; right: 0; height: 100%; max-height: none; z-index: 960; background: var(--color-surface); overflow-x: hidden; transition: height 0.25s var(--ease-keyboard), top 0.25s var(--ease-keyboard); }
    /* The fixed thread above positions against the viewport, escaping the
       body's env(safe-area-inset-top) padding — so the header would sit under
       the status bar / notch. Inset the header itself below it. env() is 0 on
       non-notched devices (desktop/web), so this is a no-op there. View As is
       left untouched (the thread is already offset 44px for its banner).
       No cap-inset-always override: position:fixed ALSO escapes the OS
       contentInset:"always" scroll inset, and env() is nonzero even on those
       builds, so env() is the single correct top inset on EVERY build. Same
       mechanics as chatbox's .msgr-sidebar-header / .app-page-header fixes
       (f6255e7, 13fb940), ported here 2026-08-02: the old flat-token override
       this replaced put the chat header under the status bar clock on native
       (Myron's device report), because the removed rule assumed the OS insets
       a position:fixed box the same way it insets normal-flow content, which
       chatbox's fix commits runtime-disproved. */
    body:not(.view-as-active) .msgr-root.conv-open .msgr-thread-header {
        padding-top: calc(max(env(safe-area-inset-top, 0px), var(--sat-floor, 0px)) + 10px);
    }
    .msgr-back-btn { display: inline-flex; }
    .msgr-msg { max-width: 85%; }
    body.view-as-active .msgr-root.conv-open .msgr-thread { top: 44px; height: calc(100% - 44px); }
    /* Lift the compose row clear of the home indicator AND the phone's curved bottom
       corners (which were clipping the attach/mic icons). The fixed thread escapes the
       body's env(safe-area-inset-bottom) padding, so pad the compose itself. env()
       collapses to 0 while the keyboard is up, so this only pads at rest. */
    /* Decoupled from .conv-open: the compose's rest padding stays CONSTANT whether or not
       a conversation is open, so toggling .conv-open on reopen never relayouts the compose.
       (On mobile the compose only renders inside the fixed thread, which is display:none
       until .conv-open, so applying this unconditionally is invisible while no conv is open.)
       When this padding was conv-open-scoped, reopening a thread grew the compose ~38px AFTER
       the open scroll, shrinking the flex message list from the bottom and clipping the newest
       message. The keyboard still animates via the inline 8px override (beats this selector). */
    .msgr-compose {
        padding-bottom: calc(max(env(safe-area-inset-bottom, 0px), var(--sab-floor, 0px)) + 12.6px);
    }
    /* contentInset:"always" builds already inset the webview above the home indicator,
       so env() would double it, keep just the generous extra. */
    html.cap-inset-always .msgr-compose {
        padding-bottom: 12.6px;
    }
    /* CSS-floor fallback: restore the floor value dropped by the zeroing rule
       above (see the css/style-03.css three-rule pattern comment). Ported
       from canopy 2026-08-11. */
    html.cap-inset-always.cap-inset-floor .msgr-compose {
        padding-bottom: calc(var(--sab-floor, 0px) + 12.6px);
    }
}

/* ── Embedded messenger tab (body.msgr-active) ──────────────────────────────
   The messenger lives as #tab-messenger inside dashboard.html (was the standalone
   messages.html). applyTabSwitch() toggles body.msgr-active while the messenger tab
   is the active surface. These rules reproduce what messages.html's inline <style>
   did (full-bleed card, chrome hidden, page-scoped scroll lock), rescoped from
   body.messenger-page to body.msgr-active so they ONLY apply while the messenger tab
   is showing and never leak onto the dashboard's other tabs. The bottom-nav clearance
   already lives on body.has-bottom-nav (it pads the BODY), so it is not repeated. */
body.msgr-active { overscroll-behavior: none; }
body.msgr-active .app-header,
body.msgr-active .nav-bar { display: none; }
/* Fill the container: zero padding (the body keeps its has-bottom-nav bottom
   clearance) and make the active messenger tab a growing flex column. */
body.msgr-active .app-container { padding: 0; }
body.msgr-active #tab-messenger.active { display: flex; flex-direction: column; flex: 1; min-height: 0; }
body.msgr-active .msgr-root { flex: 1; min-height: 0; max-height: none; border: none; border-radius: 0; }
@media (min-width: 641px) {
    /* Desktop keeps the rounded card + side padding, like messages.html did. */
    body.msgr-active .app-container { padding: 16px; }
    body.msgr-active .msgr-root { border: 1px solid var(--color-border); border-radius: 20px; }
    /* Lock the embedded messenger to the viewport so the PAGE never scrolls, only the
       inner panels do (left conv-list via .msgr-scrollable-area, right thread via
       .msgr-messages). messages.html did this inline (html,body{height:100%;overflow:hidden}
       + .app-container{min-height:0}); the embed dropped it, so the flex height chain never
       bottomed out and the whole page (left panel included) scrolled. Desktop-only: mobile
       uses a position:fixed full-screen thread and already behaves. */
    body.msgr-active { height: 100vh; height: 100dvh; overflow: hidden; }
    body.msgr-active .app-container { min-height: 0; }
}
@media (max-width: 640px) {
    /* Full-bleed app on mobile. The notch / status-bar clearance is ALREADY provided
       by whichever layer owns it: the body's env(safe-area-inset-top) padding on web +
       cap-edge-to-edge, or the OS contentInset on cap-inset-always (where the body env
       is suppressed). So the header must add ONLY breathing room (flat 8px) and must NOT
       re-apply env() — doing so double-counts the inset and leaves a tall empty band over
       the header on real iOS (env>0). This mirrors the proven-correct messages.html rule.
       (The fixed conv-open thread header is handled by its own rule above.) */
    body.msgr-active .msgr-sidebar-header { padding-top: 8px; }
    /* The conv-list column must GROW to fill the full-bleed card (messages.html did
       this inline). Without it a short list leaves a --color-bg gap above the bottom
       nav and the retention notice floats mid-screen instead of sitting at the base. */
    body.msgr-active .msgr-sidebar { flex: 1; min-height: 0; }
}

/* ── Messenger Dark Mode ── */
[data-theme="dark"] .msgr-conv-item.active {
    background: rgba(255,255,255,0.06);
}
[data-theme="dark"] .msgr-compose-input {
    background: #253030;
    color: var(--color-text);
}
.msgr-compose-input:focus {
    outline: none;
}
[data-theme="dark"] .msgr-compose-input:focus {
    outline: none;
}
[data-theme="dark"] .msgr-search-input {
    background: #1a1e1e;
    border-color: #4a5555;
    color: var(--color-text);
}
[data-theme="dark"] .msgr-reply-preview {
    background: rgba(242, 149, 78, 0.08);
}
[data-theme="dark"] .msgr-edit-input {
    background: #1a1e1e;
    border-color: #4a5555;
    color: var(--color-text);
}

/* ── In-app PDF viewer (js/pdf-viewer.js) ─────────────────────────────────────
   Full-screen overlay that renders a PDF with PDF.js inside the app, so a tapped
   document opens in place instead of the OS share sheet. Self-contained dark
   chrome (a neutral gutter behind white pages) so it reads the same in light and
   dark theme; only the brand-green header follows --color-primary. Generic: the
   messenger doc-bubble is the first caller, invoices/payslips can reuse it. */
body.pdf-viewer-open { overflow: hidden; }

.pdf-viewer-overlay {
    position: fixed;
    inset: 0;
    z-index: 100000;
    display: flex;
    flex-direction: column;
    background: #2b2d2f;
    animation: pdfViewerIn 180ms var(--ease-out, ease);
}
@keyframes pdfViewerIn { from { opacity: 0; } to { opacity: 1; } }

.pdf-viewer-header {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 8px 10px;
    padding-top: calc(max(env(safe-area-inset-top, 0px), var(--sat-floor, 0px)) + 8px);
    background: var(--color-primary);
    color: var(--color-on-primary);
    box-shadow: 0 1px 6px rgba(0, 0, 0, 0.3);
}
.pdf-viewer-title {
    flex: 1 1 auto;
    min-width: 0;
    font-size: 0.92rem;
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.pdf-viewer-btn {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 38px;
    height: 38px;
    border: none;
    background: transparent;
    color: var(--color-on-primary);
    border-radius: 50%;
    cursor: pointer;
    padding: 0;
    transition: background 120ms;
}
.pdf-viewer-btn:hover { background: rgba(255, 255, 255, 0.16); }
.pdf-viewer-btn:active { background: rgba(255, 255, 255, 0.26); }
.pdf-viewer-close { margin-right: 2px; }
.pdf-viewer-zoom-label {
    flex-shrink: 0;
    min-width: 44px;
    text-align: center;
    font-size: 0.78rem;
    font-variant-numeric: tabular-nums;
    opacity: 0.92;
}

.pdf-viewer-body {
    flex: 1 1 auto;
    position: relative;          /* anchors the centered loading/error + the absolute download btn lives on the overlay */
    overflow: auto;
    -webkit-overflow-scrolling: touch;
    overscroll-behavior: contain;
    /* allow native 1-finger scroll (pan), but no native pinch-zoom: pinch is
       handled in js/pdf-viewer.js so it can re-rasterise crisply on release. */
    touch-action: pan-x pan-y;
}
/* The pages live in this wrapper so a pinch can transform-scale the whole set in
   one cheap GPU op during the gesture; transform-origin is set per-pinch in JS. */
.pdf-viewer-pages {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    width: 100%;                 /* body width; a zoomed page wider than this overflows
                                    and the body's overflow:auto scrolls to it. NOT
                                    max-content: that picks up the canvas's intrinsic
                                    DPR-scaled width and inflates the whole layout. */
    box-sizing: border-box;
    padding: 14px 12px;
    padding-bottom: calc(max(env(safe-area-inset-bottom, 0px), var(--sab-floor, 0px)) + 18px);
    transform-origin: 0 0;
}
.pdf-viewer-page {
    margin: 0 auto 14px;         /* centers when narrower than the wrapper; left-aligns
                                    (auto -> 0) and stays scroll-reachable when wider */
    background: #fff;
    border-radius: 2px;
    box-shadow: 0 1px 8px rgba(0, 0, 0, 0.45);
    overflow: hidden;
    flex-shrink: 0;
}
.pdf-viewer-page:last-child { margin-bottom: 0; }
.pdf-viewer-page canvas { display: block; }

.pdf-viewer-loading,
.pdf-viewer-error {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 14px;
    color: rgba(255, 255, 255, 0.85);
    font-size: 0.88rem;
    text-align: center;
    padding: 24px;
}

