/* --------------------------------------------------- */
/*                CORE STYLESHEET                      */
/* --------------------------------------------------- */
/* Split into several files (issue: cold-load FOUC / phone CSS payload) -
   this file now holds only what's genuinely needed for first paint on
   EVERY load, phone included: global vars, the sign-in/username/
   access-blocked gate overlays, a handful of bare-element resets the
   phone-mounted Tasks workspace itself relies on (tasks/styles/*.css
   defines no bare button/a/img reset of its own), the spinning emblem
   (phone-reachable too, scripts/animations.js loads unconditionally), the
   fog-of-war RS-mode effect (its restore-on-load path isn't phone-gated),
   a couple of Settings-only rules still needed on phone, and the tasks
   workspace mount point itself. Everything else moved out to its own
   lazily-loaded (and in several cases phone-skipped) file:
     - styles/settingsDialog.css   - the Settings dialog
     - styles/iconManagerDialog.css - Icon Manager + Backup History + Link Stats
     - styles/desktopPanels.css    - Quick Search + add-link/add-subcontainer
                                      forms + right-click menu + the desktop
                                      Tasks Overview side panel (all already
                                      backed by desktop-only JS, so this file
                                      is also phone-skipped entirely)
     - styles/desktopChrome.css    - the icon grid, header, page tabs, corner-
                                      button toolbar/action-bar, Notes widget,
                                      and their supporting effects - every
                                      selector in it is backed by a desktop-
                                      only script or a permanently-hidden
                                      ancestor on phone, so like
                                      desktopPanels.css this one is also
                                      phone-skipped entirely, not just deferred
     - styles/auroraTheme.css      - the opt-in Aurora flat-design theme (on-demand)
     - styles/rsMode.css           - the RS-mode easter egg (on-demand)
   None of the above is duplicated here - if you're looking for Settings/
   Icon Manager/desktop chrome/Aurora/RS-mode CSS, it lives in those files
   now. */
/* --------------------------------------------------- */
/*                       IMPORTS                       */
/* --------------------------------------------------- */
/* @font-face, the icon-centering rule, the ligature-disabling rule, the h2
   rule, and ::-webkit-scrollbar-thumb/:hover now live in
   shared/styles/base.css (linked in index.html ahead of this file) — they
   were byte-for-byte identical to the tasks page's own copies (or only
   differed by an accidental drift, like the tasks page's font-weight),
   so keeping two hand-maintained copies in sync was pure risk for no
   benefit. */
/* --------------------------------------------------- */
/*                      SCROLL BAR                     */
/* --------------------------------------------------- */
::-webkit-scrollbar {
  width: 6px;
}
::-webkit-scrollbar-track {
  background: var(--main-theme);
  border-radius: 5px;
}
/* Firefox has no ::-webkit-scrollbar* equivalent — without these, it falls
   back to its own native (wide, unthemed) scrollbar, which is why the same
   element can look totally different hovered vs not: that's just Firefox's
   own OS-native hover shading on a widget we never styled. scrollbar-color
   gives Firefox the same thumb/track colors as the -webkit- rules above, so
   its native hover shading starts from our theme instead of the OS default;
   scrollbar-width: thin keeps its (otherwise much wider) native scrollbar
   closer to the 6px -webkit- one so the two engines reserve similar gutters. */
* {
  scrollbar-width: thin;
  scrollbar-color: var(--font-color) var(--main-theme);
}
/* --------------------------------------------------- */
/*                       GENERAL                       */
/* --------------------------------------------------- */
:root {
  /* Phone mode only (issue #184's roadmap) - shared between this file (the
     .tasks-workspace-mount padding that reserves room for these) and
     tasks/styles/general.css (the toolbars' own height), since a custom
     property's VALUE resolves at compute time regardless of which
     stylesheet references it, unlike a plain constant. */
  --phone-toolbar-height: 53px;
  --main-theme: #202020;
  --main-theme-lighter: #2b2b2b;
  --main-theme-darker: #131313;
  --main-theme-darkest: #000000;
  --input-color: #3f3f3f;
  --secondary-color: #5aaafa;
  /* Kept in sync with --secondary-color by updateLinkUsageBadgeTextColor
     (general.js) whenever it changes (page load, live color-picker input,
     Reset Settings) — this default matches what that same computation
     already picks for --secondary-color's own default above, so there's no
     mismatch before the very first script runs. */
  --link-usage-badge-text: #000000;
  /* Same reasoning/default as --link-usage-badge-text just above, kept in
     sync by updateAccentTabActiveTextColor (general.js) instead - every
     segmented tab row's own active button (.settings-tab-btn.active,
     .link-stats-tab.active, styles.css) uses --secondary-color as its
     background, one shared rule set for both. */
  --accent-tab-active-text: #000000;
  --font-color: #bababa;
  --font-color-clear: #d0d0d0;
  --highlight: #cfcf00;
  --cursor-radius: 120px;
  --cursor-pointer: pointer;
  --cursor-text: text;
  --cursor-auto: auto;
  --ui-color-scheme: dark;
  /* The OTHER theme's --main-theme-lighter — e.g. for the theme-toggle icon,
     so it can preview the color of the theme a click would switch to. */
  --main-theme-lighter-inverse: #c4c4c4;
  --hover-text-outline:
    -1px -1px 0 var(--main-theme-darkest), 1px -1px 0 var(--main-theme-darkest),
    -1px 1px 0 var(--main-theme-darkest), 1px 1px 0 var(--main-theme-darkest);
  /* Task card look, mirrored from the tasks page's own tasks/styles/general.css
     so .task-card-container (tasks/styles/taskCard.css, reused verbatim
     below) renders identically inside a homepage tasks overview container. */
  --card-theme: #323232;
  --card-theme-darker: #282828;
  --priority-one: #ff1414;
  --priority-two: #e5a101;
  /* Also mirrored from tasks/styles/general.css — needed by the homepage's
     read-task panel and card favorite-glow (shared/styles/readTaskPanel.css,
     shared/styles/taskCardActions.css). */
  --grid-color: #3f3f3f;
  --font-color-weak: #686868;
  --task-done: #2edb51;
  /* Sits between --main-theme-darker and --main-theme — a table header
     (e.g. .history-table thead) needs to read as its own distinct band
     rather than blending into the darker body/wrapper behind it. */
  --table-header-bg: #1a1a1a;
  /* Shared sidebar/emblem-panel sizing (styles.css, further down) — lives on
     :root rather than scoped to .emblem-sidebar-group-left itself (where it
     originally was) because .spinner-sidebar-alt-hint needs it too, and that
     hint is a sibling of the group in the DOM (index.html), not a
     descendant — a custom property only inherits down into descendants, so
     scoping it to the group would leave the hint with nothing to read. */
  --sidebar-item-width: 64px;
}
/* --------------------------------------------------- */
/*                        ICONS                        */
/* --------------------------------------------------- */
/* The i.fa-solid/etc icon-centering rule now lives in shared/styles/base.css
   (identical fix, same reasoning — FontAwesome's ::before sits flush against
   the bottom of its <i>, so this recenters it) — see that file's own comment
   for the full writeup, including why the `i` type selector matters for
   .icon-manager-color-toggle-btn .icon-manager-color-disabled-badge further
   down this file. */
/* Light theme: same variables, inverted lightness relationships (each
   dark-theme surface's role — base/lighter/darker/input — keeps the same
   relative contrast to its neighbors, just mirrored towards white instead
   of black), so nothing that depends on these tokens needs its own
   light-mode override. --secondary-color is left alone — it's the user's
   own chosen accent color, independent of theme. */
:root.light-theme {
  --main-theme: #a8a8a8;
  --main-theme-lighter: #c4c4c4;
  --main-theme-darker: #747474;
  --main-theme-darkest: #2e2e2e;
  --input-color: #cbcbcb;
  --font-color: #333333;
  --font-color-clear: #111111;
  --highlight: #d38200;
  --ui-color-scheme: light;
  --main-theme-lighter-inverse: #2b2b2b;
  /* Same lighter-towards-white relationship as --main-theme/--main-theme-lighter
     above — priority colors are left as-is, same as --secondary-color, since
     they're semantic status accents rather than theme surfaces. */
  --card-theme: #c4c4c4;
  --card-theme-darker: #a8a8a8;
  /* --task-done is a semantic status accent, left unchanged like
     --priority-one/two; --grid-color/--font-color-weak mirror the same
     lighter-towards-white relationship as the rest of this theme. */
  --grid-color: #b0b0b0;
  --font-color-weak: #767676;
  /* Same "between darker and base" relationship as the dark theme's own. */
  --table-header-bg: #8e8e8e;
}
/* The margin/padding/font-family/box-sizing reset now lives in
   shared/styles/base.css as its own *:not(dialog) rule — this page has
   nothing to add on top of it (unlike the tasks page, which layers its own
   filter: contrast(1) onto the same selector, general.css). */
*:not(input) {
  user-select: none;
}
/* The ligature-disabling input/textarea/select rule now lives in
   shared/styles/base.css — same fix, same reasoning, byte-identical to the
   tasks page's own copy. */
/* html/body themselves never scroll at all - .body-container (further
   down) is the real scrolling element now, with its own overflow-y:
   overlay so its scrollbar paints without reserving any layout space to
   begin with (Chromium; see that rule's own comment for the Firefox
   fallback). That's what actually solves the width jump switching pages
   used to cause here: with html/body permanently capped at 100vh/100%
   and never scrollable, whether the CONTENT inside them overflows or not
   is moot - there's nothing left at this level to reserve room for. */
html {
  overflow: hidden;
}
body {
  height: 100vh;
  display: flex;
  align-items: center;
  flex-direction: column;
  overflow: hidden;

  /* Falls back to this solid color for a guest/non-RS-mode-allowed visitor
     (fetchRsAssetBlobUrl resolves null, applyRsGatedMainPageAssets never
     sets background-image at all) — was a plain CSS url() here before this
     background moved behind the gated endpoint (shared/scripts/rsAssets.js,
     scripts/init.js's applyRsGatedMainPageAssets). */
  background-color: var(--main-theme-darker);
  /* 100% 100% stretched to match the viewport's exact aspect ratio,
     distorting/squashing the map on any window that isn't that same ratio —
     cover scales it proportionally instead, cropping overflow rather than
     warping it. */
  background-size: cover;
  background-position: center;
  background-attachment: fixed;
  background-repeat: no-repeat;
  color: var(--font-color);
  cursor: var(--cursor-auto);

  font-size: 14px;
  font-family: "Fira Code", monospace;
}
/* Whole-page fade as an opaque curtain on top of everything, instead of
   fading body's own opacity — fading body would multiply into every child's
   own opacity (the container spawn animation, header fade-in, etc.), muting
   them while the curtain is still lifting. This also hides the background
   image for free while covering, with no separate visibility timing needed
   for it. "page-loaded" is added by init.js a frame after load (so the
   transition actually has an opacity:1 starting point to fade from), and
   removed just before navigating to the tasks page so the curtain drops
   again before leaving. */
.page-fade-overlay {
  position: fixed;
  inset: 0;
  z-index: 9999;
  background-color: var(--main-theme-darker);
  opacity: 1;
  transition: opacity 250ms ease;
}
body.page-loaded .page-fade-overlay {
  opacity: 0;
  pointer-events: none;
}
/* Persistent reminder while an admin is viewing another GitHub account's
   profile (updateViewingAsOtherUserBanner, scripts/init.js -
   isBrowsingAsOtherUser, scripts/linkIcons.js). pointer-events: none - purely
   informational, deliberately never blocks clicking whatever's underneath it
   (the header/page-tabs-bar/phone toolbars all live at this same top edge)
   - the actual protection is queueServerPush/workerFetch's own write guards
   (shared/scripts/taskStorage.js, scripts/workerAuth.js), this is only here
   so the mode itself is never missed. Same z-index tier as .page-fade-overlay
   - the two are never actually relevant at the same moment in practice (this
   only ever shows once a real account/admin state is already known, well
   after that overlay's own load-time fade is long done), but keeping it this
   high means a native <dialog>'s own top-layer promotion is the only thing
   that can still cover it, not a plain z-index fight with anything else on
   the page. */
.viewing-as-other-user-banner {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 9999;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 6px 16px;
  background-color: #b34700;
  color: #fff;
  font-size: 12px;
  font-weight: bold;
  text-align: center;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
  pointer-events: none;
}
/* Blocks everything until a username is picked (see showUsernameGatePanel,
   init.js) — above .page-fade-overlay's own z-index so it's never covered
   while that's still fading out. No "closed" transition needed: the only
   way out is Continue, which reloads the page outright. */
.username-gate-overlay,
.signin-gate-overlay,
.access-blocked-overlay {
  position: fixed;
  inset: 0;
  z-index: 10000;
  display: none;
  align-items: center;
  justify-content: center;
  background-color: rgba(0, 0, 0, 0.75);
}
.username-gate-overlay.visible,
.signin-gate-overlay.visible,
.access-blocked-overlay.visible {
  display: flex;
}
.username-gate-panel,
.signin-gate-panel,
.access-blocked-panel {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  min-width: 350px;
  max-width: 600px;
  padding: 28px 32px;
  background-color: var(--main-theme);
  border: 1px solid var(--secondary-color);
  border-radius: 10px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
  overflow-y: auto;
  animation: username-gate-bounce-in 0.8s both;
}
/* A dropped ball's motion: accelerating (ease-in) on every fall, decelerating
   (ease-out) on every rise, each bounce landing lower than the last until it
   settles at rest — set per-keyframe via animation-timing-function so each
   segment gets its own physically-correct easing instead of one blended curve. */
@keyframes username-gate-bounce-in {
  0% {
    transform: translateY(-360px);
    opacity: 0;
    animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);
  }
  38% {
    opacity: 1;
    transform: translateY(0);
    animation-timing-function: cubic-bezier(0, 0.55, 0.45, 1);
  }
  55% {
    transform: translateY(-110px);
    animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);
  }
  70% {
    transform: translateY(0);
    animation-timing-function: cubic-bezier(0, 0.55, 0.45, 1);
  }
  82% {
    transform: translateY(-40px);
    animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);
  }
  91% {
    transform: translateY(0);
    animation-timing-function: cubic-bezier(0, 0.55, 0.45, 1);
  }
  96% {
    transform: translateY(-12px);
    animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);
  }
  100% {
    transform: translateY(0);
  }
}
.username-gate-message,
.signin-gate-message,
.access-blocked-message {
  /* Matches .quick-add-heading (shared/styles/quickAdd.css) — same header
     look wherever this app shows one. */
  margin: 0;
  font-size: 1.1em;
  color: var(--font-color-clear);
  text-align: center;
}
/* Deliberately understated (small, dim, tight to the message above) — this
   is context for anyone curious, not something that needs to compete with
   the actual instruction for attention. */
.username-gate-subtext,
.signin-gate-subtext,
.access-blocked-subtext {
  margin-top: -8px;
  font-size: 11px;
  color: var(--font-color-clear);
  opacity: 0.8;
  text-align: center;
  background-color: var(--main-theme-darker);
  padding: 10px;
  border-radius: 10px;
}
.username-gate-input-wrapper {
  position: relative;
  width: 100%;
  display: flex;
  justify-content: center;
  gap: 1px;
}
.username-gate-input-wrapper > i {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-136px, -50%);
  color: var(--secondary-color);
  font-size: 14px;
  pointer-events: none;
}
.username-gate-input {
  min-width: 0;
  padding: 8px 10px 8px 34px;
  background-color: var(--input-color);
  border: none;
  border-bottom: 2px solid var(--secondary-color);
  border-radius: 4px;
  outline: none;
  color: var(--font-color-clear);
  font-size: 14px;
  font-family: inherit;
  text-align: center;
}
/* Opens the existing-usernames dropdown (see showUsernameGatePanel,
   init.js) — a separate button rather than the input field itself being
   the trigger, since the list always shows every username regardless of
   what's typed (no live filtering), so tying it to typing would be
   misleading. */
.username-gate-suggestions-btn {
  flex-shrink: 0;
  width: 34px;
  background-color: var(--input-color);
  border: none;
  border-bottom: 2px solid var(--secondary-color);
  border-radius: 4px;
  color: var(--secondary-color);
  font-size: 12px;
  cursor: var(--cursor-pointer);
}
.username-gate-suggestions-btn:hover {
  color: var(--highlight);
}
.username-gate-input.wrong {
  border-bottom-color: var(--highlight);
  animation: username-gate-shake 300ms ease;
}
@keyframes username-gate-shake {
  0%,
  100% {
    transform: translateX(0);
  }
  25% {
    transform: translateX(-6px);
  }
  75% {
    transform: translateX(6px);
  }
}
.username-gate-current {
  display: none;
  margin: -4px 0 0;
  font-size: 12px;
  color: var(--font-color-clear);
  opacity: 0.8;
  text-align: center;
}
.username-gate-current.visible {
  display: block;
}
.username-gate-current-value {
  color: var(--highlight);
}
.username-gate-hint {
  min-height: 14px;
  margin: -6px 0 0;
  font-size: 12px;
  color: var(--font-color-clear);
  opacity: 0.75;
  text-align: center;
}
/* The shared dropdown popup (shared/scripts/dropdownSelect.js) defaults to
   z-index: 9000 — enough to clear a normal dialog (999) but not the gate
   overlay itself (10000), which would otherwise paint over it and make the
   existing-usernames list unclickable. It's appended to <body> after the
   overlay (already present in the static markup), so it's reliably this
   element's later sibling whenever the gate is what opened it. */
.username-gate-overlay.visible ~ .custom-select-popup {
  z-index: 10001;
}
/* The one shared "modern button" look — solid --main-theme-lighter fill,
   thin --secondary-color border, small radius, brightness-up on hover —
   originally just the gate panels' own Continue/Cancel, now also every
   confirm-dialog button (Save/Cancel across every .confirm-dialog
   instance), replacing the older two-tone-border/heavy-shadow look the
   plain global `button` rule still gives everything else that hasn't
   opted into this. (.settings-button used to be in this list too — it now
   matches .icon-manager-save-btn/.history-save-btn instead, see that shared
   rule further down, so Settings' own buttons look like the rest of the
   app's own save actions rather than the gate panels'.) The border (added
   on top of the original borderless look) is what actually keeps these
   buttons visibly distinct from the dialog's own --main-theme background in
   dark mode — the two are close enough in lightness there that the plain
   global button's own drop shadow (rgba(0,0,0,0.5)) all but disappears
   against an already-dark background, even though that same shadow reads
   fine in light mode. --secondary-color reliably contrasts in both themes
   instead, same reasoning Icon Manager's own save button border already
   relies on. */
.username-gate-continue,
.signin-gate-continue,
.username-gate-cancel,
.button-cancel,
.button-save,
.access-blocked-signout-btn {
  background-color: var(--main-theme-lighter);
  border: 1px solid var(--secondary-color);
  border-radius: 6px;
  color: var(--font-color-clear);
  font-weight: bold;
  cursor: var(--cursor-pointer);
  font-family: inherit;
}
.username-gate-continue,
.signin-gate-continue,
.username-gate-cancel,
.access-blocked-signout-btn {
  padding: 8px 24px;
  font-size: 14px;
}
.button-cancel,
.button-save {
  padding: 8px 20px;
}
.username-gate-continue:hover,
.signin-gate-continue:hover,
.username-gate-cancel:hover,
.button-cancel:hover,
.button-save:hover,
.access-blocked-signout-btn:hover {
  filter: brightness(1.1);
}
/* Solid fill (not the outlined look every other button on this panel uses)
   - the actual primary action now: requesting access is the thing most
   people stuck on this screen actually want, ahead of signing out or
   reading the support page. Same solid --secondary-color/white-text
   convention already used for a primary filled action elsewhere in this
   app (e.g. a task card's own blue quick-action buttons,
   shared/styles/taskCardActions.css). */
.access-blocked-request-btn {
  background-color: var(--secondary-color);
  border: 1px solid var(--secondary-color);
  border-radius: 6px;
  padding: 8px 24px;
  color: #ffffff;
  font-weight: bold;
  font-size: 14px;
  font-family: inherit;
  cursor: var(--cursor-pointer);
}
.access-blocked-request-btn:hover:not(:disabled) {
  filter: brightness(1.1);
}
.access-blocked-request-btn:disabled {
  opacity: 0.6;
  cursor: default;
}
/* Send Access Request + Sign Out side by side, wrapping to stacked once the
   panel is too narrow to fit both at their natural width (flex-wrap, not a
   fixed breakpoint) - width: 100% so justify-content: center actually
   centers them against the whole panel's width, not just against each
   other's own combined width the way an unstretched flex item here would
   (.access-blocked-panel's own align-items: center only centers this row
   AS a box, it doesn't stretch it).
   row-reverse (not row), with .access-blocked-request-btn still first in
   DOM - which item gets grouped onto which wrapped line only ever follows
   DOM/order sequence regardless of row vs row-reverse, so wrapping the
   request button STILL lands on line 1 (top) as the first flex item, same
   as plain row would; row-reverse only changes placement WITHIN a single
   line, positioning that same first-in-sequence item at the main axis's
   END - the right edge in a row - so the two states end up as: stacked ->
   request on top, side by side -> request on the right, from one property
   with no separate breakpoint needed. */
.access-blocked-actions-row {
  display: flex;
  flex-direction: row-reverse;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px;
  width: 100%;
}
/* Sits right under the request button - empty/no layout footprint until a
   request is actually sent (textContent set only then, showAccessBlockedPanel,
   scripts/init.js), then reads as quiet supporting text, not a second
   competing instruction. */
.access-blocked-request-caption {
  margin: -6px 0 0;
  font-size: 11px;
  color: var(--font-color-clear);
  opacity: 0.8;
  text-align: center;
}
/* Deliberately understated (small, dim) - the secondary/fallback option now
   that Send Access Request (above) is the panel's own primary action.
   width: fit-content overrides the global `a` rule's own width: 100%
   (styles.css, meant for the homepage's icon links) - without it this anchor
   stretches to the full panel width and, since that same global rule also
   makes every `a` a flex container, text-align: center has no effect on a
   flex item's own content (that's justify-content's job, not text-align's) -
   shrinking the box back to its own text width is what actually lets
   .access-blocked-panel's align-items: center visually center it. */
.access-blocked-support-link {
  width: fit-content;
  color: var(--secondary-color);
  font-size: 12px;
  text-decoration: none;
  text-align: center;
}
.access-blocked-support-link:hover {
  text-decoration: underline;
}
.signin-gate-continue,
.username-gate-continue {
  display: flex;
  align-items: center;
  gap: 8px;
}
/* Continue always sits at the row's far right (flex-end below); Cancel —
   only present at all when this gate was opened voluntarily from Settings,
   see showUsernameGatePanel's cancelable option, init.js — pushes itself
   to the far left via its own auto right-margin instead of the row using
   space-between, so Continue stays pinned right even on the very first,
   non-cancelable load where Cancel is display:none and would otherwise
   leave Continue alone at the row's start. */
.username-gate-cancel {
  display: none;
  margin-right: 10px;
}
.username-gate-cancel.visible {
  display: block;
}
.username-gate-actions {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 60px;
  width: 100%;
}
.username-gate-account {
  margin: -4px 0 0;
  font-size: 11px;
  color: var(--font-color-clear);
  opacity: 0.8;
  text-align: center;
  background-color: var(--main-theme-darker);
  padding: 10px;
  border-radius: 10px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
}
/* One row each — the GitHub account line above, "Not you? Sign out" below —
   rather than one flat centered line now that there's an icon plus a
   highlighted value crowding the first row. */
.username-gate-account-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}
/* Same size/lift as .profile-settings-account's own GitHub icon (Settings'
   Profile screen) — one consistent "GitHub account" look everywhere the app
   shows one. */
.username-gate-account i {
  font-size: 1.6em;
  transform: translateY(-2px);
}
/* The actual GitHub login, standing out from the plain label text around it
   — same reasoning/color as .profile-settings-value (Settings' own Profile
   screen), just its own class since the two live in unrelated markup. */
.username-gate-account-value {
  color: var(--highlight);
}
/* Plain text, not a second interactive element next to .username-gate-signout
   right after it — this half of the old combined "Not you? Sign out" string
   is only ever context for that button, never clickable on its own. */
.username-gate-not-you {
  color: var(--secondary-color);
}
.username-gate-signout {
  background: none;
  border: none;
  padding: 0;
  color: var(--secondary-color);
  font-size: inherit;
  font-family: inherit;
  text-decoration: underline;
  cursor: var(--cursor-pointer);
}
.username-gate-signout:hover {
  color: var(--highlight);
}
/* Same shell/behavior as .username-gate-account's own "Not you? Sign out"
   row above — signing out of THIS app only, not GitHub itself, so switching
   which GitHub account is signed in needs its own separate escape hatch. */
.signin-gate-switch-account {
  margin: -4px 0 0;
  font-size: 11px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}
.signin-gate-wrong-account {
  color: var(--secondary-color);
  opacity: 0.8;
}
.signin-gate-switch-account-btn {
  background: none;
  border: none;
  padding: 0;
  color: var(--secondary-color);
  font-size: inherit;
  font-family: inherit;
  text-decoration: underline;
  cursor: var(--cursor-pointer);
}
.signin-gate-switch-account-btn:hover {
  color: var(--highlight);
}
/* Deliberately much quieter than .signin-gate-continue above (plain text,
   no border/background) — signing in is still the intended path, this is
   the escape hatch for someone who doesn't want the optional extras at all
   (see the "SIGN IN FOR MORE" section, CHROME_WEB_STORE_SUBMISSION.md). */
.signin-gate-skip {
  background: none;
  border: none;
  padding: 0;
  margin-top: 6px;
  color: var(--font-color);
  opacity: 0.7;
  font-size: 12px;
  font-family: inherit;
  text-decoration: underline;
  cursor: var(--cursor-pointer);
}
.signin-gate-skip:hover {
  opacity: 1;
  color: var(--secondary-color);
}
/* h2 and h2 > input now live in shared/styles/base.css — identical rule,
   byte-for-byte. That file's cursor fallback (var(--cursor-text, text)) is
   only actually needed on the tasks page, which has no dynamic custom-
   cursor system; here --cursor-text is always defined, so the fallback is
   simply unused. */
img {
  padding: 1px;
  width: 25px;
  height: 25px;
}
a {
  min-height: 25px;
  border-radius: 10px;
  width: 100%;
  text-decoration: none;
  font-size: 16px;
  font-weight: bold;
  display: flex;
  align-items: center;
  gap: 10px;

  transition: background-color 600ms;
}

button {
  border: none;
  border-left: 2px solid var(--secondary-color);
  border-right: 2px solid var(--secondary-color);
  border-radius: 10px;
  background-color: var(--main-theme-darker);
  color: var(--font-color);
  user-select: none;
  padding: 5px;
  cursor: var(--cursor-pointer);
  box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.5);
  transition: transform 200ms ease;
}
/* :not(.settings-tab-btn.active):not(.link-stats-tab.active) - same
   exception as :root.flat-design button:hover further down this file: an
   active segmented-tab button is deliberately meant to show NO hover
   feedback at all (already selected, nothing happens on a click), which
   this plain, unscoped button:hover doesn't know to respect on its own -
   without this it kept recoloring an active tab's text on hover regardless
   of that rule's own :not(.active) exclusion (styles.css), since this one
   is a completely separate selector with no awareness of it.
   :not(.hotkey-key-btn.capturing) - same reasoning again: a key-capture
   button mid-listen sets its own computed text color for contrast against
   its own --secondary-color background, which this rule would otherwise
   outrank on pure specificity while the pointer's still over the button.
   :not(.rs-skills-panel-tab.active):not(.rs-skills-rune-tile.selected):not(
   .rs-skills-panel-category-filter-btn.active):not(.template-cell-option.selected)
   - same reasoning again: this rule's specificity (button + several
   :not()s + :hover) outranks those tiles'/tabs' own single-class
   .active/.selected :hover overrides (styles.css/tasks/styles/
   taskTemplates.css) outright, so without excluding them here too they kept
   recoloring on hover exactly like an un-excluded active tab would.
   :not(.phone-edit-preview-tab.active) - same again: the phone Edit/
   Preview toggle (tasks/styles/editTask.css) is the exact same "segmented
   tab button" shape as .settings-tab-btn/.link-stats-tab, with the same
   "already selected, no hover feedback" expectation - left out of this
   list, it kept recoloring on hover exactly like those two used to before
   they were added here.
   :not(.profile-tab-btn.active) - same again: Settings > Profile's own
   admin-only Current User/Administration switch (settingsDialog.css) is
   the exact same segmented-tab shape as .settings-tab-btn/.link-stats-tab
   above, so it needs the identical exclusion for the identical reason. */
button:not(.settings-tab-btn.active):not(.link-stats-tab.active):not(
    .hotkey-key-btn.capturing
  ):not(.rs-skills-panel-tab.active):not(.rs-skills-rune-tile.selected):not(
    .rs-skills-panel-category-filter-btn.active
  ):not(.template-cell-option.selected):not(
    .phone-edit-preview-tab.active
  ):not(.profile-tab-btn.active):hover,
label:hover {
  cursor: var(--cursor-pointer);
  color: var(--highlight);
}
/* Keyboard-focus styling for buttons now lives in the project-wide
   shared/styles/focusVisible.css instead of being restated here. */

button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  pointer-events: none;
}

/* Every top-level "background" element adjustBackground (scripts/settings.js)
   blurs/dims behind an open dialog (Settings/Icon Manager/Backup History/the
   task create-edit panel alike, all funneling through that one function) -
   the transition/will-change live on this permanent marker class (added
   unconditionally, on every call, regardless of open/close state) rather
   than only alongside .blurred itself, specifically so BOTH directions
   (blur in on open, un-blur on close) animate smoothly instead of just one
   of them - a transition property only takes effect for a given style
   change if it's already present on the element at the moment that change
   happens, not just at some point during the same class toggle. will-change
   lets the browser pre-allocate the compositing layer this needs ahead of
   time - without it, adjustBackground's own filter toggle used to force
   that allocation AND the actual blur rasterization into the same
   synchronous frame, a real, reported few-millisecond freeze on every
   single dialog open regardless of that dialog's own content/complexity. */
.adjust-bg-blur-target {
  transition: filter 150ms ease;
  will-change: filter;
}
.adjust-bg-blur-target.blurred {
  filter: blur(5px);
}

/* --------------------------------------------------- */
/*                         MAIN                        */
/* --------------------------------------------------- */
/* Wraps .tasks-side-panel + .body-container as flex siblings (index.html) —
   takes over .body-container's own former slot as body's flex-column child,
   so it needs the same align-self:stretch (body's own align-items:center
   would otherwise shrink THIS wrapper to fit-content instead of spanning
   full width) and flex-grow (.body-container's own vertical fill-the-column
   behavior) that .body-container relied on before this wrapper existed. */
.body-layout {
  display: flex;
  align-self: stretch;
  flex-grow: 1;
  min-width: 0;
  /* A flex item's default min-height is "auto" (its content's own intrinsic
     minimum), not 0 - flex-grow alone doesn't override that. Without this,
     .tasks-workspace-mount's own natural content (specifically the kanban
     board's aspect-ratio/min-height-driven size, tasks/styles/kanban.css)
     could push this element - and the mount inside it - taller than the
     real viewport allows instead of being clamped to it, confirmed as the
     actual cause of a real, reported bug: filtering the board down to a
     related task's own relations (fewer visible cards) shrank this
     element's rendered height, un-filtering grew it back, each transition
     firing applyTasksWorkspaceScale's own ResizeObserver (scripts/
     tasksWorkspace.js) and briefly rescaling the whole board for no reason
     a real window resize should be the only thing to do. min-width: 0 right
     above already guards the same class of issue on the other axis. */
  min-height: 0;
  /* .body-container itself used to carry position:relative;z-index:2 as a
     DIRECT child of body, tied against .header-container's own z-index:2 by
     DOM order (later wins) — that's what let it paint above .header-container's
     own cursor-glow overlay (position:fixed, so its box can end up ANYWHERE
     on screen, unlike header-container's own static layout box) wherever the
     two visually overlapped. Now that .body-container is nested one level
     deeper inside this wrapper, THIS is what needs to carry that same
     stacking behavior instead — .tasks-side-panel's own cards were otherwise
     silently unclickable/unhoverable underneath that glow.
     5, not that same 2 — .tasks-side-panel/.link-stats-side-panel (both
     nested inside this wrapper, both position:fixed spanning the full
     100vh) need to beat .page-tabs-bar's own z-index:4 and
     .header-search-row('.header-search-row-standalone')'s z-index:3
     (HEADER WEB SEARCH/PAGE TABS sections, this file) too, not just
     .header-container's cursor-glow — a plain z-index:2 here lost that
     fight outright (4 and 3 both beat it on value alone, no DOM-order
     tie-break to fall back on), silently tucking either open panel's own
     top edge underneath both whenever they overlap on screen. */
  position: relative;
  z-index: 5;
  /* .tasks-side-panel is itself position:fixed (see below) — spanning the
     full page height from the very top, rather than starting below the
     header the way a normal flex sibling would — so it's no longer a flex
     participant here at all. This margin is what actually pushes this
     wrapper's own content aside as the panel opens/closes instead (toggled
     via .tasks-panel-open, see toggleTasksSidePanel, taskOverviewContainer.js).
     margin-RIGHT, not left — the panel itself now opens from the right edge.
     No transition here by default on purpose — .body-container is
     display:flex; flex-wrap:wrap, so animating the width this margin
     leaves it isn't a cheap compositor-only change like the panel's own
     transform-based slide below; it's a genuine reflow of every container
     in the grid, one that a smooth multi-frame margin transition forces to
     happen continuously for its whole duration. Snapping it instantly
     instead — timed by toggleTasksSidePanel (taskOverviewContainer.js) to
     happen while the panel already fully covers that space, either right
     as it starts closing or right after it finishes opening — collapses
     that down to a single reflow that's never actually visible, since the
     opaque panel is covering the affected area of the grid at that exact
     moment either way. (See .side-swap below for the one case that's
     actually cheap enough to animate smoothly instead.) */
  margin-right: 0;
  margin-left: 0;
}
/* Scoped to while the embedded workspace is open ONLY - .body-layout's own
   base rule above deliberately has NO margin transition normally (see its
   comment for why: .body-container's own flex-wrap reflow cost, snapped
   instead of animated on purpose). That reasoning doesn't apply here -
   .body-container is display:none the whole time the workspace is open, so
   there's nothing expensive reflowing underneath. What IS visible then is
   .tasks-workspace-mount reacting to setTasksSidePanelWidth's own shrink/
   restore (scripts/sidePanels.js, used while docking next to the tasks
   side panel, scripts/tasksWorkspace.js) - this keeps the margin-push in
   sync with that panel's own width transition (.tasks-side-panel, above)
   instead of the mount jumping to its new size instantly while the panel
   is still visibly mid-shrink. */
body.tasks-workspace-open .body-layout {
  transition: margin 250ms ease;
}
/* Fixed at its full 320px width always (not animated 0→320px like this used
   to be) — slid fully off past its own right edge by default via
   translateX(100%) instead, so opening/closing is a transform-only,
   compositor-driven animation (cheap, no layout cost of its own) rather
   than an animated width (which, box-model-wise, would be no cheaper than
   .body-layout's own margin used to be — see its comment, above, for why
   that's exactly what this whole change is trying to get away from).
   overflow:hidden keeps its content from spilling out while off-screen;
   pointer-events:none keeps it (and its cards) unclickable/untabbable
   while it's sitting off past the edge of the viewport rather than really
   gone. Fixed (not a flex sibling) so it spans the full page height
   starting from the very top, past .header-container's own space, rather
   than only the portion below it. */
.tasks-side-panel {
  position: fixed;
  top: 0;
  right: 0;
  height: 100vh;
  /* Comfortably above the right-side .homepage-action corner buttons
     (z-index:40) it otherwise overlaps while open (it starts flush at the
     very right/top edge, same as they do) — those shift left out of the way
     while it's open instead (see .tasks-panel-shift below), rather than
     staying covered underneath it. */
  z-index: 45;
  flex-shrink: 0;
  /* Overridable via setTasksSidePanelWidth (scripts/sidePanels.js) - the
     embedded tasks workspace shrinks this down (floor 200px) to make room
     for itself when docking alongside an open panel, rather than always
     assuming the default 320px. width transitions now too (didn't need to
     before this), so a shrink/restore animates instead of jumping. */
  width: var(--tasks-side-panel-width, 320px);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  background-color: var(--main-theme-darker);
  box-shadow: -4px 0 16px rgba(0, 0, 0, 0.5);
  transform: translateX(100%);
  transition:
    transform 175ms ease,
    width 250ms ease;
  pointer-events: none;
}
/* Moved here from styles/iconManagerDialog.css (that file's own "LINK
   STATS SIDE PANEL" section still has the rest of this panel's styling,
   with the full reasoning) - same "real, always-present markup needs its
   off-screen positioning in a guaranteed-first-loaded file" reasoning as
   .tasks-side-panel just above, but more pressing here: iconManagerDialog.css
   is BOTH lazily-loaded (media=print->all) AND phone-skipped entirely,
   unlike desktopChrome.css (deferred but never phone-skipped for content
   like this). Confirmed as a real, reported bug: this panel briefly
   rendered as an ordinary in-flow flex child of .body-layout - fully
   visible, taking up real layout space - before that file loaded and
   applied position:fixed/transform:translateX(100%) for the first time. */
.link-stats-side-panel {
  position: fixed;
  top: 0;
  right: 0;
  height: 100vh;
  z-index: 45;
  flex-shrink: 0;
  width: 620px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  background-color: var(--main-theme-darker);
  box-shadow: -4px 0 16px rgba(0, 0, 0, 0.5);
  transform: translateX(100%);
  transition: transform 175ms ease;
  pointer-events: none;
  color: var(--font-color-clear);
  font-size: 12px;
}
/* showTasksSidePanelLoadingPlaceholder's own placeholder (scripts/
   taskOverviewContainer.js) — .tasks-side-panel-body is a flex column with
   no align-items/justify-content of its own (its real content, the card
   sections, doesn't want any), so this wrapper centers the spinner itself
   rather than leaning on the parent for it. */
.tasks-side-panel-loading {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* Same ring look/colors/animation as .icon-manager-grid-spinner
   (styles/iconManagerDialog.css) and as tasks/styles/effects.css's own
   .tasks-cell-spinner — duplicated here rather than reused directly
   because neither of those is guaranteed loaded on the homepage this early
   (window.onload's own initial tasksPanelOpen restore, scripts/init.js),
   same reasoning .tasks-cell-spinner's own duplicated @keyframes already
   documents, just in the other direction. Reuses icon-fetch-spin directly
   though (not its own copy) — deliberately kept in THIS file (core,
   below), not the lazy-loaded dialog file it originated in
   (styles/desktopPanels.css's own "ADD LINK" section, before the CSS
   split) - .tasks-side-panel-spinner renders immediately on a normal page
   load, well before any deferred/phone-skipped file could plausibly have
   loaded yet, so the keyframe it depends on has to live somewhere always
   loaded first. */
.tasks-side-panel-spinner {
  width: 36px;
  height: 36px;
  border: 5px solid var(--main-theme-lighter);
  border-top-color: var(--secondary-color);
  border-radius: 50%;
  animation: icon-fetch-spin 700ms linear infinite;
}
/* Shared by several elements outside this file too - settingsDialog.css's/
   iconManagerDialog.css's own favicon-fetch/save spinners, .icon-fetch-spinner
   (styles/desktopPanels.css) - kept here (core, always loaded first) rather
   than in whichever lazy file happened to define it originally, so every
   reference resolves correctly regardless of load order between the split
   files. */
@keyframes icon-fetch-spin {
  to {
    transform: rotate(360deg);
  }
}
/* Same "shared across a split-file boundary" reasoning as icon-fetch-spin
   just above - styles/settingsDialog.css's own .update-banner-label reuses
   this, and styles/desktopPanels.css's own .update-link-title reuses the
   exact same pulse for an unrelated floating title, with no guaranteed
   load-order between those two independently-deferred files - kept here
   (core) instead of either. */
@keyframes update-banner-pulse {
  from {
    text-shadow:
      0 0 8px var(--secondary-color),
      0 0 20px var(--secondary-color);
  }
  to {
    text-shadow:
      0 0 12px var(--secondary-color),
      0 0 35px var(--secondary-color),
      0 0 60px var(--secondary-color);
  }
}
.fog-of-war:not(.easter-egg) {
  position: fixed;
  /* Deliberately overscanned 20px past every edge (confirmed via devtools
     that the un-overscanned box is already pixel-perfect against
     window.innerWidth/innerHeight - a prior 100vw/100vh-only version of
     this rule measured exactly right yet the map still visibly bled
     through a soft, gradually-fading few pixels at the very top/bottom of
     the screen, not a hard cutoff - that gradual fade rules out a coverage
     gap and points at the mask-image itself feathering/antialiasing right
     at this masked layer's own edge, a rendering artifact rather than a
     sizing bug). Pushing the whole layer's edge past the visible viewport
     means whatever feathering happens there is off-screen instead of in
     the visible top/bottom strip. mask-image (below) is left covering only
     the original, un-overscanned viewport rect it's already computed
     against (initEasterEggs.js's own gradient math uses window.innerWidth/
     innerHeight, unaffected by this) - the extra 20px margin only ever
     contains solid fog, never the mask's own reveal-hole. */
  top: -20px;
  left: -20px;
  width: calc(100vw + 40px);
  height: calc(100vh + 40px);
  background-color: inherit;
  pointer-events: none !important;
  transition:
    mask-image 0.1s,
    -webkit-mask-image 0.1s;
  z-index: 0;
}
/* Same "must live in the guaranteed-first-loaded file" reasoning as the
   emblem-sidebar-group-right/spinner-sidebar-alt-hint safety net elsewhere
   in this file - #left-torch/#right-torch are raw, always-present markup
   (index.html), with their real opacity:0 default (styles/desktopChrome.css
   - not this file) only taking effect once that lazily-loaded (media=
   print->all) stylesheet actually activates. Before that, with literally no
   opacity rule in effect anywhere, they sit at the browser's own initial
   value (1 = fully visible) - confirmed as a real, reported bug: both
   torches briefly visible in the header on a cold/slow load regardless of
   RS mode, for however long desktopChrome.css took to activate relative to
   .header-container's own reveal (scripts/init.js). No transition here (the
   real rule's own 500ms one is what actually animates a genuine RS-mode
   toggle) - this is only ever a same-value handoff once the real rule takes
   over, never a state a user should see change. */
#left-torch,
#right-torch {
  opacity: 0;
}
/* Same reasoning, one level up - .header-container's own real opacity:0
   default (its entrance fade, styles/desktopChrome.css) is what actually
   keeps EVERYTHING inside it - torches above, but also .date-time-container
   and anything else in the header - hidden until scripts/init.js's own
   deliberate reveal (the header-container/page-tabs-bar setTimeout, once
   the rest of window.onload's own setup has actually finished). Without
   this, before desktopChrome.css loads, .header-container sits at the
   browser's own initial opacity (1) regardless of that deliberate gate,
   confirmed as a real, reported bug: whatever's inside it at that moment -
   in particular .date-time-container with no border rule in effect yet
   either (that element's own default border ALSO only lives in
   desktopChrome.css, further down this file) - was briefly visible exactly
   as if it belonged to the finished, styled page, rather than staying
   covered until it actually is one. Not the full base rule (layout/sizing
   stays in desktopChrome.css, nothing to fix there - opacity:0 alone
   already hides all of it regardless of how unstyled the rest still is) -
   just enough to guarantee nothing paints before the deliberate reveal
   above ever runs, same "no transition, only a same-value handoff" as
   every other safety net on this page. */
.header-container {
  opacity: 0;
}
/* .date-time-container's own default border (its non-Aurora look) - kept
   here alongside .header-container's own safety net just above for the
   same reason: real, always-present markup, whose "what does it look like
   right now" answer must never depend on a lazily-loaded file. Aurora's own
   removal of this border (:root.flat-design *, styles/auroraTheme.css) is
   a separate, later concern - genuinely gated behind confirming a paid
   plan over the network, not something this safety net tries to solve -
   this only guarantees the NON-Aurora look (the correct one for anyone not
   on Aurora, and the correct FIRST-paint look even for an Aurora user,
   until that confirmation lands) is what's actually in effect from the very
   first frame, instead of no border rule existing at all yet (which reads
   as "as if Aurora" regardless of whether it actually is). */
.date-time-container {
  border-left: 2px solid var(--secondary-color);
  border-right: 2px solid var(--secondary-color);
  border-radius: 10px;
}
/* .page-tabs-bar's own real opacity:0 default (styles/desktopChrome.css,
   its own comment there: "Starts invisible, same as .header-container -
   both are faded to opacity: 1 together, in the same setTimeout") - same
   gap as .header-container above, same fix. Everything inside it - the
   "add page" button, collapse-all/show-click-counts, the tab list itself,
   and .header-search-row whenever it's nested here (its own normal, non-
   standalone placement) - rides along for free once this ancestor is
   correctly invisible from the first frame, without needing its own
   individual safety net; opacity doesn't cascade like a normal inherited
   CSS property, but an ancestor rendered at 0 still visually hides
   everything painted inside it regardless of that descendant's own
   opacity value. Confirmed as a real, reported bug: the "add page" button,
   the click-counts/collapse-all toggles, and the search input were all
   independently seen flashing visible before this file activated - all
   three are simply this one gap, not three separate ones. */
.page-tabs-bar {
  opacity: 0;
}
/* .header-search-row's OWN safety net, for the one case the fix above
   doesn't reach: below HEADER_SEARCH_STANDALONE_BREAKPOINT_PX
   (webSearch.js), this row is moved OUT of .page-tabs-bar to become its
   own sibling instead (updateHeaderSearchRowPlacement, that file) and
   picks up this modifier class - real, always-present markup gets a real
   DOM move (webSearch.js is desktop-only but not phone-skipped for this
   file's own loading), so it stops inheriting invisibility from
   .page-tabs-bar the moment it's no longer nested inside it. Its own real
   opacity:0 (desktopChrome.css) documents this exact same reasoning
   already ("needed only here, not on the plain .header-search-row rule
   above") - it just also needs to exist before that file has loaded, same
   as everything else on this page. */
.header-search-row.header-search-row-standalone {
  opacity: 0;
}
a,
button,
.link-container {
  cursor: var(--cursor-pointer);
}
textarea,
input[type="text"] {
  cursor: var(--cursor-text);
}

/* --------------------------------------------------- */
/*                        OFFICE                       */
/* --------------------------------------------------- */
.emblem {
  z-index: 1;
  /* 8x .emblem's own old base size (EMBLEM_SCALE_REFERENCE, scripts/
     animations.js) - every scale() below (and every one that file applies
     directly, e.g. tickEmblem's idle spin) divides by that same 8, so the
     FINAL on-screen size is unchanged for every value the "Größe" slider
     can be set to, but the transform actually needed to reach it is
     centered around 1x (2x at the slider's own max of 16) instead of 8x
     (16x at that same max). That's what actually fixes the pixelation
     below, not just will-change on its own - see that property's own
     comment for the full mechanism. */
  height: calc((12% - 40px) * 8);
  width: auto;
  position: fixed;
  left: 50%;
  top: 50%;
  opacity: 0.8;
  transform: translate(-50%, -50%) scale(0);
  /* Promotes this to its own GPU-composited layer up front, persistently -
     without it, a browser can (and in practice does, confirmed as a real,
     reported slowdown) fall back to re-rasterizing this element's actual
     pixel content on the main thread for every transform update
     (tickEmblem's own requestAnimationFrame loop, scripts/animations.js,
     driving rotate()/scale() continuously) instead of just recompositing
     an already-rasterized texture on the GPU. That re-rasterization cost
     scales with how complex the underlying SVG actually is - most emblem
     presets are a few dozen paths/gradients and the cost is easy to miss,
     but a few (the phoenix preset especially, 1000+ path/gradient
     elements) make it very noticeable, dragging down the whole page's
     frame budget, not just the emblem's own spin.
     A GPU-composited layer's own backing texture is rasterized ONCE, at
     roughly the element's own un-transformed CSS size, then just stretched
     for every later scale() - confirmed as a real, reported regression
     from adding this property on its own (before the height/--spinner-scale
     rework above): the OLD, much smaller base size meant even the
     slider's own default (a plain scale(8)) was stretching a small cached
     bitmap 8x, visibly pixelating detailed emblems. The height above is
     what actually keeps this crisp now - will-change alone doesn't. */
  will-change: transform;
}

.emblem.scale-up {
  animation:
    scaleUpAndRotate 500ms ease forwards,
    rotate360 var(--emblem-spin-duration, 16s) linear infinite 500ms;
}
.emblem.scale-down {
  animation: scaleDownAndRotate 300ms ease forwards;
}

/* Every rotate() below is calc(...deg * --emblem-spin-direction) rather
   than a plain positive value, so setEmblemSpinDirection
   (scripts/animations.js) can flip rotation to counter-clockwise by just
   setting that one CSS var to -1 — NOT via animation-direction:reverse,
   which used to handle this instead. That approach reversed the WHOLE
   keyframe, including scaleUpAndRotate/scaleDownAndRotate's own
   opacity/scale sweep (1→--emblem-opacity, scale 1→--spinner-scale, etc.) —
   direction was only ever supposed to affect rotation, so reversed spin
   direction was also silently scaling the page-load intro DOWN instead of
   up (and would have scaled the "hide containers" outro up instead of
   down). Multiplying just the rotation value instead leaves every other
   animated property in each of these keyframes completely unaffected,
   regardless of direction.
   Every scale(var(--spinner-scale, ...)) below defaults to 1, not 8 - the
   custom property itself is set pre-divided now (setSpinnerScale, scripts/
   animations.js), so this fallback (only ever used the rare moment a
   keyframe reads it before that first runs) has to match the same already-
   divided convention, not the raw slider value. */
@keyframes scaleUpAndRotate {
  from {
    opacity: 1;
    /* calc(1 / 8), not a plain 1 - matches .emblem's own 8x-bigger base
       size above, so the intro still visibly starts small and grows into
       place exactly as before instead of starting 8x too big. */
    transform: translate(-50%, -50%) scale(calc(1 / 8)) rotate(0deg);
  }
  to {
    opacity: var(--emblem-opacity, 0.4);
    transform: translate(-50%, -50%) scale(var(--spinner-scale, 1))
      rotate(calc(360deg * var(--emblem-spin-direction, 1)));
  }
}

@keyframes rotate360 {
  from {
    transform: translate(-50%, -50%) scale(var(--spinner-scale, 1)) rotate(0deg);
  }
  to {
    transform: translate(-50%, -50%) scale(var(--spinner-scale, 1))
      rotate(calc(360deg * var(--emblem-spin-direction, 1)));
  }
}
@keyframes scaleDownAndRotate {
  from {
    opacity: 0.8;
    transform: translate(-50%, -50%) scale(var(--spinner-scale, 1)) rotate(0deg);
  }
  to {
    opacity: 0.8;
    transform: translate(-50%, -50%) scale(0)
      rotate(calc(360deg * var(--emblem-spin-direction, 1)));
  }
}
@keyframes accelerateSpin {
  from {
    transform: translate(-50%, -50%) scale(var(--spinner-scale, 1)) rotate(0deg);
  }
  to {
    transform: translate(-50%, -50%) scale(var(--spinner-scale, 1))
      rotate(calc(2160deg * var(--emblem-spin-direction, 1)));
  }
}
.emblem.accelerating {
  animation: accelerateSpin 3s ease-in forwards;
}
.emblem.fast-spin {
  animation: rotate360 0.4s linear infinite;
}
@keyframes decelerateSpin {
  from {
    transform: translate(-50%, -50%) scale(var(--spinner-scale, 1)) rotate(0deg);
  }
  to {
    transform: translate(-50%, -50%) scale(var(--spinner-scale, 1))
      rotate(calc(1080deg * var(--emblem-spin-direction, 1)));
  }
}
.emblem.decelerating {
  animation: decelerateSpin 2s ease-out forwards;
}
.emblem.spinning {
  animation: rotate360 var(--emblem-spin-duration, 16s) linear infinite;
}
/* Same "must live in the guaranteed-first-loaded file" reasoning as
   icon-fetch-spin/update-banner-pulse above (MAIN section) - these two are
   raw <div>s in the initial HTML (index.html), present from the very first
   byte, with no inline hiding of their own - their entire default-hidden
   state (opacity:0/pointer-events:none, DIRECTLY on this same selector)
   normally comes from styles/settingsDialog.css's own base rules for them
   instead. That file is only reachable via the media="print"->"all"
   lazy-CSS trick (scripts/deferredStyles.js), so there's a real window on a
   slow/cold load where these can paint with their implicit (visible)
   defaults before that swap actually lands - confirmed as a real, reported
   bug: a visible fade the instant settingsDialog.css's own opacity:0 rule
   (which carries its own transition) finally takes over from that implicit
   1. Restated here with NO transition of its own, so opacity is already 0
   in both the "before" and "after" state once settingsDialog.css activates
   - no value change, so its own transition never actually triggers. Not
   their full position:fixed/etc. base rule - just enough to guarantee
   nothing paints or is clickable before the real rule (same selector,
   later-loaded file, naturally wins the cascade once active) takes over.
   .emblem-sidebar-group-left ITSELF is deliberately NOT included here -
   unlike these two, that element's own base/.visible pair (settingsDialog.css)
   never touches opacity on ITSELF at all (only background-color) - see
   that rule's own comment for why (its otherwise-invisible box would
   permanently eat hover/click underneath it without a permanent, non-
   toggled pointer-events: none, so ITS visibility instead comes entirely
   from its children's own opacity, a separate descendant selector). Adding
   opacity: 0 here for it too was tried and confirmed wrong - nothing in
   settingsDialog.css ever sets this exact element's own opacity back to 1,
   so Customize Emblem's whole left panel stayed invisible/unclickable
   forever, .visible or not. */
.emblem-sidebar-group-right,
.spinner-sidebar-alt-hint {
  opacity: 0;
  pointer-events: none;
}
/* .emblem-sidebar-group-left's own CHILDREN, unlike the group itself just
   above - confirmed as a real, reported bug of their own: still visible
   (their real hidden state, same selector, settingsDialog.css) for a beat
   on a slow/cold load, the same way the group-right/alt-hint elements
   above used to be. Deliberately the EXACT same selector as the real rule
   (not simplified to a blanket "> *") - a plain ">*" would ALSO catch
   .spinner-sidebar-leaf-wrapper (the slider/toggle COLUMN wrappers), which
   the real rule's own :not() exclusions never reset back to visible either
   (by design - see that rule's own comment) - hiding them here too would
   have reproduced the exact "nothing left to un-hide it" regression the
   group-left exclusion above already documents, just one level deeper. Kept
   in sync with settingsDialog.css's own selector on purpose; if that one
   ever changes, this one needs the same edit or this safety net silently
   stops covering what it's meant to. No transform here (unlike the real
   rule) - only opacity/pointer-events matter for "does this look wrong",
   translateX(400px) has no visible effect while opacity is already 0. */
.emblem-sidebar-group-left
  > *:not(.spinner-sidebar-leaf-wrapper):not(.emblem-sidebar-group-right),
.spinner-sidebar-leaf-wrapper
  > *:not(.spinner-sidebar-leaf-wrapper):not(.emblem-sidebar-group-right) {
  opacity: 0;
  pointer-events: none;
}
/* Phone mode (issue #184's phone-mode roadmap, scripts/deviceMode.js) -
   these buttons all either open something with no script loaded to back it
   (Icon Manager/History/Link Stats/both "add container" variants/Report/
   bulk-import - scripts/init.js's own window.onload never even wires them
   up on phone) or (.tasks-btn) toggle into/out of a grid that no longer
   exists in phone mode, with nothing to toggle back to since Tasks opens
   automatically instead. Settings/Chat are deliberately NOT included here -
   both keep working exactly as on desktop. */
html.phone-mode .tools-btn,
html.phone-mode .add-note-btn,
html.phone-mode .add-container-btn,
html.phone-mode .import-bookmarks-btn,
html.phone-mode .report-btn,
html.phone-mode .tasks-btn {
  display: none;
}
/* Settings and Chat now live in the new top/bottom toolbars instead
   (_buildPhoneToolbars, scripts/tasksWorkspace.js) - both new buttons there
   delegate straight to these same elements' own .click() (still
   showSettings()/openChatPanel() underneath) rather than duplicating them,
   so these floating corner instances are redundant on phone, not just
   relocated. */
html.phone-mode .settings-btn,
html.phone-mode .chat-btn {
  display: none;
}
/* .header-container's own body.tasks-workspace-open rule (this file, below)
   only collapses it to height: auto (search/webSearch already never loaded
   on phone in the first place, issue #184's roadmap) - still a visible,
   bordered bar taking up real space above the auto-opened workspace for no
   reason on phone, confirmed as real, reported clutter. Hidden outright
   here instead - phone has nothing left inside it worth keeping visible. */
html.phone-mode .header-container {
  display: none !important;
}
/* Settings > Container Layout (index.html's own .container-size) - reads/
   writes categoryContainers and the (unrendered, on phone) .links-container
   grid; meaningless without it. */
html.phone-mode .container-size {
  display: none;
}
/* Settings > Display > "Show on page load" (quick search/web search/page-
   tabs-bar) - all three read/write features (the Quick Search overlay,
   the header's own web-search row, the page-tabs bar) that phone mode
   never renders in the first place (issue #184's roadmap skips loading
   their own scripts entirely there), so toggling any of them here would be
   a real setting with no visible effect to show for it. + (adjacent
   sibling), not a bare class - .settings-row-pair (the middle one of these
   three) is a generic, reused class (2 earlier unrelated rows in this same
   panel also use it), so this only reaches the specific instance directly
   following the subtitle, not every row-pair in the dialog. */
html.phone-mode .on-page-load-subtitle,
html.phone-mode .on-page-load-subtitle + .settings-row-pair,
html.phone-mode
  .on-page-load-subtitle
  + .settings-row-pair
  + .new-tab-setting.page-tabs {
  display: none;
}
/* Settings dialog itself, on phone: shorter (540px, not the desktop 680px)
   since the rows hidden throughout this whole phone-mode block free up
   real vertical room the dialog no longer needs to reserve. */
html.phone-mode .settings-dialog .settings-content {
  height: 540px;
}
/* Settings > Display > Cursor Glow Radius (.range-setting) - a live cursor-
   trail effect (styles/styles.css's own cursor-glow rendering) that never
   applies on a touchscreen with no persistent cursor to glow around at
   all. Hiding it here (not just its own row) is what lets Akzentfarbe -
   the only setting left in this .settings-row-pair - actually claim the
   full row: that wrapper's own ".settings-row-pair > * { flex: 1; }"
   (above) already gives whichever single child remains 100% of the width
   on its own, no separate "full width" override needed. */
html.phone-mode .range-setting {
  display: none;
}
/* Settings > Display > context menu section: only the Emblem half of this
   h3 (and its own "Anpassen"/Customize button, .spinner-sidebar-toggle)
   stays on phone - the right-click CONTEXT MENU this section otherwise
   configures (which entries appear, above .context-menu-toggle) has
   nothing to attach to on a touchscreen with no right-click at all.
   .context-menu-h3-divider (the vertical rule between the two halves) goes
   too - nothing left on its right to visually separate from once the
   Kontextmenü label/icon are gone. */
html.phone-mode .context-menu-settings .context-menu-h3-divider,
html.phone-mode .context-menu-settings .settings-section-h3 i.fa-list,
html.phone-mode .context-menu-settings .context-menu-h3-label,
html.phone-mode .context-menu-settings .context-menu-toggle {
  display: none;
}
/* Settings > Profile: every .profile-settings-row (Active profile, the
   export/delete/guest-hint rows further down) is a label + button pair
   that fit side by side on desktop but not on a narrow phone screen - the
   button itself never shrinks (.profile-settings-row .settings-button's
   own flex-shrink: 0, above), so a long label (.profile-settings-active's
   own profile name) or a long button (.profile-switch-btn's "Profil
   wechseln / erstellen") could each independently overflow the row on
   their own, not just the two together. Stacking instead - label on its
   own line, button on the next, both given the row's full width
   (align-items: stretch, the flex column equivalent of the row's own
   space-between) - sidesteps needing to shrink/ellipsize either one. */
html.phone-mode .profile-settings-row {
  flex-direction: column;
  align-items: stretch;
}
/* Same position: absolute; bottom: 0; left: 0 anchor to .settings-content's
   own corner as the desktop rule (settingsDialog.css) - always pinned to
   the panel's very bottom edge regardless of whether .settings-profile-screen
   itself is currently scrolled (its own flex: 1; min-height: 0;
   overflow-y: auto, settingsDialog.css), not a normal in-flow row inside
   it. Column layout instead of desktop's row + space-between - a
   phone-width row would leave the two labels (longer once translated, e.g.
   German's "Sicherheit & Datenschutz") cramped or overlapping at opposite
   edges of a much narrower screen. Kept at full width (not shrunk to
   content like a single-link version) specifically so security still has
   room to sit at the RIGHT of its own row (align-self: flex-end below)
   instead of coinciding with privacy's flex-start position - a
   shrink-to-fit container is exactly as wide as its widest child
   (security's longer label), leaving flex-end nowhere further right to
   actually move to. align-items: flex-start is the explicit default for
   privacy (and every other child that doesn't override it) so it stays
   flush left same as before. */
html.phone-mode .settings-corner-links {
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 50px;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
}
html.phone-mode .settings-security-link {
  align-self: flex-end;
}
html.phone-mode .settings-privacy-link,
html.phone-mode .settings-security-link {
  border-radius: 10px;
}
/* Reserves .settings-corner-links' own (taller, two-row) phone height
   instead of the desktop rule's plain 24px (.settings-corner-links-spacer,
   settingsDialog.css) - same "don't let the last scrolled row sit hidden
   underneath the floating corner links" reasoning, just matching phone's
   own stacked-row height (2 x 24px + the 2px gap between them, above)
   instead of desktop's single row. A real flex child's own height, not
   padding-bottom on .settings-profile-screen - see
   .settings-corner-links-spacer's own comment for why that alone doesn't
   reliably reserve scrollable space once overflow-y: auto is involved. */
html.phone-mode .settings-corner-links-spacer {
  height: 50px;
}
/* Emblem customize sidebar (issue #184's roadmap) - see
   _restructureEmblemSidebarForPhone's own comment (scripts/init.js) for
   the full "why" behind splitting the group into two new wrappers. Full-
   screen instead of a left-docked, vertically-centered column - there's no
   room on phone for a left column AND the right-docked preset picker it
   reveals to both stay on screen the way desktop happily fits both at
   once. transform: none (overriding the desktop translateY(-50%) centering
   trick) matters beyond just layout too - a transformed ancestor becomes
   the containing block for a position: fixed descendant instead of the
   real viewport, which would otherwise break BOTH .emblem-sidebar-group-top's
   own top-of-screen pin (below) and spawnEmblemPresetThumbs' own per-
   thumbnail flight animation (scripts/init.js), which temporarily sets
   position: fixed on each thumbnail using coordinates read from
   getBoundingClientRect() - real viewport-relative coordinates that only
   mean what they say if nothing between here and the real viewport is
   transformed. justify-content: space-between is what actually pins the
   top wrapper to the very top and the bottom wrapper to the very bottom -
   two flex children in a full-height column with nothing else between
   them land exactly at those two edges with no extra positioning math
   needed. The tasks board mounted underneath (.tasks-workspace-mount) is
   hidden outright while this is open (body:has(.emblem-sidebar-group-left.visible),
   further down) - the entire point of asking to see this panel at all is
   an unobstructed view of the emblem it's shaping, and a merely-blurred
   board behind it (adjustBackground's own usual treatment) still isn't
   that. */
html.phone-mode .emblem-sidebar-group-left {
  inset: 0;
  top: 0;
  left: 0;
  transform: none;
  width: 100%;
  height: 100%;
  justify-content: space-between;
  align-items: center;
  padding: 20px 15px;
  box-sizing: border-box;
}
/* The four toggle buttons, pinned to the very top of the screen by the
   group's own justify-content: space-between above - a row instead of
   desktop's own column (.spinner-sidebar-toggle-column's own phone
   override, further down), reading much more like the phone top/bottom
   app toolbars (tasks/styles/general.css/workspaceShell.css) this sits
   above than a sidebar strip. The preset picker (.emblem-sidebar-group-right,
   moved in here by _restructureEmblemSidebarForPhone) becomes a second row
   beneath it once revealed - its own phone override, further down, is what
   actually turns it into a horizontal strip instead of desktop's tall
   right-docked column. */
html.phone-mode .emblem-sidebar-group-top {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  width: 100%;
}
html.phone-mode .spinner-sidebar-toggle-column {
  flex-direction: row;
  justify-content: center;
}
/* The three sliders and the upload box, pinned to the very bottom of the
   screen by the group's own justify-content: space-between above - one
   single row (not sliders-row-then-upload-box-below, an earlier version of
   this), same "reads like a toolbar" treatment as the top wrapper. Natural
   DOM order (sliderColumn, then uploadBox, then uploadHint -
   _restructureEmblemSidebarForPhone, scripts/init.js) already puts the
   upload box right after the sliders and before the hint, so flipping this
   wrapper to flex-direction: row is all that's needed to land it at the
   very right end of that same row - .spinner-sidebar-slider-column's own
   phone override just below is what turns ITS three children into a row
   too, rather than nesting a column inside this row. uploadHint itself
   never actually renders into this row at all - it's display: none on
   phone (further down), no drag-and-drop gesture worth explaining on a
   touchscreen with no cursor to drag anything with. */
html.phone-mode .emblem-sidebar-group-bottom {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 8px;
  width: 100%;
  justify-content: center;
}
html.phone-mode .spinner-sidebar-slider-column {
  flex-direction: row;
}
/* Same reasoning as .spinner-sidebar-alt-hint's own phone rule further down
   this section - no drag-and-drop gesture worth explaining on a touchscreen
   with no cursor to drag anything with, and no real :hover state to invite
   either, so neither half of this hint's own text applies there. */
html.phone-mode .emblem-upload-hint {
  display: none;
}
/* Half of the desktop calc() (var(--sidebar-item-width) * 2 + 8px) below,
   derived from the same shared variable rather than a separate hardcoded
   value that could drift out of sync with it - there's simply no room for
   the full-size box once it's sharing a bottom row with three sliders
   instead of sitting alone under a two-column-wide row above it. Halving
   the icon glyph's own font-size alongside the box keeps it visually
   proportioned the same way, not suddenly dominating a much smaller box. */
html.phone-mode .emblem-upload-sidebar {
  width: calc(var(--sidebar-item-width) + 4px);
  font-size: 15px;
}
/* Same half-size treatment, same reasoning, for every thumbnail in the
   preset picker this box reveals - see that panel's own phone override
   just below for why the panel itself also needs to become a much more
   compact horizontal strip to hold them. */
html.phone-mode .emblem-preset-thumb {
  width: calc(var(--sidebar-item-width) + 4px);
}
/* Opens as a second row inside .emblem-sidebar-group-top now (moved there
   by _restructureEmblemSidebarForPhone, scripts/init.js), not desktop's
   own separate panel docked to the viewport's right edge - position:
   static so it participates in that wrapper's own normal flex-column flow
   instead of floating independently, and a horizontal strip
   (flex-direction: row) with its own overflow-x scrollbar instead of
   desktop's tall vertical list with overflow-y, since there's real width
   to scroll through here but no spare height below the buttons to grow
   a tall column into. scrollbar-gutter: auto (not desktop's own stable) -
   that only ever mattered for avoiding a vertical-scrollbar-appearing
   layout shift, meaningless for a horizontal one here.
   Scoped to .emblem-sidebar-group-top .emblem-sidebar-group-right, NOT a
   bare class selector - confirmed as the actual, real root cause of a
   long-reported "gap below the top toolbar" bug (Playwright-verified):
   _restructureEmblemSidebarForPhone only ever runs once Settings > Customize
   Emblem is actually opened for the first time (wireSettingsControlsBody),
   so on any phone visit that never opens it, this element is still exactly
   where the static HTML puts it - a direct position: static flow child of
   body (itself flex-direction: column) - rather than nested inside
   .emblem-sidebar-group-top. A bare class selector applied that same
   position: static (and the layout it implies) regardless, turning the
   base rule's own invisible (opacity: 0) position: fixed panel into a
   real, invisible-but-solid 88px-tall block sitting in body's own normal
   flow, pushing .body-layout - and everything in it, including the whole
   Tasks workspace - down by exactly that much. Requiring the real
   .emblem-sidebar-group-top ancestor means this override only ever takes
   effect once the JS has actually relocated the element there; left
   un-relocated, it keeps the base rule's own out-of-flow position: fixed
   instead, exactly as before this phone-mode block ever touched it. */
html.phone-mode .emblem-sidebar-group-top .emblem-sidebar-group-right {
  position: static;
  top: auto;
  bottom: auto;
  right: auto;
  width: 100%;
  max-width: 100%;
  height: auto;
  max-height: none;
  flex-direction: row;
  align-items: center;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-gutter: auto;
  /* Same edge-swipe-gesture conflict as input[type="range"]'s own
     touch-action: none (styles.css, above) - scrolling this strip all the
     way to either end and continuing to drag (a completely normal way to
     realize you've reached the end of the list) is otherwise indistinguishable
     from the start of an edge-swipe "go back" gesture to the browser itself.
     overscroll-behavior-x: contain stops the scroll from "chaining" past
     this element's own boundary once it's exhausted, which is specifically
     what that gesture recognition keys off - the strip still scrolls
     exactly as before, it just never hands off past its own edges. */
  overscroll-behavior-x: contain;
}
/* No physical Alt key on a touchscreen - the hint this hides only ever
   made sense as a keyboard shortcut discovery aid. */
html.phone-mode .spinner-sidebar-alt-hint {
  display: none;
}
/* On-page debug console (temporary diagnostic aid, scripts/deviceMode.js) -
   opt-in via Settings' own Debug: On/Off control (.debug, scripts/settings.js),
   off by default. Originally phone-only (a real phone has no visible
   DevTools console at all), now available on desktop too on the same
   opt-in basis - unscoped from html.phone-mode entirely now, JS itself is
   what decides whether this ever gets built at all. Collapsed by default
   (just the toggle button) so it never gets in the way of normal use -
   bottom-left corner on desktop; phone additionally sits above the bottom
   toolbar's own left edge (its own new-task/template/chat buttons all sit
   further right, tools/styles.css/tasks/styles/general.css), not fighting
   either app toolbar for the same corner. */
.debug-console {
  position: fixed;
  z-index: 999999;
  bottom: 6px;
  left: 6px;
  max-width: calc(100vw - 12px);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
}
html:not(.phone-mode) .debug-console {
  left: 80px;
  /* Matches .header-container's own fade-in transition/duration exactly
     (desktopChrome.css) - see .debug-console-spawn-hold's own comment below
     for why this needs to sync with that specific element. */
  transition: opacity 1000ms;
}
html.phone-mode .debug-console {
  bottom: calc(var(--phone-toolbar-height) + 6px);
}
/* Parked off-screen (_buildDebugConsolePanel, scripts/deviceMode.js) when
   Debug was already on from a previous session and the phone app toolbars
   (_buildPhoneToolbars, scripts/tasksWorkspace.js) haven't spawned in yet -
   this file is eager/render-blocking, so the hidden starting state takes
   effect from the very first paint, before this ever gets a chance to show
   at its normal position. Released back in sync with those toolbars' own
   spawn-in animation (tasks/styles/workspaceShell.css) - a plain transform,
   same reasoning as that file's own spawn treatment (no layout cost, can't
   introduce a scrollbar). translateY(100%) alone (what the real toolbars use)
   isn't enough here - unlike them, this sits elevated above the bottom edge
   by var(--phone-toolbar-height)+6px to begin with (the rule just above), so
   100% of its own height only moves it partway down, still inside the
   viewport. Adding that same elevation back on top of the 100% is what
   actually clears it past the bottom edge, same as the real toolbars'
   flush-to-edge translateY(100%) does for them. */
html.phone-mode .debug-console.debug-console-spawn-hold {
  transform: translateY(calc(100% + var(--phone-toolbar-height) + 6px));
}
/* Desktop's own equivalent - held at opacity:0 (rather than parked off-
   screen, which makes no sense here - there's no toolbar edge to slide out
   from under) until .header-container itself actually fades in
   (wireSettingsControlsBody's own setTimeout, scripts/init.js), instead of
   popping in immediately at DOMContentLoaded, long before that reveal ever
   runs - a real, reported gap mirroring the phone one above. Released by
   that same setTimeout, right alongside the header/page-tabs bar's own
   opacity flip, so all three fade in together. */
html:not(.phone-mode) .debug-console.debug-console-spawn-hold {
  opacity: 0;
  /* Unlike the phone rule above (parked well off-screen, nothing left to
     overlap), this stays put at its normal bottom-left rect while merely
     invisible - without this, its toggle button would still silently catch
     clicks/taps for however long the reveal is still pending. */
  pointer-events: none;
}
/* Toggle on the left, Copy/Clear grouped at the right end (Clear last, so
   it's the rightmost of the two) - a real row now, not the old plain
   column stack every child (toggle/clear/log) used to just sit in. */
.debug-console-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
  width: 100%;
}
.debug-console-header-right {
  display: flex;
  align-items: center;
  gap: 6px;
}
.debug-console-toggle,
.debug-console-copy,
.debug-console-clear {
  border: 1px solid var(--secondary-color);
  border-radius: 6px;
  background-color: var(--main-theme-darker);
  color: var(--font-color-clear);
  font-size: 0.75em;
  padding: 4px 8px;
  cursor: var(--cursor-pointer);
}
.debug-console-copy,
.debug-console-clear {
  display: none;
}
.debug-console.expanded .debug-console-copy,
.debug-console.expanded .debug-console-clear {
  display: inline-block;
}
.debug-console-log {
  display: none;
}
/* Selectable/copyable by design (the JS-set inline user-select: text on
   this same element is what actually overrides every other touch-oriented
   element on this page defaulting to none, see its own comment) - the
   entire point of this panel is being able to long-press, select, and copy
   whatever it's showing. width: min(90vw, 480px) - a real phone genuinely
   needs most of its own narrow width, but 90% of a large desktop screen
   would be a needlessly huge panel there; capped at a comfortable reading
   width instead once there's room to spare. */
.debug-console.expanded .debug-console-log {
  display: block;
  margin: 0;
  width: min(90vw, 480px);
  max-height: 50vh;
  overflow-y: auto;
  overflow-x: hidden;
  white-space: pre-wrap;
  word-break: break-word;
  background-color: color-mix(
    in srgb,
    var(--main-theme-darkest) 90%,
    transparent
  );
  border: 1px solid var(--secondary-color);
  border-radius: 6px;
  padding: 8px;
  font-size: 0.7em;
  line-height: 1.4;
  color: var(--font-color-clear);
}
/* Hotkeys tab - phone has no keyboard shortcuts to configure at all
   (nothing on this whole page is reachable via a physical/software
   keyboard shortcut on a touch device). Hiding it here, rather than the
   panel it opens, is what actually lets the remaining two tabs
   (Homepage/Tasks) grow to fill the full width of the row -
   .settings-tab-btn's own shared "segmented tab button" flex sizing
   (below) already divides that space evenly among however many siblings
   are actually visible. */
html.phone-mode .settings-tab-btn[data-tab="hotkeys"] {
  display: none;
}
/* Matches .phone-edit-preview-tab's own box model exactly (tasks/styles/
   editTask.css - padding: 10px, border: 1px solid) rather than just eyeballing
   a height value - the shared base rule's own padding: 4px 0/border: none
   (above) renders noticeably shorter otherwise. border-color: transparent,
   not simply omitted - an actually-invisible 1px border still occupies the
   same 1px of box-model space a real one would, which is the only reason
   restating it here matters at all; the visible look (background-color
   alone, no real border line) stays exactly what the shared rule already
   gives this button. */
html.phone-mode .settings-tab-btn {
  padding: 10px 0;
  border: 1px solid transparent;
}
/* Same taller touch target as .settings-tab-btn just above, for the exact
   same reason - Settings > Profile's own admin-only Current User/
   Administration switch (.profile-tabs, settingsDialog.css) is the
   identical segmented-tab shape, sharing that rule's own base padding: 4px 0
   otherwise. */
html.phone-mode .profile-tab-btn {
  padding: 10px 0;
  border: 1px solid transparent;
}
/* Free-plan gating (applyPlanGatedToolbarVisibility, init.js) — Tasks/Link
   Stats stay visible-but-locked on free rather than hidden outright, so a
   free account can still see the feature exists and get the "upgrade to
   Pro" hint (each button's own tooltip, wired at its own call site) instead
   of it silently not being there. Same dimmed/grayscale/shrunk treatment
   this app's own toolbar buttons already used for "unavailable right now"
   (formerly .add-tasks-container-btn.no-tasks, since removed — that state
   never actually blocked a click either, just looked disabled, so it was
   dropped entirely rather than kept alongside this one). pointer-events
   stays enabled (unlike a plain :disabled button) specifically so hovering
   still shows that tooltip — the actual click is blocked by each button's
   own isTasksFeatureAvailable()/isLinkStatsFeatureAvailable() guard, not by
   this class. */
.plan-gated-locked {
  opacity: 0.7;
  position: relative;
}
/* `filter: grayscale()` on .plan-gated-locked itself was the first attempt
   here, but filter is a compositing effect on the WHOLE rendered element —
   it would have washed out this class's own ::after PRO badge just below
   right along with everything else. A plain (non-filter) property override
   like this never cascades onto ::after's own separately-declared colors,
   so the badge stays untouched without needing to scope anything around
   it — this dashed border is the one consistent "locked" cue every feature
   using this class gets, whether or not it already had a border of its
   own. ::before needs its own explicit statement here too — a plain
   `border` (unlike `filter`/`opacity`) never cascades onto a pseudo-
   element's own separately-declared box, so .homepage-action's own
   ::before circular face (e.g. #tasks) wouldn't otherwise pick this up at
   all. !important since some of the real rules being overridden
   (.action-bar-mini-btn, .homepage-action::before, .new-tab-setting) share
   this same one-class(+pseudo) specificity and could otherwise still win
   on source order alone. */
.plan-gated-locked:not(.homepage-action.tasks-btn),
.plan-gated-locked::before {
  border: 1px dashed var(--highlight) !important;
}
.plan-gated-locked > i,
.plan-gated-locked .btn-icon {
  filter: grayscale(0.85);
}
.plan-gated-locked::after {
  content: "PRO";
  position: absolute;
  top: -4px;
  right: -4px;
  background-color: var(--highlight);
  color: var(--main-theme-darkest);
  font-size: 9px;
  font-weight: bold;
  line-height: 1;
  letter-spacing: 0.5px;
  padding: 4px 2px 2px 4px;
  border-radius: 6px;
  pointer-events: none;
}
/* .plan-check-offline (applyPlanGatedToolbarVisibility/setPlanGate, init.js)
   rides ALONGSIDE .plan-gated-locked, never instead of it — this account
   really is locked out of the feature right now either way, so every rule
   above (dimming, dashed border, grayscale icon) still applies unchanged.
   Only the badge itself needs to differ: the plan check couldn't reach the
   server at all (genuinely offline, or a flaky connection) rather than
   getting a real "you're on the free plan" answer, so telling this visitor
   to "upgrade" would be actively wrong — a neutral lock instead, same
   color language .needs-signin-locked already uses for "locked for a
   reason other than confirmed-free-plan" below. Extra class beyond
   .plan-gated-locked alone is enough specificity to win without
   !important, same reasoning for the two bespoke variants further down. */
.plan-gated-locked.plan-check-offline:not(.homepage-action.tasks-btn),
.plan-gated-locked.plan-check-offline::before {
  border-color: var(--font-color-weak) !important;
}
.plan-gated-locked.plan-check-offline::after {
  content: "🔒";
  background-color: var(--font-color-weak);
  color: var(--main-theme-darkest);
  font-size: 8px;
}
.theme-mode-setting.flat-design-setting {
  position: relative;
}
/* .plan-gated-locked lives on the whole row (init.js) now, not the tiny
   25x25px icon (.theme-mode-icon) nested inside it — that was too small
   for the class's own ::after PRO badge to sit on comfortably. Its generic
   opacity/border/badge (above) apply here directly; `> i`/`.btn-icon`
   doesn't reach .flat-design-icon's own <i> though (a grandchild of this
   row, not a direct child), so that still needs its own rule below. */
.flat-design-setting.plan-gated-locked .flat-design-icon {
  /* filter: grayscale() barely reads as "locked" here — .theme-mode-icon's
     glyph is already a near-neutral gray at rest (color:
     var(--main-theme-lighter-inverse), above) with nothing colorful for
     grayscale to desaturate. A direct color override reads clearly in both
     states instead — !important since :hover's own .theme-mode-icon rule
     (above) shares this same two-class specificity and would otherwise
     still win on source order alone for a hovered locked icon. */
  color: var(--font-color-weak) !important;
}
/* The row's own text label dims to match — otherwise only the tiny icon
   looked locked while "Aurora-Design aktivieren" read exactly as clear as
   an actually-available setting. */
.flat-design-setting.plan-gated-locked label:not(.theme-mode-icon) {
  opacity: 0.7;
}
/* -------------------------------------------------------
                TASKS WORKSPACE (inline)
   ------------------------------------------------------- */
/* Hosts the full tasks page (board/cemetery/filters/stats) embedded
   in-place on the homepage - see scripts/tasksWorkspace.js. Not a modal:
   swaps out .body-container (and the rest of the homepage's own chrome)
   for the tasks content instead of floating on top of it, toggled by adding
   "tasks-workspace-open" to <body>. */
/* body's own overflow-y: auto (above) is what actually scrolls the whole
   page whenever anything inside it is taller than 100vh - .tasks-workspace-
   mount's own overflow: hidden only clips ITS OWN children from spilling
   out of ITS box, it does nothing to stop the page itself from scrolling
   if the mount's initial, unscaled render is briefly taller than
   available space before applyTasksWorkspaceScale (scripts/
   tasksWorkspace.js) gets a chance to correct it - confirmed as a real gap,
   not just theoretical: the workspace visibly settles into a correctly
   scaled, fully-contained fit, but the page was left scrollable anyway
   from that first oversized frame. The workspace is a full view swap, not
   something meant to coexist with page scroll under any circumstance, so
   this just removes the possibility outright instead of chasing the exact
   timing gap that let it happen once.
   html (above) has its OWN separate overflow-y: auto, not just an
   inherited copy of body's - browsers pick whichever of the two ends up
   the actual "root scroller" (a real, documented quirk, not consistent
   across engines), so hiding overflow on body alone left html free to
   keep scrolling regardless. :has() is already relied on elsewhere in
   this file (e.g. body:has(.dragging-container) above) for reaching a
   different element based on a class that only ever gets toggled on
   body itself. */
body.tasks-workspace-open {
  /* overflow: hidden (base body rule, top of this file) alone only blocks
     USER-driven scrolling (wheel, dragging the scrollbar, keyboard) - it
     does nothing to stop a script from still moving scrollTop directly,
     which is exactly what scrollIntoView() does (the task-overview panel's
     own hover-connector-line feature, taskOverviewContainer.js;
     relation-indicator hovers). position: fixed removes body from the
     scrolling mechanism entirely - there's no scroll position left for
     anything, script included, to visibly move. top/left/width pin it
     exactly where it already sits (height: 100vh is already set above),
     so this is a no-op visually, just an actual guarantee instead of a
     partial one. */
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
}
/* Phone mode (issue #184's roadmap) - closes a real reported gap: the last
   stacked section (.task-done-container, tasks/styles/taskDone.css) could
   render partly hidden behind the phone bottom toolbar with no way to
   scroll the rest into view. Root cause was this element's own inherited
   height: 100vh (base body rule, top of this file) - a real mobile browser's
   address bar/gesture nav can show/hide dynamically, and `vh` is computed
   against the LARGEST possible viewport (chrome fully collapsed) even while
   that chrome is currently showing. With body pinned position: fixed the
   moment the workspace opens (this rule, just above), a box sized off that
   too-tall vh value has its own bottom edge - and everything pinned to it,
   including .phone-bottom-toolbar and .tasks-workspace-mount's own scroll
   limit - sitting behind the browser's real, currently-visible edge, with
   no scroll left anywhere (body's own is gone; the mount's internal one
   only ever reaches ITS OWN true bottom, which was already wrong) to bring
   it into view. `dvh` tracks the CURRENTLY visible viewport instead, so
   this box (and the toolbars/scroll limit pinned to it) always matches what
   the phone can actually show right now. Layered as a second declaration,
   not a replacement, of the base vh rule - a browser without dvh support
   silently keeps that one instead of computing nothing at all. */
html.phone-mode body.tasks-workspace-open {
  height: 100dvh;
}
/* .tasks-btn (re-purposed as the way back, see scripts/tasksWorkspace.js/
   scripts/init.js), .settings-btn, .chat-btn, and .report-btn stay visible
   and in their normal position - every other .homepage-action button is
   hidden. */
body.tasks-workspace-open
  .homepage-action:not(.tasks-btn):not(.settings-btn):not(.chat-btn):not(
    .report-btn
  ) {
  display: none;
}
/* !important on these three - each has JS elsewhere that re-sets an inline
   style on window resize/.body-container resize (.body-container's own
   entrance-animation code, animations.js; .page-tabs-bar/.header-search-row
   via applyPageTabsBarSetting/updateHeaderSearchRowPlacement, webSearch.js),
   which otherwise wins over a plain stylesheet rule regardless of selector
   specificity - confirmed these three are the ones that actually reappear
   without it, not a blanket "just in case". */
body.tasks-workspace-open .body-container,
body.tasks-workspace-open .page-tabs-bar,
body.tasks-workspace-open .header-search-row {
  display: none !important;
}
/* .emblem's own JS-driven spin (tickEmblem, scripts/animations.js) already
   gets paused while the workspace is open (openTasksWorkspace's own
   stopEmblemSpin() call, scripts/tasksWorkspace.js) - but that only stops
   OUR OWN transform update, not the underlying SVG asset's own internal
   animation, if the current emblem preset has one (a flickering flame, a
   shifting gradient, ...) - an <img>-embedded SVG's own CSS/SMIL animations
   keep running entirely on their own regardless of anything our JS does,
   real, ongoing rasterization cost that scales with how visually complex
   that internal animation is (confirmed live: 20-30% CPU for a single large
   emblem, even fully paused, well above the ~7% baseline with none open at
   all) - the exact same per-pixel repaint cost .emblem's own will-change
   comment above already documents for the SPINNING case, just from a
   different animation source. display: none is the only way to actually
   stop that - and it's free to do so here specifically: this element is
   z-index: 1, behind literally everything else on the page, and the
   workspace covers the ENTIRE viewport the moment it's open, so it's
   already 100% invisible regardless.
   html:not(.phone-mode) - phone has no toggleable "workspace open/closed"
   state at all, the board IS the whole phone experience (auto-opened on
   load, scripts/init.js's own window.onload) - body.tasks-workspace-open is
   simply always true there, so this rule without that exclusion permanently
   hid the emblem on phone outright instead of just while covered, confirmed
   as a real, reported regression (the emblem is meant to stay visible/
   reachable on phone same as before). */
html:not(.phone-mode) body.tasks-workspace-open .emblem {
  display: none;
}
/* .cursor-glow's own position tracking already skips updating while the
   workspace is open (setGlowCoordinations' own early return, scripts/
   settings.js) - covered 100% by the workspace same as .emblem above, for
   the same reason - but that only ever stopped the tracking, never actually
   hid the element itself, leaving its own ::before pulse animation
   (cursor-glowing-animation, below) running the entire time regardless -
   the same class of real, confirmed cost as .emblem's own now-removed
   internal-SVG-animation issue, just smaller. display: none here is what
   actually stops it. */
body.tasks-workspace-open .cursor-glow {
  display: none;
}
/* .header-container/.header-date-row stay visible but collapse to a single
   compact row (own comment on .date-time-container below) instead of their
   normal 165px-tall stacked layout, so the workspace below gets almost the
   full viewport height instead of losing a big chunk of it to a mostly-
   empty header. Padding is deliberately left untouched (the row layout
   alone already does most of the height reduction) - only height needs
   overriding, since the base rule pins it to a fixed 165px rather than
   sizing to content. */
body.tasks-workspace-open .header-container {
  height: auto;
}
/* The embedded workspace's own changelog button (workspace.partial.txt) -
   its click handler is never wired at all once embedded (initChangelog(),
   shared/scripts/changelog.js, now only ever wires it on the genuinely
   standalone tasks page - see that function's own comment for why), so
   leaving it visible here would just be a dead button. .settings-btn stays
   reachable throughout (rule above) and shows the exact same unified
   changelog now, so nothing is actually lost by hiding this. */
body.tasks-workspace-open .tasks-changelog-btn {
  display: none;
}
body.tasks-workspace-open .date-time-container {
  flex-direction: row;
  justify-content: center;
}
/* #left-torch/#right-torch's own bottom: -10px (above) is tuned for
   .date-time-container's normal stacked (column) layout — collapsed to a
   single row here (just above), the container itself is shorter, so that
   same -10px would leave the torches sitting visibly too high relative to
   the now-shorter row. A bigger negative offset drops them back down to
   the same relative position against the collapsed row's own bottom edge. */
body.tasks-workspace-open #left-torch,
body.tasks-workspace-open #right-torch {
  bottom: -48px;
  transform: scale(0.75);
}
/* CW 33, August 14th 2026, 15:04:30 - one line instead of three stacked
   rows. Comma-separates via generated content rather than touching
   updateTime's own innerHTML (shared/scripts/threads.js) - #time is last,
   so it gets no trailing comma. */
body.tasks-workspace-open #week::after,
body.tasks-workspace-open #date::after {
  content: ", ";
}

/* Sibling of .body-container inside .body-layout (index.html) - takes over
   its flex-grow:1 slot once .body-container itself is hidden above, same
   effective footprint. Fades in rather than appearing instantly - .visible
   is toggled by openTasksWorkspace/closeTasksWorkspace (scripts/
   tasksWorkspace.js) a frame after body.tasks-workspace-open itself is
   added, same "needs a frame between the display switch and the opacity
   transition, or there's nothing to transition from" reasoning as the
   homepage's own .page-fade-overlay entrance (scripts/init.js's
   window.onload). Replayed on every open, not just the first one - removed
   right as the workspace closes so the next open has a fresh 0-opacity
   starting point again. */
.tasks-workspace-mount {
  display: none;
  flex-grow: 1;
  min-width: 0;
  /* Same missing-guard bug as .body-layout's own min-height: 0 above (see
     its comment) - this element is the more direct offender, since it's
     also the one carrying overflow: hidden below, which only clips content
     that overflows the box; it doesn't stop the box itself from being sized
     larger than available in the first place, which is what min-height:
     auto's content-driven floor was silently doing without this. */
  min-height: 0;
  flex-direction: column;
  /* No align-items means the default, stretch - which DOES constrain
     .main-container's own cross-size (width, in this column flex
     container) to whatever's currently available, contradicting that
     element's own comment ("no explicit width of its own... genuinely
     content-driven") and applyTasksWorkspaceScale's entire premise
     (scripts/tasksWorkspace.js): that function's transform-origin: top
     center (workspaceShell.css) resolves against .main-container's OWN
     layout box, not against however wide its children actually are - once
     those two diverge (stretch shrinks the box below its children's true
     combined width), the scale/translate math centers around the wrong
     point entirely, and a child rendered at its own real width (confirmed
     live: .task-done-container measured outside .main-container's own
     box) overflows past an edge that was never actually where the math
     thought it was. flex-start lets .main-container size to its own
     content instead, matching what every comment in that other file
     already assumed was already true. */
  align-items: flex-start;
  opacity: 0;
  /* .main-container's own natural size (tasks/styles/workspaceShell.css)
     floors out well past what's actually available once a side panel is
     open, DevTools is docked, or the window/monitor is simply smaller than
     a full desktop - see applyTasksWorkspaceScale (scripts/tasksWorkspace.js)
     for the JS that keeps it scaled down to fit this box instead of
     overflowing it. This is just the clip - without it, the tail end of a
     natural size the JS's own min-scale floor couldn't fully absorb would
     spill out and force the whole page to scroll instead of staying
     contained in here. */
  overflow: hidden;
}
body.tasks-workspace-open .tasks-workspace-mount {
  display: flex;
}
body.tasks-workspace-open .tasks-workspace-mount.visible {
  opacity: 1;
  transition: opacity 350ms ease;
}
/* Phone mode (issue #184's roadmap) - the real root cause of the kanban
   board still rendering at its full desktop size/layout on phone even once
   its OWN internals (tasks/styles/kanban.css) reflow to a single column:
   this mount's own align-items: flex-start (its own comment above) exists
   specifically so .main-container can size to its full, unconstrained
   natural content width - the OTHER half of applyTasksWorkspaceScale's
   "let it overflow, then scale the whole thing down via transform" desktop
   strategy (scripts/tasksWorkspace.js), which is gated OFF entirely on
   phone (no reason to shrink-to-fit a board that reflows instead). Left
   unconstrained, .main-container (and everything inside it) kept rendering
   at desktop width regardless of anything the kanban/edit/preview CSS
   changes did - align-items: stretch here is what actually constrains
   .main-container's own width down to the mount's real (viewport-derived)
   width in the first place, which every other phone-mode CSS change
   upstream of this was silently assuming already happened.
   overflow-y: auto (not the desktop hidden, "the clip" for JS-scaled
   overflow that no longer applies here) - body itself is position: fixed
   while the workspace is open (body.tasks-workspace-open's own comment,
   this file), so this mount is the ONLY element left that can actually
   scroll a kanban list taller than one viewport. */
html.phone-mode body.tasks-workspace-open .tasks-workspace-mount {
  align-items: stretch;
  overflow-y: auto;
  overflow-x: hidden;
  /* Room for the new fixed top/bottom app toolbars (--phone-toolbar-height,
     :root below; _buildPhoneToolbars, scripts/tasksWorkspace.js) - both are
     position: fixed (outside normal flow, so they'd otherwise just paint
     OVER the mount's own top/bottom edge) rather than adjusting the
     mount's own real box size. */
  padding-top: var(--phone-toolbar-height);
  padding-bottom: var(--phone-toolbar-height);
  box-sizing: border-box;
}
/* Hidden outright (not just blurred/inert, adjustBackground's own usual
   treatment) while the emblem customize sidebar is open - the entire point
   of opening it at all is an unobstructed view of the emblem it's shaping,
   which a merely-blurred board sitting behind it still isn't. Desktop
   already gets this for free (openSpinnerSidebarPanel closes the real
   Settings dialog, leaving the actual homepage board/links visible behind
   the sidebar without hiding anything) - phone's own auto-opened board
   otherwise still sat there, unlike desktop's optional one. */
html.phone-mode
  body:has(.emblem-sidebar-group-left.visible)
  .tasks-workspace-mount {
  display: none;
}
/* Shown only until workspace.partial.txt/the tasks scripts have finished
   loading - replaced by the fetched markup itself once
   initTasksWorkspace() actually runs (openTasksWorkspace,
   scripts/tasksWorkspace.js). */
.tasks-workspace-loading {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-grow: 1;
  color: var(--font-color);
  font-size: 1.4em;
  gap: 12px;
}
.tasks-workspace-loading i {
  animation: tasks-workspace-loading-spin 900ms linear infinite;
}
@keyframes tasks-workspace-loading-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
/* Replaces the plain spinner (showTasksWorkspaceLoadFailure, scripts/
   tasksWorkspace.js) if loadTasksWorkspaceAssets() itself fails - stacked
   column instead of the loading state's own row, since this has a message
   and a button to fit, not just a spinner. */
.tasks-workspace-load-failed {
  flex-direction: column;
  text-align: center;
  gap: 14px;
}
/* :not(animation) override, not a separate selector avoiding
   .tasks-workspace-loading i entirely - this element deliberately reuses
   that class for the same centered-in-the-mount layout, just needs its own
   icon to stay still (a spinning warning triangle would read as broken,
   not as an error). Absolute font-size, not em-relative to the 1.4em base
   above, so it doesn't compound with that parent's own scaling. */
.tasks-workspace-load-failed i {
  animation: none;
  color: var(--highlight);
  font-size: 28px;
}
.tasks-workspace-load-failed span {
  font-size: 15px;
  max-width: 320px;
}
.tasks-workspace-load-retry-btn {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 20px;
  border: none;
  border-radius: 8px;
  background-color: var(--secondary-color);
  color: var(--accent-tab-active-text, #000000);
  font-size: 14px;
  font-weight: bold;
  cursor: var(--cursor-pointer, pointer);
}
.tasks-workspace-load-retry-btn:hover {
  filter: brightness(1.1);
}

