/* Modal and tab design system — the single source of truth for both.
 *
 * Before this file the same handful of properties were set in five places in
 * style.css that contradicted each other (a 600px cap that silently killed
 * Bootstrap's modal-lg/modal-xl, two rival "glass" content classes, four
 * separate tab treatments), so every new modal escaped through inline styles
 * and every one ended up a different size and shape. Everything a modal needs
 * lives here now; style.css must not restyle .modal-* or .nav-tabs again.
 *
 * The contract for markup is:
 *
 *   .modal > .modal-dialog[.modal-sm|.modal-lg|.modal-xl|.modal-wide]
 *     > .modal-content
 *         > .modal-header        title + .btn-close
 *         > .nav-tabs            optional, direct child, never inside the body
 *         > .modal-body          the only scrolling region
 *         > .modal-footer        .modal-status then secondary then primary
 *
 * No .modal-dialog-centered (every dialog is centred), no .modal-glass-dark or
 * .glass-modal (the surface is built in), no border-0/py-2 utilities on the
 * header and footer, and no inline max-width/height.
 */

:root {
    /* Widths. Bootstrap's own values are overridden below so that the size
       class on .modal-dialog is the only thing that decides a modal's width. */
    --modal-width-sm: 420px;
    --modal-width-md: 580px;
    --modal-width-lg: 820px;
    --modal-width-xl: 1120px;

    --modal-radius: 12px;
    --modal-gutter: 1.5rem;

    /* Height a tabbed modal holds regardless of which tab is showing, so
       switching tabs never resizes or re-centres the dialog. */
    --modal-tabbed-body-height: min(60vh, 560px);
    --modal-body-max-height: min(72vh, 680px);
}

/* ── Dialog geometry ──────────────────────────────────────────────────────── */

.modal-dialog {
    display: flex;
    flex-direction: column;
    justify-content: center;
    min-height: calc(100% - 2rem);
    margin: 0 auto;
    width: calc(100% - 2rem);
    max-width: var(--modal-width-md);
    /* The dialog is only a centring wrapper — clicks either side of the card
       must reach the backdrop so they still dismiss. */
    pointer-events: none;
}

.modal-dialog.modal-sm {
    max-width: var(--modal-width-sm);
}

.modal-dialog.modal-lg {
    max-width: var(--modal-width-lg);
}

.modal-dialog.modal-xl {
    max-width: var(--modal-width-xl);
}

/* For the two content-viewer modals (history diff, provenance report) whose job
   is showing as much document as fits. Not a general-purpose escape hatch. */
.modal-dialog.modal-wide {
    max-width: min(1400px, 92vw);
}

/* Pairs with .modal-wide: fill the viewport height instead of sizing to the
   content, so the diff or report pane inside gets a stable box to lay out in
   and does not resize as the user pages through it. */
.modal-dialog.modal-tall .modal-content {
    height: calc(100vh - 4rem);
}

.modal-dialog.modal-tall .modal-body {
    max-height: none;
}

.modal-content {
    pointer-events: auto;
    display: flex;
    flex-direction: column;
    max-height: calc(100vh - 4rem);
    /* Clips the header and footer to the rounded corners, so neither needs its
       own radius and neither can bleed past the edge. */
    overflow: hidden;

    background: var(--modal-glass-bg);
    backdrop-filter: blur(20px);
    -webkit-backdrop-filter: blur(20px);
    border: 1px solid var(--glass-border);
    border-radius: var(--modal-radius);
    box-shadow: 0 25px 60px -15px rgba(0, 0, 0, 0.45);
}

/* The page behind a modal is dimmed and blurred here, on Bootstrap's own
   backdrop element — never on .modal or .modal-content. An ancestor with
   backdrop-filter becomes a backdrop root, which leaves .modal-content's blur
   above nothing and flattens its translucent fill into a solid panel. */
.modal-backdrop.show {
    backdrop-filter: blur(5px);
    -webkit-backdrop-filter: blur(5px);
    opacity: 0.45;
}

/* #diffModal (the AI edit review / diff-viewer modal) is the one exception to
   "no per-modal escape hatch" in this file's header contract, and only for
   backdrop-filter: it's a live-compositing GPU blur, and measured directly
   (frame-timing harness, blur toggled via inline override) it costs ~28ms of
   extra frame time per frame the whole time this modal is open — 37ms vs 9ms
   average frame gap, reproduced with an EMPTY modal body, so it's the blur
   itself, not Monaco or anything diff_viewer.ts does. Every other modal in
   the app is a brief confirm/prompt; this one is realistically left open for
   extended interactive review (per-hunk clicks, scrolling), so it's the one
   place that cost is felt as visible jank rather than a one-frame blip.
   diff_viewer.ts does not need to add/remove any class for this — it's keyed
   purely on the modal's id, always in effect while #diffModal is shown. */
#diffModal .modal-content {
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
}

#diffModal ~ .modal-backdrop.show {
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
}

/* Slide up on open. */
.modal.fade .modal-dialog {
    transform: translate3d(0, 40px, 0) scale(0.97);
    opacity: 0;
    transition:
        transform 0.3s cubic-bezier(0.19, 1, 0.22, 1),
        opacity 0.2s ease-out;
}

.modal.show .modal-dialog {
    transform: translate3d(0, 0, 0) scale(1);
    opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
    .modal.fade .modal-dialog {
        transition: opacity 0.15s linear;
        transform: none;
    }
}

/* ── Header ───────────────────────────────────────────────────────────────── */

.modal-header {
    flex-shrink: 0;
    align-items: center;
    gap: 0.75rem;
    padding: 0.9rem var(--modal-gutter);
    background: transparent;
    border-bottom: 1px solid var(--border-main);
}

/* A tab strip provides its own baseline, so the header above it would draw a
   second line 40px from the first. */
.modal-content > .nav-tabs + .modal-body,
.modal-header:has(+ .nav-tabs) {
    border-bottom: 0;
}

.modal-header:has(+ .nav-tabs) {
    padding-bottom: 0.35rem;
}

.modal-title {
    margin: 0;
    font-size: 1.05rem;
    font-weight: 600;
    line-height: 1.3;
    color: var(--text-primary);
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.modal-title i,
.modal-title svg {
    width: 18px;
    height: 18px;
}

/* One close affordance everywhere: a plain Bootstrap .btn-close, tinted for the
   active theme so no template needs btn-close-white or a hand-rolled icon. The
   tint is unscoped because .btn-close is also used outside modal headers. */
.btn-close {
    filter: var(--btn-close-filter);
    opacity: 0.6;
    box-shadow: none;
}

.btn-close:hover {
    opacity: 1;
}

/* Bootstrap's .btn-close:focus ring also fires when the modal autofocuses it on
   open, boxing the corner of every freshly opened dialog. Keyboard users still
   get the ring. */
.btn-close:focus:not(:focus-visible) {
    box-shadow: none;
}

.btn-close:focus-visible {
    box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb, 13, 110, 253), 0.35);
    opacity: 1;
}

.modal-header .btn-close {
    margin: 0 0 0 auto;
    padding: 0.4rem;
}

/* ── Tabs ─────────────────────────────────────────────────────────────────── */

/* Applies to every .nav-tabs in the app, in and out of modals, so the editor,
   the profile page and the admin page all read the same. The editor's
   .right-pane-tabs is deliberately not a .nav-tabs — it is an icon-first pane
   switcher built from buttons, and styles itself in style.css. */
.nav-tabs {
    flex-shrink: 0;
    flex-wrap: nowrap;
    gap: 0.15rem;
    padding: 0 1rem;
    border-bottom: 1px solid var(--border-main);
    overflow-x: auto;
    scrollbar-width: none;
}

.nav-tabs::-webkit-scrollbar {
    display: none;
}

.modal-content > .nav-tabs {
    padding: 0 var(--modal-gutter);
}

.nav-tabs .nav-item {
    padding: 0;
}

.nav-tabs .nav-link {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    margin: 0;
    padding: 0.55rem 0.8rem;
    /* A tab is an underline and a label, never a filled box. The editor's own
       pane tab switchers (.left-pane-tabs/.right-pane-tabs) show selection via
       a sliding pill instead (motion.css) — that pill is a sibling element, not
       a background, so it has no bearing on this rule. */
    background: none;
    color: var(--tab-inactive-text);
    border: 0;
    /* Transparent, not absent — an underline that appears only when active
       would shift the label by 2px on every tab change. */
    border-bottom: 2px solid transparent;
    border-radius: 0;
    font-size: 0.875rem;
    /* Every metric on a tab is state-independent: the weight never changes, the
       underline is always 2px (transparent when inactive), the padding is fixed
       and the icon is a fixed box. A tab must not resize when it is clicked —
       that shifts every tab to its right and makes the strip twitch. State is
       carried by colour and the underline only. */
    font-weight: 500;
    white-space: nowrap;
    transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
}

.nav-tabs .nav-link:hover {
    background-color: var(--hover-color);
    color: var(--text-primary);
    border-bottom-color: transparent;
}

.nav-tabs .nav-link.active {
    background: none;
    color: var(--tab-active-text);
    border-bottom-color: var(--primary-blue);
}

/* Tabs are text-only by convention — a label and an underline, nothing else.
   No template should put an icon in a tab; this backstops the ones that slip
   through so a stray glyph cannot quietly change a tab's width again. Status
   markers built from elements other than <i>/<svg> (the AI setup modal's
   unsaved-changes dot) still show. */
.nav-tabs .nav-link i,
.nav-tabs .nav-link svg {
    display: none;
}

.nav-tabs.nav-fill .nav-item {
    flex: 1 1 auto;
}

.nav-tabs.nav-fill .nav-link {
    width: 100%;
    justify-content: center;
}

.tab-content {
    border: 0;
}

/* ── Body ─────────────────────────────────────────────────────────────────── */

.modal-body {
    flex: 1 1 auto;
    min-height: 0;
    padding: var(--modal-gutter);
    background: transparent;
    max-height: var(--modal-body-max-height);
    overflow-y: auto;
}

/* A dot-mountain mounted into a modal body needs a floor: the neutral
   .modal-body min-height:0 above would let a short body (e.g. the progress
   state of the create/import modals) collapse the scene to nothing.
   Specificity beats style.css's plain .dot-mountain-host rule. */
.modal-body.dot-mountain-host {
    min-height: 200px;
}

/* Loading-scene state: while a dot-mountain is mounted in the body, the
   scene IS the dialog — header and footer gone, body flush, and the only
   text (the progress label, see style.css's .import-progress) sits bottom
   centre like the design doc's in-context pane mock. Toggled by
   mount()/unmount() in shared/dot_mountain.ts on hosts inside a .modal;
   nothing here may fight that lifecycle, so no markup outside the contract
   above is needed in a template. */
.modal.dot-mountain-loading .modal-header,
.modal.dot-mountain-loading .modal-footer {
    display: none;
}
/* A strip of scene would read as a broken dialog; with the header and
   footer gone the body carries the whole thing, so it gets a real height.
   The shared --modal-body-max-height stays in force — on a short viewport
   the scene shrinks (overflow:hidden, never scrolls) instead of leaking
   past the screen. */
.modal.dot-mountain-loading .modal-body {
    padding: 0;
    overflow: hidden;
    min-height: 380px;
}
/* The scene canvas is transparent — over the glass dialog backdrop the dim
   dots float on the page. The loading dialog gets the mock pane's own
   backdrop (theme pair --dot-scene-bg); on .modal-content so the rounded
   corners clip it. */
.modal.dot-mountain-loading .modal-content {
    background: var(--dot-scene-bg);
    border-color: var(--dot-scene-bg);
}
/* style.css's lift rule makes every host child (incl. the progress host
   wrapper) position:relative — as the anchoring box for the centred label
   that zero-height wrapper would pin the text to the top of the body. The
   whole body is the scene here, so nothing needs lifting. */
.modal.dot-mountain-loading .modal-body > :not(.dot-mountain-canvas) {
    position: static;
}
.modal.dot-mountain-loading .import-progress {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 26px;
    text-align: center;
}
/* The mountain is the animation; the bar and the stage dots are competing
   furniture (and the indeterminate bar's 1.3 s slide is exactly the busy
   motion this round exists to remove). The label already names the stage. */
.modal.dot-mountain-loading .import-progress-bar,
.modal.dot-mountain-loading .stage-timeline {
    display: none;
}
.modal.dot-mountain-loading .import-progress-label {
    font-size: 14px;
    letter-spacing: .02em;
}

/* Hold one height across tabs so the dialog does not jump as the user moves
   between panes of very different lengths. */
.modal-content:has(> .nav-tabs) > .modal-body {
    height: var(--modal-tabbed-body-height);
    max-height: none;
}

/* #projectSettingsModal is one fixed size regardless of which section is
   showing — some sections (a couple of form fields) are much shorter than
   others (Provenance's report viewer, Skills' editor), and a size that
   changed with content would jump the dialog around on every sidebar click.
   Reuses the existing --modal-body-max-height token rather than inventing a
   new size. */
#projectSettingsModal {
    --modal-tabbed-body-height: var(--modal-body-max-height);
}

/* The gutter lives on .modal-body for every other modal; here the body is a
   bare tab host, so the padding moves to the scrolling region inside it.
   overflow:hidden (not auto) because .settings-panel below is the one
   scrolling region — letting this scroll too would mean two scrollbars for
   one pane's content. */
#projectSettingsModal .modal-body {
    /* flex-basis must stay "auto" (not the "flex: 1" shorthand's 0%) so the
       fixed height below is what the flex layout actually sizes this box to
       — with a 0% basis and .modal-content itself having no definite height,
       this box had no fixed size to grow/shrink from and tracked its
       children's content height instead, which is exactly the collapse-on-
       collapse/grow-on-tall-tab bug this rule's comment claims to prevent. */
    flex: 1 1 auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    padding-top: var(--modal-gutter);
    /* No more direct-child .nav-tabs to trigger the generic tabbed-height
       rule above (the scope switcher it depended on is gone), so this
       repeats it explicitly — still one fixed height regardless of section. */
    height: var(--modal-tabbed-body-height);
    max-height: none;
}

/* ── Settings shell: one flat left-sidebar nav, two grouped sections ────────
 * Replaces what used to be a scope switcher (User Preferences / Project
 * Settings as two tabs, each with its own full sidebar+content) with the
 * pattern Notion/Linear use for the same "account vs. this workspace" split:
 * one sidebar, always fully visible, with an icon-labeled "Account" group
 * above an icon-labeled "This Project" group. A setting that genuinely has
 * both scopes (Build, Formatting) appears once under each — the grouping
 * itself is what says which value you're editing, not a tab you have to
 * switch to find out. The dialog's header title mirrors the active section's
 * group (see menu_listeners.ts's syncSettingsHeader) for the same reason. */
.settings-shell {
    flex: 1;
    min-height: 0;
    display: flex;
    gap: 1.5rem;
}

.settings-sidebar {
    flex: 0 0 190px;
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    overflow-y: auto;
}

/* Search box: filters the nav-link list below by label text (settings_sidebar.ts).
   Sits outside .settings-sidebar-surface — it's not a pill-eligible row. */
.settings-sidebar-search {
    position: relative;
    flex: 0 0 auto;
    margin-bottom: 0.6rem;
}

.settings-sidebar-search-icon {
    position: absolute;
    left: 0.55rem;
    top: 50%;
    translate: 0 -50%;
    width: 13px;
    height: 13px;
    color: var(--text-secondary);
    pointer-events: none;
}

.settings-sidebar-search-input {
    width: 100%;
    padding: 0.35rem 0.5rem 0.35rem 1.7rem;
    border-radius: 6px;
    border: 1px solid var(--border-color);
    background: var(--input-bg, var(--hover-color));
    color: var(--text-primary);
    font-size: 0.82rem;
}

.settings-sidebar-search-input::placeholder {
    color: var(--text-secondary);
}

.settings-sidebar-search-input:focus {
    outline: none;
    border-color: var(--tab-active-text);
}

/* Positioning root for the hover/selection pills (settings_sidebar.ts) — same
   role as the file tree's .ft-surface: pills are absolutely positioned
   against this box, not against the scrolling .settings-sidebar itself. */
.settings-sidebar-surface {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
}

/* Category headers (Account / This Project) are typographically the same row
   as a nav-link — same size, case, color — with bold marking them as a header
   rather than a destination. A row that looked like a different component
   (small-caps, its own icon) read as UI chrome rather than "part of this
   list", which is also why they share the nav-link's hover pill.
   The narrower subcategories nested under them (Provenance/AI) are one step
   down in the same hierarchy, not another top-level group, so they read
   smaller/lighter and sit indented under their parent — otherwise "Account"
   and "AI" look like the same kind of thing and the tree reads flat. */
.settings-sidebar-group-primary,
.settings-sidebar-group {
    display: flex;
    align-items: center;
    position: relative;
    z-index: 1;
    width: 100%;
    gap: 0.4rem;
    padding: 0.4rem 0.65rem;
    border-radius: 6px;
    color: var(--tab-inactive-text);
    background: none;
    border: 0;
    text-align: left;
}

.settings-sidebar-group-primary {
    font-size: 0.85rem;
    font-weight: 700;
}

.settings-sidebar-group {
    font-size: 0.78rem;
    font-weight: 600;
}

.settings-sidebar-group-primary:hover,
.settings-sidebar-group:hover {
    color: var(--text-primary);
}

/* Vertical breathing room between one category's items and the next
   category header — the pill/typography no longer implies it the way the
   old small-caps treatment did, so it's spelled out as margin instead. */
.settings-sidebar-category + .settings-sidebar-category,
.settings-sidebar-category-items > .settings-sidebar-subcategory {
    margin-top: 0.5rem;
}

/* Three-step indent so the list reads as a tree, not one flat left edge:
   category header (Account/This Project) at the base, everything directly
   under it — plain nav-links (Build, General, Share…) and subcategory
   headers (AI, Provenance) alike — inset one step, and nav-links nested
   inside a subcategory (Style, Settings, Reports…) inset a second step. */
.settings-sidebar .settings-sidebar-category-items > .nav-link,
.settings-sidebar-category-items > .settings-sidebar-subcategory > .settings-sidebar-category-toggle {
    padding-left: 1.05rem;
}

.settings-sidebar-subcategory > .settings-sidebar-category-items > .nav-link {
    padding-left: 1.45rem;
}

.settings-sidebar .nav-link {
    display: block;
    position: relative;
    z-index: 1;
    width: 100%;
    text-align: left;
    padding: 0.4rem 0.65rem;
    border-radius: 6px;
    font-size: 0.85rem;
    font-weight: 500;
    color: var(--tab-inactive-text);
    background: none;
    border: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    transition: color 0.15s ease;
}

/* The pill (settings_sidebar.ts) paints hover/active, not the row itself —
   same contract as .ft-pill-hover/.ft-pill-selected in motion.css. */
.settings-sidebar .nav-link:hover {
    color: var(--text-primary);
}

.settings-sidebar .nav-link.active {
    color: var(--tab-active-text);
}

.settings-sidebar-group-label {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Chevron: geometry/icon come from .ft-chevron (file_tree.css, loaded on every
   page that has this modal); only the rotate-on-expanded trigger is local,
   since it hangs off aria-expanded rather than file_tree's .ft-open. */
.settings-sidebar-chevron {
    margin-left: auto;
}

.settings-sidebar-category-toggle[aria-expanded="true"] .settings-sidebar-chevron {
    transform: rotate(90deg);
}

/* Smooth expand/collapse, same technique as .ft-children in file_tree.css. */
.settings-sidebar-category-items {
    max-height: 2000px;
    overflow: hidden;
    opacity: 1;
    transition: max-height 0.2s ease, opacity 0.2s ease;
}

.settings-sidebar-category.collapsed > .settings-sidebar-category-items {
    max-height: 0;
    opacity: 0;
}

/* The sole positioned ancestor for this scope's panes — every section's
   .tab-pane resolves its layout against this box, never against .modal-body
   several ancestors up (that mismatch was the #tab-pane-provenance overlap
   bug: its old inset:0 landed relative to the whole modal body, painting
   its own sub-tab strip over the bar above it). */
.settings-panel {
    flex: 1;
    min-width: 0;
    min-height: 0;
    position: relative;
}

/* General/Build/Formatting/AI Style/Provenance Settings sit inside a nested
   <form class="tab-content"> (the same "one tab-content inside another" trick
   the pre-redesign markup already used, so Bootstrap's own .active handling
   needs nothing special — its `.tab-content > .tab-pane:not(.active)` hiding
   rule matches by DOM parentage, unaffected by display below).
   #projectSettingsForm and #userPreferencesForm are BOTH always present as
   siblings here, but only one has an active pane inside it at a time — a
   percentage height would need to resolve against the form, and giving both
   forms a real height unconditionally (an earlier version of this rule) drew
   two stacked boxes worth of empty space even for the scope not being
   viewed. display:contents removes the form as a layout box entirely (its
   children stay form-associated by DOM containment, so submission via the
   footer's form="..." attribute is untouched) — so a .tab-pane.active's
   height:100% below resolves straight against .settings-panel, and a form
   with no active child (the other scope's form) takes zero space instead of
   a form's own auto/percentage height either way. */
.settings-panel > form.tab-content {
    display: contents;
}

/* A plain scrolling pane, sized to and clipped by .settings-panel. Descendant
   (not direct-child) selector so it also reaches the tab-panes nested inside
   the display:contents forms above — a direct-child selector here would
   silently skip every one of them. */
.settings-panel .tab-pane.active {
    height: 100%;
    overflow-y: auto;
    padding-right: 0.25rem;
}

/* "See in account/project settings" — Build and Formatting are the two
   settings that genuinely exist at both scopes (see the settings-shell
   comment above); their panes end with this pointing at the counterpart. */
.settings-crosslink {
    margin-top: 1rem;
    padding-top: 0.75rem;
    border-top: 1px solid var(--border-color);
}

.settings-crosslink-btn {
    padding: 0;
    font-size: 0.8rem;
    color: var(--text-secondary);
    text-decoration: none;
}

.settings-crosslink-btn:hover {
    color: var(--tab-active-text);
    text-decoration: underline;
}

/* Opt-in for the few sections whose content should fill the panel itself
   rather than flow and scroll with the rest — Provenance's report iframe and
   Monaco source view, its seals list. Anchored to .settings-panel, which this
   file builds specifically to be a safe absolute-positioning root, so it can
   never again reach past a sidebar or a banner the way the old rule did. */
.settings-panel .tab-pane.settings-pane-fill.active {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

/* Panes that fill the body themselves (a diff view, a PDF, an iframe) opt out
   of the gutter rather than each overriding it with p-0. */
.modal-body.modal-body-flush {
    padding: 0;
}

/* ── Footer ───────────────────────────────────────────────────────────────── */

.modal-footer {
    flex-shrink: 0;
    align-items: center;
    justify-content: flex-end;
    gap: 0.5rem;
    padding: 0.85rem var(--modal-gutter);
    background: transparent;
    border-top: 1px solid var(--border-main);
}

/* Bootstrap gives every footer child a .5rem margin; the gap above already
   spaces them, and the margins fight .modal-status's auto margin. */
.modal-footer > * {
    margin: 0;
}

/* Inline status text ("Saved", "Invite sent") sits at the far left of the
   footer, opposite the buttons. */
.modal-footer .modal-status {
    margin-right: auto;
    font-size: 0.85rem;
    color: var(--text-secondary);
    text-align: left;
}

/* ── Welcome tour ─────────────────────────────────────────────────────────── */

/* partials/welcome_modal.html + editor/welcome_modal.ts. Five steps stacked in
   one body, only the active one visible. The body is one fixed height for every
   step: the dialog is centred, so a step-dependent height makes the whole card
   grow and shift on each Next, which reads as a jump rather than a page turn.
   The tallest step (the layout map) sets the number; shorter steps opt into
   .welcome-step-centered rather than leaving the slack at the bottom. */
:root {
    --welcome-body-height: 30rem;
}

/* The viewport cap keeps the dialog on screen on a short laptop; it is a
   constant too, so the height stays the same on every step either way. */
#welcomeModal .modal-body {
    height: min(var(--welcome-body-height), 60vh);
    overflow-y: auto;
    position: relative;
}

.welcome-step {
    display: flex;
    flex-direction: column;
    min-height: 100%;
}

.welcome-step-centered {
    justify-content: center;
}

/* Turning a page is one filmstrip moving, not two pages fading past each other:
   the outgoing and incoming pages sit exactly one width apart and travel
   together, so the trailing edge of one meets the leading edge of the next and
   they are never both over the same pixel.

   Both are lifted out of the flow for the length of the turn — left in it the
   second stacks under the first and the body grows, which reads as a drop.
   `inset: 0` resolves against the body's *padding* box, so each page spans the
   full width and one width of travel lands it exactly alongside its neighbour;
   the gutter the body used to give it, it now carries itself — and it has to be
   the same `--modal-gutter` the body uses, or the text shifts as the page lands. */
.welcome-enter-from-left,
.welcome-enter-from-right,
.welcome-exit-to-left,
.welcome-exit-to-right {
    position: absolute;
    inset: 0;
    padding: var(--modal-gutter);
    animation-duration: 0.35s;
    animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
    animation-fill-mode: both;
}

/* Nothing on its way out may catch a click meant for the page arriving. */
.welcome-exit-to-left,
.welcome-exit-to-right {
    pointer-events: none;
}

/* Clips the strip to the dialog while it moves, so the page waiting off to the
   side stays out of sight and cannot scroll the body sideways. The vertical
   scrollbar goes with it: it belongs to whichever page has settled, and letting
   it appear and vanish mid-turn twitches the whole layout sideways. */
.welcome-turning {
    overflow: hidden !important;
}

.welcome-enter-from-left {
    animation-name: welcome-slide-in-from-left;
}

.welcome-enter-from-right {
    animation-name: welcome-slide-in-from-right;
}

.welcome-exit-to-left {
    animation-name: welcome-slide-out-to-left;
}

.welcome-exit-to-right {
    animation-name: welcome-slide-out-to-right;
}

/* Going forward: the next page waits one width to the right and comes in as the
   current one leaves to the left. Going back, both run the other way. */
@keyframes welcome-slide-in-from-left {
    from { transform: translateX(100%); }
    to { transform: translateX(0); }
}

@keyframes welcome-slide-in-from-right {
    from { transform: translateX(-100%); }
    to { transform: translateX(0); }
}

@keyframes welcome-slide-out-to-left {
    from { transform: translateX(0); }
    to { transform: translateX(-100%); }
}

@keyframes welcome-slide-out-to-right {
    from { transform: translateX(0); }
    to { transform: translateX(100%); }
}

@media (prefers-reduced-motion: reduce) {
    .welcome-enter-from-left,
    .welcome-enter-from-right,
    .welcome-exit-to-left,
    .welcome-exit-to-right {
        animation-duration: 0.01s;
    }
}

/* The p5 dot-wave sits above the dimmed backdrop but below the dialog, so it
   animates around the card rather than behind the whole editor. Hidden until
   welcome_modal.ts starts it, which is also what sets display:block.

   The sketch is shared with the auth and dashboard pages, where it is a quiet
   texture behind text. Here it is the whole stage, so it is lifted instead of
   re-tuned — the constants in shared/bg_animation.ts stay as those pages want
   them.

   p5 mounts the canvas mid-animation and shows it in one step, which lands as a
   pop against the dialog's own fade. welcome_modal.ts adds .welcome-canvas-lit
   a frame after starting the sketch and drops it when the tour begins closing,
   so the wave rises and settles with the dialog. Short on purpose: the arrival
   is the sketch's own dot-by-dot reveal (revealDurationMs in bg_animation.ts),
   and a long fade on top of it would just wash the first second out. */
.welcome-canvas {
    position: fixed;
    inset: 0;
    z-index: 1052;
    display: none;
    pointer-events: none;
    opacity: 0;
    filter: brightness(2) saturate(1.3);
    transition: opacity 0.25s ease-out;
}

.welcome-canvas-lit {
    opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
    .welcome-canvas {
        transition: none;
    }
}

/* The tour opens during page load, above the ordinary modal stack, so its own
   layers need head-of-the-line z-index — otherwise a later modal (or a panel's
   own loading state) could paint over it. Only while it is open, so every other
   modal keeps the ordinary Bootstrap layering. The internal order is unchanged:
   backdrop, then the dot-wave, then the dialog. */
body.welcome-open .modal-backdrop {
    z-index: 10000;
    opacity: 0.6;
}

body.welcome-open .welcome-canvas {
    z-index: 10002;
}

body.welcome-open #welcomeModal {
    z-index: 10005;
}

/* ── Step 1: greeting beside the highlights ── */

/* Two columns: who you are looking at on the left, what it does on the right.
   The rule between them is the only decoration — the step is a greeting, not a
   feature table. */
.welcome-intro {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 1.75rem;
    align-items: center;
}

.welcome-greeting {
    text-align: center;
}

.welcome-logo {
    width: 72px;
    height: 72px;
    border-radius: 14px;
}

.welcome-greeting-text {
    margin: 1.1rem 0 0;
    font-size: 0.9rem;
    line-height: 1.6;
    color: var(--text-secondary);
}

.welcome-highlights {
    padding-left: 1.75rem;
    border-left: 1px solid var(--border-main);
}

/* Contents of the tour. Numbered by the list itself so the markup carries no
   step numbers to fall out of step with the pages. */
.welcome-toc {
    margin: 0;
    padding: 0;
    list-style: none;
    counter-reset: welcome-toc;
}

.welcome-toc li {
    counter-increment: welcome-toc;
}

.welcome-toc button {
    display: flex;
    align-items: baseline;
    gap: 0.6rem;
    width: 100%;
    padding: 0.4rem 0;
    font-size: 0.88rem;
    text-align: left;
    color: inherit;
    background: none;
    border: 0;
}

.welcome-toc button::before {
    content: counter(welcome-toc);
    font-variant-numeric: tabular-nums;
    font-size: 0.75rem;
    color: var(--primary-blue);
}

.welcome-toc button:hover,
.welcome-toc button:focus-visible {
    color: var(--primary-blue);
}

/* Google renders its own button into this container, so it only gets spacing
   and the light colour scheme the button is drawn for. */
.welcome-google-btn {
    display: flex;
    justify-content: center;
    margin-top: 1.1rem;
}

.welcome-signup-hint {
    margin: 0.5rem 0 0;
    font-size: 0.75rem;
    text-align: center;
    color: var(--text-secondary);
}

/* On the closing step the logo is the whole illustration, so it sits centred
   and larger in place of an icon. */
.welcome-cta .welcome-logo {
    width: 56px;
    height: 56px;
    margin-bottom: 0.85rem;
}

.welcome-step-title {
    font-size: 0.95rem;
    font-weight: 700;
    margin-bottom: 0.9rem;
}

/* ── Step 2: layout schematic ── */

/* A scale drawing of the real editor: menu bar across the top, then the four
   panes in the order they actually sit on screen. */
.welcome-editor-split {
    display: flex;
    gap: 1.1rem;
    align-items: stretch;
}

.welcome-editor-side {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
}

.welcome-map {
    flex: 0 0 44%;
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 8px;
    background: var(--bg-app);
    border: 1px solid var(--border-main);
    border-radius: 8px;
}

.welcome-map-panes {
    display: flex;
    gap: 6px;
    flex: 1 1 auto;
    min-height: 190px;
}

.welcome-map-zone {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 4px;
    padding: 6px;
    background: var(--bg-panel);
    color: var(--text-secondary);
    border: 1px solid var(--border-main);
    border-radius: 5px;
    font-size: 0.7rem;
    font-weight: 500;
    text-align: center;
    overflow: hidden;
    transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}

.welcome-map-menubar {
    flex-direction: row;
    height: 30px;
}

/* Pane widths echo the editor's own proportions rather than being equal. */
.welcome-map-left { flex: 0 0 20%; }
.welcome-map-center { flex: 1 1 auto; }
.welcome-map-preview { flex: 0 0 24%; }
.welcome-map-right { flex: 0 0 26%; }

.welcome-map-zone i,
.welcome-map-zone svg {
    width: 15px;
    height: 15px;
    flex-shrink: 0;
}

/* The side panes are tabbed, so their zone shows the three real tab icons
   rather than a single one that would imply a single purpose. */
.welcome-map-tabs {
    display: inline-flex;
    gap: 5px;
}

.welcome-map-zone:hover,
.welcome-map-zone:focus-visible,
.welcome-map-zone.active {
    background: color-mix(in srgb, var(--primary-blue) 16%, var(--bg-panel));
    border-color: var(--primary-blue);
    color: var(--text-primary);
}

.welcome-map-detail {
    min-height: 76px;
}

.welcome-map-detail strong {
    display: block;
    font-size: 0.85rem;
    margin-bottom: 0.15rem;
}

.welcome-map-detail p {
    margin: 0;
    font-size: 0.8rem;
    line-height: 1.45;
    color: var(--text-secondary);
}

/* ── Step 3: tips ── */

.welcome-tips {
    list-style: none;
    padding: 0;
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: 0.7rem;
}

.welcome-tips > li {
    display: flex;
    align-items: baseline;
    gap: 0.7rem;
    font-size: 0.82rem;
    line-height: 1.5;
}

.welcome-tips > li > i,
.welcome-tips > li > svg {
    width: 16px;
    height: 16px;
    flex-shrink: 0;
    align-self: center;
    color: var(--primary-blue);
}

.welcome-tips p {
    margin: 0;
}

.welcome-tips kbd {
    margin: 0 2px;
    color: var(--text-primary);
}

/* ── Step 4: preferences ── */

.welcome-prefs {
    display: flex;
    flex-direction: column;
    gap: 1.1rem;
}

.welcome-pref-label {
    display: block;
    margin-bottom: 0.4rem;
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.welcome-pref-controls {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    flex-wrap: wrap;
}

.welcome-pref-controls .form-check {
    margin: 0;
}

.welcome-pref-controls .form-select {
    width: auto;
    min-width: 150px;
}

/* Segmented control: one connected row of choices, the active one filled. */
.welcome-choice {
    display: inline-flex;
    border: 1px solid var(--border-main);
    border-radius: 6px;
    overflow: hidden;
}

.welcome-choice-btn {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.35rem 0.8rem;
    min-width: 44px;
    justify-content: center;
    background: transparent;
    color: var(--text-secondary);
    border: 0;
    border-right: 1px solid var(--border-main);
    font-size: 0.8rem;
    transition: background-color 0.15s ease, color 0.15s ease;
}

.welcome-choice-btn:last-child {
    border-right: 0;
}

.welcome-choice-btn i,
.welcome-choice-btn svg {
    width: 14px;
    height: 14px;
}

.welcome-choice-btn:hover {
    background: var(--hover-color);
    color: var(--text-primary);
}

.welcome-choice-btn.active {
    background: var(--primary-blue);
    color: var(--text-on-primary);
}

/* ── Step 5: call to action ── */

.welcome-cta {
    padding: 1.5rem;
    text-align: center;
    background: var(--hover-color);
    border: 1px solid var(--border-main);
    border-radius: 10px;
}

.welcome-account-desc {
    max-width: 46ch;
    margin: 0 auto 1.1rem;
    font-size: 0.85rem;
    line-height: 1.55;
    color: var(--text-secondary);
}

.welcome-cta-actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 0.5rem;
}

.welcome-reopen-hint {
    margin: 1.1rem 0 0;
    font-size: 0.75rem;
    text-align: center;
    color: var(--text-secondary);
}

/* ── Footer ── */

.welcome-status .form-check {
    margin: 0;
}

.welcome-dots {
    display: flex;
    align-items: center;
    gap: 6px;
    margin-right: 0.5rem;
}

.welcome-dot {
    width: 7px;
    height: 7px;
    padding: 0;
    background: var(--border-input);
    border: 0;
    border-radius: 50%;
    transition: background-color 0.15s ease, transform 0.15s ease;
}

.welcome-dot:hover {
    background: var(--text-secondary);
}

.welcome-dot.active {
    background: var(--primary-blue);
    transform: scale(1.35);
}

/* How the panes are rearranged, under the map on the editor step. */
.welcome-controls-hint {
    margin: 0.6rem 0 0;
    font-size: 0.75rem;
    line-height: 1.45;
    color: var(--text-secondary);
}

.welcome-pref-hint {
    margin: 0.4rem 0 0;
    font-size: 0.75rem;
    line-height: 1.45;
    color: var(--text-secondary);
}

/* What a thing is for, one line each — used on the assistant step. Same shape
   as the step-1 highlights, one size down. */
.welcome-uses {
    margin: 0.5rem 0 0;
    padding: 0;
    list-style: none;
}

.welcome-uses li {
    display: flex;
    align-items: center;
    gap: 0.55rem;
    padding: 0.25rem 0;
    font-size: 0.82rem;
    color: var(--text-secondary);
}

.welcome-uses-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: 1rem;
}

.welcome-lead {
    margin: 0 0 0.9rem;
    font-size: 0.88rem;
    line-height: 1.55;
    color: var(--text-secondary);
}

/* The assistant step carries a list, its settings and a note, so its tips sit
   closer together than the tips step's, which is a list on its own. */
.welcome-tips-tight {
    gap: 0.45rem;
    margin-top: 0.9rem;
}

/* Step 3: where the assistant's settings live, under the divider. */
.welcome-ai-where {
    margin-top: 1.1rem;
    padding-top: 1.1rem;
    border-top: 1px solid var(--border-main);
}


/* A labelled block for "here is a thing and where it lives" — the command bar
   on the editor step, and each of the ways of working with other people. */
.welcome-note {
    padding: 0.75rem 0.85rem;
    margin-top: 1rem;
    background: var(--hover-color);
    border: 1px solid var(--border-main);
    border-radius: 8px;
}

.welcome-note strong {
    display: block;
    font-size: 0.85rem;
    margin-bottom: 0.2rem;
}

.welcome-note p {
    margin: 0;
    font-size: 0.8rem;
    line-height: 1.5;
    color: var(--text-secondary);
}

/* A note can carry a second paragraph — the command bar's where, then its what. */
.welcome-note p + p {
    margin-top: 0.35rem;
}

.welcome-note kbd {
    margin: 0 2px;
    color: var(--text-primary);
}

/* A screenshot of the real editor, cropped to a band so it illustrates the step
   without taking the page over. The crop keeps the top-left corner, which is
   where the carets and the comment sit in the collaboration shot. */
.welcome-shot {
    margin: 0 0 0.9rem;
}

.welcome-shot img {
    display: block;
    width: 100%;
    height: 150px;
    object-fit: cover;
    object-position: left top;
    border: 1px solid var(--border-main);
    border-radius: 8px;
}

.welcome-shot figcaption {
    margin-top: 0.5rem;
    font-size: 0.8rem;
    line-height: 1.5;
    color: var(--text-secondary);
}

/* ── Connection status modal (editor.html / collaboration.ts) ──────────────
 * Fades while travelling the full width of the screen: it enters touching the
 * left edge and settles centred, and collaboration.ts adds .conn-exit-right
 * before hiding so it leaves touching the right edge. The ±(50vw − 50%)
 * offsets put the dialog's side flush with the viewport edge (the % is the
 * dialog's own half-width). Bootstrap sequences the hide on the .modal's own
 * transition duration, so it matches the dialog's below — otherwise the
 * dialog is display:none'd mid-flight. */
#connectionLostModal {
    transition: opacity 0.5s ease;
}

#connectionLostModal .modal-dialog {
    transform: translateX(calc(-50vw + 50%));
    transition: transform 0.5s ease;
}

#connectionLostModal.conn-exit-right:not(.show) .modal-dialog {
    transform: translateX(calc(50vw - 50%));
}

#connectionLostModal.show .modal-dialog {
    transform: translateX(0);
}

/* AB dot line (editor/ai_dot_fx.ts): the body is flush (modal-body-flush) so
   the line bleeds edge to edge across the whole dialog — the renderer reads
   this canvas's laid-out size every frame, so no geometry lives here beyond
   the slot. The line is the modal's whole body; its margins are the air. */
#connectionLostModal #connection-line {
    display: block;
    width: 100%;
    height: 14px;
    margin-block: 1.25rem 1.5rem;
}

/* The footer status label's left-to-right sweep on every text change, reusing
   the chat note's global status-note-reveal keyframes (style.css). Functional
   narration, so it is exempt from the reduced-motion blanket — same policy
   the chat note has. */
#connectionLostModal .modal-status.conn-status-replaying {
    animation: status-note-reveal 0.25s ease-out;
}

@media (prefers-reduced-motion: reduce) {
    #connectionLostModal .modal-status.conn-status-replaying {
        animation-duration: 0.25s !important;
    }
}

/* ── New Project modal: starter-card art ─────────────────────────────────
   The three starter cards show the source of what you get (an empty sheet,
   a LaTeX snippet, a Markdown snippet) instead of compiled-PDF thumbnails,
   which all looked identical. Markup built in projects/templates.ts; the
   ink is --text-secondary so the snippets read as backdrop, not content. */

#createProjectMainModal .tpl-preview {
    overflow: hidden;
    background: var(--bg-editor);
    color: var(--text-secondary);
    font-family: var(--bs-font-monospace, monospace);
}

#createProjectMainModal .tpl-preview-code {
    padding: 0.55rem 0.75rem;
    font-size: 0.62rem;
    line-height: 1.5;
    white-space: pre;
}

/* Document card: a rendered page (WYSIWYG), so proportional type and real
   bold / italic rather than monospace source. */
#createProjectMainModal .tpl-preview-document {
    padding: 0.55rem 0.75rem;
    font-size: 0.62rem;
    line-height: 1.5;
    font-family: inherit;
    color: var(--text-primary);
}

#createProjectMainModal .tpl-doc-title {
    font-size: 0.85rem;
    font-weight: 700;
}

#createProjectMainModal .tpl-doc-centered {
    text-align: center;
}

#createProjectMainModal .tpl-doc-byline {
    font-style: italic;
    color: var(--text-secondary);
}

#createProjectMainModal .tpl-doc-heading {
    font-size: 0.7rem;
    font-weight: 600;
    color: var(--text-secondary);
}

#createProjectMainModal .tpl-doc-bold {
    font-weight: 700;
}

#createProjectMainModal .tpl-doc-italic {
    font-style: italic;
}

/* The selected starter card. Cards only select (they no longer submit), so
   the ring is what tells the user Create will make this one. The ID beats
   style.css's .template-card / :hover border rules by specificity. */
#createProjectMainModal .template-card-selected {
    border-color: var(--border-focus);
    box-shadow: 0 0 0 1px var(--border-focus);
}
