/* ═══════════════════════════════════════════════════════════════════════════
   DESIGN TOKENS
   ═══════════════════════════════════════════════════════════════════════════ */

/* ═══════════════════════════════════════════════════════════════════════════
   DESIGN TOKENS + COMPONENT LAYER

   Everything below the "COMPONENTS" heading exists because index.html had no
   component vocabulary: every card, button, badge, modal and table was written
   as inline Tailwind and repeated ~40 times, each copy drifting a little
   (rounded-xl vs rounded-2xl, p-4 vs p-5, border-gray-100 vs border-gray-200).
   That drift is what made some screens feel like a different product.

   Prefer these classes for new markup. They are plain CSS, not Tailwind
   @apply, so they work identically in index.html and landing.html — the
   landing page loads this file too, which is how it inherits the light-mode
   contrast boost it used to be missing.
   ═══════════════════════════════════════════════════════════════════════════ */

:root {
  --kai-blue: #164c81;
  --kai-blue-dark: #0a1f44;

  /* The ONE "this tab/chip is the selected one" fill, read by .segmented-btn,
     .dash-tab-btn and the two toggle-chip families. It is a token rather than
     a repeated literal because dark mode needs a DIFFERENT hue, not merely a
     different opacity: --kai-blue on #111827 is nearly the track colour, which
     is what left the four tab strips reading as "none of these is on". */
  --tab-active: #164c81;
  --tab-active-text: #ffffff;
  --kai-orange: #f3861b;

  --surface: #ffffff;
  --surface-2: #f1f5f9;
  --surface-sunken: #e8edf3;
  --border: #cbd5e1;
  --border-strong: #94a3b8;
  --text: #111827;
  --text-muted: #6b7280;
  --text-faint: #9ca3af;

  --radius: 0.75rem;
  --radius-lg: 1rem;
  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.06);
  --shadow-pop: 0 10px 30px rgba(15, 23, 42, 0.18);
}

.dark {
  --surface: #1f2937;
  /* Lifted off the brand navy: a solid, saturated blue is the only thing that
     separates cleanly from --surface-sunken (#111827) at this size. */
  --tab-active: #2563eb;
  --tab-active-text: #ffffff;
  --surface-2: rgba(55, 65, 81, 0.5);
  --surface-sunken: #111827;
  --border: #374151;
  --border-strong: #4b5563;
  --text: #f9fafb;
  --text-muted: #9ca3af;
  --text-faint: #6b7280;

  --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4);
  --shadow-pop: 0 10px 30px rgba(0, 0, 0, 0.55);
}

/* ═══════════════════════════════════════════════════════════════════════════
   BASE — reset, print, global theme contrast
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Custom scrollbar ── */
::-webkit-scrollbar {
  width: 5px;
  height: 5px;
}

::-webkit-scrollbar-track {
  background: transparent;
}

::-webkit-scrollbar-thumb {
  background: #cbd5e1;
  border-radius: 4px;
}

.dark ::-webkit-scrollbar-thumb {
  background: #475569;
}

/* Hero panel with gradient + background image.
 *
 * The source was a 3060x2142 PNG weighing 8.16 MB — on the LOGIN screen, so it
 * was the first thing every user waited for, and it sits under an ~85% opacity
 * gradient that hides essentially all of its detail. Re-encoded to WebP at
 * 1600px it is 145 KB, a 58x reduction, with no visible difference through the
 * wash.
 *
 * The gradient is declared separately from the image so the panel has its
 * final colour immediately and the photo fades in behind it, rather than the
 * whole hero being blank until the image lands.
 */
.hero-panel {
  background-color: #0a1f44;
  background-image:
    linear-gradient(135deg,
      rgba(10, 31, 68, 0.85) 0%,
      rgba(22, 76, 129, 0.80) 50%,
      rgba(26, 95, 168, 0.75) 100%),
    url('./hero_bg.jpg');
  background-position: center;
  background-size: cover;
  background-repeat: no-repeat;
}

/* WebP where supported (145 KB vs the 244 KB JPEG). Declared as a separate
 * rule rather than image-set() so that a browser which understands neither
 * simply keeps the JPEG above instead of dropping the image entirely. */
@supports (background-image: url('./hero_bg.webp')) {
  .hero-panel {
    background-image:
      linear-gradient(135deg,
        rgba(10, 31, 68, 0.85) 0%,
        rgba(22, 76, 129, 0.80) 50%,
        rgba(26, 95, 168, 0.75) 100%),
      image-set(url('./hero_bg.webp') type('image/webp'),
                url('./hero_bg.jpg') type('image/jpeg'));
  }
}

/* The hero is hidden below 1024px (lg:flex on the panel), but a narrow desktop
 * window still only needs the smaller crop. */
@media (min-width: 1024px) and (max-width: 1400px) {
  @supports (background-image: url('./hero_bg.webp')) {
    .hero-panel {
      background-image:
        linear-gradient(135deg,
          rgba(10, 31, 68, 0.85) 0%,
          rgba(22, 76, 129, 0.80) 50%,
          rgba(26, 95, 168, 0.75) 100%),
        image-set(url('./hero_bg@800.webp') type('image/webp'),
                  url('./hero_bg.jpg') type('image/jpeg'));
    }
  }
}

/* `.hero-panel-watermark` lived here as an unused alternative treatment. It was
 * the last reference to hero_bg.png, so removing it let the 8.16 MB source be
 * deleted from the repo entirely. Recoverable from git history if wanted. */

.logo-login {
  height: 49px;
  width: 225px;
  background-image: url('./logo_login.svg');
  background-size: contain;
  background-repeat: no-repeat;
  background-position: left center;
}

.logo-nav {
  height: 49px;
  width: 225px;
  background-image: url('./logo_nav.svg');
  background-size: contain;
  background-repeat: no-repeat;
  background-position: left center;
}

/* ── Print: QR only ── */
@media print {
  body > *:not(#qr-print-area) {
    display: none !important;
  }

  #qr-print-area {
    display: flex !important;
    position: fixed;
    inset: 0;
    align-items: center;
    justify-content: center;
    background: white;
  }
}

/* ── Light mode contrast boost ── */
:root:not(.dark) body {
  background-color: #e8edf3;
}

:root:not(.dark) .bg-white {
  box-shadow:
    0 1px 3px rgba(0, 0, 0, 0.08),
    0 0 0 1px rgba(0, 0, 0, 0.06);
}

:root:not(.dark) .border-gray-100 {
  border-color: #cbd5e1 !important;
}

:root:not(.dark) .border-gray-200 {
  border-color: #94a3b8 !important;
}

:root:not(.dark) input:not([type="radio"]):not([type="checkbox"]),
:root:not(.dark) select,
:root:not(.dark) textarea {
  border-color: #94a3b8 !important;
  background-color: #ffffff !important;
}

:root:not(.dark) input:focus,
:root:not(.dark) select:focus,
:root:not(.dark) textarea:focus {
  border-color: #164c81 !important;
  box-shadow: 0 0 0 3px rgba(22, 76, 129, 0.12) !important;
}

:root:not(.dark) table thead tr {
  background-color: #f1f5f9;
}

:root:not(.dark) table tbody tr:hover {
  background-color: #f0f4f8 !important;
}

:root:not(.dark) .divide-gray-100 > * + * {
  border-color: #cbd5e1 !important;
}

:root:not(.dark) .hover\:bg-white\/8:hover,
:root:not(.dark) .hover\:bg-gray-50:hover {
  background-color: #f8fafc !important;
}

/* Tab active states */
:root:not(.dark) .hist-tab.is-active,
:root:not(.dark) button.is-active {
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

/* Card hover borders */
:root:not(.dark) .rounded-xl:hover {
  border-color: #94a3b8;
}

/* Topbar border */
:root:not(.dark) header {
  border-bottom-color: #cbd5e1 !important;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}

/* Breadcrumb area */
:root:not(.dark) .bg-gray-50 {
  background-color: #f1f5f9 !important;
}

/* Modal backdrop stronger in light */
:root:not(.dark) .bg-gray-900\/40 {
  background-color: rgba(15, 23, 42, 0.55) !important;
}

/* ── 5. Readable muted text ───────────────────────────────────────────
   `text-gray-400` on white is 2.8:1 — below the 4.5:1 minimum, and it was the
   app's default for field labels and captions. These map the two Tailwind
   classes the codebase reaches for onto tokens that actually pass. */
:root:not(.dark) .text-gray-400 {
  color: #6b7280;
}

:root:not(.dark) .text-gray-300 {
  color: #9ca3af;
}

/* ═══════════════════════════════════════════════════════════════════════════
   ROUTING & VIEW VISIBILITY

   Which .view-section is on screen, driven by switchView() / js/router.js
. See CLAUDE.md's "Routing" section for the client/server
   design; this is the one display rule it depends on.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── View visibility (routed by switchView(), js/router.js) ── */
.view-section {
  display: none;
}

.view-section.is-visible {
  display: block;
}

.view-section.is-flex {
  display: flex;
  flex-direction: column;
}

/* ── Cross-screen entrance fade ──
   switchView() toggles `is-visible`/`is-flex` instantly, same as it always
   has — no JS timing to get right here, same idiom .dash-panel and
   .master-tab-panel already use below: a CSS animation replays on its own
   whenever the element goes from `display:none` back to displayed, so the
   existing classList mutations are enough to replay this on every
   navigation. This was the last un-animated switch in the app — every modal
   and half the app's tab strips already got this treatment; the screen
   switch itself, the thing every sidebar click and card link goes through,
   never had. No fade-out is needed: the old view is simply covered by the
   new one, exactly as today. */
.view-section.is-visible:not(.is-flex),
.view-section.is-flex {
  animation: dash-panel-fade 0.16s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .view-section.is-visible,
  .view-section.is-flex {
    animation: none;
  }
}

/* ═══════════════════════════════════════════════════════════════════════════
   COMPONENTS

   One definition per visual element, reused across screens. Names are
   deliberately boring so they read the same in markup as they do here.
   Prefer these over hand-written inline Tailwind for new markup.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Master edit modal field label helper ── */
.field-label {
  display: block;
  font-size: 0.75rem;
  font-weight: 600;
  margin-bottom: 0.375rem;
  color: #6b7280;
}

.dark .field-label {
  color: #9ca3af;
}

.field-input {
  width: 100%;
  padding: 0.625rem;
  background-color: #f9fafb;
  border: 1px solid #e5e7eb;
  border-radius: 0.5rem;
  font-size: 0.875rem;
  outline: none;
  transition: border-color 0.15s;
}

.dark .field-input {
  background-color: #374151;
  border-color: #4b5563;
}

.field-input:focus {
  border-color: #164c81;
}

.dark .field-input:focus {
  border-color: #f3861b;
}

/* ── select.field-input's own chevron ──
   .field-input is shared by <input> and <select> alike (unlike
   .form-input/.form-select, which split on purpose), so it cannot gain a
   wrapper element the way .form-select-wrap draws its chevron — 11 of these
   selects sit inline in flex rows next to .field-input text inputs and
   width-classed siblings (w-28 shrink-0, flex-1, …), and wrapping only the
   select would either break that sizing or need every sibling's width
   class moved onto a new wrapper for no visual gain. A background-image
   chevron needs no wrapper and no markup change: same glyph shape and
   --text-faint colour as .form-select-wrap::after, just drawn straight on
   the element itself. */
select.field-input {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  padding-right: 2.25rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%239ca3af' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 0.75rem center;
  background-size: 0.7rem;
}

.dark select.field-input {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%236b7280' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
}

/* ── Sidebar layout ── */
#sidebar {
  position: fixed;
  top: 0;
  left: 0;
  height: 100%;
  width: 17rem;
  z-index: 50;
  transform: translateX(-100%);
  transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}

#sidebar.open {
  transform: translateX(0);
}

#sidebar-toggle-btn {
  width: 3.6rem;
  height: 2.45rem;
  border-radius: 9999px;
  right: -1.2rem;
  transform: translateX(0);
  transition:
    width 0.24s ease,
    height 0.24s ease,
    border-radius 0.24s ease,
    transform 0.24s ease,
    right 0.24s ease;
  will-change: width, height, border-radius, right;
}

#sidebar-toggle-btn.is-open {
  width: 2.7rem;
  height: 2.7rem;
  border-radius: 9999px;
  right: -1.35rem;
}

/* Desktop: push content */
@media (min-width: 1024px) {
  #main-content-area {
    margin-left: 0;
    transition: margin-left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
  }

  #main-content-area.sidebar-open {
    margin-left: 16rem;
  }

  /* #sidebar-toggle-btn is a CHILD of #sidebar and deliberately pokes past
     its right edge as a floating pill — by design, not a bug — but how far
     it pokes into #main-content-area differs by state: 17rem (sidebar width)
     + 1.35rem (open) − 16rem (open margin-left) = 2.35rem overlap while
     open, vs 1.2rem while collapsed (the closed sidebar's translateX(-100%)
     carries the button back with it, so its own `right: -1.2rem` is measured
     from a right edge that has moved to x=0). #app-banner's base
     border-left + padding already clears the smaller 1.2rem collapsed-state
     figure with room to spare, so only the open state needs the extra
     clearance — added here, scoped to it, rather than raising the banner's
     base padding for both states and leaving an oversized gap once the
     sidebar is collapsed. */
  #main-content-area.sidebar-open #app-banner {
    padding-left: 2.5rem;
  }

  #sidebar-overlay {
    display: none !important;
  }
}

/* Mobile: overlay */
@media (max-width: 1023px) {
  #sidebar-overlay {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 40;
    background: rgba(0, 0, 0, 0.45);
    -webkit-backdrop-filter: blur(2px);
    backdrop-filter: blur(2px);
  }

  #sidebar-overlay.active {
    display: block;
  }
}

/* ── Nav active state ── */
.nav-btn {
  position: relative;
}

.nav-btn.is-active {
  background-color: rgba(255, 255, 255, 0.12) !important;
  color: white !important;
  font-weight: 600;
}

.nav-btn.is-active::before {
  content: "";
  position: absolute;
  left: 0;
  top: 20%;
  bottom: 20%;
  width: 3px;
  border-radius: 0 3px 3px 0;
  background: #f3861b;
}

/* ── Sidebar styling stays consistent across themes ── */
#sidebar {
  background: linear-gradient(180deg, #0a1f44 0%, #164c81 100%) !important;
  color: #ffffff !important;
}

#sidebar nav p {
  color: rgba(255, 255, 255, 0.58) !important;
}

/* ── Sidebar tree ───────────────────────────────────────────────────────
   Five groups; a parent that owns sub-tabs expands to list them. The parents
   are static markup, the children are built by renderNavTree() (js/shell.js)
   from each screen's own tab array.

   The parent ROW is two controls, not one: the wide half navigates to the
   view, the narrow caret only expands. Separating them is what lets a reader
   look at what is under "Kelola Inventaris" without leaving the screen they
   are on — folding both jobs into one button forces a navigation to answer a
   question about the menu.
*/

.nav-group-label {
  padding: 0 0.75rem;
  margin-top: 1rem;
  margin-bottom: 0.25rem;
  font-size: 0.5625rem;
  font-weight: 700;
  letter-spacing: 0.18em;
}

.nav-item + .nav-item {
  margin-top: 0.125rem;
}

.nav-row {
  display: flex;
  align-items: stretch;
  gap: 0.125rem;
}

.nav-btn {
  display: flex;
  flex: 1 1 auto;
  align-items: center;
  gap: 0.75rem;
  min-width: 0;
  padding: 0.625rem 0.75rem;
  border-radius: 0.5rem;
  font-size: 0.875rem;
  text-align: left;
  transition: background-color 0.15s, color 0.15s;
  color: rgba(255, 255, 255, 0.9) !important;
  background-color: transparent !important;
}

.nav-btn-icon {
  width: 1rem;
  flex: 0 0 auto;
  font-size: 0.75rem;
  text-align: center;
}

.nav-btn-label {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.nav-caret {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 2rem;
  border-radius: 0.5rem;
  font-size: 0.6875rem;
  color: rgba(255, 255, 255, 0.55);
  transition: background-color 0.15s, color 0.15s, transform 0.18s;
}

.nav-caret:hover,
.nav-caret:focus-visible {
  background-color: rgba(243, 134, 27, 0.25);
  color: #ffffff;
}

.nav-caret[aria-expanded="true"] {
  transform: rotate(180deg);
  color: #ffffff;
}

.nav-children {
  margin: 0.125rem 0 0.25rem 1.4375rem;
  padding-left: 0.5rem;
  border-left: 1px solid rgba(255, 255, 255, 0.16);
}

.nav-child {
  display: block;
  width: 100%;
  padding: 0.4375rem 0.625rem;
  border-radius: 0.375rem;
  font-size: 0.8125rem;
  text-align: left;
  color: rgba(255, 255, 255, 0.72);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: background-color 0.15s, color 0.15s;
}

.nav-child:hover,
.nav-child:focus-visible {
  background-color: rgba(255, 255, 255, 0.1);
  color: #ffffff;
}

/* The active CHILD is outlined rather than filled. Its parent is already
   filled solid orange directly above it, and two solid orange rows stacked
   read as one block instead of as a place inside a place. */
/* Scoped to #sidebar to outrank `:root:not(.dark) button.is-active` further up
   this file, which sets a drop shadow on every active button and would
   otherwise replace the inset bar with a light-mode card shadow floating on a
   dark blue panel. */
#sidebar .nav-child.is-active {
  background-color: rgba(243, 134, 27, 0.22);
  color: #ffffff;
  font-weight: 650;
  box-shadow: inset 2px 0 0 #f3861b;
}

.nav-btn:hover,
.nav-btn:focus-visible {
  background-color: rgba(243, 134, 27, 0.25) !important;
  color: #ffffff !important;
}

.nav-btn.is-active {
  background-color: #f3861b !important;
  color: #ffffff !important;
  font-weight: 700 !important;
  box-shadow: 0 2px 8px rgba(243, 134, 27, 0.35) !important;
}

.nav-btn.is-active::before {
  display: none;
  /* orange fill replaces the left bar */
}

.nav-btn .fas,
.nav-btn .far,
.nav-btn .fab,
.nav-btn span {
  color: inherit !important;
}

.hover\:bg-white\/8:hover {
  background-color: rgba(10, 31, 68, 0.38) !important;
}

/* ── Light mode: topbar text forced dark ── */
:root:not(.dark) #topbar-page-title {
  color: #111827 !important;
  font-weight: 700 !important;
}

:root:not(.dark) #topbar-page-subtitle {
  color: #374151 !important;
  font-weight: 600 !important;
}

:root:not(.dark) #topbar-username {
  color: #111827 !important;
  font-weight: 700 !important;
}

:root:not(.dark) #topbar-role {
  color: #374151 !important;
  font-weight: 600 !important;
}

:root:not(.dark) #topbar-clock {
  color: #111827 !important;
  font-weight: 700 !important;
}

:root:not(.dark) #topbar-date {
  color: #374151 !important;
  font-weight: 600 !important;
}

/* ── SO / TSO toggle buttons ── */
.status-btn {
  transition: all 0.15s;
  border-width: 2px;
}

.status-btn.is-so {
  background: #dcfce7 !important;
  color: #15803d !important;
  border-color: #22c55e !important;
}

.status-btn.is-tso {
  background: #fee2e2 !important;
  color: #b91c1c !important;
  border-color: #ef4444 !important;
}

.status-btn.is-idle {
  background: #f3f4f6 !important;
  color: #9ca3af !important;
  border-color: #e5e7eb !important;
}

.dark .status-btn.is-idle {
  background: #374151 !important;
  color: #6b7280 !important;
  border-color: #4b5563 !important;
}

/* ── Toast container ── */
#toast-container {
  position: fixed;
  z-index: 9999;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  /* mobile: bottom-center */
  bottom: 1.25rem;
  left: 50%;
  transform: translateX(-50%);
  width: max-content;
  max-width: calc(100vw - 2rem);
}

@media (min-width: 640px) {
  #toast-container {
    bottom: auto;
    top: 1.25rem;
    right: 1.25rem;
    left: auto;
    transform: none;
    align-items: flex-end;
  }
}

/* ── Surfaces ─────────────────────────────────────────────────────────── */

.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-card);
}

.card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.875rem 1.25rem;
  border-bottom: 1px solid var(--border);
}

.card-title {
  font-size: 0.875rem;
  font-weight: 700;
  color: var(--text);
}

.card-body {
  padding: 1.25rem;
}

/* Small uppercase heading used above a group of controls or a table. */
.section-title {
  font-size: 0.625rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-faint);
  display: flex;
  align-items: center;
  gap: 0.375rem;
}

/* KPI tile. The oversized watermark icon is opt-in via .stat-card > .stat-icon
   so a dense row of tiles can leave it out without looking inconsistent. */
.stat-card {
  position: relative;
  overflow: hidden;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-card);
  padding: 1rem;
  transition: border-color 0.15s;
}

.stat-card:hover {
  border-color: var(--border-strong);
}

.stat-card .stat-label {
  font-size: 0.5625rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-faint);
  margin-bottom: 0.25rem;
}

.stat-card .stat-value {
  font-size: 1.5rem;
  line-height: 1.2;
  font-weight: 700;
  color: var(--text);
}

.stat-card .stat-note {
  font-size: 0.625rem;
  color: var(--text-faint);
  margin-top: 0.125rem;
}

.stat-card .stat-icon {
  position: absolute;
  right: -0.25rem;
  bottom: -0.75rem;
  font-size: 3rem;
  color: var(--surface-2);
  transition: transform 0.15s;
  pointer-events: none;
}

.stat-card:hover .stat-icon {
  transform: scale(1.15);
}

/* ── Buttons ──────────────────────────────────────────────────────────── */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  padding: 0.625rem 1.25rem;
  border-radius: 0.5rem;
  border: 1px solid transparent;
  font-size: 0.875rem;
  font-weight: 600;
  line-height: 1.25rem;
  cursor: pointer;
  transition:
    background-color 0.15s,
    border-color 0.15s,
    color 0.15s,
    opacity 0.15s;
  white-space: nowrap;
}

.btn:disabled,
.btn[disabled] {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn-sm {
  padding: 0.375rem 0.75rem;
  font-size: 0.75rem;
}

.btn-block {
  width: 100%;
}

.btn-primary {
  background: var(--kai-blue);
  color: #ffffff;
  box-shadow: 0 1px 2px rgba(22, 76, 129, 0.3);
}

.btn-primary:hover:not(:disabled) {
  background: #1a5fa8;
}

.btn-accent {
  background: var(--kai-orange);
  color: #ffffff;
}

.btn-accent:hover:not(:disabled) {
  background: #dd760f;
}

.btn-success {
  background: #059669;
  color: #ffffff;
}

.btn-success:hover:not(:disabled) {
  background: #047857;
}

.btn-danger {
  background: #dc2626;
  color: #ffffff;
}

.btn-danger:hover:not(:disabled) {
  background: #b91c1c;
}

.btn-ghost {
  background: var(--surface-2);
  color: var(--text-muted);
  border-color: var(--border);
}

.btn-ghost:hover:not(:disabled) {
  background: var(--border);
  color: var(--text);
}

/* Square icon-only button, e.g. a row's delete affordance. */
.btn-icon {
  width: 2rem;
  height: 2rem;
  padding: 0;
  border-radius: 0.5rem;
  background: transparent;
  color: var(--text-faint);
  border: none;
}

.btn-icon:hover:not(:disabled) {
  background: var(--surface-2);
  color: var(--text);
}

.btn-icon.btn-icon-danger:hover:not(:disabled) {
  background: rgba(220, 38, 38, 0.1);
  color: #dc2626;
}

/* The × in a modal header. Twenty-five identical copies of this rule used to
   live inline in index.html; the round 1.75rem form is deliberately kept, so
   adopting the component class changed no pixels. `.btn-icon` proper stays the
   2rem squared-off variant used inside table rows. */
.btn-icon-close {
  width: 1.75rem;
  height: 1.75rem;
  border-radius: 9999px;
}

/* ── Badges ───────────────────────────────────────────────────────────── */

.badge {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.125rem 0.5rem;
  border-radius: 9999px;
  font-size: 0.625rem;
  font-weight: 700;
  line-height: 1rem;
  white-space: nowrap;
}

/* A CLICKABLE badge (Model/Type completeness chips, Dokumen Umum's Spek/
   Manual buttons, the dashboard matrix's flag door, and every future one)
   is markup'd as a real `<button class="badge ...">`; a purely informational
   one (SO/TSO, Jenis/Utama columns, role pills) is a `<span>` — that choice
   was already made consistently across the app, it just was never made
   VISIBLE. Tailwind's preflight deliberately does not set `cursor: pointer`
   on `<button>` (unlike this app's own `.btn`, which does), so every
   badge-button rendered with the exact same plain arrow cursor and zero
   hover feedback as a decorative span sitting right next to it — a new
   admin has no way to tell "just a label" from "there is more here" without
   clicking to find out. This is the ONE rule that fixes all of them at
   once, because the button/span split already exists in the markup. */
button.badge {
  cursor: pointer;
  transition: transform 0.12s ease, filter 0.12s ease;
}

button.badge:hover {
  filter: brightness(0.94);
  transform: translateY(-1px);
}

.dark button.badge:hover {
  filter: brightness(1.25);
}

button.badge:active {
  transform: translateY(0);
  filter: brightness(0.88);
}

button.badge:focus-visible {
  outline: 2px solid var(--kai-blue, #164c81);
  outline-offset: 2px;
}

/* SO / TSO / AFKIR colours are defined ONCE here. They previously existed in
   three places — this file's .status-btn rules, inline Tailwind in index.html
   and a third inline copy in landing.html — and had already drifted. */
.badge-so {
  background: #dcfce7;
  color: #15803d;
}

.badge-tso {
  background: #fee2e2;
  color: #b91c1c;
}

/* AFKIR renders red everywhere in the app, not grey — it shares the TSO
   treatment because both mean "not available". It used to be lumped in with
   .badge-neutral here, which disagreed with what every screen actually drew. */
.badge-afkir {
  background: #fee2e2;
  color: #b91c1c;
}

/* The muted state ("BLM KALIBRASI", "TIDAK TERMUTASI"). #6b7280 on #f3f4f6 is
   only 4.08:1, and these badges are 10px bold — under the 4.5:1 floor. */
.badge-neutral {
  background: #f3f4f6;
  color: #4b5563;
}

/* In transit. Distinct from .badge-warn (amber, "conditional") on purpose:
   these two appear side by side on the same card and must stay separable. */
.badge-move {
  background: #ffedd5;
  color: #c2410c;
}

.badge-warn {
  background: #fef3c7;
  color: #b45309;
}

.badge-info {
  background: #dbeafe;
  color: #1d4ed8;
}

.dark .badge-so {
  background: rgba(21, 128, 61, 0.25);
  color: #4ade80;
}

.dark .badge-tso {
  background: rgba(185, 28, 28, 0.25);
  color: #f87171;
}

.dark .badge-afkir {
  background: rgba(185, 28, 28, 0.25);
  color: #f87171;
}

/* Was #9ca3af on #374151 — 3.87:1, and the inline copy this replaced was
   #6b7280 on #374151, a barely-legible 2.31:1. */
.dark .badge-neutral {
  background: #374151;
  color: #d1d5db;
}

.dark .badge-move {
  background: rgba(194, 65, 12, 0.28);
  color: #fdba74;
}

.dark .badge-warn {
  background: rgba(180, 83, 9, 0.25);
  color: #fbbf24;
}

.dark .badge-info {
  background: rgba(29, 78, 216, 0.25);
  color: #93c5fd;
}

/* Role badges (Pusat Data ▸ Pengguna), Phase 7 cross-cutting: one of the
   five hand-rolled `roleColors`/`_ROLE_PILL` pills this app carried, and the
   only one of the five with no existing `.badge-*` colour close enough to
   reuse — ADMIN_WILAYAH/TEKNISI/PIMPINAN map onto
   .badge-info/.badge-so/.badge-neutral below (a deliberate colour reuse, not
   a semantic one: a role badge and a status badge are different facts that
   happen to want the same hues). SUPER_ADMIN alone has no such sibling, hence
   this one class — five names, not five hand-rolled Tailwind class strings
   kept in step by memory across two maps (`ROLE_BADGE` in the Tambah
   Pengguna preview pane, `_ROLE_PILL` in the table) that had already
   drifted to disagree with each other on which colour meant which role. */
.badge-role-super {
  background: #f3e8ff;
  color: #7e22ce;
}

.dark .badge-role-super {
  background: rgba(126, 34, 206, 0.25);
  color: #d8b4fe;
}

/* ── Istilah glossary hint ─────────────────────────────────────
   One shared "?" affordance next to institution-specific jargon (SO, TSO,
   Afkir, Peruntukan, Pengadaan, UPT, Balaiyasa, Mutasi, Kalibrasi, Wilayah).
   `renderIstilahHint()`/the click delegate live in js/core.js. */
.istilah-hint {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  margin-left: 0.25rem;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-muted);
  font-size: 10px;
  font-weight: 700;
  line-height: 1;
  cursor: help;
  vertical-align: middle;
}

.istilah-hint:hover,
.istilah-hint:focus-visible {
  border-color: var(--kai-blue);
  color: var(--kai-blue);
}

.dark .istilah-hint:hover,
.dark .istilah-hint:focus-visible {
  border-color: var(--kai-orange);
  color: var(--kai-orange);
}

.istilah-popover {
  position: absolute;
  z-index: 150;
  max-width: 260px;
  padding: 0.6rem 0.75rem;
  border-radius: 0.65rem;
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: var(--shadow-pop);
  font-size: 12px;
  line-height: 1.4;
  color: var(--text);
}

.istilah-popover strong {
  display: block;
  margin-bottom: 0.2rem;
  font-size: 12px;
  color: var(--text);
}

.istilah-popover p {
  margin: 0;
  color: var(--text-muted);
}

/* ── Modals ───────────────────────────────────────────────────────────── */

.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: rgba(15, 23, 42, 0.55);
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);
}

.modal-panel {
  width: 100%;
  max-width: 32rem;
  max-height: 90vh;
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-pop);
  overflow: hidden;
}

.modal-panel.modal-wide {
  max-width: 48rem;
}

.modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 1rem 1.25rem;
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}

.modal-head h3 {
  font-size: 1rem;
  font-weight: 700;
  color: var(--text);
}

.modal-head p {
  font-size: 0.75rem;
  color: var(--text-muted);
  margin-top: 0.125rem;
}

.modal-body {
  padding: 1.25rem;
  overflow-y: auto;
  flex: 1 1 auto;
}

.modal-foot {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.5rem;
  padding: 0.875rem 1.25rem;
  border-top: 1px solid var(--border);
  background: var(--surface-2);
  flex-shrink: 0;
}

/* ── Letterhead (KAI/BUMN banner) ─────────────────────────────────────── */
/*
   Extracted from the Dashboard's banner, which was inline Tailwind:
   `flex flex-wrap items-center gap-4 px-5 py-3 border-l-4 border-kai-blue
   bg-white dark:bg-gray-800`, an `h-8` KAI mark, a `min-w-0 flex-1`
   title/sub/dateline block, and a `hidden md:flex` right group holding a
   `text-[9px] font-semibold tracking-[0.15em] … max-w-[170px] truncate` org
   line plus an `h-6` BUMN mark. Kelola Data Alat Kerja is getting the
   identical banner; two inline copies would drift the moment either one was
   touched, so this is that block expressed once. Every value below is the
   exact Tailwind-computed value it replaces (gap-4 = 1rem, px-5 = 1.25rem,
   py-3 = 0.75rem, border-l-4 = 4px, flex-1 = `flex: 1 1 0%`, and
   --text-faint is #9ca3af light / #6b7280 dark — the precise
   text-gray-400/text-gray-500 pair the org line used), so applying these
   classes in place of the old utility list changes no pixels.

   The light/dark logo swap itself is NOT part of this component: it stays
   two <img> tags in the markup (`dark:hidden` / `hidden dark:block`)
   choosing between the colour SVG and its white knockout, because these are
   official multi-colour marks and a `brightness-0 invert` filter would
   misrepresent both (see assets/LOGO-SUMBER.md). .letterhead-mark only
   carries what both logo instances need regardless of which one is showing;
   the KAI mark's h-8 vs the BUMN mark's h-6 stay as Tailwind height
   utilities in the markup, since the two logos are deliberately not the
   same size.
*/

.letterhead {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 1rem;
  padding: 0.75rem 1.25rem;
  border-left: 4px solid var(--kai-blue);
  background: var(--surface);
}

/* The height lives HERE, not in a Tailwind `h-8` in the markup. It was left to
   the markup once and one of the two banners simply did not carry it, so a
   viewBox-only SVG with no intrinsic height rendered ~700px tall and pushed the
   whole page down. A component class that needs the caller to remember one more
   utility is not a component. */
.letterhead-mark {
  flex-shrink: 0;
  height: 2rem;
  width: auto;
}

/* The BUMN mark sits a size down from the KAI one, deliberately: they are not
   peers on this bar, one is the operator and one is the ministry programme. */
.letterhead-mark-sm {
  flex-shrink: 0;
  height: 1.5rem;
  width: auto;
}

.letterhead-body {
  min-width: 0;
  flex: 1 1 0%;
}

/* One type scale for both banners. These were inline Tailwind, duplicated per
   banner, which is exactly how the two drifted apart in the first place. */
.letterhead-title {
  font-size: 0.875rem;
  font-weight: 700;
  line-height: 1.25;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.letterhead-sub {
  font-size: 0.75rem;
  font-weight: 600;
  line-height: 1.25;
  color: var(--text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.letterhead-dateline {
  font-size: 0.625rem;
  line-height: 1.25;
  margin-top: 0.125rem;
  color: var(--text-faint);
}

/* The right-hand group: org line plus the BUMN mark. Hidden below `md:`, where
   the bar has no room for a second block beside the title. */
.letterhead-aside {
  display: none;
  align-items: center;
  gap: 0.5rem;
  flex-shrink: 0;
}

@media (min-width: 768px) {
  .letterhead-aside {
    display: flex;
  }
}

.letterhead-org {
  font-size: 9px;
  font-weight: 600;
  letter-spacing: 0.15em;
  text-align: right;
  line-height: 1.25;
  color: var(--text-faint);
  max-width: 170px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ── Detail hero (Detail Aset / Detail Suku Cadang identity header) ──────
   The persistent header used to be a two-card grid stretched to equal
   height by its sibling (the Spesifikasi Alat Kerja card, which can run
   from ~230px to 500px+ depending on how complete that Model/Type's data
   is) — on a short-content card that left hundreds of px of empty space,
   repeated on every tab. This is a single card sized to its own content,
   deliberately its own component rather than a reuse of `.letterhead`
   (that one is the report/list-screen banner with org branding; this is a
   per-record identity header). The full spec card now lives in the
   Identitas tab only, where it's topically relevant. */
.detail-hero {
  display: flex;
  align-items: flex-start;
  gap: 0.875rem;
  padding: 1rem 1.25rem;
  border-left: 4px solid var(--kai-blue);
  background: var(--surface);
  border-radius: 0.75rem;
  border-top: 1px solid var(--border);
  border-right: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}

/* Status tones swap the left accent, not the whole card — set inline via
   style="border-left-color: …" per status, same values as .badge-so/-tso. */
.detail-hero-icon {
  flex-shrink: 0;
  width: 2.75rem;
  height: 2.75rem;
  border-radius: 0.625rem;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--surface-2);
  color: var(--kai-blue);
  font-size: 1.125rem;
}

.dark .detail-hero-icon {
  color: #93c5fd;
}

.detail-hero-body {
  min-width: 0;
  flex: 1 1 0%;
}

/* ── Toolbar (search + actions strip above a list) ───────────────────── */

.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem;
  padding: 0.75rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-card);
}

.toolbar-search {
  position: relative;
  flex: 1 1 14rem;
  min-width: 0;
}

.toolbar-search input {
  width: 100%;
  padding: 0.625rem 0.75rem 0.625rem 2.25rem;
  border-radius: 0.5rem;
  border: 1px solid var(--border-strong);
  background: var(--surface);
  color: var(--text);
  font-size: 0.875rem;
  outline: none;
  transition: border-color 0.15s, box-shadow 0.15s;
}

.toolbar-search input:focus {
  border-color: var(--kai-blue);
  box-shadow: 0 0 0 3px rgba(22, 76, 129, 0.12);
}

.toolbar-search .toolbar-search-icon {
  position: absolute;
  left: 0.75rem;
  top: 50%;
  transform: translateY(-50%);
  color: var(--text-faint);
  font-size: 0.8125rem;
  pointer-events: none;
}

/* ── Tables ───────────────────────────────────────────────────────────── */

.table-std {
  width: 100%;
  text-align: left;
  font-size: 0.875rem;
  white-space: nowrap;
  border-collapse: collapse;
}

.table-std thead tr {
  background: var(--surface-2);
  border-bottom: 1px solid var(--border);
}

.table-std th {
  padding: 0.75rem 1rem;
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--text-muted);
}

.table-std td {
  padding: 0.75rem 1rem;
  color: var(--text);
}

.table-std tbody tr + tr {
  border-top: 1px solid var(--border);
}

.table-std tbody tr:hover {
  background: var(--surface-2);
}

/* Every Pusat Data master table hovers via a raw Tailwind `hover:bg-*`
   utility straight on the `<tr>` rather than through `.table-std` (Alat
   Kerja, Pengguna, Wilayah's resort rows, Dokumen…), and neither that nor
   `.table-std` itself carried a transition — the highlight snapped on and
   off with no interpolation, out of step with every other hover surface in
   the app (`.btn`, `.master-tab`, `.wilayah-row-head`, `.varian-card-photo`,
   all of which animate). One rule, scoped broadly enough to reach both
   shapes without needing every render function to add its own
   `transition-colors` utility by hand. */
.table-std tbody tr,
table.table-stack tbody tr {
  transition: background-color 0.15s ease;
}

.table-wrap {
  overflow-x: auto;
}

/* ── Segmented control ────────────────────────────────────────────────── */
/*
   Three groups in this app are the same control drawn three different ways:
   the Pantau Riwayat mode switch, the asset-detail mode switch, and the Pusat
   Data master tabs. The first two each hand-maintained six Tailwind class
   arrays in JS and swapped them on click, in near-identical 30-line copies
   (`_setHistoryTab` and `switchDetailTab`). This is that control, once.

   Mark the active button with `.is-active`; nothing else changes.
*/

.segmented {
  display: inline-flex;
  gap: 0.125rem;
  padding: 0.1875rem;
  border-radius: 0.625rem;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
}

.segmented-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.375rem;
  padding: 0.4375rem 0.875rem;
  border-radius: 0.4375rem;
  font-size: 0.8125rem;
  font-weight: 600;
  color: var(--text-muted);
  white-space: nowrap;
  cursor: pointer;
  transition: background-color 0.15s, color 0.15s, box-shadow 0.15s;
}

.segmented-btn:hover:not(.is-active) {
  color: var(--text);
  background: var(--surface-2);
}

.segmented-btn.is-active {
  background: var(--tab-active);
  color: var(--tab-active-text);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}

/* Bigger from 640px up, and only there. These strips are the primary
   navigation of five screens (Detail Aset's Riwayat sub-tabs most of all) and
   at 13px/7px they were too quiet to answer "which one am I on" at a glance.
   The phone rules below stay exactly as they were: tools/verify/audit.py
   checks that nothing overflows a 390px viewport, and this control is already
   flex-stretched to the full width there. */
@media (min-width: 640px) {
  .segmented-btn {
    padding: 0.5625rem 1.125rem;
    font-size: 0.875rem;
    font-weight: 650;
  }

  .segmented.is-sub .segmented-btn {
    font-size: 0.8125rem;
  }
}

/* Full width on a phone: three modes across a 390px viewport need the room,
   and a segmented control that wraps reads as three separate buttons. */
@media (max-width: 480px) {
  .segmented {
    display: flex;
    width: 100%;
  }

  .segmented-btn {
    flex: 1 1 0;
    padding-left: 0.375rem;
    padding-right: 0.375rem;
  }
}

/* ── Segmented control, radio flavour ──────────────────────────────────
   Same skin as `.segmented-btn`, driven by a real `<input type="radio">`
   instead of a JS class toggle. Used by Laporan Kondisi's Unit Peruntukan,
   which used to be four bare radios in a tinted box — a third control shape
   in a column that already had `.form-select` and `.status-btn`.

   The radio STAYS. `_wireEditUptFilter()` (js/views/aset-detail.js) reads
   `input[name="edit-unit"]:checked` and re-narrows the UPT list from a
   delegated `change` listener, and js/shell.js reads the same selector on
   submit. Replacing them with buttons would need a second writer of the
   active state, which is the drift this component exists to prevent — so the
   input is visually hidden (`.sr-only`) and the SIBLING span is the segment.

   Sibling-selector, not `:has()`: nothing here needs the parent, and the
   adjacent form works everywhere the app already runs.
*/

.segmented.is-fill {
  display: flex;
  width: 100%;
}

.segmented-radio {
  display: flex;
  flex: 1 1 0;
  cursor: pointer;
}

.segmented-radio > .segmented-btn {
  flex: 1 1 0;
}

.segmented-radio > input:checked + .segmented-btn {
  background: var(--tab-active);
  color: var(--tab-active-text);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}

/* A hidden input cannot show a focus ring, so the segment wears it. Keyboard
   users arrow between radios in a group; without this the selection moves
   invisibly. */
.segmented-radio > input:focus-visible + .segmented-btn {
  outline: 2px solid var(--kai-blue, #164c81);
  outline-offset: 2px;
}

/* Four peruntukan labels do not fit one 390px row. Wrapping is correct here —
   unlike the tab strips above, these are a form field's options, not the
   screen's primary navigation, so two rows of two reads fine. */
@media (max-width: 480px) {
  .segmented.is-fill {
    flex-wrap: wrap;
  }

  .segmented.is-fill .segmented-radio {
    flex: 1 1 45%;
  }
}

/* ── Chips (active filter summary) ────────────────────────────────────── */
/*
   Kelola Data Aset, Pulihkan Aset Afkir and Kelola Data Alat Kerja each keep
   their whole sort/filter state behind a modal, with nothing on the screen
   saying a filter is applied — so a view showing 12 of 1,121 assets looked
   identical to one showing all of them. These are that state, made visible and
   individually removable.

   `.filter-chip` above is a different thing and stays: it is the "N filter
   aktif" counter on a collapsed <details> in Laporan.
*/

.chip-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.375rem;
}

/* ── PAGER: JUMP TO PAGE ───────────────────────────────────────────────
   Rendered by renderPagerBar() (js/core.js) once a list runs past seven
   pages, which is exactly when the number strip starts hiding most of its
   range behind an ellipsis. Sized to match `.pager-size` beside it rather
   than to the browser's default number input, which is much wider than
   three digits need and would unbalance the row.

   The spinners are removed: this is a "go to" field, not a stepper — the
   ‹ › buttons two controls to the left are the stepper, and a second pair
   of tiny arrows next to them reads as a duplicate of them. */
.pager-jump {
  width: 4rem;
  padding: 0.375rem 0.5rem;
  border-radius: 0.5rem;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  font-size: 0.6875rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  text-align: center;
  outline: none;
  transition: border-color 0.15s;
}

.pager-jump:focus {
  border-color: var(--kai-blue);
}

.pager-jump::-webkit-outer-spin-button,
.pager-jump::-webkit-inner-spin-button {
  appearance: none;
  -webkit-appearance: none;
  margin: 0;
}

.pager-jump[type="number"] {
  -moz-appearance: textfield;
  appearance: textfield;
}

.chip {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  max-width: 100%;
  padding: 0.1875rem 0.5rem 0.1875rem 0.625rem;
  border-radius: 9999px;
  border: 1px solid var(--border-strong);
  background: var(--surface-2);
  color: var(--text);
  font-size: 0.6875rem;
  font-weight: 600;
  line-height: 1.4;
}

.chip-label {
  color: var(--text-faint);
  font-weight: 700;
  text-transform: uppercase;
  font-size: 0.5625rem;
  letter-spacing: 0.03em;
}

.chip-value {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The remove affordance. 16px visually, but `pointer: coarse` below grows the
   hit area to 44px without changing the layout — a row of chips that doubled
   in height on a phone would defeat the point. */
.chip-x {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  position: relative;
  width: 1rem;
  height: 1rem;
  border-radius: 9999px;
  color: var(--text-faint);
  font-size: 0.625rem;
  cursor: pointer;
  transition: background-color 0.15s, color 0.15s;
}

.chip-x:hover {
  background: var(--kai-red, #dc2626);
  color: #ffffff;
}

.chip-clear {
  font-size: 0.6875rem;
  font-weight: 700;
  color: var(--kai-blue);
  text-decoration: underline;
  cursor: pointer;
  padding: 0.1875rem 0.25rem;
}

.dark .chip-clear {
  color: #93c5fd;
}

/* Kelola Inventaris's mobile Gudang rail reuses .chip for its compact form,
   but had no selected-state style at all — aria-pressed was set with nothing
   rendering differently. Matches the desktop rail's blue-fill/white-text
   look in light theme and its tinted-blue look in dark, so both breakpoints
   show the same selected gudang consistently.
   The KPI drill-down's Perlu Perhatian reason chips
   (`.dash-drill-reason-chip`) are the same toggle-chip idiom, so this rule
   covers both rather than a second copy. */
.inv-rail-btn.chip[aria-pressed="true"],
.dash-drill-reason-chip[aria-pressed="true"] {
  background: var(--tab-active);
  border-color: var(--tab-active);
  color: var(--tab-active-text);
}

/* ── Stepper (Kelola Data Alat Kerja asset forms) ─────────────────────── */
/*
   `peruntukan` and `sumber_pengadaan` are baked into the asset's composite
   primary key, so a wrong pick is not a display bug — it is a malformed PK
   that cannot be corrected later without rewriting every child row. The
   stepper exists to make that consequence visible WHILE choosing, which is
   also why the form is paired with a live preview of the id_aset being minted.
*/

.step {
  position: relative;
  padding-left: 2rem;
}

.step::before {
  content: attr(data-step);
  position: absolute;
  left: 0;
  top: -0.125rem;
  display: flex;
  align-items: center;
  justify-content: center;
  /* min-width, not width: the Model/Type step is numbered "1b" because it is a
     sub-step of choosing the alat kerja, and a fixed 22px circle clips it. */
  min-width: 1.375rem;
  padding: 0 0.1875rem;
  height: 1.375rem;
  border-radius: 9999px;
  background: var(--surface-sunken);
  border: 1px solid var(--border-strong);
  color: var(--text-faint);
  font-size: 0.625rem;
  font-weight: 700;
  transition: background-color 0.2s, color 0.2s, border-color 0.2s;
}

/* The connecting rail. Stops short of the next marker so it reads as a track
   rather than a border. */
.step::after {
  content: "";
  position: absolute;
  left: 0.6875rem;
  top: 1.5rem;
  bottom: -1.25rem;
  width: 1px;
  background: var(--border);
}

.step:last-of-type::after {
  display: none;
}

/* Two steps on one row.

   The KDAK forms are 916px of content in a 737px scroller at 1440x900, so 179px
   — including the live id_aset preview — was always below the fold and step 1
   and the preview could never be on screen together, which is the entire point
   of a live preview. The panels went from max-w-lg to max-w-2xl; this is what
   spends the extra width instead of just making the fields wider.

   Paired only at `sm:` and up. On a phone the stepper stays a single vertical
   column, because two 44px-target radio groups side by side at 390px is worse
   than scrolling. */
.step-pair {
  display: grid;
  gap: 1rem;
}

/* Wrapping two steps changes what `.step:last-of-type` matches: the second of a
   pair becomes the last div among ITS siblings, so the global rule above hides
   its rail. Below `sm:` the pair is still a stacked column and that rail is the
   one connecting it to the next step, so put it back. */
.step-pair > .step:last-of-type::after {
  display: block;
}

@media (min-width: 640px) {
  .step-pair {
    grid-template-columns: 1fr 1fr;
    gap: 1rem 1.25rem;
  }

  /* One connecting rail per row, not two. Each `.step` draws its own at its own
     left edge, so the second of a pair would put a stray vertical line through
     the middle of the form. */
  .step-pair > .step + .step::after {
    display: none;
  }
}

.step.is-done::before {
  content: "\f00c";           /* fa-check */
  font-family: "Font Awesome 6 Free";
  font-weight: 900;
  background: var(--kai-blue);
  border-color: var(--kai-blue);
  color: #ffffff;
}

.step.is-active::before {
  background: var(--kai-orange);
  border-color: var(--kai-orange);
  color: #ffffff;
}

.step-title {
  display: block;
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--text-muted);
  margin-bottom: 0.5rem;
}

/* The live id_aset preview. Monospace and segment-annotated: the whole point is
   that the user can see which choice produced which segment. */
.id-preview {
  border: 1px dashed var(--border-strong);
  border-radius: 0.5rem;
  background: var(--surface-sunken);
  padding: 0.625rem 0.75rem;
}

.id-preview-caption {
  font-size: 0.5625rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  margin-bottom: 0.375rem;
}

.id-preview-value {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
  font-size: 0.9375rem;
  font-weight: 700;
  color: var(--kai-blue);
  word-break: break-all;
  line-height: 1.5;
}

.dark .id-preview-value {
  color: #93c5fd;
}

/* A segment the user has not chosen yet. */
.id-seg-empty {
  color: var(--text-faint);
  font-weight: 400;
}

.id-preview-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 0.75rem;
  margin-top: 0.375rem;
  font-size: 0.5625rem;
  color: var(--text-faint);
}

/* ── Empty state ──────────────────────────────────────────────────────── */

.empty-state {
  padding: 3rem 1rem;
  text-align: center;
  color: var(--text-faint);
  font-size: 0.875rem;
}

.empty-state i {
  display: block;
  font-size: 1.875rem;
  margin-bottom: 0.5rem;
  opacity: 0.7;
}

/* Error variant — the inventory loaders used to `return` silently on a failed
   response, leaving stale rows with no indication anything had gone wrong. */
.empty-state.is-error {
  color: #b91c1c;
}

.dark .empty-state.is-error {
  color: #f87171;
}

/* ── Definition grid (label / value pairs on a detail card) ───────────── */

.def-grid {
  display: grid;
  grid-template-columns: minmax(6rem, auto) 1fr;
  gap: 0.375rem 1rem;
  font-size: 0.8125rem;
}

.def-grid dt {
  color: var(--text-faint);
}

.def-grid dd {
  color: var(--text);
  font-weight: 500;
  word-break: break-word;
}

/* ── Collapsible filter section (Laporan) ─────────────────────────────── */

.filter-section {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--surface);
}

.filter-section + .filter-section {
  margin-top: 0.75rem;
}

.filter-section > summary {
  display: flex;
  align-items: center;
  gap: 0.625rem;
  padding: 0.75rem 1rem;
  cursor: pointer;
  list-style: none;
  background: var(--surface-2);
  font-size: 0.8125rem;
  font-weight: 600;
  color: var(--text);
}

.filter-section > summary::-webkit-details-marker {
  display: none;
}

.filter-section > summary::after {
  content: "\f078"; /* fa-chevron-down */
  font-family: "Font Awesome 6 Free";
  font-weight: 900;
  margin-left: auto;
  font-size: 0.6875rem;
  color: var(--text-faint);
  transition: transform 0.15s;
}

.filter-section[open] > summary::after {
  transform: rotate(180deg);
}

.filter-section .filter-body {
  padding: 1rem;
}

/* Chip summarising which filters are active inside a collapsed section, so
   nothing is ever hidden without a trace. */
.filter-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.0625rem 0.5rem;
  border-radius: 9999px;
  background: rgba(22, 76, 129, 0.1);
  color: var(--kai-blue);
  font-size: 0.625rem;
  font-weight: 700;
}

.dark .filter-chip {
  background: rgba(147, 197, 253, 0.15);
  color: #93c5fd;
}

/* ── File input (certificate upload) ──────────────────────────────────── */

.file-drop {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.75rem;
  border: 1px dashed var(--border-strong);
  border-radius: 0.5rem;
  background: var(--surface-2);
  transition: border-color 0.15s, background-color 0.15s;
}

.file-drop:hover {
  border-color: var(--kai-blue);
}

.file-drop input[type="file"] {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 0.75rem;
  color: var(--text-muted);
}

.file-hint {
  font-size: 0.6875rem;
  color: var(--text-faint);
  margin-top: 0.375rem;
}

/* ── 4. Skeleton rows ─────────────────────────────────────────────────
   The global overlay blanks the whole screen on every apiFetch. For a list
   refresh that is heavier than the change deserves; a skeleton keeps the page
   readable and the scroll position intact. */
.skeleton {
  background: linear-gradient(
    90deg,
    var(--surface-2) 25%,
    var(--surface-sunken) 37%,
    var(--surface-2) 63%
  );
  background-size: 400% 100%;
  animation: skeleton-sweep 1.4s ease-in-out infinite;
  border-radius: 0.375rem;
  color: transparent !important;
}

@keyframes skeleton-sweep {
  0% { background-position: 100% 50%; }
  100% { background-position: 0 50%; }
}

@media (prefers-reduced-motion: reduce) {
  .skeleton {
    animation: none;
  }
}

/* Sized variants, so a placeholder can be written as one class rather than an
   inline style at every call site. `.skeleton-text` is a line of body copy;
   the `-sm`/`-lg` variants are a caption and a heading. Widths are varied at
   the call site so a block of them does not read as a solid rectangle. */
.skeleton-text,
.skeleton-text-sm,
.skeleton-text-lg {
  display: block;
  height: 0.875rem;
  margin: 0.375rem 0;
}

.skeleton-text-sm {
  height: 0.625rem;
}

.skeleton-text-lg {
  height: 1.25rem;
}

/* A stand-in for one card in a grid, or one row in a list. */
.skeleton-card {
  height: 5.5rem;
  border-radius: 0.75rem;
}

.skeleton-row {
  height: 2.5rem;
  border-radius: 0.5rem;
  margin-bottom: 0.375rem;
}

/* Wrapper that marks a region as loading. `aria-busy` is the real signal for
   assistive tech — a shimmer means nothing to a screen reader — so the
   helpers in js/core.js set it alongside the placeholder markup. */
.is-loading {
  pointer-events: none;
}

/* ── Sort direction indicator ──
   Mirrors .toolbar-search / .toolbar-search-icon exactly: a leading icon
   inside a relatively-positioned wrapper, with the control's own left padding
   pushed clear of it. Both #dash-drill-sort and #dash-drill-rsort were bare
   selects with nothing on screen to say "this sorts, and here is which way" —
   the icon flips between fa-arrow-up-short-wide (ascending) and
   fa-arrow-down-wide-short (descending) as the selection changes; see
   _updateSortIcon() in js/views/dash-drill.js. */
/* ── Custom-chevron select ──
   A plain <select> styled with only Tailwind border/background utilities —
   the pattern KDAK's own form selects use, and the one #user-tambah-modal's
   Peran/Wilayah selects copied at first — still draws its OWN native
   dropdown arrow (appearance: auto). That arrow shares no visual language
   with anything else this app draws: not with .toolbar-select's filter
   dropdowns just above (those carry NO Tailwind overrides at all, so the OS
   renders box and arrow together and the two cannot clash), and not with
   .toolbar-search's fully custom text box. `.form-select` removes the
   native chrome entirely and draws one glyph in its place, in the SAME box
   .toolbar-search's input already uses (design tokens, not raw Tailwind
   grays), so a form's role/region pickers read as the same control
   language as the rest of the toolbar rather than a third one. */
.form-select-wrap {
  position: relative;
}

.form-select {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  width: 100%;
  padding: 0.625rem 2.25rem 0.625rem 0.75rem;
  border-radius: 0.5rem;
  border: 1px solid var(--border-strong);
  background: var(--surface);
  color: var(--text);
  font-size: 0.875rem;
  outline: none;
  transition: border-color 0.15s, box-shadow 0.15s, opacity 0.15s;
}

.form-select:focus {
  border-color: var(--kai-blue);
  box-shadow: 0 0 0 3px rgba(22, 76, 129, 0.12);
}

.form-select:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

.form-select-wrap::after {
  content: "\f078"; /* fa-chevron-down */
  font-family: "Font Awesome 6 Free";
  font-weight: 900;
  position: absolute;
  right: 0.85rem;
  top: 50%;
  transform: translateY(-50%);
  font-size: 0.65rem;
  color: var(--text-faint);
  pointer-events: none;
}

/* .step.is-locked (Stepper component, further down) already dims a locked
   select itself via a plain `select` tag selector, which composes with
   `.form-select` for free; this dims the drawn-in chevron the same way it
   already dims the step's own label. */
.step.is-locked .form-select-wrap::after {
  opacity: 0.55;
}

/* ── MULTI-SELECT FIELD ────────────────────────────────────────────────
   One field that holds several values at once. Built for Proses Laporan's
   Alat Kerja filter, the app's first: every other
   filter in RAMCES narrows to exactly one value, and a report covering
   three tool types could not be asked for at all.

   It is deliberately NOT a native <select multiple>, which draws its own
   scrolling list box, requires ctrl-click nobody discovers, and looks like
   nothing else here — the same reasoning that produced `.form-select`.
   The trigger IS `.form-select`, wrapped in `.form-select-wrap`,
   so the chevron, the border, the focus ring and both themes come from the
   one definition rather than a lookalike that drifts from it.

   A popover, not a modal: no focus trap and no a11y-modal involvement,
   because it is a field's own list and Tab out of it should move on
   through the form the way Tab out of a <select> does. */
.ms-field {
  position: relative;
}

.ms-trigger {
  text-align: left;
  cursor: pointer;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ms-trigger[aria-expanded="true"] {
  border-color: var(--kai-blue);
  box-shadow: 0 0 0 3px rgba(22, 76, 129, 0.12);
}

.ms-count {
  font-weight: 700;
  color: var(--kai-blue);
}

.dark .ms-count {
  color: #93c5fd;
}

.ms-panel {
  position: absolute;
  z-index: 30;
  top: calc(100% + 0.25rem);
  left: 0;
  right: 0;
  display: flex;
  flex-direction: column;
  max-height: 20rem;
  border-radius: 0.5rem;
  border: 1px solid var(--border-strong);
  background: var(--surface);
  box-shadow: 0 10px 30px rgba(15, 23, 42, 0.18);
  overflow: hidden;
}

/* On a phone the field is near the bottom of a long filter card as often as
   not, so the list gets less room and its own scroll rather than pushing
   the panel off screen. */
@media (max-width: 640px) {
  .ms-panel {
    max-height: 60vh;
  }
}

.ms-search {
  position: relative;
  padding: 0.5rem;
  border-bottom: 1px solid var(--border);
}

.ms-search input {
  width: 100%;
  padding: 0.4375rem 0.625rem 0.4375rem 2rem;
  border-radius: 0.375rem;
  border: 1px solid var(--border-strong);
  background: var(--surface-2);
  color: var(--text);
  font-size: 0.75rem;
  outline: none;
}

.ms-search input:focus {
  border-color: var(--kai-blue);
}

.ms-search i {
  position: absolute;
  left: 1.125rem;
  top: 50%;
  transform: translateY(-50%);
  font-size: 0.6875rem;
  color: var(--text-faint);
  pointer-events: none;
}

.ms-bulk {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.375rem 0.625rem;
  border-bottom: 1px solid var(--border);
  background: var(--surface-2);
}

.ms-bulk button {
  font-size: 0.6875rem;
  font-weight: 700;
  color: var(--kai-blue);
  cursor: pointer;
}

.dark .ms-bulk button {
  color: #93c5fd;
}

.ms-bulk button:disabled {
  color: var(--text-faint);
  cursor: default;
}

.ms-bulk .ms-tally {
  margin-left: auto;
  font-size: 0.625rem;
  font-weight: 600;
  color: var(--text-faint);
}

.ms-options {
  overflow-y: auto;
  padding: 0.25rem;
}

.ms-option {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  width: 100%;
  padding: 0.4375rem 0.5rem;
  border-radius: 0.375rem;
  font-size: 0.75rem;
  color: var(--text);
  text-align: left;
  cursor: pointer;
}

.ms-option:hover {
  background: var(--surface-2);
}

.ms-option:focus-visible {
  outline: 2px solid var(--kai-blue);
  outline-offset: -2px;
}

.ms-option input {
  width: 0.875rem;
  height: 0.875rem;
  accent-color: var(--kai-blue);
  cursor: pointer;
  flex-shrink: 0;
}

.ms-option-label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ms-option-code {
  margin-left: auto;
  font-size: 0.625rem;
  font-weight: 700;
  color: var(--text-faint);
  flex-shrink: 0;
}

.ms-empty {
  padding: 1rem 0.5rem;
  text-align: center;
  font-size: 0.6875rem;
  color: var(--text-faint);
}

.ms-chips {
  margin-top: 0.5rem;
}


/* ── GROUPED REPORT ROWS ───────────────────────────────────────────────
   Proses Laporan's preview, grouped. A flat list of
   several hundred identical rows answers "what happened" and nothing else;
   the group head answers "how much of it, and where", which is the question
   a report is written to settle.

   The head is a real <button> inside a full-width cell, so it is reachable
   by keyboard and announces its own state. The KAI-blue left rule is the
   only ornament, and it earns its place by marking where one section starts
   in a table whose rows are otherwise indistinguishable at a glance.

   Subtotals are `tabular-nums` for the reason a ledger always is: a column
   of figures is read by comparing digits in the same position. */
.rpt-group td {
  padding: 0 !important;
  border-left: 3px solid var(--kai-blue);
  background: var(--surface-2);
}

.dark .rpt-group td {
  border-left-color: #60a5fa;
}

.rpt-group-head {
  display: block;
  width: 100%;
  text-align: left;
  cursor: pointer;
  color: var(--text);
}

/* The cell spans a table that is wider than the viewport on every report
   here, so anything laid out across its full width sits behind a horizontal
   scroll. Pinning the head's own contents to the left keeps the name, the
   row count and the subtotal on screen at any scroll position, while the
   button around them still spans the row so the whole row stays the hit
   target. `width: max-content` is what makes `left: 0` meaningful — a
   full-width sticky element has nothing to stick to. */
.rpt-group-inner {
  position: sticky;
  left: 0;
  display: flex;
  align-items: center;
  gap: 0.625rem;
  width: max-content;
  max-width: 100%;
  padding: 0.5rem 0.875rem;
}

.rpt-group-head:hover {
  background: rgba(22, 76, 129, 0.06);
}

.dark .rpt-group-head:hover {
  background: rgba(96, 165, 250, 0.1);
}

.rpt-group-head:focus-visible {
  outline: 2px solid var(--kai-blue);
  outline-offset: -2px;
}

.rpt-caret {
  font-size: 0.625rem;
  color: var(--text-faint);
  transition: transform 0.15s ease;
  flex-shrink: 0;
}

.rpt-group-head[aria-expanded="true"] .rpt-caret {
  transform: rotate(90deg);
}

.rpt-group-name {
  font-weight: 700;
  font-size: 0.75rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.rpt-group-count {
  font-size: 0.6875rem;
  color: var(--text-faint);
  white-space: nowrap;
}

.rpt-group-sum {
  font-size: 0.75rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--kai-blue);
  white-space: nowrap;
}

.dark .rpt-group-sum {
  color: #93c5fd;
}

/* The grand total. Heavier rule above it, because it closes the table
   rather than opening a section. */
.rpt-total td {
  border-top: 2px solid var(--border-strong);
  background: var(--surface-2);
  padding: 0 !important;
  font-weight: 700;
  font-size: 0.75rem;
}

.rpt-total .rpt-group-inner {
  padding: 0.625rem 0.875rem;
  gap: 0.875rem;
}

.rpt-total-sum {
  font-variant-numeric: tabular-nums;
  color: var(--kai-blue);
}

.dark .rpt-total-sum {
  color: #93c5fd;
}

@media (max-width: 640px) {
  /* .table-stack centres every colspan cell, which is right for an empty
     state and wrong for a section head — it would sit in the middle of the
     card with its subtotal beneath it. */
  .table-stack tr.rpt-group,
  .table-stack tr.rpt-total {
    border: 0;
    border-radius: 0;
    margin-bottom: 0.375rem;
    padding: 0;
  }
  .table-stack tr.rpt-group td[colspan],
  .table-stack tr.rpt-total td[colspan] {
    display: block;
    justify-content: flex-start;
    text-align: left;
  }
}

.toolbar-select {
  position: relative;
}

.toolbar-select select {
  padding-left: 2.25rem;
}

.toolbar-select .toolbar-select-icon {
  position: absolute;
  left: 0.75rem;
  top: 50%;
  transform: translateY(-50%);
  color: var(--text-faint);
  font-size: 0.75rem;
  pointer-events: none;
  transition: transform 0.15s ease;
}

#dash-drill-panel {
  flex: 1 1 auto;
  overflow-y: auto;
}

/* Equal-stretch tab strip. `.segmented` is normally sized to its content; a
   four-tab detail switcher reads better filling the whole right pane width,
   at every viewport — not only the phone-only rule .segmented already had. */
.segmented.is-stretch {
  display: flex;
  width: 100%;
}

.segmented.is-stretch .segmented-btn {
  flex: 1 1 0;
  /* A flex item defaults to min-width:auto, so a nowrap label sets a floor the
     item cannot shrink below and the strip overflows its container instead of
     dividing it. */
  min-width: 0;
}

/* Stretch on a real screen, scroll on a phone.
   Proses Laporan's report picker carries FIVE tabs.
   At 1440px a content-width strip overflows its wrapper and clips the first
   tab, so it needs `.is-stretch`; at 390px five stretched tabs collide into
   each other's labels, which is worse than scrolling. This modifier is the
   only honest answer to both: divide the width where there is width to divide,
   and fall back to natural size inside the `.scroll-hint` scroller below 640px
   — including overriding `.segmented`'s own phone rule further down, which
   assumes three-ish items and full-width is right for them. */
.segmented.is-stretch-sm {
  display: inline-flex;
  width: auto;
}

@media (min-width: 640px) {
  .segmented.is-stretch-sm {
    display: flex;
    width: 100%;
  }

  .segmented.is-stretch-sm .segmented-btn {
    flex: 1 1 0;
    min-width: 0;
  }
}

/* Four equal sub-tabs do not fit across a phone: at 390px the labels plus
   their icons ran past the viewport and clipped "Mutasi" clean off the right
   edge (Detail Aset ▸ Riwayat). Two rows of two, as a
   GRID rather than a scroller — the same choice the dashboard tab strip made,
   and for the same reason: a strip whose last item is invisible reads as a
   missing feature, and `.scroll-hint`'s end-fade cannot help here (it is inert
   on a non-flex user, a known gap in CLAUDE.md). */
@media (max-width: 639px) {
  .segmented.is-stretch.is-sub {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 0.2rem;
  }

  .segmented.is-stretch.is-sub .segmented-btn {
    width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
  }
}

.segmented-btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.segmented-btn:disabled:hover {
  background: transparent;
  color: var(--text-muted);
}

/* A tab row nested one level below another `.segmented` (Riwayat's own
   Semua/Perbaikan/Kalibrasi/Mutasi switch, Tindakan's Laporan
   Kondisi/Kalibrasi/Mutasi switch, and the KPI drill-down's Pemeliharaan/
   Kalibrasi/Mutasi sub-tab rows) — same pill mechanics,
   smaller scale, so the nesting reads by size rather than needing a second
   colour language. */
.segmented.is-sub {
  gap: 0.1rem;
  padding: 0.1rem;
  border-radius: 0.5rem;
}

.segmented.is-sub .segmented-btn {
  padding: 0.3125rem 0.625rem;
  font-size: 0.75rem;
}

/* Tindakan's three sub-forms used to keep the blue/perbaikan, cyan/kalibrasi,
   orange/mutasi colour-coding `_ADET_TIPE_META` already uses for Riwayat's
   timeline badges, reshaped onto the shared `.segmented` pill. In light
   theme `.segmented-btn.is-active` is a solid kai-blue fill with white
   text, so a text-only tone override
   would read as low-contrast cyan/orange-on-blue — dropped in light theme;
   dark theme keeps its original tinted-background/coloured-text look, where
   the distinction still reads fine. */
.dark .segmented-btn.is-active.tone-kalibrasi {
  color: #67e8f9;
}

.dark .segmented-btn.is-active.tone-mutasi {
  color: #fdba74;
}

/* ══════════════════════════════════════════════════════════════════════
   THE KPI STRIP
   ══════════════════════════════════════════════════════════════════════

   Shared by #dash-kpi-strip and Kelola Data Alat Kerja's two groups, all
   painted by renderKpiStrip() in js/core.js. The card's own appearance is
   still inline Tailwind in that function, because the accent colour is
   per-card data (hover:border-${aksen}) and the CDN build is a runtime JIT
   that resolves it from the DOM. What lives here is only what a utility
   class cannot say: the group heading, the mobile collapse, and the
   entrance animation. */

.kpi-group + .kpi-group {
  margin-top: 0.875rem;
}

.kpi-group-label {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  margin-bottom: 0.5rem;
  font-size: 0.625rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* States what the numbers beside it are counted over. The strip is
   fleet-wide for every role, including a region-scoped ADMIN_WILAYAH, so
   the figures stay comparable between users and between screens; the
   letterhead's own sub-line is where the signed-in user's scope is said. */
.kpi-group-scope {
  font-weight: 500;
  letter-spacing: 0.02em;
  text-transform: none;
  opacity: 0.75;
}

/* Reveal on FIRST paint only; renderKpiStrip() adds the class per call and
   the callers stop passing `reveal` afterwards. The strip repaints on every
   stats refresh, and replaying an entrance animation on ten tiles because a
   WebSocket broadcast arrived reads as a glitch, not as feedback. */
.kpi-reveal {
  animation: kdak-reveal 0.16s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .kpi-reveal {
    animation: none;
  }
}

/* Two lines, not one truncated one. `Perlu Perhatian` carries three reason
   counts ("0 TSO · 0 kalibrasi · 9 di balaiyasa") and at a seventh of a
   1440px row `truncate` cut it mid-word, so the card printed a number and
   then stopped mid-explanation. Clamping to two lines is why every card
   also carries a min-height: without it the one card that wraps would be
   taller than its six row-mates. */
.kpi-card-sub {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.kpi-card {
  min-height: 6.25rem;
}

.kpi-card-skeleton {
  display: flex;
  flex-direction: column;
  gap: 0.375rem;
  padding: 0.75rem;
  border-radius: 0.75rem;
  border: 1px solid var(--border);
  background: var(--surface);
  min-height: 5.25rem;
}

/* ── Toolbar dropdown menu ──
   The shared "Impor Massal" dropdown in Pusat Data's toolbar. Same reveal
   keyframes .master-menu already defines (master-menu-reveal /
   master-menu-reveal-up), in ITS OWN class rather than the kebab menu's own:
   .master-menu's <640px rule makes itself permanently visible because it is
   correct there (a per-row menu already stacked into a card has nothing
   left to toggle), which would be wrong here — this is a toggle-driven
   dropdown hung off a single toolbar button, and it must stay closed on a
   phone exactly as it does on desktop until that button is tapped. */
.toolbar-dropdown-menu {
  display: none;
  position: absolute;
  right: 0;
  top: 100%;
  z-index: 30;
  min-width: 12rem;
  margin-top: 0.25rem;
  padding: 0.25rem;
  border-radius: 0.75rem;
  border: 1px solid var(--border);
  background: var(--surface);
  box-shadow: 0 10px 25px rgb(0 0 0 / 0.15);
  transform-origin: top right;
}

.toolbar-dropdown-menu.is-open {
  display: flex;
  flex-direction: column;
  animation: master-menu-reveal 0.14s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .toolbar-dropdown-menu.is-open {
    animation: none;
  }
}

.toolbar-dropdown-item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.65rem;
  border-radius: 0.5rem;
  font-size: 0.75rem;
  font-weight: 600;
  text-align: left;
  white-space: nowrap;
  color: var(--text);
  background: none;
  border: 0;
  cursor: pointer;
}

.toolbar-dropdown-item:hover,
.toolbar-dropdown-item:focus-visible {
  background: var(--surface-2);
}

/* ── Model/Type Spesifikasi chips ──
   Values are free-form per tool ("Max. Torque" here, "Runtime" there), so
   slot POSITION (1-5) is the only thing stable enough to colour by — the
   same row of the Rekap template lands in the same hue down the whole
   table. Two-line clamp, never truncate: cutting "12,5 kg" to "12,5…" loses
   the unit, and never a title= as the only carrier of the full text — the
   same rule the KDAK KW badge follows, because title never fires on touch.
   The full label:nilai pair is read into #varian-table-info instead, via
   data-tip + a delegated listener, the same idiom #kdak-table-info uses. */
.spek-chip {
  display: -webkit-inline-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  /* .table-std sets white-space: nowrap on the whole table so its other
     columns stay single-line; without overriding it here the clamp above
     has nothing to wrap onto and every chip renders as one long line. */
  white-space: normal;
  max-width: 11rem;
  padding: 0.125rem 0.5rem;
  border-radius: 0.375rem;
  font-size: 0.6875rem;
  line-height: 1.25;
  font-weight: 600;
  cursor: default;
}

.spek-chip-1 { background: rgba(37, 99, 235, 0.12); color: #1d4ed8; }
.spek-chip-2 { background: rgba(217, 119, 6, 0.14); color: #b45309; }
.spek-chip-3 { background: rgba(5, 150, 105, 0.13); color: #047857; }
.spek-chip-4 { background: rgba(219, 39, 119, 0.12); color: #be185d; }
.spek-chip-5 { background: rgba(124, 58, 237, 0.13); color: #6d28d9; }

.dark .spek-chip-1 { background: rgba(96, 165, 250, 0.18); color: #93c5fd; }
.dark .spek-chip-2 { background: rgba(251, 191, 36, 0.18); color: #fcd34d; }
.dark .spek-chip-3 { background: rgba(52, 211, 153, 0.18); color: #6ee7b7; }
.dark .spek-chip-4 { background: rgba(244, 114, 182, 0.18); color: #f9a8d4; }
.dark .spek-chip-5 { background: rgba(167, 139, 250, 0.18); color: #c4b5fd; }

/* ── The DAFTAR group's mobile collapse ──────────────────────────────
   Below lg: the heading is the control and the grid collapses to zero.
   An earlier cut clipped the grid at 3.25rem instead, which cut through
   the middle of a card's number: that reads as a rendering fault, not as
   an affordance. Above lg: none of this applies, which is also why it is
   not a <details> -- that element's `open` is one piece of state shared by
   every width, so collapsing it on a phone would leave it collapsed on a
   desktop with no summary row to reopen it. */
.kpi-group-toggle {
  width: 100%;
  text-align: left;
  background: none;
  border: 0;
  padding: 0;
}

.kpi-group-count {
  display: none;
  padding: 0.05rem 0.4rem;
  border-radius: 999px;
  background: var(--surface-2);
  color: var(--text-muted);
  font-size: 0.6rem;
  font-weight: 700;
}

.kpi-group-chevron {
  display: none;
  margin-inline-start: auto;
  font-size: 0.65rem;
  color: var(--text-muted);
  transition: transform 0.22s ease;
}

@media (max-width: 1023px) {
  .kpi-group-toggle {
    cursor: pointer;
    min-height: 2.75rem;
    align-items: center;
  }

  .kpi-group-count,
  .kpi-group-chevron {
    display: inline-flex;
  }

  .kpi-group-toggle[aria-expanded="true"] .kpi-group-chevron {
    transform: rotate(180deg);
  }

  .kpi-group-toggle:focus-visible {
    outline: 2px solid var(--kai-blue, #2563eb);
    outline-offset: 2px;
    border-radius: var(--radius);
  }

  .kpi-group-collapsible {
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.22s ease;
  }

  .kpi-group-collapsible.is-open {
    max-height: 44rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .kpi-group-collapsible,
  .kpi-group-chevron {
    transition: none;
  }
}

/* ── The one directed sort choice ────────────────────────────────────────
   Replaces a field <select> plus a six-button "Arah Urutan" grid in all four
   sort modals. Sized and coloured like the field select it replaces, so the
   modals did not otherwise move; it sits inside `.toolbar-select`, which is
   what supplies the leading arrow icon and its left padding. */
.sort-choice-select {
  width: 100%;
  padding: 0.625rem 0.75rem;
  background: var(--surface-2, #f9fafb);
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  font-size: 0.875rem;
  color: var(--text);
  outline: none;
  transition: border-color 0.15s ease;
}

.dark .sort-choice-select {
  background: #374151;
  border-color: #4b5563;
}

/* An <optgroup> label is styled by the platform, not fully by us, but weight
   and colour do land in every browser this ships to. Fourteen options in four
   named groups stays scannable; one flat list of fourteen does not. */
.sort-choice-select optgroup {
  font-weight: 700;
  font-style: normal;
  color: var(--text-faint);
}

.sort-choice-select option {
  font-weight: 400;
  color: var(--text);
}

/* ── Staged bulk import ──────────────────────────────────────────────────── */

/* The step marker. Not a `.segmented`: these are not choices the operator can
   make, they report where the import has got to, so nothing here is clickable
   and none of it takes focus. */
.bulk-steps {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  margin: 0;
  padding: 0.625rem 1.25rem;
  list-style: none;
  border-bottom: 1px solid var(--border);
  background: var(--surface-2, rgba(148, 163, 184, 0.06));
  font-size: 0.6875rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-faint);
  overflow-x: auto;
  scrollbar-width: none;
}

.bulk-steps::-webkit-scrollbar {
  display: none;
}

.bulk-steps li {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  white-space: nowrap;
  padding: 0.125rem 0.5rem;
  border-radius: 999px;
}

.bulk-steps li + li::before {
  content: "\203A";
  margin-right: 0.5rem;
  color: var(--text-faint);
  opacity: 0.6;
}

.bulk-steps li.is-active {
  background: rgba(0, 82, 156, 0.1);
  color: var(--kai-blue, #00529c);
}

.dark .bulk-steps li.is-active {
  background: rgba(59, 130, 246, 0.2);
  color: #93c5fd;
}

.bulk-steps li.is-done {
  color: #15803d;
}

.dark .bulk-steps li.is-done {
  color: #86efac;
}

/* The counted summary line above both tables. One row, three facts, and the
   numbers carry the colour rather than the whole strip, so "4 bermasalah"
   reads as a warning without the panel reading as an error. */
.bulk-summary {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.25rem 1rem;
  padding: 0.625rem 0.875rem;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  background: var(--surface-2, rgba(148, 163, 184, 0.06));
  font-size: 0.75rem;
  color: var(--text-muted, #4b5563);
}

.bulk-summary strong {
  font-size: 0.9375rem;
  font-weight: 800;
  color: var(--text);
}

.bulk-summary .is-ok strong {
  color: #15803d;
}

.bulk-summary .is-bad strong {
  color: #b45309;
}

.dark .bulk-summary .is-ok strong {
  color: #86efac;
}

.dark .bulk-summary .is-bad strong {
  color: #fcd34d;
}

/* The preview list is bounded so the modal footer, which holds the only button
   that writes anything, cannot be pushed off the bottom of a long file. */
.bulk-preview-wrap {
  max-height: 42vh;
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
}

/* The reason column must WRAP. `.table-std` sets `white-space: nowrap`, which
   is right for the dense operational tables it was written for and exactly
   wrong here: the last column holds a sentence explaining why a row cannot be
   imported, and nowrap pushed it past the wrapper's right edge so it read
   "Kode Alat \"ZZZ\" tidak dikena". A reason truncated before its verb is worse
   than no reason, and this column is the entire point of the staged import. */
.bulk-preview-wrap th:last-child,
.bulk-preview-wrap td:last-child {
  white-space: normal;
  min-width: 13rem;
  line-height: 1.4;
}

/* The other columns stay nowrap but must not each demand their natural width,
   or six of them together push the reason column off the panel again. */
.bulk-preview-wrap table {
  table-layout: auto;
  width: 100%;
}

.bulk-preview-wrap td {
  vertical-align: top;
}

/* A flagged row is tinted AND carries its reason as text in the Status cell.
   Colour alone would leave the reason unavailable to anyone who cannot see it,
   and unavailable in the stacked mobile layout where the tint is a card edge. */
.bulk-row-bad {
  background: rgba(245, 158, 11, 0.08);
}

.dark .bulk-row-bad {
  background: rgba(245, 158, 11, 0.12);
}

.bulk-progress {
  height: 0.5rem;
  border-radius: 999px;
  background: rgba(148, 163, 184, 0.25);
  overflow: hidden;
}

.bulk-progress-bar {
  height: 100%;
  border-radius: 999px;
  background: var(--kai-blue, #00529c);
  transition: width 0.2s ease;
}

.dark .bulk-progress-bar {
  background: #3b82f6;
}

@media (prefers-reduced-motion: reduce) {
  .bulk-progress-bar {
    transition: none;
  }
}

/* ═══════════════════════════════════════════════════════════════════════════
   PER-SCREEN — Dashboard (incl. Laporan Perbaikan / Kurva MCF tabs)
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Dashboard matrix cell gradient ──
   .dash-swatch-mixed (near .dash-swatch, below) shares this exact rule
   rather than repeating its values, so the Matriks Kesiapan legend swatch
   and the cell it actually describes cannot drift apart. */
.matrix-cell-mixed,
.dash-swatch-mixed {
  background: linear-gradient(135deg, #dcfce7 50%, #fee2e2 50%);
}

.dark .matrix-cell-mixed,
.dark .dash-swatch-mixed {
  background: linear-gradient(
    135deg,
    rgba(21, 128, 61, 0.35) 50%,
    rgba(185, 28, 28, 0.35) 50%
  );
}

/* ── Dashboard tab panel ── */
.dash-tab-btn {
  white-space: nowrap;
  transition: all 0.15s;
}

.dash-tab-btn.is-active {
  background-color: var(--tab-active);
  color: var(--tab-active-text);
  font-weight: 700;
  box-shadow: 0 2px 8px rgba(22, 76, 129, 0.25);
}

/* This strip reads the shared --tab-active token, so light and dark are one
   rule rather than two. The dark half used to be a surface-2 fill with
   light-blue text, which on #111827 is barely a fill at all; the token carries
   the hue swap instead. Its light-mode look is unchanged and is still what
   every other tab strip in the app was unified onto.

   The doubled class raises specificity to (0,2,0) so the size below outranks
   the `text-xs` Tailwind utility on the buttons themselves, whose injected
   stylesheet may come after this file. */
@media (min-width: 640px) {
  .dash-tab-btn.dash-tab-btn {
    padding: 0.6875rem 0.875rem;
    font-size: 0.8125rem;
  }
}

.dash-tab-btn:not(.is-active) {
  color: #6b7280;
}

.dash-tab-btn:not(.is-active):hover {
  background-color: rgba(22, 76, 129, 0.1);
  color: #164c81;
}

.dark .dash-tab-btn:not(.is-active):hover {
  background-color: rgba(255, 255, 255, 0.08);
  color: #93c5fd;
}

/* A `translateY` "pop" on .is-active was tried here and reverted: it shifts
   the active tab's rendered box 1px above its row-mates, which reads as a
   THIRD row to anything measuring tab position — including the harness that
   asserts the six tabs form exactly two clean rows at 390px. The background
   colour + shadow + bold weight already carry the "active" signal; a
   position shift is the one kind of motion this control cannot afford. */

/* ── Dashboard tab-panel fade ──
   _switchDashTab() (js/views/dashboard.js) toggles `hidden` on `.dash-panel`
   instantly, same as it always has — no JS timing to get right here. A CSS
   animation restarts on its own whenever the element it is on goes from
   `display:none` back to displayed, so removing `hidden` alone is enough to
   replay this on every switch. */
.dash-panel:not(.hidden) {
  animation: dash-panel-fade 0.16s ease-out;
}

@keyframes dash-panel-fade {
  from {
    opacity: 0;
    transform: translateY(3px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  .dash-panel:not(.hidden) {
    animation: none;
  }
}

/* ── Dashboard dot indicators ── */
.dash-dot {
  width: 7px;
  height: 7px;
  border-radius: 9999px;
  background: #cbd5e1;
  transition:
    background 0.2s,
    transform 0.2s;
  cursor: pointer;
}

.dash-dot.is-active {
  background: #164c81;
  transform: scale(1.4);
}

.dark .dash-dot.is-active {
  background: #60a5fa;
}

/* ── Per-tab filter row legend ──
   The four-swatch key used to sit once above ALL the tabs, where it described
   matrix cell colours on five panels that had no such cells. It now renders
   only where it means something — see _DASH_FILTER_SPEC in
   js/views/dashboard.js. */
.dash-key {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  white-space: nowrap;
}

.dash-swatch {
  width: 0.75rem;
  height: 0.75rem;
  border-radius: 0.125rem;
  display: inline-block;
  flex-shrink: 0;
}

/* The matrix legend's five swatches, named to match what each one actually
   depicts (SO / TSO / mixed / a real-but-empty UPT / a slot that does not
   apply). It used to mix Tailwind's bg-green-500 / bg-red-500 utilities
   here with the -mixed/-zero/-empty cell classes reused directly — correct,
   but two different naming conventions on one four-item key. -so/-tso are
   the only two genuinely NEW declarations: the solid, saturated tones the
   matrix's own cells never render directly (badge-so/-tso and
   matrix-cell-mixed all use the pastel/gradient versions), matching the
   exact #22c55e/#ef4444 .status-btn already uses elsewhere in this file for
   an unambiguous "this is SO/TSO" signal. -mixed/-zero/-empty are declared
   beside .matrix-cell-mixed / .dash-matrix-cell-zero / .dash-matrix-slot-empty
   (see above) rather than repeated here. */
.dash-swatch-so {
  background: #22c55e;
}

.dash-swatch-tso {
  background: #ef4444;
}

/* The filter rows stack under the panel header on a phone; the controls get a
   full-width row of their own so a 150px-wide <select> is not squeezed to
   nothing beside a four-item legend. */
@media (max-width: 639px) {
  .dash-filter-row > div:first-child > select {
    flex: 1 1 100%;
  }
}

/* ── Matrix table ── */
#dash-matrix-table th,
#dash-matrix-table td {
  min-width: 2.6rem;
  text-align: center;
  font-size: 0.7rem;
  padding: 0.3rem 0.2rem;
}

#dash-matrix-table th {
  font-weight: 700;
}

/* ── Matrix table fluid sizing ── */
#dash-matrix-table th,
#dash-matrix-table td {
  padding: 0.35rem 0.5rem;
  font-size: clamp(9px, 1vw, 12px);
  white-space: nowrap;
}

#dash-matrix-table th {
  font-weight: 600;
}

/* Wilayah column: values left, header center */
#dash-matrix-table td:first-child {
  text-align: left !important;
}

#dash-matrix-table th:first-child {
  text-align: center !important;
}

/* ── Matrix: JR/JB/ME column groups, empty-cell language, tap/hover info ──

   The per-UPT cells used to print an em dash "—" for BOTH "this region has
   no UPT in this slot" and "a real UPT with zero assets right now" — a dash
   reads as a fillable placeholder, not as "nothing here," so neither state
   prints any text any more. The two are told apart by background alone:
   `.dash-matrix-cell-zero` (a real UPT, just empty) vs
   `.dash-matrix-slot-empty` (the column doesn't apply to this region at all,
   diagonal hatch, unfocusable — there is nothing to say about it). See
   _matrixCell() / _matrixSlotEmptyCell() in js/views/dashboard.js.

   Native `title` tooltips never fire on touch, so every informative cell
   carries `data-tip` + `tabindex="0"` instead, read by ONE delegated
   listener (_wireMatrixInfo()) into the `#dash-matrix-info` strip above the
   table — the same mechanism on a mouse, a finger, or a keyboard. */
/* .dash-swatch-zero / .dash-swatch-empty (near .dash-swatch, below) are
   declared on these SAME selectors rather than a second copy of the values,
   for the identical reason .dash-swatch-mixed shares .matrix-cell-mixed
   above: a legend swatch must not be able to disagree with the cell it
   describes. */
/* The resort code printed inside each matrix cell. A column in
   this table is the i-th resort OF EACH REGION, so the numeric header cannot
   name a resort and "which resort is column 17?" had no answer except a
   hover. Every cell now carries its own code above its SO/TSO figure.

   Shown from 1280px up only: below that the table is already scrolling
   sideways and a second line per cell costs more width than the label
   returns. It is decoration for the layout, never for the data, so it is
   `aria-hidden`-equivalent in effect — the authoritative name stays in the
   cell's `data-tip`, which is what the info strip and screen readers use. */
.dash-matrix-code {
  display: none;
}

@media (min-width: 1280px) {
  .dash-matrix-code {
    display: block;
    font-size: 0.5625rem;
    line-height: 1.1;
    font-weight: 600;
    letter-spacing: 0.01em;
    color: var(--text-muted);
    opacity: 0.75;
    margin-bottom: 0.0625rem;
  }

  .dash-matrix-slot-empty .dash-matrix-code {
    display: none;
  }
}

.dash-matrix-cell-zero,
.dash-swatch-zero {
  background: var(--surface-2);
}

.dark .dash-matrix-cell-zero,
.dark .dash-swatch-zero {
  background: rgba(255, 255, 255, 0.03);
}

.dash-matrix-slot-empty,
.dash-swatch-empty {
  background-color: var(--surface);
  background-image: repeating-linear-gradient(
    45deg,
    var(--border) 0,
    var(--border) 1px,
    transparent 1px,
    transparent 7px
  );
  opacity: 0.55;
}

.dark .dash-matrix-slot-empty,
.dark .dash-swatch-empty {
  opacity: 0.32;
}

#dash-matrix-table td[data-tip],
#dash-matrix-table th[data-tip] {
  cursor: pointer;
  transition: filter 0.15s ease, box-shadow 0.15s ease;
}

#dash-matrix-table td[data-tip]:hover,
#dash-matrix-table th[data-tip]:hover,
#dash-matrix-table td[data-tip]:focus-visible,
#dash-matrix-table th[data-tip]:focus-visible {
  filter: brightness(0.96);
  box-shadow: inset 0 0 0 2px var(--kai-blue);
  outline: none;
}

.dark #dash-matrix-table td[data-tip]:hover,
.dark #dash-matrix-table th[data-tip]:hover,
.dark #dash-matrix-table td[data-tip]:focus-visible,
.dark #dash-matrix-table th[data-tip]:focus-visible {
  filter: brightness(1.2);
}

/* JR/JB/ME column-group header + the divider between blocks. Only rendered
   in "Semua" mode — a single-branch tab has nothing to separate. */
.dash-matrix-group-th {
  font-weight: 700;
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  background: var(--surface-2);
  color: var(--text-muted);
}

.dash-matrix-group-start {
  border-left: 2px solid var(--border-strong) !important;
}

/* The info strip. Fixed min-height so its text changing does not reflow the
   panel above/below it, and a dashed border that turns solid + kai-blue once
   it is actually reporting something rather than showing the hint. */
.dash-matrix-info {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.75rem;
  color: var(--text-muted);
  min-height: 1.75rem;
  padding: 0.375rem 0.625rem;
  margin: 0 0 0.75rem;
  background: var(--surface-2);
  border: 1px dashed var(--border);
  border-radius: 0.5rem;
  transition: color 0.15s ease, border-color 0.15s ease;
}

.dash-matrix-info.is-active {
  color: var(--text);
  border-style: solid;
  border-color: var(--kai-blue);
}

/* The KW cross-tab note, shown only under a JR/JB/ME
   branch filter — see _renderMatrixPanel()'s kwLegendEl toggle. Amber, not
   the neutral `.dash-matrix-info` styling, since this states a caveat about
   what's on screen rather than echoing whatever cell the user is pointing
   at. */
.dash-matrix-kw-note {
  font-size: 0.75rem;
  color: var(--amber-700, #b45309);
  background: var(--amber-50, #fffbeb);
  border: 1px solid var(--amber-200, #fde68a);
  border-radius: 0.5rem;
  padding: 0.375rem 0.625rem;
  margin: 0 0 0.75rem;
}

.dark .dash-matrix-kw-note {
  color: #fcd34d;
  background: rgba(180, 83, 9, 0.12);
  border-color: rgba(180, 83, 9, 0.35);
}

/* Sits UNDER the matrix, not above it with the KW note: it explains a figure
   the reader only has a question about once they have read the Total column.
   Deliberately quieter than .dash-matrix-kw-note — that one flags a rule that
   changes what a number MEANS, this one just accounts for a difference
. */
.dash-matrix-reconcile {
  font-size: 0.6875rem;
  color: var(--text-muted);
  padding: 0.5rem 0.125rem 0;
  margin: 0;
}

/* ── Dashboard chart-panel loading skeleton ──────────────────────────────
   #dash-chart-bar/avail/trend build their Chart.js canvas synchronously once
   the rollup fetch resolves — on a cache miss there is a real window where
   the tab is on screen and the canvas is empty/stale. A row of bars using the
   same `.skeleton` shimmer every other placeholder in the app already uses
   (see js/core.js skeletonRows()/skeletonCards()), shown/hidden opposite the
   canvas by _showChartSkeleton()/_hideChartSkeleton() in
   js/views/dashboard.js. `.hidden` is the existing Tailwind utility already
   used to toggle every other dash-panel — no local override needed. */
.dash-chart-skeleton {
  display: flex;
  align-items: flex-end;
  gap: 0.75rem;
  height: 280px;
  padding: 0 0.5rem 0.75rem;
}

.dash-chart-skeleton span.skeleton {
  flex: 1 1 0;
  min-width: 0;
  border-radius: 0.375rem 0.375rem 0 0;
}

.dash-chart-skeleton span.skeleton:nth-child(1) { height: 45%; }
.dash-chart-skeleton span.skeleton:nth-child(2) { height: 72%; }
.dash-chart-skeleton span.skeleton:nth-child(3) { height: 58%; }
.dash-chart-skeleton span.skeleton:nth-child(4) { height: 88%; }
.dash-chart-skeleton span.skeleton:nth-child(5) { height: 38%; }
.dash-chart-skeleton span.skeleton:nth-child(6) { height: 65%; }

/* ── Matrix flag badge, now a button ─────────────────────────────────────
   A non-zero ⚑ count in the Matriks Kesiapan table opens Perlu Perhatian
   scoped to that region — see data-action="matrix-flag" / _wireMatrixFlag()
   in js/views/dashboard.js. `.badge`/`.badge-warn` were only ever a
   read-only pill; this is the affordance that says the pill is a button now. */
button.badge-warn[data-action="matrix-flag"] {
  cursor: pointer;
}

button.badge-warn[data-action="matrix-flag"]:hover,
button.badge-warn[data-action="matrix-flag"]:focus-visible {
  filter: brightness(0.95);
  outline: 2px solid #b45309;
  outline-offset: 1px;
}

/* ── Laporan Pemeliharaan / Analisis Kerusakan: the same small, scoped motion
   the rest of the dashboard uses. Both classes below are used ONLY inside
   #dash-panel-perbaikan and #dash-panel-mcf (see js/views/pemeliharaan-dashboard.js)
   — never on shared base classes, matching the .dash-panel-fade /
   .dash-drill-region-body rule those panels already follow. ── */

/* #rd-kpi-card (the "Total Perbaikan Alat" KPI/gauge card) and #rd-mcf-card
   (the Kurva MCF stat card) hold PERSISTENT nodes — render()/renderMcf() only
   overwrite textContent in place, they never re-insert the card itself — so a
   plain "animate on mount" keyframe would only ever play once, on first
   paint. _replayFade() removes this class, forces a reflow, and re-adds it on
   every render()/renderMcf() call, including a Muat Ulang click, which is
   exactly when the shared .dash-panel tab-switch fade does NOT replay because
   the panel was already visible. */
.rd-panel-refresh {
  animation: rd-panel-refresh-fade 0.22s ease-out;
}

@keyframes rd-panel-refresh-fade {
  from {
    opacity: 0.35;
  }
  to {
    opacity: 1;
  }
}

/* The workshop rail rows, the sparepart ranking rows and the composition
   legend rows, by contrast, ARE freshly inserted on every render (innerHTML
   replaces them wholesale), so this one plays on its own with no JS timing —
   same idiom as .dash-drill-region-body. Opacity-only (no transform): one of
   the three uses is a <tr>, and table-row display types have inconsistent
   transform support across engines. */
.rd-row-reveal {
  animation: rd-row-reveal-fade 0.18s ease-out;
}

@keyframes rd-row-reveal-fade {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .rd-panel-refresh,
  .rd-row-reveal {
    animation: none;
  }
}

/* ══════════════════════════════════════════════════════════════════════
   ANALISIS KERUSAKAN  (Dashboard ▸ tab `mcf`)
   ══════════════════════════════════════════════════════════════════════

   Three components the existing layer could not express, so they live here
   rather than as inline Tailwind that would drift the way the twenty
   hand-rolled status spans did before `.badge`:

     .rd-verdict-icon   the one-glyph reading of the trend
     .rd-prio-row       a machine in the work queue (also the repeat-offender
                        list, which is the same row with less arithmetic)
     .rd-rank-row       a ranked group with its rate drawn behind it

   All three are BUTTONS: every one of them opens something. Colour is never
   the only carrier — the verdict pairs its hue with an arrow and a sentence,
   and the ratio chip prints the number it is colouring. */

.rd-verdict-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: 0.625rem;
  font-size: 0.95rem;
  border: 1px solid transparent;
}

.rd-verdict-icon.is-buruk {
  background: rgba(208, 59, 59, 0.12);
  border-color: rgba(208, 59, 59, 0.35);
  color: #b02f2f;
}

.rd-verdict-icon.is-baik {
  background: rgba(12, 163, 12, 0.12);
  border-color: rgba(12, 163, 12, 0.35);
  color: #0a7d0a;
}

.rd-verdict-icon.is-netral {
  background: rgba(22, 76, 129, 0.1);
  border-color: rgba(22, 76, 129, 0.3);
  color: var(--kai-blue);
}

.rd-verdict-icon.is-sepi {
  background: var(--surface-2);
  border-color: var(--border);
  color: var(--text-faint);
}

.dark .rd-verdict-icon.is-buruk { color: #f08c8c; }
.dark .rd-verdict-icon.is-baik { color: #6fd46f; }
.dark .rd-verdict-icon.is-netral {
  color: #7db4ea;
  border-color: rgba(125, 180, 234, 0.35);
}

/* ── The work queue row ──────────────────────────────────────────────
   Grid, not flex: the ratio chip is a fixed column so every row's arithmetic
   starts at the same x, which is what lets the eye run down the list and
   compare severities without reading each line. */
.rd-prio-row {
  display: grid;
  grid-template-columns: 3.25rem minmax(0, 1fr) 0.75rem;
  align-items: center;
  gap: 0.625rem;
  width: 100%;
  padding: 0.5rem 0.625rem;
  border-radius: 0.5rem;
  border: 1px solid transparent;
  text-align: left;
  transition: background-color 0.15s, border-color 0.15s;
}

.rd-prio-row:hover {
  background: var(--surface-2);
  border-color: var(--border);
}

.rd-prio-row:focus-visible {
  outline: 2px solid var(--kai-blue);
  outline-offset: 1px;
}

.rd-prio-ratio {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.2rem 0;
  border-radius: 0.375rem;
  font-size: 0.75rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  border: 1px solid transparent;
}

.rd-prio-ratio.is-kritis {
  background: rgba(208, 59, 59, 0.13);
  border-color: rgba(208, 59, 59, 0.35);
  color: #b02f2f;
}

.rd-prio-ratio.is-serius {
  background: rgba(236, 131, 90, 0.16);
  border-color: rgba(236, 131, 90, 0.4);
  color: #b85a30;
}

.rd-prio-ratio.is-awas {
  background: rgba(250, 178, 25, 0.16);
  border-color: rgba(250, 178, 25, 0.4);
  color: #8a6210;
}

.dark .rd-prio-ratio.is-kritis { color: #f08c8c; }
.dark .rd-prio-ratio.is-serius { color: #f0a882; }
.dark .rd-prio-ratio.is-awas { color: #f2c85c; }

.rd-prio-body {
  display: flex;
  flex-direction: column;
  min-width: 0;
  gap: 0.05rem;
}

.rd-prio-title {
  font-size: 0.75rem;
  font-weight: 700;
  color: var(--text);
  word-break: break-all;
}

.rd-prio-alat {
  font-size: 0.6875rem;
  color: var(--text-muted);
  word-break: break-word;
}

/* The arithmetic that justifies the row's place in the list. Deliberately the
   quietest line and deliberately always present: a queue that shows only its
   verdict is a queue nobody can argue with, and this one must be arguable. */
.rd-prio-math {
  font-size: 0.625rem;
  color: var(--text-faint);
  line-height: 1.5;
}

.rd-prio-chev {
  font-size: 0.625rem;
  color: var(--text-faint);
}

.rd-prio-row.is-ringkas {
  grid-template-columns: 2.5rem minmax(0, 1fr) 0.75rem;
}

/* ── The ranked group row ────────────────────────────────────────────
   The bar is a background layer sized by the rate, not a chart: the label and
   the figure stay selectable text, and a group with a tiny rate still shows a
   2% sliver rather than vanishing. */
.rd-rank-row {
  position: relative;
  display: block;
  width: 100%;
  padding: 0.4rem 0.55rem;
  border-radius: 0.5rem;
  overflow: hidden;
  text-align: left;
  transition: background-color 0.15s;
}

.rd-rank-row:hover { background: var(--surface-2); }

.rd-rank-row:focus-visible {
  outline: 2px solid var(--kai-blue);
  outline-offset: 1px;
}

.rd-rank-bar {
  position: absolute;
  inset: 0 auto 0 0;
  background: rgba(22, 76, 129, 0.1);
  border-radius: 0.5rem;
}

.dark .rd-rank-bar { background: rgba(16, 135, 237, 0.16); }

.rd-rank-face {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 0 0.5rem;
  align-items: baseline;
}

.rd-rank-nama {
  font-size: 0.6875rem;
  font-weight: 700;
  color: var(--text);
  word-break: break-word;
}

.rd-rank-angka {
  display: inline-flex;
  align-items: baseline;
  gap: 0.25rem;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.rd-rank-angka b {
  font-size: 0.8125rem;
  font-weight: 700;
  color: var(--text);
}

.rd-rank-angka small {
  font-size: 0.5625rem;
  color: var(--text-faint);
}

.rd-rank-gap {
  font-size: 0.5625rem;
  font-weight: 700;
  padding: 0.05rem 0.3rem;
  border-radius: 0.25rem;
}

.rd-rank-gap.is-buruk { background: rgba(208, 59, 59, 0.14); color: #b02f2f; }
.rd-rank-gap.is-baik { background: rgba(12, 163, 12, 0.14); color: #0a7d0a; }
.rd-rank-gap.is-netral { background: var(--surface-2); color: var(--text-faint); }

.dark .rd-rank-gap.is-buruk { color: #f08c8c; }
.dark .rd-rank-gap.is-baik { color: #6fd46f; }

.rd-rank-sub {
  grid-column: 1 / -1;
  font-size: 0.5625rem;
  color: var(--text-faint);
}

/* The rest of the work queue, and the groups too small to rank, share one
   disclosure shape — both are "present, but not what you came here to read". */
.rd-prio-more,
.rd-rank-tipis {
  margin-top: 0.5rem;
  padding-top: 0.5rem;
  border-top: 1px solid var(--border);
}

.rd-prio-more > summary,
.rd-rank-tipis > summary {
  cursor: pointer;
  font-size: 0.625rem;
  font-weight: 700;
  color: var(--text-faint);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  padding: 0.15rem 0;
}

.rd-rank-tipis-note {
  font-size: 0.5625rem;
  color: var(--text-faint);
  line-height: 1.6;
  margin: 0.25rem 0 0.4rem;
}

/* Phone: the ratio chip narrows and the row keeps its three columns rather
   than stacking — the chip IS the scan target, so putting it on its own line
   would cost the list the only thing that makes it skimmable. */
@media (max-width: 480px) {
  .rd-prio-row {
    grid-template-columns: 2.75rem minmax(0, 1fr) 0.625rem;
    gap: 0.5rem;
    padding: 0.5rem 0.4rem;
  }

  .rd-prio-row.is-ringkas {
    grid-template-columns: 2.25rem minmax(0, 1fr) 0.625rem;
  }

  .rd-rank-face {
    grid-template-columns: minmax(0, 1fr);
  }

  .rd-rank-angka {
    justify-content: flex-start;
  }
}

/* ═══════════════════════════════════════════════════════════════════════════
   PER-SCREEN — Dashboard drill-down modal (js/views/dash-drill.js)

   Its two-pane shape (a left context pane + a right working pane inside
   a modal) is the same one KDAK's own forms borrow below, rather than
   inventing a second convention for the same layout problem.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ══════════════════════════════════════════════════════════════════════
   DASHBOARD DRILL-DOWN MODAL (js/views/dash-drill.js)

   Every KPI card on the Dashboard opens the same modal, scoped to what the
   card counts: a two-pane view, a lazy region → asset tree on the left, four
   tabs (Pemeliharaan / Kalibrasi / Mutasi / Ketersediaan) on the right. See
   the module docstring for the full contract; this block is only its shape.
   ══════════════════════════════════════════════════════════════════════ */

/* `.modal-wide` tops out at 48rem — fine for a form, too narrow for a
   two-pane tree-plus-detail view. Widened again from 76rem: at that width the
   right pane's 8-9 column tables still forced their own horizontal scroll on
   a 1440px screen with room to spare. */
.modal-panel.modal-xl {
  max-width: 84rem;
  width: min(96vw, 84rem);
}

.dash-drill-split {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1.25rem;
  /* `.modal-body` already sets 1.25rem — this used to override it to 0,
     which is why every edge of the two-pane view pressed flush against the
     modal border. Padding restored, and bumped slightly on the sides since a
     two-pane grid reads more cramped than a single form at the same inset. */
  padding: 1.25rem 1.5rem;
  min-height: 0;
}

@media (max-width: 639px) {
  /* The full-bleed modal below `sm:` has no border-radius or backdrop margin
     to spare, so its own inset can shrink without the content reading as
     pinned to the glass again. */
  .dash-drill-split {
    padding: 1rem;
  }
}

@media (min-width: 1024px) {
  .dash-drill-split {
    grid-template-columns: minmax(0, 23rem) minmax(0, 1fr);
    align-items: stretch;
  }
}

#dash-drill-left,
#dash-drill-right {
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  gap: 0.75rem;
}

/* Stacked, not side-by-side: the sort dropdown sits directly UNDER the
   search field, one column, at every viewport. A wrapping flex ROW puts the
   two side by side whenever there is room, which is not what this wants. */
.dash-drill-left-controls {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.dash-drill-left-controls .toolbar-search,
.dash-drill-left-controls .toolbar-select {
  flex: 1 1 auto;
  width: 100%;
}

#dash-drill-sort {
  width: 100%;
  min-width: 9rem;
}

.dash-drill-note {
  font-size: 0.75rem;
  line-height: 1.3;
  color: var(--text-muted);
  padding: 0.5rem 0.625rem;
  background: var(--surface-2);
  border: 1px dashed var(--border-strong);
  border-radius: 0.5rem;
}

#dash-drill-tree {
  flex: 1 1 auto;
  overflow-y: auto;
  max-height: 46vh;
  border: 1px solid var(--border);
  border-radius: 0.625rem;
  background: var(--surface);
}

@media (min-width: 1024px) {
  #dash-drill-tree {
    max-height: none;
  }
}

.dash-drill-region-btn {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.625rem 0.75rem;
  text-align: left;
  font-size: 0.8125rem;
  font-weight: 600;
  color: var(--text);
  background: var(--surface-2);
  border: none;
  border-bottom: 1px solid var(--border);
}

.dash-drill-region-btn:hover {
  background: var(--surface-sunken);
}

/* Plays automatically every time a region is expanded: the body is freshly
   inserted HTML each render (innerHTML replaces it wholesale — see
   _renderRegionRow() in js/views/dash-drill.js), and a CSS animation
   restarts whenever the element it is on is newly mounted, so no JS class
   toggling is needed to trigger it. */
.dash-drill-region-body {
  animation: dash-drill-reveal 0.16s ease-out;
}

@keyframes dash-drill-reveal {
  from {
    opacity: 0;
    transform: translateY(-4px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  .dash-drill-region-body {
    animation: none;
  }
}

.dash-drill-asset-row {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.625rem;
  padding: 0.5rem 0.75rem 0.5rem 1.5rem;
  text-align: left;
  font-size: 0.75rem;
  color: var(--text);
  background: var(--surface);
  border: none;
  border-bottom: 1px solid var(--border);
}

.dash-drill-asset-row:hover {
  background: var(--surface-2);
}

.dash-drill-asset-row.is-selected {
  background: rgba(22, 76, 129, 0.1);
  box-shadow: inset 3px 0 0 var(--kai-blue);
}

.dark .dash-drill-asset-row.is-selected {
  background: rgba(59, 130, 246, 0.16);
}

.dash-drill-group-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.75rem;
  font-size: 0.75rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--text-muted);
  background: var(--surface-sunken);
  border-bottom: 1px solid var(--border);
}

.dash-drill-group-count {
  margin-left: auto;
  font-weight: 700;
  color: var(--text);
}

.dash-drill-count {
  font-size: 0.6875rem;
  color: var(--text-faint);
  text-align: right;
  padding: 0.125rem 0.125rem 0;
}

.dash-drill-subject {
  padding: 0.75rem;
  border: 1px solid var(--border);
  border-radius: 0.625rem;
  background: var(--surface-2);
}

/* ── The unified nav/detail strip ──────────────────
   "Lihat Semua" (its own single-button .segmented), a divider, the five
   scope tabs, and one conditional sub-row. One column, tight gaps: this
   sits directly above #dash-drill-rtools and #dash-drill-panel, which
   already carry their own spacing. */
#dash-drill-nav {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.dash-drill-nav-divider {
  height: 1px;
  background: var(--border);
  margin: 0.125rem 0;
}

/* "Lihat Semua (Total Aset)" is one full-width button inside a .segmented
   track. That track paints a sunken background and a border to GROUP
   siblings — which is right for the five-tab row below the divider, and wrong
   here, where there are no siblings to group: a single button filling its own
   track reads as a filled bar or a section heading, not as something to
   click. Drop the track and let the button be a button, with a real hover
   state so the affordance is visible before the pointer arrives. The active
   state still comes from .segmented-btn.is-active, so the kai-blue/white fill
   this row gets when selected is unchanged. */
#dash-drill-nav > .segmented.is-stretch:not([role="tablist"]) {
  background: transparent;
  border-color: transparent;
  padding: 0;
}

#dash-drill-nav > .segmented.is-stretch:not([role="tablist"]) .segmented-btn {
  border: 1px solid var(--border);
}

#dash-drill-nav > .segmented.is-stretch:not([role="tablist"]) .segmented-btn:not(.is-active):hover {
  background: var(--surface-sunken);
  color: var(--text);
}

.dash-drill-sub-kalibrasi {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

.dash-drill-sub-kalibrasi .segmented {
  flex: 1 1 14rem;
}

.dash-drill-sub-kalibrasi .toolbar-select {
  flex: 0 0 auto;
}

#dash-drill-kal-tahun {
  width: auto;
  min-width: 9rem;
}

/* Tailwind's `.hidden` (one class, `display: none`) loses to `.segmented.is-
   stretch` (two classes, `display: flex`) on specificity alone — source
   order never even gets consulted. `_renderTabsState()` toggles `.hidden` on
   these four sub-rows to show only the active tab's own, so without this
   override the Pemeliharaan/Mutasi rows stayed visibly flexed under every
   other tab too. An id selector outranks any number of classes. */
#dash-drill-sub-pemeliharaan.hidden,
#dash-drill-sub-kalibrasi.hidden,
#dash-drill-sub-mutasi.hidden,
#dash-drill-sub-perhatian.hidden {
  display: none;
}

/* Reuses the reason labels already in the tree groups (see
   _PERHATIAN_GROUPS in js/views/dash-drill.js) as clickable, NON-exclusive
   toggles — an asset can be both TSO and overdue, so hiding one reason must
   not force-select another. The selected look is `.inv-rail-btn.chip`'s own
   rule, below, widened to cover this class too rather than duplicated. */
.dash-drill-reason-chip {
  cursor: pointer;
}

.dash-drill-kal-status {
  padding: 0.75rem;
  border: 1px solid var(--border);
  border-radius: 0.625rem;
  background: var(--surface-2);
}

/* :not(.hidden) is load-bearing, not decoration. _renderRTools()
   (js/views/dash-drill.js) hides this toolbar with classList.toggle("hidden"),
   and an ID selector (1,0,0) outranks Tailwind's .hidden (0,1,0) whatever the
   source order — so a bare `#dash-drill-rtools { display: flex }` made that
   toggle inert. The right-pane search box and sort dropdown were therefore
   drawn on EVERY tab whether or not a table existed to search or sort, and on
   the four tabs where _rSortOptions() returns [] the sort rendered as an empty
   select. _rtoolsVisible()'s logic was correct the whole time; this rule was
   overruling it. Same fix, same reason, as .dash-panel:not(.hidden) and
   .master-tab-panel:not(.hidden) above. */
#dash-drill-rtools:not(.hidden) {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

#dash-drill-rtools .toolbar-search {
  flex: 1 1 10rem;
}

#dash-drill-rtools .toolbar-select {
  flex: 0 0 auto;
}

#dash-drill-rsort {
  width: auto;
  min-width: 9rem;
}

/* ── Mobile: one pane at a time ──────────────────────────────────────────
   Same idiom as #view-masterdata.is-drilled: the modal root carries
   .is-drilled once an asset is picked, which hides the tree and shows the
   right pane plus the back button. Above lg: both panes sit side by side and
   this block does not apply at all. */
@media (max-width: 1023px) {
  #dash-drill-modal:not(.is-drilled) #dash-drill-right {
    display: none;
  }

  #dash-drill-modal.is-drilled #dash-drill-left {
    display: none;
  }
}

@media (min-width: 1024px) {
  #dash-drill-back {
    display: none !important;
  }
}

/* Full-bleed below sm: a two-pane view has no room to spare for a backdrop
   margin on a 360px screen. */
@media (max-width: 639px) {
  #dash-drill-modal.modal-backdrop {
    padding: 0;
  }

  #dash-drill-modal .modal-panel {
    max-width: none;
    width: 100%;
    height: 100dvh;
    max-height: 100dvh;
    border-radius: 0;
  }
}

/* ── Open/close fade + scale ──
   Scoped to THIS modal's own id, deliberately not on the shared
   `.modal-backdrop`/`.modal-panel` base rules — those back ~30 other modals
   across the app that this pass never touched, and giving every one of them
   new motion was never asked for. `.is-open` is toggled by
   openDashDrill()/closeDashDrill() in js/views/dash-drill.js, which also
   defers re-adding `hidden` on close until this transition has had time to
   finish. */
#dash-drill-modal {
  opacity: 0;
  transition: opacity 0.18s ease;
}

#dash-drill-modal.is-open {
  opacity: 1;
}

#dash-drill-modal .modal-panel {
  transform: scale(0.97) translateY(8px);
  transition: transform 0.18s ease, opacity 0.18s ease;
}

#dash-drill-modal.is-open .modal-panel {
  transform: scale(1) translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  #dash-drill-modal,
  #dash-drill-modal .modal-panel {
    transition: none;
  }
}

/* ═══════════════════════════════════════════════════════════════════════════
   PER-SCREEN — Kelola Data Aset
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Density ──────────────────────────────────────────────────────────── */
/*
   Kelola Data Aset shows 1,121 assets as cards. At "nyaman" spacing that is a
   lot of scrolling for someone who knows what they are looking for, so the
   toolbar carries a toggle and the choice is remembered.

   Scoped to the view, not global: the dashboard and the master tables are
   designed at one density and gain nothing from being squeezed.
*/

#view-database.is-compact #db-cards-container {
  gap: 0.5rem;
}

#view-database.is-compact #db-cards-container > * {
  font-size: 0.75rem;
}

#view-database.is-compact #db-cards-container .p-4,
#view-database.is-compact #db-cards-container .p-5 {
  padding: 0.625rem;
}

#view-database.is-compact #db-cards-container .py-3 {
  padding-top: 0.25rem;
  padding-bottom: 0.25rem;
}

/* Secondary detail is the first thing to go when the user asks for density. */
#view-database.is-compact #db-cards-container .density-hide {
  display: none;
}

/* ═══════════════════════════════════════════════════════════════════════════
   PER-SCREEN — Kelola Data Alat Kerja (KDAK)

   The split-pane Tambah/Edit forms borrow the drill-down modal's own
   two-pane convention directly. `.letterhead` (the shared KAI/BUMN
   banner both #dash-banner and #kdak-banner use) lives in COMPONENTS,
   near the other reusable surfaces — it is not KDAK-only.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Modal width tier ──
   .modal-wide tops out at 48rem — fine for the OLD single-column form, too
   narrow now that the same form gains a second pane. .modal-xl (84rem) goes
   the other way: it is sized for the drill-down's two data tables and is
   more width than an operator entering ID segments needs. 64rem is chosen so
   the right-hand form column — once the fixed 20rem spec pane and the grid
   gap are subtracted — stays wide enough to hold .step-pair's two-column
   layout without cramping it; see the .step-pair landmine note below. At the
   harness's 1440x900 window this resolves to exactly 1024px (94vw would be
   wider here, so the 64rem cap wins). */
.modal-panel.modal-split {
  max-width: 64rem;
  width: min(94vw, 64rem);
}

/* ── The two-pane form body ──
   Modelled directly on .dash-drill-split (see DASHBOARD DRILL-DOWN MODAL,
   above): the same padding override for the same reason (.modal-body's own
   1.25rem reads cramped once there are two panes to separate), the same
   sub-640px reduction back down for the same reason (a full-bleed modal at
   that width has no backdrop margin to spare), and the same lg: breakpoint
   switching from a single column to two. The columns themselves differ from
   the drill-down's, because this pane holds a live <form> rather than a
   region tree: minmax(0, 20rem) keeps the spec pane from stealing space the
   form needs, minmax(26rem, 1fr) is what keeps .step-pair's two radio
   columns from cramping (see below). */
.kdak-form-split {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1.25rem;
  padding: 1.25rem 1.5rem;
  min-height: 0;
}

@media (max-width: 639px) {
  .kdak-form-split {
    padding: 1rem;
  }
}

@media (min-width: 1024px) {
  .kdak-form-split {
    grid-template-columns: minmax(0, 20rem) minmax(26rem, 1fr);
    align-items: start;
  }
}

/* Layout only — deliberately no `overflow` here. The `overflow-y-auto`
   TAILWIND class token on the markup's form-pane div (not this class) is
   what tools/verify/audit.py:191 finds via
   `panel.querySelector('.overflow-y-auto')`, a literal class-token
   selector, and it must stay the ONLY node inside the panel carrying that
   exact class. If a future edit ever needs this pane to scroll on its own,
   that has to happen by adding the Tailwind class in the markup again, not
   by adding `overflow` to this rule — a second scrolling mechanism here is
   how audit.py's crowding check silently degrades to a skipped diagnostic
   instead of a failing one. */
.kdak-form-pane {
  min-width: 0;
  min-height: 0;
}

/* The .step-pair landmine: .step-pair's two-column split (see the Stepper
   component, above) fires on a VIEWPORT media query at 640px, not on the
   width of whatever column it sits in. Once the form lives in the narrower
   right-hand column of this grid, a viewport between roughly 1024px and
   1280px can satisfy that media query while the actual column is too narrow
   to hold two fields side by side. The 64rem panel + minmax(26rem, 1fr) form
   column is sized to avoid this on its own — verified in a real browser at
   1024/1100/1280/1440px before shipping — so no container-query fallback is
   added here speculatively; if it turns out to still cramp, the fix belongs
   on .step-pair itself (container-type: inline-size + a @container query),
   not as a patch bolted on here. */

/* ── The spec/tool preview pane ──
   Above lg: a normal column: its own scrollbar, a subtly different surface
   from the form beside it so the two panes read as distinct regions without
   a heavy border doing all the work.

   Below lg: a collapsed SUMMARY STRIP that expands in place on tap — see
   .kdak-spec-summary below for the always-visible row, and
   _renderKdakSpecPane()/the click handler in kdak.js for what toggles
   `.is-open`. The operator reaches step 1 of the form immediately; nothing
   forces a scroll past a photo to start filling in fields. */
.kdak-spec-pane {
  min-width: 0;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  transition: max-height 0.2s ease;
}

@media (min-width: 1024px) {
  .kdak-spec-pane {
    overflow-y: auto;
  }
}

@media (max-width: 1023px) {
  .kdak-spec-pane {
    position: relative;
    max-height: 4.5rem;
    overflow: hidden;
    cursor: pointer;
  }

  /* Fades the clipped content rather than hard-cutting it, so the collapsed
     strip reads as "there is more below" instead of as the whole picture. */
  .kdak-spec-pane::before {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: 2.25rem;
    background: linear-gradient(to bottom, transparent, var(--surface-2) 85%);
    pointer-events: none;
  }

  .kdak-spec-pane::after {
    content: "Lihat spesifikasi \25BE";
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0.375rem;
    text-align: center;
    font-size: 0.6875rem;
    font-weight: 700;
    color: var(--kai-blue);
    pointer-events: none;
  }

  .dark .kdak-spec-pane::after {
    color: #93c5fd;
  }

  .kdak-spec-pane.is-open {
    max-height: 70vh;
    overflow-y: auto;
    cursor: default;
  }

  .kdak-spec-pane.is-open::before {
    display: none;
  }

  /* A matching affordance to collapse it back, rather than the tap target
     silently vanishing once the pane is open. */
  .kdak-spec-pane.is-open::after {
    content: "Sembunyikan spesifikasi \25B4";
    position: sticky;
    bottom: 0;
    padding: 0.25rem 0;
    background: var(--surface-2);
  }
}

@media (prefers-reduced-motion: reduce) {
  .kdak-spec-pane {
    transition: none;
  }
}

/* Keyboard reachability for the collapsed toggle — the pane carries
   `tabindex="0"` in the markup precisely so this can be reached without a
   pointer. Unscoped to any breakpoint: harmless at lg:, where the pane is
   not a toggle at all. */
.kdak-spec-pane:focus-visible {
  outline: 2px solid var(--kai-blue);
  outline-offset: 2px;
}

.dark .kdak-spec-pane:focus-visible {
  outline-color: #93c5fd;
}

/* The one-line summary row: a 40px thumbnail (or a glyph placeholder), the
   tool/model name, and "{n} spesifikasi". Desktop shows the full spec card
   directly, so this row is mobile-only. */
.kdak-spec-summary {
  display: flex;
  align-items: center;
  gap: 0.625rem;
  padding: 0.625rem 0.75rem;
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--text);
}

.kdak-spec-summary-thumb {
  flex-shrink: 0;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 0.375rem;
  object-fit: contain;
  background: var(--surface);
  border: 1px solid var(--border);
}

.kdak-spec-summary-count {
  margin-left: auto;
  flex-shrink: 0;
  font-size: 0.6875rem;
  font-weight: 700;
  color: var(--text-faint);
}

@media (min-width: 1024px) {
  .kdak-spec-summary {
    display: none;
  }
}

/* renderSpekCard()'s <dl> switches to a second (dense mode: grid-cols-1
   sm:grid-cols-2) column at Tailwind's `sm:` breakpoint — a VIEWPORT media
   query, same landmine class as .step-pair above, just smaller: nested
   inside this pane's fixed 20rem column, `sm:` fires long before the pane
   itself is anywhere near wide enough for two columns, which is exactly the
   overflow this rule exists to prevent. `.kdak-spec-pane dl` outscopes the
   Tailwind utility by specificity (one class + one element beats one class
   alone) with no !important needed. */
.kdak-spec-pane dl {
  grid-template-columns: 1fr;
}

/* ── Kantor Wilayah cell badge ──
   Rides on .badge.badge-info for colour; this adds only what a plain badge
   does not carry. It explains itself via `data-tip` (see
   _wireKdakTableInfo() in js/views/kdak.js, the KDAK-scoped twin of
   _wireMatrixInfo() above), so it gets the same cursor + focus treatment the
   matrix's own [data-tip] cells use. The expanded touch hit area lives with
   the rest of the @media (pointer: coarse) block, above. */
.kdak-kw {
  cursor: help;
  letter-spacing: 0.03em;
}

.kdak-kw:focus-visible {
  outline: 2px solid var(--kai-blue);
  outline-offset: 1px;
}

.dark .kdak-kw:focus-visible {
  outline-color: #60a5fa;
}

/* Brief background flash on #kdak-table-info whenever it updates — that bar
   sits at a fixed spot in the table header, far
   enough from wherever the mouse is actually hovering (the KW badge,
   `.kdak-kw` above, especially) that a plain text swap was easy to miss
   entirely. Toggled by _wireKdakTableInfo() in js/views/kdak.js. */
@keyframes kdak-info-flash {
  0% {
    background: rgba(22, 76, 129, 0.16);
  }
  100% {
    background: transparent;
  }
}

@keyframes kdak-info-flash-dark {
  0% {
    background: rgba(96, 165, 250, 0.18);
  }
  100% {
    background: transparent;
  }
}

#kdak-table-info.kdak-table-info-flash {
  animation: kdak-info-flash 900ms ease-out;
}

.dark #kdak-table-info.kdak-table-info-flash {
  animation-name: kdak-info-flash-dark;
}

/* ── Locked step ──
   A step that is not reachable yet: dimmed marker (contrast down, not the
   label — this means "not yet", not "irrelevant"), and a `not-allowed`
   cursor over its own controls. Composes with .step.is-done / .step.is-active
   (Stepper component, above) by construction: applyKdakFormGate() only ever
   adds/removes `.is-locked`, never those two, which stay owned by
   renderIdPreview() in kdak.js. */
.step.is-locked::before {
  background: var(--surface-2);
  border-color: var(--border);
  color: var(--text-faint);
  opacity: 0.6;
}

/* A locked step has to LOOK not-yet-reachable, or the only feedback is a click
   that does nothing. Dimmed, not hidden and not greyed to the point of reading
   as permanently unavailable: these steps are coming, in a known order, and the
   form is telling the operator where it is up to. The label keeps full contrast
   so the sequence is still readable ahead of time. */
.step.is-locked .step-title,
.step.is-locked label,
.step.is-locked .file-hint {
  opacity: 0.55;
}

.step.is-locked input,
.step.is-locked select,
.step.is-locked button {
  cursor: not-allowed;
  opacity: 0.5;
  background: var(--surface-2);
}

/* A short flash on the marker the moment a step becomes reachable. Not
   played by `.is-locked` being removed on its own — a CSS animation does not
   replay just because a class was taken away — so kdak.js adds
   `.step-unlock-flash` for one animation cycle right after clearing
   `.is-locked` (the same remove/reflow/re-add idiom `_replayFade()` already
   documents for .rd-panel-refresh, above), then removes it again. */
@keyframes kdak-step-unlock {
  0% {
    box-shadow: 0 0 0 0 rgba(22, 76, 129, 0.55);
  }
  70% {
    box-shadow: 0 0 0 0.5rem rgba(22, 76, 129, 0);
  }
  100% {
    box-shadow: 0 0 0 0 rgba(22, 76, 129, 0);
  }
}

.step-unlock-flash::before {
  animation: kdak-step-unlock 0.5s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .step-unlock-flash::before {
    animation: none;
  }
}

/* ── Generic reveal ──
   Fade+slide for freshly-rendered KDAK content: the KPI strip on mount and
   the spec pane's content each time it swaps to a new alat/model. Modelled
   on .dash-drill-region-body / @keyframes dash-drill-reveal, above — a CSS
   animation restarts on its own whenever the element carrying this class is
   newly inserted (an innerHTML replace, same as that rule), so nothing here
   needs JS timing beyond attaching the class to the freshly-built markup. */
@keyframes kdak-reveal {
  from {
    opacity: 0;
    transform: translateY(-4px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.kdak-reveal {
  animation: kdak-reveal 0.16s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .kdak-reveal {
    animation: none;
  }
}

/* ── Modal open/close fade + scale ──
   Copied from #dash-drill-modal's own rule (DASHBOARD DRILL-DOWN MODAL,
   above), scoped to these two ids for the identical reason that one is
   scoped to itself: roughly 30 other modals ride on the shared
   .modal-backdrop/.modal-panel base rules and were never asked for new
   motion. `.is-open` is toggled by the same double-rAF pattern
   dash-drill.js documents, which likewise defers re-adding `hidden` until
   this transition has had time to finish — so Esc, which closes through the
   dialog's own close control (js/a11y-modal.js), still runs the handler
   that clears `.is-open` rather than hiding the panel behind its back. */
#kdak-tambah-modal,
#kdak-edit-modal {
  opacity: 0;
  transition: opacity 0.18s ease;
}

#kdak-tambah-modal.is-open,
#kdak-edit-modal.is-open {
  opacity: 1;
}

#kdak-tambah-modal .modal-panel,
#kdak-edit-modal .modal-panel {
  transform: scale(0.97) translateY(8px);
  transition: transform 0.18s ease, opacity 0.18s ease;
}

#kdak-tambah-modal.is-open .modal-panel,
#kdak-edit-modal.is-open .modal-panel {
  transform: scale(1) translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  #kdak-tambah-modal,
  #kdak-tambah-modal .modal-panel,
  #kdak-edit-modal,
  #kdak-edit-modal .modal-panel {
    transition: none;
  }
}

/* ── Long-list rendering ──
   Guarded above 640px on purpose. Below that, .table-stack turns each <tr>
   into a card of variable height (however many spec rows/badges it wraps to
   with `data-label` captions), and a fixed contain-intrinsic-size would make
   the page jump as the browser's estimate collides with the real height
   during scroll. Above 640px every row is the same fixed height, so the
   browser can skip rendering off-screen rows almost for free. */
@media (min-width: 640px) {
  #kdak-table-body tr {
    content-visibility: auto;
    contain-intrinsic-size: 0 56px;
  }
}

/* ═══════════════════════════════════════════════════════════════════════════
   PER-SCREEN — Pusat Data
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Pusat Data: two-level layout under lg: ───────────────────────────── */
/*
   Six master tabs, plus a create form, plus a data table, all stacked on one
   390px screen. Above lg: that is a tab strip and a panel, which is fine.
   Below it, the tabs become a LIST and the panel only appears once a master is
   chosen — one level of content at a time, with a way back.

   The whole thing is one class on the view, toggled by switchMasterTab() and
   #master-back. Above lg: none of it applies, so the desktop layout is
   untouched by construction.
*/

#master-detail-head {
  display: none;
}

@media (max-width: 1023px) {
  /* Level 1: the six tiles ARE the list of masters, so nothing has to turn
     into one. The rules that used to live here reshaped #master-tabs into a
     list of chevron rows and dimmed its active state; that strip is gone
     (see MASTER_TILES in js/views/masterdata.js), and the tiles already read
     as a grid of choices at any width. */
  #view-masterdata:not(.is-drilled) #master-toolbar,
  #view-masterdata:not(.is-drilled) .master-tab-panel {
    display: none !important;
  }

  /* Level 2: the panel only. The tiles are hidden rather than left above the
     table, because six of them plus a toolbar plus a table is three levels of
     content competing for one 390px viewport, which is the whole reason this
     screen drills. #master-detail-head is the way back. */
  #view-masterdata.is-drilled #master-tiles,
  #view-masterdata.is-drilled #master-banner {
    display: none;
  }

  #view-masterdata.is-drilled #master-detail-head {
    display: flex;
  }
}

/* ── Pusat Data tab-panel fade ──
   switchMasterTab() toggles `hidden` on `.master-tab-panel` instantly, same
   as it always has — no JS timing to get right here. The SAME idiom
   .dash-panel's own tab-switch fade already uses: a CSS animation restarts
   on its own whenever the element it is on goes from `display:none` back to
   displayed, so removing `hidden` alone is enough to replay this on every
   switch. Six tabs snapping straight to their new table was the last
   un-animated switch left in Pusat Data. */
.master-tab-panel:not(.hidden) {
  animation: dash-panel-fade 0.16s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .master-tab-panel:not(.hidden) {
    animation: none;
  }
}

/* ── The same idiom, three more screens' own copies ──
   Pantau Riwayat's _setHistoryTab(), Kelola Inventaris's setInvTab() and
   Proses Laporan's tab switch all toggled `hidden` with no animation while
   Dashboard/Pusat Data already fade in above — an accident of which screen
   got touched in which session, not a deliberate speed choice. Each keeps
   its own scoped class rather than sharing one, same reasoning as
   .dash-panel/.master-tab-panel: this chapter's shared classes are for
   things many UNRELATED modals share, not screen-local tab switches. */
.hist-tab-panel:not(.hidden),
.inv-tab-panel:not(.hidden),
.exp-preview-panel:not(.hidden) {
  animation: dash-panel-fade 0.16s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .hist-tab-panel:not(.hidden),
  .inv-tab-panel:not(.hidden),
  .exp-preview-panel:not(.hidden) {
    animation: none;
  }
}

/* ── Sidebar sub-menu tree expand/collapse ──
   [data-children-for] mounts used the `hidden` ATTRIBUTE, unlike the CSS
   auto-replay panels above — its buttons must not stay in the tab order
   while collapsed, so this needs the same JS-timed dance .modal-fade-scale
   uses (openFadeModal/closeFadeModal in js/core.js), applied to max-height
   instead of opacity/scale: see _openNavChildren()/_closeNavChildren() in
   js/shell.js. A flat ceiling, not a measured one — same idiom
   .kpi-group-collapsible.is-open already uses. */
.nav-children {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.2s ease;
}

.nav-children.is-open {
  max-height: 20rem;
}

@media (prefers-reduced-motion: reduce) {
  .nav-children {
    transition: none;
  }
}

/* ── Pusat Data's own tile renderer ───────────────────────
   renderMasterTabs() in js/views/masterdata.js, NOT renderKpiStrip(). Pusat
   Data's six tiles are navigation, they switch a table, they do not report a
   filtered count the way a Dashboard/KDAK card does, and sharing a renderer
   with those two was already the seam where renderKpiStrip acquired a second
   personality (the deleted `tablist` flag).

   The active state is ONE KAI-orange bar along the tile's top edge, not a
   per-tile tinted background — six tiles each washing themselves a different
   colour to mean "I am open" reads as six different signals instead of one.
   The bar animates in from the left (transform, not width, so it is cheap)
   and is deliberately the ONLY thing that changes on activation besides the
   shadow; the tile's own accent colour (still a per-tile Tailwind class,
   border-{aksen} on hover, applied by the JS) keeps identifying WHICH
   master a tile is, the bar identifies THAT one is open. */
.master-tab {
  min-height: 6.25rem;
}

.master-tab::after {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  border-radius: 0 0 2px 2px;
  background: var(--kai-orange);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.25s ease;
}

.master-tab.is-active::after {
  transform: scaleX(1);
}

.master-tab.is-active {
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.08);
}

/* Light theme fills the active tile solid kai-blue with white text, matching
   every other tab strip in the app (`.dash-tab-btn.is-active`). An orange top
   bar is redundant once the whole tile is filled, so it is hidden; dark theme
   keeps its original orange-bar-only look untouched (the unguarded rule
   above still applies there). */
:root:not(.dark) .master-tab.is-active {
  background: var(--kai-blue);
  box-shadow: 0 2px 8px rgba(22, 76, 129, 0.25);
}

:root:not(.dark) .master-tab.is-active::after {
  background: transparent;
}

:root:not(.dark) .master-tab.is-active p,
:root:not(.dark) .master-tab.is-active h3 {
  color: #ffffff;
}

:root:not(.dark) .master-tab.is-active i {
  color: rgba(255, 255, 255, 0.2);
}

@media (prefers-reduced-motion: reduce) {
  .master-tab::after {
    transition: none;
  }
}

/* ── One action vocabulary for every Pusat Data Opsi cell ──
   Every table mixed hand-rolled coloured pills, .btn-icon and .badge-
   lookalikes, differently per panel. STATE goes in its own column as a
   real .badge; VERBS go here, in a kebab menu, so the column stops being
   six buttons wide and every action gets a real modal instead of an
   inline onclick="…('${value}')" string interpolation (an apostrophe in a
   nama_lokasi broke that row's markup — see CLAUDE.md finding #8).

   wireActionMenu()/actionMenuHtml() in js/core.js are the pair that drive
   this: the container is wired ONCE (idempotent, matching renderKpiStrip's
   own mount.dataset.wired guard) and survives every tbody re-render. */
.master-menu-wrap {
  position: relative;
  display: inline-block;
}

.master-menu {
  display: none;
  /* This absolute/top/right triple is the PRE-JS default, painted for exactly
     one frame before wireActionMenu's _positionActionMenu() overwrites it
     with computed `position: fixed` + `left`/`top` — see that function's
     docstring for why: a row near the bottom of a scrollable table wrapper
     opened a menu that was fully clipped by the wrapper's own overflow, and
     `fixed` is what escapes it. transform-origin here matches the DOWNWARD
     open case; .master-menu-up below overrides it for the upward one. */
  position: absolute;
  right: 0;
  top: 100%;
  z-index: 40;
  min-width: 11rem;
  margin-top: 0.25rem;
  padding: 0.25rem;
  border-radius: 0.75rem;
  border: 1px solid var(--border);
  background: var(--surface);
  box-shadow: 0 10px 25px rgb(0 0 0 / 0.15);
  transform-origin: top right;
}

.master-menu.is-open {
  display: flex;
  flex-direction: column;
  animation: master-menu-reveal 0.14s ease-out;
}

/* _positionActionMenu() adds this when the button sits too close to the
   viewport's bottom edge to open downward — the reveal has to travel from
   below in that case, or it visibly slides in from the wrong direction. */
.master-menu.master-menu-up {
  transform-origin: bottom right;
  animation-name: master-menu-reveal-up;
}

@keyframes master-menu-reveal {
  from {
    opacity: 0;
    transform: translateY(-4px) scale(0.98);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

@keyframes master-menu-reveal-up {
  from {
    opacity: 0;
    transform: translateY(4px) scale(0.98);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

@media (prefers-reduced-motion: reduce) {
  .master-menu.is-open {
    animation: none;
  }
}

.master-menu-item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.65rem;
  border-radius: 0.5rem;
  font-size: 0.75rem;
  font-weight: 600;
  text-align: left;
  white-space: nowrap;
  color: var(--text);
  background: none;
  border: 0;
  cursor: pointer;
}

.master-menu-item:hover,
.master-menu-item:focus-visible {
  background: var(--surface-2);
}

.master-menu-item.is-danger {
  color: #dc2626;
}
.dark .master-menu-item.is-danger {
  color: #f87171;
}

/* Below 640px, .table-stack has already turned the row into a card, so a
   floating popup is one more layer of indirection over content already
   stacked vertically. The toggle disappears and the menu becomes an
   always-visible inline row of buttons — one rule, no JS branch; .is-open
   stops mattering because the menu is never hidden here to begin with. */
@media (max-width: 639px) {
  .master-menu-btn {
    display: none;
  }
  .master-menu {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
    position: static;
    box-shadow: none;
    border: 0;
    padding: 0.5rem 0 0;
    margin-top: 0;
    background: none;
    /* Always visible here, never toggled — the desktop reveal would replay
       on every re-render otherwise, which is motion with nothing to reveal. */
    animation: none;
  }
  .master-menu-item {
    flex: 1 1 auto;
    justify-content: center;
    border: 1px solid var(--border);
  }
}

/* ── The shared two-pane detail modal shell ──────────────
   .dash-drill-split and .kdak-form-split are this shape twice already; this
   is the third. Used by the Alat Kerja document modal, the Model/Type detail
   modal, and the Dokumen preview, so a document, a photo and a spec block
   look the same wherever they are reached from. One column, then a fixed
   left pane beside a flexible right pane from lg:. */
.master-detail-split {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1.25rem;
}

@media (min-width: 1024px) {
  .master-detail-split {
    grid-template-columns: minmax(0, 22rem) minmax(26rem, 1fr);
  }
}

/* docPreviewPane()'s inline PDF viewer, js/views/spektek.js. A fixed aspect
   ratio rather than a fixed height, so the frame does not look identical for
   a one-page spektek and a thirty-page manual. */
.master-doc-frame {
  width: 100%;
  aspect-ratio: 8.5 / 11;
  max-height: 32rem;
  border-radius: 0.5rem;
  overflow: hidden;
  border: 1px solid var(--border);
  background: var(--surface-2);
}

.master-doc-frame iframe {
  width: 100%;
  height: 100%;
  border: 0;
}

/* ── Pusat Data table Opsi column stays reachable ──
   Same idiom as .kdak-col-aksi in the KDAK section above (not reused
   directly — that class name is scoped to that screen by convention, this
   is the Pusat Data equivalent): once the Model/Type column dropped its
   max-width cap, its badges can run wide enough to push the kebab button
   off a 1440px screen's visible area. Off below 640px, where .table-stack
   has already turned the row into a card and a sticky cell means nothing. */
@media (min-width: 640px) {
  .master-col-opsi {
    position: sticky;
    right: 0;
    z-index: 1;
    background: var(--surface);
    box-shadow: -6px 0 6px -6px rgba(15, 23, 42, 0.18);
  }

  thead .master-col-opsi {
    background: var(--surface-2);
  }

  tbody tr:hover .master-col-opsi {
    background: var(--surface-2);
  }
}

/* ── Model/Type completeness legend ──
   The Alat Kerja table colours each Model/Type chip by how complete its
   Rekap block is (merk+specs+photo / partial / no merk at all). A caption
   explaining the three colours earns its place under the toolbar rather
   than as a per-chip title= (title never fires on touch) — the same
   reasoning #kdak-kw-legend already applies to the KW badge. */
.master-legend-dot {
  display: inline-block;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 9999px;
}

/* ── Model/Type table Foto thumbnail ──
   Opens #varian-detail-modal — the same detail view the Spesifikasi chips
   and Alat Kerja's own Model/Type badges open (one detail view, not a
   lightbox and a modal that disagree). A hover/focus overlay states the
   affordance, "Lihat & ganti", rather than leaving a bare image looking
   like decoration. */
.varian-foto-btn {
  position: relative;
  display: block;
  width: 2.75rem;
  height: 2.75rem;
  border-radius: 0.5rem;
  overflow: hidden;
  border: 1px solid var(--border);
  background: var(--surface);
  padding: 0;
  cursor: pointer;
}

.varian-foto-btn img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  background: #fff;
}

.varian-foto-btn .varian-foto-placeholder {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-faint);
}

.varian-foto-btn::after {
  content: "Lihat & ganti";
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2px;
  text-align: center;
  font-size: 0.5rem;
  font-weight: 700;
  line-height: 1.1;
  color: #fff;
  background: rgba(15, 23, 42, 0.72);
  opacity: 0;
  transition: opacity 0.15s;
}

.varian-foto-btn:hover::after,
.varian-foto-btn:focus-visible::after {
  opacity: 1;
}

/* ── The spec card heading ───────────────────────────────────────────────
   The model-name badge used to sit beside the "Spesifikasi Alat Kerja" title
   on the same flex line, pushed right by `ml-auto`, and wrapped only once it
   no longer fitted. A name like "MILWAUKEE M18 ONEFHIWF34 High Torque Impact
   Wrench" therefore squeezed the title, and where it wrapped depended on the
   pane it happened to be rendered into. The title now owns its line and the
   badge always begins on the next one, in the ~20rem KDAK pane, on the asset
   detail screen and on landing.html alike. */
.spek-card-head {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.375rem;
}

.spek-card-head-title {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
}

.spek-card-head .badge {
  max-width: 100%;
  white-space: normal;
  text-align: left;
  line-height: 1.35;
}

/* A drop TARGET rather than a one-line file row. `.file-drop` is a horizontal
   strip built for a visible <input type="file"> beside a label, which is what
   every other upload in the app is. The bulk importer wants the opposite: a
   large, obviously droppable area with the native input hidden behind its own
   button, so the column direction and the centring are a variant rather than a
   second component. */
.file-drop.file-drop-lg {
  flex-direction: column;
  justify-content: center;
  text-align: center;
  padding: 1.5rem 1rem;
  gap: 0.25rem;
}

.file-drop.file-drop-lg.is-dragover {
  border-color: var(--kai-blue);
  background: rgba(0, 82, 156, 0.06);
}

.dark .file-drop.file-drop-lg.is-dragover {
  background: rgba(59, 130, 246, 0.12);
}

/* ── Pusat Data ▸ Wilayah & UPT ──────────────────────────────────────────
   lokasi and upt are one table split on tipe, and a UPT's parent is DERIVED
   from its own id string, never stored (see CLAUDE.md, "The lokasi
   hierarchy is encoded in IDs"). Two tabs would present that as two
   masters; this is the merged screen's own two-pane shell. It reuses
   .master-detail-split for the column split — the identical shape Alat
   Kerja's Dokumen Umum modal and the Model/Type detail modal already use —
   just not inside a modal, so it supplies its own padding here instead of
   riding on .modal-body's. */
.wilayah-panes {
  padding: 1rem;
}

.wilayah-row {
  border-bottom: 1px solid var(--border);
}

.wilayah-row:last-child {
  border-bottom: none;
}

.wilayah-row:hover,
.wilayah-row.is-selected {
  background: var(--surface-2);
}

.wilayah-row-head {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  padding: 0.625rem 0.75rem;
  text-align: left;
  background: none;
  border: 0;
  cursor: pointer;
  color: inherit;
  font: inherit;
}

.wilayah-row.is-selected .wilayah-row-head {
  box-shadow: inset 3px 0 0 var(--kai-blue, #2563eb);
}

.wilayah-row-actions {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  padding-right: 0.5rem;
}

/* The mobile accordion body — resorts expand IN PLACE rather than becoming a
   third screen and a third piece of drill state. lg:hidden because the SAME
   markup is drawn again into the desktop right pane by the same renderer
   (_wilayahDetailHtml() in js/views/masterdata-lokasi.js); showing both at
   once would read as the screen disagreeing with itself. */
.wilayah-row-body {
  padding: 0 0.75rem 0.75rem 1.25rem;
}

/* Reuses .dash-drill-region-body's own keyframe (assets/style.css, DASHBOARD
   DRILL-DOWN MODAL section) under a screen-scoped class name — the curve is
   worth sharing, the class is not: dash-drill's own class is a per-screen
   convention, not an extracted component, so a future change to one must not
   silently reach the other. */
.wilayah-row-reveal {
  animation: dash-drill-reveal 0.16s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .wilayah-row-reveal {
    animation: none;
  }
}

.wilayah-resort-group + .wilayah-resort-group {
  margin-top: 0.75rem;
}

.wilayah-resort-group-head {
  font-size: 0.6875rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-muted);
  margin-bottom: 0.375rem;
}

.wilayah-resort-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.5rem 0.625rem;
  border-radius: 0.5rem;
  background: var(--surface-2);
  margin-bottom: 0.375rem;
}

/* ── Pusat Data ▸ Model/Type: photo-first card grid ──────────
   Replaces the eight-column table. Alat Kerja stays a dense table on
   purpose — 104 short, katalog-sourced rows every asset id is built from —
   while a Model/Type is optional, richer enrichment (a photo, a merk, five
   free-form spec rows, its own documents) whose content reads naturally as
   a grid of cards, not eight thin table cells. That contrast IS the
   differentiation between the two screens, not an oversight. */
.varian-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  gap: 1rem;
  padding: 1rem;
}

.varian-grid > .empty-state {
  grid-column: 1 / -1;
}

.varian-card {
  display: flex;
  flex-direction: column;
  border-radius: 0.75rem;
  border: 1px solid var(--border);
  background: var(--surface);
  overflow: hidden;
  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}

/* Reuses spekFotoHtml()'s own photo/placeholder block (js/views/spektek.js)
   unchanged — the SAME renderer landing.html and the asset detail screen
   use — so this is a role="button" div, not a real <button>: spekFotoHtml()
   returns a <div>, which is invalid content inside a real button. The
   panel's own delegated click/keydown listeners treat it identically. */
.varian-card-photo {
  position: relative;
  cursor: pointer;
  display: block;
}

.varian-card-photo::after {
  content: "Lihat & ganti";
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.75rem;
  font-weight: 700;
  color: #fff;
  background: rgba(15, 23, 42, 0.55);
  opacity: 0;
  transition: opacity 0.15s;
}

.varian-card-photo:hover::after,
.varian-card-photo:focus-visible::after {
  opacity: 1;
}

.varian-card-body {
  padding: 0.75rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  flex: 1 1 auto;
}

.varian-card-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.5rem;
}

/* The reverse cross-link: jumps to Alat Kerja, pre-filtered to
   this code — see window.openMasterAlatFor() in js/views/masterdata-varian.js. */
.varian-card-alat {
  font-size: 0.6875rem;
  font-weight: 700;
  color: var(--text-muted);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  text-align: left;
}

.varian-card-alat:hover,
.varian-card-alat:focus-visible {
  color: var(--kai-blue, #2563eb);
  text-decoration: underline;
}

.varian-card-actions {
  flex: 0 0 auto;
}

.varian-card-title {
  cursor: pointer;
}

.varian-card-title h4 {
  font-size: 0.9375rem;
  font-weight: 700;
  line-height: 1.3;
  color: var(--text);
}

.varian-card-title:hover h4,
.varian-card-title:focus-visible h4 {
  color: var(--kai-blue, #2563eb);
}

.varian-card-sub {
  font-size: 0.6875rem;
  color: var(--text-muted);
  font-weight: 400;
  margin-top: 0.125rem;
}

.varian-card-merk {
  font-size: 0.75rem;
  color: var(--text-muted);
}

.varian-card-badges,
.varian-card-specs {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
}

/* Pushes the badges row (and everything after it) to the card's bottom
   edge, so a short spec list and a long one both end their card flush with
   their row-mates rather than at whatever height their own content stops. */
.varian-card-badges {
  margin-top: auto;
}

.varian-card-specs {
  padding-top: 0.375rem;
  border-top: 1px dashed var(--border);
}

/* ═══════════════════════════════════════════════════════════════════════════
   PER-SCREEN — Kelola Inventaris
   ═══════════════════════════════════════════════════════════════════════════ */

/* No screen-specific rules: this screen's own modals
   (#inv-add-modal, #inv-category-modal, ...) reach `.plain-modal-panel`
   under MOTION below, and everything else it renders is Components. */

/* ═══════════════════════════════════════════════════════════════════════════
   PER-SCREEN — Proses Laporan
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Sticky export bar (Proses Laporan) ───────────────────────────────── */
/*
   The Excel and PDF buttons used to sit ABOVE the preview table. Any real
   export meant scrolling past up to 600 rows to check the data and then all
   the way back up to press a button — so the two things the screen exists for
   were never on screen together.

   `bottom` clears #mobile-bottom-nav (which is only present under lg:) plus the
   iOS safe-area inset, so the bar never sits under the nav or under the home
   indicator.
*/

#exp-actions {
  position: sticky;
  bottom: calc(env(safe-area-inset-bottom, 0px) + 4.5rem);
  z-index: 20;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem;
  margin-top: 1rem;
  padding: 0.75rem 1rem;
  border: 1px solid var(--border);
  border-radius: 0.75rem;
  background: var(--surface);
  box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.08);
}

@media (min-width: 1024px) {
  /* No bottom nav at this width, so the bar can sit lower. */
  #exp-actions {
    bottom: calc(env(safe-area-inset-bottom, 0px) + 1rem);
  }
}

/* A SECOND, independent hide, for an element whose `hidden` class already has
   an owner. applyRoleGating() toggles `hidden` on every [data-write] element,
   so a screen that also wants to hide one per-tab cannot use that class without
   the two silently overwriting each other — the defect Detail Aset's action bar
   was rebuilt to remove. Used by Pantau Riwayat's Impor Massal control, which
   is both SUPER_ADMIN-only AND absent on the Mutasi tab. */
.is-tab-hidden {
  display: none !important;
}

/* ═══════════════════════════════════════════════════════════════════════════
   LANDING PAGE — the QR card's own sticky action bar

   landing.html is the most mobile-heavy surface in the system: it is reached
   by pointing a phone camera at a sticker on a machine, in the field. Its tab
   panel can run several screens long once a reader signs in, and the one
   control the whole page exists for — file a condition report — sat at the
   bottom of it. Sticky below `sm:`, static above it, where the panel fits and
   a pinned bar would only take room from the content.

   The blur + translucent ground is so a row of table text scrolling underneath
   does not read through the button. `env(safe-area-inset-bottom)` keeps it
   clear of the home indicator on a notched phone.
   ═══════════════════════════════════════════════════════════════════════════ */

@media (max-width: 639px) {
  .sticky-action {
    position: sticky;
    bottom: 0;
    z-index: 20;
    background: color-mix(in srgb, var(--surface) 92%, transparent);
    backdrop-filter: blur(8px);
    padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
    border-top: 1px solid var(--border);
  }
}

/* ═══════════════════════════════════════════════════════════════════════════
   MOBILE FIRST — the shared primitives

   A desktop-width design simply allowed to shrink gives 24 tables, 20
   horizontal scrollers and not
   one column-priority rule anywhere in index.html: a technician on a
   phone got a 240-column matrix to pan across and a drawer as the
   only way to change screen. These are the primitives that fixed it.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── 1. Stacked tables ────────────────────────────────────────────────
   Under 640px each <tr> becomes a card and each <td> prints its own caption
   from `data-label`. Horizontal scrolling is a desktop affordance: on a phone
   it hides columns behind a gesture with nothing to suggest they are there.

   Usage: add `.table-stack` to the <table> and `data-label="Kode"` to each
   <td>. Cells with no data-label (an actions column) render full width. */
@media (max-width: 639px) {
  .table-stack,
  .table-stack thead,
  .table-stack tbody,
  .table-stack tr,
  .table-stack td {
    display: block;
    width: 100%;
  }

  /* Visually hidden, not display:none — the header text is still the accessible
     name for each cell, and screen readers follow the table semantics. */
  .table-stack thead {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .table-stack tbody tr {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 0.25rem 0;
    margin-bottom: 0.625rem;
    white-space: normal;
  }

  .table-stack tbody tr + tr {
    border-top: 1px solid var(--border);
  }

  .table-stack td {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
    padding: 0.4375rem 0.875rem;
    white-space: normal;
    text-align: right;
  }

  .table-stack td::before {
    content: attr(data-label);
    flex: 0 0 auto;
    font-size: 0.6875rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-muted);
    text-align: left;
  }

  /* A cell with no caption (buttons, checkboxes) spans the row. */
  .table-stack td:not([data-label]) {
    justify-content: flex-end;
    text-align: right;
  }
  .table-stack td:not([data-label])::before {
    content: none;
  }

  /* Empty states and spinners keep their own centred layout. */
  .table-stack td[colspan]::before {
    content: none;
  }
  .table-stack td[colspan] {
    justify-content: center;
    text-align: center;
  }

  /* The wrapper no longer needs to scroll once the table stacks. */
  .table-stack-wrap {
    overflow-x: visible !important;
  }
}

/* ── 1b. Horizontal scroll affordance ─────────────────────────────────

   A strip that scrolls sideways with `scrollbar-none` and nothing else has no
   way of saying so. The dashboard tab bar at 390px is 925px of content in
   308px of viewport: three of its six tabs were unreachable-looking, because
   nothing on screen suggested there were six.

   A fade at each end, toggled by `data-scroll-start` / `data-scroll-end`
   attributes that `wireScrollHints()` in js/core.js stamps on scroll — so the
   left fade disappears at the left edge instead of implying content that is
   not there. Pure CSS `mask` cannot do that, since it has no way to know the
   scroll position.

   Pointer-events: none throughout, or the fade would eat taps on the tab
   underneath it — which on a 44px touch target is most of it. */
.scroll-hint {
  position: relative;
}

/* `.scroll-hint::before`/`::after`'s fade needs `.scroll-hint` itself to be
   the flex/scrolling element — `flex: 0 0 auto` and `align-self: stretch`
   are no-ops outside a flex container, so the pseudo-elements collapse to
   zero height and paint nothing (the "inert, not wrong" gap CLAUDE.md
   documents). Scoped to `.overflow-x-auto` rather than added to the base
   class: `.scroll-hint` also wraps children (e.g. `.segmented.is-stretch`)
   that are ALREADY the flex container and never overflow their own box —
   making the outer wrapper flex too would fight that child's own sizing
   for no reason. This targets only the usages that mark themselves as the
   actual scroller. */
.scroll-hint.overflow-x-auto {
  display: flex;
}

.scroll-hint::before,
.scroll-hint::after {
  content: "";
  position: sticky;
  top: 0;
  align-self: stretch;
  flex: 0 0 auto;
  width: 1.75rem;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.18s ease;
  z-index: 2;
}

/* The fade must overlay the strip, not displace it — and the negative margin
   that buys that has to go on the INNER side only, the side facing the
   content.

   It was written as `margin-inline: -1.75rem`, which applies to both sides at
   once: net inline size 28px − 28px − 28px = MINUS 28px, so the pseudo-element
   pulled every item after it 28px to the left, out of its own scroller and
   under the viewport edge. On the Gudang rail at 390px that rendered the first
   chip as "emua Gudang" with its left half cut off (rail box at x=24, first
   chip at x=2, the 22px being 28px of pull minus the 6px flex gap). The
   dashboard tab bar hit the same thing on "Matriks Kesiapan" and was worked
   around by replacing that scroller with a grid — see the comment in
   index.html — rather than fixed here, so it came back on the next strip.

   One side each: net inline size is 0, and the box still paints 28px over the
   neighbouring content because it is sticky and above it in the stack. */
.scroll-hint::before {
  left: 0;
  margin-right: -1.75rem;
  background: linear-gradient(to right, var(--surface), transparent);
}

.scroll-hint::after {
  right: 0;
  margin-left: -1.75rem;
  background: linear-gradient(to left, var(--surface), transparent);
}

.scroll-hint[data-scroll-start="0"]::before {
  opacity: 1;
}

.scroll-hint[data-scroll-end="0"]::after {
  opacity: 1;
}

/* ── 2. Mobile bottom navigation ──────────────────────────────────────
   Five destinations within thumb reach. The middle slot is the primary
   action — reporting a machine's condition is the one thing a technician
   does in the field, and it used to be four taps deep behind the drawer. */
#mobile-bottom-nav {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 60;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  align-items: end;
  background: var(--surface);
  border-top: 1px solid var(--border);
  /* Clears the iOS home indicator and Android gesture bar. */
  padding-bottom: env(safe-area-inset-bottom, 0);
  box-shadow: 0 -2px 12px rgba(15, 23, 42, 0.08);
}

@media (min-width: 1024px) {
  #mobile-bottom-nav {
    display: none;
  }
}

.bnav-btn {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.1875rem;
  /* 56px: comfortably above the 44px minimum touch target. */
  min-height: 56px;
  padding: 0.5rem 0.25rem;
  font-size: 0.625rem;
  font-weight: 600;
  line-height: 1.1;
  color: var(--text-muted);
  background: none;
  border: 0;
  transition: color 0.15s;
}

.bnav-btn i {
  font-size: 1.0625rem;
}

.bnav-btn.is-active {
  color: var(--kai-blue);
}

.dark .bnav-btn.is-active {
  color: #60a5fa;
}

.bnav-btn:active {
  background: var(--surface-2);
}

/* The raised primary action. */
.bnav-btn.bnav-primary {
  color: #fff;
}

.bnav-btn.bnav-primary i {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.75rem;
  margin-top: -1.125rem;
  border-radius: 9999px;
  background: var(--kai-orange);
  color: #fff;
  box-shadow: 0 4px 12px rgba(243, 134, 27, 0.4);
}

.bnav-btn.bnav-primary span {
  color: var(--text-muted);
}

/* The scroll container must clear the bar, or the last row of every list sits
   permanently underneath it. */
@media (max-width: 1023px) {
  #view-container {
    padding-bottom: calc(5.5rem + env(safe-area-inset-bottom, 0px)) !important;
  }
}

/* ── 3. Touch targets ─────────────────────────────────────────────────
   Interactive controls get a 44px minimum hit area on touch devices without
   growing visually — the padding does the work. Applied via `pointer: coarse`
   so a mouse-driven desktop keeps its compact density. */
@media (pointer: coarse) {
  button,
  [role="button"],
  select,
  a.btn,
  .nav-btn,
  .dash-tab-btn {
    min-height: 44px;
  }

  .btn-icon {
    min-width: 44px;
    min-height: 44px;
  }

  /* Inline table-row buttons are the exception that proves the rule: they sit
     in a dense list where 44px each would double every row's height. They get
     a transparent expanded hit area instead. */
  td button {
    position: relative;
  }
  td button::after {
    content: "";
    position: absolute;
    inset: -0.5rem;
  }

  /* Same treatment for a chip's remove control: a row of filter chips that
     doubled in height on a phone would cost more than the chips are worth. */
  .chip-x::after {
    content: "";
    position: absolute;
    inset: -0.75rem;
  }

  /* The chips themselves stay compact, but the row gets breathing room so
     adjacent hit areas do not overlap. */
  .chip-row {
    gap: 0.5rem;
  }

  /* The multi-select's option rows and a grouped report's section head are
     both full-width controls in a list, so they grow to the minimum outright
     rather than taking the expanded-hit-area treatment above. */
  .ms-option {
    min-height: 44px;
  }
  .ms-bulk button {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }
  .rpt-group-inner {
    min-height: 44px;
  }

  /* Same idiom for the KDAK "Kantor Wilayah" cell badge: it is a small pill
     inside a dense table cell, not a full-size control, so it gets an
     invisible expanded hit area rather than growing to 44px visually.
     .kdak-spec-pane's own collapsed tap target needs no rule here: its
     clipped height (4.5rem, see the KDAK section further down) already
     clears the 44px minimum on its own. */
  /* The Aksi column stays reachable.

   Twelve columns of real words - SIAP OPERASI, TIDAK PERLU KALIBRASI, SEDANG
   TERMUTASI - do not fit 1440px, and the wrapper has always been allowed to
   scroll. What is not acceptable is the Edit button being the thing that falls
   off the right edge, because then the row's only action is the one part of it
   you cannot see. Pinning it costs nothing and removes the reason to shorten
   the labels back into codes.

   Off below 640px: .table-stack has already turned each row into a card by
   then, and a sticky cell inside a card is meaningless. */
@media (min-width: 640px) {
  .kdak-col-aksi {
    position: sticky;
    right: 0;
    z-index: 1;
    background: var(--surface);
    box-shadow: -6px 0 6px -6px rgba(15, 23, 42, 0.18);
  }

  thead .kdak-col-aksi {
    background: var(--surface-2);
  }

  /* The row hover tint is painted on the <tr>, which a sticky cell with its own
     background would otherwise punch a hole in. */
  tbody tr:hover .kdak-col-aksi {
    background: var(--surface-2);
  }
}

.kdak-kw {
    position: relative;
  }
  .kdak-kw::after {
    content: "";
    position: absolute;
    inset: -0.75rem;
  }
}

/* ── 6. Detail history as a timeline under sm: ────────────────────────
   The asset-detail Perbaikan and Kalibrasi panels are `.table-stack`, so under
   640px each row already becomes a card. History is a SEQUENCE, though, and a
   stack of disconnected cards does not say so — the Mutasi panel next to them
   has been a real timeline all along, and the three read as unrelated screens.

   This adds the rail and the node to the stacked rows, so all three detail
   panels tell the same visual story. Above 640px nothing changes: the tables
   stay tables, where the columns line up and comparison is the point. */
@media (max-width: 639px) {
  .table-timeline.table-stack tbody tr {
    position: relative;
    padding-left: 1.5rem;
  }

  /* The rail. Runs the full height of the row and joins to the next one. */
  .table-timeline.table-stack tbody tr::before {
    content: "";
    position: absolute;
    left: 0.3125rem;
    top: 0;
    bottom: -0.75rem;
    width: 1px;
    background: var(--border);
  }

  .table-timeline.table-stack tbody tr:last-child::before {
    bottom: auto;
    height: 0.75rem;
  }

  /* The node. */
  .table-timeline.table-stack tbody tr::after {
    content: "";
    position: absolute;
    left: 0;
    top: 0.625rem;
    width: 0.6875rem;
    height: 0.6875rem;
    border-radius: 9999px;
    background: var(--surface);
    border: 2px solid var(--kai-blue);
  }
}

/* ═══════════════════════════════════════════════════════════════════════════
   MOTION — shared, cross-screen animation primitives

   A screen-specific fade/reveal (e.g. the dashboard tab-panel fade, the
   KDAK step-unlock flash) stays with that screen's own section above;
   this chapter is only for the ones many unrelated modals share.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── The same fade + scale, given a name instead of a fourth ID list ──
   #dash-drill-modal wrote it once; #kdak-tambah-modal/#kdak-edit-modal above
   copied it by hand for a second pair of ids, because neither predates a
   shared name for it. #user-tambah-modal is the first modal to
   opt in through a CLASS instead — `class="modal-fade-scale"` plus
   openFadeModal(id) / closeFadeModal(id) in js/core.js, which do the
   double-rAF-open / delayed-hidden-close dance this needs to actually play
   (removing `hidden` and adding `is-open` in the same tick applies the open
   state before the browser paints the closed one, and no transition fires).
   Every Pusat Data modal (Detail/Edit/Status/Approve/Reject/Password/Logout/
   Delete, and the shared #confirm-modal every customConfirm() call in the
   ENTIRE app opens) now opts in the same way — the "some multi-step forms
   are animated, some one-field confirms are not" split this comment used to
   describe was exactly what this session's polish pass was asked to close;
   an abrupt show/hide on eight of nine Pengguna dialogs read as a different,
   cruder generation of UI sitting one click away from the smooth ones.
   `.plain-modal-panel` is the SAME idea one level down: a handful of modals
   (#confirm-modal, and Kelola Inventaris's #inv-add-modal/#inv-category-modal)
   predate `.modal-panel` and are hand-rolled Tailwind panels, not the shared
   component — this class exists so THEY can still opt into the fade+scale
   motion without being rewritten onto `.modal-panel` (which would drag in
   sizing/shape rules that would need re-fighting for each one's own layout). */
.modal-fade-scale {
  opacity: 0;
  transition: opacity 0.18s ease;
}

.modal-fade-scale.is-open {
  opacity: 1;
}

.modal-fade-scale .modal-panel,
.modal-fade-scale .plain-modal-panel {
  transform: scale(0.97) translateY(8px);
  transition: transform 0.18s ease, opacity 0.18s ease;
}

.modal-fade-scale.is-open .modal-panel,
.modal-fade-scale.is-open .plain-modal-panel {
  transform: scale(1) translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  .modal-fade-scale,
  .modal-fade-scale .modal-panel,
  .modal-fade-scale .plain-modal-panel {
    transition: none;
  }
}

/* ══════════════════════════════════════════════════════════════════════
   FORM CONTROLS — text inputs and textareas
   ══════════════════════════════════════════════════════════════════════
   `.form-select` covers dropdowns; these cover its text-input siblings.
   Hand-rolled Tailwind on every <input> is exactly how three write forms on
   one screen drift into three different field shapes (p-2.5 vs p-2, gray-50
   vs white, three focus colours). Same
   tokens, same padding rhythm and same focus ring as `.form-select`, so a
   form built from all three reads as one control language.

   Deliberately NOT applied app-wide in this pass — a global input sweep is
   its own change with its own screenshots. New forms should reach for these.
   ══════════════════════════════════════════════════════════════════════ */

.form-input,
.form-textarea {
  width: 100%;
  padding: 0.625rem 0.75rem;
  border-radius: 0.5rem;
  border: 1px solid var(--border-strong);
  background: var(--surface);
  color: var(--text);
  font-size: 0.875rem;
  outline: none;
  transition: border-color 0.15s, box-shadow 0.15s, opacity 0.15s;
}

.form-textarea {
  resize: vertical;
  min-height: 5rem;
  line-height: 1.5;
}

.form-input::placeholder,
.form-textarea::placeholder {
  color: var(--text-faint);
}

.form-input:focus,
.form-textarea:focus {
  border-color: var(--kai-blue);
  box-shadow: 0 0 0 3px rgba(22, 76, 129, 0.12);
}

.form-input:disabled,
.form-textarea:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

/* A read-only field displaying a fact rather than taking input — it must not
   look like a control the reader failed to fill in. */
.form-input.is-static {
  background: var(--surface-2);
  border-style: dashed;
  opacity: 1;
  cursor: default;
}

.form-label {
  display: block;
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--text-muted);
  margin-bottom: 0.375rem;
}

.form-label .req {
  color: #ef4444;
}

.form-hint {
  font-size: 0.6875rem;
  color: var(--text-faint);
  margin-top: 0.3rem;
}

/* ══════════════════════════════════════════════════════════════════════
   DETAIL ASET — hero action bar
   ══════════════════════════════════════════════════════════════════════
   Tindakan stopped being a tab: reporting a condition, filing a calibration
   and transferring an asset are ACTIONS on the record in front of the
   reader, not a place to navigate to. They live in the hero and open real
   dialogs, which is also what makes the forms centred and modal-shaped —
   they are modals now, rather than page content styled to resemble one.
   ══════════════════════════════════════════════════════════════════════ */

.detail-actions {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 0.625rem;
  margin-top: 0.875rem;
  padding-top: 0.875rem;
  border-top: 1px dashed var(--border);
}

/* Only the overflow menu is left (a read-only role, or an asset already
   written off): no separator, since there is nothing above it to separate
   from, and no reserved height for buttons that were never drawn. */
.detail-actions.is-menu-only {
  margin-top: 0.5rem;
  padding-top: 0;
  border-top: none;
}

/* Pushes the overflow menu to the far end without an empty spacer element.
   Below 640px `.master-menu` renders as a static inline row of its own (see
   its rules above), so the auto-margin is dropped there and the menu wraps
   onto its own line rather than being crushed against the right edge. */
/* A column stack, so the overflow menu is aligned by `align-self` rather than
   by the auto-margin the old horizontal strip used. */
.detail-actions .master-menu-wrap {
  align-self: flex-end;
}

@media (max-width: 639px) {
  .detail-actions .master-menu-wrap {
    align-self: stretch;
  }

  /* Opt this ONE mount out of `.master-menu`'s phone behaviour, which turns
     the overflow menu into an always-visible inline row of buttons. That rule
     is right where it came from — a table row that has stacked into a card,
     where a popup anchored to a vanished cell would be awkward — but wrong
     here: the hero is not a stacked row, and six permanently-drawn buttons
     filled the whole first screen and pushed the history entirely below the
     fold (confirmed at 390px). The kebab toggle comes back and the menu goes
     back to being a menu.

     It opens full-width from the left rather than the desktop's right-anchored
     popup: _positionActionMenu() returns early below 640px, so nothing
     computes a fixed position for it here. */
  .detail-actions .master-menu-btn {
    display: inline-flex;
  }

  .detail-actions .master-menu {
    display: none;
    position: absolute;
    right: 0;
    left: auto;
    top: 100%;
    flex-direction: column;
    flex-wrap: nowrap;
    min-width: 13rem;
    margin-top: 0.25rem;
    padding: 0.25rem;
    border: 1px solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
    box-shadow: 0 10px 25px rgb(0 0 0 / 0.15);
  }

  .detail-actions .master-menu.is-open {
    display: flex;
  }

  .detail-actions .master-menu-item {
    flex: 0 0 auto;
    justify-content: flex-start;
    border: 0;
  }
}

/* ── Detail Aset action cards ──────────────────────────────────────────
   The three write controls used to be `btn-sm` in a row: three short nouns
   ("Laporan Kondisi", "Kalibrasi", "Mutasi") that named a RECORD rather than
   the act of filing one, small enough to scan past, with the reason a
   disabled Kalibrasi was disabled parked in a footnote under the whole strip
   where it read as a comment on all three.

   A card states the verb, then what filing it actually does, and carries its
   own disabled reason — which is also what lets `aria-describedby` point at
   something specific instead of at a shared note. `.action-card` is the whole
   button; nothing here is inline Tailwind, so the three stay in step.
*/

.action-card-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.625rem;
}

@media (min-width: 640px) {
  .action-card-grid {
    grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
  }
}

.action-card {
  display: flex;
  align-items: flex-start;
  gap: 0.75rem;
  width: 100%;
  padding: 0.875rem 1rem;
  text-align: left;
  border-radius: 0.75rem;
  border: 1px solid var(--border-strong);
  background: var(--surface);
  cursor: pointer;
  transition: border-color 0.15s, box-shadow 0.15s, background-color 0.15s;
}

.action-card:hover:not(:disabled) {
  border-color: var(--kai-blue);
  background: var(--surface-2);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
}

.action-card:focus-visible {
  outline: 2px solid var(--kai-blue, #164c81);
  outline-offset: 2px;
}

/* Not `display: none`. A reader who expects to calibrate this machine deserves
   to be told why they cannot, rather than hunting for a button that was never
   drawn. */
.action-card:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

.action-card-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: 0.5rem;
  font-size: 0.9375rem;
  background: var(--surface-sunken);
  color: var(--text-muted);
}

.action-card.is-kondisi .action-card-icon {
  background: #e0f2fe;
  color: #0369a1;
}

.action-card.is-kalibrasi .action-card-icon {
  background: #cffafe;
  color: #0e7490;
}

.action-card.is-mutasi .action-card-icon {
  background: #ffedd5;
  color: #c2410c;
}

.dark .action-card.is-kondisi .action-card-icon {
  background: rgba(3, 105, 161, 0.22);
  color: #7dd3fc;
}

.dark .action-card.is-kalibrasi .action-card-icon {
  background: rgba(14, 116, 144, 0.22);
  color: #67e8f9;
}

.dark .action-card.is-mutasi .action-card-icon {
  background: rgba(194, 65, 12, 0.22);
  color: #fdba74;
}

.action-card-text {
  display: flex;
  flex-direction: column;
  gap: 0.125rem;
  min-width: 0;
}

.action-card-title {
  font-size: 0.9375rem;
  font-weight: 700;
  line-height: 1.2;
  color: var(--text);
}

.action-card-sub {
  font-size: 0.75rem;
  line-height: 1.35;
  color: var(--text-muted);
}

/* The amber "this machine is physically somewhere else right now" chip.
   Lived inside the Identitas panel until this pass, where a reader on any
   other tab could not see it — the fact it states is true on every tab. */
.detail-elsewhere {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  margin-top: 0.5rem;
  padding: 0.25rem 0.5rem;
  border-radius: 0.375rem;
  font-size: 0.6875rem;
  font-weight: 600;
  background: #fffbeb;
  border: 1px solid #fde68a;
  color: #b45309;
}

.dark .detail-elsewhere {
  background: rgba(120, 53, 15, 0.25);
  border-color: rgba(180, 83, 9, 0.5);
  color: #fbbf24;
}

/* ══════════════════════════════════════════════════════════════════════
   DETAIL ASET — Riwayat "Semua" timeline
   ══════════════════════════════════════════════════════════════════════
   Replaces a flat stack of identical cards, which gave a reader no sense of
   sequence, spacing or era — the one thing a history is for. Entries hang
   off a single rail, grouped by year.
   ══════════════════════════════════════════════════════════════════════ */

.rw-timeline {
  position: relative;
}

.rw-year {
  display: flex;
  align-items: center;
  gap: 0.625rem;
  margin: 1.25rem 0 0.625rem;
  font-size: 0.75rem;
  font-weight: 800;
  letter-spacing: 0.08em;
  color: var(--text-muted);
}

.rw-year:first-child {
  margin-top: 0;
}

.rw-year::after {
  content: "";
  flex: 1 1 auto;
  height: 1px;
  background: var(--border);
}

.rw-entry {
  position: relative;
  display: grid;
  grid-template-columns: 1.75rem minmax(0, 1fr);
  gap: 0.75rem;
  padding-bottom: 1rem;
}

/* The rail is drawn per entry rather than as one absolutely-positioned line,
   so it stops cleanly at the last entry of a year group instead of running
   past it into the next heading. */
.rw-entry::before {
  content: "";
  position: absolute;
  left: 0.8125rem;
  top: 1.75rem;
  bottom: 0;
  width: 1px;
  background: var(--border);
}

.rw-entry:last-child::before {
  display: none;
}

.rw-dot {
  position: relative;
  z-index: 1;
  width: 1.75rem;
  height: 1.75rem;
  border-radius: 999px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.6875rem;
  background: var(--surface-2);
  border: 1px solid var(--border);
}

.rw-dot.is-pemeliharaan {
  color: var(--kai-blue);
}

.dark .rw-dot.is-pemeliharaan {
  color: #93c5fd;
}

.rw-dot.is-kalibrasi {
  color: #0891b2;
}

.dark .rw-dot.is-kalibrasi {
  color: #67e8f9;
}

.rw-dot.is-mutasi {
  color: var(--kai-orange);
}

.rw-body {
  min-width: 0;
  padding-top: 0.1875rem;
}

.rw-head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.rw-date {
  font-size: 0.8125rem;
  font-weight: 700;
  color: var(--text);
}

.rw-kind {
  font-size: 0.6875rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

.rw-time {
  margin-left: auto;
  font-size: 0.6875rem;
  color: var(--text-faint);
}

.rw-text {
  margin-top: 0.25rem;
  font-size: 0.8125rem;
  color: var(--text-muted);
  overflow-wrap: anywhere;
}

.rw-meta {
  margin-top: 0.25rem;
  font-size: 0.6875rem;
  color: var(--text-faint);
}

.rw-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  margin-top: 0.375rem;
}

/* ══════════════════════════════════════════════════════════════════════
   DETAIL ASET — availability panel
   ══════════════════════════════════════════════════════════════════════
   This tab used to borrow the dashboard drill-down's panel, which describes
   the UPT, the Wilayah and the tool type AROUND the machine. It now
   describes the machine: how much of the last twelve months it spent Siap
   Operasi, when it was down, and for how long.
   ══════════════════════════════════════════════════════════════════════ */

.avail-figure {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
}

.avail-pct {
  font-size: 1.875rem;
  font-weight: 800;
  line-height: 1;
  color: var(--kai-blue);
  font-variant-numeric: tabular-nums;
}

.dark .avail-pct {
  color: #93c5fd;
}

.avail-bar {
  height: 0.5rem;
  border-radius: 999px;
  background: var(--surface-sunken);
  overflow: hidden;
  margin-top: 0.5rem;
}

.avail-bar > span {
  display: block;
  height: 100%;
  border-radius: 999px;
  background: #22c55e;
}

.avail-bar.is-low > span {
  background: #f59e0b;
}

.avail-bar.is-poor > span {
  background: #ef4444;
}

/* Twelve month cells. Scrolls inside its own box rather than shrinking the
   cells below a readable width — the page body must never scroll sideways. */
.avail-months {
  display: flex;
  gap: 0.25rem;
  margin-top: 0.875rem;
  overflow-x: auto;
  padding-bottom: 0.25rem;
}

.avail-month {
  flex: 1 0 2.25rem;
  text-align: center;
}

.avail-month-cell {
  height: 2.25rem;
  border-radius: 0.3125rem;
  border: 1px solid var(--border);
  background: var(--surface-sunken);
  position: relative;
  overflow: hidden;
}

/* Each cell is a STACKED bar: the month's unavailable time is the cell's own
   background, and the SO share fills over it from the bottom. A month that
   was fully down is therefore solid red, and one with no data at all is an
   empty dashed box.

   The first shape of this drew only the green fill, so a month spent entirely
   Tidak Siap Operasi rendered a zero-height fill over a neutral cell —
   pixel-identical to a month with no records. The two most opposite states
   the strip can show looked the same, which is the one thing it must not do
   (found live on 12.ACK.1.24.B.VIII, down for all of January). */
.avail-month-cell.has-data {
  background: #fecaca;
}

.dark .avail-month-cell.has-data {
  background: rgba(239, 68, 68, 0.4);
}

.avail-month-cell > span {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  background: #22c55e;
}

.avail-month-cell.is-empty {
  border-style: dashed;
  opacity: 0.5;
}

.avail-month-label {
  margin-top: 0.25rem;
  font-size: 0.5625rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-faint);
}

.avail-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
  gap: 0.625rem;
  margin-top: 0.875rem;
}

.avail-stat {
  padding: 0.5rem 0.625rem;
  border-radius: 0.5rem;
  background: var(--surface-2);
  border: 1px solid var(--border);
}

.avail-stat dt {
  font-size: 0.5625rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-weight: 700;
  color: var(--text-faint);
}

.avail-stat dd {
  margin-top: 0.125rem;
  font-size: 0.9375rem;
  font-weight: 700;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}

/* ── The two stacked blocks of the merged Identitas tab's left column ── */
.detail-split {
  border-top: 1px solid var(--border);
  margin-top: 1.25rem;
  padding-top: 1.25rem;
}

.detail-section-title {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.75rem;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-muted);
  margin-bottom: 0.75rem;
}

/* ══════════════════════════════════════════════════════════════════════
   PANTAU RIWAYAT ASET
   ══════════════════════════════════════════════════════════════════════ */

/* ── The certificate tile on a Kalibrasi card ──
   Replaces a "Terlampir — unduh" text link: the certificate is the evidence
   the row is about, so it is shown rather than described. An image gets a real
   thumbnail (fetched as a blob — the endpoint is Bearer-authenticated, see
   js/views/riwayat.js); a PDF gets the icon, because rendering one as a
   thumbnail would need a PDF library the project deliberately does not load. */
.sert-tile {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.25rem 0.5rem 0.25rem 0.25rem;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  background: var(--surface-2);
  text-align: left;
  max-width: 100%;
  /* The tile is a flex ITEM of the card's "Sertifikat" row, and a flex item's
     default `min-width: auto` is its content width — so a 40-character stored
     filename pushed the tile straight past the card's right edge at 390px
     rather than letting the name ellipsize. */
  min-width: 0;
  cursor: pointer;
  transition: border-color 0.15s, background-color 0.15s;
}

.sert-tile:hover {
  border-color: var(--kai-blue);
  background: var(--surface);
}

.sert-tile:focus-visible {
  outline: 2px solid var(--kai-blue);
  outline-offset: 2px;
}

.sert-thumb {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.25rem;
  flex: 0 0 auto;
  border-radius: 0.375rem;
  overflow: hidden;
  background: var(--surface-sunken);
  color: var(--text-muted);
}

.sert-thumb > .skeleton {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 0;
  margin: 0;
}

.sert-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.sert-thumb.is-pdf {
  color: #dc2626;
  font-size: 1.125rem;
}

.sert-thumb.is-broken {
  color: var(--text-muted);
}

.sert-tile-meta {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.sert-tile-label {
  font-size: 0.6875rem;
  font-weight: 700;
  color: var(--kai-blue);
}

.dark .sert-tile-label {
  color: #93c5fd;
}

.sert-tile-name {
  font-size: 0.625rem;
  color: var(--text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100%;
}

/* The preview panes inside #sertifikat-modal. */
.sert-preview-img {
  max-width: 100%;
  max-height: 70vh;
  object-fit: contain;
  border-radius: 0.5rem;
}

.sert-preview-frame {
  width: 100%;
  height: 70vh;
  border: 0;
  border-radius: 0.5rem;
  background: #ffffff;
}

/* ── The live mutation stopwatch on a Mutasi card ──
   KAI blue on light, white on dark, per the client's own spec. Tabular
   figures so the seconds column does not jitter the row a pixel every tick,
   which is what makes a live counter read as noise rather than information. */
.mutasi-timer {
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.01em;
  color: var(--kai-blue);
}

.dark .mutasi-timer {
  color: #ffffff;
}
