:root {
    /* This file loads AFTER tailwind.css (header.gohtml:41 then :72) and :root here
       has the same specificity as theme.css's :root and its [data-theme="dark"] block,
       so source order wins and THIS is what a page actually wears. Consolidating the
       three live defaults is out of scope for #412 and stays that way.

       Two rules bind every value below, and the tests in
       template/brandable_surface_test.go hold both.

       1. The ink follows the fill (ADR-166, R4.4). Change one of a pair and you must
          change the other.
       2. primary is READ as well as filled — text-primary paints tick labels, active
          rows and section headings — so the fill also has to clear 4.5:1 against its
          own scheme's surface. That is what forces two pairs rather than one: no
          single tone clears 4.5 on both white paper and #2a2c2e.

       Light was #4998B0 with black ink. The fill was legible under its ink (6.40) but
       failed as text on white (3.28), which is what put a black-on-blue Apply button
       in every filter modal. #2C6C80 is the same hue two steps down: 5.89 under white
       ink AND 5.89 as text on white, so one value satisfies both rules. */
    --color-primary: #2C6C80;
    --color-on-primary: #ffffff;
    /* Only ever drawn as text on a 10% tint of itself (bg-accent/10 + text-accent, on
       the three avatar chips). It was a second copy of the primary hex and drifted the
       moment primary moved, so it is an alias now and cannot drift again. */
    --color-accent: var(--color-primary);
    --color-secondary: #4965b0;
    --color-tertiary: #49b095;
}

/* The dark half of rule 2 above. theme.css:149 already declares a dark --color-primary,
   but it loses to the :root block above on source order, so before this rule the dark
   scheme wore the LIGHT fill: #2C6C80 reads at 2.38:1 on #2a2c2e, and #4998B0 before it
   only reached 4.27. Restating the theme's own dark tone here is what lets it apply.

   #85d1eb reads at 8.22 on the dark surface and carries black at 12.32. The ink is
   black rather than theme.css's #003642 for one reason: rule 1 is enforced through
   helpers.BrandInk, which picks between black and white, and a tonal third value would
   have to weaken that check for every pair to let this one through. */
[data-theme="dark"] {
    --color-primary: #85d1eb;
    --color-on-primary: #000000;
}

html {
    scrollbar-gutter: stable;
    /* Contain the full-bleed sticky header's 100vw without a horizontal scrollbar.
       `clip` (unlike `hidden`/`auto`) does not create a scroll container, so it
       leaves the header's `position: sticky` intact. */
    overflow-x: clip;
    /* Kill the document's elastic (rubber-band) overscroll. Without this, pulling
       past the top/bottom stretches the scrolled layer independently of the pinned
       breadcrumb strip (position: fixed) vs. the sticky app header — the two chrome
       layers drift apart and expose a gap ("crack") between them at the page ends.
       No bounce → nothing to desync. Supported in all modern browsers (Safari 16+). */
    overscroll-behavior-y: none;
}

/* The scroll-anchor offset for [data-scroll-spy-target="section"] lives with the sticky-header
   chrome further down (search --kito-sticky-offset) — the single source of truth for how far a
   section must clear the compacted app header + pinned breadcrumb strip. */

/* Prevent content shift when modals open/close.
   Flowbite Modal adds inline padding-right to body to compensate for the
   scrollbar disappearing, but scrollbar-gutter:stable already reserves that
   space. The extra padding causes a layout jump — override it. */
body {
    padding-right: 0 !important;
}

/* Dark-mode app canvas (HCT Dynamic Color Engine).
   The semantic `surface` token is the ELEVATED panel color (the JetBrains #2B2B2B
   charcoal band), so cards/sections read as lifted. The page itself sits on the
   deeper base neutral (raw palette T10 ≈ #1E1F22) so that elevation is visible.
   Without this rule dark-mode cards would float on the browser-default white page. */
[data-theme="dark"] body {
    /* JetBrains-style main-window charcoal (~#1E1F22): a hair lighter than the raw
       neutral T10 (#191c1e), which read as too near-black. Mixed from the two
       adjacent neutral palette steps so it stays seed-derived. Panels (surface,
       ~#2B2B2B) still sit clearly above this. */
    background-color: color-mix(in srgb, var(--palette-neutral-10) 70%, var(--palette-neutral-20) 30%);
    color: var(--color-on-surface);
}

/* Dark-mode placeholder text — the browser/Tailwind default (a mid gray) is nearly
   invisible on the dark input surfaces. Use the muted outline tone so placeholders
   read as clearly secondary but legible. */
[data-theme="dark"] ::placeholder {
    color: var(--color-outline);
    opacity: 1;
}

/* Neobrutalist Turbo Drive progress bar */
.turbo-progress-bar {
    height: 8px;
    background-color: #4998B0;
    border-bottom: 3px solid #000;
    border-radius: 0;
    z-index: 9999;
}

.page-container {
    position: relative;
    min-height: 100dvh;
    padding-bottom: 4rem;
    margin-bottom: 4rem;
}

.ts-control {
    z-index: 0;
}

/* PMS location modal: match tom-select to sibling text inputs (border-2 ink/20, py-2 px-3) */
[data-inline-edit-form="location"] .ts-wrapper {
    min-height: 0;
}
[data-inline-edit-form="location"] .ts-wrapper .ts-control {
    border: 2px solid rgb(0 0 0 / 0.2) !important;
    border-radius: 0.125rem !important;
    padding: 0.5rem 0.75rem !important;
    font-size: 1rem;
    line-height: 1.5rem;
    min-height: 0 !important;
    box-shadow: none !important;
}
[data-inline-edit-form="location"] .ts-wrapper .ts-control > .item {
    padding: 0 !important;
    margin: 0 !important;
}
[data-inline-edit-form="location"] .ts-wrapper.focus .ts-control {
    border-color: var(--color-primary, #2C6C80) !important;
    outline: none;
}

/* Wizard unit search: keep Tom Select control at fixed height when item is selected */
.wizard-unit-search-wrapper .ts-wrapper .ts-control {
    min-height: 44px;
    max-height: 44px;
    overflow: hidden;
    flex-wrap: nowrap;
    align-items: center;
}
.wizard-unit-search-wrapper .ts-wrapper .ts-control > .item {
    max-width: calc(100% - 40px);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Ensure native select elements are readable on mobile */
select {
    font-size: 16px;
}
select option {
    font-size: 16px;
}

/* ------------------------------------------------------------------------------------------------------------------ */
/* Tooltip */
/* ------------------------------------------------------------------------------------------------------------------ */
/* Tooltip container */
.tooltip {
    position: relative;
    display: inline-block;
}

/* Tooltip text */
.tooltip .tooltiptext {
    visibility: hidden;
    width: 300px;
    color: #fff;
    text-align: center;
    padding: 0.25rem 0.5rem;
    right: 32%;
    bottom: 46%;
    margin-left: -150px; /* Use half of the width (150/2 = 75), to center the tooltip */
    /* Position the tooltip text - see examples below! */
    position: absolute;
    z-index: 100;
    font-size: 12px ;
}

/* Show the tooltip text when you mouse over the tooltip container */
.tooltip:hover .tooltiptext {
    visibility: visible;
}

/* Use the Stimulus tooltip controller when JS is available. */
html[data-js-tooltips="true"] [data-tip]::before,
html[data-js-tooltips="true"] [data-tip]::after,
html[data-js-tooltips="true"] [data-tooltip]::before,
html[data-js-tooltips="true"] [data-tooltip]::after {
    content: none !important;
    display: none !important;
}

/* ------------------------------------------------------------------------------------------------------------------ */
/* Navigation Modal — Legacy classes removed, now using daisyUI + Tailwind utilities in templates */
/* ------------------------------------------------------------------------------------------------------------------ */

/* ============================================================
   Gantt Month Overview
   ============================================================ */

.gantt-scroll-container {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    position: relative;
    white-space: nowrap;
    font-size: 0; /* eliminate whitespace between inline-block month segments */
}

.gantt-timeline-wrapper {
    display: flex;
    flex-direction: row;
}

.gantt-fixed-units {
    flex-shrink: 0;
    width: 12rem;
    z-index: 20;
    background: #ffffff;
    border-right: 1px solid rgb(226 232 240);
    position: sticky;
    left: 0;
}

.gantt-unit-header-cell {
    height: 4rem; /* matches the day header row height (CW + weekday + day number) */
    border-top: 1px solid rgb(241 245 249);
    border-bottom: 1px solid rgb(226 232 240);
    box-sizing: border-box;
    background: #ffffff;
    position: sticky;
    top: 7.5rem;
    z-index: 25;
}

.gantt-unit-label {
    display: flex;
    align-items: center;
    padding: 0 0.625rem;
    box-sizing: border-box;
    border-bottom: 1px solid rgb(241 245 249);
    white-space: nowrap;
    overflow: hidden;
}

.gantt-unit-label a {
    color: var(--color-primary);
    font-size: 0.75rem;
    font-weight: 500;
    text-decoration: none;
    overflow: hidden;
    text-overflow: ellipsis;
}

.gantt-unit-label a:hover {
    text-decoration: underline;
    text-underline-offset: 2px;
}

.gantt-month-segment {
    display: inline-block;
    vertical-align: top;
    font-size: 0.75rem; /* restore font size (parent has font-size: 0 to kill whitespace) */
}

.gantt-unit-row {
    border-bottom: 1px solid rgb(241 245 249);
    padding-top: 2px;
    padding-bottom: 2px;
    position: relative;
    z-index: 0;
    box-sizing: border-box;
    overflow: visible;
}

.gantt-drop-target {
    background-color: rgba(73, 152, 176, 0.25) !important;
    box-shadow: inset 0 0 0 2px rgba(73, 152, 176, 0.6);
    z-index: 5 !important;
    position: relative;
}

.gantt-unit-row > .gantt-bar,
.gantt-unit-row > a.gantt-bar {
    min-height: 0;
    height: 1.5rem;
    line-height: 1.5rem;
    overflow: hidden;
    margin-top: 3px;
    margin-bottom: 3px;
    font-size: 0.6875rem;
}

.gantt-grid {
    display: grid;
    position: relative;
    align-items: stretch;
    min-height: 2.5rem;
    /* Fixed cell width for infinite scroll mode: 2.5rem */
}

.gantt-header-unit,
.gantt-unit-name {
    position: sticky;
    left: 0;
    z-index: 20;
    background: #ffffff;
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid rgb(241 245 249);
    border-right: 1px solid rgb(241 245 249);
    min-width: 8rem;
    max-width: 8rem;
    box-sizing: border-box;
    overflow-wrap: break-word;
    word-break: break-word;
}

.gantt-header-unit {
    white-space: nowrap;
}

.gantt-header-day {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-end;
    padding: 0.375rem 0 0.25rem;
    border-bottom: 1px solid rgb(241 245 249);
    border-right: 1px solid rgb(248 250 252);
    font-size: 0.75rem;
    height: 4rem; /* increased from 3.5rem to prevent CW cutoff */
    box-sizing: border-box;
}

.gantt-day-header-row {
    z-index: 20;
    background: white;
    border-bottom: 1px solid rgb(226 232 240);
}

.gantt-cw {
    font-size: 0.625rem;
    color: rgb(148 163 184);
    margin-bottom: 0.125rem;
}

.gantt-weekday {
    font-size: 0.6875rem;
    color: #6b7280;
}

.gantt-day-num {
    font-size: 0.75rem;
    color: rgb(71 85 105);
    padding: 0.125rem 0.375rem;
}

.gantt-header-day.gantt-today {
    background-color: rgba(73, 152, 176, 0.10) !important;
    border-right: 1px solid rgba(73, 152, 176, 0.2) !important;
}

.gantt-day-today {
    background: color-mix(in srgb, var(--color-primary) 15%, transparent);
    color: var(--color-primary);
    border-color: transparent;
    border-radius: 9999px;
    font-weight: 600;
    padding: 0.125rem 0.375rem;
}

.gantt-cell {
    min-height: 2.5rem;
}

/* Vertical day gridlines — removed background-image gradient in favour of CSS grid
   borders on .gantt-line at reduced opacity (calendar-calm-clean-ui). */
.gantt-unit-row {
    background-image: none;
}

.gantt-today-bg {
    /* Background handled by the gantt-today-line overlay */
}

/* Today column vertical line — drawn by JS as an absolutely positioned overlay */
.gantt-today-line {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 2.5rem;
    background-color: rgba(73, 152, 176, 0.06);
    border-left: 1px solid rgba(73, 152, 176, 0.2);
    border-right: 1px solid rgba(73, 152, 176, 0.2);
    pointer-events: none;
    z-index: 4;
}

/* Row wrapper with horizontal separator */
.gantt-row-wrapper {
    position: relative;
    border-bottom: 1px solid rgb(241 245 249 / 0.08);
}

/* Lines overlay — absolutely positioned OVER the grid, pointer-events pass through */
.gantt-lines-overlay {
    display: grid;
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 3;
    pointer-events: none;
}

.gantt-line {
    border-right: 1px solid rgb(248 250 252 / 0.06);
    border-bottom: 1px solid rgb(241 245 249);
}

.gantt-line-today {
    border-right: 1px solid rgba(73, 152, 176, 0.2);
    border-left: 1px solid rgba(73, 152, 176, 0.2);
    background-color: rgba(73, 152, 176, 0.04);
}

/* ─── Calendar grid row hover ───
   Highlights the day cell under the cursor so the user can track which row they're on.
   Also styles the unit-label on hover for a subtle lift. */
[data-grid-role="day-cell"][data-unit-id]:hover {
    background-color: rgba(241, 245, 249, 0.8); /* slate-100/80 */
}
[data-grid-role="day-cell"][data-unit-id][data-today-column]:hover {
    background-color: color-mix(in srgb, var(--color-primary) 20%, transparent); /* primary/20 — stronger today hover */
}
[data-grid-role="unit-label"]:hover {
    background-color: rgba(248, 250, 252, 1); /* slate-50 */
}

/* ─── Today column accent ───
   A stronger top border on the today header cell makes the column pop visually. */
[data-grid-role="header"][data-today-column] {
    border-bottom: 2px solid color-mix(in srgb, var(--color-primary) 50%, transparent);
    box-shadow: inset 0 -2px 4px -2px color-mix(in srgb, var(--color-primary) 15%, transparent);
}
/* ─── Calendar grid geometry ───
   The single declaration of the grid's track sizes. The page template, the pre-paint
   boot script and the controllers' scroll arithmetic all derive from these, replacing
   four hand-maintained copies that had already drifted (the scroll math measured 44px
   against a grid rendering 48px, so "go to month" landed ~8% short).

   Declared in PIXELS on purpose: the JS scroll arithmetic works in px, so expressing
   the source in rem would let the two silently disagree the moment a root font-size
   differed from 16px. calendar_math.js mirrors these, and
   calendar_geometry.test.js fails if the two ever diverge. */
#calendar-grid {
    --kito-cal-day-w: 48px;
    --kito-cal-label-w: 220px;
    --kito-cal-row-h: 48px; /* was grid-auto-rows: minmax(3rem, auto) */
}

/* Collapsed calendar tree rows. Visibility is a class, not an inline style read back
   as state by a dozen call sites (see setRowHidden/isRowHidden in unit_tree_utils.js)
   — which also means CSS can finally see it. */
#calendar-grid .is-collapsed {
    display: none;
}

/* The today column is ONE overlay spanning every row, not a tint repeated on every
   day cell. Five producers used to emit that tint through three mechanisms and it
   lost to Tailwind's stylesheet order, so a rule like this one existed purely to win
   the fight; with a single element there is nothing left to conflict with. Spanning
   1 / -1 also means a row added later — a lazily-loaded branch, a client-rendered
   month — is covered without emitting anything of its own.

   Placement comes from --col-start like every other dated element (column_layout.js);
   only the row span and the paint live here. */
.calendar-today-column {
    grid-row: 1 / -1;
    z-index: 1; /* above day cells, below bars (z-index 2) and the sticky header */
    pointer-events: none; /* never intercepts a drag, a cell click or a bar click */
    background-color: color-mix(in srgb, var(--color-primary) 8%, transparent);
    border-inline: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
}

/* ─── Calendar grid column placement ───
   A dated element's column is DERIVED from its date and the grid's current origin
   and published as --col-start (plus --span for bars); see column_layout.js. CSS,
   not JS, turns that into a grid line, so nothing has to read layout back out of a
   serialized inline style — and a row that arrives late carrying a stale origin is
   corrected by the next reconcile pass instead of staying a month out of place.

   Scoped to [data-date] / .gantt-bar on purpose: the sticky unit-label column and
   the corner header sit at column 1 forever and keep their inline grid-column.
   The var() fallbacks keep an element that somehow has no placement yet out of an
   implicit track rather than collapsing the row. */
#calendar-grid [data-date] {
    grid-column: var(--col-start, auto);
}
#calendar-grid .gantt-bar {
    grid-column: var(--col-start, auto) / span var(--span, 1);
}

/* Gantt bar base */
.gantt-bar {
    position: relative;
    z-index: 2;
    display: flex;
    align-items: center;
    padding: 0;
    border-radius: 0.5rem;
    font-size: 0.75rem;
    font-weight: 500;
    white-space: nowrap;
    text-decoration: none;
    cursor: pointer;
    margin: 0.25rem 1px;
    height: calc(var(--kito-cal-row-h, 3rem) - 0.5rem);
    box-sizing: border-box;
    border: none;
    align-self: center;
    overflow: hidden;
    background: #e2e8f0;
    color: #475569;
}

.gantt-bar > span {
    display: flex;
    align-items: center;
}

.gantt-bar:hover {
    box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
    transform: translateY(-0.5px);
    z-index: 20;
}

/* Gantt bar badge tooltip — shows on hover of icon wrapper */
.gantt-bar-badge-wrap:hover .gantt-badge-tip {
    display: block;
}
.gantt-badge-tip {
    display: none;
    position: absolute;
    bottom: calc(100% + 6px);
    right: 0;
    z-index: 100;
    pointer-events: none;
    white-space: nowrap;
}
.gantt-badge-tip > span {
    background: #1e293b;
    color: white;
    font-size: 10px;
    font-weight: 500;
    padding: 2px 8px;
    border-radius: 2px;
    white-space: nowrap;
}

/* Responsive bar content — on narrow bars the guest name truncates with an
   ellipsis instead of being hidden behind an info icon (calendar-badge-display-fix).
   The two-class selector overrides the Tailwind `flex` utility so the name renders
   as a block and `text-overflow: ellipsis` can take effect. */
.gantt-bar .gantt-bar-guest-name {
    display: block;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
}
/* Very short bars (1-3 day spans) are too narrow for status icons — hide the
   icons so the truncated guest name keeps the space. The name stays visible. */
.gantt-bar[data-span="1"] .gantt-bar-icons,
.gantt-bar[data-span="2"] .gantt-bar-icons,
.gantt-bar[data-span="3"] .gantt-bar-icons {
    display: none;
}

/* Half-day edge bars (4-5 day spans with a check-in/out edge) lose usable width —
   hide the status icons so the guest name keeps the space. The name stays visible
   and truncates with an ellipsis. */
.gantt-bar.gantt-checkin-edge[data-span="4"] .gantt-bar-icons,
.gantt-bar.gantt-checkout-edge[data-span="4"] .gantt-bar-icons,
.gantt-bar.gantt-checkin-edge[data-span="5"] .gantt-bar-icons,
.gantt-bar.gantt-checkout-edge[data-span="5"] .gantt-bar-icons {
    display: none;
}

/* Status colors — solid pill backgrounds, no border */
.gantt-status-completed {
    background: #d1fae5;
    color: #065f46;
}

.gantt-status-confirmed {
    background: #fef9c3;
    color: #92400e;
}

.gantt-status-pending {
    background: #fef9c3;
    color: #a16207;
}

.gantt-status-new {
    background: #ede9fe;
    color: #5b21b6;
}

.gantt-status-draft,
.gantt-status-tentative {
    background: #ede9fe;
    color: #5b21b6;
}

.gantt-status-cancelled {
    background: #fee2e2;
    color: #991b1b;
}

.gantt-status-checked_in {
    background: #dbeafe;
    color: #1e40af;
}

/* Orange, matching the checked_out status badge (bg-orange-50/text-orange-700) and the
   booking list card's border-l-orange-500. */
.gantt-status-checked_out {
    background: #ffedd5;
    color: #9a3412;
}

.gantt-status-overlap {
    background: linear-gradient(135deg, #fef3c7 25%, #fde68a 25%, #fde68a 50%, #fef3c7 50%, #fef3c7 75%, #fde68a 75%);
    background-size: 8px 8px;
    color: #92400e;
    font-weight: 700;
}
.gantt-status-overlap:hover {
    filter: brightness(0.95);
}

/* ─── Month gantt: check-in / check-out tapered edges ───
   Bars that start on a check-in day get a tapered left edge (angled inward).
   Bars that end on a check-out day get a tapered right edge (angled inward).
   This visually communicates arrivals/departures at a glance. */
.gantt-checkin-edge {
    border-top-left-radius: 2px;
    border-bottom-left-radius: 0;
    clip-path: polygon(0.1875rem 0, 100% 0, 100% 100%, 0 100%);
}
.gantt-checkout-edge {
    border-top-right-radius: 0;
    border-bottom-right-radius: 2px;
    clip-path: polygon(0 0, 100% 0, calc(100% - 0.1875rem) 100%, 0 100%);
}
/* When a bar is both check-in and check-out (single-night stay), taper both sides */
.gantt-checkin-edge.gantt-checkout-edge {
    clip-path: polygon(0.1875rem 0, 100% 0, calc(100% - 0.1875rem) 100%, 0 100%);
}

/* Colored turnover accent borders on month gantt */
.gantt-turnover-start::before {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    width: 3px;
    background: #22c55e;
    pointer-events: none;
    z-index: 3;
    border-radius: 0 0 0 0.5rem;
}
.gantt-turnover-end::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    right: 0;
    width: 3px;
    background: #f59e0b;
    pointer-events: none;
    z-index: 3;
    border-radius: 0 0.5rem 0 0;
}

/* KIT-130: status accent border for the canvas-positioned (day / 3-day) and
   grid-positioned (week) booking bars. The month gantt conveys status with
   a solid pill background; these views are visually busier, so they add a 3px
   left border in the status accent color. `currentColor` resolves to the bar's
   text color, which the day/3-day controller sets inline and the week view sets
   via the `.gantt-status-*` classes — so the accent always tracks the status. */
.cal-status-accent {
    border-left: 3px solid currentColor;
}

/* KIT-134: tapered half-day edges for the day/3-day (canvas) and week (grid)
   views. Unlike the month gantt's .gantt-checkin-edge/.gantt-checkout-edge —
   which ALSO carry a half-cell margin to inset the bar — these views already
   inset the bar to its half-cell positionally (canvas geometry / grid lines),
   so these classes apply ONLY the angled clip-path plus flat inner corners.
   Both polygons keep the bottom-left vertex at x=0 so the .cal-status-accent
   left border survives the clip and stays visible on every bar. A bar that is
   both a real check-in and check-out (the common case) gets both classes and
   tapers on both ends, matching the month gantt; a bar clipped at the window
   edge keeps that side straight. */
.cal-clip-checkin {
    clip-path: polygon(0.25rem 0, 100% 0, 100% 100%, 0 100%);
    border-top-left-radius: 0;
    border-bottom-left-radius: 0;
    border-left: none;
    padding-left: 0.5rem;
}
.cal-clip-checkout {
    clip-path: polygon(0 0, 100% 0, calc(100% - 0.25rem) 100%, 0 100%);
    border-top-right-radius: 0;
    border-bottom-right-radius: 0;
    padding-right: 0.5rem;
}
/* Colored turnover borders — only shown when two bookings share the same day */
.cal-clip-checkin.cal-turnover-start::before {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    width: 0.25rem;
    background: #22c55e;
    pointer-events: none;
    z-index: 1;
}
.cal-clip-checkout.cal-turnover-end::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    right: 0;
    width: 0.25rem;
    background: #f59e0b;
    pointer-events: none;
    z-index: 1;
}
.cal-clip-checkin.cal-clip-checkout {
    clip-path: polygon(0.25rem 0, 100% 0, calc(100% - 0.25rem) 100%, 0 100%);
    border-radius: 0;
    border-left: none;
}

/* Clipped start — continuation from previous month: flat left edge, hide name */
.gantt-clipped-start {
    border-top-left-radius: 2px;
    border-bottom-left-radius: 2px;
    margin-left: 0;
    border-left: none;
}
.gantt-clipped-start > span.truncate,
.gantt-clipped-start > span.gantt-guest-count {
    visibility: hidden;
}

/* Clipped end — continues into next month: flat right edge */
.gantt-clipped-end {
    border-top-right-radius: 2px;
    border-bottom-right-radius: 2px;
    margin-right: 0;
}

/* Half-day check-in/check-out edges — a bar stops at the MIDDLE of its arrival
   day and at the middle of its departure day, so a stay that begins the day
   another ends takes the other half of that one cell and both are visible.
   The clip-path angles the two ends, and the two angles are parallel, so the
   pair reads as one cell split by a diagonal rather than as two bars that
   happen to touch.

   These rules carry the SHAPE only. The half-cell inset is a placement and it
   belongs to .cal-bar, which expresses it as --cal-lead / --cal-trail on its
   own left and width (see the note there).

   No margin here, and that is load-bearing rather than tidiness. Every element
   that wears these classes is a .cal-bar: absolutely positioned, with both left
   and width already set. A margin on such a box does not inset it, it SHIFTS
   it — the bar keeps its width and slides off the midpoint it just computed.
   .cal-bar answered that with `margin: 0`, which held for one class and lost to
   two: `.gantt-checkin-edge.gantt-checkout-edge` outranks `.cal-bar` on
   specificity no matter how late .cal-bar is written, and that pair is what
   every stay lying wholly inside the window wears. Measured in Chrome, those
   bars ran 21px right of where they belonged — an eighth of the arrival cell
   instead of half, seven eighths of the departure cell instead of half, and a
   same-day turnover's arriving bar reduced to the 7px sliver the departing bar
   left it. The fix is not a bigger override; it is that there is only one
   mechanism, and it is the offset.
   TestCalendarWindowBar_EveryEdgeCombinationResolvesToZeroMargin reads this
   file and resolves the cascade, so a margin added back here fails a test
   rather than moving every booking on the calendar. */
.gantt-checkin-edge {
    clip-path: polygon(0.5rem 0, 100% 0, 100% 100%, 0 100%);
    padding-left: 0.75rem;
    border-top-left-radius: 0;
    border-bottom-left-radius: 0;
}
.gantt-checkout-edge {
    clip-path: polygon(0 0, 100% 0, calc(100% - 0.5rem) 100%, 0 100%);
    border-top-right-radius: 0;
    border-bottom-right-radius: 0;
}
.gantt-checkin-edge.gantt-checkout-edge {
    clip-path: polygon(0.5rem 0, 100% 0, calc(100% - 0.5rem) 100%, 0 100%);
    padding-left: 0.75rem;
    border-radius: 0;
}
.gantt-checkout-edge .gantt-bar-icons {
    padding-right: 0.625rem;
}

/* Same-day turnover colored indicators — only applied when two bookings share the same day.
   Green = check-in side (left edge), Amber = check-out side (right edge).
   Pseudo-elements fill the angled edge area with solid color.
   The parent's clip-path clips them to the exact diagonal shape. */
.gantt-turnover-start::before {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    width: 0.5rem;
    background: #22c55e;
    pointer-events: none;
    z-index: 1;
}
.gantt-turnover-end::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    right: 0;
    width: 0.5rem;
    background: #f59e0b;
    pointer-events: none;
    z-index: 1;
}

/* Guest count badge */
.gantt-guest-count {
    display: inline-flex;
    align-items: center;
    align-self: center;
    justify-content: center;
    width: 1rem;
    height: 1rem;
    border-radius: 2px;
    background: rgba(255, 255, 255, 0.7);
    font-size: 0.5625rem;
    font-weight: 700;
    margin-left: 0.25rem;
    flex-shrink: 0;
    line-height: 1;
}

/* Drag-and-drop styles for month overview */
.gantt-bar-dragging { opacity: 0.5; border: 2px dashed #9CA3AF; }
.gantt-drop-valid { background-color: #F0FDF4; border: 2px solid #86EFAC; }
.gantt-drop-swap { background-color: #EFF6FF; border: 2px solid #93C5FD; }
.gantt-drop-invalid { background-color: #FEF2F2; cursor: not-allowed; }
.gantt-bar-loading { animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
.gantt-bar[draggable="true"] { cursor: grab; }
.gantt-bar[draggable="true"]:active { cursor: grabbing; }

@keyframes pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: .5; }
}

/* Drag-and-drop styles for month overview calendar */
.gantt-bar-dragging { opacity: 0.5; border: 2px dashed #9CA3AF; }
.gantt-drop-valid { background-color: rgba(134, 239, 172, 0.3); outline: 2px solid #86EFAC; outline-offset: -2px; }
.gantt-drop-swap { background-color: rgba(147, 197, 253, 0.3); outline: 2px solid #93C5FD; outline-offset: -2px; }
.gantt-drop-invalid { background-color: rgba(252, 165, 165, 0.2); cursor: not-allowed; }
.gantt-bar-loading { animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
.gantt-bar[draggable="true"] { cursor: grab; }
.gantt-bar[draggable="true"]:active { cursor: grabbing; }

/* Infinite Scroll Calendar — merged into main gantt-scroll-container above */

.gantt-header-label {
    position: sticky;
    top: 0;
    z-index: 30;
}

.gantt-loading-indicator {
    position: fixed;
    bottom: 1rem;
    right: 1rem;
    z-index: 50;
}

.calendar-loading-overlay {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(255, 255, 255, 0.7);
    z-index: 35;
}

.calendar-loading-overlay[hidden] {
    display: none;
}

/* Calendar unit thumbnail — smooth enlarge on hover */
[data-grid-role="unit-label"] img {
    transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s ease;
}
[data-grid-role="unit-label"] img:hover {
    transform: scale(1.35);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    z-index: 50;
    position: relative;
}

.calendar-loading-spinner {
    width: 2rem;
    height: 2rem;
    border-radius: 9999px;
    border: 3px solid color-mix(in srgb, var(--color-primary) 20%, transparent);
    border-top-color: var(--color-primary);
    animation: calendar-spin 0.7s linear infinite;
}

@keyframes calendar-spin {
    to {
        transform: rotate(360deg);
    }
}

.calendar-skeleton-indicator {
    position: absolute;
    inset: 0;
    pointer-events: none;
    z-index: 34;
    background: linear-gradient(90deg, rgba(255, 255, 255, 0.45) 25%, rgba(73, 152, 176, 0.2) 50%, rgba(255, 255, 255, 0.45) 75%);
    background-size: 220% 100%;
    animation: calendar-skeleton-shimmer 1.1s ease-in-out infinite;
}

@keyframes calendar-skeleton-shimmer {
    0% {
        background-position: 200% 0;
    }
    100% {
        background-position: -200% 0;
    }
}

.gantt-error-toast {
    position: fixed;
    bottom: 1rem;
    left: 50%;
    transform: translateX(-50%);
    background: #ef4444;
    color: white;
    padding: 0.5rem 1rem;
    border-radius: 0.375rem;
    z-index: 50;
}
.gantt-dragging-active .gantt-cell {
    z-index: 10;
    position: relative;
}
/* Show a ghost preview spanning multiple cells */
.gantt-drop-preview {
    position: absolute;
    top: 0;
    bottom: 0;
    background-color: rgba(73, 152, 176, 0.15);
    border: 2px dashed rgba(73, 152, 176, 0.5);
    border-radius: 2px;
    pointer-events: none;
    z-index: 9;
    transition: opacity 0.15s ease;
}

/* Payment status badge on gantt bars */
.gantt-badge-payment {
    position: absolute;
    top: -4px;
    left: 2px;
    font-size: 8px;
    font-weight: bold;
    width: 12px;
    height: 12px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    line-height: 1;
}
.gantt-badge-payment--paid { background: #d1fae5; color: #065f46; }
.gantt-badge-payment--partial { background: #fef3c7; color: #92400e; }
.gantt-badge-payment--overdue { background: #fee2e2; color: #991b1b; }

/* Unread message count badge */
.gantt-badge-unread {
    position: absolute;
    top: -4px;
    right: 2px;
    font-size: 8px;
    font-weight: bold;
    min-width: 12px;
    height: 12px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    background: #ef4444;
    color: white;
    line-height: 1;
    padding: 0 2px;
}

/* Housekeeping status on gantt unit cells */
.gantt-cell[data-housekeeping="dirty"] { border-bottom: 3px solid #ef4444; }
.gantt-cell[data-housekeeping="in_progress"] { border-bottom: 3px solid #eab308; }
.gantt-cell[data-housekeeping="clean"] { border-bottom: 3px solid #22c55e; }

/* View Options toggle buttons */
.gantt-toggle-btn {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    padding: 0.25rem 0.5rem;
    font-size: 0.625rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    border: 1px solid rgb(226 232 240);
    border-radius: 0.25rem;
    background: white;
    color: #9ca3af;
    cursor: pointer;
    transition: all 0.15s;
    min-height: 44px;
    min-width: 44px;
}
.gantt-toggle-btn--active {
    border-color: rgba(73, 152, 176, 0.4);
    color: var(--color-primary);
    background: rgba(73, 152, 176, 0.05);
}
.gantt-toggle-btn--active:hover {
    border-color: #9ca3af;
    color: #6b7280;
    background: #ffffff;
}

.gantt-toggle-btn:not(.gantt-toggle-btn--active):hover {
    border-color: var(--color-primary);
    color: var(--color-primary);
    background: rgba(73, 152, 176, 0.05);
}

/* Hide overlays when toggled off */
.gantt-hide-financials .gantt-badge-payment { display: none !important; }
.gantt-hide-financials .gantt-bar-badge--payment { display: none !important; }
.gantt-hide-operations .gantt-cell[data-housekeeping] { border-bottom-color: transparent !important; }
.gantt-hide-operations .gantt-bar-badge--housekeeping { display: none !important; }
.gantt-hide-guestInfo .gantt-badge-unread { display: none !important; }
.gantt-hide-guestInfo .gantt-bar-badge--messages { display: none !important; }

/* Unread messages badge — a digit centred inside a circle.

   justify-content centres the glyph's ADVANCE BOX, not its ink, and flex centring
   centres the LINE BOX, not the digit standing on its baseline. Neither lands the
   ink on the circle's centre by itself, so both axes take a sub-pixel correction.
   The one below the padding is the horizontal counterpart to it.

   MEASURE THEM OFF THE RASTER. Screenshot the badge at 8x, find the red disc and
   the white ink in that ONE image, classify each by its own colour, and subtract
   the two centres. Reading the ink against the ELEMENT's own screenshot bounds
   carries a ±1px artefact instead, because the browser rounds an element's box to
   device pixels and identical CSS then measures differently depending only on
   where the element landed on the page.

   That method was already written down here on 2026-09-16 and the numbers that
   went with it still came out with the SIGN INVERTED on both axes: a 1px pad went
   on top of a digit that was already sitting low, and a 1.25px indent went on one
   already sitting right. The "before" row for the sibling .kito-rail-badge reads
   0.625px left of what a fresh measurement gives — exactly half of 1.25px, i.e.
   one indent's worth — so it looks to have been captured with the correction
   already applied and the fix then sized to cancel an offset that was not there.

   Ink offset from the disc's centre in CSS px, over the digits 1-9, re-measured
   2026-09-17 under General Sans at 11px:

       indent   padding          dx              dy
       1.25px   1px top     +0.31 … +0.63   +0.94 … +1.06   (was shipped)
       0        1px bottom  -0.31 … +0.06   -0.06 … +0.06   <- shipped

   Both corrections are whole moves with known magnitudes, which is what makes the
   table checkable: dropping the indent takes 0.625px off every digit (text-indent
   shifts a centred single glyph by HALF its value), and moving the pad from top to
   bottom lifts every digit by 1.0px. Fractional padding snaps to the same place,
   so 1px is the only step there is. "1" is the -0.31px end alone — its ink is a
   narrow stem inside a digit-width advance box, and only special-casing one glyph
   would move it. Re-measure if the badge changes size or weight.

   No tabular-nums here. It looks like the right tool and is not: it pads narrow
   digits out to the widest digit's width and does not distribute that padding
   evenly. Checked under General Sans: it moves the ink not at all. Counts are one
   or two digits in practice, so column alignment buys nothing anyway.

   letter-spacing is zeroed rather than inherited: trailing letter-space counts toward
   the inline box and pushes the glyph left by half of it. Nothing on the calendar
   path sets tracking today, but this badge is shared with the booking cards. */
.gantt-bar-badge--messages,
.booking-status-icon--messages {
    letter-spacing: 0;
    padding-bottom: 1px;
}

/* Documentation prose styles (replaces @tailwindcss/typography) */
.doc-prose h1 { font-size: 1.875rem; font-weight: 700; margin-top: 2rem; margin-bottom: 0.75rem; color: #111827; line-height: 1.3; }
.doc-prose h2 { font-size: 1.5rem; font-weight: 600; margin-top: 1.75rem; margin-bottom: 0.5rem; color: #1f2937; line-height: 1.35; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.4rem; }
.doc-prose h3 { font-size: 1.25rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; color: #374151; line-height: 1.4; }
.doc-prose h4 { font-size: 1.1rem; font-weight: 600; margin-top: 1.25rem; margin-bottom: 0.4rem; color: #374151; }
.doc-prose p { margin-top: 0.75rem; margin-bottom: 0.75rem; line-height: 1.7; color: #374151; }
.doc-prose ul { list-style-type: disc; padding-left: 1.5rem; margin-top: 0.5rem; margin-bottom: 0.75rem; }
.doc-prose ol { list-style-type: decimal; padding-left: 1.5rem; margin-top: 0.5rem; margin-bottom: 0.75rem; }
.doc-prose li { margin-top: 0.25rem; margin-bottom: 0.25rem; line-height: 1.6; color: #374151; }
.doc-prose li > ul, .doc-prose li > ol { margin-top: 0.25rem; margin-bottom: 0.25rem; }
.doc-prose a { color: var(--color-primary); text-decoration: underline; text-underline-offset: 2px; }
.doc-prose a:hover { opacity: 0.8; }
.doc-prose strong { font-weight: 600; color: #111827; }
.doc-prose em { font-style: italic; }
.doc-prose code { background: #f3f4f6; padding: 0.15rem 0.35rem; border-radius: 0.25rem; font-size: 0.875rem; color: #dc2626; }
.doc-prose pre { background: #1f2937; color: #e5e7eb; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-top: 1rem; margin-bottom: 1rem; font-size: 0.875rem; line-height: 1.6; }
.doc-prose pre code { background: none; padding: 0; color: inherit; font-size: inherit; }
.doc-prose blockquote { border-left: 3px solid var(--color-primary); padding-left: 1rem; margin-top: 1rem; margin-bottom: 1rem; color: #6b7280; font-style: italic; }
.doc-prose hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.5rem 0; }
.doc-prose table { width: 100%; border-collapse: collapse; margin-top: 1rem; margin-bottom: 1rem; font-size: 0.875rem; }
.doc-prose th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid #e5e7eb; font-weight: 600; color: #374151; }
.doc-prose td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f3f4f6; color: #4b5563; }

/* Occupancy Heatmap — Neobrutalist */
.heatmap-vacant { background-color: #f1f5f9; border: 1px solid #e2e8f0; } /* slate-100 with border */
.heatmap-partial { background-color: #fcd34d; border: 1px solid #f59e0b; } /* amber-300 with amber border */
.heatmap-full { background-color: #ef4444; border: 1px solid #dc2626; } /* red-500 with red border */

/* Badge pop animation — subtle scale bounce when count updates */
@keyframes badge-pop {
    0% { transform: scale(1); }
    30% { transform: scale(1.4); background-color: #22c55e; }
    60% { transform: scale(0.9); }
    100% { transform: scale(1); }
}

/* ============================================================
   Operations Command Center — Calendar Grid
   ============================================================ */

/* Base day cell — fixed 40px columns */
.calendar-cell {
    width: 40px;
    min-width: 40px;
    position: relative;
}

/* Booking bar base — absolutely positioned within the day cell */
.booking-bar {
    position: absolute;
    top: 2px;
    bottom: 2px;
    border-radius: 2px;
}

/* Half-day edge classes */
.booking-bar--full    { left: 0;   right: 0; }
.booking-bar--checkin { left: 50%; right: 0; }
.booking-bar--checkout { left: 0;  right: 50%; }
.booking-bar--single  { left: 50%; right: 50%; }

/* Provisional / unconfirmed booking */
.booking-bar--provisional {
    border: 2px dashed currentColor;
    background: transparent;
    opacity: 0.7;
}

/* Housekeeping heatmap — bottom border on day cells */
.calendar-cell[data-housekeeping="dirty"]       { border-bottom: 4px solid #ef4444; }
.calendar-cell[data-housekeeping="in_progress"] { border-bottom: 4px solid #eab308; }
.calendar-cell[data-housekeeping="inspected"]   { border-bottom: 4px solid #22c55e; }

/* Check-in state overlays */
.booking-bar[data-checkin-state="expected"]    { opacity: 0.5; }
.booking-bar[data-checkin-state="checked_in"]  { opacity: 1; }
.booking-bar[data-checkin-state="checked_out"] { filter: grayscale(1); opacity: 0.7; }

/* Hide scrollbar but keep scroll functionality */
.scrollbar-hide {
    -ms-overflow-style: none;
    scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
    display: none;
}

/* View Options toggle — hide layers on the enhanced calendar grid */
#calendar-grid.hide-financials .gantt-bar-badge--payment { display: none; }
#calendar-grid.hide-housekeeping .gantt-bar-badge--housekeeping { display: none; }
#calendar-grid.hide-operations .gantt-bar-badge--messages { display: none; }
#calendar-grid.hide-guest-info .gantt-bar-guest-name { opacity: 0; width: 0; overflow: hidden; }
#calendar-grid.flat-view [data-parent-path]:not([data-droppable]) { display: none; }
#calendar-grid.hide-rates .gantt-rate-label { display: none; }
.hide-operations .booking-bar[data-checkin-state] { opacity: 1; filter: none; }
.hide-guest-info .calendar-badge--comms           { display: none; }

/* Calendar drag-selection highlight */
.calendar-cell-selected {
    background-color: rgb(73 152 176 / 0.15) !important;
    outline: 2px solid rgb(73 152 176 / 0.5);
    outline-offset: -2px;
}

/* Heatmap highlight for suggested dates */
.heatmap-selected {
    outline: 2px solid rgb(73 152 176 / 0.8);
    outline-offset: -2px;
    background-color: rgb(73 152 176 / 0.2) !important;
}

/* Optimistic UI states */
[data-optimistic="saving"] {
    opacity: 0.6;
    pointer-events: none;
    cursor: wait;
}
[data-optimistic="error"] {
    outline: 2px solid rgb(239 68 68 / 0.8);
    outline-offset: 2px;
    animation: optimistic-shake 0.35s ease-in-out;
}
[data-optimistic="confirmed"] {
    outline: 2px solid rgb(34 197 94 / 0.6);
    outline-offset: 2px;
}
@keyframes optimistic-shake {
    0%, 100% { transform: translateX(0); }
    20%       { transform: translateX(-4px); }
    40%       { transform: translateX(4px); }
    60%       { transform: translateX(-3px); }
    80%       { transform: translateX(3px); }
}

/* ── Chat message entrance animation ── */
@keyframes chat-message-slide-in {
    0%   { opacity: 0; transform: translateY(8px); }
    100% { opacity: 1; transform: translateY(0); }
}

.chat-message-enter {
    animation: chat-message-slide-in 0.3s ease-out;
}

@media (prefers-reduced-motion: reduce) {
    .chat-message-enter {
        animation: none;
    }
}

/* ══════════════════════════════════════════════════════════════════════════
   Useful Tutorials — Just-in-Time contextual tutorial modal
   Plain CSS (app.css is not Tailwind-processed). References the @theme custom
   properties emitted to :root by tailwind.css (--color-primary, --color-ink,
   --shadow-brutalist, --shadow-brutalist-sm).
   ══════════════════════════════════════════════════════════════════════════ */

/* Overlay backdrop.
   NOTE: app.css loads after tailwind.css, so a bare `.tutorial-overlay { display:flex }`
   would override Tailwind's `.hidden` (display:none) at equal specificity and the
   overlay would never hide. The two-class `.tutorial-overlay.hidden` rule below wins
   on specificity so `hidden` still hides it. Same pattern for `.tutorial-body`. */
.tutorial-overlay {
    position: fixed;
    inset: 0;
    z-index: 100;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1rem;
    background: rgba(15, 23, 42, 0.35);
    backdrop-filter: blur(2px);
}

.tutorial-overlay.hidden {
    display: none;
}

/* Clean calm panel — rounded-xl, subtle shadow */
.tutorial-panel {
    position: relative;
    background: var(--color-surface, #ffffff);
    border: 1px solid var(--color-outline-variant, rgba(30, 41, 59, 0.12));
    box-shadow: 0 20px 60px -12px rgba(0, 0, 0, 0.15), 0 8px 20px -8px rgba(0, 0, 0, 0.08);
    border-radius: 1rem; /* rounded-xl */
    width: 100%;
    max-width: 48rem; /* max-w-3xl */
    overflow: hidden;
}

/* Close button — top-right, above the visual/content, 44px touch target */
.tutorial-close-btn {
    position: absolute;
    top: 0.75rem;
    right: 0.75rem;
    z-index: 10;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    border-radius: 0.5rem;
    border: none;
    background: transparent;
    color: var(--color-on-surface-variant, #475569);
    cursor: pointer;
    transition: color 150ms ease, background 150ms ease;
}

.tutorial-close-btn:hover {
    color: var(--color-on-surface, #1e293b);
    background: var(--color-surface-container, rgba(0, 0, 0, 0.04));
}

/* Desktop split layout: 35% visual / 65% content (≥768px) */
.tutorial-body {
    display: flex;
    flex-direction: column;
}

.tutorial-body.hidden {
    display: none;
}

.tutorial-visual {
    background: var(--color-surface-container-low, rgba(73, 152, 176, 0.04));
    display: flex;
    align-items: center;
    justify-content: center;
    max-height: 220px;
    overflow: hidden;
    padding: 2rem;
}

.tutorial-visual img,
.tutorial-visual video,
.tutorial-visual svg {
    width: 100%;
    height: 100%;
    object-fit: contain;
    max-height: 200px;
}

.tutorial-content-wrap {
    padding: 1.5rem 1.25rem;
    display: flex;
    flex-direction: column;
}

@media (min-width: 768px) {
    .tutorial-body {
        flex-direction: row;
        min-height: 340px;
    }
    .tutorial-visual {
        flex: 0 0 35%;
        max-width: 35%;
        max-height: none;
        padding: 2.5rem;
    }
    .tutorial-visual img,
    .tutorial-visual video,
    .tutorial-visual svg {
        max-height: none;
    }
    .tutorial-content-wrap {
        flex: 0 0 65%;
        max-width: 65%;
        padding: 2rem 2.5rem;
        justify-content: center;
    }
}

/* Clean navigation buttons */
.tutorial-nav {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    margin-top: 1.5rem;
}

.tutorial-nav-btn {
    min-height: 44px;
    padding: 0.5rem 1.25rem;
    border: 1px solid var(--color-outline, rgba(30, 41, 59, 0.15));
    box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
    background: var(--color-surface, #ffffff);
    font-weight: 500;
    font-size: 0.875rem;
    color: var(--color-on-surface, #1e293b);
    cursor: pointer;
    border-radius: 0.5rem;
    transition: background 150ms ease, border-color 150ms ease;
}

.tutorial-nav-btn:hover {
    background: var(--color-surface-container-low, #f8fafc);
    border-color: var(--color-outline, rgba(30, 41, 59, 0.2));
}

.tutorial-nav-btn:active {
    background: var(--color-surface-container, #f1f5f9);
}

/* Primary CTA */
.tutorial-cta-btn {
    position: relative;
    min-height: 44px;
    padding: 0.75rem 1.5rem;
    border: none;
    background: var(--color-primary, #2C6C80);
    /* The ink follows the fill here too (ADR-166, R4.4). This is the only fill in
       this stylesheet that hardcoded its own ink, and no guard could see it: the
       template scan reads .gohtml and the controller scan reads .js, so a fill
       authored in CSS is in neither. White on #4998B0 is 3.28:1 and misses AA. */
    color: var(--color-on-primary, #ffffff);
    font-weight: 600;
    font-size: 0.875rem;
    cursor: pointer;
    border-radius: 0.5rem;
    box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
    transition: background 150ms ease, opacity 150ms ease;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
}

.tutorial-cta-btn:hover {
    opacity: 0.9;
}

.tutorial-cta-btn:active {
    opacity: 0.85;
}

/* Secondary CTA (for the Ask AI button) */
.tutorial-cta-btn-secondary {
    position: relative;
    min-height: 44px;
    padding: 0.75rem 1.5rem;
    border: 1px solid var(--color-outline, rgba(30, 41, 59, 0.15));
    background: var(--color-surface, #ffffff);
    color: var(--color-on-surface, #1e293b);
    font-weight: 600;
    font-size: 0.875rem;
    cursor: pointer;
    border-radius: 0.5rem;
    box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
    transition: background 150ms ease, border-color 150ms ease;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
}

.tutorial-cta-btn-secondary:hover {
    background: var(--color-surface-container-low, #f8fafc);
    border-color: var(--color-outline, rgba(30, 41, 59, 0.2));
}

/* Step indicator dots */
.tutorial-step-indicator {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    margin-top: 1rem;
}

@media (prefers-reduced-motion: reduce) {
    .tutorial-panel,
    .tutorial-nav-btn,
    .tutorial-cta-btn,
    .tutorial-cta-btn-secondary {
        transition: none;
    }
}

/* ═══════════════════════════════════════════════════════════════════════════
   Sticky shrinking chrome
   ---------------------------------------------------------------------------
   Two coordinated layers driven by the structure/header_scroll controller:

     .kito-header        → the app header. Full-bleed sticky bar; compacts to a
                           thin strip (is-compact) once the page is scrolled.
     .kito-page-header   → the unified page header (breadcrumb + title). Pins
                           just beneath the compacted app header (is-stuck) and
                           collapses its secondary content for a slim strip.

   The controller only toggles the state classes; every transition is expressed
   here so the motion is smooth, subtle and premium. --kito-header-compact-h is
   the single source of truth for the compacted height / pin offset — shared with
   the JS so the two never drift.

   Z-INDEX SCALE (the app header sits below modal overlays so modals cover
   the entire viewport — standard overlay behavior):
      page content / sub-navs ...... 0 – 40   (cards, sticky calendar/table headers)
      pinned page header ........... 43       (.kito-page-header.is-stuck / parent row)
      page-level gates ............. 44       (billing lock, setup, check-in gate backdrops:
                                              below the header so its menu stays usable)
      app header ................... 45       (.kito-header — above content, below modals)
      top-region overlays .......... 48       (toasts, quota flash, social notifications)
      modals / nested modals ....... 50 – 70  (page + feedback modals)
      nav menu backdrop / modal .... 250 / 300
      critical overlays ............ 9999     (progress bar, auth expiry, etc.)
   Rule of thumb: the header is 45 so it stays above page content (<=40) but below
   modal overlays (50+), so modals cover everything including the header bar. */
:root {
    --kito-header-compact-h: 3.75rem; /* 60px */
    --kito-page-header-stuck-h: 3rem; /* 48px — 0.75rem padding-top + 1.5rem title + 0.75rem padding-bottom */
    --kito-sticky-offset: calc(var(--kito-header-compact-h) + var(--kito-page-header-stuck-h) + 2.5rem); /* header + breadcrumb strip + breathing room */
    --kito-ease: cubic-bezier(0.4, 0, 0.2, 1);
}

/* ── App header ──────────────────────────────────────────────────────────── */
.kito-header {
    /* Full-bleed: the bar's background + border span the whole viewport, even
       though the app body is capped at max-w-screen-xl and centred. The inner
       `container mx-auto` still centres the content in the viewport, so the logo/
       menu line up exactly with the page content (which centres inside the equally
       centred body). Root `overflow-x: clip` (below) stops the 100vw creating a
       horizontal scrollbar without breaking the sticky positioning. */
    width: 100vw;
    margin-left: calc(50% - 50vw);
    margin-right: calc(50% - 50vw);
    /* Disable scroll anchoring on the header. When the header compacts (height
       change via is-compact), Chrome's scroll anchoring can adjust scrollY to
       "compensate" for the layout shift, pushing scrollY back below the compact
       threshold and creating an infinite compact/expand oscillation. */
    overflow-anchor: none;
    /* The stacking lift lives on the sticky <section> (z-[45]) — NOT here. Giving the
       header its own z-index/stacking context is unnecessary and would only scope it
       inside the section again. See the z-index scale block above. */
    transition: background-color 300ms var(--kito-ease);
}
.kito-header-bar {
    transition: padding-top 300ms var(--kito-ease),
                padding-bottom 300ms var(--kito-ease);
}
.kito-header-logo {
    height: 4rem; /* h-16 */
    width: auto;
    transition: height 300ms var(--kito-ease);
}
img.kito-header-logo { object-fit: contain; }
.kito-header-logo > svg { height: 100%; width: auto; }
/* Docs, account avatar and menu buttons share one identical box (48px → 44px
   compact). Their icons keep their own template sizes (docs size-6, menu size-10)
   and are NOT resized on compaction — only the button box eases down, so the menu
   stripes never distort.
   The avatar is the exception: it is a filled circle rather than a line icon, so a
   box that shrinks around a fixed 40px disc would crop it. It eases down with its
   box (see .kito-header-avatar below). */
.kito-header-action {
    height: 3rem;      /* 48px */
    width: 3rem;
    min-height: 3rem;
    padding: 0;
    transition: height 300ms var(--kito-ease),
                width 300ms var(--kito-ease),
                min-height 300ms var(--kito-ease);
}
/* The account avatar inside a .kito-header-action box. 40px at rest inside the
   48px box, easing to 36px when the box compacts to 44px — the same 4px of
   breathing room on each side in both states, so the circle never touches the
   ring the button draws on hover. */
.kito-header-avatar {
    /* No `display` here on purpose: the three tiers of header_profile_avatar set
       their own (the monogram and glyph are flex containers, the photo is a plain
       box), and they are all flex ITEMS of the button, so width/height apply
       regardless. `flex: none` stops the disc being squeezed oval if a longer
       monogram ever pushes against the box. */
    flex: none;
    width: 2.5rem;   /* 40px */
    height: 2.5rem;
    transition: width 300ms var(--kito-ease),
                height 300ms var(--kito-ease);
}

/* Compacted app header — added once the page scrolls past the threshold. */
.kito-header.is-compact {
    box-shadow: none;
}
.kito-header.is-compact .kito-header-bar {
    padding-top: 0.5rem;
    padding-bottom: 0.5rem;
    min-height: var(--kito-header-compact-h);
}
.kito-header.is-compact .kito-header-logo {
    height: 2.75rem; /* 44px — still clearly legible */
}
/* Compact: all three buttons shrink their box in lockstep, staying square and
   equal and comfortably tappable (44px). Line icons are unchanged (see above);
   the avatar disc eases down with its box so it keeps its inset. */
.kito-header.is-compact .kito-header-action {
    height: 2.75rem;   /* 44px */
    width: 2.75rem;
    min-height: 2.75rem;
}
.kito-header.is-compact .kito-header-avatar {
    width: 2.25rem;   /* 36px */
    height: 2.25rem;
}

/* ── AI Assistant panel top offset ───────────────────────────────────────── */
/* The panel must sit flush below the app header in both states. At rest the
   header is ~6rem (py-4 + h-16 logo); compacted it is --kito-header-compact-h.
   The transition matches the header's own 300ms ease so they move together. */
.kito-ai-panel {
    top: 6rem;
    transition: top 300ms var(--kito-ease), width 200ms ease;
}
/* Mobile: full-width panel covering the screen below the header */
@media (max-width: 1023px) {
    .kito-ai-panel {
        width: 100% !important;
        top: 0;
        height: 100dvh;
    }
}
body:has(.kito-header.is-compact) .kito-ai-panel {
    top: var(--kito-header-compact-h);
}
/* When the panel is open, the page content shrinks to make room so the panel
   sits beside the content rather than overlaying it. The body class is toggled
   by the ai-assistant controller on open/close/expand. The padding goes on
   .page-container because the content uses max-w-screen-xl which ignores body
   margin, but responds to its own container's available width.
   Only applies on lg+ (≥1024px) — on smaller screens the panel overlays. */
@media (min-width: 1024px) {
    body.kito-ai-panel-open .page-container {
        padding-right: 24rem;
        transition: padding-right 200ms ease;
    }
    body.kito-ai-panel-expanded .page-container {
        padding-right: calc(100vw - var(--kito-rail-w));
        transition: padding-right 200ms ease;
    }
    .page-container {
        transition: padding-right 200ms ease;
    }
    /* Suppress the transition during page navigation so the layout doesn't
       animate from full-width → narrowed when the panel is already open.
       The controller adds this class before applying state and removes it
       after the first paint. */
    body.kito-ai-panel-no-transition .page-container,
    body.kito-ai-panel-no-transition.kito-ai-panel-open .page-container,
    body.kito-ai-panel-no-transition.kito-ai-panel-expanded .page-container {
        transition: none;
    }
}

/* ── Unified page header (breadcrumb + title) ────────────────────────────── */
.kito-page-header {
    overflow-anchor: none;
    transition: padding-top 300ms var(--kito-ease),
                padding-bottom 300ms var(--kito-ease);
}
.kito-page-header-title {
    transform-origin: left center;
    transition: font-size 300ms var(--kito-ease),
                line-height 300ms var(--kito-ease),
                margin-top 300ms var(--kito-ease);
}
/* The context row (metadata + status pills) and the subtitle collapse away when the
   header pins, leaving a strip of breadcrumb + compacted title. */
.kito-page-header-secondary {
    /* `clip`, NOT `hidden`. Both clip at the padding box, but `hidden` also makes
       this a scroll container, and something in here is always taller than the box:
       the unit header's assignee pill is `min-h-[44px] -my-2`, so its touch target
       paints 44px while occupying 28px of flow and overhangs by 8px. Focusing it —
       the click that opens the assignee picker — scrolls it into view, and with no
       scrollbar and nothing resetting scrollTop the metadata line above kept the top
       6px of its text sheared off until the page reloaded (#1181). `clip` cannot
       scroll, so there is nothing to put back. */
    overflow: clip;
    /* Clip cuts at the padding box, so this gives the tallest pill's border and
       focus ring room while the negative margin preserves the layout (#1609). */
    padding-bottom: 0.25rem;
    margin-bottom: -0.25rem;
    max-height: 8rem;
    opacity: 1;
    transition: max-height 320ms var(--kito-ease),
                opacity 200ms ease,
                margin-top 300ms var(--kito-ease),
                padding-bottom 300ms var(--kito-ease),
                margin-bottom 300ms var(--kito-ease);
}

/* Pinned state — a slim frosted strip beneath the compacted app header.
   Positioning (position/top/left/width) is applied inline by the header_scroll
   controller: `position: fixed` frees the strip from its (often short) wrapper so
   it can travel the full page, and JS keeps it aligned to the content column across
   breakpoints. A synced placeholder holds its space in flow. The NAV itself stays
   the width of the content column (so the breadcrumb, title and feedback button
   line up), and the full-bleed frosted background is painted by ::before.

   Two selector forms handle which element is actually pinned:
     .kito-page-header.is-stuck          → nav is the pin target (no CTA siblings)
     .is-stuck:has(> .kito-page-header)  → parent flex row is the pin target (CTA
                                           button sits beside the nav and must travel
                                           with the frosted strip so it isn't buried
                                           beneath the backdrop). */
.kito-page-header.is-stuck,
.is-stuck:has(> .kito-page-header) {
    /* Below the rail (44) and the app header (45): its frosted background is
       full-bleed and would otherwise band straight across the rail. */
    z-index: 43;
    /* Symmetric vertical padding around the breadcrumb + title strip. */
    padding-top: 0.75rem;
    padding-bottom: 0.75rem;
    /* Smooth top (tracks header compaction), left/width (nav rail toggle). The
       strip follows the app header as it shrinks, avoiding a snap. */
    transition: top 300ms var(--kito-ease), left 200ms var(--kito-ease), width 200ms var(--kito-ease);
}
.kito-page-header.is-stuck::before,
.is-stuck:has(> .kito-page-header)::before {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    /* Extend from the content column out to both viewport edges (vars set by JS). */
    left: calc(-1 * var(--kito-strip-left, 0px));
    right: calc(-1 * var(--kito-strip-right, 0px));
    z-index: -1; /* behind the breadcrumb/title content, above the page */
    background-color: rgba(255, 255, 255, 0.85);
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);
    border-bottom: 1px solid rgb(226 232 240); /* slate-200 */
}
/* Was `.dark ...` — but this app themes via [data-theme="dark"], not a `.dark`
   class, so the override never applied and the pinned breadcrumb strip flashed
   white in dark mode. Re-scoped + mapped onto the semantic panel surface. */
[data-theme="dark"] .kito-page-header.is-stuck::before,
[data-theme="dark"] .is-stuck:has(> .kito-page-header)::before {
    background-color: color-mix(in srgb, var(--color-surface) 85%, transparent);
    border-bottom-color: var(--color-outline-variant);
}
/* Placeholder that reserves the pinned strip's space in normal flow. */
.kito-page-header-placeholder { flex: none; }
/* When the nav lives inside a stuck parent row, the row owns the positioning,
   sizing and padding; strip them from the nav so it behaves as a normal flex
   child of the pinned strip. Without this, the rail-specific !important
   width/left leak onto the nav and break the flex layout (pushing the CTA
   off-screen and doubling the left inset). */
.is-stuck:has(> .kito-page-header) > .kito-page-header.is-stuck {
    position: static !important;
    left: auto !important;
    width: auto !important;
    padding-top: 0;
    padding-bottom: 0;
    padding-left: 0;
    z-index: auto;
}
/* The parent row already paints the frosted ::before; suppress the duplicate on
   the inner nav to avoid a double border and doubled blur. */
.is-stuck:has(> .kito-page-header) > .kito-page-header.is-stuck::before {
    content: none;
}
.kito-page-header.is-stuck .kito-page-header-title {
    font-size: 1.125rem; /* text-lg */
    line-height: 1.5rem;
    margin-top: 0;
}
/* The context row — the unit's metadata and its status pills on one line — lives
   INSIDE .kito-page-header-secondary and collapses with it, so the pinned strip is
   breadcrumb + compacted title and nothing else. The pills used to be held out of
   this block so they survived pinning; they did, and that was the bug — anything
   riding the pinned strip reads as part of the breadcrumb no matter where inside it
   sits. State belongs to the page and scrolls away with it. */
.kito-page-header.is-stuck .kito-page-header-secondary {
    max-height: 0;
    opacity: 0;
    padding-bottom: 0;
    margin-bottom: 0;
    /* The secondary stays a flex child while collapsed, so the parent's gap-1 would
       otherwise leave a 4px phantom gap BELOW the title — making the pinned strip
       look bottom-heavy. Pull it up by the gap so the strip wraps breadcrumb+title
       symmetrically against the 0.5rem top/bottom padding. */
    margin-top: -0.25rem;
}

/* ── Sidebar sticky offset ───────────────────────────────────────────────── */
/* All scroll-spy sidebars use this so they clear both the compacted app header
   AND the pinned breadcrumb strip. Single source of truth: --kito-sticky-offset. */
.kito-sidebar-sticky {
    position: sticky;
    top: var(--kito-sticky-offset);
}
/* Scroll-to targets (section cards) clear the combined sticky chrome when
   navigated via anchor links or scrollIntoView({ block: "start" }).

   [data-section-anchor] carries the same offset because the offset is a property
   of BEING a scroll target, not of having a scroll-spy sidebar. Pages migrated off
   the sidebar (show-page-tab-navigation R6) keep data-section-anchor and are still
   deep-linked into — by workspace-tabs' ANCHOR_MAP, among others — so hanging the
   rule solely off the scroll-spy attribute would drop those cards under the sticky
   header the moment their sidebar was removed. */
[data-scroll-spy-target="section"],
[data-section-anchor] {
    scroll-margin-top: var(--kito-sticky-offset);
}

/* ═══════════════════════════════════════════════════════════════════════════
   Desktop navigation rail (>= 1024px)          templates/structure/nav_rail.gohtml
   ---------------------------------------------------------------------------
   Three-region shell, as opposed to the single centred column used below lg:

        ┌──────────────────────────────────────────────┐
        │ .kito-header   full-bleed, sticky            │
        ├────────┬─────────────────────────────────────┤
        │ .kito- │  page content                       │
        │ rail   │  (.kito-page-header lives HERE, so  │
        │ fixed  │   the breadcrumb belongs to the     │
        │        │   content column and keeps pinning) │
        └────────┴─────────────────────────────────────┘

   The rail is `position: fixed` rather than a flex/grid sibling because the app
   has no single content wrapper — every page template opens its own
   `container mx-auto / max-w-screen-xl` section. Reserving the rail's space with
   `padding-left` on <body> therefore offsets all ~90 of them at once, with no
   page template touched.

   `body:has(.kito-rail)` scopes the whole shell change to pages that actually
   render a rail; chrome-less pages (workspace picker, admin console, public
   pages) keep the centred body untouched with no extra class plumbing.

   Width is one custom property, --kito-rail-w, flipped by [data-nav-rail] on
   <html>. Everything (rail width, body offset, header pull-back) reads that one
   value, so expanded/collapsed can never drift apart. */
:root {
    --kito-rail-w-expanded: 15rem;   /* 240px — label column, no wider than needed */
    --kito-rail-w-collapsed: 4.25rem; /* 68px — icon column */
    --kito-rail-w: var(--kito-rail-w-expanded);
    /* Where the rail's FIRST ITEM starts — the uncompacted app header's height.
       A constant, deliberately: the rail used to track the header's live bottom
       edge from JS, which made it slide up and down (and briefly overlap) every
       time the header compacted or expanded on scroll. The rail box itself now
       starts at the top of the viewport and simply pads this much out of the
       way, so its surface runs behind the header and nothing moves on scroll. */
    --kito-rail-top: 6rem;
}
html[data-nav-rail="collapsed"] {
    --kito-rail-w: var(--kito-rail-w-collapsed);
}

.kito-rail {
    display: none; /* below lg the hamburger drawer is the only navigation */
}

/* The drawer's count pills carry the same optical skew as the rail's — a digit
   stands on the baseline, so flex centring leaves the font's whole descent as
   dead space beneath it and the number rides high. Measured in Chrome at
   12px/700 inside the h-6 pill: 5.35px above the ink, 5.88px below. Half the
   correction the rail needs (its pill is smaller and its line-height rounds the
   other way), and it lands the skew at 0.04px.

   It lives here, not in the class string, because that string is duplicated
   across five call sites — nav_workspace_menu.gohtml, navigation.gohtml, and
   three Turbo Stream literals in badge_turbo_streams.go — and only the two
   templates are scanned by the Tailwind build. The selectors survive a stream
   replace: the badge keeps its id and its parent. */
#hamburger-badge,
[data-nav-group-target="item"] > [id$="-badge"] {
    padding-top: 0.5px;
}

/* The drawer's combined group badge, hidden while the group is expanded — the
   exact counterpart of `.kito-rail-group.is-open > .kito-rail-grouphead
   .kito-rail-badge` further down, for the surface below lg.

   Declarative on purpose: it states "expanded group ⇒ no sum on the header" as
   an invariant of the document, rather than as something every code path that
   touches this markup has to remember. The badge element IS re-rendered
   underneath the class — navigation_controller#refreshCounts reloads
   navigation-modal-frame on every chat/badge event, and the drawer comes back
   freshly server-rendered with no `hidden` on any badge, taking
   nav_group_controller down and back up with it. Keyed off an ANCESTOR, this
   rule cannot be lost in that swap; a class can, and is.

   No Turbo Stream is addressed to this element — the group streams were taken
   out of the customer-wide broadcast because the sum is permission-gated and a
   broadcast has no recipient to gate against (badge_turbo_streams.go). If one
   ever were, this rule covers that too, and for a reason JS cannot: Turbo's
   StreamElement.render() awaits nextRepaint() BEFORE it renders, so a
   turbo:before-stream-render listener's requestAnimationFrame is registered —
   and therefore fires — one frame ahead of the swap it is meant to follow.

   nav_group_controller#_syncGroupBadge still toggles `hidden` on this element:
   that class is the controller's record of user-driven open/close state, and
   what tests and E2E assertions read. The hiding itself is this rule's job.
   Both are load-bearing — see the note above _syncGroupBadge before deleting
   either.

   `[data-nav-group-target="content"]` is what scopes this to the drawer: the
   rail's group container carries data-group-id too, but its children are
   data-nav-rail-target and its badge ids end in -rail / -rail-tip.

   Deliberately OUTSIDE the @media (min-width: 1024px) block below: the drawer
   is the below-lg navigation, so scoping this to the rail's breakpoint would
   switch it off at exactly the widths that need it. */
div[data-group-id]:has([data-nav-group-target="content"][data-open="true"]) [id$="-group-badge"] {
    display: none;
}

@media (min-width: 1024px) {
    /* ── Shell ───────────────────────────────────────────────────────────── */
    /* The app is LEFT-ALIGNED against the rail rather than centred in the
       viewport. Centring the (capped) content column in the space left over by
       the rail means collapsing the rail buys nothing: the 172px it gives back
       is split evenly onto both sides as dead air, so the content never moves
       closer to the nav. Capping the body at rail + content instead makes the
       column start immediately after the rail at both widths, and collapsing
       actually reclaims space. */
    body:has(.kito-rail) {
        margin-left: 0;
        margin-right: 0;
        padding-left: var(--kito-rail-w);
        transition: padding-left 200ms var(--kito-ease);
    }
    /* The header spans the full viewport, above the rail. Its usual full-bleed
       trick centres on a centred body; with the body left-aligned and padded we
       simply pull it back by the rail width instead. */
    body:has(.kito-rail) .kito-header {
        width: 100vw;
        margin-left: calc(-1 * var(--kito-rail-w));
        margin-right: 0;
        transition: margin-left 200ms var(--kito-ease),
                    background-color 300ms var(--kito-ease);
    }
    /* …and its inner column stops being a centred, capped column at all: with a
       rail on screen the header is app chrome, not a page element, so it spans
       the viewport edge to edge — logo hard left, docs link hard right. The
       18px inset matches the rail's icon column (0.5rem rail padding + 0.625rem
       item padding), so the logo sits on the same vertical line as the nav icons
       and does not move when the rail collapses. */
    body:has(.kito-rail) .kito-header .container,
    body:has(.kito-rail) .kito-header .container > div {
        max-width: none;
        margin-left: 0;
        margin-right: 0;
    }
    body:has(.kito-rail) .kito-header .container > div {
        padding-left: 1.125rem;
        padding-right: 1.125rem;
    }

    /* Page content containers: with the body left-aligned and the rail reserving
       padding-left, inner .container elements should fill the available width
       without re-centring. A small right inset mirrors the left px-4 gutter so
       content never touches the viewport edge. */
    body:has(.kito-rail) .container {
        max-width: none;
        margin-inline: 0;
        padding-right: 1rem;
    }
    body:has(.kito-rail) .container > [class*="max-w-screen"] {
        max-width: none;
        margin-inline: 0;
    }

    /* Scroll-spy grids: use a fixed-width sidebar + fluid content column so
       section cards fill ALL remaining width rather than a rigid 75% (3/4). Pages
       that use the shared partial get this from the template; pages that inline
       the grid get it from this override.

       The template classes are `lg:grid-cols-4` + `lg:col-span-1` / `lg:col-span-3`,
       which assume 4 tracks. Overriding to 2 tracks without resetting the spans
       causes the content (span 3) to wrap below the sidebar because CSS grid
       cannot fit 3 spans into 1 remaining column. The companion rules below pin
       the aside to column 1 and the content to column 2. */
    body:has(.kito-rail) [data-controller~="scroll-spy"].grid {
        grid-template-columns: fit-content(15rem) 1fr;
    }
    body:has(.kito-rail) [data-controller~="scroll-spy"].grid > aside {
        grid-column: 1;
    }
    body:has(.kito-rail) [data-controller~="scroll-spy"].grid > aside + * {
        grid-column: 2;
    }

    /* Pinned breadcrumb strip: override the JS-set inline left/width with
       CSS-variable-driven values so the strip transitions in sync with the body's
       padding-left when the rail toggles. !important is required to beat inline
       styles set by header_scroll_controller.applyColumnGeometry. */
    body:has(.kito-rail) .kito-page-header.is-stuck,
    body:has(.kito-rail) .is-stuck:has(> .kito-page-header) {
        left: var(--kito-rail-w) !important;
        width: calc(100vw - var(--kito-rail-w)) !important;
        padding-left: 1rem;
        transition: left 200ms var(--kito-ease), width 200ms var(--kito-ease);
    }
    /* The frosted ::before background spans the full viewport regardless of the
       strip's position — from the left edge of the rail to the right viewport edge. */
    body:has(.kito-rail) .kito-page-header.is-stuck::before,
    body:has(.kito-rail) .is-stuck:has(> .kito-page-header)::before {
        left: calc(-1 * var(--kito-rail-w));
        right: 0;
        transition: left 200ms var(--kito-ease);
    }

    /* ── Rail ────────────────────────────────────────────────────────────── */
    .kito-rail {
        position: fixed;
        /* Runs the full height of the viewport and passes BEHIND the app header
           (which is z-45 on its sticky section): the header is app chrome and
           owns the top edge. The items are held clear of it by padding, not by
           moving the box, so a compacting header never drags the rail with it. */
        top: 0;
        left: 0;
        bottom: 0;
        z-index: 44; /* under the app header (45), over the pinned strip (43) */
        display: flex;
        flex-direction: column;
        width: var(--kito-rail-w);
        padding: calc(var(--kito-rail-top) + 0.5rem) 0.5rem 0.75rem;
        background-color: var(--color-surface);
        border-right: 1px solid var(--color-outline-variant);
        transition: width 200ms var(--kito-ease);
        /* visible so collapsed-mode flyouts can escape the rail */
        overflow: visible;
    }

    /* The toggle's glyph sits on the same vertical line as every item icon below
       it, in BOTH modes — the eye reads a broken icon column instantly. Expanded:
       button is 2.375rem wide, left-aligned, so its centred 1.125rem glyph lands
       at rail-padding + 0.625rem, exactly where an item's icon starts (change one
       of the three and the column bends). Collapsed:
       items centre themselves across the rail, so the head centres too. */
    .kito-rail-head {
        display: flex;
        align-items: center;
        justify-content: flex-start;
        padding: 0.25rem 0 0.5rem;
    }
    html[data-nav-rail="collapsed"] .kito-rail-head {
        justify-content: center;
    }
    .kito-rail-toggle {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 2.375rem;
        height: 2.125rem;
        padding: 0;
        border-radius: 0.5rem;
        color: var(--color-on-surface-variant);
        transition: background-color 150ms var(--kito-ease), color 150ms var(--kito-ease);
    }
    .kito-rail-toggle:hover {
        background-color: var(--color-surface-container);
        color: var(--color-on-surface);
    }
    .kito-rail-toggle > svg { width: 1.125rem; height: 1.125rem; }

    .kito-rail-nav {
        flex: 1 1 auto;
        min-height: 0;
        overflow-y: auto;
        overflow-x: hidden;
        display: flex;
        flex-direction: column;
        gap: 0.125rem;
        scrollbar-width: thin;
    }
    /* Collapsed: the list is short (icons only) and flyouts must escape, so the
       scroll container is dropped rather than clipping them. */
    html[data-nav-rail="collapsed"] .kito-rail-nav {
        overflow: visible;
    }

    .kito-rail-foot {
        flex: none;
        display: flex;
        flex-direction: column;
        gap: 0.25rem;
        padding-top: 0.5rem;
        margin-top: 0.5rem;
        border-top: 1px solid var(--color-outline-variant);
    }
    .kito-rail-divider {
        height: 1px;
        margin: 0.5rem 0.25rem;
        background-color: var(--color-outline-variant);
    }

    /* ── Item ────────────────────────────────────────────────────────────── */
    .kito-rail-item {
        position: relative;
        display: flex;
        align-items: center;
        /* Tight enough that the icon reads as part of the label rather than a
           column of its own. */
        gap: 0.5rem;
        width: 100%;
        /* 36px: dense enough for a tool, still a comfortable pointer target.
           The 44px minimum is a touch rule and this rail is pointer-only. */
        min-height: 2.25rem;
        padding: 0.3125rem 0.625rem;
        border-radius: 0.5rem;
        font-size: 0.8125rem;
        font-weight: 500;
        color: var(--color-on-surface-variant);
        text-align: left;
        white-space: nowrap;
        transition: background-color 150ms var(--kito-ease), color 150ms var(--kito-ease);
    }
    .kito-rail-item:hover {
        background-color: var(--color-surface-container);
        color: var(--color-on-surface);
    }
    /* Brand, not system: an active rail item is an active menu indicator (ADR-166,
       R4.2). The tint takes --color-brand and the label takes --color-brand-text,
       because the label is text drawn IN the brand colour on the page surface rather
       than on a brand fill, and a 12% tint is still effectively the page surface. */
    .kito-rail-item.is-active {
        background-color: color-mix(in srgb, var(--color-brand) 12%, transparent);
        color: var(--color-brand-text);
        font-weight: 600;
    }
    /* 18px. The shared nav_icon glyphs are authored at `size-5` (20px) for the
       mobile drawer, which is a touch surface; the rail is pointer-only and
       reads better a notch denser, so it pins its own size here rather than
       shrinking the drawer's icons with it. */
    .kito-rail-icon {
        flex: none;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 1.125rem;
        height: 1.125rem;
    }
    .kito-rail-icon > svg { width: 1.125rem; height: 1.125rem; }
    .kito-rail-label {
        flex: 1 1 auto;
        overflow: hidden;
        text-overflow: ellipsis;
    }
    .kito-rail-badge {
        flex: none;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-width: 1.125rem;
        height: 1.125rem;
        /* The bottom 1px is optical centring, not spacing — see below. */
        padding: 0 0.25rem 1px;
        border-radius: 9999px;
        background-color: rgb(239 68 68); /* red-500 */
        color: #fff;
        font-size: 0.6875rem;
        /* 500, not 700. At 11px, white on red-500, a bold digit's counters close
           up and the strokes bloom into each other — 6 and 8 stop being tellable
           apart at a glance, which is the one job this element has. */
        font-weight: 500;
        /* Flex centring centres the font's CONTENT AREA (ascent + descent), and a
           digit is not centred inside that box: it stands on the baseline and
           leaves the whole descent empty below. line-height 1 drops the
           half-leading rounding on top of that. What is left is a sub-pixel
           offset that has to be measured, and the two corrections below are it.

           MEASURE IT OFF THE RASTER. Both nudges were previously derived from a
           model of the line box, and both came out with the SIGN INVERTED: the
           1px pad was on top, pushing a digit that was already 1.0px low down to
           1.5px, and a text-indent of 1.25px pushed a digit that was already
           0.63px right out to 1.25px. The way to check is to screenshot the pill
           at 8x, find the red disc and the white ink in that one image, and
           subtract the two centres. No line-box arithmetic survives contact with
           a real font's metrics.

           Under General Sans at 11px, measured that way over the digits 1-9 and
           "12", ink offset from the disc centre in CSS px:

               padding      indent        dx              dy
               1px top      1.25px   +0.31 … +0.69   +0.94 … +1.06   (was shipped)
               1px bottom   0        -0.31 … +0.06   -0.06 … +0.06   <- shipped

           Dropping the indent moves every digit left by 0.625px and moving the
           pad from top to bottom lifts every one by 1.0px, which is why the two
           rows are the same spread in two places. 2-9 and "12" now land within
           0.06px of centre — the font's own side bearings, well under what an eye
           resolves here. "1" is the -0.31px end on its own: its ink is a narrow
           stem sitting left inside a digit-width advance box, and nothing short
           of special-casing one glyph moves it. Re-measure if the size or the
           weight changes. */
        line-height: 1;
        /* Zeroed rather than inherited: trailing letter-space counts toward the
           inline box and drags the glyph left by half of it, which would undo the
           horizontal centring above. Nothing sets tracking on the rail today. */
        letter-spacing: 0;
    }
    .kito-rail-chevron {
        flex: none;
        width: 0.875rem;
        height: 0.875rem;
        color: var(--color-on-surface-variant);
        transition: transform 200ms var(--kito-ease);
    }
    .kito-rail-group.is-open > .kito-rail-grouphead .kito-rail-chevron {
        transform: rotate(180deg);
    }
    /* A collapsed group whose active page lives inside it still has to read as
       "you are here" — the parent icon carries the state for its children. */
    .kito-rail-group.has-active > .kito-rail-grouphead .kito-rail-icon {
        color: var(--color-brand-text);
    }
    /* The group head carries the SUM of its children's counts (nav_rail.gohtml,
       navigation_items.go). Open, every child badge is on screen with its own
       number, so the sum is redundant — the total and its parts side by side is
       noise, and the eye has to work out which is which. The element only hides,
       it is never removed: navigation_controller#recomputeGroupBadges keeps
       writing the current total into it the whole time the group is open (it
       recomputes off the child badges, which do stream), so closing the group
       shows a current count rather than a stale one. Expanded rail only in
       practice — collapsed, is-open is still on the group but the corner dot
       below covers the same ground. */
    .kito-rail-group.is-open > .kito-rail-grouphead .kito-rail-badge {
        display: none;
    }
    /* Zero count still renders the badge span — it is the element the client's
       recompute writes into, and one that only existed at a non-zero count could
       never come back — and an empty flex item takes the row gap all the same,
       which would push the chevron 0.5rem inward on every tenant with nothing
       pending, i.e. the common case. Same reasoning as `.kito-rail-tip >
       span:empty` further down. Scoped to the group head: the flat rows put their
       badge last, where the gap costs nothing. The recompute fills the span, so it
       stops matching :empty. */
    .kito-rail-grouphead > span:empty {
        display: none;
    }

    /* ── Expandable group ────────────────────────────────────────────────── */
    .kito-rail-group { position: relative; }
    /* grid-template-rows 0fr→1fr animates to the content's natural height with
       no JS measuring (the drawer's nav-group controller still does it in JS). */
    .kito-rail-sub {
        display: grid;
        grid-template-rows: 0fr;
        transition: grid-template-rows 200ms var(--kito-ease);
    }
    .kito-rail-group.is-open > .kito-rail-sub { grid-template-rows: 1fr; }
    /* Arrival is not an interaction. The rail is re-rendered on every Turbo Drive
       visit, and the nav-rail controller restores this browser's persisted groups
       one paint later — which without this played the expand animation on every
       single navigation. The controller carries `is-booting` for exactly that
       window (see nav_rail_controller.js § connect). The chevron is included: it
       would otherwise spin on arrival for the same reason. */
    .kito-rail.is-booting .kito-rail-sub,
    .kito-rail.is-booting .kito-rail-chevron {
        transition: none;
    }
    .kito-rail-sub-inner {
        overflow: hidden;
        display: flex;
        flex-direction: column;
        gap: 0.125rem;
        margin-left: 1.25rem;
        padding-left: 0.5rem;
        border-left: 1px solid var(--color-outline-variant);
    }
    .kito-rail-sub-inner > .kito-rail-item.is-child { min-height: 2rem; }
    /* The group name repeats at the top of the collapsed flyout, where the rail
       itself no longer shows it. Hidden in the expanded rail. */
    .kito-rail-subhead { display: none; }

    /* ── Collapsed mode ──────────────────────────────────────────────────── */
    html[data-nav-rail="collapsed"] .kito-rail-label,
    html[data-nav-rail="collapsed"] .kito-rail-chevron {
        display: none;
    }
    html[data-nav-rail="collapsed"] .kito-rail-item {
        justify-content: center;
        padding-left: 0;
        padding-right: 0;
        /* A badged item ALWAYS carries its badge element, empty at zero, because
           it is a Turbo Stream replace target. That empty <span> is still a flex
           item, so the row gap would sit between the icon and nothing and shove
           the icon off centre — which is what made Team Chat sit left of the
           other icons. Collapsed rows are icon-only: no gap to give. */
        gap: 0;
    }
    /* Badges have no room beside a centred icon, so they become a corner dot. */
    html[data-nav-rail="collapsed"] .kito-rail-badge {
        position: absolute;
        top: 0.25rem;
        right: 0.5rem;
        min-width: 0.5rem;
        width: 0.5rem;
        height: 0.5rem;
        padding: 0;
        font-size: 0;
        overflow: hidden;
    }

    /* Label flyout for flat items. */
    .kito-rail-tip { display: none; }
    html[data-nav-rail="collapsed"] .kito-rail-item:hover > .kito-rail-tip,
    html[data-nav-rail="collapsed"] .kito-rail-item:focus-visible > .kito-rail-tip {
        /* Flex, not block: the tooltip holds the label AND the count pill. */
        display: inline-flex;
        align-items: center;
        gap: 0.375rem;
        position: absolute;
        left: calc(100% + 0.625rem);
        top: 50%;
        transform: translateY(-50%);
        z-index: 1;
        padding: 0.375rem 0.625rem;
        border-radius: 0.375rem;
        background-color: var(--color-inverse-surface, #111827);
        color: var(--color-inverse-on-surface, #ffffff);
        font-size: 0.75rem;
        font-weight: 500;
        white-space: nowrap;
        pointer-events: none;
        box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
    }
    /* The count element stays in the DOM at zero (it is a Turbo Stream replace
       target), and an empty flex item would still take the gap — trailing space
       inside the tooltip. */
    .kito-rail-tip > span:empty { display: none; }
    .kito-rail-tip-count {
        flex: none;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-width: 1.125rem;
        height: 1.125rem;
        /* Same pill as .kito-rail-badge, same count, so the weight and the
           optical centring are copied from it rather than chosen here. The
           measurements are on that rule; change both together or the collapsed
           rail's tooltip shows a differently-drawn copy of the number the row
           beside it is showing. */
        padding: 0 0.25rem 1px;
        border-radius: 9999px;
        background-color: rgb(239 68 68); /* red-500, same as .kito-rail-badge */
        color: #fff;
        font-size: 0.6875rem;
        font-weight: 500;
        line-height: 1;
        letter-spacing: 0;
    }
    /* A group head IS a .kito-rail-item, so the rule above would open its label
       tooltip at left: calc(100% + 0.625rem) at the same moment the children
       flyout opens at left: 100% — two panels, overlapping, one on top of the
       other. A group's hover affordance is its flyout, which already names every
       child and shows each count in full, so the tooltip has nothing to add and
       loses.

       Which makes the group head's "<BadgeID>-rail-tip" element unreachable —
       there is no width at which it renders. It is kept anyway, as a parity
       anchor: a group badge is a three-surface element by definition, and
       badge_turbo_streams_test.go enumerates all three. The full argument is on
       the element itself in templates/structure/nav_rail.gohtml; read it before
       deleting either side. (Note it is NOT stream-fed: railTipBadgeStream is
       emitted only for flat rows, and a group badge is never broadcast. The count
       in it is written by navigation_controller#paintGroupBadge.)

       Must stay AFTER the rule it overrides: identical specificity, source order
       decides. */
    html[data-nav-rail="collapsed"] .kito-rail-grouphead:hover > .kito-rail-tip,
    html[data-nav-rail="collapsed"] .kito-rail-grouphead:focus-visible > .kito-rail-tip {
        display: none;
    }

    /* Sub-item flyout for groups — the collapsed replacement for expansion. */
    html[data-nav-rail="collapsed"] .kito-rail-sub {
        display: none;
    }
    html[data-nav-rail="collapsed"] .kito-rail-group:hover > .kito-rail-sub,
    html[data-nav-rail="collapsed"] .kito-rail-group:focus-within > .kito-rail-sub {
        display: block;
        position: absolute;
        left: 100%;
        top: -0.25rem;
        z-index: 1;
        min-width: 13rem;
        margin-left: 0.5rem;
        padding: 0.375rem;
        border: 1px solid var(--color-outline-variant);
        border-radius: 0.625rem;
        background-color: var(--color-surface);
        box-shadow: 0 12px 32px rgb(0 0 0 / 0.16);
    }
    html[data-nav-rail="collapsed"] .kito-rail-sub-inner {
        overflow: visible;
        margin-left: 0;
        padding-left: 0;
        border-left: 0;
    }
    html[data-nav-rail="collapsed"] .kito-rail-sub .kito-rail-label { display: block; }
    html[data-nav-rail="collapsed"] .kito-rail-sub .kito-rail-item {
        justify-content: flex-start;
        /* Restores the icon/label/badge spacing the icon-only rail zeroes out. */
        gap: 0.5rem;
        padding-left: 0.625rem;
        padding-right: 0.625rem;
    }
    /* The flyout is a full-width menu, not an icon column, so its badges go back
       to being readable counts — the corner-dot fallback above is only for rows
       that have nowhere to put a number. */
    html[data-nav-rail="collapsed"] .kito-rail-sub .kito-rail-badge {
        position: static;
        min-width: 1.125rem;
        width: auto;
        height: 1.125rem;
        /* Keeps the base rule's 1px optical BOTTOM pad — a `0 0.25rem` shorthand
           here would zero it and drop the digit 0.5px, but only in the flyout. */
        padding: 0 0.25rem 1px;
        font-size: 0.6875rem;
        overflow: visible;
    }
    html[data-nav-rail="collapsed"] .kito-rail-subhead {
        display: block;
        padding: 0.25rem 0.625rem 0.375rem;
        font-size: 0.6875rem;
        font-weight: 700;
        text-transform: uppercase;
        letter-spacing: 0.05em;
        color: var(--color-on-surface-variant);
    }
    /* Collapsed, a group's counts are hidden inside a flyout nobody sees until
       they hover, so the rail would say nothing at all about unread work behind
       a group icon. Mirror the flat rows' corner dot onto the group head when
       any child carries a count; the number itself appears in the flyout.
       Driven by :has, so the per-badge Turbo Streams keep it live on their own —
       there is no aggregate count to compute or push.

       This now COINCIDES with the group head's own combined badge rather than
       competing with it: collapsed, that badge takes the same top/right/size from
       html[data-nav-rail="collapsed"] .kito-rail-badge, and .kito-rail-item is
       position:relative, so both boxes resolve against the same button and land
       pixel-for-pixel on top of each other in the same red. Deliberately kept as
       the fallback, because it fires in the two cases the badge cannot: while the
       group is open (is-open hides the badge, the dot still says "work inside"),
       and when a child's count comes from a badge the group total does not carry.
       Verified rather than assumed — if either rule's offset or size ever moves,
       the other has to move with it or this becomes a doubled dot. */
    html[data-nav-rail="collapsed"] .kito-rail-group:has(.kito-rail-sub .kito-rail-badge) > .kito-rail-grouphead::after {
        content: "";
        position: absolute;
        top: 0.25rem;
        right: 0.5rem;
        width: 0.5rem;
        height: 0.5rem;
        border-radius: 9999px;
        background-color: rgb(239 68 68); /* red-500, same as .kito-rail-badge */
    }

    /* The flyout opens across a gap; a transparent bridge keeps :hover alive
       while the pointer crosses it. */
    html[data-nav-rail="collapsed"] .kito-rail-group::after {
        content: "";
        position: absolute;
        left: 100%;
        top: 0;
        width: 0.75rem;
        height: 100%;
    }

    /* ── Rail icon hover motion ──────────────────────────────────────────────
       Hovering a row plays a short animation on its icon — the gear turns, the
       warning triangle shakes, the trend line redraws itself. The point is
       recognition, not decoration: the motion restates what the icon depicts, so
       the row confirms where it goes before you have finished reading the label.
       In the collapsed rail, where there IS no label, it is doing most of the
       explaining.

       Keyed on data-nav-icon, which every glyph carries (nav_icon.gohtml and
       admin_nav_icon, gated by TestNavIcon_EveryGlyphCarriesItsKey). An unknown
       key still animates — it falls to the generic pop below — so a new nav item
       is never inert, it just is not yet characterful.

       Two mechanisms, and the difference matters:

         transition — for motions that hold while hovered (the gear stays turned).
           Reversible: unhovering plays it backwards.
         animation  — for motions that return to where they started (a wiggle, a
           shake, a redraw ending fully drawn). These MUST end at the resting
           state, because an animation that does not is snapped back the instant
           the pointer leaves.

       The whole section is behind prefers-reduced-motion, so a user who has asked
       their OS for less movement gets a rail that does not move at all. */
    @media (prefers-reduced-motion: no-preference) {
        /* Sub-element transforms are authored in viewBox units (the icons are all
           24×24), not in the element's own bounding box — a 4px-wide clock hand
           has to rotate about the clock's centre, not its own. */
        .kito-rail-icon > svg * {
            transform-box: view-box;
        }

        /* The generic pop: every icon without a rule of its own, including the
           Admin Console's set and the account avatar's fallback figure. */
        .kito-rail-item:hover .kito-rail-icon > svg,
        .kito-rail-item:focus-visible .kito-rail-icon > svg {
            animation: kito-nav-pop 320ms var(--kito-ease);
        }

        /* Things that swing from a hinge at the top. */
        .kito-rail-item:hover .kito-rail-icon > svg[data-nav-icon="guest-chat"],
        .kito-rail-item:focus-visible .kito-rail-icon > svg[data-nav-icon="guest-chat"],
        .kito-rail-item:hover .kito-rail-icon > svg[data-nav-icon="team-chat"],
        .kito-rail-item:focus-visible .kito-rail-icon > svg[data-nav-icon="team-chat"] {
            transform-origin: 50% 15%;
            animation: kito-nav-swing 500ms var(--kito-ease);
        }

        /* Things that lift: a home you return to, a building, a picture frame. */
        .kito-rail-item:hover .kito-rail-icon > svg[data-nav-icon="overview"],
        .kito-rail-item:focus-visible .kito-rail-icon > svg[data-nav-icon="overview"],
        .kito-rail-item:hover .kito-rail-icon > svg[data-nav-icon="console-workspaces"],
        .kito-rail-item:focus-visible .kito-rail-icon > svg[data-nav-icon="console-workspaces"],
        .kito-rail-item:hover .kito-rail-icon > svg[data-nav-icon="building"],
        .kito-rail-item:focus-visible .kito-rail-icon > svg[data-nav-icon="building"] {
            animation: kito-nav-bob 450ms var(--kito-ease);
        }

        /* A megaphone shouts; a ticket gets torn off at an angle. */
        .kito-rail-item:hover .kito-rail-icon > svg[data-nav-icon="marketing"],
        .kito-rail-item:focus-visible .kito-rail-icon > svg[data-nav-icon="marketing"],
        .kito-rail-item:hover .kito-rail-icon > svg[data-nav-icon="bookings-group"],
        .kito-rail-item:focus-visible .kito-rail-icon > svg[data-nav-icon="bookings-group"] {
            animation: kito-nav-wiggle 520ms var(--kito-ease);
        }

        /* The one alarm in the rail. Sharper and shorter than everything else,
           deliberately — a conflict is the only nav item that wants your hands. */
        .kito-rail-item:hover .kito-rail-icon > svg[data-nav-icon="conflicts"],
        .kito-rail-item:focus-visible .kito-rail-icon > svg[data-nav-icon="conflicts"] {
            animation: kito-nav-shake 400ms var(--kito-ease);
        }

        /* Flat things you turn over to see: a 3D box, a payment card, a link. */
        .kito-rail-item:hover .kito-rail-icon > svg[data-nav-icon="unit-types"],
        .kito-rail-item:focus-visible .kito-rail-icon > svg[data-nav-icon="unit-types"],
        .kito-rail-item:hover .kito-rail-icon > svg[data-nav-icon="channels"],
        .kito-rail-item:focus-visible .kito-rail-icon > svg[data-nav-icon="channels"],
        .kito-rail-item:hover .kito-rail-icon > svg[data-nav-icon="console-billing"],
        .kito-rail-item:focus-visible .kito-rail-icon > svg[data-nav-icon="console-billing"],
        .kito-rail-item:hover .kito-rail-icon > svg[data-nav-icon="card"],
        .kito-rail-item:focus-visible .kito-rail-icon > svg[data-nav-icon="card"] {
            animation: kito-nav-tilt 420ms var(--kito-ease);
        }

        /* The gear is the exception that HOLDS: a transition, not an animation, so
           it turns a quarter and stays turned for as long as you are on the row,
           then unwinds when you leave. A setting is a state, and the icon says so. */
        .kito-rail-icon > svg[data-nav-icon="settings"] {
            transition: transform 400ms var(--kito-ease);
        }
        .kito-rail-item:hover .kito-rail-icon > svg[data-nav-icon="settings"],
        .kito-rail-item:focus-visible .kito-rail-icon > svg[data-nav-icon="settings"] {
            animation: none; /* beats the generic pop above */
            transform: rotate(90deg);
        }

        /* ── Part-level motion ───────────────────────────────────────────────
           Where the icon's MEANING lives in one of its parts, that part moves and
           the frame stays put: the clock's hands sweep inside a still face, the
           calendar's hangers lift off a still page. These glyphs carry class
           hooks in nav_icon.gohtml (na-draw / na-lift / na-tick). */

        /* The hands go round once. */
        .kito-rail-item:hover .kito-rail-icon > svg .na-tick,
        .kito-rail-item:focus-visible .kito-rail-icon > svg .na-tick {
            transform-origin: 12px 12px;
            animation: kito-nav-tick 700ms var(--kito-ease);
        }

        /* The frame around a part-animated glyph stays still — the pop would drag
           the moving part along with it and read as one wobble instead of two
           separate things. */
        .kito-rail-item:hover .kito-rail-icon > svg:has(.na-tick),
        .kito-rail-item:focus-visible .kito-rail-icon > svg:has(.na-tick),
        .kito-rail-item:hover .kito-rail-icon > svg:has(.na-draw),
        .kito-rail-item:focus-visible .kito-rail-icon > svg:has(.na-draw),
        .kito-rail-item:hover .kito-rail-icon > svg:has(.na-lift),
        .kito-rail-item:focus-visible .kito-rail-icon > svg:has(.na-lift) {
            animation: none;
        }

        .kito-rail-item:hover .kito-rail-icon > svg .na-lift,
        .kito-rail-item:focus-visible .kito-rail-icon > svg .na-lift {
            animation: kito-nav-lift 420ms var(--kito-ease);
        }

        /* Redraw. pathLength="1" in the markup normalises every path to one unit
           long, so a 4px list row and a 20px trend line take exactly as long to
           draw — without it each glyph would run at its own speed.

           dasharray lives INSIDE the keyframes on purpose: it applies only while
           the animation runs, so the resting icon is a plain solid stroke with no
           dash state to clean up. */
        .kito-rail-item:hover .kito-rail-icon > svg .na-draw,
        .kito-rail-item:focus-visible .kito-rail-icon > svg .na-draw {
            animation: kito-nav-draw 450ms var(--kito-ease);
        }
        /* Multi-row glyphs (the list, the clipboard) draw top-down rather than all
           at once — a list writes itself a line at a time.

           Counted with sibling combinators, NOT :nth-of-type: the marked rows are
           `path` elements sitting among other `path` elements (the clipboard's
           frame is one), so nth-of-type would count the frame and delay the first
           row. `.na-draw ~ .na-draw` counts only the marked ones. */
        .kito-rail-item:hover .kito-rail-icon > svg .na-draw ~ .na-draw,
        .kito-rail-item:focus-visible .kito-rail-icon > svg .na-draw ~ .na-draw {
            animation-delay: 70ms;
        }
        .kito-rail-item:hover .kito-rail-icon > svg .na-draw ~ .na-draw ~ .na-draw,
        .kito-rail-item:focus-visible .kito-rail-icon > svg .na-draw ~ .na-draw ~ .na-draw {
            animation-delay: 140ms;
        }
    }

    @keyframes kito-nav-pop {
        0%   { transform: scale(1); }
        45%  { transform: scale(1.16); }
        100% { transform: scale(1); }
    }
    @keyframes kito-nav-bob {
        0%   { transform: translateY(0); }
        35%  { transform: translateY(-2.5px); }
        70%  { transform: translateY(0.5px); }
        100% { transform: translateY(0); }
    }
    @keyframes kito-nav-swing {
        0%   { transform: rotate(0); }
        25%  { transform: rotate(-9deg); }
        55%  { transform: rotate(6deg); }
        80%  { transform: rotate(-3deg); }
        100% { transform: rotate(0); }
    }
    @keyframes kito-nav-wiggle {
        0%   { transform: rotate(0) scale(1); }
        30%  { transform: rotate(-8deg) scale(1.06); }
        60%  { transform: rotate(6deg) scale(1.06); }
        85%  { transform: rotate(-2deg) scale(1.02); }
        100% { transform: rotate(0) scale(1); }
    }
    @keyframes kito-nav-shake {
        0%, 100% { transform: translateX(0); }
        20%      { transform: translateX(-1.5px); }
        40%      { transform: translateX(1.5px); }
        60%      { transform: translateX(-1px); }
        80%      { transform: translateX(1px); }
    }
    @keyframes kito-nav-tilt {
        0%   { transform: perspective(60px) rotateY(0) scale(1); }
        50%  { transform: perspective(60px) rotateY(28deg) scale(1.06); }
        100% { transform: perspective(60px) rotateY(0) scale(1); }
    }
    @keyframes kito-nav-tick {
        from { transform: rotate(0); }
        to   { transform: rotate(360deg); }
    }
    @keyframes kito-nav-lift {
        0%   { transform: translateY(0); }
        40%  { transform: translateY(-1.5px); }
        100% { transform: translateY(0); }
    }
    @keyframes kito-nav-draw {
        0%   { stroke-dasharray: 1; stroke-dashoffset: 1; }
        100% { stroke-dasharray: 1; stroke-dashoffset: 0; }
    }

    /* ── Foot: workspace switcher ────────────────────────────────────────── */
    /* The shared workspace_switcher partial is styled for the header drawer (pill
       button, sky accent, right-aligned dropdown opening downward). In the rail it
       is a full-width row at the bottom of the viewport, so both are re-pointed:
       the button becomes a rail row, the dropdown opens upward. */
    .kito-rail-workspace:empty { display: none; }
    .kito-rail-workspace [data-controller="workspace-switcher"] { width: 100%; }
    .kito-rail-workspace [data-controller="workspace-switcher"] > button {
        display: flex;
        width: 100%;
        max-width: none;
        min-height: 2.5rem;
        padding: 0.375rem 0.625rem;
        border-width: 1px;
        border-color: var(--color-outline-variant);
        border-radius: 0.5rem;
        color: var(--color-on-surface-variant);
    }
    .kito-rail-workspace [data-workspace-switcher-target="dropdown"] {
        top: auto;
        bottom: 100%;
        left: 0;
        right: auto;
        width: 100%;
        min-width: 13rem;
        margin-top: 0;
        margin-bottom: 0.375rem;
    }
    html[data-nav-rail="collapsed"] .kito-rail-workspace > * > button {
        justify-content: center;
        padding-left: 0;
        padding-right: 0;
    }
    html[data-nav-rail="collapsed"] .kito-rail-workspace > * > button > span,
    html[data-nav-rail="collapsed"] .kito-rail-workspace > * > button > svg:last-child {
        display: none;
    }
    html[data-nav-rail="collapsed"] .kito-rail-workspace [data-workspace-switcher-target="dropdown"] {
        left: calc(100% + 0.5rem);
        width: 14rem;
    }

    /* ── Foot: account menu ──────────────────────────────────────────────── */
    .kito-rail-account { position: relative; }
    /* Slightly larger than the 1.125rem icon box it sits in, on purpose — a face
       reads as a face only above a certain size. Kept in proportion when the icon
       column changes size. */
    .kito-rail-avatar > span { width: 1.375rem; height: 1.375rem; }
    .kito-rail-account-menu {
        position: absolute;
        bottom: calc(100% + 0.5rem);
        left: 0;
        z-index: 2;
        width: 15rem;
        padding: 0.375rem;
        border: 1px solid var(--color-outline-variant);
        border-radius: 0.625rem;
        background-color: var(--color-surface);
        box-shadow: 0 16px 40px rgb(0 0 0 / 0.18);
    }
    /* Collapsed: no width to drop into, so it opens beside the rail instead,
       bottom-aligned with the button it belongs to. */
    html[data-nav-rail="collapsed"] .kito-rail-account-menu {
        bottom: 0;
        left: calc(100% + 0.5rem);
    }
    .kito-account-menu-item {
        display: flex;
        align-items: center;
        gap: 0.625rem;
        width: 100%;
        min-height: 2.25rem;
        padding: 0.375rem 0.625rem;
        border-radius: 0.5rem;
        font-size: 0.875rem;
        font-weight: 500;
        color: var(--color-on-surface);
        white-space: nowrap;
        transition: background-color 150ms var(--kito-ease), color 150ms var(--kito-ease);
    }
    .kito-account-menu-item:hover {
        background-color: var(--color-surface-container);
    }
    .kito-account-menu-item.is-danger:hover {
        background-color: rgb(239 68 68 / 0.1);
        color: rgb(220 38 38);
    }
    .kito-account-menu-sep {
        height: 1px;
        margin: 0.375rem 0.25rem;
        background-color: var(--color-outline-variant);
    }
    .kito-account-menu-item.is-active {
        background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
        color: var(--color-primary);
        font-weight: 600;
    }

    /* The row icon. Its own class rather than .kito-rail-icon, because these rows
       are shared with the header dropdown, which has no rail to belong to. */
    .kito-account-menu-icon {
        flex: none;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 1.25rem;
        height: 1.25rem;
    }
    .kito-account-menu-icon > svg { width: 1.25rem; height: 1.25rem; }

    /* Language: ONE control showing the active language, opening the list —
       never a button per language. */
    .kito-account-menu-locale { position: relative; }
    .kito-account-menu-chevron {
        flex: none;
        width: 1rem;
        height: 1rem;
        margin-left: auto;
        color: var(--color-on-surface-variant);
    }
    /* Opens DOWNWARD by default, which is what the header dropdown needs — it
       hangs from the top of the viewport with the whole page below it. The rail's
       copy is flipped below: its menu already opens upward off the rail foot, so a
       downward nested list would run off the bottom of the screen. */
    .kito-account-locale-list {
        position: absolute;
        top: calc(100% + 0.25rem);
        left: 0;
        right: 0;
        z-index: 1;
        padding: 0.25rem;
        border: 1px solid var(--color-outline-variant);
        border-radius: 0.5rem;
        background-color: var(--color-surface);
        box-shadow: 0 12px 32px rgb(0 0 0 / 0.16);
    }
    .kito-rail-account-menu .kito-account-locale-list {
        top: auto;
        bottom: calc(100% + 0.25rem);
    }
    .kito-account-locale-list form,
    .kito-account-locale-list button { width: 100%; }

    /* ── Header account dropdown ─────────────────────────────────────────────
       The header avatar's popup. Same chrome and same rows as the rail's Account
       flyout (account_menu_items) — only the anchoring differs: it drops from the
       bar and is right-aligned, because the avatar sits at the right edge and a
       left-aligned panel would hang off the viewport. */
    .kito-header-account-menu {
        position: absolute;
        top: calc(100% + 0.5rem);
        right: 0;
        z-index: 2;
        width: 15rem;
        padding: 0.375rem;
        border: 1px solid var(--color-outline-variant);
        border-radius: 0.625rem;
        background-color: var(--color-surface);
        box-shadow: 0 16px 40px rgb(0 0 0 / 0.18);
    }

    /* ── Foot: legal links ───────────────────────────────────────────────── */
    /* On screen at all times rather than buried in the account menu. Expanded
       they are a plain text row; collapsed there is no width for the words, so
       they become one icon with a hover/focus flyout. */
    .kito-rail-legal {
        position: relative;
        display: flex;
        align-items: center;
        padding-top: 0.5rem;
        margin-top: 0.25rem;
        border-top: 1px solid var(--color-outline-variant);
    }
    .kito-rail-legal-icon {
        display: none;
        align-items: center;
        justify-content: center;
        width: 100%;
        height: 1.75rem;
        color: var(--color-on-surface-variant);
    }
    .kito-rail-legal-icon > svg { width: 1rem; height: 1rem; }
    .kito-rail-legal-links {
        display: flex;
        gap: 0.875rem;
        padding: 0 0.625rem;
        font-size: 0.6875rem;
        color: var(--color-on-surface-variant);
    }
    .kito-rail-legal-links a:hover { color: var(--color-primary); }

    html[data-nav-rail="collapsed"] .kito-rail-legal-icon { display: flex; }
    html[data-nav-rail="collapsed"] .kito-rail-legal-links { display: none; }
    html[data-nav-rail="collapsed"] .kito-rail-legal:hover .kito-rail-legal-links,
    html[data-nav-rail="collapsed"] .kito-rail-legal:focus-within .kito-rail-legal-links {
        display: flex;
        flex-direction: column;
        gap: 0.25rem;
        position: absolute;
        left: calc(100% + 0.5rem);
        bottom: 0;
        z-index: 1;
        padding: 0.5rem 0.75rem;
        border: 1px solid var(--color-outline-variant);
        border-radius: 0.5rem;
        background-color: var(--color-surface);
        box-shadow: 0 12px 32px rgb(0 0 0 / 0.16);
        white-space: nowrap;
        font-size: 0.75rem;
    }
}

/* Respect reduced-motion: keep the states, drop the animation. */
@media (prefers-reduced-motion: reduce) {
    .kito-rail,
    .kito-rail-sub,
    .kito-rail-item,
    body:has(.kito-rail),
    .kito-header,
    .kito-header-bar,
    .kito-header-logo,
    .kito-header-action,
    .kito-header-action > svg,
    .kito-page-header,
    .kito-page-header-title,
    .kito-page-header-secondary {
        transition: none;
    }
}

/* ── Dark mode toggle icon swap (HCT Dynamic Color Engine) ──
   The theme-toggle header button carries a sun and a moon SVG. Show the sun in
   light mode (default) and the moon when <html data-theme="dark"> is set. Pure
   CSS — the theme-toggle Stimulus controller only flips the data-theme attribute. */
[data-theme-icon="dark"] {
    display: none;
}
[data-theme="dark"] [data-theme-icon="light"] {
    display: none;
}
[data-theme="dark"] [data-theme-icon="dark"] {
    display: inline-block;
}

/* ═══════════════════════════════════════════════════════════════════════════
   Dark-mode overrides for hardcoded-CSS surfaces (HCT Dynamic Color Engine)
   ───────────────────────────────────────────────────────────────────────────
   These components color themselves with literal hex in app.css / vendor CSS
   (not Tailwind utility classes), so they can't flip via the semantic tokens
   automatically. Each rule is scoped under [data-theme="dark"] so light mode is
   untouched, and maps onto the same semantic tokens (--color-surface*, etc.) the
   rest of the app uses — keeping the JetBrains-charcoal surfaces consistent.
   ═══════════════════════════════════════════════════════════════════════════ */

/* Gantt calendar — the sticky fixed-unit column + header cells were pure #ffffff,
   which glared against the dark grid. Lift them onto the elevated panel surface. */
[data-theme="dark"] .gantt-fixed-units,
[data-theme="dark"] .gantt-unit-header-cell,
[data-theme="dark"] .gantt-header-unit,
[data-theme="dark"] .gantt-unit-name,
[data-theme="dark"] .gantt-day-header-row {
    background: var(--color-surface);
    color: var(--color-on-surface);
}

/* Gantt booking bars (were slate-100 bg / slate-600 text). */
[data-theme="dark"] .gantt-bar {
    background: var(--color-surface-container-high);
    color: var(--color-on-surface);
}

/* Gantt view-option toggle buttons (were white / gray). */
[data-theme="dark"] .gantt-toggle-btn {
    background: var(--color-surface-container);
    color: var(--color-on-surface-variant);
    border-color: var(--color-outline-variant);
}
[data-theme="dark"] .gantt-toggle-btn--active:hover {
    background: var(--color-surface-container-high);
    color: var(--color-on-surface);
}

/* TomSelect controls + dropdowns — form selects app-wide (vendor default is white). */
[data-theme="dark"] .ts-control,
[data-theme="dark"] .ts-dropdown {
    background: var(--color-surface-container-low);
    color: var(--color-on-surface);
    border-color: var(--color-outline-variant);
}
[data-theme="dark"] .ts-dropdown .active,
[data-theme="dark"] .ts-dropdown .option:hover {
    background: var(--color-surface-container-high);
    color: var(--color-on-surface);
}

/* Tutorial / help overlay panels (were #ffffff). */
[data-theme="dark"] .tutorial-panel,
[data-theme="dark"] .tutorial-nav-btn,
[data-theme="dark"] .tutorial-close-btn,
[data-theme="dark"] .tutorial-cta-btn-secondary {
    background: var(--color-surface);
    color: var(--color-on-surface);
}

/* Documentation prose — hardcoded light text/border/code colors. */
[data-theme="dark"] .doc-prose h1,
[data-theme="dark"] .doc-prose h2,
[data-theme="dark"] .doc-prose h3,
[data-theme="dark"] .doc-prose p,
[data-theme="dark"] .doc-prose li {
    color: var(--color-on-surface);
}
[data-theme="dark"] .doc-prose code {
    background: var(--color-surface-container);
    color: var(--color-on-surface);
}
[data-theme="dark"] .doc-prose th,
[data-theme="dark"] .doc-prose td,
[data-theme="dark"] .doc-prose table {
    border-color: var(--color-outline-variant);
}

/* ── Landing demo calendar shimmer (landing-demo-calendar Req 5.1–5.5) ─────────
   The showcase calendar is fully server-rendered, so this is not a data-loading
   state — it covers the window between first paint and web fonts settling, during
   which a 5 × 31 grid reflows visibly. The demo-calendar Stimulus controller drops
   the class on connect, typically within 100–300ms.

   The animation runs a FINITE three times rather than infinitely: without
   JavaScript nothing removes the class, and an endless shimmer over content that is
   already complete and readable is worse than no shimmer at all. `forwards` parks it
   on a transparent final frame, so the no-JS outcome is the finished calendar. */
/* The showcase's frame height, cut on a row boundary so no unit is sliced.
   The window's viewport is `absolute inset-0`, so the grid inside does not size
   this box and the box has to say how tall it is.

   Desktop: a 56px header plus five 56px unit rows (--kito-cal-header-h and
   --kito-cal-row-h). Phone: the same header plus three, because the chapter this
   sits in also carries a heading and a subtitle and five rows put the bottom of
   the card off the screen. Three properties is enough to show what a Gantt of
   properties looks like; a fourth row half-cut off is not. */
.demo-calendar-frame {
    height: 21rem;
}

@media (max-width: 640px) {
    .demo-calendar-frame {
        height: 14rem;
    }
}

.demo-calendar-skeleton {
    position: relative;
    overflow: hidden;
}
.demo-calendar-skeleton::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 50;
    pointer-events: none;
    background: linear-gradient(
        100deg,
        transparent 0%,
        var(--color-surface-container-high) 45%,
        var(--color-surface-container-high) 55%,
        transparent 100%
    );
    background-size: 200% 100%;
    background-repeat: no-repeat;
    opacity: 0.55;
    animation: demo-shimmer 2s ease-in-out 3 forwards;
}

@keyframes demo-shimmer {
    0%   { background-position: -150% 0; opacity: 0.55; }
    99%  { background-position: 250% 0;  opacity: 0.55; }
    100% { background-position: 250% 0;  opacity: 0; }
}

/* A sweeping gradient is exactly the kind of decoration reduced-motion asks to be
   spared. The overlay is dropped entirely rather than frozen mid-sweep, which would
   leave a permanent grey band across the grid. */
@media (prefers-reduced-motion: reduce) {
    .demo-calendar-skeleton::before {
        display: none;
    }
}

/* ── The showcase calendar must never swallow a page scroll ────────────────────
   `overflow-hidden` on the viewport (demo_calendar_window.gohtml) was supposed to
   settle this and did not, because it only refuses to MOVE — it does not stop the
   element being a scroll container. The real calendar's base rule gives that
   container `overscroll-behavior: contain`, which is right there (a month pane
   that hands the rest of its gesture to the document scrolls the page out from
   under the booking being dragged) and fatal here: contain kills scroll
   CHAINING, so a wheel gesture over a container that cannot itself scroll goes
   nowhere at all. The visitor reached the calendar chapter and the page stopped.

   `auto` is the initial value, restored for this one instance: the demo has
   nothing to protect — it refuses user scrolling outright — so every gesture over
   it belongs to the document.

   Written as `#calendar-viewport[data-testid="…"]` so it out-specifies the ID rule
   without `!important`, and so the literal `#calendar-viewport {` still appears
   exactly ONCE in this file — the geometry guard finds the desktop rule by
   substring and must keep finding it. Same trick as the mobile label-pane
   override further down. */
#calendar-viewport[data-testid="landing-demo-calendar-viewport"] {
    overscroll-behavior: auto;
}

/* ══════════════════════════════════════════════════════════════════════════
   Onboarding CTA — the two-face hover swap
   Plain CSS (app.css is not Tailwind-processed). Applied by
   partials/shared/onboarding_panel.gohtml when a button is given a HoverIcon.

   The button holds two faces stacked in a single grid cell: the resting one
   (label + arrow) and the hover one (the button's own icon + the same label).
   Hovering plays them in sequence rather than crossfading them, which is what
   makes it read as deliberate instead of busy:

     0–140ms   the resting face lifts a hair and fades out
     140–200ms nothing — the deliberate blank beat
     200ms     the icon springs in on the left
     260ms     the label slides out from behind the icon into place

   Leaving reverses it with every delay dropped, so the button snaps back the
   moment the pointer goes rather than making the user wait out the sequence.

   Because both faces occupy the same cell the button is as wide as the wider of
   them and holds that width throughout — no reflow, no neighbouring content
   twitching mid-hover.
   ══════════════════════════════════════════════════════════════════════════ */

.onboarding-cta {
    display: inline-grid;
    place-items: center;
}

.onboarding-cta__face {
    grid-area: 1 / 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    white-space: nowrap;
}

/* Resting face. The delay lives on the base state, so it applies on the way
   BACK in (after the hover face has cleared out) and is cancelled below for the
   way out. */
.onboarding-cta__face--rest {
    opacity: 1;
    transform: translateY(0) scale(1);
    transition:
        opacity 180ms cubic-bezier(0.22, 1, 0.36, 1) 140ms,
        transform 180ms cubic-bezier(0.22, 1, 0.36, 1) 140ms;
}

.onboarding-cta:hover .onboarding-cta__face--rest,
.onboarding-cta:focus-visible .onboarding-cta__face--rest {
    opacity: 0;
    transform: translateY(-0.35rem) scale(0.96);
    transition-duration: 140ms;
    transition-delay: 0ms;
}

/* The icon arrives on a gentle overshoot — the only spring in the sequence, so
   it is the beat the eye lands on. */
.onboarding-cta__icon {
    display: inline-flex;
    opacity: 0;
    transform: scale(0.5);
    transition:
        opacity 140ms ease-out,
        transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

.onboarding-cta:hover .onboarding-cta__icon,
.onboarding-cta:focus-visible .onboarding-cta__icon {
    opacity: 1;
    transform: scale(1);
    transition-delay: 200ms;
}

/* The label rides in from the left, masked so it appears to unfurl from behind
   the icon rather than fly in over it. The padding/margin pair gives descenders
   room inside the mask without changing where anything sits. */
.onboarding-cta__label {
    display: inline-flex;
    overflow: hidden;
    padding-block: 0.125rem;
    margin-block: -0.125rem;
}

.onboarding-cta__label > span {
    display: inline-block;
    opacity: 0;
    transform: translateX(-105%);
    transition:
        opacity 160ms ease-out,
        transform 380ms cubic-bezier(0.22, 1, 0.36, 1);
}

.onboarding-cta:hover .onboarding-cta__label > span,
.onboarding-cta:focus-visible .onboarding-cta__label > span {
    opacity: 1;
    transform: translateX(0);
    transition-delay: 260ms;
}

/* Reduced motion gets no swap at all: the resting face simply stays put. A
   frozen or instant swap would still be a content change on hover, which is the
   part that is disorienting — not just its speed. */
@media (prefers-reduced-motion: reduce) {
    .onboarding-cta__face--rest,
    .onboarding-cta__icon,
    .onboarding-cta__label > span {
        transition: none;
    }

    .onboarding-cta:hover .onboarding-cta__face--rest,
    .onboarding-cta:focus-visible .onboarding-cta__face--rest {
        opacity: 1;
        transform: none;
    }

    .onboarding-cta:hover .onboarding-cta__icon,
    .onboarding-cta:focus-visible .onboarding-cta__icon,
    .onboarding-cta:hover .onboarding-cta__label > span,
    .onboarding-cta:focus-visible .onboarding-cta__label > span {
        opacity: 0;
    }
}

/* ---------------------------------------------------------------------------
   First-run workspace onboarding ("Kiro") — workspace-onboarding Req 1/2.

   This replaced a canvas dissolve. The canvas repainted two full-screen fills
   plus a freshly allocated radial gradient on every frame, which is what made
   the reveal stutter — and it could only erase a flat colour, so it could not
   survive the photographic background this screen now has.

   The reveal is now three properties animating together on ONE element:

     * opacity + transform  — compositor-only, so they stay smooth even when the
       main thread is busy. They alone carry the whole effect on any browser
       that ignores the mask below.
     * --onboarding-reveal        — a registered custom property (@property), which is
       what makes the radial mask INTERPOLATE rather than jump. Without the
       registration a custom property is just a string and transitions on it do
       nothing, so this declaration is load-bearing, not decoration.

   Browsers without @property (or without mask-image) simply get the scale-and-
   fade. That is a graceful loss of character, not a broken animation.
--------------------------------------------------------------------------- */

@property --onboarding-reveal {
    syntax: "<percentage>";
    inherits: false;
    initial-value: 0%;
}

.onboarding-overlay {
    --onboarding-reveal: 0%;
    /* Sampled from the artwork, so a slow or failed image degrades to a matching
       colour rather than a white flash. Declared here rather than as a Tailwind
       arbitrary value because this rule also has to guarantee opacity for the
       backdrop-root trick below — the two belong together. */
    background-color: #cfe1f0;
    /* --onboarding-reveal is the OUTER edge of the hole; the 22-point feather trails
       BEHIND it. That ordering is the whole trick, and getting it backwards is
       a bug that hides in plain sight: written as `transparent 0%, transparent
       var(--onboarding-reveal), #000 calc(... + 22%)`, a resting value of 0% yields
       `transparent 0% → #000 22%` — a permanent see-through hole punched
       through the middle of the overlay, exactly where the copy sits. That is
       what let the dashboard ghost through the glass and washed the text out;
       the "bright centre" was the page underneath, not the artwork.

       With the feather trailing, a resting 0% gives `transparent -22% → #000 0%`,
       which is opaque from the centre outward. */
    -webkit-mask-image: radial-gradient(circle at 50% 50%,
        transparent calc(var(--onboarding-reveal) - 22%),
        #000 var(--onboarding-reveal));
    mask-image: radial-gradient(circle at 50% 50%,
        transparent calc(var(--onboarding-reveal) - 22%),
        #000 var(--onboarding-reveal));
    will-change: opacity, transform;

    /* BACKDROP ROOT. Without this the glass panel's backdrop-filter samples
       straight past the overlay and blurs the DASHBOARD behind it — the unit
       empty-state card was visibly ghosting through the artwork.
       backdrop-filter only stops at an ancestor that establishes a backdrop
       root, and per the Filter Effects spec `opacity < 1` is one of the things
       that establishes one. 0.995 is imperceptible (half a percent) but is
       enough to make the overlay the boundary, so the panel now samples only
       the artwork and scrim inside it.
       isolation rides along to pin the stacking context for the same reason. */
    opacity: 0.995;
    isolation: isolate;
}

/* While a full-screen onboarding surface is up there is nothing below it to
   reach, so the page must not scroll. The overlay is position:fixed and adds no
   height of its own — the scrollable content is the dashboard behind it.

   The class goes on BOTH <html> and <body>. Locking only <body> looks right and
   does nothing: the viewport's scrolling element here is <html>, so the page
   keeps scrolling underneath a body that has politely stopped. */
.onboarding-scroll-locked {
    overflow: hidden;
}

/* Scrim over the artwork. The image has a bright white disc dead centre, right
   where the copy lands, and dark type on it washed out. A flat tint would kill
   the artwork; this leaves the edges nearly untouched and takes the hotspot
   down, so the panel sits on a consistent value at any viewport size.

   Every value here is half what it was, because the panel in front of it stopped
   being a 40%-white card. These two are one system: the scrim controls the
   hotspot and the glass controls the copy's surface, and while the panel was
   near-opaque the scrim could lean on the centre as hard as it liked without
   anyone seeing it. Now the artwork reads through, so that 0.28 centre showed up
   as a grey bruise behind the headline — and .onboarding-glass brightens what it
   samples anyway, which is the same job done in the right place. */
.onboarding-overlay__scrim {
    position: absolute;
    inset: 0;
    pointer-events: none;
    background:
        radial-gradient(ellipse 60% 55% at 50% 50%,
            rgba(15, 23, 42, 0.14) 0%,
            rgba(15, 23, 42, 0.07) 45%,
            rgba(15, 23, 42, 0.02) 75%,
            rgba(15, 23, 42, 0) 100%),
        linear-gradient(to bottom,
            rgba(15, 23, 42, 0.03) 0%,
            rgba(15, 23, 42, 0.01) 40%,
            rgba(15, 23, 42, 0.04) 100%);
}

/* The welcome panel, built as glass rather than as a translucent card.

   The blur is short on purpose. Tailwind's backdrop-blur-2xl and -3xl (40px and
   64px) average the artwork to a flat tone, and a flat tone is frosted plastic:
   the panel stops being something you look THROUGH and becomes something you
   look AT. At 16px the artwork's shapes still resolve behind the copy, which is
   the whole ask.

   brightness() is doing the work that opacity used to. Dark copy needs a light
   surface, and now that the image reads through, the fill alone cannot supply
   one without going opaque again. Lifting the sampled pixels instead keeps the
   panel sheer and the text readable at the same time.

   saturate() is deliberately absent. It is the standard glass trick and it is
   what gives these panels a colour cast, pulling whatever hue sits behind them
   into the surface. This one stays neutral.

   The edge is inset highlights, not a uniform border. Glass catches light along
   its top rim and carries a fainter return along the bottom inside face; a 1px
   border of one colour all the way round reads as a card outline, which is the
   tell that separates this from the real thing. */
.onboarding-glass {
    background: rgba(255, 255, 255, 0.16);
    backdrop-filter: blur(16px) brightness(1.08);
    -webkit-backdrop-filter: blur(16px) brightness(1.08);
    border: 1px solid rgba(255, 255, 255, 0.30);
    box-shadow:
        inset 0 1px 0 0 rgba(255, 255, 255, 0.55),
        inset 0 -1px 0 0 rgba(255, 255, 255, 0.14),
        0 20px 45px -15px rgba(15, 23, 42, 0.35);
}

/* Without backdrop-filter there is no glass to speak of, only a 16%-white sheet
   over a photograph, and the dark copy lands on whatever the artwork happens to
   be doing there. Firefox with the flag off and older WebKit both land here, so
   the fallback goes opaque enough to guarantee the contrast the filter was
   buying. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .onboarding-glass {
        background: rgba(255, 255, 255, 0.72);
    }
}

/* Expo-out: fast commitment, long settle. The same curve the landing page's
   scroll reveals use, so the app has one motion signature rather than two. */
.onboarding-overlay.is-dissolving {
    /* The three do NOT share a curve, and that is the point.

       The mask uses ease-in-out, NOT the expo-out the rest of the app favours.
       Expo-out front-loads so hard that --onboarding-reveal passed 100% within 250ms
       — the reveal was over before the eye caught it, and the whole thing read
       as an abrupt cut. Ease-in-out spreads the travel across the full duration,
       which is what makes the expanding circle legible as motion.

       Opacity is held back for the same reason in reverse: on a shared curve it
       fell under 0.02 by 600ms, so the layer vanished before the mask had done
       anything. It now only clears the remainder, after the reveal has played. */
    transition:
        --onboarding-reveal 1100ms cubic-bezier(0.65, 0, 0.35, 1),
        opacity 300ms linear 800ms,
        transform 1100ms cubic-bezier(0.16, 1, 0.3, 1);
    /* Past 100% by the feather width plus a margin: the gradient is sized to the
       farthest CORNER, so the trailing edge has to clear that too or the last
       frame leaves four dark wedges. */
    --onboarding-reveal: 155%;
    opacity: 0;
    /* A drift toward the viewer rather than away — the dashboard is arriving,
       the overlay is not retreating. */
    transform: scale(1.06);
}

/* Entrance: each line starts slightly ABOVE its resting place and settles down
   into it.

   This is an ANIMATION, not a JS-toggled transition, so it starts on the first
   painted frame instead of waiting for Stimulus to boot and add a class — the
   wait was visible as a beat of empty overlay. It also means the copy still
   arrives if the bundle fails outright.

   The stagger comes from --onboarding-delay, set inline per element in the template.
   The delay belongs to an element's POSITION in the group, and nth-child rules
   encode that position in a stylesheet that cannot see the markup change. */
@keyframes onboarding-rise-in {
    from {
        opacity: 0;
        transform: translateY(-18px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

.onboarding-rise {
    animation: onboarding-rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
    animation-delay: var(--onboarding-delay, 0ms);
}

/* The welcome copy leaves before the overlay does, so the reveal is never
   fighting text for attention. Cancelling the animation is what lets a
   transition take over — an running animation outranks any transition on the
   same property, so without `animation: none` the exit simply would not play. */
.onboarding-overlay.is-dissolving .onboarding-rise {
    animation: none;
    opacity: 0;
    transform: translateY(-10px);
    transition:
        opacity 260ms cubic-bezier(0.4, 0, 1, 1),
        transform 260ms cubic-bezier(0.4, 0, 1, 1);
}

@media (prefers-reduced-motion: reduce) {
    /* Honour the preference without dropping the transition entirely: a
       hard cut from a full-screen overlay to a dense dashboard is its own kind
       of jarring. A short, motionless cross-fade is the accessible reading. */
    .onboarding-overlay,
    .onboarding-overlay.is-dissolving {
        -webkit-mask-image: none;
        mask-image: none;
        transform: none;
        transition: opacity 200ms linear;
    }
    .onboarding-rise {
        animation: none;
        opacity: 1;
        transform: none;
    }
    .onboarding-overlay.is-dissolving .onboarding-rise {
        opacity: 0;
        transform: none;
        transition: opacity 200ms linear;
    }
}

/* ═══════════════════════════════════════════════════════════════════════════
   BANNER ARTWORK — slow zoom-out on reveal

   One rule behind every piece of full-bleed artwork on the platform. The image
   paints slightly overscanned and settles back to its natural framing over two
   seconds: the surface reads as arriving rather than as having always been
   there.

   Applied at:
     shared/onboarding_panel  — masthead, the 15 zero-data pages
     shared/neo_empty_state   — hero + split banners, default illustration
     auth/workspace_picker    — post-login banner
     pages/auth/login         — the login gate illustration (runs INWARD; see
                                the --gate modifier)

   Deliberately NOT on the landing pages: they run their own scroll-reveal
   motion, and a second animation on the same element fights it.

   This is an ANIMATION, not a JS-toggled transition, for the same reason the
   Kiro overlay copy is — it starts on the first painted frame instead of
   waiting for Stimulus to boot, and it still plays if the bundle never loads.
   `both` fill is what holds the zoomed-in frame from paint until the animation
   starts; without it the banner would show at rest for a frame and then jump.
   Lazy frames get it for free: the animation begins when the node is inserted,
   which is exactly when the section becomes visible.

   On the banners the overscan is invisible because the cards clip it — hero and
   split each keep `overflow-hidden`, and the images are `object-cover`, so the
   extra 6% is cropped rather than shown as growth. The default layout's
   illustration is the exception; see the modifier below.
   ═══════════════════════════════════════════════════════════════════════════ */
@keyframes kito-banner-zoom-out {
    from {
        transform: scale(var(--kito-banner-zoom-from, 1.06));
    }
    to {
        transform: scale(1);
    }
}

.kito-banner-zoom {
    /* Long and gently decelerating. A snappier curve reads as a UI transition;
       this should read as ambient, closer to a slow camera pull than a reveal. */
    animation: kito-banner-zoom-out var(--kito-banner-zoom-duration, 2000ms)
        cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
    /* Promote once so the scale runs on the compositor — a full-bleed banner
       repainting every frame is the one place this actually shows up. */
    will-change: transform;
    backface-visibility: hidden;
}

/* The default layout's centred illustration caps at 200px, where the banners'
   6% overscan is about twelve pixels of travel — technically present, visually
   nothing. It gets a larger start so the settle reads at that size.

   Only the START scales. The duration deliberately stays shared: two empty
   states on one page settling at different speeds is the kind of mismatch you
   notice without being able to name it.

   Nothing clips this one — it sits in a dashed card with no overflow-hidden —
   but a transform does not reflow, so the heading below it does not move and
   the overscan spills into the card's 2rem padding, which is empty. */
.kito-banner-zoom--illustration {
    --kito-banner-zoom-from: 1.14;
}

/* Google's custom-button specification uses fixed brand values rather than the
   application's HCT palette. Keep the exception behind one provider class so
   every unbranded/config-only provider retains the shared semantic treatment. */
.social-provider-button--google {
    background: #ffffff;
    border-color: #747775;
    color: #1f1f1f;
    font-family: 'Roboto', sans-serif;
    font-weight: 500;
    line-height: 20px;
    padding-left: 12px;
    padding-right: 12px;
}

.social-provider-icon-google--dark {
    display: none;
}

[data-theme="dark"] .social-provider-button--google {
    background: #131314;
    border-color: #8e918f;
    color: #e3e3e3;
}

[data-theme="dark"] .social-provider-icon-google--light {
    display: none;
}

[data-theme="dark"] .social-provider-icon-google--dark {
    display: block;
}

/* The login gate runs the effect backwards: a slow push IN, ending slightly
   closer than it started, rather than a settle back out.

   The banners are a section arriving — they come to rest, and the copy beside
   them is what you are meant to end up reading. The gate is the opposite
   errand: it is the room you are walking into while you type, so it should
   still be moving toward you when it stops.

   3% rather than the banners' 6%, because this is near-full-viewport artwork —
   the shared start travels roughly 55px here against a banner's 10. Same
   proportion, well past subtle at that size.

   Only animation-name is overridden, so duration and easing stay shared: the
   push decelerates into its final frame exactly as the settle does, which is
   what keeps two opposite directions reading as one gesture. A fresh
   `animation` shorthand here would quietly drop both.

   It rests at 1.03 rather than returning to 1. That is deliberate — scaling
   BELOW 1 would pull the image off the edges of its column and show the
   gradient behind it. No sharpness is lost that object-cover was not already
   costing: a full-bleed cover image is resampled to its container regardless,
   so there is no native 1:1 frame here to preserve. */
.kito-banner-zoom--gate {
    animation-name: kito-banner-zoom-in;
}

@keyframes kito-banner-zoom-in {
    from {
        transform: scale(1);
    }
    to {
        transform: scale(var(--kito-banner-zoom-to, 1.03));
    }
}

@media (prefers-reduced-motion: reduce) {
    /* Ambient motion with no informational content — exactly what the
       preference is for. The artwork stays; only the movement goes. */
    .kito-banner-zoom {
        animation: none;
        transform: none;
        will-change: auto;
    }
}

/* ------------------------------------------------------------------------------------------------------------------ */
/* Landing hero parallax — the scroll-driven half                                                                      */
/* ------------------------------------------------------------------------------------------------------------------ */
/* The other half is assets/js/controller/landing/parallax_hero_controller.js, and the
   two are one feature. Read that file's header before touching these numbers.

   The controller adds .parallax-hero--scroll-driven only where the browser supports
   `animation-timeline` (Chrome 115+, Safari 26+), and then does nothing else: the
   motion below is tied to the scroll position on the compositor, so it cannot lag the
   page. The controller's own rAF loop, which every other browser gets, is a frame
   behind by construction and stutters through momentum scrolling. Same motion, better
   engine — that is the entire reason this duplicate exists.

   THE NUMBERS ARE DUPLICATED IN THE CONTROLLER (BACKGROUND_TRAVEL, CONTENT_TRAVEL,
   SCALE). CSS can only translate by a percentage of the element's own box, so the JS
   measures each target against its own height as well, and the two agree by using the
   same two percentages. Change one, change both.

   9% is nine tenths of the overhang a 1.2× scale leaves above and below the image; the
   remaining tenth is the margin that keeps the image's own edge out of the frame.
   Content drifts a fifth as far, and against it: enough to separate the planes, not
   enough to carry a headline out of the section it belongs to. */
@media (prefers-reduced-motion: no-preference) {
    /* Named rather than each element calling view() on itself: the content block is
       shorter than its section and would otherwise enter and leave on its own schedule,
       drifting out of step with the image behind it. One timeline, measured on the
       section, is what makes them one movement. */
    .parallax-hero--scroll-driven {
        view-timeline: --parallax-hero block;
    }

    .parallax-hero--scroll-driven [data-parallax-hero-target="background"],
    .parallax-hero--scroll-driven [data-parallax-hero-target="content"] {
        animation-timeline: --parallax-hero;
        /* `auto` is what fills a progress-based timeline end to end. A duration in
           seconds would be ignored here, and the `animation` shorthand would quietly
           reset this to 0s — hence longhands throughout. */
        animation-duration: auto;
        animation-timing-function: linear;
        /* The range is `cover 0%` to `cover 100%` by default: top edge entering the
           bottom of the viewport, bottom edge clearing the top. `both` holds the end
           pose either side of it, so a section parked off screen is not un-transformed. */
        animation-fill-mode: both;
        will-change: transform;
    }

    .parallax-hero--scroll-driven [data-parallax-hero-target="background"] {
        animation-name: parallax-hero-background;
    }

    .parallax-hero--scroll-driven [data-parallax-hero-target="content"] {
        animation-name: parallax-hero-content;
    }
}

@keyframes parallax-hero-background {
    from {
        transform: translate3d(0, calc(-9% * var(--parallax-hero-depth, 1)), 0) scale(1.2);
    }
    to {
        transform: translate3d(0, calc(9% * var(--parallax-hero-depth, 1)), 0) scale(1.2);
    }
}

@keyframes parallax-hero-content {
    from {
        transform: translate3d(0, calc(1.8% * var(--parallax-hero-depth, 1)), 0);
    }
    to {
        transform: translate3d(0, calc(-1.8% * var(--parallax-hero-depth, 1)), 0);
    }
}

/* ═══════════════════════════════════════════════════════════════════════════
   Booking list — inline assign affordances (booking-list-redesign R5/R6)
   ═══════════════════════════════════════════════════════════════════════════ */
/* The "+ Assign person" and "+ Assign unit" buttons on the booking card are
   only useful to someone who holds booking:update — the five assign routes
   are behind IAMGuard, so for anyone else the click ends as a rejection toast.
   The gate cannot be a `{{ if userHasPermission }}` in the card partial: the
   card is ALSO rendered by the projectionist's SSE broadcast, which has no
   request (a zero-value &http.Request{}, on which userHasPermission is false for
   everyone) and ships one html to the whole tenant — the guard would have made
   the buttons vanish from every card, for every user, on the first live update.

   So the answer is rendered once, per user, on the list page's controller
   wrapper (`data-booking-assign-locked`, templates/pages/booking/
   bookings.gohtml) — an element no stream ever replaces — and inherited by
   every descendant card, including the ones Turbo swaps in later.

   Both nodes of the unit slot are always in the DOM; this decides which shows.
   These rules sit outside @layer, so they win over Tailwind's `.hidden`
   regardless of source order. */
[data-booking-assign-locked="true"] [data-assign-affordance="person"],
[data-booking-assign-locked="true"] [data-assign-affordance="unit"] {
    display: none;
}

[data-booking-assign-locked="true"] [data-assign-affordance="unit-locked"] {
    display: inline;
}

/* ═══════════════════════════════════════════════════════════════════════════
   CALENDAR WINDOW — two panes, no body grid
   (spec calendar-window-render, Task 4.5)

   The old calendar body was a single CSS Grid where header, day cell, bar, label
   and today overlay all had to publish a correct grid-column AND grid-row. That
   is the coupling that made three async producers have to agree, and it is what
   _syncColumns / _reassignGridRows / _fillMissingCells / _deduplicateBars
   existed to repair.

   Here:
     * a bar's position is a property of the bar alone — three numbers written
       once by the server (--col / --len / --lane);
     * vertical order is document order — nothing carries a row index;
     * DAY BOUNDARIES ARE PAINTED, not built. This is the whole element budget:
       a 500-unit × 180-day window is ~1,000 rows + 180 day headers + ~2,000
       bars, not 90,000 cells (AC-6, AC-21).

   Every colour, taper and badge rule is the EXISTING one — .gantt-status-*,
   .gantt-clipped-*, .gantt-checkin-edge / .gantt-checkout-edge,
   .gantt-turnover-*, .calendar-today-column. Only the placement mechanism
   changed, so the calendar looks the same (AC-23).
   ═══════════════════════════════════════════════════════════════════════════ */

#calendar-viewport {
    /* Declared here as well as on #calendar-grid so the window renderer is not
       coupled to the legacy grid's element. Same pixel values, and the server
       computes data-scroll-left from the same day width — expressed in PIXELS
       because the scroll arithmetic is in pixels (see the note above
       #calendar-grid). */
    /* A day column is as wide as a row is tall: the cells are SQUARE. Anything
       else makes a grid of bookings read as a grid of something else. Both
       numbers move together or the calendar stops being one — see
       TestThePublishedGeometryMatchesTheStylesheet, which reads this file. */
    --kito-cal-day-w: 56px;
    --kito-cal-label-w: 220px;
    /* 56px, not 48. The bar is a 44px touch target and may not shrink (AC-25),
       so the only way to stop it sitting flush against the row's borders is to
       give the lane more room: 56 − 44 leaves a 6px gutter above and below,
       where 48 left 2px and the grid read as cramped. */
    --kito-cal-row-h: 56px;
    /* A row is max(lane)+1 lanes tall; one lane is one row's worth of height. */
    --kito-cal-lane-h: var(--kito-cal-row-h);
    /* Matched to the row rhythm, and enough for a legible week marker above the
       weekday and the day number without any of them overlapping. */
    --kito-cal-header-h: 56px;
    /* The air above and below a bar inside its lane. One number, so the bar's
       top offset and its height can never disagree about it. */
    --kito-cal-lane-gutter: 6px;
    /* The INNER hairlines: day boundaries and row separators. Deliberately faint —
       they are there to let the eye track a row across six months, not to draw
       boxes. The outer edges (the header's bottom rule, the label pane's right
       rule) stay at full outline-variant, so the grid still has a frame.
       Expressed as a semantic token, not the old raw slate ramp, so it follows
       the theme; mixing toward `transparent` lightens it in dark mode too. */
    --kito-cal-line: color-mix(in srgb, var(--color-outline-variant) 28%, transparent);
    /* The unit spine — the 4px left border every label row reserves. Its own
       token because it is a 4px accent, not a hairline: tying it to
       --kito-cal-line meant lightening the grid also washed out the spine, which
       is a different decision that happened to share a variable. */
    --kito-cal-spine: color-mix(in srgb, var(--color-outline-variant) 45%, transparent);

    overscroll-behavior: contain;
    overflow-anchor: none;
    scroll-behavior: auto;
}

/* The canvas is exactly as wide as the window is long. --days is written by the
   server; nothing measures it. */
/* A FILLING view — day, three-day, week — stretches to the width it is given
   rather than to a pixel count. Its --kito-cal-day-w is a percentage of the
   canvas (set inline by the frame), so every consumer of that variable keeps
   working unchanged: .cal-day's width, the bar's left/width, the half-day
   --cal-lead/--cal-trail insets, and the day-line gradient are all already
   expressed in terms of it, and a percentage resolves against the same box a
   pixel value occupied.

   Only the two rules that hard-code the CANVAS extent have to change, because
   `days × 100%` is not a width — it is the canvas measuring itself. */
#calendar-panes.cal-fills {
    width: 100%;
}
#calendar-panes.cal-fills > #calendar-canvas {
    width: auto;
    flex: 1 1 auto;
    /* Without this a flex item refuses to shrink below its content, and the
       seven columns would push the pane wider than the viewport again. */
    min-width: 0;
}

#calendar-canvas {
    width: calc(var(--days) * var(--kito-cal-day-w));
    flex: none;
    /* Dragging the canvas pans it, so it advertises that. A bar keeps its own
       pointer cursor — dragging a bar moves the booking. */
    cursor: grab;
}

/* For the duration of a pan: no text selection anywhere (a drag across the grid
   would otherwise highlight every guest name it crosses) and a grabbing cursor
   that survives the pointer leaving the canvas mid-gesture, which it does,
   because the gesture is tracked on the document. */
body[data-calendar-dragging] {
    cursor: grabbing;
    user-select: none;
    -webkit-user-select: none;
}
body[data-calendar-dragging] #calendar-canvas,
body[data-calendar-dragging] .cal-bar {
    cursor: grabbing;
}

#calendar-labels {
    width: var(--kito-cal-label-w);
}

/* The corner sits above both sticky axes, so it must out-rank the day header
   (which is sticky top) and the label pane (which is sticky left). */
#calendar-labels > .cal-corner,
#calendar-header {
    height: var(--kito-cal-header-h);
    box-sizing: border-box;
}

#calendar-header {
    border-bottom: 1px solid var(--color-outline-variant);
}

/* The corner is the TOP of the unit spine: the same 4px every label row reserves,
   so the "Units" heading sits on the same left edge as the names beneath it and
   the line runs unbroken from the header down the tree.
   Declared here rather than as utilities on the element so one place owns the
   width — TestTheUnitPaneReservesOneLeftBorderWidth reads this file and holds it
   equal to the rows'. Full outline-variant, matching the corner's own bottom and
   right rules: the header belongs to the frame, and the frame is stronger than
   the lines inside it. */
#calendar-labels > .cal-corner {
    border-left: 4px solid var(--color-outline-variant);
}

/* ─── Day headers: the ONLY per-day elements in the document ─── */
.cal-day {
    position: relative; /* the week marker is positioned against this column */
    width: var(--kito-cal-day-w);
    flex: none;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-end;
    padding-block: 0.25rem;
    border-right: 1px solid var(--kito-cal-line);
    text-align: center;
}
.cal-day-weekday {
    font-size: 9px;
    line-height: 1;
    color: color-mix(in srgb, var(--color-ink) 60%, transparent);
}
.cal-day-number {
    font-size: 0.75rem;
    font-weight: 600;
    color: color-mix(in srgb, var(--color-ink) 70%, transparent);
}
.cal-day-number-today {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.5rem;
    height: 1.5rem;
    border-radius: 9999px;
    color: var(--color-primary);
    background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
/* Calendar-week marker.
   Out of flow, and now legible. The header used to be 44px, into which a
   week-start column had to fit the marker, the weekday and the day number: 43px
   on an ordinary day and 55px when that day is also today (the today pill is
   24px). With `justify-content: flex-end` the overflow came off the top, so the
   marker was the thing that got clipped — and shrinking it to 8px to fit made it
   unreadable instead of invisible.

   Both are fixed by the header's 56px: absolute positioning costs the column no
   height at all, and there is now room for an 11px marker above the weekday
   without either touching. The top-left corner is where the week boundary
   already is. */
.cal-day-week {
    position: absolute;
    top: 3px;
    left: 3px;
    font-size: 11px;
    font-weight: 500;
    line-height: 1;
    font-variant-numeric: tabular-nums;
    color: var(--color-primary);
    background: color-mix(in srgb, var(--color-primary) 12%, transparent);
    border-radius: 3px;
    padding: 2px 4px;
    pointer-events: none;
}

/* ─── Rows ───
   The day lines are a repeating gradient. There is no element per (unit, day),
   so there is nothing to keep in sync, nothing to deduplicate, and nothing to
   backfill — the entire _fillMissingCells / _datesPresentFor bug class has no
   surface to exist on (AC-6).

   content-visibility keeps off-screen rows out of layout and paint with no
   script: no IntersectionObserver, no sentinel, no vertical-scroll controller
   (AC-22). The explicit height means skipping a row can never shift the ones
   below it. */
.cal-row {
    position: relative;
    box-sizing: border-box;
    height: calc(var(--lanes, 1) * var(--kito-cal-lane-h));
    border-bottom: 1px solid var(--kito-cal-line);
    content-visibility: auto;
    contain-intrinsic-size: auto calc(var(--lanes, 1) * var(--kito-cal-lane-h));
}

.cal-canvas-row {
    background-color: var(--color-surface);
    background-image: repeating-linear-gradient(
        to right,
        var(--kito-cal-line) 0 1px,
        transparent 1px var(--kito-cal-day-w)
    );
}

/* Group (non-bookable) rows keep the old day cell's quieter background. */
.cal-canvas-row.cal-group-row {
    background-color: var(--color-surface-container-low);
}

.cal-label-row {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    background-color: var(--color-surface);
    border-right: 1px solid var(--color-outline-variant);
    /* EVERY label row carries the same 4px left border, group or not, so a unit's
       text starts at the same x as its parent's and indentation is a function of
       depth alone. Only the COLOUR changes below — a width that varied by row
       kind would knock the whole tree half a character out of alignment, which is
       what it did when only group rows had one.
       TestEveryLabelRowReservesTheSameLeftBorder pins the widths together. */
    border-left: 4px solid var(--kito-cal-spine);
}

/* Depth accent on group labels — the same primary / secondary / tertiary ladder
   the old grid used, expressed once instead of in three branches of markup. */
.cal-label-row.cal-group-row { border-left: 4px solid var(--color-tertiary); }
.cal-label-row.cal-depth-0   { border-left-color: var(--color-primary); }
.cal-label-row.cal-depth-1   { border-left-color: var(--color-secondary); }

.cal-group-toggle { color: var(--color-tertiary); }
.cal-depth-0 .cal-group-toggle { color: var(--color-primary); }
.cal-depth-1 .cal-group-toggle { color: var(--color-secondary); }
.cal-group-toggle svg { transform: rotate(0deg); }
.cal-group-toggle[aria-expanded="true"] svg { transform: rotate(90deg); }

/* Both label kinds share a line box that hugs the glyphs.
   Without it the group name inherited the document's 1.5 line-height (21px at
   depth 0) while the unit name used Tailwind's text-xs pairing (16px), so the two
   centred on different-sized boxes: flexbox centres the LINE BOX, and a box with
   leading is not optically centred on the text inside it. Side by side in a 56px
   row that reads as the parent sitting slightly off. line-height: 1 removes the
   leading from the equation, so both centre the same way. */
.cal-group-name,
.cal-unit-name {
    line-height: 1;
    /* A flex item only shrinks below its content width once it has an explicit
       minimum; without this a long unit name pushes the pane wider instead of
       truncating. */
    min-width: 0;
}

.cal-group-name {
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--color-tertiary);
}

.cal-unit-name {
    font-size: 0.75rem;
    font-weight: 500;
    color: color-mix(in srgb, var(--color-ink) 80%, transparent);
}
.cal-depth-0 .cal-group-name { font-size: 0.875rem; font-weight: 900; color: var(--color-primary); }
.cal-depth-1 .cal-group-name { font-weight: 700; color: var(--color-secondary); }

/* ─── The today column ───
   ONE absolutely placed overlay, at the column the server resolved. It reuses
   the existing .calendar-today-column paint; only the placement is new, because
   there is no grid to span 1 / -1 any more. */
#calendar-today {
    position: absolute;
    top: 0;
    bottom: 0;
    left: calc(var(--col) * var(--kito-cal-day-w));
    width: var(--kito-cal-day-w);
    grid-row: auto;
}

/* ─── Bars ───
   left / width / top come from the three numbers the server wrote. Nothing
   recomputes them; a live update rewrites --col and --len on the same element
   and the browser does the rest (AC-19).

   --cal-lead / --cal-trail carry the half-day insets that the grid version
   expressed as margins. Margins cannot be used here: on an absolutely positioned
   box with both left and width set, a margin shifts the box without shrinking
   it, so a check-in bar would overhang its own last day. Folding them into the
   offsets keeps the identical taper and the identical geometry. */
.cal-bar {
    --cal-lead: 0px;
    --cal-trail: 0px;
    position: absolute;
    z-index: 2;
    left: calc(var(--col) * var(--kito-cal-day-w) + var(--cal-lead) + 1px);
    width: calc(var(--len) * var(--kito-cal-day-w) - var(--cal-lead) - var(--cal-trail) - 2px);
    top: calc(var(--lane, 0) * var(--kito-cal-lane-h) + var(--kito-cal-lane-gutter));
    /* 44px in the default 56px lane — the bar IS the touch target, so this is a
       floor, not a preference (AC-25). Spacing is bought by growing the lane,
       never by shrinking this. */
    height: calc(var(--kito-cal-lane-h) - 2 * var(--kito-cal-lane-gutter));
    /* A bar is placed by offset, so it has no margin to give. This says so, and
       it is a statement rather than a defence: it used to be the defence
       against the half-cell margin .gantt-checkin-edge declared, and it lost,
       because `.gantt-checkin-edge.gantt-checkout-edge` is two classes and this
       is one. Source order does not beat specificity. The margins are gone from
       those rules now; if one comes back, this line will not save the calendar
       and TestCalendarWindowBar_EveryEdgeCombinationResolvesToZeroMargin will
       say so. */
    margin: 0;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    font-size: 0.75rem;
    font-weight: 500;
    white-space: nowrap;
    text-decoration: none;
    cursor: pointer;
    overflow: hidden;
}
/* The bar's PAINT sits in :where() — zero specificity — so the shared
   .gantt-status-* / .gantt-clipped-* / .gantt-*-edge rules, which appear earlier
   in this file, still win. A plain `.cal-bar { background: … }` here would have
   silently repainted every booking slate grey, because equal specificity is
   resolved by source order. */
:where(.cal-bar) {
    padding: 0;
    border: none;
    border-radius: 0.5rem;
    background: #e2e8f0;
    color: #475569;
}
.cal-bar:hover {
    box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
    z-index: 20;
}
.cal-bar > span {
    display: flex;
    align-items: center;
}
/* The guest name truncates rather than disappearing behind an icon. */
.cal-bar .gantt-bar-guest-name {
    display: block;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
}
/* Bars too narrow for status icons give the space to the name — the same spans
   and the same rule as the grid version. */
.cal-bar[data-span="1"] .gantt-bar-icons,
.cal-bar[data-span="2"] .gantt-bar-icons,
.cal-bar[data-span="3"] .gantt-bar-icons,
.cal-bar.gantt-checkin-edge[data-span="4"] .gantt-bar-icons,
.cal-bar.gantt-checkout-edge[data-span="4"] .gantt-bar-icons,
.cal-bar.gantt-checkin-edge[data-span="5"] .gantt-bar-icons,
.cal-bar.gantt-checkout-edge[data-span="5"] .gantt-bar-icons {
    display: none;
}
/* Half-day edges: the taper (clip-path, radii, padding) still comes from
   .gantt-checkin-edge / .gantt-checkout-edge; only the inset moves from margin
   to offset. */
.cal-bar.gantt-checkin-edge  { --cal-lead:  calc(var(--kito-cal-day-w) / 2 - 1px); }
.cal-bar.gantt-checkout-edge { --cal-trail: calc(var(--kito-cal-day-w) / 2 - 1px); }
.cal-bar.gantt-clipped-start { --cal-lead: 0px; }
.cal-bar.gantt-clipped-end   { --cal-trail: 0px; }

/* Relocation hold bar (booking-relocation-guardrails Task 14.2, design § 11).

   A hold is not a booking, and it must not be readable as one at a glance: it is
   drawn DASHED, on the warning tone, with a transparent-ish fill, in a lane below
   the row's bookings. The placement mechanism is unchanged — it is a .cal-bar
   and it is positioned from --col / --len / --lane like every other bar, because
   a second placement mechanism is how the calendar's bars stopped agreeing with
   its columns.

   The paint sits at full specificity (0,1,0) so it beats .cal-bar's zero-
   specificity :where() default, and it carries no .gantt-status-* class at all —
   there is no booking status here to colour by. */
.cal-hold-bar {
    background: color-mix(in srgb, var(--color-warning-container) 55%, transparent);
    color: var(--color-on-warning-container);
    border: 1px dashed var(--color-warning);
    border-radius: 0.375rem;
    font-style: italic;
}
.cal-hold-bar:hover {
    background: color-mix(in srgb, var(--color-warning-container) 80%, transparent);
}

/* "Move pending" — the source booking of an active hold. The bar keeps its own
   status colour (the booking has NOT moved and may never move), and gains
   this pill. On bars too narrow for status icons the pill goes with them, which
   is the existing rule for that space rather than a new one. */
.gantt-move-pending {
    display: inline-flex;
    align-items: center;
    gap: 0.125rem;
    padding: 0 0.25rem;
    border-radius: 9999px;
    border: 1px dashed var(--color-warning);
    background: var(--color-warning-container);
    color: var(--color-on-warning-container);
    font-size: 0.625rem;
    font-weight: 600;
    line-height: 1.25;
    white-space: nowrap;
}

/* View options, server-rendered onto the viewport. Same layers, same selectors
   as the grid version — the class is just decided by the persisted document
   instead of localStorage. */
/* ═══════════════════════════════════════════════════════════════════════════
   MOBILE — the day view IS the mobile calendar
   (spec calendar-views-migration, Task 6)

   There is no mobile template, no mobile route and no mobile branch on the
   server. `mobile_calendar_controller.js` (752 lines) rendered room rows in the
   browser from a chunk endpoint; all of it asked the question the day view
   already answers. What is left is a PRESENTATION: the same frame, the same
   window, narrower.

   ── The columns are NOT here ──────────────────────────────────────────────
   Making the day, 3-day and week columns fit the screen is `cal-fills` above:
   those views express a column as a FRACTION of the canvas, at every width, so
   they need no breakpoint and no viewport arithmetic. That is strictly better
   than a mobile override — an expression with no width term in it is right
   everywhere — and it is why nothing below touches --kito-cal-day-w.

   ── What IS mobile-specific: the label pane ───────────────────────────────
   220px of a 390px phone is more than half the screen spent on unit names, and
   with a filling canvas it is also half the space the columns divide. The canvas
   this replaces used 100px (mobile_calendar_controller.js: LABEL_WIDTH); 8.25rem
   is the compromise — narrower than the desktop pane, wider than the one it
   replaces.

   It costs something, stated plainly: the label width appears in the centred
   scroll formula as L/2 (calendar.ScrollLeftFor), so on a phone the month view
   lands today (220 − 132) / 2 = 44px off centre. A test bounds that at less than
   one day column — today drifting inside the viewport is cosmetic, today
   drifting out of it is not. The filling views are unaffected: their content
   fits, so their offset is 0 either way.

   The breakpoint is DESKTOP_MIN_WIDTH − 1 from booking_info_modal.js, so the
   layout and the full-screen booking sheet switch at the same width.
   TestTheMobileLayoutAndTheMobileSheetShareOneBreakpoint reads both files.

   The selector is #calendar-viewport[data-testid] rather than
   #calendar-viewport: it out-specifies the base rule without !important, and it
   keeps the literal "#calendar-viewport {" appearing exactly ONCE in this file —
   the geometry guard finds that rule by substring, and it must keep finding the
   desktop one.
   ═══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1023px) {
    #calendar-viewport[data-testid] {
        --kito-cal-label-w: 8.25rem;
    }
}

/* The LANDING demo goes narrower still, and only on a phone.
   demo_calendar_controller switches that showcase to a week framing below 640px —
   seven columns filling the canvas, the way the product's own week view fills its
   canvas — and seven columns have to divide what is left after the label pane.
   At 8.25rem of a 390px screen they get 32px each, which is a day number and no
   room for the weekday above it. 6.5rem buys back 28px, so a column is 36px.

   The pane is still wider than the 100px the retired mobile calendar used, and it
   is a landing-page showcase of five short property names rather than the real
   calendar's unit tree, so it can afford to be. Everything else about the frame is
   inherited unchanged, which is the whole point of the demo being the real grid. */
@media (max-width: 640px) {
    #calendar-viewport[data-testid="landing-demo-calendar-viewport"] {
        --kito-cal-label-w: 6.5rem;
    }
}

#calendar-viewport.hide-financials .gantt-bar-badge--payment { display: none; }
#calendar-viewport.hide-housekeeping .gantt-bar-badge--housekeeping { display: none; }
#calendar-viewport.hide-operations .gantt-bar-badge--messages { display: none; }
#calendar-viewport.hide-guest-info .gantt-bar-guest-name { opacity: 0; width: 0; overflow: hidden; }
#calendar-viewport.hide-rates .gantt-rate-label { display: none; }

/* The calendar legend — a right-anchored disclosure that slides in.
   Off-screen to the RIGHT when closed and settling to its anchor when open, so
   the motion reads as coming from the button rather than dropping out of it.
   The transition is on transform and opacity only; `hidden` does the layout, so
   nothing animates width or height and nothing reflows the toolbar. */
.cal-legend {
    display: flex;
    transform: translateX(0.75rem);
    opacity: 0;
    transition: transform 150ms ease-out, opacity 150ms ease-out;
}
.cal-legend[data-open] {
    transform: translateX(0);
    opacity: 1;
}
.cal-legend.hidden {
    display: none;
}
@media (prefers-reduced-motion: reduce) {
    .cal-legend { transition: none; }
}

/* ══════════════════════════════════════════════════════════════════════════
   Mechanical latch indicators — unit KPI summary cards
   ══════════════════════════════════════════════════════════════════════════
   A vintage door-latch plate ("VACANT" / "ENGAGED") for the Available and
   Occupied cards on the units index. Purely a status gauge: it carries no
   action, no focus, no hover state — anything that reads as pressable would
   promise an interaction the card does not have.

   Plain CSS (app.css is not Tailwind-processed) and deliberately a component
   rather than a pile of utilities: the recessed look is one inset shadow, one
   hairline border and one hairline top highlight that have to stay in step
   with each other, and the border-radius/padding are tuned to sit inside the
   existing rounded-xl cards without competing with them.

   The palette is the Tailwind emerald/rose 50–800 range the spec calls for,
   written as literals so the plate keeps its enamel tone regardless of the
   semantic --color-* tokens the surrounding card follows. Dark theme swaps to
   translucent tints of the same hues — an enamel-white plate at full strength
   would glare on a dark surface. */
.indicator-latch {
    display: inline-flex;
    align-items: center;
    gap: 0.375rem;
    padding: 0.1875rem 0.5rem;
    border: 1px solid transparent;
    border-radius: 0.375rem;
    /* text-xs / tracking-widest, matching the plain captions on the two cards
       beside it so the row keeps one type scale. */
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
    font-size: 0.75rem;
    line-height: 1rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    /* The recess: a soft inner shadow plus a 1px light edge along the top, which
       is what sells "sunk into the card" rather than "floating on it". */
    box-shadow:
        inset 0 1px 2px rgb(0 0 0 / 0.06),
        inset 0 1px 0 rgb(255 255 255 / 0.6);
    /* Decoration, not a control. */
    cursor: default;
    user-select: none;
    white-space: nowrap;
}

/* The status lamp. A drawn dot rather than an emoji: it takes the plate's own
   colour, scales with the text, and stays silent to screen readers, which read
   the legend instead. */
.indicator-latch__dot {
    width: 0.375rem;
    height: 0.375rem;
    border-radius: 9999px;
    background-color: currentColor;
    /* Recessed lamp, not a glowing halo: a hairline of shade along the bottom of
       the dot, matching the direction of the plate's own inset shadow. */
    box-shadow: inset 0 -1px 1px rgb(0 0 0 / 0.25);
    flex-shrink: 0;
}

.indicator-latch--vacant {
    background-color: #ecfdf5; /* emerald-50 */
    border-color: #a7f3d0;     /* emerald-200 */
    color: #065f46;            /* emerald-800 */
}

.indicator-latch--engaged {
    background-color: #fff1f2; /* rose-50 */
    border-color: #fecdd3;     /* rose-200 */
    color: #9f1239;            /* rose-800 */
}

[data-theme="dark"] .indicator-latch {
    box-shadow:
        inset 0 1px 3px rgb(0 0 0 / 0.35),
        inset 0 1px 0 rgb(255 255 255 / 0.04);
}
[data-theme="dark"] .indicator-latch--vacant {
    background-color: rgb(6 78 59 / 0.35);  /* emerald-900 @ 35% */
    border-color: rgb(16 185 129 / 0.35);   /* emerald-500 @ 35% */
    color: #6ee7b7;                          /* emerald-300 */
}
[data-theme="dark"] .indicator-latch--engaged {
    background-color: rgb(136 19 55 / 0.35); /* rose-900 @ 35% */
    border-color: rgb(244 63 94 / 0.35);     /* rose-500 @ 35% */
    color: #fda4af;                           /* rose-300 */
}

/* ── Split-flap roll for the metric number ──────────────────────────────────
   Played by the unit--latch-metric controller when a re-render brings a count
   this tab has not seen before — a Turbo Frame reload, a Turbo Stream swap, an
   SSE-driven refresh. It is one flap, not a loop: the card is glanced at, not
   watched, so the motion only has to say "this just changed".

   transform-origin at the top edge with a perspective hinge is what makes it
   read as a card flipping down on a hinge rather than a plain fade. */
@keyframes latch-flap-in {
    0% {
        transform: perspective(120px) rotateX(-88deg);
        opacity: 0;
    }
    55% {
        transform: perspective(120px) rotateX(12deg);
        opacity: 1;
    }
    100% {
        transform: perspective(120px) rotateX(0deg);
        opacity: 1;
    }
}

.latch-metric-flap {
    animation: latch-flap-in 420ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
    transform-origin: 50% 0%;
    backface-visibility: hidden;
}

/* Reduced motion still gets the state change, just without the hinge. */
@media (prefers-reduced-motion: reduce) {
    .latch-metric-flap {
        animation: none;
        transition: opacity 150ms ease-out;
    }
}

/* ------------------------------------------------------------------------------------------------------------------ */
/* Landing header                                                                                                      */
/* ------------------------------------------------------------------------------------------------------------------ */
/* The bar has TWO appearances and they are written together on purpose, because
   the failure mode of splitting them across utility classes in the template is
   that one gets updated and the other does not — and the broken half is the one
   that only appears after you scroll.

     default      opaque surface, dark type. Fifteen landing pages open on a white
                  document and this is correct for all of them.
     --immersive  transparent with a dark blur, light type. For a page whose first
                  screen is a full-bleed photograph, where the opaque bar cuts a
                  hard white block across the top of the image.

   .is-solid, set by landing_nav_controller.js, returns an immersive bar to the
   default appearance once the image it was floating over has scrolled away. */

/* One source for the bar's height. It is declared on :root rather than on the bar
   because the DIORAMA reads it too — every chapter clears the fixed header by
   deriving its top padding from this number, and a chapter that guesses at the
   height is a heading that disappears behind the bar the moment either changes.

   120px is a generous bar and it is right on a desktop. On a phone it is a sixth
   of the screen spent on a logo and a hamburger, and it was the single largest
   reason the story's chapters did not fit their viewport. */
:root {
    --landing-nav-height: 7.5rem;
}

/* The switch is lg, not md, and it moved there with the mega menu. Below lg the
   bar shows a logo and a hamburger, and a 120px bar to hold two elements is the
   same waste on a tablet that it always was on a phone.

   Written as Tailwind writes it — `(width < 64rem)`, which is verbatim what the
   `lg:` variant compiles to — because this has to be the SAME boundary as the
   `lg:` classes in landing_header.gohtml and DESKTOP_QUERY in both landing
   controllers.

   Two ways to get that wrong, and both were made here before this line was:
   pixels agree with `lg:` only at a 16px default font size, and `max-width:
   63.999rem` leaves a hairline at 1023.99px that matches neither side. Range
   syntax has no complement to compute. */
@media (width < 64rem) {
    :root {
        --landing-nav-height: 4.5rem;
    }
}

.landing-nav {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 50;
    background: var(--color-surface);
    border-bottom: 1px solid var(--color-outline-variant);
    transition: background-color 320ms ease, border-color 320ms ease, backdrop-filter 320ms ease;
}

/* 28px above and below a 64px logo, up from 16px. The bar was not short, it was
   tight — the logo very nearly touched both edges, which reads as cramped however
   tall the bar is. */
.landing-nav__row {
    height: var(--landing-nav-height);
}

.landing-nav__logo {
    color: var(--color-primary);
}

.landing-nav__link {
    color: var(--color-on-surface-variant);
}

.landing-nav__link:hover {
    color: var(--color-on-surface);
}

/* .landing-nav__cta is gone. It styled the Contact link as a ghost button, and
   Contact moved into the Company panel when the mega menu landed, so the class had
   no user left in any template. The bar now holds two triggers, two plain links
   and one filled button, which is the hierarchy the ghost was there to create in
   the first place. */

.landing-nav__locale {
    color: var(--color-on-surface-variant);
}

.landing-nav__locale:hover {
    background: var(--color-surface-container-low);
    color: var(--color-on-surface);
}

.landing-nav__burger {
    border: 1px solid var(--color-outline-variant);
    background: var(--color-surface);
    color: var(--color-on-surface);
}

.landing-nav__burger:hover {
    background: var(--color-surface-container-low);
}

/* The burger is the close button too, and the glyph says which. Driven off
   aria-expanded rather than a class, because that attribute has to be correct for
   screen readers anyway — a second class toggled alongside it is a second source
   for one fact, and the two disagree the first time someone edits only one. */
.landing-nav__burger-cross,
.landing-nav__burger[aria-expanded="true"] .landing-nav__burger-bars {
    display: none;
}

.landing-nav__burger[aria-expanded="true"] .landing-nav__burger-cross {
    display: block;
}

.landing-nav__backdrop,
.landing-nav__drawer {
    top: var(--landing-nav-height);
}

/* ── Immersive ─────────────────────────────────────────────────────────────── */

.landing-nav--immersive:not(.is-solid) {
    background: transparent;
    border-bottom-color: transparent;
}

/* ── The tint and blur live on a PSEUDO-ELEMENT, and that is not a style choice ──
   They carry a `mask-image`, and a mask applies to the element AND EVERY
   DESCENDANT. On the <nav> itself it therefore clipped the language switcher's
   dropdown, which hangs below the bar: the panel opened, faded out through the
   mask's gradient and was cut off at nothing. Nobody looking at that would guess
   the cause was a decorative fade.

   On a pseudo-element the mask has nothing to clip but itself, and the bar's
   contents are free to overflow it. The row is given its own stacking position
   below so it draws above this layer rather than under it. */
.landing-nav--immersive:not(.is-solid)::before {
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    /* A gradient rather than a flat tint: the type needs a backing at the very top
       and the image needs to be unobstructed a few pixels below it, and a solid
       translucent panel gives a visible bottom edge that is just a fainter version
       of the white bar this replaces. */
    /* Slate, and light. This was taken up to 86% opacity over a near-black once and
       it was worse: at that weight the gradient stops reading as a shadow the type
       is sitting in and becomes a bar again — a dark one instead of a white one,
       which is the same problem wearing a different coat. The photograph has to
       stay visible through it. */
    background-image: linear-gradient(to bottom, rgb(15 23 42 / 66%) 0%, rgb(15 23 42 / 36%) 55%, transparent 100%);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    /* The blur must not extend past the type into clear image, or the top of the
       photograph looks smeared. */
    mask-image: linear-gradient(to bottom, #000 0%, #000 62%, transparent 100%);
    -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 62%, transparent 100%);
}

/* Above the tint layer. Without this the pseudo-element — being positioned —
   paints over the static logo and links it is supposed to sit behind. */
.landing-nav__inner {
    position: relative;
    z-index: 1;
}

.landing-nav--immersive:not(.is-solid) .landing-nav__logo {
    color: #fff;
}

/* EVERY control in the bar belongs in these two rules, and the mega triggers were
   left out of them when they were added — so Platform and Company rendered in
   on-surface-variant, a mid grey mixed for white paper, directly over a
   photograph. Unreadable, and only on the ten pages that wear the immersive skin,
   which is every page a visitor actually browses.

   The trap is that a new control looks right while you build it: the solid bar is
   the default and the grey is correct there. Add anything to
   landing_header.gohtml and add it here in the same edit. */
.landing-nav--immersive:not(.is-solid) .landing-nav__link,
.landing-nav--immersive:not(.is-solid) .landing-nav__locale,
.landing-nav--immersive:not(.is-solid) .landing-mega-trigger {
    color: rgb(255 255 255 / 78%);
}

/* aria-expanded rides with :hover because an open panel's trigger is the one the
   visitor is using — it reads as active, the same as a pointer on it.

   These have to name the trigger explicitly rather than leaning on
   .landing-mega-trigger:hover above. That rule is (0,2,0) and the immersive
   selector is (0,3,0), so without this the immersive colour would win over the
   hover and the trigger would stop responding at exactly the moment it should. */
.landing-nav--immersive:not(.is-solid) .landing-nav__link:hover,
.landing-nav--immersive:not(.is-solid) .landing-nav__locale:hover,
.landing-nav--immersive:not(.is-solid) .landing-mega-trigger:hover,
.landing-nav--immersive:not(.is-solid) .landing-mega-trigger[aria-expanded="true"] {
    color: #fff;
    background: transparent;
}

.landing-nav--immersive:not(.is-solid) .landing-nav__cta {
    border-color: rgb(255 255 255 / 45%);
    color: #fff;
}

.landing-nav--immersive:not(.is-solid) .landing-nav__cta:hover {
    border-color: #fff;
    background: rgb(255 255 255 / 12%);
}

.landing-nav--immersive:not(.is-solid) .landing-nav__burger {
    border-color: rgb(255 255 255 / 40%);
    background: rgb(15 23 42 / 45%);
    color: #fff;
}

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

/* ------------------------------------------------------------------------------------------------------------------ */
/* Landing mega menu                                                                                                   */
/* ------------------------------------------------------------------------------------------------------------------ */
/* Two triggers in the bar, two sheets under it, and the same rows repeated in the
   phone drawer. The markup is templates/landing/partials/nav/mega_menu.gohtml and
   the items come from template/landing_nav_menu.go — read that file first, it is
   where a new entry gets added.

   ── One appearance, both header skins ────────────────────────────────────────
   The bar has two looks (see § "Landing header" above) and the sheet has one: the
   dark glass, everywhere. Deliberate, and worth saying why, because the obvious
   move is to give the solid bar a white panel to match it.

   A menu is not part of the page it opens over. Every destination in it — the
   platform, migration, contact, legal — is a dark page, so a white sheet would be
   a flash of the wrong material between the click and the page. And there are
   only three light-chrome landing pages left (/register and the public detail
   pages), so a second skin would be a whole parallel palette maintained for the
   pages nobody browses a menu on.

   ── The breakpoint, in four places ───────────────────────────────────────────
   lg: the media queries below, the `lg:` classes in the header template, and
   DESKTOP_QUERY in BOTH landing controllers. Below it the sheets are
   display:none and the drawer answers instead.

   All four are written as `64rem`, which is what Tailwind compiles `lg:` to. Two
   earlier versions of this comment described a boundary the code did not have:
   pixels drift from `lg:` at any default font size but 16px, and `max-width:
   63.999rem` leaves a hairline that matches neither side.

   xl is the second switch and only the featured card depends on it: at lg the
   card takes the width the two link columns need. */

.landing-mega-trigger {
    display: inline-flex;
    align-items: center;
    gap: 0.375rem;
    min-height: 44px;
    /* The bar's own links are plain text, so this is too. A trigger with a border
       or a fill would read as the bar's call to action, which is Book a call four
       elements to the right. */
    font-size: 1rem;
    font-weight: 500;
    color: var(--color-on-surface-variant);
    background: none;
    border: none;
    cursor: pointer;
    transition: color 200ms ease;
}

.landing-mega-trigger:hover,
.landing-mega-trigger[aria-expanded="true"] {
    color: var(--color-on-surface);
}

.landing-mega-trigger__chevron {
    width: 1rem;
    height: 1rem;
    /* Half a step down from the label. At full opacity the chevron competes with
       the word it belongs to and the bar reads as five glyphs rather than two
       labels. */
    opacity: 0.6;
    transition: transform 220ms ease, opacity 200ms ease;
}

.landing-mega-trigger[aria-expanded="true"] .landing-mega-trigger__chevron {
    transform: rotate(180deg);
    opacity: 1;
}

.landing-mega-trigger:focus-visible {
    outline: 2px solid var(--color-primary);
    outline-offset: 4px;
    border-radius: 0.375rem;
}

/* ── The sheet ─────────────────────────────────────────────────────────────── */

.landing-mega {
    position: absolute;
    top: var(--landing-nav-height);
    left: 0;
    right: 0;
    z-index: 45;
    /* Under the bar's own row (z-index 1 inside .landing-nav__inner) but over the
       backdrop at 40, so the scrim dims the page and not the menu. */
    border-bottom: 1px solid rgb(255 255 255 / 10%);
    /* The same recipe as .landing-immersive::before, so the sheet is made of the
       material the pages it links to are made of. Not a copy that has to be kept
       in step: the values are the gradient's identity and they are commented at
       source — sunset warm top right, wood glow bottom left, cold sky above, over
       a slate that deepens downward. */
    background:
        radial-gradient(90% 120% at 82% 0%, rgb(232 135 63 / 18%) 0%, transparent 62%),
        radial-gradient(70% 100% at 4% 100%, rgb(198 128 74 / 12%) 0%, transparent 66%),
        linear-gradient(180deg, rgb(14 20 28 / 97%) 0%, rgb(13 18 25 / 98%) 100%);
    /* The landing page's hero is a photograph and the bar floats over it, so the
       few pixels of image beside the sheet's shadow are real detail worth
       softening. This is the one place on a landing page where a blur does
       something — see § "Landing — the immersive skin" for why the pages
       themselves have none. */
    backdrop-filter: blur(20px);
    -webkit-backdrop-filter: blur(20px);
    box-shadow: 0 32px 64px -24px rgb(0 0 0 / 65%);
    animation: landing-mega-in 180ms ease-out;
}

@keyframes landing-mega-in {
    from {
        opacity: 0;
        /* Four pixels. The sheet should look like it came from behind the bar,
           not like it flew in — anything further reads as a transition rather
           than as a panel appearing. */
        transform: translateY(-4px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

/* A hairline of light along the top edge, where the sheet meets the bar. Without
   it the two dark surfaces merge and the sheet has no top. */
.landing-mega::before {
    content: "";
    position: absolute;
    inset: 0 0 auto 0;
    height: 1px;
    background: rgb(255 255 255 / 8%);
    pointer-events: none;
}

.landing-mega__inner {
    padding-top: 2.5rem;
    padding-bottom: 2.5rem;
}

.landing-mega__grid {
    display: grid;
    gap: 2.5rem;
}

.landing-mega__links {
    display: grid;
    /* Rows sit flush against each other and pick up their padding from the item.
       A gap here would double the space the hover fill already implies. */
    gap: 0;
    align-content: start;
}

/* ── Rows ──────────────────────────────────────────────────────────────────── */
/* Shared with the drawer. Nothing below this point knows which surface it is in,
   which is what lets one partial render both. */

.landing-nav-item {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: 0.875rem;
    align-items: start;
    min-height: 44px;
    padding: 0.75rem;
    border-radius: 0.75rem;
    transition: background-color 180ms ease;
}

.landing-nav-item:hover {
    background: rgb(255 255 255 / 6%);
}

.landing-nav-item:focus-visible {
    outline: 2px solid var(--color-primary);
    outline-offset: 2px;
}

.landing-nav-item__icon {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2.5rem;
    height: 2.5rem;
    flex-shrink: 0;
    border-radius: 0.625rem;
    border: 1px solid rgb(255 255 255 / 12%);
    background: rgb(255 255 255 / 6%);
    color: rgb(255 255 255 / 82%);
    transition: background-color 180ms ease, border-color 180ms ease, color 180ms ease;
}

/* The badge is where the brand colour enters the menu. On the row itself a
   primary tint would fight the description's grey; on a 40px square it reads as
   the item lighting up. */
.landing-nav-item:hover .landing-nav-item__icon {
    border-color: rgb(79 179 212 / 45%);
    background: rgb(79 179 212 / 16%);
    color: #9ad9ee;
}

.landing-nav-item__body {
    display: flex;
    flex-direction: column;
    gap: 0.125rem;
    min-width: 0;
}

.landing-nav-item__title {
    display: inline-flex;
    align-items: center;
    gap: 0.375rem;
    font-size: 0.9375rem;
    font-weight: 600;
    color: #fff;
}

.landing-nav-item__external {
    font-size: 0.75rem;
    color: rgb(255 255 255 / 45%);
}

.landing-nav-item__desc {
    font-size: 0.875rem;
    line-height: 1.5;
    color: rgb(255 255 255 / 58%);
}

/* ── The featured card ─────────────────────────────────────────────────────── */

.landing-nav-feature {
    display: none;
    flex-direction: column;
    /* Its own height, not the grid row's. A 7:3 strip plus three lines of body is
       shorter than the Company column's five rows, and stretched to match it the
       card is a picture, some text and then a hand's width of empty glass. */
    align-self: start;
    /* The card is a link and clears this several times over — a 16:10 image in a
       320px column is 200px before the body starts. Declared anyway so the floor
       is stated rather than inferred from a picture that could be swapped for a
       shorter one. */
    min-height: 44px;
    overflow: hidden;
    border-radius: 1rem;
    border: 1px solid rgb(255 255 255 / 12%);
    background: rgb(255 255 255 / 4%);
    transition: border-color 200ms ease, background-color 200ms ease;
}

.landing-nav-feature:hover {
    border-color: rgb(255 255 255 / 22%);
    background: rgb(255 255 255 / 7%);
}

.landing-nav-feature:focus-visible {
    outline: 2px solid var(--color-primary);
    outline-offset: 2px;
}

/* 7:3, which is 1680×720 exactly — the shape every file in
   assets/images/banners/ is authored at.

   It was 16:10, and that is why the picture looked wrong. `cover` against a 2.33:1
   source in a 1.6:1 box throws away 31% of the width, so the card showed the
   middle of a composed banner with both ends cut off, and the part it kept was
   blown up further than it needed to be. Matching the source means no crop at all
   and the largest downscale the box allows, which is where a raster image looks
   sharpest.

   NEW ARTWORK GOES IN AT THIS RATIO. Anything squarer gets its sides cropped by
   the `cover` below, which is the right failure — better a crop than a stretch —
   but it is a crop nobody asked for. */
.landing-nav-feature__frame {
    display: block;
    aspect-ratio: 7 / 3;
    overflow: hidden;
}

.landing-nav-feature__frame img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* The image used to ease to scale(1.04) on hover. It is gone, and it was the
   other half of the "not sharp" problem.

   A transform promotes the image to its own composited layer and resamples it,
   and a 4% UPSCALE resamples it above its natural size — so the picture was
   softer than the file it came from, and softer again while the transition ran.
   On a 137px-tall strip that is all anyone sees.

   The card is not left without a hover state. Its border lifts, its fill lifts and
   the arrow in the CTA slides, all of which are cheap and none of which touch a
   pixel of the photograph. */

.landing-nav-feature__body {
    display: flex;
    flex-direction: column;
    gap: 0.375rem;
    padding: 1.125rem 1.25rem 1.25rem;
}

.landing-nav-feature__title {
    font-size: 0.9375rem;
    font-weight: 600;
    color: #fff;
}

.landing-nav-feature__text {
    font-size: 0.875rem;
    line-height: 1.5;
    color: rgb(255 255 255 / 58%);
}

.landing-nav-feature__cta {
    display: inline-flex;
    align-items: center;
    gap: 0.375rem;
    margin-top: 0.375rem;
    font-size: 0.875rem;
    font-weight: 600;
    color: #7cc9e4;
}

.landing-nav-feature__cta span {
    transition: transform 200ms ease;
}

.landing-nav-feature:hover .landing-nav-feature__cta span {
    transform: translateX(3px);
}

/* ── The scrim ─────────────────────────────────────────────────────────────── */

.landing-mega-backdrop {
    position: fixed;
    top: var(--landing-nav-height);
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 40;
    background: rgb(9 12 17 / 55%);
    backdrop-filter: blur(2px);
    -webkit-backdrop-filter: blur(2px);
    animation: landing-mega-scrim-in 220ms ease-out;
}

@keyframes landing-mega-scrim-in {
    from { opacity: 0; }
    to { opacity: 1; }
}

/* ── Widths ────────────────────────────────────────────────────────────────── */

/* The three widths below are written exactly as Tailwind writes `lg:` and `xl:`,
   so they cannot drift from the classes in the template. See the note by
   --landing-nav-height above for the two ways of getting this wrong. */

@media (width < 64rem) {
    /* The sheets and the scrim belong to the desktop bar. Below lg the drawer
       renders the same items and these must not be reachable at all — a panel
       left displayable here is a full-width dark sheet a phone can open by
       resizing into it. */
    .landing-mega,
    .landing-mega-backdrop {
        display: none !important;
    }
}

@media (width >= 64rem) {
    /* Two link columns once a group has more than two items. The class is set in
       the template from the item count rather than by a media query alone,
       because a two-item group in two columns is one row of links beside one row
       of white space. */
    .landing-mega__links--split {
        grid-template-columns: repeat(2, minmax(0, 1fr));
        column-gap: 1.5rem;
    }
}

@media (width >= 80rem) {
    .landing-mega__grid {
        grid-template-columns: minmax(0, 1fr) 20rem;
    }

    .landing-nav-feature {
        display: flex;
    }
}

@media (prefers-reduced-motion: reduce) {
    .landing-mega,
    .landing-mega-backdrop {
        animation: none;
    }

    .landing-mega-trigger__chevron,
    .landing-nav-item,
    .landing-nav-item__icon,
    .landing-nav-feature,
    .landing-nav-feature__cta span {
        transition: none;
    }

    .landing-nav-feature:hover .landing-nav-feature__cta span {
        transform: none;
    }
}

/* ------------------------------------------------------------------------------------------------------------------ */
/* Landing drawer                                                                                                      */
/* ------------------------------------------------------------------------------------------------------------------ */
/* The phone and tablet half of the mega menu. Same items, same row component,
   different disclosure — one collapsible group at a time instead of one sheet.

   It used to be `bg-surface`: a white sheet dropping over a dark photograph on
   every landing page. It is now the same glass as the sheets, so the two surfaces
   are recognisably one menu and neither hands the visitor a material the site
   does not otherwise use. */

.landing-nav__drawer {
    /* The bar is fixed and this hangs off it, so the drawer's own height has to
       be capped or a long menu scrolls the document behind a locked body and the
       last item is unreachable. */
    max-height: calc(100dvh - var(--landing-nav-height));
    overflow-y: auto;
    overscroll-behavior: contain;
    border-bottom: 1px solid rgb(255 255 255 / 10%);
    background:
        radial-gradient(120% 60% at 80% 0%, rgb(232 135 63 / 14%) 0%, transparent 60%),
        linear-gradient(180deg, rgb(14 20 28 / 98%) 0%, rgb(13 18 25 / 99%) 100%);
    backdrop-filter: blur(20px);
    -webkit-backdrop-filter: blur(20px);
    box-shadow: 0 32px 64px -24px rgb(0 0 0 / 70%);
    color-scheme: dark;
}

.landing-drawer__scroll {
    padding: 0.75rem 0.75rem 0;
}

/* No rules between the top-level rows. There were, between the two groups and
   above the direct links, from when the groups were headings and the lines were
   what separated a heading's territory from the next one's. Now that all four
   read as one kind of row, a divider between Platform and Company but not between
   Pricing and Demo is a line with nothing to say.

   The one rule that stays is above the foot, where the content genuinely changes
   from navigation to an action and a setting. */

/* Set exactly like .landing-drawer__link below, because in this drawer they are
   the same kind of thing.

   The first version made these small, uppercase, tracked and dimmer, borrowed
   from the footer's column headings. That is right in a footer, where a heading
   sits above a list you can already see and its job is to label it. It is wrong
   here: the groups are COLLAPSED, so the heading is the only thing on screen, and
   a visitor taps Platform for the same reason they tap Pricing. Dimming it put
   the two items that lead somewhere further below the two that lead to one page
   each, which is the hierarchy upside down — and it read as disabled.

   What marks the difference is the chevron, which is what a chevron is for. */
.landing-drawer__summary {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    min-height: 48px;
    padding: 0 0.75rem;
    border-radius: 0.625rem;
    font-size: 1rem;
    font-weight: 500;
    color: rgb(255 255 255 / 88%);
    background: none;
    border: none;
    cursor: pointer;
    transition: background-color 180ms ease, color 180ms ease;
}

.landing-drawer__summary:hover,
.landing-drawer__summary[aria-expanded="true"] {
    background: rgb(255 255 255 / 6%);
    color: #fff;
}

.landing-drawer__summary:focus-visible {
    outline: 2px solid var(--color-primary);
    outline-offset: -2px;
    border-radius: 0.5rem;
}

.landing-drawer__chevron {
    width: 1.125rem;
    height: 1.125rem;
    opacity: 0.6;
    transition: transform 220ms ease;
}

.landing-drawer__summary[aria-expanded="true"] .landing-drawer__chevron {
    transform: rotate(180deg);
}

.landing-drawer__items {
    padding-bottom: 0.5rem;
}

.landing-drawer__direct {
    display: flex;
    flex-direction: column;
}

.landing-drawer__link {
    display: flex;
    align-items: center;
    min-height: 48px;
    padding: 0 0.75rem;
    border-radius: 0.625rem;
    font-size: 1rem;
    font-weight: 500;
    color: rgb(255 255 255 / 88%);
    transition: background-color 180ms ease;
}

.landing-drawer__link:hover {
    background: rgb(255 255 255 / 6%);
}

.landing-drawer__foot {
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
    padding: 1rem 1.25rem 1.25rem;
    margin-top: 0.5rem;
    border-top: 1px solid rgb(255 255 255 / 8%);
}

/* The picker's button takes its colours from .landing-nav__locale, which follows
   the BAR's state — and on a solid bar that is dark type. Inside the drawer it
   sits on glass, so the scope re-points it the way the immersive skin re-points
   the page's tokens. */
.landing-drawer__foot .landing-nav__locale {
    color: rgb(255 255 255 / 78%);
}

.landing-drawer__foot .landing-nav__locale:hover {
    background: rgb(255 255 255 / 8%);
    color: #fff;
}

/* The picker sits at the very bottom of a scrolling drawer, so its panel opens
   UPWARD here. Downward it opens into the container's bottom edge and the third
   language is below the fold of a box the visitor has already scrolled to the end
   of, which looks like a menu with two languages in it. */
.landing-drawer__foot [data-locale-switcher-target="dropdown"] {
    top: auto;
    bottom: 100%;
    margin-top: 0;
    margin-bottom: 0.5rem;
}

@media (prefers-reduced-motion: reduce) {
    .landing-drawer__chevron,
    .landing-drawer__summary,
    .landing-drawer__link {
        transition: none;
    }
}

/* ------------------------------------------------------------------------------------------------------------------ */
/* Landing footer                                                                                                      */
/* ------------------------------------------------------------------------------------------------------------------ */
/* The quiet bookend. Dark ground in the photograph's floor tones, muted type, and
   nothing in it competing with the page's actual calls to action — by the time a
   visitor is here they are either leaving or looking for a legal link.

   It sits below the fold by construction: the story above it is four viewports
   tall, so the hero keeps its full height on first paint. */
.landing-footer {
    background: #0d1219;
    border-top: 1px solid rgb(255 255 255 / 8%);
    color: rgb(255 255 255 / 62%);
}

.landing-footer a {
    color: rgb(255 255 255 / 62%);
    transition: color 200ms ease;
}

.landing-footer a:hover {
    color: #fff;
}

.landing-footer__heading {
    color: rgb(255 255 255 / 92%);
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.12em;
    text-transform: uppercase;
}

/* ------------------------------------------------------------------------------------------------------------------ */
/* Landing diorama — the scroll-driven WebGL story                                                                     */
/* ------------------------------------------------------------------------------------------------------------------ */
/* The other half is assets/js/controller/landing/diorama_controller.js and the lib
   under assets/js/lib/diorama. Read the controller's header first — it explains the
   three cases in which none of this runs and the page falls back to a banner image.

   The layout is the standard sticky-stage scrollytelling frame, and the whole reason
   it is worth naming: the STORY is tall, the STAGE inside it is one viewport and
   sticky, and the chapters are ordinary blocks that scroll past the stage. The camera
   is driven by how far the story has travelled, so "how long does chapter 2 last" is
   answered by the height of chapter 2's block and nothing else. Adding a chapter is
   adding a div and a shot to SHOTS.

   The stage is BEHIND the chapters and unclickable. Everything a visitor reads or
   presses is real DOM above it — the scene never intercepts a pointer, and the page
   is fully usable with the canvas removed, which is exactly what the fallback does. */

.diorama {
    position: relative;
}

/* Scroll snapping between diorama chapters is handled entirely by
   diorama_scroll_controller.js — a JS-driven animation with a custom easing
   curve. CSS scroll-snap is NOT used because it overrides programmatic scrollTo
   calls and causes an instant jump instead of a smooth animation. */

.diorama__stage {
    position: sticky;
    top: 0;
    height: 100svh;
    /* Not 100vh: on mobile Safari the toolbar collapse changes vh mid-scroll, which
       resizes the drawing buffer on every scroll direction change. svh is the small
       viewport — it does not move, so the canvas is allocated once. */
    width: 100%;
    overflow: hidden;
    pointer-events: none;
    z-index: 0;
    /* Takes the stage back out of the flow it just claimed, so the first chapter
       starts at the top of the story and every chapter scrolls OVER the stage rather
       than after it. Without this the section is one viewport taller than the sum of
       its chapters, and the stage is followed by the chapters instead of sitting
       behind them.

       This affects LAYOUT ONLY. Which shot is framed is decided by where the chapter
       elements actually are — see chapterProgress() in camera_path.js — so a chapter
       taller than one viewport re-times nothing. The min-height below is a floor that
       gives each chapter room to breathe, not a measurement anything depends on. */
    margin-bottom: -100svh;
}

/* Both fill the stage and OVERLAP, so the canvas can cross-fade over the banner.
   In normal flow they would stack: the canvas would take the full height and push
   the banner out of an overflow:hidden box, so the fallback — the thing the whole
   progressive-enhancement story rests on — would be clipped to nothing and never
   seen by any of the people it exists for. The stage is position:sticky, which is a
   positioned ancestor, so inset:0 resolves against it. */
.diorama__canvas,
.diorama__fallback {
    position: absolute;
    inset: 0;
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* ── The gradient backdrop ──────────────────────────────────────────────────────
   On desktop (where WebGL will run), the stage shows the SAME dark gradient as the
   pricing page — warm sunset radials over slate — instead of the fallback photograph.
   This eliminates the "zoom" that plagued the old approach: a gradient has no spatial
   content, so there is nothing to shift when the canvas fades in over it. The scene
   simply materialises out of mood lighting.

   On mobile (below the DESKTOP media query), the fallback <img> stays visible as
   the full hero — the controller declines to build a scene there, so no handover
   ever happens and no mismatch can exist. */
.diorama__stage::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    background:
        radial-gradient(115% 75% at 78% 6%, rgb(232 135 63 / 26%) 0%, transparent 58%),
        radial-gradient(85% 60% at 6% 94%, rgb(198 128 74 / 16%) 0%, transparent 62%),
        radial-gradient(120% 55% at 50% 0%, rgb(138 151 166 / 14%) 0%, transparent 60%),
        linear-gradient(180deg, #121820 0%, #0e141c 46%, #0d1219 100%);
}

/* The canvas fades in over the gradient. Because there is no image underneath with
   its own framing, there is no shift — only the photograph gaining depth and motion
   as it appears. The transition is short (600ms) but not instant, so the scene feels
   like it comes alive rather than popping.

   will-change promotes the canvas to its own compositor layer before the transition
   starts, preventing the jank of a mid-animation layer promotion. */
.diorama__canvas {
    opacity: 0;
    transition: opacity 600ms cubic-bezier(0.16, 1, 0.3, 1);
    will-change: opacity;
    z-index: 1;
}

.diorama--live .diorama__canvas {
    opacity: 1;
}

/* ── The fallback photograph ────────────────────────────────────────────────────
   On MOBILE (no WebGL) this is the hero. On DESKTOP it is hidden by default — the
   gradient does the pre-scene job. It is only un-hidden on desktop if the controller
   explicitly fails (WebGL unavailable, import error, texture decode failure), at
   which point .diorama--fallback is added and the image appears over the gradient.

   The mobile path never touches the controller's desktop gate, so .diorama__fallback
   is simply visible there by the absence of the hiding rule. */
.diorama__fallback {
    z-index: 1;
}

/* ── The phone crop ─────────────────────────────────────────────────────────────
   The photograph is 1920×1080. `object-fit: cover` in a 390×800 stage scales it to
   1422 wide to fill the height, so a phone sees 27% of the frame: a headboard and
   a slice of wall, with the window the shot is actually about off the left edge.
   It reads as a wall, not as a room.

   Shortening the BOX is what un-crops it, and it is worth spelling out why, because
   the instinct is to reach for a scale transform. Cover scales by the larger of the
   two ratios, and on a phone that is always the height one. Take the box to 62% and
   the required scale drops from 0.74 to 0.46, so 44% of the frame is visible —
   sixteen hundred pixels of room instead of nine hundred. Nothing is resampled and
   nothing is letterboxed; the crop simply stops being desperate.

   The remaining 38% is the gradient that is already painted behind this image
   (.diorama__stage::before), and the mask hands one to the other over the last
   fifth of the image rather than at a hard line. So the phone gets a photograph
   dissolving into mood lighting, which is the same move the desktop canvas makes
   when it fades in — one idea, two implementations of it.

   Desktop is untouched: there the image is hidden behind the gradient anyway and
   only appears if the WebGL scene fails to boot. */
@media (max-width: 640px) {
    .diorama__fallback {
        height: 62%;
        mask-image: linear-gradient(to bottom, #000 0%, #000 80%, transparent 100%);
        -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 80%, transparent 100%);
    }
}

@media (min-width: 1024px) and (pointer: fine) {
    .diorama__fallback {
        opacity: 0;
        transition: opacity 400ms ease-out;
    }

    .diorama--fallback .diorama__fallback {
        opacity: 1;
    }
}

/* ── The readability scrim ────────────────────────────────────────────────────
   A photograph of a sunset is a terrible thing to put white text on: it is bright,
   warm and busy exactly where the copy goes. This darkens it enough for white type
   to hold contrast, weighted to the left where three of the four chapters sit.

   IN CSS AND NOT IN THE SHADER, deliberately. It has to cover the canvas AND the
   static <img> — on mobile, on a reduced-motion machine, on a locked-down browser,
   and in the moment before the texture decodes. Put it in the fragment shader and
   every one of those visitors reads white text directly on a sunset.

   Sitting inside .diorama__stage, which is aria-hidden and pointer-events:none, so
   it can never take a click from the copy above it. */
.diorama__stage::after {
    content: "";
    z-index: 2;
}

/* The readability scrim over the hero photograph. The landing stage paints it
   as ::after; the login page's hero column uses the class on a plain div. One
   rule, so the two cannot drift. */
.diorama__stage::after,
.hero-scrim {
    position: absolute;
    inset: 0;
    pointer-events: none;
    background:
        linear-gradient(105deg, rgb(15 23 42 / 76%) 0%, rgb(15 23 42 / 44%) 45%, rgb(15 23 42 / 18%) 68%, rgb(15 23 42 / 46%) 100%),
        linear-gradient(to top, rgb(15 23 42 / 38%) 0%, transparent 34%);
}

/* Chapters sit above the stage and carry the copy. They are laid out as full-height
   blocks so each one owns a slice of scroll, which is what gives its shot its dwell
   time.

   ── Copy sits high, not centred ──────────────────────────────────────────────
   `flex-start`, because vertically centred copy in a full-height block lands in
   the middle of the screen and reads as though the page is still loading — the
   eye expects a heading near the top. The chapter BOX stays a full viewport tall;
   only its contents move, so this changes nothing about the scroll timing (see
   chapterProgress in camera_path.js, which measures the boxes, not the text).

   The top padding is not decoration. The landing header is `position: fixed`, so
   it takes no space in flow and floats over the first `--landing-nav-height` of
   every chapter — top-aligned content without this clearance goes under it.

   It is now DERIVED from that height rather than a clamp that happened to exceed
   it. The clamp was `clamp(11rem, 20vh, 14rem)` and the core-platform chapter
   overrode it to `clamp(7rem, 12vh, 9rem)` — 112px against a 120px bar, so that
   chapter's eyebrow and heading sat behind the header on every screen narrow
   enough to take the clamp's minimum. Two hand-tuned numbers against a third
   nobody was checking. Written as `header + air` there is one number, it cannot
   be smaller than the bar, and the mobile bar drops the whole block by 48px for
   free.

   ── height: auto, not 100svh ─────────────────────────────────────────────────
   A chapter used to be exactly one viewport tall whatever was inside it, so
   content that did not fit — the six-card grid and the founder card on a phone —
   simply overflowed its box and drew on top of the chapter below. The scroll
   controller no longer assumes a chapter is a viewport tall — it measures where
   they actually are — so a chapter is free to be as tall as it needs and two of
   them can no longer occupy the same pixels. min-height keeps the short ones a
   full screen, which is what gives each shot its dwell time.

   The bottom padding keeps the last line of a chapter off the navigation pinned
   at the foot of the viewport. */
.diorama__chapter {
    position: relative;
    z-index: 1;
    min-height: 100svh;
    display: flex;
    align-items: flex-start;
    padding-top: calc(var(--landing-nav-height) + clamp(2rem, 6vh, 4.5rem));
    padding-bottom: 5.5rem;
    pointer-events: none;
}

/* Two chapters want every pixel they can have: the core-platform grid, and the
   support card with its two sub-cards and a CTA. They take the header clearance
   and almost none of the air.

   Neither may go BELOW the clearance. That was the old override's mistake — 7rem
   against a 7.5rem bar — and it is what put the core-platform eyebrow behind the
   header on a narrow window. `header + 1rem` cannot make that mistake. */
.diorama__chapter#diorama-infrastructure,
.diorama__chapter#diorama-support {
    padding-top: calc(var(--landing-nav-height) + 1rem);
}

.diorama__chapter#diorama-infrastructure {
    padding-bottom: 3rem;
}

.diorama__chapter > * {
    pointer-events: auto;
}

/* The reveal. Chapters start low and dim and settle when the controller marks them
   active, so the copy arrives WITH its shot rather than on an observer of its own a
   hundred pixels away. .is-active is set by announceChapter().

   ── EVERY RULE HERE IS SCOPED UNDER .diorama--live, AND THAT IS THE POINT ────────
   .diorama--live is added by the controller only once a scene has actually been
   built. So the hidden-until-revealed state cannot exist unless something is
   running that will do the revealing.

   Written the obvious way round — panel hidden by default, revealed by .is-active —
   this is a page whose entire copy is invisible whenever the JavaScript does not
   run. Not the WebGL: the JavaScript. A blocked bundle, a CSP the customer's proxy
   tightened, an old browser that chokes parsing a module, `noscript` — any of them
   and a marketing page renders four empty screens with a picture behind them, while
   every automated check still passes because the text IS in the DOM.

   Inverting it makes the failure mode boring: no JS, no .diorama--live, no rule, and
   the page is simply a page. */
.diorama--live .diorama__panel {
    opacity: 0;
    transform: translate3d(0, 16px, 0);
    transition:
        opacity 250ms cubic-bezier(0.16, 1, 0.3, 1),
        transform 250ms cubic-bezier(0.16, 1, 0.3, 1);
}

.diorama--live .diorama__chapter.is-active .diorama__panel {
    opacity: 1;
    transform: translate3d(0, 0, 0);
}

/* The copy is white directly on the scrim, with no card behind it.

   There WAS a frosted card here, and it went when the backdrop became a photograph.
   A light panel over a room reads as a dialog box floating in front of the view —
   it covers the very thing that makes the section say "hospitality" — and the scrim
   already does the contrast job the card was doing. The type shadow below is what
   replaces it: cheap, and it holds even where the photograph is at its brightest.

   Because the scrim is CSS and unconditional, this needs no .diorama--live scoping.
   The copy is white on the canvas, white on the static image, and white on mobile,
   which is one appearance to reason about instead of three. */
.diorama__panel {
    text-shadow: 0 1px 24px rgb(15 23 42 / 55%);
}

.diorama__panel .bg-primary,
.diorama__panel .backdrop-blur-md,
.diorama__panel .support-card {
    text-shadow: none;
}

/* ── Landing sync comparison ──────────────────────────────────────────────────
   templates/landing/partials/sync_comparison.gohtml: four channel tabs that
   disagree, next to one window where every channel agrees. Pure CSS motion,
   because the landing shell loads no app bundle.

   The two sides move differently on purpose. The chaos side twitches out of step,
   each tab on its own clock, and its warnings pulse. The sync side runs on one
   four-second clock: the sweep goes down the rows and each tick answers it as it
   passes, so the eye reads one system rather than four. */
.sync-compare__side {
    position: relative;
    box-shadow: 0 30px 60px -30px rgb(0 0 0 / 60%);
}

.sync-compare__side--chaos {
    border-color: rgb(248 113 113 / 22%);
    background: linear-gradient(160deg, rgb(127 29 29 / 20%) 0%, rgb(255 255 255 / 3%) 60%);
}

.sync-compare__side--sync {
    border-color: rgb(52 211 153 / 26%);
    background: linear-gradient(160deg, rgb(6 78 59 / 26%) 0%, rgb(255 255 255 / 3%) 60%);
    box-shadow:
        0 30px 60px -30px rgb(0 0 0 / 60%),
        0 0 80px -30px rgb(52 211 153 / 45%);
}

.sync-compare__chrome {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    height: 30px;
    padding: 0 0.625rem;
    font-size: 11px;
    font-weight: 500;
    color: rgb(255 255 255 / 82%);
    border-bottom: 1px solid rgb(255 255 255 / 8%);
    background: rgb(255 255 255 / 4%);
}

.sync-compare__chrome--flat {
    gap: 0.75rem;
}

.sync-compare__favicon {
    flex-shrink: 0;
    width: 8px;
    height: 8px;
    border-radius: 2px;
}

/* The pile. Each tab keeps its resting offset in custom properties so the drift
   keyframes can move it relative to where it sits rather than snapping it home. */
.sync-compare__stack {
    min-height: 214px;
}

.sync-compare__tab {
    --x: 0px;
    --r: 0deg;
    position: absolute;
    left: 0;
    right: 0;
    height: 124px;
    overflow: hidden;
    border-radius: 0.625rem;
    border: 1px solid rgb(255 255 255 / 10%);
    display: flex;
    flex-direction: column;
    background: rgb(22 30 44 / 92%);
    box-shadow: 0 14px 28px -12px rgb(0 0 0 / 70%);
    transform: translateX(var(--x)) rotate(var(--r));
    animation: sync-compare-drift 5.5s ease-in-out infinite;
}

.sync-compare__tab--1 { --x: -6px; --r: -2.4deg; top: 0;    opacity: 0.7;  animation-duration: 6.1s; }
.sync-compare__tab--2 { --x: 7px;  --r: 1.8deg;  top: 30px; opacity: 0.8;  animation-duration: 4.7s; animation-delay: -1.3s; }
.sync-compare__tab--3 { --x: -3px; --r: -1deg;   top: 60px; opacity: 0.9;  animation-duration: 5.3s; animation-delay: -2.6s; }
.sync-compare__tab--4 {
    --x: 2px;
    --r: 0.6deg;
    top: 90px;
    bottom: 0;
    height: auto;
    border-color: rgb(248 113 113 / 35%);
    animation: sync-compare-shake 4.8s ease-in-out infinite;
}

@keyframes sync-compare-drift {
    0%, 100% { transform: translateX(var(--x)) rotate(var(--r)); }
    50%      { transform: translate(calc(var(--x) + 3px), -2px) rotate(calc(var(--r) + 0.5deg)); }
}

/* Still for most of the cycle, then a short twitch: the front tab noticing. */
@keyframes sync-compare-shake {
    0%, 82%, 100% { transform: translateX(var(--x)) rotate(var(--r)); }
    85%           { transform: translateX(calc(var(--x) - 3px)) rotate(calc(var(--r) - 0.6deg)); }
    88%           { transform: translateX(calc(var(--x) + 3px)) rotate(calc(var(--r) + 0.6deg)); }
    91%           { transform: translateX(calc(var(--x) - 2px)) rotate(var(--r)); }
    94%           { transform: translateX(calc(var(--x) + 1px)) rotate(var(--r)); }
}

.sync-compare__alert {
    flex-shrink: 0;
    border-radius: 9999px;
    padding: 0.125rem 0.5rem;
    font-size: 10px;
    font-weight: 600;
    white-space: nowrap;
    animation: sync-compare-alert 2.4s ease-in-out infinite;
}

.sync-compare__alert--red {
    color: rgb(254 202 202);
    background: rgb(239 68 68 / 22%);
    --glow: rgb(239 68 68 / 45%);
}

.sync-compare__alert--amber {
    color: rgb(253 230 138);
    background: rgb(245 158 11 / 20%);
    --glow: rgb(245 158 11 / 40%);
    animation-delay: -1.1s;
}

@keyframes sync-compare-alert {
    0%, 100% { box-shadow: 0 0 0 0 transparent; }
    50%      { box-shadow: 0 0 0 4px var(--glow); }
}

.sync-compare__overlap {
    background: repeating-linear-gradient(135deg, rgb(239 68 68 / 55%) 0 4px, rgb(239 68 68 / 15%) 4px 8px);
    box-shadow: inset 0 0 0 1px rgb(252 165 165 / 70%);
    animation: sync-compare-overlap 1.6s ease-in-out infinite;
}

@keyframes sync-compare-overlap {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.55; }
}

/* The hinge sits over the gap between the two sides. */
.sync-compare__hinge {
    position: absolute;
    left: 50%;
    top: 50%;
    z-index: 2;
    width: 36px;
    height: 36px;
    align-items: center;
    justify-content: center;
    border-radius: 9999px;
    color: #fff;
    background: rgb(15 23 42 / 85%);
    box-shadow:
        inset 0 0 0 1px rgb(255 255 255 / 16%),
        0 0 24px rgb(52 211 153 / 35%);
    transform: translate(-50%, -50%);
}

.sync-compare__window {
    background: rgb(15 23 42 / 55%);
    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 10%);
}

.sync-compare__live {
    position: relative;
    width: 7px;
    height: 7px;
    border-radius: 9999px;
    background: rgb(52 211 153);
}

.sync-compare__live::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: rgb(52 211 153);
    animation: sync-compare-ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite;
}

@keyframes sync-compare-ping {
    75%, 100% { transform: scale(2.6); opacity: 0; }
}

.sync-compare__rows {
    overflow: hidden;
}

.sync-compare__sweep {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    height: 30%;
    pointer-events: none;
    background: linear-gradient(to bottom, transparent, rgb(52 211 153 / 14%) 50%, transparent);
    transform: translateY(-100%);
    animation: sync-compare-sweep 4s linear infinite;
}

@keyframes sync-compare-sweep {
    to { transform: translateY(340%); }
}

.sync-compare__tick {
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    width: 16px;
    height: 16px;
    border-radius: 9999px;
    color: rgb(110 231 183);
    background: rgb(16 185 129 / 16%);
    animation: sync-compare-tick 4s ease-out infinite;
}

.sync-compare__row--0 .sync-compare__tick { animation-delay: 0.45s; }
.sync-compare__row--1 .sync-compare__tick { animation-delay: 1.25s; }
.sync-compare__row--2 .sync-compare__tick { animation-delay: 2.05s; }
.sync-compare__row--3 .sync-compare__tick { animation-delay: 2.85s; }

@keyframes sync-compare-tick {
    0%, 20%, 100% { transform: scale(1); background: rgb(16 185 129 / 16%); box-shadow: 0 0 0 0 transparent; }
    6%            { transform: scale(1.3); background: rgb(16 185 129 / 45%); box-shadow: 0 0 12px rgb(52 211 153 / 60%); }
}

.sync-compare__ticker {
    height: 1.25rem;
}

.sync-compare__ticker-list li {
    height: 1.25rem;
    line-height: 1.25rem;
}

/* Four events and the first again, each shown for about two and a half
   seconds. The last frame equals the first, so the loop wraps without a jump. */
.sync-compare__ticker-list {
    animation: sync-compare-ticker 12s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}

@keyframes sync-compare-ticker {
    0%, 20%   { transform: translateY(0); }
    25%, 45%  { transform: translateY(-1.25rem); }
    50%, 70%  { transform: translateY(-2.5rem); }
    75%, 95%  { transform: translateY(-3.75rem); }
    100%      { transform: translateY(-5rem); }
}

@media (prefers-reduced-motion: reduce) {
    .sync-compare__tab,
    .sync-compare__alert,
    .sync-compare__overlap,
    .sync-compare__live::after,
    .sync-compare__sweep,
    .sync-compare__tick,
    .sync-compare__ticker-list {
        animation: none;
    }

    .sync-compare__sweep {
        display: none;
    }
}

/* ── Login turnover comparison ────────────────────────────────────────────────
   templates/landing/partials/turnover_comparison.gohtml: a turnover day run from
   a group chat, next to the same day run by one cleaning mission. It borrows the
   sync comparison's side, window, chrome and hinge; only the chat and the step
   rail are new.

   The chaos side piles up questions nobody answers, then clears and starts over.
   The sync side runs one ten-second clock: the rail fills and each step lights
   as the fill reaches it. Both loops share the ten seconds, so the "not ready"
   on the left and "ready" on the right line up once per loop. */
.turnover-compare__unread {
    flex-shrink: 0;
    min-width: 18px;
    padding: 0 5px;
    border-radius: 9999px;
    font-size: 10px;
    font-weight: 700;
    line-height: 16px;
    text-align: center;
    color: #fff;
    background: rgb(239 68 68);
    animation: sync-compare-alert 2.4s ease-in-out infinite;
    --glow: rgb(239 68 68 / 45%);
}

.turnover-compare__bubble {
    max-width: 88%;
    align-self: flex-start;
    padding: 0.3125rem 0.5625rem;
    border-radius: 0.75rem 0.75rem 0.75rem 0.25rem;
    font-size: 11px;
    line-height: 1.3;
    color: rgb(255 255 255 / 85%);
    background: rgb(255 255 255 / 9%);
    opacity: 0;
    animation-duration: 10s;
    animation-timing-function: ease-out;
    animation-iteration-count: infinite;
}

.turnover-compare__bubble--mine {
    align-self: flex-end;
    border-radius: 0.75rem 0.75rem 0.25rem 0.75rem;
    color: rgb(254 226 226);
    background: rgb(239 68 68 / 20%);
}

.turnover-compare__bubble--0 { animation-name: turnover-compare-bubble-0; }
.turnover-compare__bubble--1 { animation-name: turnover-compare-bubble-1; }
.turnover-compare__bubble--2 { animation-name: turnover-compare-bubble-2; }
.turnover-compare__bubble--3 { animation-name: turnover-compare-bubble-3; }

@keyframes turnover-compare-bubble-0 {
    0%       { opacity: 0; transform: translateY(6px); }
    4%, 90%  { opacity: 1; transform: none; }
    96%, 100% { opacity: 0; transform: none; }
}

@keyframes turnover-compare-bubble-1 {
    0%, 18%  { opacity: 0; transform: translateY(6px); }
    22%, 90% { opacity: 1; transform: none; }
    96%, 100% { opacity: 0; transform: none; }
}

@keyframes turnover-compare-bubble-2 {
    0%, 36%  { opacity: 0; transform: translateY(6px); }
    40%, 90% { opacity: 1; transform: none; }
    96%, 100% { opacity: 0; transform: none; }
}

@keyframes turnover-compare-bubble-3 {
    0%, 54%  { opacity: 0; transform: translateY(6px); }
    58%, 90% { opacity: 1; transform: none; }
    96%, 100% { opacity: 0; transform: none; }
}

/* Someone is always about to ask the next thing. */
.turnover-compare__typing {
    display: inline-flex;
    align-self: flex-start;
    gap: 3px;
    padding: 0.4375rem 0.5625rem;
    border-radius: 0.75rem;
    background: rgb(255 255 255 / 6%);
}

.turnover-compare__typing i {
    width: 4px;
    height: 4px;
    border-radius: 9999px;
    background: rgb(255 255 255 / 55%);
    animation: turnover-compare-typing 1.2s ease-in-out infinite;
}

.turnover-compare__typing i:nth-child(2) { animation-delay: 0.15s; }
.turnover-compare__typing i:nth-child(3) { animation-delay: 0.3s; }

@keyframes turnover-compare-typing {
    0%, 60%, 100% { opacity: 0.35; transform: none; }
    30%           { opacity: 1; transform: translateY(-2px); }
}

/* The rail runs through the centres of the 20px dots. */
.turnover-compare__rail {
    position: absolute;
    left: 9.5px;
    top: 10px;
    bottom: 10px;
    width: 1px;
    background: rgb(255 255 255 / 12%);
}

.turnover-compare__fill {
    position: absolute;
    inset: 0;
    background: rgb(52 211 153);
    box-shadow: 0 0 8px rgb(52 211 153 / 60%);
    transform-origin: top;
    transform: scaleY(0);
    animation: turnover-compare-fill 10s linear infinite;
}

/* Steps light at 0, 16, 32, 48 and 64 percent of the loop. The fill reaches
   each dot at the same moment, holds full until 90 percent, then everything
   resets together. */
@keyframes turnover-compare-fill {
    0%        { transform: scaleY(0); }
    64%, 90%  { transform: scaleY(1); opacity: 1; }
    96%       { transform: scaleY(1); opacity: 0; }
    100%      { transform: scaleY(0); opacity: 0; }
}

.turnover-compare__step {
    color: rgb(255 255 255 / 40%);
    animation-duration: 10s;
    animation-iteration-count: infinite;
}

.turnover-compare__dot {
    position: relative;
    z-index: 1;
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    width: 20px;
    height: 20px;
    border-radius: 9999px;
    color: rgb(255 255 255 / 45%);
    background: rgb(22 30 44);
    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 14%);
    animation-duration: 10s;
    animation-iteration-count: infinite;
}

.turnover-compare__step--0 { animation-name: turnover-compare-step-0; }
.turnover-compare__step--1 { animation-name: turnover-compare-step-1; }
.turnover-compare__step--2 { animation-name: turnover-compare-step-2; }
.turnover-compare__step--3 { animation-name: turnover-compare-step-3; }
.turnover-compare__step--4 { animation-name: turnover-compare-step-4; }

.turnover-compare__step--0 .turnover-compare__dot { animation-name: turnover-compare-dot-0; }
.turnover-compare__step--1 .turnover-compare__dot { animation-name: turnover-compare-dot-1; }
.turnover-compare__step--2 .turnover-compare__dot { animation-name: turnover-compare-dot-2; }
.turnover-compare__step--3 .turnover-compare__dot { animation-name: turnover-compare-dot-3; }
.turnover-compare__step--4 .turnover-compare__dot { animation-name: turnover-compare-dot-4; }

@keyframes turnover-compare-step-0 { 0%, 96%, 100% { color: rgb(255 255 255 / 40%); } 2%, 90% { color: rgb(255 255 255 / 92%); } }
@keyframes turnover-compare-step-1 { 0%, 16%, 96%, 100% { color: rgb(255 255 255 / 40%); } 18%, 90% { color: rgb(255 255 255 / 92%); } }
@keyframes turnover-compare-step-2 { 0%, 32%, 96%, 100% { color: rgb(255 255 255 / 40%); } 34%, 90% { color: rgb(255 255 255 / 92%); } }
@keyframes turnover-compare-step-3 { 0%, 48%, 96%, 100% { color: rgb(255 255 255 / 40%); } 50%, 90% { color: rgb(255 255 255 / 92%); } }
@keyframes turnover-compare-step-4 { 0%, 64%, 96%, 100% { color: rgb(255 255 255 / 40%); } 66%, 90% { color: rgb(110 231 183); } }

/* A dot pops when the fill arrives, then stays lit. */
@keyframes turnover-compare-dot-0 { 0%, 96%, 100% { color: rgb(255 255 255 / 45%); background: rgb(22 30 44); } 2%, 90% { color: rgb(110 231 183); background: rgb(6 78 59); } 1% { transform: scale(1.25); } 4% { transform: none; } }
@keyframes turnover-compare-dot-1 { 0%, 16%, 96%, 100% { color: rgb(255 255 255 / 45%); background: rgb(22 30 44); transform: none; } 18%, 90% { color: rgb(110 231 183); background: rgb(6 78 59); } 17% { transform: scale(1.25); } 20% { transform: none; } }
@keyframes turnover-compare-dot-2 { 0%, 32%, 96%, 100% { color: rgb(255 255 255 / 45%); background: rgb(22 30 44); transform: none; } 34%, 90% { color: rgb(110 231 183); background: rgb(6 78 59); } 33% { transform: scale(1.25); } 36% { transform: none; } }
@keyframes turnover-compare-dot-3 { 0%, 48%, 96%, 100% { color: rgb(255 255 255 / 45%); background: rgb(22 30 44); transform: none; } 50%, 90% { color: rgb(110 231 183); background: rgb(6 78 59); } 49% { transform: scale(1.25); } 52% { transform: none; } }
@keyframes turnover-compare-dot-4 { 0%, 64%, 96%, 100% { color: rgb(255 255 255 / 45%); background: rgb(22 30 44); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 14%); transform: none; } 66%, 90% { color: #fff; background: rgb(16 185 129); box-shadow: 0 0 14px rgb(52 211 153 / 70%); } 65% { transform: scale(1.3); } 68% { transform: none; } }

/* The login column is locked to the viewport. On a short screen the heading and
   the copy come first and the picture gives way. */
@media (max-height: 760px) {
    .turnover-compare {
        display: none;
    }
}

@media (prefers-reduced-motion: reduce) {
    .turnover-compare__unread,
    .turnover-compare__bubble,
    .turnover-compare__typing i,
    .turnover-compare__fill,
    .turnover-compare__step,
    .turnover-compare__dot {
        animation: none;
    }

    /* Still, the picture shows the end state of each side. */
    .turnover-compare__bubble { opacity: 1; }
    .turnover-compare__fill { transform: none; }
    .turnover-compare__step { color: rgb(255 255 255 / 92%); }
    .turnover-compare__dot { color: rgb(110 231 183); background: rgb(6 78 59); }
}

/* ── Chapter navigation ───────────────────────────────────────────────────────
   Sticky, not fixed. As the last child of .diorama with `bottom`, it rides the
   foot of the viewport for exactly as long as the story is on screen and then
   scrolls away with it — no JavaScript deciding when to show or hide it, and
   nothing left hovering over the footer if a script fails halfway.

   Above the chapters (z-index) and clickable, unlike the chapters themselves,
   which are pointer-events:none so the scene reads as one surface. */
/* The LAST chapter's padding means something different at both ends.
   Everywhere else the bottom padding is clearance: the pill floats over the bottom
   ~82px of the viewport, so a chapter whose content reaches its own bottom edge —
   the calendar card, the six-card grid — would slide under it. At the end of the
   story the pill has come to rest in flow BELOW the chapter, so nothing needs
   clearing and the same padding is free to be what the page actually wants there,
   which is room between the founder card and everything after it.

   `:has(+ .diorama__nav)` rather than `:last-of-type`: it says the thing that is
   actually true — this is the chapter the navigation follows — and it keeps being
   true if a chapter is added after it. */
.diorama__chapter:has(+ .diorama__nav) {
    /* Vertically centre the founder card within its full-viewport chapter so the
       content floats in the middle of the screen rather than sitting at the top.
       Centring only makes sense while the card is shorter than the screen, so the
       header clearance comes back as a floor: on a phone the card is taller, the
       chapter grows, and without this the portrait starts under the bar. */
    align-items: center;
    padding-top: calc(var(--landing-nav-height) + 1rem);
    padding-bottom: 4rem;
    min-height: 100svh;
}

.diorama__nav {
    position: sticky;
    bottom: clamp(1rem, 3vh, 2rem);
    z-index: 2;
    display: flex;
    justify-content: center;
    padding: 0 1rem;
    /* Space to whatever follows the story.
       Sticky elements still occupy their place in flow, so at the very bottom of
       the page this one comes to rest exactly where the section ends — which is
       exactly where the footer begins, leaving the pill sitting on the footer's
       top edge. This is the gap between the two once it has landed. */
    margin-bottom: 1.75rem;
    pointer-events: none;
}

.diorama__nav-list {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    padding: 0.375rem;
    border-radius: 999px;
    pointer-events: auto;
    background: rgb(15 23 42 / 62%);
    border: 1px solid rgb(255 255 255 / 14%);
    backdrop-filter: blur(16px);
    box-shadow: 0 18px 40px -20px rgb(0 0 0 / 70%);
    /* Narrow windows scroll it rather than wrapping it into two rows, which would
       change the section's height and re-time every shot below it. */
    max-width: 100%;
    overflow-x: auto;
    scrollbar-width: none;
}

.diorama__nav-list::-webkit-scrollbar {
    display: none;
}

.diorama__nav-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    /* The 44px touch target the design system requires, even though this is a
       pointer-only affordance in practice — it is a real link on a phone too. */
    min-height: 44px;
    padding: 0 0.9rem;
    border-radius: 999px;
    white-space: nowrap;
    color: rgb(255 255 255 / 62%);
    transition: color 220ms ease, background-color 220ms ease;
}

.diorama__nav-item:hover {
    color: rgb(255 255 255 / 92%);
    background: rgb(255 255 255 / 8%);
}

.diorama__nav-item.is-active {
    color: #fff;
    background: rgb(255 255 255 / 14%);
}

.diorama__nav-item:focus-visible {
    outline: 2px solid rgb(255 255 255 / 70%);
    outline-offset: 2px;
}

.diorama__nav-num {
    font-size: 0.6875rem;
    font-weight: 600;
    letter-spacing: 0.14em;
    opacity: 0.7;
}

.diorama__nav-label {
    font-size: 0.8125rem;
    font-weight: 600;
}

/* On a phone the four labels do not fit and the bar would run off both edges.
   The numerals stay, and each item keeps its accessible name from the label text
   — which is still in the DOM, only clipped. */
@media (max-width: 640px) {
    .diorama__nav-label {
        position: absolute;
        width: 1px;
        height: 1px;
        overflow: hidden;
        clip-path: inset(50%);
        white-space: nowrap;
    }

    .diorama__nav-item {
        padding: 0 0.75rem;
    }
}

/* Reduced motion: the controller has already declined to build a scene, so the only
   thing left here is to stop the copy from sliding. It still fades, because a reveal
   with no movement is not a vestibular problem — a reveal that never resolves is an
   accessibility one. */
@media (prefers-reduced-motion: reduce) {
    .diorama--live .diorama__panel {
        transform: none;
        transition: opacity 300ms ease-out;
    }

    .diorama__canvas {
        transition: none;
    }
}

/* ------------------------------------------------------------------------------------------------------------------ */
/* Landing support card — onboarding and video support (chapter 04)                                                    */
/* ------------------------------------------------------------------------------------------------------------------ */
/* The markup is in templates/landing/partials/diorama_story.gohtml.

   This is the only element on the landing page that moves on its own, and that is
   a decision rather than an effect. Every other chapter is lit by the scene behind
   it and reads as part of one continuous room; this one is the page's actual claim
   — that the person on the support call wrote the code — and it earns being the
   thing the eye goes to. A slowly rotating gradient sweep around a hairline border
   does that with no image to load and nothing to lay out.

   ── Why a rotated pseudo-element and not an animated @property angle ────────────
   `@property --angle` plus an animated conic-gradient is the tidier way to write
   this, and it degrades to a STATIC BORDER in every browser that has not shipped
   it — which is silent, so nobody would notice for a year. A pseudo-element with a
   plain `transform: rotate` animation works everywhere the site already supports,
   and there is no half-state to miss.

   The ::before is a square large enough to cover the card's diagonal at every
   rotation (a rotating rectangle sweeps its own diagonal), clipped by the parent's
   overflow. The card sits on top and covers all of it but the 1px rim, which is
   the border. */

/* The static rim, and it is load-bearing rather than a nicety. A rotating square
   covers a circle of its own side length, so on a tall narrow card — a phone —
   the corners fall outside the sweep at some angles. Painting the base colour on
   the element itself means an uncovered corner shows the hairline it would have
   had anyway, instead of a gap in the border. */
.support-spotlight {
    position: relative;
    padding: 1px;
    border-radius: 1.5rem;
    overflow: hidden;
    background: rgb(255 255 255 / 14%);
    box-shadow: 0 40px 90px -50px rgb(56 189 248 / 55%);
}

.support-spotlight::before {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    /* Big enough to cover a phone-shaped card's diagonal, capped so a wide desktop
       card does not rasterise a 2000px conic gradient for a 1px rim. */
    width: min(220%, 1400px);
    aspect-ratio: 1;
    transform: translate(-50%, -50%);
    transform-origin: center;
    background: conic-gradient(
        from 0deg,
        rgb(255 255 255 / 14%) 0deg,
        rgb(56 189 248 / 90%) 40deg,
        rgb(129 140 248 / 90%) 85deg,
        rgb(255 255 255 / 14%) 150deg,
        rgb(255 255 255 / 14%) 360deg
    );
    animation: support-sweep 9s linear infinite;
}

@keyframes support-sweep {
    to {
        transform: translate(-50%, -50%) rotate(360deg);
    }
}

/* Above the sweep because it is positioned and later in paint order; the sweep is
   a pseudo-element of the same parent, so no z-index arithmetic is involved. */
.support-card {
    position: relative;
    border-radius: calc(1.5rem - 1px);
    background:
        radial-gradient(120% 90% at 12% 0%, rgb(56 189 248 / 12%) 0%, transparent 60%),
        rgb(11 17 27 / 82%);
    backdrop-filter: blur(18px);
    -webkit-backdrop-filter: blur(18px);
}

.support-badge {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.3rem 0.7rem;
    border-radius: 999px;
    border: 1px solid rgb(56 189 248 / 35%);
    background: rgb(56 189 248 / 12%);
    color: rgb(186 230 253);
    font-size: 0.6875rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    white-space: nowrap;
}

.support-point {
    border-radius: 1rem;
    border: 1px solid rgb(255 255 255 / 10%);
    background: rgb(255 255 255 / 4%);
    padding: 0.95rem 1rem 1.1rem;
}

@media (min-width: 640px) {
    .support-point {
        padding: 1.15rem 1.25rem 1.35rem;
    }
}

.support-point__icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.5rem;
    height: 2.5rem;
    border-radius: 0.75rem;
    border: 1px solid rgb(56 189 248 / 25%);
    background: rgb(56 189 248 / 12%);
    color: rgb(125 211 252);
}

/* The sweep is decoration with no state in it, so stopping it costs the section
   nothing. It parks at the angle the keyframes start from, which is a static
   gradient rim rather than a dead grey line. */
@media (prefers-reduced-motion: reduce) {
    .support-spotlight::before {
        animation: none;
    }
}

/* ------------------------------------------------------------------------------------------------------------------ */
/* Landing channel marquee — the drifting strip of OTAs (chapter 03)                                                   */
/* ------------------------------------------------------------------------------------------------------------------ */
/* The other two thirds of this are
     templates/landing/partials/channel_marquee.gohtml            — the roster
     assets/js/controller/landing/logo_marquee_controller.js      — the drift

   ── The one thing to know before editing ─────────────────────────────────────
   The strip is a SCROLL CONTAINER, not a transform. The controller nudges
   `scrollLeft`; it does not animate anything. That is what lets a visitor swipe,
   flick, drag or Tab through the roster, and it is why "pause on interaction" is
   not implemented anywhere — the controller stops nudging and the browser's own
   scrolling is simply what is happening.

   So there are no @keyframes here, and adding some would take the strip away from
   the visitor. If the drift needs to change, it changes in the controller. */

.channel-marquee {
    /* Pulls the strip out to the panel's full width while the heading above it
       stays in its centred column. A logo strip that stops short of the edges
       reads as a boxed widget; one that runs off both sides reads as a row that
       continues past the frame, which is what a marquee is claiming. */
    width: 100%;
}

.channel-marquee__viewport {
    overflow-x: auto;
    overflow-y: hidden;
    /* Auto, emphatically not smooth. `scroll-behavior: smooth` applies to
       programmatic scrolling too, so every one of the controller's sub-pixel
       nudges would be queued as its own animation and the strip would judder to a
       halt somewhere behind where it should be. */
    scroll-behavior: auto;
    /* The scrollbar is chrome on a decorative strip and it sits directly under the
       logos. The row stays scrollable by every means that matters — swipe, wheel,
       drag, keyboard — with the bar itself hidden. */
    scrollbar-width: none;
    -ms-overflow-style: none;
    /* Both edges dissolve instead of cutting. Without this the loop's seam is
       invisible in the middle of the strip and then perfectly obvious at the
       frame's edge, where a logo is bisected by a hard border. */
    -webkit-mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
    mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
}

.channel-marquee__viewport::-webkit-scrollbar {
    display: none;
}

/* Focusable, so it needs a focus ring — and `:focus-visible` rather than `:focus`,
   because clicking a strip of logos should not draw one. */
.channel-marquee__viewport:focus-visible {
    outline: 2px solid rgb(255 255 255 / 45%);
    outline-offset: 4px;
    border-radius: 1rem;
}

/* The two rows sit side by side and are read as one continuous band. `width:
   max-content` is what stops flex from squeezing them into the viewport, which
   would make the halfway mark meaningless and the loop wrong. */
.channel-marquee__track {
    display: flex;
    width: max-content;
}

.channel-marquee__row {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    /* Half the gap on each side, so the join between the roster and its clone has
       exactly the same spacing as every other gap in the row. A full gap here and
       the seam is a visible double-space once per lap. */
    padding: 0 0.375rem;
    margin: 0;
    list-style: none;
}

.channel-logo {
    display: flex;
    align-items: center;
    gap: 0.625rem;
    flex: none;
    /* 44px, the project's touch-target floor. Nothing here is pressable, but the
       row is dragged with a finger and a band this size is what makes it feel like
       something to grab rather than something to miss. */
    min-height: 44px;
    padding: 0.625rem 1rem;
    border: 1px solid rgb(255 255 255 / 12%);
    border-radius: 0.875rem;
    background: rgb(255 255 255 / 5%);
    backdrop-filter: blur(12px);
    /* The same glass the migration and founder cards wear, so the strip belongs to
       the story rather than arriving from a component library. */
    color: rgb(255 255 255 / 72%);
    white-space: nowrap;
    transition: color 220ms ease, border-color 220ms ease, background-color 220ms ease;
}

/* Muted at rest, resolved on hover. The mark and the name both inherit `color`, so
   one declaration moves the whole pill and there is no second asset to keep in
   step. */
.channel-logo:hover {
    color: rgb(255 255 255 / 96%);
    border-color: rgb(255 255 255 / 28%);
    background: rgb(255 255 255 / 9%);
}

/* 20px, not the 24px of the source SVGs' viewBox.
   The name beside it is 15px with a cap height of about 11.5px, so a 24px mark is
   more than twice the height of the letters it sits next to: geometrically centred
   — the boxes agree to a quarter of a pixel — and still reading as a large icon
   placed beside small text rather than as one object. Twenty is the size at which
   the mark and the word start looking like a single lockup. Anything smaller and
   the marks with real detail in them (Tripadvisor's owl, Marriott's M) start to
   mush. */
.channel-logo__glyph {
    width: 1.25rem;
    height: 1.25rem;
    flex: none;
    fill: currentColor;
}

/* The four channels with no open-licensed vector. Deliberately a lettered tile and
   not an approximation of a logo — same box as the real marks, same colour, so the
   row keeps its rhythm while being honest that this is a placeholder.

   Every value here is tied to the box above: change one, change all three, or the
   tile stops matching the marks it stands in for. */
.channel-logo__glyph--letter {
    display: grid;
    place-items: center;
    border: 1.5px solid currentColor;
    border-radius: 0.375rem;
    font-size: 0.6875rem;
    font-weight: 700;
    line-height: 1;
    opacity: 0.85;
}

.channel-logo__name {
    font-size: 0.9375rem;
    font-weight: 600;
    letter-spacing: -0.01em;
}

.channel-marquee__note {
    margin-top: 1.25rem;
    text-align: center;
    font-size: 0.8125rem;
    color: rgb(255 255 255 / 55%);
}

/* On a phone the pills are the only thing on their line and three of them fill the
   screen, which makes the strip read as three buttons rather than as a row that
   continues. Smaller here so five or six are in frame and the drift has something
   to be a drift THROUGH. */
@media (max-width: 639px) {
    .channel-logo {
        gap: 0.5rem;
        padding: 0.5rem 0.75rem;
    }

    /* The name drops to 13px here, so the mark comes down with it — the whole point
       of the 20px above is the RATIO to the text, and leaving the mark alone while
       shrinking the word would undo it on exactly the screens where the pills are
       tightest. */
    .channel-logo__glyph {
        width: 1.125rem;
        height: 1.125rem;
    }

    .channel-logo__glyph--letter {
        font-size: 0.625rem;
        border-radius: 0.3125rem;
    }

    .channel-logo__name {
        font-size: 0.8125rem;
    }
}

/* ------------------------------------------------------------------------------------------------------------------ */
/* Landing — the immersive skin                                                                                        */
/* ------------------------------------------------------------------------------------------------------------------ */
/* The dark treatment the landing page's sub-pages wear: pricing and contact today,
   whatever follows them tomorrow. It does the job by RE-POINTING THE DESIGN TOKENS
   rather than by restyling the markup.

   Named for the landing area and not for pricing, because a second page adopting it
   is the normal case rather than the exception — a class called `pricing-immersive`
   on the contact page is a comment that has started lying.

   ── Why tokens and not utilities ─────────────────────────────────────────────────
   The components underneath — the control centre, the carousel, the rate table, the
   contact form — are written in semantic classes: `bg-surface`, `text-on-surface`,
   `border-outline-variant`, `text-primary`. Every one of those compiles to
   `var(--color-…)`, so overriding the variables inside this scope re-skins about
   seven hundred lines of partials at once, with no diff in any of them.

   That is not only less churn. The landing--pricing CONTROLLER toggles four of
   those same classes on the cycle buttons (bg-surface, text-on-surface,
   text-on-surface-variant, shadow-sm) and three on the carousel cards. Restyling
   the templates by hand would have left the JavaScript writing light-theme classes
   onto a dark page — the state changes would have been the one part of the surface
   that stayed white, and no test would have caught it because the markup is only
   wrong after a click.

   ── Why there is no backdrop-filter ──────────────────────────────────────────────
   This is glassmorphism over a CSS GRADIENT, and blurring a smooth gradient
   returns the same smooth gradient. The effect here comes from the translucent
   dark fills, the hairline borders and the depth of the shadows; a blur would cost
   a compositing layer per card and change not one pixel. The header keeps its own
   blur — it floats over the photograph on the landing page, where there is real
   detail to soften. */

.landing-immersive {
    position: relative;
    /* Ends on the footer's exact colour, so the page runs into it without a seam. */
    background-color: #0d1219;
    /* Native controls follow — the number input's spinner, the focus rings the UA
       draws, the scrollbar inside the plan carousel. Without it a dark page has a
       white spinner box sitting in the middle of its glass card. */
    color-scheme: dark;

    /* ── The surfaces ────────────────────────────────────────────────────────────
       Glass tinted WHITE, not tinted toward the page's own black. The first pass
       went the other way on the theory that a darker card separates white type
       further — true in isolation, and wrong here, because the gradient behind it
       is already dark: a card darker still reads as a hole rather than as a pane,
       and the ladder of nested cards in the deep dive lost every edge between its
       levels. Lifting the fill costs a point or two of text contrast (13:1, from
       15:1) and buys back the whole structure — each nesting step is one more 6%
       of white, so a card inside a card is visibly inside it. */
    --color-surface: rgb(255 255 255 / 6%);
    --color-surface-container-low: rgb(255 255 255 / 4%);
    /* CONTROL surfaces rather than panels — the slider's rail is
       `bg-surface-container` — so they carry on past the panels: a rail no lighter
       than the card it sits in is a groove nobody can see. */
    --color-surface-container: rgb(255 255 255 / 18%);
    --color-surface-container-high: rgb(255 255 255 / 22%);
    --color-surface-container-highest: rgb(255 255 255 / 26%);

    --color-on-surface: #f4f7fa;
    --color-on-surface-variant: rgb(226 232 240 / 78%);
    --color-outline: rgb(255 255 255 / 32%);
    --color-outline-variant: rgb(255 255 255 / 16%);

    /* ── Primary, in two parts ───────────────────────────────────────────────────
       The variable is what primary READS as — tick labels, plan subtitles, the
       per-unit figure, focus rings, the tinted active row — so it has to survive on
       glass. The app's #00677d is mixed for white paper and sits at roughly 2:1
       here, which is why this is a lighter step of the same hue rather than the
       token itself. Deliberately deeper and less pale than the first pass (#8ed4ee),
       which read as cyan rather than as the product's blue.

       What primary FILLS is #00677d exactly — see below. A fill carries its own
       text, so it is the one place the app's own value works unchanged. */
    --color-primary: #4fb3d4;
    /* The app's primary, verbatim from theme.css. Named here so the fill rules
       below and the toggle read from one place. */
    --pricing-primary-fill: #00677d;

    /* ── Status, all three families ──────────────────────────────────────────────
       Success arrived with the skin and error and info did not, which is a gap
       that costs a page its legibility rather than a shade of polish. Everything
       a landing page paints with a status token was resolving to the LIGHT
       theme's value on a near-black document: `text-error-text` came out #9b4052,
       a dark maroon, against a 6%-white card over #0d1219, so the validation
       messages under every field of the registration form were close to
       unreadable. The info notice failed the other way, because
       --color-on-info-container was not overridden either: near-white type on
       #cce5ff.

       The foreground values are theme.css's own `[data-theme="dark"]` values,
       verbatim. They are not invented here, and they are already what the app
       uses when it is dark.

       The CONTAINERS are not verbatim, and that is the one deliberate departure.
       theme.css fills them opaque (#7d293b, #004b73) because it paints on a solid
       dark surface. Here every panel is translucent white over a gradient, and an
       opaque fill in the middle of that reads as a hole punched through the page
       rather than as a banner sitting on it. So they follow
       --color-success-container above: the same hue, carried at an alpha that
       lets the gradient through. The foregrounds are light enough to clear 7:1
       against the blend either way.

       Kept in step by template/landing_immersive_token_coverage_test.go, which
       derives the list from the markup rather than restating it. A family added
       to a landing page and forgotten here fails that test by name. */
    --color-success: #6ee7b7;
    --color-success-text: #6ee7b7;
    --color-success-container: rgb(16 185 129 / 20%);
    --color-on-success-container: #baf5d8;

    --color-error: #ffb2bc;
    --color-error-text: #ffb2bc;
    --color-error-container: rgb(125 41 59 / 35%);
    --color-on-error-container: #ffd9dd;

    --color-info: #92ccff;
    --color-info-container: rgb(0 75 115 / 45%);
    --color-on-info-container: #cce5ff;
}

/* The gradient, standing in for the photograph the landing page opens on: the
   sunset warm through the top right, the room's wood glow bottom left, the cold
   sky above, over a slate that deepens toward the footer. Same colours, no
   megabyte — and nothing here to compete with a price.

   On a pseudo-element rather than on the box, so it cannot be repeated by any
   nested background and so `background-color` above stays the one flat fallback if
   gradients are unsupported. */
.landing-immersive::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    background:
        radial-gradient(115% 75% at 78% 6%, rgb(232 135 63 / 26%) 0%, transparent 58%),
        radial-gradient(85% 60% at 6% 94%, rgb(198 128 74 / 16%) 0%, transparent 62%),
        radial-gradient(120% 55% at 50% 0%, rgb(138 151 166 / 14%) 0%, transparent 60%),
        linear-gradient(180deg, #121820 0%, #0e141c 46%, #0d1219 100%);
}

/* Everything the page renders sits above the gradient. One rule rather than a
   z-index on each block: the gradient is at 0, its siblings are at 1, and nothing
   in the page has to know the layer exists. */
.landing-immersive > * {
    position: relative;
    z-index: 1;
}

/* ── What primary FILLS ───────────────────────────────────────────────────────
   `bg-primary` is always a button or a badge here, and every one of them already
   carries `text-white` — so a fill takes the app's own primary, unchanged, and the
   markup's foreground is correct as written (white on #00677d is ~6:1).

   This is the one place the token survives the move to a dark page intact. It reads
   as the product's blue rather than as a bright accent, which is what a button that
   commits someone to a plan should look like.

   The class selector out-specifies nothing it needs to — `text-white` is left to do
   its job — but it does have to beat the `bg-primary` utility, which it does on
   specificity (0,2,0 against 0,1,0) without `!important`. The slider thumbs are NOT
   caught by it: they are written as `[&::-webkit-slider-thumb]:bg-primary`, a
   different class entirely, so they follow the variable and stay light against
   their white ring — a thumb in the fill colour would vanish into its own rail. */
.landing-immersive .bg-primary {
    background-color: var(--pricing-primary-fill);
}

.landing-immersive .hover\:bg-primary\/90:hover {
    background-color: #007e98;
}

/* ── A banner inside the immersive skin ───────────────────────────────────────
   The contact page keeps its photograph — a page whose only content is a form has
   nothing to focus on, so the picture is the welcome rather than an obstacle in
   front of the thing the visitor came for. The pricing page is the opposite case
   and has none.

   The problem a banner has here is the JOIN. The image is a bright, pale render and
   the page under it is near-black, so ending it on a straight edge draws a hard
   light-to-dark line across the page — the "banner pasted on top" look the whole
   treatment exists to get away from.

   Two layers solve it and neither is a colour that has to be kept in step with the
   artwork:

     mask-image  fades the IMAGE ITSELF out at the bottom, so what appears beneath
                 it is the page's own gradient, whatever that gradient happens to
                 be. A matching solid colour would have to be re-tuned every time
                 either end moved; a mask cannot fall out of step.
     ::after     a scrim over what is left, for the white type. Weighted to the top
                 and bottom and light through the middle, so the subject of the
                 photograph stays visible while the copy keeps its contrast. */
.landing-immersive__banner {
    position: relative;
    overflow: hidden;
    mask-image: linear-gradient(to bottom, #000 0%, #000 52%, transparent 100%);
    -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 52%, transparent 100%);
}

.landing-immersive__banner::after {
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    background:
        linear-gradient(to bottom, rgb(11 15 21 / 58%) 0%, rgb(11 15 21 / 30%) 42%, rgb(11 15 21 / 72%) 100%);
}

.landing-immersive__banner img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* ── De-emphasis has to be re-tuned for a dark page ───────────────────────────
   The controller dims everything that is not the matched tier: `opacity-60` on the
   plan cards, `opacity-40` on the rate-table rows. Both were judged against white,
   where fading grey text toward a white page still leaves it grey and readable.
   Over glass they fade toward BLACK, and the same numbers put "€29.00 per month
   base" on an unmatched card below the point where it can be read at all — the
   ladder a visitor is supposed to compare against went out with the contrast.

   Lifted just enough to stay legible while the matched tier still plainly wins:
   it keeps its full opacity, its ring and its badge, none of which the others get.

   The `:not()` clauses matter. `hover:opacity-100` and `focus-within:opacity-100`
   are on the same cards at (0,2,0), and these selectors would otherwise out-specify
   them and pin a hovered card at 0.78 — the fade would stop responding to the
   pointer, which is the one moment it is supposed to. */
.landing-immersive [data-landing--pricing-target="carouselCard"].opacity-60:not(:hover):not(:focus-within) {
    opacity: 0.78;
}

.landing-immersive [data-landing--pricing-target~="matrixRow"].opacity-40 {
    opacity: 0.66;
}

/* ── A dialog is the one surface that cannot be glass ─────────────────────────
   Both dialogs on this page — the enterprise lead form and the "what is a unit?"
   explainer — build their panel out of `bg-surface`, which is now 6% white. Over a
   dimmed backdrop that is not a panel, it is a pane of the page showing through
   itself, and the form's labels land on top of the plan cards behind them.

   So the panel, and only the panel, goes opaque. The controls INSIDE it keep the
   glass fill: over an opaque panel a 6% field is a legible step lighter, which is
   exactly the separation an input wants. `> .bg-surface` is what draws that line —
   the panel is the dialog's own child, the fields are further down. */
.landing-immersive [role="dialog"] > .bg-surface {
    background-color: #171e28;
}

/* The explainer's four unit-type tiles are the one thing on this page written in
   raw palette classes — `bg-blue-50` on a `blue-200` border, repeated four times —
   rather than in tokens, so nothing above reaches them and they render as four
   near-white cards in a dark dialog.

   Re-pointed here rather than in the template because the alternative is the same
   three colours edited in four places, and because unit_explainer.gohtml is reached
   only from this page: the rule and its only caller live in the same scope. If the
   explainer is ever reused on a light surface, it still has its own colours and
   this simply stops applying. */
.landing-immersive [role="dialog"] .bg-blue-50 {
    background-color: rgb(79 179 212 / 10%);
}

.landing-immersive [role="dialog"] .border-blue-200 {
    border-color: rgb(79 179 212 / 26%);
}

.landing-immersive [role="dialog"] .text-blue-500,
.landing-immersive [role="dialog"] .text-blue-600 {
    color: var(--color-primary);
}

/* ── The billing toggle has to look like a switch ─────────────────────────────
   It is two buttons in a pill, and the selected one is marked by `bg-surface` —
   which on white paper is a white chip on a grey track and reads instantly. Under
   this skin `bg-surface` is a 6% glass panel sitting on a 4% glass track, a two
   percent difference that nobody sees: the control rendered as two words with a
   faint outline round them, and the page's one real choice looked like a caption.

   So the selected option is stated in the fill colour instead. Same reasoning as
   the buttons — it is the product's blue, on the element that decides what every
   figure below it says.

   Written against the target attribute rather than a class, because the CLASSES on
   these buttons belong to the controller: it toggles bg-surface / shadow-sm /
   text-on-surface / text-on-surface-variant on every cycle change, and a rule
   hooked to any of those would style the state only until the next click. The
   attribute is the stable half, and `aria-pressed` is the state — which is already
   the accessible truth of this control, so the styling and the semantics cannot
   drift apart. */
.landing-immersive [data-landing--pricing-target="cycleOption"] {
    color: rgb(226 232 240 / 72%);
}

.landing-immersive [data-landing--pricing-target="cycleOption"]:hover {
    color: #fff;
}

.landing-immersive [data-landing--pricing-target="cycleOption"][aria-pressed="true"] {
    background-color: var(--pricing-primary-fill);
    color: #fff;
    box-shadow: 0 1px 2px rgb(0 0 0 / 45%);
}

/* And the track it runs in, one step up from the card so the pill has something to
   sit on rather than floating in the middle of the panel. */
.landing-immersive [role="group"][aria-label="Billing cycle"] {
    background-color: rgb(255 255 255 / 8%);
    border-color: rgb(255 255 255 / 18%);
}

/* ── The plan CTA has never actually been hidden ──────────────────────────────
   `class="hidden inline-flex …"` on the per-card "Select <plan>" link, with the
   controller toggling `hidden` to reveal it on the matched tier. Both utilities
   set `display`, they have equal specificity, and `.inline-flex` is emitted after
   `.hidden` in the compiled sheet — so the later one wins and every card has
   always shown its buy button, on both pricing pages, whatever the controller
   does. The matched badge next to it uses `hidden absolute`, where the second
   utility sets `position`, so that one has always worked; the difference is why
   this survived so long.

   Fixed here rather than in the markup because the markup is right: `hidden` plus
   a display for when it is not hidden is the ordinary Tailwind idiom, and the
   controller toggling one class is the correct amount of JavaScript. The
   attribute selector out-specifies the utility and restores the intent. */
[data-plan-select-cta].hidden {
    display: none;
}

/* Tailwind registers --tw-ring-offset-color with `inherits: false`, so setting it
   on the wrapper reaches nothing. It has to land on each element that might draw a
   ring, which is what the descendant selector does. Left alone, every
   `ring-offset-2` on this page draws a WHITE gap between the control and its focus
   ring — the one part of the design that only appears for keyboard users. */
.landing-immersive * {
    --tw-ring-offset-color: #0d1219;
}

.landing-immersive ::placeholder {
    color: rgb(226 232 240 / 45%);
}

/* ── Book a Call CTA (cal-booking-integration FR-3) ─────────────────────────
   The Cal.com booking button, wherever it appears.

   ONE treatment, white throughout: white type, a white hairline, and a
   translucent white fill. No primary-tinted variant — an earlier version tinted
   the fill with primary on light surfaces and that simply reproduced the solid
   blue button this replaced.

   Authored here rather than as Tailwind utilities because the opacity classes it
   needs (hover:bg-white/20) generate no rule at all in the built CSS, so a
   utility-only version would ship a hover state that silently did nothing.

   White-on-white has almost no contrast, so the light placements get the filled
   variant below rather than this one. That is not a second design: it is the same
   button answering the same question on the other half of the site. */
.kito-book-call {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    min-height: 44px;
    padding: 0 1.1rem;
    border-radius: 0.375rem;
    border: 1px solid rgb(255 255 255 / 55%);
    background: rgb(255 255 255 / 10%);
    color: #fff;
    font-size: 0.875rem;
    font-weight: 600;
    line-height: 1;
    transition: background-color 150ms ease, border-color 150ms ease;
}

.kito-book-call svg {
    flex-shrink: 0;
    display: block;
}

.kito-book-call span {
    display: inline-flex;
    align-items: center;
}

.kito-book-call:hover {
    border-color: #fff;
    background: rgb(255 255 255 / 20%);
}

.kito-book-call:focus-visible {
    outline: 2px solid rgb(255 255 255 / 70%);
    outline-offset: 2px;
}

/* ── The same button, on the light half of the site ──────────────────────────
   Two placements sit on an opaque light surface, and on both the rule above is
   white type on a white bar with a white hairline round it — present in the DOM,
   invisible to the eye.

     .landing-nav:not(--immersive)  the solid bar the document pages wear
     .landing-nav.is-solid          an immersive bar after the image scrolled past

   The mobile drawer was the third, and it is not any more. This darkened mix is
   for white paper, and the drawer became dark glass with the mega menu — so the
   treatment that used to rescue the button there is now the thing making it a
   near-black smudge on a dark sheet. The base rule above is the white-on-dark
   variant and is what the drawer wants, so the drawer is simply absent from these
   selectors rather than carrying a third case.

   Both halves of that have now been got wrong once, in opposite directions, which
   is what a placement list in a selector costs: whoever changes a surface has to
   remember this file exists.

   It is the SAME button, in the brand colour instead of white: a hairline, a
   barely-there tint and coloured type. A solid fill was tried first and it read
   as a different component from the one in the hero directly below it — the page
   has one Book a call and it should look like one thing.

   ── Why the type is not simply var(--color-primary) ─────────────────────────
   The landing brand teal resolves to #4998b0, which is 3.3:1 on white. The label
   is 14px at weight 600, which is normal text, so AA wants 4.5:1 and that fails.
   Mixed 22% toward black it is 4.7:1 against the tinted fill it actually sits on,
   and still unmistakably the brand colour. Measure against the FILL, not against
   white: the 8% tint costs about a tenth of a point and this only clears by two.

   The mix is stated once as a custom property because the border, the icon and
   the label have to be the same colour or the button looks assembled from parts.
   On a dark surface the same mix lands above 5:1, so there is no second rule. */
.landing-nav:not(.landing-nav--immersive) .kito-book-call,
.landing-nav.is-solid .kito-book-call {
    --kito-book-call-ink: color-mix(in srgb, var(--color-primary) 78%, #000);
    border-color: color-mix(in srgb, var(--color-primary) 45%, transparent);
    background: color-mix(in srgb, var(--color-primary) 8%, transparent);
    color: var(--kito-book-call-ink);
}

.landing-nav:not(.landing-nav--immersive) .kito-book-call:hover,
.landing-nav.is-solid .kito-book-call:hover {
    border-color: var(--kito-book-call-ink);
    background: color-mix(in srgb, var(--color-primary) 14%, transparent);
}

.landing-nav:not(.landing-nav--immersive) .kito-book-call:focus-visible,
.landing-nav.is-solid .kito-book-call:focus-visible {
    outline-color: var(--kito-book-call-ink);
}

.kito-section-card {
    padding: 1.5rem;
}

.kito-field-row {
    padding-block: 0.75rem;
    border-bottom-width: 1px;
    border-bottom-style: solid;
    border-bottom-color: var(--color-outline-variant);
}

@supports (color: color-mix(in lab, red, red)) {
    .kito-field-row {
        border-bottom-color: color-mix(in oklab, var(--color-outline-variant) 40%, transparent);
    }
}

.kito-field-row:last-child,
.kito-field-row[data-field-row-no-divider] {
    border-bottom-width: 0;
}

.kito-field-row[data-field-row-standalone] {
    padding-block: 0;
}

@media (min-width: 48rem) {
    .kito-section-card {
        padding: 2rem;
    }
}

/* ── Organization setup stepper: mobile expand ──────────────────────────────
   Below sm, a sequence longer than five steps collapses the steps far from
   the current one behind an "…" button (setupStepperMobileMax,
   templates/partials/setup/stepper.gohtml). Tapping it sets data-expanded on
   the stepper's root <ol>. These two rules are what the tap actually does:
   the previously hidden circles and connector lines come back with the same
   display value the sm breakpoint already gives them, and the "…" markers
   that replaced them go away. Higher specificity than the plain `.hidden`
   and `sm:hidden` utilities beats them without `!important`. */
[data-testid="organization-setup-stepper"][data-expanded="true"] [data-setup-stepper-hidden="true"] {
    display: flex;
}

[data-testid="organization-setup-stepper"][data-expanded="true"] [data-setup-stepper-gap="true"] {
    display: none;
}

/* ══════════════════════════════════════════════════════════════════════════
   Organization setup completion celebration (#1855)
   Plain CSS. The popper pops in, then confetti bursts outward and fades. Under
   prefers-reduced-motion the animation is off and the popper sits at its final
   frame with the confetti hidden.
   ══════════════════════════════════════════════════════════════════════════ */
.org-setup-popper {
    transform-origin: 20% 90%;
    animation: org-setup-pop 0.9s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}

.org-setup-confetti {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 8px;
    height: 12px;
    border-radius: 2px;
    opacity: 0;
    animation: org-setup-burst 1.4s ease-out both;
    animation-delay: calc(0.35s + var(--delay, 0s));
}

.org-setup-confetti-primary { background: var(--color-primary); }
.org-setup-confetti-secondary { background: var(--color-secondary); }
.org-setup-confetti-tertiary { background: var(--color-tertiary); }
.org-setup-confetti-success { background: var(--color-success); }
.org-setup-confetti-warning { background: var(--color-warning); }

@keyframes org-setup-pop {
    0% { transform: scale(0.2) rotate(-25deg); opacity: 0; }
    60% { transform: scale(1.15) rotate(8deg); opacity: 1; }
    100% { transform: scale(1) rotate(0); opacity: 1; }
}

@keyframes org-setup-burst {
    0% { transform: translate(-50%, -50%) rotate(0) scale(0.4); opacity: 0; }
    15% { opacity: 1; }
    100% { transform: translate(calc(-50% + var(--tx, 0px)), calc(-50% + var(--ty, 0px) + 40px)) rotate(var(--rot, 180deg)) scale(1); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
    .org-setup-popper {
        animation: none;
    }

    .org-setup-confetti {
        animation: none;
        display: none;
    }
}
