* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
  /* Light navy/clinical theme (SPLYZA-Motion-style) for chrome/panels.
     The 3D viewport and video/skeleton preview areas stay dark on
     purpose — like most 3D/video tools, that content reads better on
     black regardless of the surrounding UI theme. */
  --bg: #eef1f6;
  --panel: rgba(255, 255, 255, 0.94);
  --panel-border: rgba(20, 40, 70, 0.12);
  --accent: #2c5aa0;
  --accent-soft: rgba(44, 90, 160, 0.12);
  --accent-border: rgba(44, 90, 160, 0.4);
  --active: #1c8a5c;
  --active-soft: rgba(28, 138, 92, 0.12);
  --active-border: rgba(28, 138, 92, 0.45);
  --danger: #d64545;
  --danger-soft: rgba(214, 69, 69, 0.1);
  --danger-border: rgba(214, 69, 69, 0.4);
  /* Third status step (good/warn/critical) -- used to only exist as a
     one-off hardcoded hex on a single badge rule (.is-status-rom-warn);
     promoted to a real token alongside --active/--danger for the ROM
     report redesign (user: "cần làm dễ nhìn nhất và cũng quan trọng
     nhất"), which needed the same warn color in SVG marks too. */
  --warn: #b45309;
  --warn-soft: rgba(217, 119, 6, 0.12);
  --warn-border: rgba(217, 119, 6, 0.4);
  /* 5th hue, admin-dashboard only (user: "trang quản lý đang đơn điệu
     quá" -- before this, --accent/--active/--danger/--warn were already
     spoken for by other meanings elsewhere in the app, so distinguishing
     stat tiles/section icons by color needed one more token rather than
     reusing one of those out of context). */
  --violet: #7c3aed;
  --violet-soft: rgba(124, 58, 237, 0.12);
  --violet-border: rgba(124, 58, 237, 0.4);
  --text: #16233a;
  --text-dim: #5b6b85;
  --surface-soft: rgba(20, 40, 70, 0.05);
  --navy-banner: #1e3a5f;
  --mono: ui-monospace, "SFMono-Regular", "Cascadia Code", "Consolas", monospace;
  --header-h: 60px;
  --sidebar-w: 208px;

  /* Slack-style dark rail — piloted on the admin dashboard only, now
     applied app-wide (user: "Refactor toàn bộ luôn mà, làm cho đẹp như
     slack, không cần giữ bố cục như hiện tại"). Aubergine, matching
     Slack's own sidebar hue. */
  --rail-bg: #3b0e3d;
  --rail-hover-bg: rgba(255, 255, 255, 0.08);
  --rail-active-bg: rgba(255, 255, 255, 0.15);
  --rail-text: rgba(255, 255, 255, 0.94);
  --rail-text-dim: rgba(255, 255, 255, 0.64);
  --rail-border: rgba(255, 255, 255, 0.12);

  /* Card polish tokens (user: "bố cục, cách bài trí, khoảng cách, bo góc,
     bóng đổ mềm thì bổ sung cho tôi", referencing Lark Suite's UI) --
     same colors/layout as before, just a shared soft-shadow pair and a
     slightly larger, consistent corner radius so every floating card
     (admin cards, stat tiles, home shortcuts, the video list) reads as
     one coherent shape language instead of each having its own one-off
     radius/shadow value. */
  --shadow-card: 0 1px 2px rgba(20, 10, 25, 0.05), 0 2px 14px rgba(20, 10, 25, 0.06);
  --shadow-card-hover: 0 2px 6px rgba(20, 10, 25, 0.08), 0 10px 26px rgba(20, 10, 25, 0.1);
  --radius-card: 14px;
}
/* Collapsed tabbar/header — frees up screen space for content (mainly
   the video viewer's two players). Toggled via #btn-toggle-chrome. */
body.chrome-collapsed {
  --header-h: 44px;
  --sidebar-w: 56px;
}
/* Phone-width auto-collapse (UI audit: at ~430px the 208px sidebar ate
   nearly half the screen and the header title clipped with no way to
   recover short of the user finding the manual collapse toggle) -- the
   same end state as body.chrome-collapsed above, just applied
   automatically by viewport instead of requiring a click. The manual
   toggle still works independently above this width. */
@media (max-width: 640px) {
  :root { --header-h: 44px; --sidebar-w: 56px; }
  .brand-text { display: none; }
  .nav-item { justify-content: center; padding: 10px 4px; }
  .nav-item .nav-label { display: none; }
  #version-label { display: none; }
  .sidebar-user-trigger { justify-content: center; padding: 8px 4px; }
  .sidebar-user-text, .sidebar-user-trigger .user-chip-caret { display: none; }
}
html, body {
  height: 100%;
  /* Slack's own real font stack (-apple-system/Segoe UI/Roboto -- the
     OS's native UI font, no web font load) -- not "Lato", which an
     earlier round wrongly assumed was Slack's typeface. */
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  background: var(--bg);
  color: var(--text);
  overflow: hidden;
}
#layout {
  position: relative;
  width: 100vw;
  height: 100vh;
}

/* ---- Persistent header: brand + auth (always visible, spans full
   width above the sidebar) ---- */
#app-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: var(--header-h);
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 0 20px;
  background: var(--rail-bg);
  border-bottom: 1px solid var(--rail-border);
  overflow: hidden;
  transition: height 0.18s ease;
}
.header-left {
  display: flex;
  align-items: center;
  gap: 14px;
  min-width: 0;
}
.chrome-toggle {
  flex-shrink: 0;
  width: 26px;
  height: 26px;
  border-radius: 8px;
  border: 1px solid var(--rail-border);
  background: rgba(255, 255, 255, 0.08);
  color: var(--rail-text-dim);
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
.chrome-toggle:hover { background: rgba(255, 255, 255, 0.16); color: var(--rail-text); }
.brand {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
  flex-shrink: 1;
}
body.chrome-collapsed .brand-text { display: none; }
.brand-text { min-width: 0; }
.brand-text h1 {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
.brand-mark {
  width: 10px;
  height: 10px;
  border-radius: 999px;
  background: var(--accent);
  box-shadow: 0 0 10px 2px var(--accent-soft);
  flex-shrink: 0;
}
.brand-text h1 {
  font-size: 14px;
  font-weight: 700;
  letter-spacing: -0.01em;
  line-height: 1.2;
  color: var(--rail-text);
}
.header-right {
  display: flex;
  align-items: center;
  gap: 8px;
}

/* ---- Sidebar: persistent left nav (Trang chủ, Giả lập, Video, Cài
   đặt, Hướng dẫn) — stays visible in every page, including the
   full-screen video viewer. ---- */
#app-sidebar {
  position: fixed;
  top: var(--header-h);
  left: 0;
  bottom: 0;
  width: var(--sidebar-w);
  z-index: 55;
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 16px 10px;
  background: var(--rail-bg);
  border-right: none;
  overflow-y: auto;
  overflow-x: hidden;
  transition: width 0.18s ease;
}
.nav-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 12px;
  border-radius: 6px;
  border: none;
  background: transparent;
  color: var(--rail-text-dim);
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  text-align: left;
  font-family: inherit;
  white-space: nowrap;
  transition: background 0.15s ease, color 0.15s ease;
}
.nav-item:hover { background: var(--rail-hover-bg); color: var(--rail-text); }
.nav-item.is-active { background: var(--rail-active-bg); color: #fff; font-weight: 700; }
.nav-item .nav-icon { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.nav-item .nav-icon svg { width: 18px; height: 18px; }
body.chrome-collapsed .nav-item { justify-content: center; padding: 10px 4px; }
body.chrome-collapsed .nav-item .nav-label { display: none; }
/* Admin is a back-office role, not "user + extra powers" — its sidebar
   shows only the dashboard sections (Tổng quan / Người dùng / Video),
   not the simulation/video-upload/settings items regular users get. */
.nav-item-admin { display: none; }
body.role-admin .nav-item:not(.nav-item-admin) { display: none; }
body.role-admin .nav-item-admin { display: flex; }
/* Owner keeps the full regular-user sidebar, just WITH this one extra
   item (user: "owner add member tương ứng vào workspace") -- unlike
   admin above, nothing else is hidden for this role. */
.nav-item-owner { display: none; }
body.role-owner .nav-item-owner { display: flex; }
#sidebar-footer {
  margin-top: auto;
  padding: 8px 2px 2px;
  border-top: 1px solid var(--rail-border);
  display: flex;
  flex-direction: column;
  gap: 6px;
}
#sidebar-footer #version-label {
  font-family: var(--mono);
  font-size: 10px;
  color: var(--rail-text-dim);
  padding: 0 10px;
}
/* Used to hide the whole footer when collapsed -- now that the account
   menu lives here, that would make logout unreachable in collapsed mode.
   Shrinks to just the avatar instead, same as a nav item collapsing to
   just its icon. */
body.chrome-collapsed #version-label { display: none; }
body.chrome-collapsed .sidebar-user-trigger { justify-content: center; padding: 8px 4px; }
body.chrome-collapsed .sidebar-user-text, body.chrome-collapsed .user-chip-caret { display: none; }

/* ---- Slack-style dark rail (header + sidebar, one continuous band —
   same as Slack's own workspace nav) now applies app-wide; #app-main
   (every page's actual content) stays on the light canvas, same as
   Slack's white message pane. Piloted on just the admin dashboard
   first, approved, then rolled out everywhere (user: "Refactor toàn bộ
   luôn mà, làm cho đẹp như slack"). The actual .chip/#status/role-badge/
   #btn-logout COLOR rules live further down, where each selector's own
   full (non-color) rule already is -- an earlier attempt duplicated
   them up here instead, which silently lost the cascade to those later,
   equal-specificity rules and left the header text/badges rendering in
   light-theme colors against this dark bar (caught by code review). ---- */
/* Small presence dot before the logged-in user's own name in the
   top-right chip -- the one bit of "real" Slack chrome that's actually
   true here (whoever's looking at this page is, definitionally, online
   right now), not a fabricated status. */
#user-chip-name::before {
  content: "";
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--active);
  margin-right: 6px;
  vertical-align: middle;
}
/* Dashboard/settings cards read as "cards floating on a canvas" (Slack's
   own panes) rather than flat bordered boxes -- a soft shadow instead of
   relying on the border alone. */
.admin-card,
.admin-stat-card {
  border-color: transparent;
  box-shadow: var(--shadow-card);
}
.admin-dash-topbar h2 {
  font-weight: 700;
  letter-spacing: -0.01em;
}

/* ---- Icons: a small inline-SVG set (stroke="currentColor", so active/
   hover/dark states all just work) replaces the emoji every nav item +
   the admin stat cards used before -- emoji render inconsistently
   across platforms/fonts and read as a placeholder, not a designed icon
   set, which is what actually looked unfinished. ----
   .admin-icon-chip: a reusable colored square (section headers, stat
   tiles) -- the admin dashboard used to color at most 2 of its 4 stat
   tiles and left every section header a plain gray caption with no icon
   at all, which read as monotonous/unfinished (user: "trang quản lý
   đang đơn điệu quá, bạn tham khảo UI đi") next to apps like Linear/
   Stripe/Vercel that always pair a section with a colored icon mark.
   The color is applied directly where the chip is used (its own class,
   e.g. is-violet), not inherited from a parent state class -- simpler
   than the old is-accent/is-active-color cascade this replaces, which a
   past code review already had to fix once for targeting the wrong
   element. */
.admin-icon-chip {
  width: 34px;
  height: 34px;
  border-radius: 10px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--surface-soft);
  color: var(--text-dim);
}
.admin-icon-chip svg { width: 17px; height: 17px; }
.admin-icon-chip.is-accent { background: var(--accent-soft); color: var(--accent); }
.admin-icon-chip.is-violet { background: var(--violet-soft); color: var(--violet); }
.admin-icon-chip.is-warn { background: var(--warn-soft); color: var(--warn); }
.admin-icon-chip.is-active { background: var(--active-soft); color: var(--active); }
.admin-icon-chip.is-danger { background: var(--danger-soft); color: var(--danger); }

/* ---- Buttons: a real primary/danger pair instead of borrowing the
   room-sim page's .cam-btn look (that class is shared with other pages
   and functions differently in context, so it's left alone) -- Slack's
   own button green (#007a5a), not the sidebar's aubergine -- Slack
   itself uses a different hue for actionable buttons than for nav
   chrome, and reusing the nav color for buttons would make the button
   read as "another nav item", not an action. Generic (not admin-only)
   on purpose -- also used by the auth submit button and the Record
   page's own start/stop buttons, the clear single CTA on each of
   those pages. */
.btn-primary {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  border: none;
  border-radius: 8px;
  padding: 9px 18px;
  font: inherit;
  font-size: 13px;
  font-weight: 700;
  color: #fff;
  background: #007a5a;
  cursor: pointer;
  transition: background 0.12s ease;
}
.btn-primary:hover { background: #148567; }
.btn-primary:active { background: #00543e; }

/* ---- Tables: a distinct header band + taller rows + rounded-rect (not
   pill) badges -- closer to Slack's own admin-console tables (its
   member-list tags are small rounded rects, not full pills) than the
   rest of this app's existing pill badges, which stay as-is everywhere
   outside the admin page. ---- */
body.role-admin .dash-table th {
  background: var(--surface-soft);
  padding: 10px 14px;
  position: sticky;
  top: 0;
}
body.role-admin .dash-table td { padding: 13px 14px; }
body.role-admin .dash-badge { border-radius: 6px; }

/* ---- Main content area: right of sidebar, below header. Holds the
   3D room viewport + its HUD, and the full-screen Home/Dashboard page
   (mutually exclusive via body[class^="page-"]). ---- */
#app-main {
  position: fixed;
  top: var(--header-h);
  left: var(--sidebar-w);
  right: 0;
  bottom: 0;
  overflow: hidden;
  transition: top 0.18s ease, left 0.18s ease;
}
#canvas-container {
  position: absolute;
  inset: 0;
  z-index: 0;
  background:
    radial-gradient(ellipse 80% 60% at 50% 0%, rgba(44, 90, 160, 0.12), transparent 60%),
    #05070b;
}
body:not(.page-room) #hud,
body:not(.page-room) #pose-panel,
body:not(.page-room) #pos-log-wrap,
body:not(.page-room) #status {
  display: none;
}

/* ---- Home / dashboard page — covers the 3D canvas when active. ---- */
#home-view {
  display: none;
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow-y: auto;
  background: var(--bg);
}
body.page-home #home-view { display: block; }
.home-body {
  max-width: 860px;
  margin: 0 auto;
  padding: 40px 24px 60px;
}
/* Quick-view shortcut cards (UI audit: home.hint promised a "quick view
   below" that didn't exist -- just a greeting over empty space). Cards
   on canvas, same look as .video-job-card/.admin-stat-card elsewhere in
   this redesign, not a new pattern. */
.home-quick-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 16px;
  margin-top: 28px;
}
.home-quick-card {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 10px;
  text-align: left;
  border: 1px solid transparent;
  background: var(--panel);
  border-radius: var(--radius-card);
  padding: 20px;
  font: inherit;
  cursor: pointer;
  box-shadow: var(--shadow-card);
  transition: border-color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease;
}
.home-quick-card:hover { border-color: var(--accent-border); box-shadow: var(--shadow-card-hover); transform: translateY(-1px); }
.home-quick-icon {
  width: 36px;
  height: 36px;
  border-radius: 10px;
  background: var(--accent-soft);
  color: var(--accent);
  display: flex;
  align-items: center;
  justify-content: center;
}
.home-quick-icon svg { width: 20px; height: 20px; }
.home-quick-title { font-size: 14px; font-weight: 700; color: var(--text); }
.home-quick-desc { font-size: 12px; color: var(--text-dim); line-height: 1.5; }

/* ---- Video page — same full-screen takeover as Home/Room, not a
   side drawer; this is a primary feature (upload + review processed
   videos), it deserves the whole screen rather than a 380px sliver. ---- */
.video-page-view {
  display: none;
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow-y: auto;
  background: var(--bg);
}
body.page-video .video-page-view { display: block; }

/* ---- Video 2 (IK) page — separate full-screen takeover, same pattern
   as Video above, but on its own CSS class so toggling one page never
   shows/hides the other. Experimental: new full-skeleton analytic IK
   retargeter (static/pose_retarget_ik.js) living alongside the
   original per-limb one (static/pose_retarget.js), which this page
   deliberately never imports or modifies. ---- */
.video-page-view2 {
  display: none;
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow-y: auto;
  background: var(--bg);
}
body.page-video2 .video-page-view2 { display: block; }

/* ---- Video list page — the processed-video list promoted out of a tab
   on the Video page into its own sidebar item (user: "ý tôi đưa video
   list ra đây luôn", pointing at the sidebar nav list itself) -- same
   full-screen-page pattern as every other nav item. ---- */
.video-page-view-list {
  display: none;
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow-y: auto;
  background: var(--bg);
}
body.page-videolist .video-page-view-list { display: block; }

/* ---- ROM thresholds page (regular user's own scoped editor, not the
   admin dashboard's cross-account one) — same full-screen-page pattern
   as every other nav item; user: "ngưỡng góc khớp là ở tài khoản user
   cũng có chứ, 1 bác sĩ có nhiều bệnh nhân mà". Reuses .video-page-
   header/.video-page-body for the outer chrome, just caps its one card
   narrower than those 1400px-wide defaults since there's only a single
   column of content here, not a multi-column layout. ---- */
.rom-thresholds-page-view {
  display: none;
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow-y: auto;
  background: var(--bg);
}
body.page-romthresholds .rom-thresholds-page-view { display: block; }
.rom-thresholds-card { max-width: 720px; }

/* ---- Members page (owner-only) — same full-screen-page pattern as
   every other nav item. ---- */
.members-page-view {
  display: none;
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow-y: auto;
  background: var(--bg);
}
body.page-members .members-page-view { display: block; }
.members-page-view .video-page-body { max-width: 760px; flex-direction: column; }
.members-card form.admin-inline-form { max-width: 420px; }
#member-invite-link-row.is-hidden { display: none; }
#member-invite-link-row { word-break: break-all; }
.member-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 13px 2px;
  border-bottom: 1px solid var(--panel-border);
}
.member-row:last-child { border-bottom: none; }
.member-row-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.member-row-email { font-size: 11px; color: var(--text-dim); }

/* ---- Record page — its own full-screen takeover (same pattern as
   Video above), separated out because the user found "record" mixed
   into the upload page's sidebar cluttered. ---- */
.record-page-view {
  display: none;
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow-y: auto;
  background: var(--bg);
}
body.page-record .record-page-view { display: block; }
.record-page-body {
  max-width: 640px;
  margin: 0 auto;
  padding: 20px 32px 60px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.video-page-header {
  max-width: 1400px;
  margin: 0 auto;
  padding: 40px 32px 0;
}
.video-page-header h2 { font-size: 22px; font-weight: 700; }
.video-page-body {
  max-width: 1400px;
  margin: 0 auto;
  padding: 20px 32px 60px;
  display: flex;
  gap: 28px;
  align-items: flex-start;
}
.video-page-col-controls {
  flex: 0 0 380px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}
.video-page-col-jobs { flex: 1; min-width: 0; }
/* Video upload form caps its own width instead of stretching full-page
   width now that the job list lives on its own separate page (user:
   "ý tôi đưa video list ra đây luôn" -- it used to be a tab sharing this
   same .video-page-body with the list). */
.video-page-upload-body {
  max-width: 480px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}
/* The list page shares .video-page-body's 1400px cap with the Video
   upload page, but a row's actual content (patient name, tags, actions)
   never gets near that width -- on a wide screen it left a large dead
   gap between the content and the Watch/Delete buttons pinned to the
   row's right edge, making the whole list look sparse/unbalanced (user:
   "tôi thấy xấu quá" after a screenshot review). Capped narrower here,
   same idea as Settings (880px) and the admin dashboard (1180px) each
   capping their own content instead of using the page-wide 1400px. */
.video-page-view-list .video-page-body { max-width: 1040px; }
/* .video-page-body's shared `align-items: flex-start` is fine for the
   2-column upload page (each column sizes to its own content), but
   combined with the shared `@media (max-width: 900px) { flex-direction:
   column }` rule, it ALSO stops a column child from stretching to the
   container's full width below 900px -- the child shrinks to its own
   content's width instead. For this page's one `.video-page-col-jobs`
   child, that content is the job list, whose rows (long filename +
   timestamp text) are wider than a phone screen -- so below 900px the
   whole list silently needed horizontal scrolling to reach Watch/
   Delete, found while screenshotting at 420px for this same review. */
.video-page-view-list .video-page-col-jobs { align-self: stretch; min-width: 0; }
.video-job-list-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  margin-bottom: 12px;
}
.video-job-list-head .admin-search { min-width: 240px; }
@media (max-width: 520px) {
  .video-job-list-head { flex-direction: column; align-items: stretch; }
  .video-job-list-head .admin-search { min-width: 0; }
}
/* #video-job-list (no "2", the hidden legacy page) keeps the card-grid
   layout it's always had -- untouched, consistent with that whole page
   being dead/unreachable UI left alone all session. */
#video-job-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 14px;
  align-items: start;
}
/* #video-job-list2 (the live Video page) converted from that same card
   grid to a single-column bordered list (user: "Processed videos làm
   dạng list ấy, đừng làm dạng bảng, slack có làm dạng bảng đâu" --
   Slack's own lists, e.g. its Files tab, are single-column rows, never
   a multi-column card grid). One container border instead of N
   per-card shadows; a border-bottom divides consecutive rows instead of
   a gap between floating cards. */
/* Wraps the header bar + the rows together so the two look like one
   card (shared border/radius) even though #video-job-list2 itself gets
   fully replaced on every render (see renderJobList()) -- the header
   bar lives as a separate, static sibling so re-rendering the rows
   never wipes it out. */
.video-job-list-wrap {
  border: 1px solid var(--panel-border);
  border-radius: var(--radius-card);
  background: var(--panel);
  overflow: hidden;
  box-shadow: var(--shadow-card);
}
/* Real columns, one CSS grid template shared by the header bar and every
   row (user: "tên bệnh nhân, ghi chú, file vào 1 cột là sai rồi" -- a
   first pass just labeled 2 loose flex zones, which didn't actually line
   up with the header's own per-field wording since patient/filename/note
   were all still stacked together inside one of those zones). Six real
   columns now: patient, video (filename + tags), upload date, status,
   note, actions -- NOT an HTML <table> (still no cell borders/row
   lines inside a row, still full-bleed hover etc.), just grid instead of
   flex so each field gets its own aligned column top to bottom. */
.video-page-view-list { --vjrow-grid: minmax(120px, 1fr) minmax(160px, 1.7fr) minmax(90px, 0.8fr) minmax(64px, 0.5fr) minmax(110px, 0.9fr) 220px; }
.video-job-list-headerbar {
  display: grid;
  grid-template-columns: var(--vjrow-grid);
  gap: 10px 14px;
  padding: 12px 18px;
  background: var(--surface-soft);
  border-bottom: 1px solid var(--panel-border);
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-dim);
}
.video-job-list-col-actions { text-align: right; }
@media (max-width: 760px) {
  .video-job-list-headerbar { display: none; }
}
#video-job-list2 {
  display: flex;
  flex-direction: column;
}
@media (max-width: 900px) {
  .video-page-body { flex-direction: column; }
  .video-page-col-controls { flex: 0 0 auto; width: 100%; }
}
.home-body.is-hidden { display: none; }
.home-body h2 { font-size: 22px; font-weight: 700; margin-bottom: 6px; }

/* ---- Admin dashboard — replaces the plain greeting for admin
   accounts. Its own left nav (Tổng quan / Người dùng / Video) switches
   between panels; unlike .home-body it isn't width-capped, since
   tables need the room. ---- */
.admin-dashboard {
  max-width: 1180px;
  margin: 0 auto;
  padding: 32px 24px 60px;
}
.admin-dashboard.is-hidden { display: none; }
.admin-dash-topbar { margin-bottom: 20px; }
.admin-dash-topbar h2 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
.admin-dash-sub { font-size: 13px; color: var(--text-dim); }
.admin-dash-topbar-row {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  margin-bottom: 12px;
}
.admin-dash-title-row { display: flex; align-items: center; gap: 14px; }
.admin-dash-org-avatar {
  width: 44px;
  height: 44px;
  border-radius: 11px;
  flex-shrink: 0;
  background: var(--accent);
  color: #fff;
  font-size: 18px;
  font-weight: 700;
  text-transform: uppercase;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* Read-only "currently viewing X" reminder + shortcut to the real
   Organizations table (see the HTML comment above it) -- a button, not a
   <select>, so it never implies "pick from this list right here" the
   way the old dropdown did. */
.admin-viewing-chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  max-width: 320px;
  padding: 8px 10px 8px 14px;
  border-radius: 999px;
  border: 1px solid var(--panel-border);
  background: var(--panel);
  cursor: pointer;
  font: inherit;
  transition: border-color 0.12s ease, background 0.12s ease;
}
.admin-viewing-chip:hover { border-color: var(--accent-border); background: var(--accent-soft); }
.admin-viewing-chip-label {
  font-size: 10px;
  font-weight: 700;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.02em;
  white-space: nowrap;
}
.admin-viewing-chip-name {
  font-size: 13px;
  font-weight: 700;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
.admin-viewing-chip svg { width: 14px; height: 14px; flex-shrink: 0; color: var(--text-dim); }
/* Generic bordered/transparent button -- same shape as .dash-delete-btn
   but accent-colored, for a secondary action next to a primary control
   (here: "+ Tổ chức mới" beside the org picker) instead of a full
   .btn-primary, which would visually compete with the picker itself. */
.admin-btn-ghost {
  font-size: 11px;
  font-weight: 600;
  white-space: nowrap;
  padding: 7px 12px;
  border-radius: 8px;
  border: 1px solid var(--accent-border);
  color: var(--accent);
  background: transparent;
  cursor: pointer;
}
.admin-btn-ghost:hover { background: var(--accent-soft); }
#admin-owner-invite-form.is-hidden { display: none; }
#admin-owner-invite-form { max-width: 420px; margin-top: 8px; }
#admin-owner-link-row.is-hidden { display: none; }
#admin-owner-link-row { word-break: break-all; }

/* Section switching (Tổng quan / Người dùng / Video) is driven by the
   real sidebar nav (.nav-item-admin) instead of a nav nested in here —
   this is just the panel area those buttons show/hide. */
.admin-dash-content { min-width: 0; }
.admin-dash-panel { display: none; }
.admin-dash-panel.is-active { display: block; }

.admin-stat-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 16px;
  margin-bottom: 22px;
}
/* Icon chip + value/label laid out as a horizontal row (Vercel/Linear
   usage-tile pattern) instead of the old icon-over-value stack -- gives
   each tile a real focal point instead of reading as 3 stacked lines of
   plain text. A subtle hover lift matches .home-quick-card's existing
   "floating card" treatment elsewhere in the app. */
.admin-stat-card {
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: var(--radius-card);
  padding: 18px 20px;
  display: flex;
  align-items: center;
  gap: 14px;
  transition: box-shadow 0.12s ease, transform 0.12s ease;
}
.admin-stat-card:hover { box-shadow: var(--shadow-card-hover); transform: translateY(-2px); }
.admin-stat-body { min-width: 0; }
.admin-stat-value { font-size: 24px; font-weight: 700; color: var(--text); line-height: 1.2; }
.admin-stat-label { font-size: 11px; color: var(--text-dim); margin-top: 2px; }

.admin-card {
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: var(--radius-card);
  padding: 20px 22px;
  margin-bottom: 18px;
  min-width: 0;
}
/* ROM panel's angle + speed thresholds used to just stack top to bottom
   like every other admin-card, reading as one long monotonous scroll --
   side by side once there's room for both, same pair of cards. */
.admin-card-grid-2 {
  display: grid;
  grid-template-columns: 1fr;
  gap: 18px;
  align-items: start;
}
@media (min-width: 960px) {
  .admin-card-grid-2 { grid-template-columns: 1fr 1fr; }
  .admin-card-grid-2 > .admin-card { margin-bottom: 0; }
}
/* The users/videos tables have more columns than a narrow screen can
   fit — scroll horizontally within the card's own bounds rather than
   overflowing past the page (which pushed content off-screen with no
   way to reach it). */
#dashboard-users, #dashboard-jobs, #dashboard-organizations { overflow-x: auto; }
/* System-status tiles -- its own class (not a reuse of .pose-stats/.stat,
   which the 3D room's HUD panel also uses for an unrelated purpose) so
   this card could get a real visual treatment: a status dot + tile
   background instead of 4 plain label/value text lines in a row. */
.status-tile-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 12px;
}
.status-tile {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 14px;
  border-radius: 12px;
  background: var(--surface-soft);
}
.status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--text-dim);
  flex-shrink: 0;
}
.status-dot.is-active { background: var(--active); box-shadow: 0 0 0 3px var(--active-soft); }
.status-tile-label { font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.02em; }
.status-tile-value { font-family: var(--mono); font-size: 13px; font-weight: 600; color: var(--text); margin-top: 1px; }
.admin-card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 16px;
  flex-wrap: wrap;
}
/* Groups a search box + an action button together on the head's right
   side (the Organizations card's own "+ Tổ chức mới" alongside its
   search, same row as Users/Videos' search-only right side) so they
   wrap together as one unit instead of the button landing on its own
   line below. */
.admin-card-head-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
/* Title no longer stands alone as a small gray caption -- paired with an
   .admin-icon-chip (see below) inside .admin-card-head-left, so it reads
   as a real section heading rather than a muted table label. */
.admin-card-head-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
.admin-card-title { font-size: 15px; font-weight: 700; color: var(--text); }
.admin-card-subtitle { font-size: 12px; color: var(--text-dim); margin-top: 1px; }
/* Icon+rounded-rect treatment used to be body.role-admin-scoped (one
   rule here, a near-duplicate override elsewhere) -- a leftover from
   before this app's styling went app-wide, with no real reason a
   non-admin's own search field (the Video page's new video/patient
   search) should look like a plain unstyled pill while the admin
   dashboard's identical-purpose search gets the real icon treatment.
   Consolidated into this one rule instead of leaving two partial,
   cascade-order-dependent copies. */
.admin-search {
  font-size: 12px;
  padding: 8px 12px 8px 32px;
  border-radius: 8px;
  border: 1px solid var(--panel-border);
  background: var(--surface-soft) url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="%235b6b85" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') no-repeat 10px center;
  color: var(--text);
  min-width: 180px;
}
/* background-color, not the background shorthand -- the shorthand
   resets every sub-property it doesn't mention, including the search
   icon's background-image set above, wiping the icon out the moment
   the field is focused (AI code review, 9f0c470..HEAD). */
.admin-search:focus { outline: none; border-color: var(--accent-border); background-color: var(--panel); }

.chip {
  background: rgba(255, 255, 255, 0.08);
  border: 1px solid var(--rail-border);
  border-radius: 999px;
  padding: 8px 14px;
  font-size: 12px;
  font-weight: 500;
  color: var(--rail-text-dim);
  white-space: nowrap;
}
#version-label { font-family: var(--mono); font-size: 11px; }
#status { color: var(--rail-text); }

/* ---- HUD stat panel ---- */
#hud {
  position: absolute;
  top: 20px;
  right: 20px;
  z-index: 10;
  width: 272px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.hud-section {
  background: var(--panel);
  border: 1px solid var(--panel-border);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border-radius: 16px;
  padding: 14px 16px;
}
.hud-section-title {
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 10px;
}
.stat-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px 14px;
}
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat-label { font-size: 10px; color: var(--text-dim); }
.stat-value {
  font-family: var(--mono);
  font-size: 15px;
  font-weight: 600;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
.hud-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.hud-row + .hud-row { margin-top: 10px; }
.hud-row-label { font-size: 12px; color: var(--text-dim); }
#fps-label {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--active);
  font-variant-numeric: tabular-nums;
}
.pill {
  display: inline-block;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.01em;
  padding: 4px 10px;
  border-radius: 999px;
  background: var(--surface-soft);
  color: var(--text-dim);
  transition: background 0.15s ease, color 0.15s ease;
}
.pill.is-active {
  background: var(--active-soft);
  color: var(--active);
}

/* ---- Coordinate log drawer ---- */
#pos-log-wrap {
  position: absolute;
  left: 20px;
  bottom: 20px;
  z-index: 10;
  width: 420px;
  max-width: calc(100vw - 320px);
  background: var(--panel);
  border: 1px solid var(--panel-border);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border-radius: 14px;
  overflow: hidden;
}
#pos-log-wrap summary {
  list-style: none;
  cursor: pointer;
  padding: 10px 14px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-dim);
  display: flex;
  align-items: center;
  justify-content: space-between;
}
#pos-log-wrap summary::-webkit-details-marker { display: none; }
#pos-log-wrap summary::after {
  content: "▾";
  font-size: 12px;
  transition: transform 0.15s ease;
}
#pos-log-wrap[open] summary::after { transform: rotate(180deg); }
#pos-log {
  font-family: var(--mono);
  font-size: 10px;
  line-height: 1.5;
  color: #fcd34d;
  background: rgba(0, 0, 0, 0.35);
  border-top: 1px solid var(--panel-border);
  padding: 10px 14px;
  max-height: 160px;
  overflow-y: auto;
  white-space: pre-wrap;
  word-break: break-all;
}

/* ---- Pose skeleton panel ---- */
#pose-panel {
  position: absolute;
  top: 20px;
  left: 20px;
  z-index: 10;
  width: 320px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.pose-view {
  position: relative;
  width: 100%;
  aspect-ratio: 480 / 640;
  border-radius: 14px;
  overflow: hidden;
  background: #000;
  border: 1px solid var(--panel-border);
}
#pose-skeleton-canvas,
#pose-camera-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}
#pose-skeleton-canvas {
  background: radial-gradient(ellipse 80% 70% at 50% 50%, rgba(44, 90, 160, 0.10), #000 70%);
}
#pose-camera-img {
  opacity: 0.55;
  pointer-events: none;
}
.pose-stats {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px 12px;
}
.pose-stat-value {
  font-family: var(--mono);
  font-size: 13px;
  font-weight: 600;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}
#pose-status-text {
  font-size: 11px;
  color: var(--text-dim);
  line-height: 1.4;
  min-height: 1.4em;
}
#pose-status-text.is-error { color: var(--danger); }
#pose-status-text.is-active { color: var(--active); }

.camera-place-controls {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 10px;
}
.camera-place-row {
  display: flex;
  gap: 8px;
}
.cam-btn {
  flex: 1;
  border: 1px solid var(--panel-border);
  background: var(--surface-soft);
  color: var(--text);
  border-radius: 10px;
  padding: 8px 10px;
  font-size: 11px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.cam-btn:hover:not(:disabled) {
  background: var(--accent-soft);
  border-color: var(--accent-border);
}
.cam-btn.is-active {
  background: var(--active-soft);
  border-color: var(--active-border);
  color: var(--active);
}
.cam-btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}
.camera-height-wrap {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.camera-height-wrap label {
  font-size: 10px;
  color: var(--text-dim);
  display: flex;
  justify-content: space-between;
}
#camera-height {
  width: 100%;
  accent-color: var(--accent);
}
body.is-placing-camera #status::after {
  content: attr(data-placing-hint);
  color: var(--active);
}

/* ---- Settings & Guide — real full pages now (same takeover pattern as
   Home/Video/Record above), not a right-side drawer over whatever page
   was active. Slack doesn't show its own equivalent settings as a
   popover either (user: "cài đặt ra luôn, không phải hiện popup như
   vậy, slack không làm thế") -- same reasoning the Video page's own
   comment already gives for why IT isn't a side drawer either. ---- */
.settings-page-view,
.guide-page-view {
  display: none;
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow-y: auto;
  background: var(--bg);
}
body.page-settings .settings-page-view { display: block; }
body.page-guide .guide-page-view { display: block; }
.settings-page-body,
.guide-page-body {
  max-width: 720px;
  margin: 0 auto;
  padding: 20px 32px 60px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}
/* Settings specifically gets a Preferences-style left tab rail + right
   content pane (Slack's own Preferences dialog shape -- General/
   Notifications/Language & region/... down the left, that tab's
   controls on the right), so it needs more width than Guide's plain
   single-column read. */
.settings-page-body { max-width: 880px; }
/* Guide is a real reading page now, not a cramped 380px drawer -- its
   old typography (a 10px all-caps label + 11px muted status-line text,
   both borrowed from compact control-UI classes used elsewhere) read as
   too small/thin for a full page (user: "font chưa đạt" after the
   earlier font-stack fix -- the actual issue here wasn't the typeface,
   it was these sizes). Dedicated classes instead of reusing
   .settings-section-title/.side-status, so this change doesn't affect
   either class's many OTHER (intentionally compact) uses elsewhere.
   .page-body-text (originally Guide-only, generalized once the Record
   page needed the exact same "real paragraph on a real page" treatment
   for its own hint text) is shared by any page's actual reading
   content; .guide-section-title stays Guide-specific since no other
   page currently needs that heading style. */
.guide-page-body { gap: 28px; }
.guide-section-title {
  font-size: 16px;
  font-weight: 700;
  color: var(--text);
  margin-bottom: 6px;
}
.page-body-text {
  font-size: 14px;
  line-height: 1.6;
  color: var(--text-dim);
}
.settings-shell {
  display: flex;
  gap: 32px;
  align-items: flex-start;
}
.settings-tabs {
  flex: 0 0 180px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.settings-tab {
  text-align: left;
  border: none;
  background: transparent;
  border-radius: 8px;
  padding: 9px 12px;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  color: var(--text-dim);
  cursor: pointer;
}
.settings-tab:hover { background: var(--surface-soft); color: var(--text); }
.settings-tab.is-active { background: var(--accent-soft); color: var(--accent); }
.settings-tab-content { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 20px; }
.settings-tab-panel { display: none; }
.settings-tab-panel.is-active { display: block; }
#settings-status.is-hidden { display: none; }
@media (max-width: 720px) {
  .settings-shell { flex-direction: column; gap: 16px; }
  /* UI audit: overflow-x:auto hard-clipped "Appearanc[e]" at the
     viewport edge with no scroll affordance -- it was in fact reachable
     by swiping, but looked broken rather than scrollable. 3 short
     labels wrap to 2 lines cleanly at this width, so there's no need
     for scrolling at all. */
  .settings-tabs { flex-direction: row; flex-wrap: wrap; flex: 0 0 auto; width: 100%; }
}
.settings-section-title {
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 10px;
}
/* Appearance & Walk style: both render as ONE bordered list group of
   compact radio-rows, not a grid of individually-boxed cards stacked on
   top of each other (user: "giao diện chồng các button không giống
   slack, không ai làm như thế", then confirmed Appearance "cũng thế" --
   same complaint applies there too). Reads as one coherent choice
   group, same shape Slack's own Preferences uses for a single-choice
   list. Appearance keeps a per-option color indicator (a small swatch
   dot on the left, not a full-width bar) since it's the one list where
   that distinction is meaningful; Walk style uses a plain radio dot
   instead (see .settings-radio-dot below) since there's no color
   concept for a walk cycle. */
.settings-radio-list {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--panel-border);
  border-radius: 10px;
  overflow: hidden;
  background: var(--panel);
}
.settings-radio-item {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  text-align: left;
  border: none;
  border-bottom: 1px solid var(--panel-border);
  background: transparent;
  padding: 12px 14px;
  cursor: pointer;
  color: var(--text);
  font: inherit;
}
.settings-radio-item:last-child { border-bottom: none; }
.settings-radio-item:hover { background: var(--surface-soft); }
.settings-radio-dot {
  position: relative;
  flex-shrink: 0;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 2px solid var(--panel-border);
}
.settings-radio-item.is-selected .settings-radio-dot { border-color: var(--active); }
.settings-radio-item.is-selected .settings-radio-dot::after {
  content: "";
  position: absolute;
  inset: 3px;
  border-radius: 50%;
  background: var(--active);
}
.settings-radio-text { display: flex; flex-direction: column; gap: 2px; }
.settings-swatch-dot {
  flex-shrink: 0;
  width: 18px;
  height: 18px;
  border-radius: 50%;
}
.settings-radio-check {
  flex-shrink: 0;
  margin-left: auto;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  display: none;
}
.settings-radio-item.is-selected .settings-radio-check {
  display: block;
  background-color: var(--active);
  background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>');
  background-repeat: no-repeat;
  background-position: center;
  background-size: 10px 10px;
}
.settings-card-title {
  font-size: 12px;
  font-weight: 700;
}
.settings-card-desc {
  font-size: 10px;
  color: var(--text-dim);
  line-height: 1.35;
}
.settings-select {
  width: 100%;
  padding: 8px 30px 8px 10px;
  font-family: inherit;
  font-size: 12px;
  color: var(--text);
  background: var(--surface-soft) url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="%235b6b85" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>') no-repeat right 8px center;
  background-size: 14px 14px;
  border: 1px solid var(--panel-border);
  border-radius: 10px;
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
}
.settings-select:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.side-status {
  font-size: 11px;
  color: var(--text-dim);
  min-height: 1.3em;
}
.side-status.is-active { color: var(--active); }
.side-status.is-error { color: var(--danger); }
#btn-open-settings, #btn-open-video {
  pointer-events: auto;
  cursor: pointer;
  border: 1px solid var(--panel-border);
  background: var(--panel);
  color: var(--text);
  border-radius: 999px;
  padding: 8px 14px;
  font-size: 12px;
  font-weight: 600;
}
#btn-close-viewer, #btn-close-viewer2, #btn-viewer-fullscreen {
  border: none;
  background: transparent;
  color: var(--text-dim);
  font-size: 20px;
  cursor: pointer;
  line-height: 1;
  border-radius: 50%;
  transition: background 0.12s ease, color 0.12s ease;
  /* The × glyph alone is a tiny hit target on touch — pad the button out
     well past it without disturbing the header's visual layout. */
  padding: 12px;
  margin: -12px;
}
#btn-close-viewer:hover, #btn-close-viewer2:hover, #btn-viewer-fullscreen:hover {
  background: var(--surface-soft);
  color: var(--text);
}
#btn-viewer-fullscreen { font-size: 15px; }
/* gap must clear the buttons' own -12px/12px padding-margin tap-target
   trick (see #btn-close-viewer etc. above) on BOTH sides, or the two
   buttons' actually-clickable areas overlap — a click meant for one can
   land on the other. 12+12=24px is the minimum that guarantees no
   overlap. */
.video-viewer-header-actions { display: flex; align-items: center; gap: 24px; }

/* ---- Video upload panel ---- */
.video-upload-drop {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  border: 1.5px dashed var(--panel-border);
  border-radius: 12px;
  padding: 28px 12px;
  text-align: center;
  font-size: 12px;
  color: var(--text-dim);
  cursor: pointer;
  transition: border-color 0.15s ease, background 0.15s ease;
}
.video-upload-drop:hover, .video-upload-drop.is-dragover {
  border-color: var(--accent-border);
  background: var(--accent-soft);
}
.video-upload-drop-icon { color: var(--text-dim); }
.video-upload-drop-main { font-weight: 600; color: var(--text); }
.video-sample-row {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 8px;
}
.video-sample-btn {
  border: 1px solid var(--panel-border);
  background: var(--surface-soft);
  color: var(--text-dim);
  border-radius: 999px;
  padding: 9px 12px;
  font-size: 10px;
  cursor: pointer;
}
.video-sample-btn:hover { border-color: var(--accent-border); color: var(--text); }
.video-model-row { display: flex; flex-wrap: wrap; gap: 6px; }
.video-engine-wrap.is-hidden { display: none; }
.video-model-btn {
  border: 1px solid var(--panel-border);
  background: var(--surface-soft);
  color: var(--text-dim);
  border-radius: 999px;
  padding: 10px 14px;
  font-size: 11px;
  font-weight: 600;
  cursor: pointer;
}
.video-model-btn:hover { border-color: var(--accent-border); color: var(--text); }
.video-model-btn.is-selected {
  background: var(--accent-soft);
  border-color: var(--accent-border);
  color: var(--accent);
}
.video-record-wrap { display: flex; flex-direction: column; gap: 8px; }
.video-record-preview {
  width: 100%;
  max-height: 240px;
  border-radius: 12px;
  background: #000;
  object-fit: cover;
}
.video-record-preview.is-mirrored { transform: scaleX(-1); }
.video-record-preview.is-recording { outline: 2px solid #ef4444; outline-offset: -2px; }
.video-record-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 200px;
  border: 1.5px dashed var(--panel-border);
  border-radius: 12px;
  color: var(--text-dim);
  background: var(--surface-soft);
}
.video-record-wrap .is-hidden { display: none; }
.video-record-controls { display: flex; flex-wrap: wrap; gap: 6px; }
/* Rebuilt (user: "hiện tại thấy giao diện và cách sắp xếp xấu quá" on
   the job list) -- the old card put status as plain 10px text crammed
   against the model text with justify-content:space-between (read as
   two unrelated labels fighting for space), always rendered a progress
   bar even once a job was long done (clutter -- JS now only emits the
   bar element for queued/processing jobs), and gave Watch/Delete the
   3D-room HUD's .cam-btn treatment: two flex:1 neutral buttons stretched
   to the full card width with equal visual weight, so a destructive
   action read as equally prominent as the primary one. Now: name +
   status badge share one head row (status becomes a real colored pill,
   not loose text), model/engine/LiDAR info become small neutral chips
   (.video-job-tag) instead of a cramped inline sentence, and actions
   are auto-width/left-aligned with Watch as the clear accent action and
   Delete as a quiet ghost-danger link beside it, not its equal twin. */
/* Still used by the legacy, hidden #video-job-list page (video_panel.js)
   -- left alone/untouched; the live page's own cards became
   .video-job-row below instead (a single-column list, not a grid of
   these). */
.video-job-card {
  border: 1px solid transparent;
  background: var(--panel);
  border-radius: var(--radius-card);
  padding: 14px 16px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  box-shadow: var(--shadow-card);
}
/* One row per video, full width, divided by border-bottom (see
   #video-job-list2 above) -- not a self-contained floating card
   anymore, so no per-row border/shadow/radius of its own. Grid, with the
   same --vjrow-grid template as .video-job-list-headerbar above, so
   patient/video/date/status/note/actions each land in their own real
   column instead of several fields sharing one. */
.video-job-row {
  display: grid;
  grid-template-columns: var(--vjrow-grid);
  align-items: start;
  gap: 12px 14px;
  padding: 16px 18px;
  border-bottom: 1px solid var(--panel-border);
}
.video-job-row:last-child { border-bottom: none; }
.video-job-cell { min-width: 0; }
.video-job-cell-video {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.video-job-cell-date { padding-top: 2px; }
.video-job-cell-status { padding-top: 2px; }
.video-job-cell-status .video-job-bar { margin-top: 6px; }
.video-job-tags {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
}
.video-job-name {
  flex: 1;
  min-width: 0;
  font-size: 13px;
  font-weight: 700;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Patient identity gets its own column now (user: "mỗi video tương ứng
   với 1 bệnh nhân" / later: "tên bệnh nhân, ghi chú, file vào 1 cột là
   sai rồi" -- it used to share one column with the filename). Used to
   be an always-visible borderless <input> whose EMPTY placeholder ("Tên hoặc
   mã bệnh nhân...") rendered as dark, bold, and nearly identical to a
   real filled-in value -- on an account where every video still had an
   empty patient_name, the whole list silently read as "every patient is
   named 'Tên hoặc mã bệnh nhân...'" (user: "tên bệnh nhân cũng thế...
   lẽ ra có icon cây bút rồi sửa"). Converted to an explicit view/edit
   toggle, same pattern now used for the note field below: a plain text
   view (clearly worded "Chưa gán bệnh nhân" + italics when empty, never
   placeholder-style fake data) plus a visible pencil button -- clicking
   either swaps in the real <input> (focused, text selected) for
   editing; blurring/Enter saves and swaps back to the view. */
.video-job-field {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 6px;
}
.video-job-field-view {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 6px;
  cursor: pointer;
  border-radius: 6px;
  padding: 2px 4px;
  margin: -2px -4px;
}
.video-job-field-view:hover { background: var(--surface-soft); }
.video-job-field-view:hover .video-job-edit-btn { opacity: 1; }
.video-job-field-text {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Allowed to wrap instead of the base rule's nowrap+ellipsis -- the
   empty-state copy is a full instructional sentence ("Chưa gán bệnh
   nhân — bấm để thêm" / "No patient assigned — click to add"), not a
   short data value, and the Patient column is sized for short real
   names. Truncating it to "No patient assi…" (found by a full-system UI
   audit) defeated the whole point of switching away from a placeholder
   look in the first place -- it read as cut off/broken rather than
   informative. The row already has vertical room (the Video column
   routinely shows 3 lines: filename, tags, owner), so wrapping to 2-3
   lines here costs nothing. */
.video-job-field-text.is-empty {
  color: var(--text-dim);
  font-weight: 600;
  font-style: italic;
  white-space: normal;
  overflow: visible;
  text-overflow: clip;
}
/* Text had no size of its own, so it fell back to the page's plain 16px
   body font -- oversized and visually flat next to everything else in
   the row (11px/600 filename, 10px tags/status), user: "font chữ sau
   khi điền tên xấu quá". Each field now matches its OWN edit <input>'s
   size once it has a real value: patient name is the row's identity,
   bold like .video-job-patient-input (13px/700); the note is a
   secondary annotation, plain like .video-job-note (11px) -- not one
   shared size for both. Deliberately NOT setting `color` here: the
   inherited default already equals var(--text) exactly, and the
   .is-empty rule above needs to keep winning that property for the
   dimmed placeholder state (same specificity, so adding color here
   would silently override it depending on source order). */
.video-job-cell-patient .video-job-field-text { font-size: 13px; }
.video-job-cell-patient .video-job-field-text:not(.is-empty) { font-weight: 700; }
.video-job-cell-note .video-job-field-text { font-size: 11px; }
/* No pencil on the empty state (user: "1 cái bút để sửa thôi, 2 bút làm
   gì" -- every row showed one pencil next to the patient field AND
   another next to the note field, even when BOTH were still empty). The
   empty-state wording already says "click to add" in words ("...bấm để
   thêm" / "+ Thêm ghi chú") -- pairing that with a pencil icon right
   next to it is two affordances for the one same action. Once a field
   actually HAS a value, plain text alone gives no hint it's editable,
   so the pencil earns its place there. Clicking the row still enters
   edit mode either way (.video-job-field-view's own click handler, not
   the button specifically) -- this only changes which case gets the
   extra visual nudge. */
.video-job-field-text.is-empty ~ .video-job-edit-btn { display: none; }
.video-job-edit-btn {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  padding: 0;
  border: none;
  background: none;
  color: var(--text-dim);
  border-radius: 6px;
  cursor: pointer;
  opacity: 0.55;
  transition: opacity 0.12s ease, color 0.12s ease, background 0.12s ease;
}
.video-job-edit-btn svg { width: 13px; height: 13px; }
.video-job-edit-btn:hover { opacity: 1; color: var(--accent); background: var(--accent-soft); }
.video-job-field:not(.is-editing) .video-job-field-input { display: none; }
.video-job-field.is-editing .video-job-field-view,
.video-job-field.is-editing .video-job-edit-btn { display: none; }
.video-job-patient-input {
  flex: 1;
  min-width: 0;
  font: inherit;
  font-size: 13px;
  font-weight: 700;
  color: var(--text);
  border: 1px solid var(--accent-border);
  background: var(--panel);
  padding: 2px 6px;
  border-radius: 6px;
}
.video-job-patient-input:focus { outline: none; box-shadow: 0 0 0 2px var(--accent-soft); }
.video-job-filename {
  display: block;
  font-size: 11px;
  font-weight: 600;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* "Done" and the frame count used to be one concatenated badge string
   ("Xong — 300 khung hình") -- read oddly as a single pill (user:
   "trạng thái done 300 frame cũng kì"). The badge now states only the
   status; the frame count moves into its own .video-job-tag alongside
   the model/LiDAR tags below, consistent with how every other piece of
   per-video metadata is already shown. */
.video-job-status-badge {
  flex: none;
  font-size: 10px;
  font-weight: 700;
  line-height: 1.4;
  padding: 4px 8px;
  border-radius: 8px;
  background: var(--accent-soft);
  color: var(--accent);
}
.video-job-row.is-done .video-job-status-badge { background: var(--active-soft); color: var(--active); }
.video-job-row.is-error .video-job-status-badge { background: var(--danger-soft); color: var(--danger); }
/* Still used by the legacy, hidden #video-job-list page (video_panel.js)
   -- left alone/untouched, so its own .video-job-meta markup still
   needs this rule even though the live page no longer uses the class. */
.video-job-meta {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 4px 10px;
}
.video-job-tag {
  font-size: 10px;
  font-weight: 600;
  color: var(--text-dim);
  background: var(--surface-soft);
  border-radius: 6px;
  padding: 3px 7px;
}
.video-job-bar {
  height: 4px;
  width: 100%;
  border-radius: 999px;
  background: var(--panel-border);
  overflow: hidden;
}
.video-job-bar-fill {
  height: 100%;
  background: var(--accent);
  transition: width 0.2s ease;
}
.video-job-owner {
  font-size: 10px;
  color: var(--text-dim);
}
/* Still used by the legacy, hidden #video-job-list page (video_panel.js)
   -- the live page now styles its upload-time text via the whole
   .video-job-cell-date column instead of this span directly. */
.video-job-time {
  font-size: 10px;
  color: var(--text-dim);
}
/* Note gets the same view/edit toggle as the patient field above (user:
   "ghi chú cũng thế, lẽ ra có icon cây bút rồi sửa") -- an empty note
   used to render as a full-width bordered input box on EVERY row (most
   rows have no note), which read as clutter/an unfinished form rather
   than an optional field. Empty state is now a quiet "+ Thêm ghi chú"
   ghost prompt with no visible box; a filled note shows as plain text
   with a pencil to edit, same as the patient field. Lives in its own
   grid column now (user: "ghi chú... vào 1 cột là sai rồi" -- it used to
   share the content column with patient name and filename). */
.video-job-cell-note .video-job-field-text.is-empty { font-style: normal; }
/* Still used by the legacy, hidden #video-job-list page (video_panel.js)
   -- its own .video-job-note-row wraps the input directly (no
   .video-job-field there), and still wants it capped rather than
   filling the whole card width. The live page's note input sits inside
   .video-job-field (a flex row) instead, where plain `flex: 1` below is
   enough to fill its grid cell. */
.video-job-note-row { display: flex; }
.video-job-note-row .video-job-note { max-width: 320px; }
.video-job-note {
  flex: 1;
  font-size: 11px;
  padding: 6px 8px;
  border-radius: 8px;
  border: 1px solid var(--accent-border);
  background: var(--panel);
  color: var(--text);
}
.video-job-note:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--accent-soft);
}
@media (max-width: 760px) {
  /* Stack the 6 columns top to bottom in the same order the grid
     defines them (patient, video, date, status, note, actions) --
     matches the header bar being hidden at this width too, since columns
     stop lining up with anything once there's only one left. */
  .video-job-row { grid-template-columns: 1fr; }
}
/* Required field (not the optional .video-job-note) -- larger/bolder
   border than the note input so it reads as the thing to fill in
   first, before picking a detection type or dropping a file. */
.video-patient-input {
  width: 100%;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  padding: 9px 12px;
  border-radius: 8px;
  border: 1px solid var(--panel-border);
  background: var(--panel);
  color: var(--text);
}
.video-patient-input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.video-patient-input.is-invalid { border-color: var(--danger); }
.video-job-actions {
  flex: none;
  display: flex;
  align-items: center;
  gap: 14px;
}
/* Tighter gap for the live page specifically (.video-job-cell is only
   ever combined with .video-job-actions there, never on the legacy
   page's own actions div) -- 3 buttons now share this column instead of
   2, and the Report button is deliberately narrow/icon-only, so the
   wider legacy gap would push Delete past the column's width. */
.video-job-cell.video-job-actions { gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
/* Both actions are now icon+label buttons with a visible border -- user:
   "action không rõ ràng" (Delete used to be bare borderless text next to
   Watch's solid button, easy to miss/misread as non-interactive). Watch
   keeps the stronger filled-accent treatment as the primary action;
   Delete is a real (if quiet) outlined button by default, only turning
   danger-red on hover -- still visually secondary to Watch, but
   unambiguously a clickable button either way. */
.video-job-watch-btn,
.video-job-delete-btn {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  border-radius: 8px;
  padding: 6px 14px;
  font-size: 11px;
  font-weight: 700;
  cursor: pointer;
  transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.video-job-watch-btn svg,
.video-job-delete-btn svg { width: 13px; height: 13px; flex: none; }
.video-job-watch-btn {
  border: 1px solid var(--accent-border);
  background: var(--accent-soft);
  color: var(--accent);
}
.video-job-watch-btn:hover { background: var(--accent-border); }
.video-job-delete-btn {
  border: 1px solid var(--panel-border);
  background: var(--panel);
  color: var(--text-dim);
}
.video-job-delete-btn:hover { border-color: var(--danger-border); background: var(--danger-soft); color: var(--danger); }
/* Opens the ROM/joint-angle report directly instead of making the user
   click Watch then a second toggle inside the viewer (user: "vẫn thiếu
   action xem bảng khớp"). Icon-only (title/aria-label carry the name) so
   3 actions still fit the row without crowding Watch/Delete, which stay
   the two labeled, higher-emphasis actions. */
.video-job-report-btn {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 29px;
  height: 29px;
  padding: 0;
  border-radius: 8px;
  border: 1px solid var(--panel-border);
  background: var(--panel);
  color: var(--text-dim);
  cursor: pointer;
  transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.video-job-report-btn svg { width: 14px; height: 14px; }
.video-job-report-btn span { display: none; }
.video-job-report-btn:hover { border-color: var(--accent-border); background: var(--accent-soft); color: var(--accent); }
.video-stage {
  position: relative;
  width: 100%;
  background: #000;
  border-radius: 12px;
  overflow: hidden;
  border: 1px solid var(--panel-border);
}
#video-player,
#video-player2 {
  display: block;
  width: 100%;
  height: 100%;
  /* Container is now sized by the flex row (fills the screen), not by
     the video's own aspect ratio — contain it instead of letting the
     box stretch the picture, and keep the skeleton overlay's letterbox
     math (drawOverlay/computeContentRect) in sync with this. */
  object-fit: contain;
}
#video-overlay,
#video-overlay2 {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* Sits visually on top of the video to draw the skeleton, but must
     never intercept clicks — otherwise the native <video> controls
     (play, seek, fullscreen) underneath become unclickable. */
  pointer-events: none;
}
/* ---- Player row: 2D video (left) paired with a live joint-angle/speed
   chart (right) -- same card, shared toolbar below. Both panes fill the
   row's full (flex-grown) height so the two use all the room the
   collapsible header/sidebar leave behind. The right pane used to be an
   embedded 3D puppet preview (SPLYZA-Motion-style); user: "bên phải vẫn
   là 3d, mà giờ tôi không làm 3d nên thấy không cần thiết" -- replaced
   with the chart below, id/class names kept as "mini3d-slot" to avoid
   needless churn since it's still conceptually the same "right-hand
   slot in the player row". ---- */
.player-row {
  display: flex;
  gap: 10px;
}
.player-row .video-stage { flex: 1; min-width: 0; height: 100%; }
.mini3d-slot {
  flex: 1;
  min-width: 0;
  height: 100%;
  background: var(--panel);
  border-radius: 12px;
  overflow: hidden;
  border: 1px solid var(--panel-border);
  position: relative;
}
.live-chart-wrap {
  height: 100%;
  box-sizing: border-box;
  padding: 14px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
}
.live-chart-wrap.is-hidden { display: none; }
.live-chart-head {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 12px;
  flex-shrink: 0;
}
.live-chart-head-label {
  font-size: 12px;
  font-weight: 600;
  color: var(--text-dim);
  flex-shrink: 0;
}
#live-chart-joint-select { flex: 1; min-width: 0; }
/* No width/height override here (unlike the old fixed 120px tall version)
   -- the shared .rom-chart-svg rule's `width: 100%; aspect-ratio: 3 / 1`
   already fills this slot's actual width (user: "kích thước biểu đồ phải
   to lên chứ, vừa với giao diện" -- a one-time ~360px cap tried right after
   fixing the distortion bug left most of this slot's real estate empty,
   since the video viewer gives it the video's FULL height but the chart
   was still only ever as wide as that small cap allowed). `.live-chart-wrap`
   above already scrolls (`overflow-y: auto`) for the rare case a very wide
   slot's resulting height (2 stacked panes, "Both" is the default view)
   still exceeds it. */
/* Tracks videoEl.currentTime every frame (video_panel2.js's
   updateLiveChartPlayhead) -- separate from wireRomChart's own hover
   crosshair (.rom-chart-crosshair) so inspecting a past/future frame by
   hovering doesn't fight with "where the video actually is right now".
   Green (the app's existing .btn-primary green), not var(--accent) --
   the data line itself (buildRomPaneSvg's <path>) is already drawn in
   var(--accent) blue, so the playhead used to blend into it. */
.rom-chart-live-playhead { stroke: #007a5a; stroke-width: 1.5; }
.mini3d-placeholder {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 12px;
  text-align: center;
  font-size: 11px;
  color: var(--text-dim);
}
.mini3d-placeholder.is-hidden { display: none; }
.angle-readout {
  position: absolute;
  top: 8px;
  left: 8px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  pointer-events: none;
}
.angle-pill {
  background: rgba(0, 0, 0, 0.6);
  color: #fff;
  font-family: var(--mono);
  font-size: 11px;
  padding: 3px 8px;
  border-radius: 6px;
}
/* ROM (range-of-motion) report — an overlay WITHIN #video-viewer2 (its
   nearest positioned ancestor is .video-viewer, position:fixed), so it
   sits above the two video panes even in fullscreen mode (z-index 70),
   where a globally fixed .side-panel (z-index 50) would otherwise
   render underneath them. */
.rom-report-panel {
  position: absolute;
  inset: 0;
  z-index: 10;
  display: none;
  flex-direction: column;
  background: var(--panel);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
}
.rom-report-panel.is-open { display: flex; }
.rom-report-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 18px;
  border-bottom: 1px solid var(--panel-border);
  flex-shrink: 0;
}
.rom-report-header h3 { font-size: 14px; font-weight: 700; }
.rom-report-header button {
  border: none;
  background: none;
  font-size: 18px;
  line-height: 1;
  color: var(--text-dim);
  cursor: pointer;
  padding: 4px 8px;
}
.rom-report-header button:hover { color: var(--text); }
.rom-report-body {
  flex: 1;
  overflow-y: auto;
  padding: 14px 18px 24px;
}
.rom-report-intro {
  font-size: 11px;
  margin-bottom: 12px;
  max-width: 60em;
}
/* Lets the table scroll horizontally on its own on a narrow window
   instead of overflowing the whole panel -- there was no such wrapper
   before this redesign added an extra column. */
.rom-report-table-wrap {
  overflow-x: auto;
  border: 1px solid var(--panel-border);
  border-radius: 10px;
}
.rom-report-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
}
/* The actual measurements (not the joint name or the verdict badge,
   which already stand out their own way) are what a reader scans this
   table FOR -- user: "chữ số rõ ràng hơn" -- so each number column gets
   real weight instead of matching the table's own plain default. Min/max
   (nth-child 5/6) render as .rom-frame-link buttons, not plain text, so
   bolding the <td> itself does nothing there -- that button's own rule
   gets the same treatment separately below instead. */
.rom-report-table td:nth-child(4),
.rom-report-table td:nth-child(7) {
  font-weight: 700;
  color: var(--text);
}
.rom-report-table td:first-child { font-weight: 600; }
.rom-report-table th {
  text-align: left;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-dim);
  padding: 8px 10px;
  background: var(--surface-soft);
  border-bottom: 1px solid var(--panel-border);
  white-space: nowrap;
  position: sticky;
  top: 0;
}
.rom-report-table td {
  padding: 9px 10px;
  border-bottom: 1px solid var(--panel-border);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.rom-report-table tbody tr:last-child td { border-bottom: none; }
.rom-report-table tbody tr:hover { background: var(--surface-soft); }
/* The whole point of this pass: a warn/abnormal row has to pop out of a
   page of otherwise-normal rows without the reader checking every badge
   one by one (user: "cần làm dễ nhìn nhất") -- a left accent stripe +
   a faint full-row wash, same status colors the badge already uses, not
   a new meaning. Normal rows stay neutral on purpose so problem rows are
   the ones that stand out, not the other way around. */
.rom-report-table tr.is-rom-warn td { background: var(--warn-soft); }
.rom-report-table tr.is-rom-warn td:first-child { box-shadow: inset 3px 0 0 var(--warn); }
.rom-report-table tr.is-rom-abnormal td { background: var(--danger-soft); }
.rom-report-table tr.is-rom-abnormal td:first-child { box-shadow: inset 3px 0 0 var(--danger); }
.rom-report-table tr.is-rom-warn:hover td,
.rom-report-table tr.is-rom-abnormal:hover td { filter: brightness(0.97); }
.rom-range-bar { display: block; }
.rom-range-bar-track { stroke: var(--panel-border); stroke-width: 2; stroke-linecap: round; }
.rom-range-bar-band { fill: var(--active-soft); stroke: var(--active-border); stroke-width: 1; }
.rom-range-bar-whisker { stroke-width: 3; stroke-linecap: round; }
.rom-range-bar-dot { stroke: var(--panel); stroke-width: 2; }
.rom-range-bar.is-tier-good .rom-range-bar-whisker { stroke: var(--active); }
.rom-range-bar.is-tier-good .rom-range-bar-dot { fill: var(--active); }
.rom-range-bar.is-tier-warn .rom-range-bar-whisker { stroke: var(--warn); }
.rom-range-bar.is-tier-warn .rom-range-bar-dot { fill: var(--warn); }
.rom-range-bar.is-tier-critical .rom-range-bar-whisker { stroke: var(--danger); }
.rom-range-bar.is-tier-critical .rom-range-bar-dot { fill: var(--danger); }
.rom-range-bar.is-tier-insufficient .rom-range-bar-whisker { stroke: var(--text-dim); }
.rom-range-bar.is-tier-insufficient .rom-range-bar-dot { fill: var(--text-dim); }
.rom-frame-link {
  border: none;
  background: none;
  color: var(--accent);
  font-family: var(--mono);
  font-size: 13px;
  font-weight: 700;
  cursor: pointer;
  padding: 0;
  text-decoration: underline;
  text-underline-offset: 2px;
}
.rom-frame-link:hover { color: var(--active); }
/* Row-level "view this joint's chart" action (user, pointing at the
   table's own header row: "...ý là thêm cột ở đây, bấm là nhảy biểu đồ
   lên" -- originally scrolled to a matching inline card, then the user
   said "bấm View chart là lên popup, bỏ ảnh thu nhỏ ở dưới đi" and pasted
   the whole inline grid's content asking to remove it entirely, so this
   is now the ONLY way to reach a joint's chart, via #modal-chart-zoom). */
.rom-jump-chart-btn {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 5px 10px;
  border: 1px solid var(--accent-border);
  background: transparent;
  color: var(--accent);
  font-size: 11px;
  font-weight: 600;
  border-radius: 999px;
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.12s ease;
}
.rom-jump-chart-btn svg { width: 13px; height: 13px; flex-shrink: 0; }
.rom-jump-chart-btn:hover { background: var(--accent-soft); }
.dash-badge.is-status-rom-warn { background: var(--warn-soft); color: var(--warn); }
/* Score tile + Joint-overview summary card side by side (user: "nó nằm
   bên cạnh cái này" -- pointing at the score tile) -- a matched pair of
   equal-width cards instead of 2 different-width full-width blocks
   stacked on top of each other. Stacks on narrow screens, same
   breakpoint/pattern as .admin-card-grid-2. */
.rom-report-top-row {
  display: grid;
  grid-template-columns: 1fr;
  gap: 14px;
  margin: 4px 0 18px;
}
@media (min-width: 640px) {
  .rom-report-top-row { grid-template-columns: 1fr 1fr; }
}
/* ---- ROM score hero -- the single most important number in the report
   (user: "cần làm dễ nhìn nhất và cũng quan trọng nhất"), so it leads the
   panel as a full-width card with a radial gauge instead of the old
   small inline pill. Tier color (good/warn/critical) washes the whole
   card at low opacity -- the gauge ring carries the strong color, text
   stays on text tokens (dataviz: "text never wears the data color"). */
.rom-score-tile {
  display: flex;
  align-items: center;
  gap: 20px;
  padding: 16px 20px;
  border: 1px solid var(--panel-border);
  border-radius: 14px;
  background: var(--surface-soft);
}
.rom-score-tile.is-tier-good { background: var(--active-soft); border-color: var(--active-border); }
.rom-score-tile.is-tier-warn { background: var(--warn-soft); border-color: var(--warn-border); }
.rom-score-tile.is-tier-critical { background: var(--danger-soft); border-color: var(--danger-border); }
/* The verdict badge used to share the EXACT SAME soft tier color as the
   card it sits on (both .dash-badge.is-status-done and
   .rom-score-tile.is-tier-good resolve to var(--active-soft)) -- user:
   "màu gần giống nhau quá, có cái nào khác khác đi", pointing at this
   exact "Good" badge on a 95% card. Fine everywhere else .dash-badge is
   used (tables etc sit on a neutral background), but here the badge is
   the card's own headline verdict and needs to read as a distinct chip,
   not blend into its own backdrop -- solid tier color + white text
   instead of soft-on-soft, scoped to just this card. */
.rom-score-tile .dash-badge { color: #fff; }
.rom-score-tile .dash-badge.is-status-done { background: var(--active); }
.rom-score-tile .dash-badge.is-status-rom-warn { background: var(--warn); }
.rom-score-tile .dash-badge.is-status-error { background: var(--danger); }
.rom-score-gauge { flex-shrink: 0; }
.rom-score-gauge-track { fill: none; stroke: var(--panel-border); stroke-width: 8; }
.rom-score-gauge-fill { fill: none; stroke-width: 8; stroke-linecap: round; transition: stroke-dasharray 0.3s ease; }
.is-tier-good .rom-score-gauge-fill { stroke: var(--active); }
.is-tier-warn .rom-score-gauge-fill { stroke: var(--warn); }
.is-tier-critical .rom-score-gauge-fill { stroke: var(--danger); }
.rom-score-gauge-value {
  font-size: 26px;
  font-weight: 700;
  fill: var(--text);
  font-variant-numeric: proportional-nums;
}
/* Makes the ring itself an obvious click target (user: "bấm vào con số
   ...%... popup lên") -- a plain unstyled wrapper button around an SVG
   gives zero affordance on its own, so a hover state is added here
   rather than relying on the cursor alone. */
.rom-score-gauge-btn {
  border: none;
  background: none;
  padding: 0;
  border-radius: 50%;
  cursor: pointer;
  line-height: 0;
  transition: transform 0.12s ease;
}
.rom-score-gauge-btn:hover { transform: scale(1.05); }
.rom-score-meta { display: flex; flex-direction: column; gap: 6px; }
/* Joint-overview's compact summary card -- same padding/border/radius as
   .rom-score-tile so the pair reads as 2 cards of one kind sitting in
   .rom-report-top-row, not 2 unrelated components that happen to be
   side by side. Neutral background (not tier-tinted like the score
   tile) since there's no single "this card's own color" the way the
   score tile's one aggregate number has -- the worst JOINT'S tier is a
   property of one row, not of "overview" as a whole. */
.rom-overview-summary-card {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 16px 20px;
  border: 1px solid var(--panel-border);
  border-radius: 14px;
  background: var(--panel);
}
.rom-overview-summary-icon {
  flex-shrink: 0;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--surface-soft);
  color: var(--text-dim);
  display: flex;
  align-items: center;
  justify-content: center;
}
.rom-overview-summary-icon svg { width: 22px; height: 22px; }
.rom-overview-detail-btn { align-self: flex-start; margin-top: 4px; }
/* The popup opened by .rom-score-gauge-btn -- same stroke-dasharray ring
   technique as .rom-score-gauge, just bigger and with BOTH arcs (normal
   + abnormal) actually drawn instead of one arc over a plain gray track,
   so neither portion is left to be inferred (user: "có hiển thị được %
   không bình thường, có bình thường như biểu đồ tròn"). Always green
   (normal) vs red (abnormal) regardless of the tile's own good/warn/
   critical tier color -- this chart's whole point is the literal
   normal-vs-not split, not a restatement of the tier severity. */
.rom-score-pie-body { display: flex; flex-direction: column; align-items: center; gap: 20px; }
.rom-score-pie-abnormal { fill: none; stroke: var(--danger); stroke-width: 16; }
.rom-score-pie-normal { fill: none; stroke: var(--active); stroke-width: 16; }
.rom-score-pie-value {
  font-size: 32px;
  font-weight: 700;
  fill: var(--text);
  font-variant-numeric: proportional-nums;
}
.rom-score-pie-legend { display: flex; flex-direction: column; gap: 10px; width: 100%; }
.rom-score-pie-legend-item {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 13px;
}
.rom-score-pie-legend-swatch { width: 12px; height: 12px; border-radius: 3px; flex: none; }
.rom-score-pie-legend-swatch.is-normal { background: var(--active); }
.rom-score-pie-legend-swatch.is-abnormal { background: var(--danger); }
.rom-score-pie-legend-label { flex: 1; color: var(--text-dim); }
.rom-score-pie-legend-value { font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
.rom-score-headline {
  font-size: 14px;
  font-weight: 700;
  color: var(--text);
}
.rom-score-label { font-size: 12px; color: var(--text-dim); }
.rom-chart-section-title {
  font-size: 15px;
  font-weight: 700;
  margin: 24px 0 4px;
}
.rom-chart-caption {
  font-size: 11px;
  margin-bottom: 8px;
}
/* Explains the band/dot encoding visually (a swatch, not just the
   caption's prose) -- user: "UI xấu quá" on this whole section; relying
   on a sentence to explain what a color means is exactly the kind of
   thing a small legend exists to fix. Shown once for the whole grid,
   not repeated per card. */
.rom-chart-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  margin-bottom: 14px;
}
.rom-chart-legend-item {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 11px;
  color: var(--text-dim);
}
.rom-chart-legend-swatch { width: 12px; height: 12px; border-radius: 3px; flex: none; }
.rom-chart-legend-swatch.is-band { background: var(--active-soft); border: 1px solid var(--active-border); }
.rom-chart-legend-swatch.is-dot { background: var(--danger); border-radius: 50%; width: 8px; height: 8px; }
/* "Which joint needs attention" overview (user: "làm nhiều dạng" ->
   clarified as "thêm biểu đồ tổng quan so sánh các khớp") -- plain HTML
   bars, not SVG, so they reflow to any width instead of needing a fixed
   viewBox like the time-series charts below. Sorted worst-first by
   buildRomOverviewChart, color-coded with the same good/warn/critical
   tier tokens as everything else in this report (never a new color
   convention just for this one chart). */
.rom-overview-chart {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-bottom: 20px;
}
.rom-overview-row {
  display: grid;
  grid-template-columns: minmax(120px, 220px) 1fr 44px;
  align-items: center;
  gap: 12px;
}
.rom-overview-label {
  font-size: 13px;
  font-weight: 600;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.rom-overview-bar-track {
  height: 14px;
  border-radius: 999px;
  background: var(--surface-soft);
  overflow: hidden;
}
.rom-overview-bar-fill {
  height: 100%;
  border-radius: 999px;
  transition: width 0.3s ease;
}
.rom-overview-bar-fill.is-tier-good { background: var(--active); }
.rom-overview-bar-fill.is-tier-warn { background: var(--warn); }
.rom-overview-bar-fill.is-tier-critical { background: var(--danger); }
.rom-overview-bar-fill.is-tier-insufficient { background: var(--text-dim); }
.rom-overview-value {
  font-size: 14px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  text-align: right;
  color: var(--text);
}
@media (max-width: 560px) {
  .rom-overview-row { grid-template-columns: 1fr; gap: 4px; }
  .rom-overview-value { text-align: left; }
}
/* Bar/Pie tab toggle for the overview section (user, pasting the bar
   list's own rendered content back: "cái này có 1 tab biểu đồ tròn nữa
   nha") -- the title+caption and the tab buttons share one row so the
   tabs read as "views of the thing just described" rather than a
   separate control floating on its own line. */
.rom-overview-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}
.rom-overview-head .rom-chart-section-title,
.rom-overview-head .rom-chart-caption { margin: 0; }
.rom-overview-tabs {
  display: flex;
  gap: 4px;
  padding: 3px;
  background: var(--surface-soft);
  border-radius: 999px;
  flex-shrink: 0;
  margin-top: 2px;
}
.rom-overview-tab-btn {
  border: none;
  background: none;
  padding: 6px 14px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  color: var(--text-dim);
  cursor: pointer;
  transition: background 0.12s ease, color 0.12s ease;
}
.rom-overview-tab-btn.is-active { background: var(--panel); color: var(--text); box-shadow: 0 1px 2px rgba(20, 10, 25, 0.08); }
.rom-overview-panel { display: none; margin-top: 14px; }
.rom-overview-panel.is-active { display: block; }
/* Chart title shown above EACH tab's own content (user: "biểu đồ thì làm
   thiếu trục, thiếu tên biểu đồ" -- the tab button's own short "Bar"/
   "Pie" label is a control, not a chart title). */
.rom-overview-chart-title {
  font-size: 12px;
  font-weight: 700;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.02em;
  margin-bottom: 10px;
}
/* A real 0/50/100% scale (same complaint: "thiếu trục") -- lined up over
   the bar tracks via the exact same 3-column grid the rows themselves
   use, not a separately-positioned label that could drift out of
   alignment if the row grid ever changes. */
.rom-overview-axis-row {
  display: grid;
  grid-template-columns: minmax(120px, 220px) 1fr 44px;
  gap: 12px;
  margin-bottom: 6px;
}
.rom-overview-axis {
  display: flex;
  justify-content: space-between;
  font-size: 10px;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}
/* Pie view -- each slice = that joint's outOfRangePct as a SHARE of the
   total across all joints (not of 100%, since these per-joint
   percentages don't sum to a whole on their own) -- answers "which joint
   makes up the biggest piece of this clip's total abnormality", a
   different question than the bars' own "how far out of range is each
   joint on its own". Colored by joint IDENTITY now (the dataviz skill's
   validated categorical palette, assigned by index in categoricalColorFor
   -- see video_panel2.js), not by severity tier like the bars -- user:
   "cho màu khác nhau chứ": with most joints sharing one "good" severity,
   the old tier-colored pie read as nearly all one color, indistinguishable
   slice to slice without hovering. Colors are inline styles (computed per
   render), not CSS classes, same pattern this file already uses for
   computed bar widths. */
.rom-overview-pie-wrap {
  display: flex;
  align-items: center;
  gap: 24px;
  flex-wrap: wrap;
}
.rom-overview-pie { flex-shrink: 0; }
.rom-overview-pie-slice { fill: none; stroke-width: 20; }
.rom-overview-pie-legend { display: flex; flex-direction: column; gap: 8px; min-width: 180px; flex: 1; }
.rom-overview-pie-legend-item {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 12px;
}
.rom-overview-pie-legend-swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; }
.rom-overview-pie-legend-label { flex: 1; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rom-overview-pie-legend-value { font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
@media (max-width: 480px) {
  .rom-overview-pie-wrap { flex-direction: column; align-items: stretch; }
}
/* .rom-chart-card is now ONLY ever rendered inside #modal-chart-zoom
   (user: pasted the whole old inline grid's content and said "bỏ cái
   này đi, đưa hết vào khi view popup") -- openChartZoom in
   video_panel2.js is the one caller, so this card's own border/padding/
   background are reset flat here rather than keeping a "floating card"
   look that only made sense sitting in a grid alongside several others. */
.rom-chart-card { border: none; padding: 0; background: none; }
#modal-chart-zoom-body .rom-chart-pane + .rom-chart-pane { margin-top: 18px; }
#modal-chart-zoom-body .rom-chart-pane-label { font-size: 12px; }
/* No separate tick-label font-size override needed here -- SVG text set
   via CSS lives in the same viewBox-scaled coordinate space as the
   chart's lines/dots, so a wider box already scales the existing 7px
   tick text, 2px line stroke, and 2px dots up proportionally with it.
   No height override needed either (unlike the old fixed 280px) -- the
   shared .rom-chart-svg rule's aspect-ratio already gives this its own
   taller render simply by being wider (up to the modal's 960px cap, see
   .modal-dialog.is-wide) than the live mini chart slot. */
.rom-chart-pane + .rom-chart-pane { margin-top: 8px; }
.rom-chart-pane-label {
  font-size: 10px;
  font-weight: 600;
  color: var(--text-dim);
  margin-bottom: 2px;
}
.rom-chart-wrap { position: relative; }
/* `aspect-ratio` (not a fixed `height`), matching the SVG's own
   `viewBox="0 0 300 100"` (see rom_chart_panel.js's ROM_CHART_VB_W/H) --
   the browser then scales both axes by the SAME factor to fill the
   available width, so the line/dots/stroke-width/tick text never get
   non-uniformly stretched or squished regardless of this slot's actual
   width (user: "kích thước vẽ biểu đồ hơi xấu", confirmed by screenshotting
   the real SVG at several container widths -- a FIXED height here
   combined with the SVG's old `preserveAspectRatio="none"` attribute
   looked fine only by coincidence whenever a context's width happened to
   be close to 3x its height, and visibly fattened the line/squished the
   tick text/turned the round data-point dots into ellipses whenever it
   wasn't, worst on a narrow phone screen). Per-context sizing (how WIDE
   each context lets this render) now lives on each context's own
   selector instead (see `#mini3d-slot2 .rom-chart-svg`'s `max-width`
   above and `#modal-chart-zoom-body`'s width: min(960px, 100%) modal). */
.rom-chart-svg { width: 100%; aspect-ratio: 3 / 1; display: block; cursor: crosshair; }
.rom-chart-tick { font-size: 8px; fill: var(--text-dim); font-family: var(--mono); }
/* X-axis title text (user: "thiếu trục x và trục y, làm cho đàng hoàng
   đi") -- a bit dimmer/smaller than the numeric tick labels since it's
   read once per chart, not re-scanned per data point. */
.rom-chart-axis-title { font-size: 7px; fill: var(--text-dim); opacity: 0.8; }
/* Angle/Speed/Both view toggle (user: "đang có 2 biểu đồ, 3 chế độ view 1
   view 2, view cả 2") -- same small pill-tab look as
   .rom-overview-tabs/.rom-overview-tab-btn, reused here too instead of a
   third slightly-different tab style. */
.rom-chart-view-tabs {
  display: inline-flex;
  gap: 4px;
  padding: 3px;
  background: var(--surface-soft);
  border-radius: 999px;
  margin-bottom: 14px;
}
.rom-chart-view-btn {
  border: none;
  background: none;
  padding: 6px 14px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  color: var(--text-dim);
  cursor: pointer;
  transition: background 0.12s ease, color 0.12s ease;
}
.rom-chart-view-btn.is-active { background: var(--panel); color: var(--text); box-shadow: 0 1px 2px rgba(20, 10, 25, 0.08); }
.rom-chart-tooltip {
  position: absolute;
  background: rgba(0, 0, 0, 0.75);
  color: #fff;
  font-family: var(--mono);
  font-size: 10px;
  padding: 2px 6px;
  border-radius: 5px;
  pointer-events: none;
  white-space: nowrap;
  z-index: 1;
}
/* Generic admin text input (the admin search box's own class has a
   baked-in magnifier icon, wrong here) + the label-less input+button row
   the workspace-name card needs -- first real consumer is that card,
   but named generically since a second settings field is likely later. */
.admin-text-input {
  flex: 1;
  min-width: 0;
  font: inherit;
  font-size: 13px;
  padding: 9px 12px;
  border-radius: 8px;
  border: 1px solid var(--panel-border);
  background: var(--panel);
  color: var(--text);
}
.admin-text-input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.admin-inline-form {
  display: flex;
  align-items: center;
  gap: 10px;
  max-width: 480px;
}
.rom-patient-picker {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 10px;
}
.rom-patient-picker label { font-size: 12px; font-weight: 600; color: var(--text-dim); }
.rom-patient-picker .settings-select { max-width: 260px; }
#rom-patient-hint.is-hidden { display: none; }
/* Same per-element .is-hidden convention as #rom-patient-hint above --
   this codebase has no generic `.is-hidden { display:none }` utility
   rule, so these new elements (the regular-user ROM page, static/
   rom_editor.js's updateVisibility()) each need their own. Caught by
   actually looking at a screenshot -- the earlier computed-style check
   (classList.contains("is-hidden")) only proved the CLASS was being
   toggled, not that it had any visual effect; without these rules the
   table/save button stayed visible with no patient selected, and the
   "no patients yet" message never hid once one was. */
#rom-patient-hint3.is-hidden,
#rom-user-empty.is-hidden,
#rom-select-prompt3.is-hidden,
#rom-table-wrap3.is-hidden,
#rom-actions-wrap3.is-hidden { display: none; }
.rom-admin-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 12px;
  margin-top: 10px;
}
.rom-admin-table th {
  text-align: left;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-dim);
  padding: 6px 10px;
  border-bottom: 1px solid var(--panel-border);
}
.rom-admin-table td {
  padding: 6px 10px;
  border-bottom: 1px solid var(--panel-border);
}
.rom-admin-table input[type="number"] {
  width: 70px;
  padding: 4px 6px;
  border: 1px solid var(--panel-border);
  border-radius: 6px;
  background: var(--surface-soft);
  color: var(--text);
  font-family: var(--mono);
  font-size: 12px;
}
.rom-admin-table input.is-invalid { border-color: var(--danger); }
.rom-admin-actions {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 12px;
}
.player-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-top: 8px;
  flex-wrap: wrap;
}
.speed-control {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 11px;
  color: var(--text-dim);
  white-space: nowrap;
  margin-left: auto;
}
.speed-control select {
  background: var(--surface-soft);
  border: 1px solid var(--panel-border);
  border-radius: 8px;
  padding: 4px 8px;
  color: var(--text);
  font-size: 11px;
  font-family: inherit;
}
/* Joint-numbering overlay toggle (user: "ở FE có nút bật tắt xem đánh
   số" -- the JOINT_LABELS 1-17 debug numbering was always-on, unlisted
/* Shared compact toolbar-pill look, originally written for the
   camera-angle preset cycle button (now removed along with the joint-
   number toggle -- both only ever controlled the embedded 3D puppet
   preview, which is gone, see .mini3d-slot's own comment) -- still used
   by #btn-rom-report2. */
.camera-angle-btn {
  background: var(--surface-soft);
  border: 1px solid var(--panel-border);
  border-radius: 8px;
  padding: 4px 8px;
  color: var(--text);
  font-size: 11px;
  font-family: inherit;
  cursor: pointer;
  white-space: nowrap;
}
.camera-angle-btn:hover {
  border-color: var(--accent-border);
}

/* ---- Full-screen video viewer: "Xem lại + khung xương" as its own
   tab-like takeover — fills the main content area only (not the header
   or sidebar), so the sidebar nav stays reachable while reviewing. ---- */
.video-viewer {
  position: fixed;
  top: var(--header-h);
  left: var(--sidebar-w);
  right: 0;
  bottom: 0;
  z-index: 70;
  background: var(--bg);
  display: none;
  flex-direction: column;
  transition: top 0.18s ease, left 0.18s ease;
}
.video-viewer.is-open { display: flex; }
/* Fullscreen: the viewer takes the WHOLE viewport (not just the area
   left of the app header/sidebar), and the toolbar/status row is
   dropped too — per the request, this mode is meant to show only the
   two video panes, nothing else competing for space. The viewer's own
   slim title bar stays (it's the only way back out). */
.video-viewer.is-fullscreen { top: 0; left: 0; }
.video-viewer.is-fullscreen .player-toolbar,
.video-viewer.is-fullscreen #viewer-status {
  display: none;
}
/* No padding/gap around or between the two panes either — otherwise the
   app's page background (--bg) shows through as a light border/seam
   around what's meant to be an edge-to-edge, "just the two videos"
   view. (Black bars can still show up *inside* either pane — that's
   each video/canvas's own letterboxing where its content's aspect
   ratio doesn't exactly match the pane, not this outer gap.) */
.video-viewer.is-fullscreen .video-viewer-body { padding: 0; gap: 0; }
.video-viewer.is-fullscreen .player-row { gap: 0; }
/* The two panes still get a visible dividing/outer border (so it's
   clear where one ends and the other begins edge-to-edge) — just
   square instead of rounded, since rounded corners read oddly once a
   pane's own corner sits flush against the actual screen edge. */
.video-viewer.is-fullscreen .video-stage,
.video-viewer.is-fullscreen .mini3d-slot {
  border-radius: 0;
}
/* Slim strip, not a full header — the filename is a small corner label,
   not a headline; most of the screen goes to the two video panels. */
.video-viewer-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 6px 14px;
  border-bottom: 1px solid var(--panel-border);
  background: var(--panel);
  flex-shrink: 0;
}
.video-viewer-header h2 {
  font-size: 12px;
  font-weight: 600;
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}
.video-viewer-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 12px 16px 16px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  width: 100%;
  margin: 0 auto;
}
.player-row { flex: 1; min-height: 0; }

/* ---- Tablet (iPad 11" portrait ~834px, and narrower): the video and
   3D reconstruction stop competing for horizontal space and stack full
   width instead — each one gets the big main screen, not a squeezed
   half-width pane. ---- */
@media (max-width: 1024px) {
  .player-row { flex-direction: column; }
  /* flex:0 0 auto + height:auto, not the row layout's flex:1/height:100%
     — in column direction, a stretched/filled .video-stage would leave a
     black gap below the (aspect-ratio-limited) video content. Each pane
     gets its own independent sizing here instead (mini3d-slot: fixed
     vh; video-stage: driven by the video's natural aspect ratio). */
  .player-row .video-stage { width: 100%; flex: 0 0 auto; height: auto; }
  .mini3d-slot { width: 100%; height: 46vh; flex: 0 0 auto; }
  .video-viewer-header { padding: 14px 16px; }
  .video-viewer-body { padding: 14px 16px 20px; }
  #video-player,
  #video-player2 { height: auto; max-height: 46vh; }
  .player-toolbar { flex-direction: column; align-items: stretch; gap: 12px; }
}

@media (max-width: 900px) {
  /* Sized against #app-main's own box (100%), not 100vw — #app-main is
     already narrower than the viewport (offset by the sidebar), so a
     100vw-based width would overflow past it and get clipped.

     #pose-panel and #hud are both absolutely positioned (top-left /
     top-right respectively) so they never collide at desktop widths —
     but widening BOTH to ~100% here for a narrow screen put them at the
     same top:20px with no shared layout to keep them apart, so on a
     10" tablet portrait they'd render on top of one another (only
     "looked" stacked by luck of #hud's content happening to be shorter
     than #pose-panel's). Giving each an explicit vertical band —
     #pose-panel 0-52%, #hud 52-80%, #pos-log-wrap the rest at the
     bottom — makes non-overlap guaranteed regardless of content
     height, with each band scrolling internally if its content
     doesn't fit. */
  #hud {
    width: calc(100% - 40px);
    top: 52%;
    max-height: 28%;
    overflow-y: auto;
  }
  #pose-panel {
    /* NOT calc(100% - 40px) like #hud — .pose-view inside keeps its
       480:640 aspect-ratio, so stretching this panel to near-full
       viewport width blows the preview box up to 1000px+ tall on a
       tablet, which alone blows past max-height below and scrolls the
       "Gắn camera" button and stats out of sight with no visible
       scrollbar hint. Cap the panel's own width instead so the
       aspect-ratio box stays a sane size. */
    width: min(240px, calc(100% - 40px));
    max-height: 48%;
    overflow-y: auto;
  }
  #pos-log-wrap { width: calc(100% - 40px); max-width: none; }
  .brand-text h1 { font-size: 13px; }
  #version-label, #status { display: none; }

  /* Admin dashboard: search box stacks under the card title instead of
     squeezing into the same row as it. */
  .admin-card-head { flex-direction: column; align-items: stretch; }
  .admin-search { width: 100%; }
  .admin-card-head-actions { flex-direction: column; align-items: stretch; }
}

/* ---- Generic small popup dialog (workspace rename/create, the ROM
   chart zoom view) -- this app had no reusable centered-dialog pattern
   before this (the only prior full-screen overlay, #auth-gate below, is
   a fixed GATE, not a dismissible popup over the already-loaded app).
   Lower z-index than #auth-gate/#invite-gate (100) since those two are
   mutually exclusive with the whole app being usable at all, whereas a
   modal always opens ON TOP of already-visible app chrome. */
.modal-overlay {
  position: fixed;
  inset: 0;
  z-index: 90;
  background: rgba(10, 2, 12, 0.4);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
}
.modal-overlay.is-hidden { display: none; }
.modal-dialog {
  width: min(420px, 100%);
  max-height: calc(100vh - 48px);
  overflow-y: auto;
  /* Solid white, not var(--panel) (rgba(255,255,255,0.94) -- slightly
     see-through) -- user: "màu nền trắng đục cho tôi, để cho tôi dễ xem"
     -- a popup sitting over a blurred backdrop benefits from a fully
     opaque surface more than a card embedded in the page flow does. */
  background: #fff;
  border-radius: var(--radius-card);
  padding: 22px 24px;
  box-shadow: 0 20px 60px rgba(10, 2, 12, 0.35);
}
/* The chart-zoom dialog needs real room for 2 much bigger panes --
   everything else here is a short name-entry form. */
.modal-dialog.is-wide { width: min(960px, 100%); }
.modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 16px;
}
.modal-title { font-size: 16px; font-weight: 700; color: var(--text); }
.modal-close-btn {
  border: none;
  background: transparent;
  color: var(--text-dim);
  font-size: 20px;
  cursor: pointer;
  line-height: 1;
  border-radius: 50%;
  padding: 10px;
  margin: -10px;
  flex-shrink: 0;
  transition: background 0.12s ease, color 0.12s ease;
}
.modal-close-btn:hover { background: var(--surface-soft); color: var(--text); }
.modal-body { display: flex; flex-direction: column; gap: 12px; }
.modal-actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  margin-top: 18px;
}

/* ---- Auth gate ---- */
/* Backdrop tinted with the rail's own aubergine (not a generic near-
   black) -- the sign-in screen is a visitor's very first impression of
   the app's own brand color, same reasoning Slack's own sign-in page
   uses its brand hue behind the card rather than plain black. */
#auth-gate, #invite-gate {
  position: fixed;
  inset: 0;
  z-index: 100;
  background: radial-gradient(ellipse at top, #4a1550 0%, #1c0620 70%);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  display: flex;
  align-items: center;
  justify-content: center;
}
#auth-gate.is-hidden { display: none; }
.auth-card {
  width: min(380px, 92vw);
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 16px;
  padding: 32px 28px;
  display: flex;
  flex-direction: column;
  gap: 16px;
  box-shadow: 0 20px 60px rgba(10, 2, 12, 0.45);
}
/* Workspace mark above the heading -- the same thing Slack's own
   sign-in page leads with (a workspace/app icon), not just a bare
   form dropped on the page. */
.auth-mark {
  width: 44px;
  height: 44px;
  border-radius: 11px;
  background: var(--rail-bg);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  font-weight: 700;
  margin-bottom: 4px;
}
.auth-card h2 { font-size: 21px; font-weight: 700; letter-spacing: -0.01em; }
.auth-card p.auth-sub { font-size: 13px; color: var(--text-dim); margin-top: -10px; }
.auth-field { display: flex; flex-direction: column; gap: 6px; }
.auth-field label { font-size: 12px; font-weight: 600; color: var(--text); }
.auth-field input {
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 8px;
  padding: 11px 12px;
  color: var(--text);
  font-size: 14px;
  font-family: inherit;
  transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.auth-field input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
/* Layout overrides only (bigger radius/padding/text than the base
   .btn-primary scale, to match this card's own inputs) -- the actual
   color/hover/active values come from .btn-primary itself, applied
   alongside this class in the HTML, instead of a second copy of the
   same 3 color values. */
.auth-submit { border-radius: 8px; padding: 11px; font-size: 14px; }
/* No account-creation link anymore (self-registration removed -- user:
   "người dùng không thể tạo tài khoản") -- plain informational text,
   not a button. */
.auth-switch { font-size: 13px; color: var(--text-dim); text-align: center; line-height: 1.5; }
#auth-error { font-size: 12px; color: var(--danger); min-height: 1.2em; }
#invite-gate.is-hidden { display: none; }
#invite-error { font-size: 12px; color: var(--danger); min-height: 1.2em; }
#invite-invalid.is-hidden, #invite-loading.is-hidden { display: none; }

/* ---- Bottom-of-rail account chip + Slack-style account dropdown ----
   Real Slack puts the signed-in account's avatar at the BOTTOM of its
   leftmost icon rail, not a top bar (user: "đã nói giao diện như slack
   nhưng biểu tượng user đặt sai vị trí rồi" -- correcting an earlier,
   wrong guess that put it in the top-right header). Styled like a
   nav-item (full-width, same padding/hover) since it visually lives in
   that same rail. */
#user-chip { display: none; }
#user-chip.is-visible { display: block; }
.sidebar-user-trigger {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 8px 10px;
  border: none;
  border-radius: 6px;
  background: transparent;
  cursor: pointer;
  font: inherit;
  text-align: left;
}
.sidebar-user-trigger:hover, .sidebar-user-trigger[aria-expanded="true"] { background: var(--rail-hover-bg); }
.user-chip-avatar {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.18);
  color: var(--rail-text);
  font-size: 12px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
  text-transform: uppercase;
  flex-shrink: 0;
}
.sidebar-user-text {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
  align-items: flex-start;
}
.sidebar-user-name {
  font-size: 13px;
  font-weight: 600;
  color: var(--rail-text);
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#user-chip .role-badge {
  font-size: 10px;
  font-weight: 700;
  padding: 1px 7px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.16);
  color: var(--rail-text-dim);
}
.user-chip-caret {
  width: 14px;
  height: 14px;
  color: var(--rail-text-dim);
  flex-shrink: 0;
  transition: transform 0.15s ease;
}
.sidebar-user-trigger[aria-expanded="true"] .user-chip-caret { transform: rotate(180deg); }
/* The menu itself sits on the light canvas surface, not the dark rail --
   same as Slack's own account menu, which is a plain white popover even
   though it's triggered from the dark sidebar. position:fixed with
   JS-computed left/bottom (see auth.js's openUserMenu) -- opens UPWARD
   since the trigger sits at the bottom of the screen, and a fixed box
   isn't clipped by an ancestor's overflow since its containing block is
   the viewport. */
.user-menu {
  display: none;
  position: fixed;
  width: 240px;
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 10px;
  box-shadow: 0 -12px 28px rgba(10, 20, 40, 0.22);
  padding: 6px;
  z-index: 80;
}
.user-menu.is-open { display: block; }
.user-menu-header {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 8px;
}
.user-menu-avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: var(--accent-soft);
  color: var(--accent);
  font-size: 14px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
  text-transform: uppercase;
  flex-shrink: 0;
}
.user-menu-id { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.user-menu-name {
  font-size: 13px;
  font-weight: 700;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.user-menu-workspace { font-size: 11px; color: var(--text-dim); }
.user-menu-divider { height: 1px; background: var(--panel-border); margin: 4px 4px; }
.user-menu-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  border: none;
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  text-align: left;
  padding: 9px 8px;
  border-radius: 6px;
  cursor: pointer;
}
.user-menu-item svg { width: 16px; height: 16px; flex-shrink: 0; color: var(--text-dim); }
.user-menu-item:hover { background: var(--surface-soft); }
.user-menu-item-danger { color: var(--danger); }
.user-menu-item-danger svg { color: var(--danger); }
.user-menu-item-danger:hover { background: var(--danger-soft); }
body.role-admin #user-menu-settings-group { display: none; }
/* ---- Dashboard tables ---- */
.dash-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 12px;
}
.dash-table th, .dash-table td {
  text-align: left;
  padding: 10px 10px;
  border-bottom: 1px solid var(--panel-border);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 220px;
}
.dash-table tbody tr:hover { background: var(--surface-soft); }
.dash-table tbody tr:last-child td { border-bottom: none; }
.dash-table th { color: var(--text-dim); font-weight: 600; text-transform: uppercase; font-size: 10px; letter-spacing: 0.02em; }
.dash-empty {
  font-size: 12px;
  color: var(--text-dim);
  text-align: center;
  padding: 28px 12px;
}
.dash-cell-user {
  display: flex;
  align-items: center;
  gap: 8px;
}
.dash-avatar {
  flex-shrink: 0;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--accent-soft);
  color: var(--accent);
  font-size: 11px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
  text-transform: uppercase;
}
.dash-badge {
  display: inline-block;
  padding: 3px 10px;
  border-radius: 999px;
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
}
.dash-badge.is-role-admin { background: var(--accent-soft); color: var(--accent); }
.dash-badge.is-role-owner { background: var(--active-soft); color: var(--active); }
.dash-badge.is-role-user { background: var(--surface-soft); color: var(--text-dim); }
.dash-badge.is-status-done { background: var(--active-soft); color: var(--active); }
.dash-badge.is-status-error { background: rgba(214, 69, 69, 0.12); color: var(--danger); }
.dash-badge.is-status-processing,
.dash-badge.is-status-queued { background: var(--accent-soft); color: var(--accent); }
.dash-note-cell { color: var(--text-dim); font-style: italic; }
.dash-note-cell:empty::before { content: "—"; font-style: normal; }
.dash-delete-btn {
  font-size: 10px;
  padding: 4px 10px;
  border-radius: 6px;
  border: 1px solid var(--danger);
  color: var(--danger);
  background: transparent;
  cursor: pointer;
}
.dash-delete-btn:hover { background: rgba(214, 69, 69, 0.1); }
/* Organizations table's 2 row actions (user: "danh sách workspace ở
   admin nên làm như thế nào, có thể có nhiều, combobox thì ko hợp lý
   đâu") -- same compact pill shape as .dash-delete-btn, accent-colored
   instead of danger since neither action is destructive. */
.dash-org-view-btn,
.dash-org-rename-btn {
  font-size: 10px;
  font-weight: 600;
  padding: 4px 10px;
  border-radius: 6px;
  border: 1px solid var(--accent-border);
  color: var(--accent);
  background: transparent;
  cursor: pointer;
  white-space: nowrap;
}
.dash-org-view-btn:hover,
.dash-org-rename-btn:hover { background: var(--accent-soft); }
.dash-org-view-btn { margin-right: 6px; }
