/* Bubble Hub — an ethereal constellation of Soapbox services around one key.
 *
 * Bubble Hub (August 2026)
 * Nabil Izagaren, for Solidarity Works! International. Licensed MIT.
 */

/* ------------------------------------------------------------------ tokens */

/* Every colour is declared once, as light-dark(dawn, night). The palette used
   to be written out twice — once under a prefers-color-scheme media query and
   once under [data-theme=dark] — and the two promptly drifted apart, which is
   the whole reason for this shape. Only the values that are NOT colours still
   need the two blocks below, because light-dark() takes colours alone. */

:root {
  color-scheme: light dark;

  --bg: light-dark(#eef0fb, #06060f);
  --bg-deep: light-dark(#e4e8f7, #0b0a1c);
  --ink: light-dark(#191634, #f2f0ff);
  /* Raised twice now. Small text on translucent glass over a moving backdrop
     loses more contrast than the swatch suggests, and the faint tone was
     doing work it could not carry. */
  --ink-soft: light-dark(#3f3c64, #cfcaef);
  --ink-faint: light-dark(#5d5a83, #aaa4d4);
  --glass: light-dark(rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.055));
  --glass-strong: light-dark(rgba(255, 255, 255, 0.76), rgba(255, 255, 255, 0.09));
  --glass-edge: light-dark(rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.18));
  --hairline: light-dark(rgba(40, 32, 90, 0.13), rgba(255, 255, 255, 0.12));
  /* The rail's scrollbar, as HSL parts so the thumb can be given an alpha at
     the point of use. Violet rather than grey: it belongs to the page's own
     palette, which is what stopped the old scrollbar reading as a stray strip
     of chrome between the panels. */
  /* Sealed messages in the inbox: the one row that reports an envelope rather
     than its contents, so it gets its own colour. */
  --accent-sealed: light-dark(268 62% 54%, 268 88% 76%);
  --rail-thumb: light-dark(258 40% 62% / 0.36, 258 70% 78% / 0.26);
  --rail-thumb-hover: light-dark(258 48% 56% / 0.6, 258 80% 82% / 0.45);
  --spoke: light-dark(rgba(70, 60, 140, 0.34), rgba(190, 180, 255, 0.5));
  --spoke-faint: light-dark(rgba(70, 60, 140, 0.16), rgba(190, 180, 255, 0.26));
  --halo: light-dark(rgba(120, 100, 220, 0.28), rgba(150, 130, 255, 0.5));
  --shadow-tint: light-dark(rgba(46, 34, 106, 0.55), rgba(0, 0, 0, 0.9));
  --shadow-tint-lift: light-dark(rgba(46, 34, 106, 0.62), rgba(0, 0, 0, 0.95));
  --orb-1: light-dark(#a78bfa, #6d4aff);
  --orb-2: light-dark(#67e8f9, #12b8c8);
  --orb-3: light-dark(#fda4af, #ff4f9a);
  --orb-4: light-dark(#fcd34d, #3b5bff);
  --focus: light-dark(#5b46d6, #a99bff);
  /* Fainter than the panel glass, for the controls floating on the backdrop. */
  --chrome-glass: light-dark(rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.028));
  --chrome-edge: light-dark(rgba(40, 32, 90, 0.07), rgba(255, 255, 255, 0.065));
  --danger: light-dark(hsl(352 78% 46%), hsl(352 100% 76%));

  --shadow: 0 26px 64px -28px var(--shadow-tint);
  --shadow-lift: 0 36px 78px -26px var(--shadow-tint-lift);

  --orb-opacity: 0.4;
  --orb-blend: normal;
  --star-opacity: 0;
  --grain-opacity: 0.035;
}

:root[data-theme='light'] {
  color-scheme: light;
}

:root[data-theme='dark'] {
  color-scheme: dark;
}

/* The four quantities light-dark() cannot carry. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    --orb-opacity: 0.32;
    --orb-blend: screen;
    --star-opacity: 1;
    --grain-opacity: 0.05;
  }
}

:root[data-theme='dark'] {
  --orb-opacity: 0.32;
  --orb-blend: screen;
  --star-opacity: 1;
  --grain-opacity: 0.05;
}

/* -------------------------------------------------------------------- base */

* {
  box-sizing: border-box;
}

html,
body {
  height: 100%;
}

body {
  margin: 0;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  color: var(--ink);
  font: 400 16px/1.55 ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter,
    system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
  transition: background 0.6s ease, color 0.6s ease;
}

/* --------------------------------------------------------------- backdrop */

.sky {
  position: fixed;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  background: radial-gradient(120% 90% at 50% 0%, var(--bg-deep), var(--bg) 70%);
}

.orb {
  position: absolute;
  width: 54vmax;
  height: 54vmax;
  border-radius: 50%;
  filter: blur(76px);
  opacity: var(--orb-opacity);
  mix-blend-mode: var(--orb-blend);
  will-change: transform;
}

.orb-1 {
  background: var(--orb-1);
  top: -26vmax;
  left: -18vmax;
  animation: drift-a 32s ease-in-out infinite;
}
.orb-2 {
  background: var(--orb-2);
  bottom: -30vmax;
  right: -16vmax;
  animation: drift-b 41s ease-in-out infinite;
}
.orb-3 {
  background: var(--orb-3);
  top: 18vmax;
  right: -24vmax;
  width: 46vmax;
  height: 46vmax;
  animation: drift-c 46s ease-in-out infinite;
}
.orb-4 {
  background: var(--orb-4);
  bottom: -8vmax;
  left: 6vmax;
  width: 40vmax;
  height: 40vmax;
  animation: drift-d 37s ease-in-out infinite;
}

@keyframes drift-a {
  50% {
    transform: translate3d(19vmax, 11vmax, 0) scale(1.22);
  }
}
@keyframes drift-b {
  50% {
    transform: translate3d(-17vmax, -14vmax, 0) scale(1.16);
  }
}
@keyframes drift-c {
  50% {
    transform: translate3d(-14vmax, 17vmax, 0) scale(0.82);
  }
}
@keyframes drift-d {
  50% {
    transform: translate3d(15vmax, -13vmax, 0) scale(1.28);
  }
}

.stars {
  position: absolute;
  inset: 0;
  opacity: var(--star-opacity);
  transition: opacity 0.6s ease;
}

.star {
  position: absolute;
  border-radius: 50%;
  background: #fff;
  animation: twinkle var(--dur) ease-in-out infinite;
  animation-delay: var(--delay);
}

@keyframes twinkle {
  0%,
  100% {
    opacity: 0.12;
    transform: translateY(0);
  }
  50% {
    opacity: 0.8;
    transform: translateY(-6px);
  }
}

/* Soap bubbles, rising. The wrapper carries the climb and the inner element
   the sideways drift, so the two can run at different speeds without either
   fighting the other for the transform.

   The mask dissolves them before they reach the header. Without it, a bubble
   part-way through its climb at page load appears from nowhere behind the
   search bar, which reads as a glitch rather than as a bubble. */
.soap {
  position: absolute;
  inset: 0;
  overflow: hidden;
   The second mask keeps them to the middle of the screen. Drifting behind the
   panels, a bubble was blurred by the panel's own backdrop filter into a grey
   smudge between the cards, which read as dirt on the glass rather than as a
   bubble. */
  /* ONE mask layer, not two composited. The previous version intersected a
     vertical and a horizontal gradient, and -webkit-mask-composite composites
     differently from mask-composite, which left a hard vertical seam across
     the backdrop. A single radial does both jobs with no compositing. */
  -webkit-mask-image: radial-gradient(125% 100% at 50% 108%, #000 38%, rgba(0, 0, 0, 0.4) 72%, transparent 94%);
  mask-image: radial-gradient(125% 100% at 50% 108%, #000 38%, rgba(0, 0, 0, 0.4) 72%, transparent 94%);
}

/* Scaled as a whole rather than per bubble: each bubble already carries its
   own opacity, set at random when it is blown, and the slider is a multiplier
   over the lot of them so that relationship survives. */
.soap {
  opacity: var(--soap-level, 1);
  transition: opacity 0.3s ease;
}

/* The darkness slider: one wash over the whole backdrop, above the orbs and
   the stars and below everything you read. Nothing here is interactive, so it
   takes no pointer events and needs no z-index games. */
.sky::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: #05030f;
  opacity: var(--dark-wash, 0);
  transition: opacity 0.35s ease;
}

.soap-bubble {
  position: absolute;
  bottom: -14vh;
  animation: rise var(--dur) linear var(--delay) infinite;
  will-change: transform;
}

.soap-bubble i {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  animation: sway var(--sway) ease-in-out var(--delay) infinite alternate;
  background: radial-gradient(
    circle at 30% 26%,
    rgba(255, 255, 255, 0.26),
    rgba(255, 255, 255, 0.04) 42%,
    rgba(255, 255, 255, 0.01) 62%,
    transparent 72%
  );
  border: 1px solid rgba(255, 255, 255, 0.08);
  box-shadow: inset -6px -8px 14px rgba(255, 255, 255, 0.045),
    inset 5px 6px 12px rgba(190, 220, 255, 0.075);
}

:root[data-theme='light'] .soap-bubble i {
  background: radial-gradient(
    circle at 30% 26%,
    rgba(255, 255, 255, 0.5),
    rgba(255, 255, 255, 0.16) 45%,
    rgba(190, 205, 255, 0.07) 68%,
    transparent 76%
  );
  border-color: rgba(255, 255, 255, 0.4);
}

@media (prefers-color-scheme: light) {
  :root:not([data-theme='dark']) .soap-bubble i {
    background: radial-gradient(
      circle at 30% 26%,
      rgba(255, 255, 255, 0.5),
      rgba(255, 255, 255, 0.16) 45%,
      rgba(190, 205, 255, 0.07) 68%,
      transparent 76%
    );
    border-color: rgba(255, 255, 255, 0.4);
  }
}

@keyframes rise {
  from {
    transform: translateY(0);
    opacity: 0;
  }
  8% {
    opacity: 1;
  }
  85% {
    opacity: 1;
  }
  to {
    transform: translateY(-130vh);
    opacity: 0;
  }
}

@keyframes sway {
  from {
    transform: translateX(-14px) scale(0.97);
  }
  to {
    transform: translateX(14px) scale(1.03);
  }
}

/* Lore bubbles: the same soap, but carrying something. They sit above the
   stage so they can be hovered, while the layer itself stays click-through. */
/* Behind the constellation, not beside it. A lore bubble may drift anywhere,
   and where a service bubble is on top, the service bubble wins the hover.
   That is what stops them interfering. */
.lore {
  position: fixed;
  inset: 0;
  z-index: 1;
  overflow: hidden;
  pointer-events: none;
  /* Faded out across the header band. A bubble that drifted up there sat
     behind the header, where it could be seen but neither hovered nor
     popped. */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 11%);
  mask-image: linear-gradient(to bottom, transparent 0, #000 11%);
}

.lore-bubble {
  position: absolute;
  bottom: -18vh;
  pointer-events: auto;
  /* Grab, not help. These are dragged out of the stream and put down; the
     question-mark cursor described something you could only interrogate. */
  cursor: grab;
  animation: rise var(--dur) linear var(--delay) infinite;
}

.lore-bubble > i {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background: radial-gradient(
    circle at 30% 26%,
    rgba(255, 255, 255, 0.15),
    rgba(255, 255, 255, 0.03) 44%,
    transparent 72%
  );
  border: 1px solid rgba(255, 255, 255, 0.07);
  box-shadow: inset -6px -8px 16px rgba(255, 255, 255, 0.03);
  transition: transform 0.3s cubic-bezier(0.34, 1.4, 0.5, 1), border-color 0.3s;
}

.lore-bubble::after {
  content: '?';
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: 13px;
  color: rgba(255, 255, 255, 0.16);
  pointer-events: none;
  transition: opacity 0.3s, color 0.3s;
}

/* HOVER HOLDS IT VISIBLE, and the !important is the whole point rather than a
   shortcut. `rise` fades opacity from 1 to 0 across the last 15% of its cycle
   and this rule pauses the animation, so pointing at a bubble in that window
   froze it part-way through fading and it stayed a ghost for as long as you
   pointed at it: at 58 to 92 seconds a cycle, a 9 to 14 second window per
   bubble in which hovering made it disappear. An author !important beats an
   animation in the cascade, which is what lets a paused frame be overridden.

   Reported as "some disappear when I hover over them". They were not being
   removed; they were being held at the opacity they happened to have. */
.lore-bubble:hover,
.lore-bubble:focus-within,
.lore-bubble.is-held,
.lore-bubble.is-pinned {
  opacity: 1 !important;
}

.lore-bubble:hover {
  animation-play-state: paused;
  z-index: 2;
}

.lore-bubble:hover > i {
  transform: scale(1.08);
  border-color: rgba(255, 255, 255, 0.34);
  background: radial-gradient(
    circle at 30% 26%,
    rgba(255, 255, 255, 0.3),
    rgba(255, 255, 255, 0.06) 44%,
    transparent 72%
  );
}

.lore-bubble:hover::after {
  opacity: 0;
}

/* A small informational bubble. It carries the same card as a large one, so it
   opens on hover exactly the same way; only the circle is smaller. */
.lore-bubble.is-small > i {
  box-shadow: inset -3px -4px 9px rgba(255, 255, 255, 0.03);
}

.lore-bubble.is-small .lore-card {
  /* The card is anchored to a smaller circle, so it needs to clear less. */
  left: calc(100% + 8px);
}

.lore-bubble.is-small.is-left .lore-card,
.lore-bubble.is-small .lore-card.is-left {
  left: auto;
  right: calc(100% + 8px);
}

.lore-card {
  position: absolute;
  top: 50%;
  left: calc(100% + 14px);
  transform: translateY(-50%) translateX(-6px);
  width: 244px;
  padding: 13px 15px;
  border-radius: 16px;
  border: 1px solid var(--hairline);
  background: var(--glass);
  backdrop-filter: blur(22px) saturate(160%);
  -webkit-backdrop-filter: blur(22px) saturate(160%);
  box-shadow: var(--shadow), inset 0 1px 0 var(--glass-edge);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.28s ease, transform 0.28s ease, visibility 0.28s;
}

.lore-card.is-left {
  left: auto;
  right: calc(100% + 14px);
  transform: translateY(-50%) translateX(6px);
}

.lore-bubble:hover .lore-card {
  opacity: 1;
  visibility: visible;
  transform: translateY(-50%) translateX(0);
}

.lore-card b {
  display: block;
  margin-bottom: 5px;
  font-size: 13.5px;
  color: var(--ink);
}

.lore-card p {
  margin: 0;
  font-size: 11.5px;
  line-height: 1.55;
  color: var(--ink-soft);
  /* A belt to plainText's braces. Relay content is written by strangers and a
     single unbroken token — a long URL, a hash, a base64 blob — will push out
     of any box that does not say otherwise. Four lines, then it stops. */
  overflow-wrap: anywhere;
  display: -webkit-box;
  -webkit-line-clamp: 4;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.lore-card b {
  overflow-wrap: anywhere;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.lore-tag {
  display: block;
  margin-bottom: 6px;
  font-size: 9px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.lore-link {
  display: inline-block;
  margin-top: 9px;
  font-size: 10.5px;
  letter-spacing: 0.06em;
  color: var(--ink-soft);
  text-decoration: none;
  border-bottom: 1px solid var(--hairline);
}

.lore-link:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
}

.lore-add {
  margin-top: 10px;
  padding: 7px 14px;
  border: 0;
  border-radius: 999px;
  background: linear-gradient(135deg, hsl(265 90% 62%), hsl(210 92% 58%));
  color: #fff;
  font: inherit;
  font-size: 11.5px;
  cursor: pointer;
}

/* The discovery layer is NOT governed by the soap setting any more: it has its
   own density slider and its own source switches under the bubbles button, and
   turning the decorative background off is no longer a reason to stop showing
   what is on Nostr. Density 0 is how you turn this off. */

.grain {
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: var(--grain-opacity);
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* --------------------------------------------------------------- chrome */

/* Three columns, the outer two equal, so the middle one is centred on the PAGE
   rather than merely between its neighbours: the search bar then sits on the
   same vertical as the identity bubble below it, which is what the two of them
   being the page's centre line is supposed to mean.
   Grid rather than an absolutely positioned search, which is how this was
   tried before: an absolute element can sit on top of the control cluster and
   swallow its clicks, and a grid column cannot overlap its neighbours. When
   the room runs out the search shrinks instead of overlapping anything. */
.chrome {
  position: relative;
  /* Above the panel rails (6). The header creates its own stacking context,
     so the paint menu's z-index only counts INSIDE the header: at 5 the whole
     header, menu and all, was painted under the panels. */
  z-index: 20;
  /* THE SEARCH TAKES ITS OWN ROW, centred, under the controls.
     
     It used to sit in the middle track of a three-column grid, so that it
     centred on the profile bubble below it. That requirement is kept here and
     the grid is not: for the middle track to be centred, both side tracks must
     be equal, which left the controls 704px when they need about 730. They
     wrapped to two rows and the search was stranded between them, aligned with
     neither. Twenty pixels short at 1920, and further short at every width
     below it, so it was not a wide-screen accident but the layout failing
     wherever the controls are this numerous.
     
     A centred row of its own gives both properties at once: still centred on
     the bubble, and no longer competing with eight controls for the same line. */
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px 16px;
  padding: 22px clamp(18px, 4vw, 40px);
}

.chrome > .wordmark {
  order: 0;
  /* Pushes the controls to the far end of the first row. */
  margin-right: auto;
}

/* AN EXPLICIT LINE BREAK between the controls and the search. `flex: 0 0 100%`
   on the search alone did not do it: max-width clamps a flex item's hypothetical
   size BEFORE the line-breaking pass, so a 460px cap made it fit on the first
   line and it sat out to the right of the controls instead of below them. A
   zero-height full-width item between the two forces the break, and the search
   then keeps its cap and its centring. */
.chrome::after {
  content: '';
  order: 2;
  flex: 0 0 100%;
  height: 0;
}

.chrome > .chrome-right {
  order: 1;
  /* It MUST be able to shrink and wrap. A grid track does not grow to fit its
     contents when it is capped, so an over-wide cluster simply overflowed and
     was drawn on top of the search box: at 1440 the controls wanted 775px in a
     383px track and the two overlapped by 295px. Six view buttons and five
     icons is more than a third of a laptop screen. */
  min-width: 0;
  flex-wrap: wrap;
  row-gap: 8px;
}

/* The view switch is the widest single thing in there and the first that
   should give. */
.chrome > .chrome-right .view-switch {
  flex-shrink: 1;
  min-width: 0;
}

.wordmark {
  display: flex;
  align-items: baseline;
  gap: 10px;
  font-size: 13px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.wordmark b {
  font-weight: 600;
  letter-spacing: 0.14em;
  color: var(--ink);
}

.wordmark span {
  letter-spacing: 0.1em;
  font-size: 11px;
  color: var(--ink-faint);
}

/* The tagline wraps the wordmark onto two lines on a narrow phone. */
@media (max-width: 480px) {
  .wordmark span {
    display: none;
  }
}

.chrome-right {
  display: flex;
  align-items: center;
  gap: 10px;
}

/* The header's middle column, centred on the page at every width, so it holds
   the same vertical as the identity bubble under it. The top of each ring is
   deliberately left empty for it to hang over. */

.hub-search {
  position: relative;
  /* Its own row, third, centred by the auto margins and capped so it does not
     stretch to 1840px on a wide screen. The cap is what keeps it reading as
     centred ON the profile bubble rather than merely spanning the page. */
  order: 3;
  flex: 0 1 460px;
  margin-inline: auto;
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 8px 14px;
  border: 1px solid var(--hairline);
  border-radius: 999px;
  background: var(--glass);
  backdrop-filter: blur(14px) saturate(150%);
  -webkit-backdrop-filter: blur(14px) saturate(150%);
  transition: border-color 0.25s;
}

.hub-search-key {
  flex: none;
  padding: 2px 6px;
  border: 1px solid var(--hairline);
  border-radius: 6px;
  font: inherit;
  font-size: 10px;
  letter-spacing: 0.06em;
  line-height: 1.5;
  color: var(--ink-faint);
  background: var(--glass);
  transition: opacity 0.2s ease;
}

/* Out of the way once you are in the field: it has done its job by then, and
   it sits where the text is heading. */
.hub-search:focus-within .hub-search-key {
  opacity: 0;
}

@media (max-width: 860px) {
  .hub-search-key {
    display: none;
  }
}

.hub-search:focus-within {
  border-color: var(--glass-edge);
}

.hub-search-icon {
  width: 15px;
  height: 15px;
  flex: none;
  fill: none;
  stroke: var(--ink-faint);
  stroke-width: 1.7;
  stroke-linecap: round;
}

.hub-search-input {
  flex: 1;
  min-width: 0;
  border: 0;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 13px;
}

.hub-search-input:focus {
  outline: none;
}

.hub-search-input::placeholder {
  color: var(--ink-faint);
}

.hub-search-results {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  right: 0;
  max-height: min(56vh, 460px);
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 8px;
  border-radius: 18px;
  /* Nearly opaque, not glass. This panel carries small text over the busiest
     part of the page — the constellation, the drifting bubbles and the profile
     all pass behind it — and at panel translucency the results were legible
     only where the backdrop happened to be dark. */
  background: light-dark(rgba(252, 251, 255, 0.96), rgba(17, 15, 40, 0.95));
  backdrop-filter: blur(30px) saturate(150%);
  -webkit-backdrop-filter: blur(30px) saturate(150%);
  border: 1px solid var(--glass-edge);
  box-shadow: 0 30px 60px -22px var(--shadow-tint), inset 0 1px 0 var(--glass-edge);
  animation: settle-in 0.22s ease both;
}

.hub-search-results[hidden] {
  display: none;
}

.result-group {
  margin: 6px 8px 4px;
  font-size: 9.5px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.result-row {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  min-width: 0;
  padding: 8px 10px;
  border: 0;
  border-radius: 12px;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 12.5px;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition: background 0.18s;
}

.result-row:hover,
.result-row:focus-visible {
  background: var(--glass-strong);
  outline: none;
}

.result-dot {
  width: 8px;
  height: 8px;
  flex: none;
  border-radius: 50%;
  background: hsl(var(--accent));
  box-shadow: 0 0 8px -1px hsl(var(--accent) / 0.9);
}

.result-text {
  min-width: 0;
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
}

.result-sub {
  font-size: 10.5px;
  color: var(--ink-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A result with more than one thing you can do with it. The strip sits under
   the row rather than inside it, because the row is itself a link or a button
   and nesting one inside the other is invalid and swallows the clicks. */
.result-wrap {
  display: flex;
  flex-direction: column;
}

/* The middle column is the one that must give: a group id is 64 characters and
   was pushing the action off the row. */
.result-text b,
.result-sub {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.result-action {
  flex: none;
  white-space: nowrap;
}

.result-extras {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  padding: 0 10px 7px 30px;
}

.result-extra {
  padding: 2px 8px;
  border: 1px solid var(--hairline);
  border-radius: 999px;
  background: none;
  color: var(--ink-faint);
  font: inherit;
  font-size: 10.5px;
  text-decoration: none;
  cursor: pointer;
  transition: color 0.18s, border-color 0.18s, background 0.18s;
}

.result-extra:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
  background: var(--glass);
}

.result-action {
  margin-left: auto;
  flex: none;
  font-size: 10px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.result-empty {
  margin: 8px;
  font-size: 11.5px;
  color: var(--ink-faint);
}

/* Hub is the plain launcher; Dashboard adds the panels and the legend around
   it. Nothing about the constellation itself changes, so the switch never
   moves anything the eye is already tracking. */
.view-switch {
  display: inline-flex;
  padding: 3px;
  border-radius: 999px;
  gap: 2px;
  background: var(--chrome-glass);
  border-color: var(--chrome-edge);
  box-shadow: none;
}

.view-btn {
  padding: 6px 14px;
  border: 0;
  border-radius: 999px;
  background: none;
  color: var(--ink-faint);
  font: inherit;
  font-size: 11.5px;
  letter-spacing: 0.06em;
  cursor: pointer;
  transition: background 0.25s, color 0.25s;
}

.view-btn:hover {
  color: var(--ink);
}

.view-btn[aria-pressed='true'] {
  background: var(--glass-strong);
  color: var(--ink);
  box-shadow: inset 0 1px 0 var(--glass-edge);
}

body[data-view='hub'] .dashboard,
body[data-view='hub'] .legend,
body[data-view='hub'] .arrange-hint {
  display: none;
}

/* Header controls are meant to sit lightly on the backdrop: barely there
   until you reach for them. Hence their own, fainter, glass rather than the
   panel glass. */
.theme-toggle {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 14px 8px 11px;
  border: 1px solid var(--chrome-edge);
  border-radius: 999px;
  background: var(--chrome-glass);
  backdrop-filter: blur(16px) saturate(140%);
  -webkit-backdrop-filter: blur(16px) saturate(140%);
  color: var(--ink-soft);
  font: inherit;
  font-size: 12px;
  font-weight: 400;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  cursor: pointer;
  transition: color 0.25s, border-color 0.25s, background 0.25s, transform 0.25s;
}

.theme-toggle:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
  background: var(--glass);
  transform: translateY(-1px);
}

.theme-toggle svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
}

.theme-toggle .icon-moon {
  display: none;
}
:root[data-theme='dark'] .theme-toggle .icon-moon {
  display: block;
}
:root[data-theme='dark'] .theme-toggle .icon-sun {
  display: none;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .theme-toggle .icon-moon {
    display: block;
  }
  :root:not([data-theme='light']) .theme-toggle .icon-sun {
    display: none;
  }
}

/* ---------------------------------------------------------------- stage */

/* The stage takes whatever the header and footer leave, so the ring never
   pushes the page into a scrollbar and no magic number has to be kept in step
   with the chrome. Everything else -- panels, legend, hint -- is positioned
   around the centre inside it, so the identity bubble stays dead centre and
   the whole page reads outward from it. */
.stage {
  position: relative;
  z-index: 2;
  /* The stage itself is only a container. Left interactive it would sit over
     the whole lore layer and swallow every hover meant for it. Its own
     children take their pointer events back below. */
  pointer-events: none;
  flex: 1 1 auto;
  /* The constellation needs room. When the panels are in the flow below it
     they must push the page taller and let it scroll, rather than squeezing
     the stage down until the bubbles pile onto the core. Ring mode only ever
     runs above 760px of viewport height, so this floor cannot itself cause a
     scrollbar. */
  min-height: 600px;
  min-width: 0;
}

.spokes {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}

/* Each service carries one accent hue, which reads well glowing on a dark
   ground and washes out on pale glass. --accent-ink is that hue taken down
   towards the ink colour for the light theme only; the raw --accent stays in
   use for glows, where washing out is the point. */
.node,
.spokes g,
.legend-item {
  --accent-ink: light-dark(color-mix(in oklab, hsl(var(--accent)) 72%, #191634), hsl(var(--accent)));
}

/* Each spoke carries its service's theme colour, so the ring reads as five
   coloured groups radiating from the centre rather than a neutral web. */
.spoke {
  fill: none;
  stroke: var(--accent-ink);
  stroke-width: 1.1;
  stroke-linecap: round;
  opacity: 0.5;
  transition: stroke 0.4s ease, opacity 0.4s ease, stroke-width 0.3s ease;
}

.spoke.is-satellite {
  stroke-width: 1;
  opacity: 0.32;
  stroke-dasharray: 2 7;
}

/* The three zones of the orbit view, drawn faintly behind everything. They do
   two jobs: they say that the sizes mean distance, and they are the snap
   targets, so dragging a bubble is aiming at something visible. */
.orbits {
  pointer-events: none;
}

.orbit-path {
  fill: none;
  stroke: var(--ink);
  stroke-width: 1;
  stroke-dasharray: 2 8;
  stroke-linecap: round;
  opacity: 0.26;
  transition: opacity 0.3s ease, stroke-width 0.3s ease;
}

.orbit-name {
  fill: var(--ink-faint);
  font-size: 9.5px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  /* Named only while something is being arranged. The rest of the time the
     rings carry the idea on their own and three labels would be clutter. */
  opacity: 0;
  transition: opacity 0.3s ease;
}

.stage.is-arranging .orbit-name {
  opacity: 0.75;
}

.stage.is-arranging .orbit-path {
  opacity: 0.22;
}

/* The ring a held bubble would land on. */
.orbit.is-target .orbit-path {
  opacity: 0.5;
  stroke-width: 1.4;
}

.orbit.is-target .orbit-name {
  fill: var(--ink);
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .orbit-path,
  .orbit-name {
    transition: none;
  }
}

.node:hover ~ .constellation .spoke,
.spokes g:hover .spoke {
  opacity: 0.9;
}

.pulse {
  fill: none;
  stroke: var(--accent-ink);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-dasharray: 26 3000;
  stroke-dashoffset: 3026;
  opacity: 0;
  filter: drop-shadow(0 0 6px hsl(var(--accent) / 0.9));
}

.is-signed-in .pulse {
  animation: travel 1.5s cubic-bezier(0.4, 0, 0.2, 1) var(--pulse-delay) 1 both;
}

@keyframes travel {
  0% {
    stroke-dashoffset: 3026;
    opacity: 0;
  }
  12% {
    opacity: 1;
  }
  85% {
    opacity: 1;
  }
  100% {
    stroke-dashoffset: 2600;
    opacity: 0;
  }
}

/* The orbit view puts three rings round the profile where the others put one,
   so the profile gives some of its diameter back. Held at the documented floor
   of 272px, below which the panel content stops fitting inside the circle:
   this is the star of the system getting out of its planets' way, not a
   licence to shrink it further. */
body[data-view='orbit'] .core {
  --core-size: clamp(236px, 26vh, 258px);
}

/* ----------------------------------------------------------------- glass */

.glass {
  background: var(--glass);
  backdrop-filter: blur(22px) saturate(160%);
  -webkit-backdrop-filter: blur(22px) saturate(160%);
  border: 1px solid var(--hairline);
  box-shadow: var(--shadow), inset 0 1px 0 var(--glass-edge);
}

/* ------------------------------------------------------------------ core */

.core {
  position: absolute;
  top: 50%;
  left: 50%;
  /* Tied to the viewport height, because the ring has to clear the core and
     a short window has no room for both at full size. The floor is the point
     below which the panel content stops fitting inside the circle. */
  width: var(--core-size, clamp(244px, 29vh, 286px));
  height: var(--core-size, clamp(244px, 29vh, 286px));
  /* Its own, more opaque ground rather than the general glass: this circle
     carries the smallest text on the page, over the brightest part of the
     backdrop, and at panel opacity the numbers were hard to read. */
  background: light-dark(rgba(255, 255, 255, 0.7), rgba(22, 20, 52, 0.62));
  margin: 0;
  border-radius: 50%;
  display: grid;
  place-items: center;
  padding: 18px;
  text-align: center;
  /* --x and --y are the drift, rewritten every frame by the animation loop.
     --core-dx and --core-dy are where you dragged it to, which must survive
     that: hence two pairs rather than one. */
  transform: translate(
    calc(-50% + var(--x, 0px) + var(--core-dx, 0px)),
    calc(-50% + var(--y, 0px) + var(--core-dy, 0px))
  );
  cursor: grab;
  z-index: 4;
}

.core::before {
  content: '';
  position: absolute;
  inset: -34px;
  border-radius: 50%;
  background: radial-gradient(circle at 50% 50%, var(--halo), transparent 68%);
  opacity: 0.85;
  z-index: -1;
  animation: breathe 7s ease-in-out infinite;
}

.core::after {
  content: '';
  position: absolute;
  inset: 1px;
  border-radius: 50%;
  background: radial-gradient(120% 90% at 30% 14%, rgba(255, 255, 255, 0.3), transparent 58%);
  pointer-events: none;
}

@keyframes breathe {
  50% {
    opacity: 0.45;
    transform: scale(1.07);
  }
}

.core-panel {
  display: none;
  width: 100%;
  flex-direction: column;
  align-items: center;
  gap: 7px;
  animation: fade-up 0.5s ease both;
}

/* The signed-in panel carries the most: avatar, name, key, six numbers, a
   status line and a way out. It is packed tighter so all of it stays inside
   the circle. */
/* The signed-in stack, sized to FIT. Measured rather than guessed: at the old
   values the content ran 281px inside a 261px circle with 30px of padding, so
   the status line and the sign-out button hung out of the bottom of it. The
   usable height is the diameter less twice the padding, and everything below
   is set against that.

   Note there is no second .identity-name rule any more. There were two, 16
   lines apart, setting 17px and then 19px; the second silently won, which is
   why editing the first one changed nothing. */
.core[data-state='in'] .core-panel {
  gap: 3px;
}

.core[data-state='in'] .avatar {
  width: 40px;
  height: 40px;
}

.core[data-state='in'] .identity-name {
  font-size: 16px;
}

.core[data-state='in'] .identity-status {
  font-size: 9.5px;
  line-height: 1.3;
  max-width: 23ch;
  color: var(--ink-soft);
}

.core[data-state='in'] .npub {
  font-size: 10px;
  padding: 3px 10px;
}

.core[data-state='in'] .core-stats {
  max-width: 210px;
  gap: 6px 6px;
}

.core[data-state='in'] .btn-quiet {
  padding: 0 10px;
}

.core[data-state='out'] .panel-out,
.core[data-state='key'] .panel-key,
.core[data-state='busy'] .panel-busy,
.core[data-state='in'] .panel-in {
  display: flex;
}

@keyframes fade-up {
  from {
    opacity: 0;
    transform: translateY(7px);
  }
}

.eyebrow {
  font-size: 10.5px;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin: 0;
}

.core h1 {
  margin: 0;
  font-size: 27px;
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.15;
}

.core p.sub {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--ink-soft);
  max-width: 21ch;
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  max-width: 214px;
  padding: 10px 16px;
  border-radius: 999px;
  border: 1px solid var(--hairline);
  background: var(--glass-strong);
  color: var(--ink);
  font: inherit;
  font-size: 13.5px;
  cursor: pointer;
  transition: transform 0.22s, border-color 0.22s, box-shadow 0.22s, background 0.22s;
}

.btn:hover {
  transform: translateY(-1.5px);
  border-color: var(--glass-edge);
  box-shadow: 0 12px 26px -14px var(--halo);
}

.btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  transform: none;
}

.btn-primary {
  background: linear-gradient(135deg, hsl(265 90% 62%), hsl(210 92% 58%));
  border-color: transparent;
  color: #fff;
  box-shadow: 0 14px 30px -14px hsl(265 90% 62% / 0.9);
}

.btn-quiet {
  border-color: transparent;
  background: none;
  font-size: 12.5px;
  color: var(--ink-soft);
  padding: 4px 10px;
  width: auto;
}

.btn-quiet:hover {
  color: var(--ink);
  box-shadow: none;
}

.btn svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.key-form {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  width: 100%;
}

.key-input {
  width: 100%;
  max-width: 222px;
  padding: 10px 14px;
  border-radius: 999px;
  border: 1px solid var(--hairline);
  background: var(--glass-strong);
  color: var(--ink);
  font: inherit;
  font-size: 12.5px;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  text-align: center;
  transition: border-color 0.22s, box-shadow 0.22s;
}

.key-input::placeholder {
  color: var(--ink-faint);
  font-family: inherit;
}

.key-input:focus {
  outline: none;
  border-color: var(--focus);
  box-shadow: 0 0 0 3px var(--halo);
}

.caution {
  margin: 0;
  font-size: 10.5px;
  line-height: 1.45;
  color: var(--ink-faint);
  max-width: 25ch;
}

.extension-note a {
  color: var(--ink-soft);
  text-decoration: none;
  border-bottom: 1px solid var(--hairline);
}

.extension-note a:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
}

.error {
  margin: 0;
  font-size: 11.5px;
  line-height: 1.4;
  color: var(--danger);
  max-width: 25ch;
  min-height: 1em;
}

/* signed in */

.avatar {
  width: 46px;
  height: 46px;
  border-radius: 50%;
  object-fit: cover;
  border: 1px solid var(--glass-edge);
  background: var(--glass-strong);
  box-shadow: 0 10px 26px -12px var(--halo);
}

.avatar-bloom {
  box-shadow: 0 10px 26px -12px var(--halo), inset 0 0 18px rgba(255, 255, 255, 0.28);
}

.identity-name {
  margin: 0;
  font-size: 16px;
  font-weight: 500;
  letter-spacing: -0.01em;
  max-width: 15ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.npub {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 11px;
  border-radius: 999px;
  border: 1px solid var(--hairline);
  background: var(--glass-strong);
  color: var(--ink-soft);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 10.5px;
  cursor: pointer;
  transition: color 0.2s, border-color 0.2s;
}

.npub:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
}

.npub svg {
  width: 11px;
  height: 11px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
}

/* Your numbers, inside the identity bubble rather than in a side panel: this
   is the one place on the page that is about you. Two rows of three, kept
   narrow so they stay inside the circle. */
.core-stats {
  display: grid;
  /* Columns follow the count, written by renderCoreStats, so two numbers sit
     as a pair and five do not leave one stranded on a row of its own. */
  grid-template-columns: repeat(var(--stat-columns, 3), minmax(0, 1fr));
  gap: 6px 4px;
  width: 100%;
  max-width: 208px;
}

.core-stats[data-columns='2'] {
  --stat-columns: 2;
  max-width: 150px;
}
.core-stats[data-columns='3'] {
  --stat-columns: 3;
}
.core-stats[data-columns='4'] {
  --stat-columns: 4;
  max-width: 224px;
}

.core-stat {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0;
}

.core-stat b {
  font-size: 17px;
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
  transition: color 0.2s ease;
}

.core-stat span {
  font-size: 8.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-soft);
  white-space: nowrap;
}

.spinner {
  width: 30px;
  height: 30px;
  border-radius: 50%;
  border: 2px solid var(--hairline);
  border-top-color: var(--ink-soft);
  animation: spin 0.9s linear infinite;
}

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

/* ----------------------------------------------------------------- nodes */

/* The node IS the bubble: its label hangs off the bottom in its own absolute
   box. That way the point the layout positions and the circle on screen are
   the same point, and clearance arithmetic in app.js means what it says. */
/* Size, icon, label and text width are set on the element by applyTier in
   app.js, from the one table there. The values below are the fallbacks that
   apply before a tier has been assigned; nothing here should restate a size
   that the table already owns. */
.node {
  --size: var(--tier-size, 72px);
  --icon: var(--tier-icon, 26px);
  --label: var(--tier-label, 13px);
  --text-w: var(--tier-text, 100px);
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 3;
  width: var(--size);
  height: var(--size);
  text-decoration: none;
  color: inherit;
  transform: translate(calc(-50% + var(--x, 0px)), calc(-50% + var(--y, 0px)));
  /* The bubble changes size when it crosses into another zone mid-drag, so
     the change has to be something you watch rather than a jump. */
  transition: width 0.35s cubic-bezier(0.34, 1.3, 0.5, 1),
    height 0.35s cubic-bezier(0.34, 1.3, 0.5, 1);
}

.node-text {
  position: absolute;
  top: calc(100% + 9px);
  left: 50%;
  transform: translateX(-50%);
  /* Narrower than the bubble spacing allows for. At 132px the labels of two
     neighbours in the same theme overlapped even when their bubbles did not,
     because clustering deliberately tightens spacing within a theme. */
  width: var(--text-w);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  text-align: center;
}

.bubble {
  position: relative;
  width: var(--size);
  height: var(--size);
  border-radius: 50%;
  display: grid;
  place-items: center;
  /* The rim carries the theme colour too, so a bubble is identifiable by its
     edge as well as by its icon. */
  border-color: hsl(var(--accent) / 0.3);
  transition: transform 0.4s cubic-bezier(0.34, 1.4, 0.5, 1), box-shadow 0.4s ease,
    border-color 0.4s ease, width 0.35s cubic-bezier(0.34, 1.3, 0.5, 1),
    height 0.35s cubic-bezier(0.34, 1.3, 0.5, 1);
}

.bubble::after {
  content: '';
  position: absolute;
  inset: 1px;
  border-radius: 50%;
  background: radial-gradient(115% 85% at 32% 16%, rgba(255, 255, 255, 0.32), transparent 55%);
  pointer-events: none;
}

.bubble svg {
  width: var(--icon);
  height: var(--icon);
  fill: none;
  stroke: var(--accent-ink);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: filter 0.4s ease, transform 0.4s ease;
  filter: drop-shadow(0 0 7px hsl(var(--accent) / 0.4));
}

.node-label {
  font-size: var(--label);
  font-weight: 500;
  letter-spacing: 0.005em;
  transition: color 0.3s;
}

/* Shown only in compact mode, where there is no hover to put it behind. */
.node-blurb {
  display: none;
}

.node-kind {
  font-size: 10px;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--ink-soft);
  /* Same shadow as the label: these sit over bubbles and the backdrop, where
     a faint tone disappears. */
  text-shadow: 0 1px 10px light-dark(rgba(255, 255, 255, 0.55), rgba(0, 0, 0, 0.6));
  transition: color 0.3s;
}

/* Labels read outward from the centre: under a bubble in the lower half of the
   orbit view, over one in the upper half. Set by measure() in app.js, which
   also does the footprint arithmetic for whichever side it chose. */
.node.label-above .node-text {
  top: auto;
  bottom: calc(100% + 9px);
}

/* The outermost tier carries less: a quieter label, and no category line. It
   is the smallest thing on the stage and the kind would be four pixels of
   uppercase nobody reads. */
.node[data-tier='3'] .node-label {
  color: var(--ink-soft);
}

.node[data-tier='3'] .node-kind,
.node[data-tier='2'] .node-kind {
  display: none;
}

/* The middle tier keeps its kind line only while it is being pointed at,
   which is the moment the extra word is worth the space. */
.node[data-tier='2']:hover .node-kind,
.node[data-tier='2']:focus-within .node-kind {
  display: block;
}

/* The node's own z-index makes it a stacking context, so the hover card
   cannot escape it: raising the card alone left it painted behind whichever
   bubbles come later in the DOM. The whole node lifts instead. */
.node:hover,
.node:focus-within {
  z-index: 7;
}

.node:hover .bubble,
.node:focus-visible .bubble {
  transform: scale(1.11);
  border-color: hsl(var(--accent) / 0.6);
  box-shadow: var(--shadow-lift), inset 0 1px 0 var(--glass-edge),
    0 0 34px -4px hsl(var(--accent) / 0.55);
}

.node:hover .bubble svg,
.node:focus-visible .bubble svg {
  filter: drop-shadow(0 0 12px hsl(var(--accent) / 0.85));
  transform: scale(1.06);
}

.node:hover .node-label {
  color: var(--accent-ink);
}

.node:focus-visible {
  outline: none;
}

.node:focus-visible .node-label {
  color: var(--accent-ink);
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* hover card */

.tip {
  position: absolute;
  top: calc(100% + 62px);
  left: 50%;
  width: 208px;
  padding: 11px 13px;
  border-radius: 15px;
  transform: translate(-50%, 5px);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.28s ease, transform 0.28s ease, visibility 0.28s;
  pointer-events: none;
  font-size: 11.5px;
  line-height: 1.45;
  color: var(--ink-soft);
  text-align: center;
  z-index: 6;
}

.node.tip-above .tip {
  top: auto;
  bottom: calc(100% + 14px);
  transform: translate(-50%, -5px);
}

.node.tip-above:hover .tip,
.node.tip-above:focus-visible .tip {
  transform: translate(-50%, 0);
}

.node:hover .tip,
.node:focus-visible .tip {
  opacity: 1;
  visibility: visible;
  transform: translate(-50%, 0);
}

.tip-category {
  display: block;
  margin-bottom: 5px;
  font-size: 9px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--accent-ink);
}

.tip .go {
  display: block;
  margin-top: 7px;
  font-size: 10.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent-ink);
}

/* ---------------------------------------------------------------- legend */

.legend {
  display: flex;
  flex-direction: column;
  box-shadow: 0 8px 22px -18px var(--shadow-tint), inset 0 1px 0 var(--glass-edge);
  gap: 2px;
  padding: 13px 13px 11px;
  border-radius: 18px;
  min-width: 172px;
}

.legend-title {
  margin: 0 0 6px 6px;
  font-size: 9.5px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.legend-item {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 6px 7px;
  border: 0;
  border-radius: 10px;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 12.5px;
  text-align: left;
  cursor: pointer;
  transition: background 0.22s, color 0.22s;
}

/* Pointing at a bubble names its theme here. The row takes the theme's own
   colour and unfolds its description; the other five step back so the answer
   is unambiguous. */
.legend-item {
  flex-wrap: wrap;
  row-gap: 0;
}

.legend-blurb {
  flex-basis: 100%;
  padding-left: 19px;
  font-size: 11px;
  line-height: 1.35;
  color: var(--ink-faint);
  /* Collapsed rather than hidden, so six of these do not make the panel jump
     by six rows the moment one opens. */
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition: grid-template-rows 0.28s cubic-bezier(0.2, 0.8, 0.3, 1), opacity 0.22s ease;
}

.legend-blurb > * {
  overflow: hidden;
}

.legend-item.is-live {
  background: var(--glass-strong);
  color: var(--accent-ink, var(--ink));
}

.legend-item.is-live .legend-blurb {
  grid-template-rows: 1fr;
  opacity: 1;
  padding-top: 3px;
}

.legend-item.is-live .legend-dot {
  box-shadow: 0 0 0 3px hsl(var(--accent) / 0.28), 0 0 14px hsl(var(--accent) / 0.7);
}

.legend-item.is-faint {
  opacity: 0.42;
}

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

.legend-item:hover,
.legend-item:focus-visible {
  background: var(--glass-strong);
  color: var(--ink);
  outline: none;
}

.legend-dot {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  flex: none;
  background: hsl(var(--accent));
  box-shadow: 0 0 10px -1px hsl(var(--accent) / 0.9);
}

.legend-count {
  margin-left: auto;
  font-size: 10.5px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-faint);
}

.popped-note {
  margin-top: 7px;
  padding: 7px 9px;
  border: 1px solid hsl(352 90% 62% / 0.35);
  border-radius: 10px;
  background: hsl(352 90% 62% / 0.09);
  color: var(--ink-soft);
  font: inherit;
  font-size: 10.5px;
  line-height: 1.35;
  text-align: left;
  cursor: pointer;
  transition: color 0.2s, border-color 0.2s;
}

.popped-note:hover {
  color: var(--ink);
  border-color: hsl(352 90% 62% / 0.7);
}

.reset-layout {
  margin-top: 7px;
  padding: 6px 8px;
  border: 1px solid var(--hairline);
  border-radius: 10px;
  background: none;
  color: var(--ink-soft);
  font: inherit;
  font-size: 11px;
  cursor: pointer;
  transition: color 0.2s, border-color 0.2s;
}

.reset-layout:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
}

/* Inside the legend card, where it cannot land on a bubble or a panel. */
.arrange-hint {
  margin: 7px 6px 0;
  font-size: 10.5px;
  line-height: 1.45;
  color: var(--ink-faint);
}

/* Highlighting a theme from the legend. */
.node.is-dim,
.spokes g.is-dim {
  opacity: 0.18;
}

.node.is-dim {
  filter: saturate(0.4);
}

.node,
.spokes g {
  transition: opacity 0.35s ease, filter 0.35s ease;
}

/* ------------------------------------------------------------- dragging */

/* A just-added bubble pulses so you can find it in a ring of a dozen. */
.node.is-new {
  z-index: 6;
}

.node.is-new .bubble {
  animation: new-pulse 1.2s cubic-bezier(0.3, 0, 0.2, 1) infinite;
  border-color: hsl(var(--accent) / 0.9);
}

.node.is-new .node-label {
  color: var(--accent-ink);
  font-weight: 600;
}

.node.is-new .bubble svg {
  filter: drop-shadow(0 0 12px hsl(var(--accent) / 0.95));
}

@keyframes new-pulse {
  0% {
    box-shadow: 0 0 0 0 hsl(var(--accent) / 0.8), 0 0 26px -2px hsl(var(--accent) / 0.65);
    transform: scale(1);
  }
  55% {
    box-shadow: 0 0 0 28px hsl(var(--accent) / 0), 0 0 34px -2px hsl(var(--accent) / 0.4);
    transform: scale(1.08);
  }
  100% {
    box-shadow: 0 0 0 0 hsl(var(--accent) / 0), 0 0 26px -2px hsl(var(--accent) / 0.65);
    transform: scale(1);
  }
}

.node.is-dragging {
  z-index: 8;
  cursor: grabbing;
}

.node.is-dragging .bubble {
  transform: scale(1.14);
  box-shadow: var(--shadow-lift), inset 0 1px 0 var(--glass-edge),
    0 0 44px -2px hsl(var(--accent) / 0.7);
}

.node.is-dragging .tip {
  opacity: 0;
  visibility: hidden;
}

.stage.is-arranging {
  cursor: grabbing;
  user-select: none;
}

.stage.is-arranging .node:not(.is-dragging) {
  opacity: 0.55;
}

/* ------------------------------------------------------------- dashboard */

/* Two arrangements, and the narrow one is the base so it cannot be got wrong:
   the panels sit below the constellation in ordinary flow, where nothing can
   collide with anything. Only when there is genuinely room do they lift into
   two rails either side of the centre. Rails rather than fixed corners,
   because the number of panels is now up to the reader. */
.dashboard {
  position: relative;
  z-index: 6;
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: center;
  gap: 12px;
  width: 100%;
  max-width: 1000px;
  margin: 0 auto;
  padding: 4px clamp(16px, 4vw, 32px) 8px;
}

.dashboard[hidden] {
  display: none;
}

.rail {
  display: flex;
  flex-direction: column;
  gap: 12px;
  flex: 1 1 300px;
  min-width: 0;
  max-width: 460px;
}

.card {
  animation: settle-in 0.55s cubic-bezier(0.2, 0.8, 0.3, 1) both;
  padding: 15px 16px;
  border-radius: 20px;
  background: var(--glass);
  backdrop-filter: blur(22px) saturate(160%);
  -webkit-backdrop-filter: blur(22px) saturate(160%);
  border: 1px solid var(--hairline);
  /* Deliberately tighter than the shared --shadow, which blurs 64px and is
     offset 26px down: each panel cast a soft dark smudge below itself, the
     panel's own width, which read as a stray vertical gradient on the
     backdrop rather than as a shadow. */
  box-shadow: 0 8px 22px -18px var(--shadow-tint), inset 0 1px 0 var(--glass-edge);
}

/* Opacity only. This animation also animated transform, and a running or
   filling animation overrides the inline transform that dragging a panel
   sets, so a dragged card saved its new position and then sat where it was. */
@keyframes settle-in {
  from {
    opacity: 0;
  }
}

.card-you {
  animation-delay: 0.05s;
}

.card-relays {
  animation-delay: 0.13s;
}

.card-feed {
  animation-delay: 0.21s;
}

/* Wide enough for the panels to leave the flow and take up station around the
   centre. They are fixed to the viewport, so the header and footer stay clear
   of them, and the constellation reads their real boxes (keepOutBoxes in
   app.js) and pulls in until no bubble sits underneath one.

   Below this width three corner panels and a twelve-bubble ring genuinely do
   not both fit: the ring would have to shrink so far to clear them that it
   would stop being the point of the page. So the flow arrangement above is
   the base, and this is the enhancement. */
@media (min-width: 1180px) {
  .dashboard {
    display: contents;
  }

  .rail {
    position: fixed;
    /* Above the stage. The stage is a positioned element with z-index 2, and
       a positioned element with z-index auto paints BENEATH it whatever the
       DOM order, so without this the stage lies over the panels and eats
       every click on them: the legend, the reset button, the panel controls.
       They looked fine and did nothing. */
    z-index: 6;
    /* MEASURED, not assumed. This was a hard 84px, from when the header was
       one short row; the header has since grown a search bar and a six-way
       view switcher and now stands 126px tall, so the first card in the LEFT
       rail was landing on top of the wordmark. Nothing caught it because the
       left rail's usual occupant is the legend, which is pinned to the rail's
       foot and so never sat at the top. applyRailTop() in app.js writes the
       header's real height here and keeps it current as the header wraps. */
    top: var(--rail-top, 84px);
    bottom: 78px;
    /* Narrow enough that a 1280 laptop can still hold the ring between the
       two rails. Below 1180 they go back into the flow. */
    width: clamp(198px, 17.5vw, 262px);
    max-width: none;
    flex: none;
    /* The rail scrolls. It used to be overflow:visible with every card capped
       at 38vh instead, which kept the rail on screen by squeezing each panel
       into a letterbox: with four widgets on, none of them showed enough to be
       worth having. Scrolling the rail lets a panel be as tall as it needs.

       The two reasons that were given for not scrolling are both answered
       rather than avoided. A scroll container clips horizontally as well, so a
       dragged card would be cut off at the rail's edge: the rail gives up its
       overflow for the duration of a drag (see .is-dragging-panel below). And
       the scrollbar chrome read as a stray grey gradient between the panels:
       it is styled to the page below, and the rail is masked so content fades
       out at both ends rather than being sliced. */
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-gutter: stable;
    /* Room for the scrollbar and the fade, so neither sits on the cards. */
    padding: 10px 4px 14px;
    scrollbar-width: thin;
    scrollbar-color: hsl(var(--rail-thumb)) transparent;
    -webkit-mask-image: linear-gradient(
      to bottom,
      transparent 0,
      #000 12px,
      #000 calc(100% - 16px),
      transparent 100%
    );
    mask-image: linear-gradient(
      to bottom,
      transparent 0,
      #000 12px,
      #000 calc(100% - 16px),
      transparent 100%
    );
  }

  /* A slim glass scrollbar rather than the platform's. Same hairline and same
     translucency as the panels it runs beside, so it reads as part of the rail
     rather than as chrome bolted onto it. */
  .rail::-webkit-scrollbar {
    width: 7px;
  }

  .rail::-webkit-scrollbar-track {
    background: transparent;
  }

  .rail::-webkit-scrollbar-thumb {
    border-radius: 999px;
    background: hsl(var(--rail-thumb));
    border: 2px solid transparent;
    background-clip: padding-box;
    transition: background 0.25s ease;
  }

  .rail:hover::-webkit-scrollbar-thumb {
    background: hsl(var(--rail-thumb-hover));
    background-clip: padding-box;
  }

  /* Dragging a panel: the rail stops being a scroll container so the card can
     come out of it, and the mask goes with it or the card fades as it leaves.
     Both rails, because a card can be dragged from one side to the other. */
  body.is-dragging-panel .rail {
    overflow: visible;
    -webkit-mask-image: none;
    mask-image: none;
  }

  /* A cap remains, but a generous one: it stops a single tall panel, usually
     Raw events, from being the entire rail. */
  .card {
    max-height: 72vh;
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-width: none;
  }

  .card::-webkit-scrollbar {
    width: 0;
  }

  .rail-left {
    left: clamp(16px, 2.4vw, 30px);
  }

  .rail-right {
    right: clamp(16px, 2.4vw, 30px);
  }

  .card {
    flex: 0 0 auto;
  }

  /* Pinned to the foot of its rail, however many panels are above it. */
  .legend {
    margin-top: auto;
  }
}

.card-title {
  margin: 0 0 10px;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.card-note {
  margin: 10px 0 0;
  font-size: 11px;
  line-height: 1.5;
  color: var(--ink-faint);
}

.dash-about {
  margin: 0 0 10px;
  font-size: 12px;
  line-height: 1.5;
  color: var(--ink-soft);
}

.dash-about:empty {
  display: none;
}

.dash-nip05 {
  margin: 0 0 10px;
  font-size: 11px;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  color: var(--ink-faint);
  overflow: hidden;
  text-overflow: ellipsis;
}

.stats {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
}

.stat {
  display: flex;
  flex-direction: column;
  gap: 1px;
  padding: 9px 11px;
  border-radius: 13px;
  background: var(--glass-strong);
  border: 1px solid var(--hairline);
}

.stat-value {
  font-size: 20px;
  font-weight: 500;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}

.stat-label {
  font-size: 10.5px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.stat-note {
  font-size: 10px;
  color: var(--ink-faint);
}

.relay-list,
.feed-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 7px;
}

.relay-row {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 12px;
}

.relay-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  flex: none;
  background: var(--ink-faint);
  transition: background 0.3s, box-shadow 0.3s;
}

.relay-row[data-state='connecting'] .relay-dot,
.relay-row[data-state='open'] .relay-dot {
  background: hsl(48 96% 60%);
  animation: pulse-dot 1.1s ease-in-out infinite;
}

.relay-row[data-state='done'] .relay-dot {
  background: hsl(158 84% 50%);
  box-shadow: 0 0 8px -1px hsl(158 84% 50% / 0.9);
}

.relay-row[data-state='failed'] .relay-dot,
.relay-row[data-state='timeout'] .relay-dot {
  background: var(--danger);
}

@keyframes pulse-dot {
  50% {
    opacity: 0.3;
  }
}

.relay-name {
  color: var(--ink-soft);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.relay-meta {
  margin-left: auto;
  flex: none;
  font-size: 10.5px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-faint);
}

.card-feed {
  min-height: 0;
}

.feed-item {
  padding: 9px 11px;
  border-radius: 13px;
  background: var(--glass-strong);
  border: 1px solid var(--hairline);
}

.feed-text {
  margin: 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--ink-soft);
  overflow-wrap: anywhere;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.feed-time {
  display: block;
  margin-top: 5px;
  font-size: 9.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

/* -------------------------------------------------------- raw events */

.event-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 7px;
}

.event-item {
  border-radius: 13px;
  background: var(--glass-strong);
  border: 1px solid var(--hairline);
  overflow: hidden;
}

.event-head {
  display: flex;
  align-items: center;
  gap: 7px;
  width: 100%;
  padding: 8px 10px;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 11px;
  text-align: left;
  cursor: pointer;
}

.kind-badge {
  flex: none;
  padding: 2px 7px;
  border-radius: 999px;
  font-size: 9.5px;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.04em;
  border: 1px solid transparent;
}

.kind-badge[data-class='regular'] {
  background: hsl(210 90% 60% / 0.16);
  color: light-dark(hsl(210 90% 34%), hsl(210 90% 76%));
}
.kind-badge[data-class='replaceable'] {
  background: hsl(32 96% 58% / 0.18);
  color: light-dark(hsl(32 96% 32%), hsl(32 96% 72%));
}
.kind-badge[data-class='ephemeral'] {
  background: hsl(352 90% 62% / 0.16);
  color: light-dark(hsl(352 80% 40%), hsl(352 100% 78%));
}
.kind-badge[data-class='addressable'] {
  background: hsl(265 90% 68% / 0.18);
  color: light-dark(hsl(265 80% 42%), hsl(265 95% 80%));
}

.event-kind-name {
  color: var(--ink-soft);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.event-check {
  margin-left: auto;
  flex: none;
  font-size: 9.5px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.event-check[data-ok='true'] {
  color: hsl(158 84% 42%);
}
:root[data-theme='dark'] .event-check[data-ok='true'],
:root:not([data-theme='light']) .event-check[data-ok='true'] {
  color: hsl(158 84% 62%);
}

.event-check[data-ok='false'] {
  color: var(--danger);
}

.event-json {
  margin: 0;
  padding: 9px 10px;
  border-top: 1px solid var(--hairline);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 9.5px;
  line-height: 1.5;
  color: var(--ink-soft);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  max-height: 220px;
  overflow-y: auto;
}

/* ----------------------------------------------------- relay disagreement */

.spread-summary {
  margin: 0 0 10px;
  font-size: 11.5px;
  line-height: 1.5;
  color: var(--ink-soft);
}

.spread-summary b {
  font-weight: 600;
  color: var(--ink);
}

.spread-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 6px;
  font-size: 11px;
}

.spread-name {
  width: 40%;
  color: var(--ink-soft);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.spread-bar {
  flex: 1;
  height: 6px;
  border-radius: 999px;
  background: var(--glass-strong);
  border: 1px solid var(--hairline);
  overflow: hidden;
}

.spread-fill {
  display: block;
  height: 100%;
  border-radius: 999px;
  background: linear-gradient(90deg, hsl(188 90% 55%), hsl(265 90% 68%));
}

.spread-count {
  flex: none;
  font-size: 10px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-faint);
}

/* ---------------------------------------------------------- app window */

.appwin {
  position: fixed;
  /* THE WINDOW BAND, 41 to 45, one per WINDOW_LIMIT. Windows are restacked
     inside it on every focus rather than climbing a counter, so the dock at 50
     and the sheet at 55 stay reachable however long the session runs. */
  z-index: 41;
  inset: 68px clamp(12px, 2.5vw, 34px) clamp(12px, 2vh, 26px);
  display: flex;
  flex-direction: column;
  border-radius: 20px;
  overflow: hidden;
  border: 1px solid var(--glass-edge);
  background: light-dark(rgba(255, 255, 255, 0.86), rgba(14, 12, 34, 0.86));
  backdrop-filter: blur(26px) saturate(160%);
  -webkit-backdrop-filter: blur(26px) saturate(160%);
  box-shadow: 0 50px 110px -30px rgba(0, 0, 0, 0.65);
  animation: window-in 0.32s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}

.appwin[hidden] {
  display: none;
}

@keyframes window-in {
  from {
    opacity: 0;
    transform: translateY(14px) scale(0.985);
  }
}

.appwin-bar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 12px;
  border-bottom: 1px solid var(--hairline);
  background: var(--glass);
  /* The bar is the handle, so it must not select text as you drag it. */
  cursor: grab;
  user-select: none;
}

body.is-moving-window .appwin-bar {
  cursor: grabbing;
}

/* Dragging over an iframe loses the pointer to the framed document, which then
   swallows every move event and the window sticks. Taking pointer events off
   the frame for the duration keeps them coming back to us. */
body.is-moving-window .appwin-frame {
  pointer-events: none;
}

.appwin-sizes {
  margin-left: auto;
  flex: none;
}

.appwin-sizes .seg-btn {
  min-width: 28px;
  padding: 3px 7px;
  font-size: 11px;
}

/* Contacting the maker belongs in the footer beside his name, not in the bar
   of somebody else's app: in a window showing Ditto, "contact the maker" reads
   as an offer to contact Ditto's. */

/* The corner you drag to resize. Two short strokes, the convention, rather
   than a box that would read as content. */
.appwin-grip {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 18px;
  height: 18px;
  z-index: 2;
  cursor: nwse-resize;
  background-image: linear-gradient(
    135deg,
    transparent 0 45%,
    var(--ink-faint) 45% 55%,
    transparent 55% 70%,
    var(--ink-faint) 70% 80%,
    transparent 80%
  );
  opacity: 0.55;
}

.appwin-grip:hover {
  opacity: 1;
}

.appwin-dot {
  width: 10px;
  height: 10px;
  flex: none;
  border-radius: 50%;
}

.appwin-title {
  font-size: 13px;
  font-weight: 600;
}

.appwin-host {
  font-size: 11px;
  color: var(--ink-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.appwin-open {
  margin-left: auto;
  flex: none;
  font-size: 11px;
  color: var(--ink-soft);
  text-decoration: none;
  border-bottom: 1px solid var(--hairline);
}

.appwin-open:hover {
  color: var(--ink);
}

.appwin-close {
  flex: none;
  width: 26px;
  height: 26px;
  border: 1px solid var(--hairline);
  border-radius: 50%;
  background: none;
  color: var(--ink-soft);
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
  transition: color 0.2s, border-color 0.2s, background 0.2s;
}

.appwin-close:hover {
  color: #fff;
  border-color: transparent;
  background: hsl(352 90% 62%);
}

.appwin-body {
  position: relative;
  flex: 1;
  min-height: 0;
  background: light-dark(#fff, #0b0a18);
}

.appwin-frame {
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}

.appwin-frame[hidden] {
  display: none;
}

/* A bar along the bottom, not a cover. As a full overlay it hid a service
   that had loaded slowly but perfectly well. */
.appwin-blocked {
  position: absolute;
  inset: auto 0 0 0;
  z-index: 2;
  background: light-dark(rgba(255, 255, 255, 0.95), rgba(11, 10, 24, 0.95));
  border-top: 1px solid var(--hairline);
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 10px 16px;
  padding: 12px 18px;
  text-align: center;
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--ink-soft);
}

.appwin-blocked[hidden] {
  display: none;
}

.appwin-blocked p {
  margin: 0;
  max-width: 46ch;
}

.appwin-blocked-title {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
}

.appwin-blocked p {
  font-size: 11.5px;
}

.appwin-blocked-open {
  margin-top: 6px;
  width: auto;
  text-decoration: none;
}

/* ------------------------------------------------------------ add sheet */

/* Each sheet section belongs to one button. */
.sheet[data-mode='add'] .sheet-section[data-sheet='settings'],
.sheet[data-mode='settings'] .sheet-section[data-sheet='add'] {
  display: none;
}

/* The paintbrush swatch, and the colours it slides down. */
.paint {
  position: relative;
}

.paint-btn {
  position: relative;
  display: inline-grid;
  place-items: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 1px solid var(--chrome-edge);
  border-radius: 999px;
  background: var(--chrome-glass);
  cursor: pointer;
  transition: border-color 0.25s, transform 0.25s;
}

.paint-btn:hover {
  border-color: var(--glass-edge);
  transform: translateY(-1px);
}

.paint-swatch {
  position: absolute;
  inset: 4px;
  border-radius: 50%;
  opacity: 0.9;
}

.paint-icon {
  position: relative;
  width: 15px;
  height: 15px;
  fill: none;
  stroke: #fff;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}

.bubbles {
  position: relative;
}

.bubbles-btn {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 1px solid var(--hairline);
  border-radius: 50%;
  background: var(--glass);
  color: var(--ink-soft);
  cursor: pointer;
  transition: border-color 0.2s, color 0.2s, transform 0.2s;
}

.bubbles-btn:hover {
  border-color: var(--glass-edge);
  color: var(--ink);
  transform: translateY(-1px);
}

.bubbles-icon {
  width: 19px;
  height: 19px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.4;
}

/* One filled highlight, so the cluster reads as bubbles rather than as rings. */
.bubbles-icon .bubbles-shine {
  fill: currentColor;
  stroke: none;
  opacity: 0.75;
}

.bubbles-menu {
  position: absolute;
  top: calc(100% + 10px);
  right: 0;
  z-index: 30;
  width: 292px;
  max-height: min(70vh, 560px);
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: hsl(var(--rail-thumb)) transparent;
  padding: 13px 14px 15px;
  border: 1px solid var(--hairline);
  border-radius: 16px;
  background: var(--glass-strong);
  backdrop-filter: blur(22px) saturate(160%);
  -webkit-backdrop-filter: blur(22px) saturate(160%);
  box-shadow: var(--shadow);
  animation: fade-in 0.18s ease both;
}

.bubbles-menu[hidden] {
  display: none;
}

.bubbles-note {
  margin: 0 0 10px;
  font-size: 11px;
  line-height: 1.45;
  color: var(--ink-faint);
}

.discovery-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 10px;
}

.discovery-toggle {
  width: 100%;
}

.paint-menu {
  position: absolute;
  top: calc(100% + 10px);
  right: 0;
  z-index: 22;
  width: 232px;
  padding: 13px;
  border-radius: 18px;
  border: 1px solid var(--hairline);
  background: var(--glass);
  backdrop-filter: blur(22px) saturate(160%);
  -webkit-backdrop-filter: blur(22px) saturate(160%);
  box-shadow: var(--shadow), inset 0 1px 0 var(--glass-edge);
  animation: paint-drop 0.24s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}

.paint-menu[hidden] {
  display: none;
}

@keyframes paint-drop {
  from {
    opacity: 0;
    transform: translateY(-8px);
  }
}

.paint-label {
  margin: 0 0 7px 2px;
  font-size: 9.5px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.paint-menu .seg {
  width: 100%;
  margin-bottom: 12px;
}

.paint-menu .seg-btn {
  flex: 1;
  padding: 6px 8px;
  font-size: 11.5px;
}

.palette-row {
  display: flex;
  flex-wrap: wrap;
  gap: 9px;
  margin-bottom: 10px;
}

.palette-dot {
  width: 27px;
  height: 27px;
  padding: 0;
  border: 2px solid transparent;
  border-radius: 50%;
  cursor: pointer;
  box-shadow: 0 3px 10px -4px rgba(0, 0, 0, 0.6);
  transition: transform 0.22s cubic-bezier(0.34, 1.4, 0.5, 1), border-color 0.22s;
}

.palette-dot:hover {
  transform: scale(1.14);
}

.palette-dot[aria-pressed='true'] {
  border-color: var(--ink);
  transform: scale(1.08);
}

.settings-btn {
  display: inline-grid;
  place-items: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 1px solid var(--chrome-edge);
  border-radius: 999px;
  background: var(--chrome-glass);
  color: var(--ink-soft);
  cursor: pointer;
  transition: color 0.25s, border-color 0.25s, background 0.25s, transform 0.25s;
}

.settings-btn:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
  background: var(--glass);
  transform: translateY(-1px);
}

.settings-btn svg {
  width: 17px;
  height: 17px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
}

.add-btn,
.pin-btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 8px 14px 8px 11px;
  border: 1px solid var(--chrome-edge);
  border-radius: 999px;
  background: var(--chrome-glass);
  box-shadow: none;
  color: var(--ink-soft);
  font: inherit;
  font-size: 12px;
  font-weight: 400;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  cursor: pointer;
  transition: color 0.25s, border-color 0.25s, background 0.25s, transform 0.25s;
}

.add-btn:hover,
.pin-btn:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
  background: var(--glass);
  transform: translateY(-1px);
}

.pin-btn[aria-pressed='true'] {
  color: #fff;
  border-color: transparent;
  background: linear-gradient(135deg, hsl(352 90% 62%), hsl(320 85% 58%));
  box-shadow: 0 10px 24px -12px hsl(352 90% 62% / 0.9);
}

.pin-btn svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Popping mode: the whole stage takes a pin, and bubbles say so. */
body.popping .node,
body.popping .bubble {
  cursor: crosshair;
}

body.popping .node:hover .bubble {
  border-color: hsl(352 90% 62% / 0.8);
  box-shadow: var(--shadow-lift), 0 0 34px -4px hsl(352 90% 62% / 0.6);
}

body.popping .tip {
  display: none;
}

.node.is-popped {
  animation: pop 0.32s cubic-bezier(0.3, 0, 0.2, 1) both;
  pointer-events: none;
}

@keyframes pop {
  40% {
    transform: translate(calc(-50% + var(--x, 0px)), calc(-50% + var(--y, 0px))) scale(1.28);
    opacity: 0.9;
  }
  to {
    transform: translate(calc(-50% + var(--x, 0px)), calc(-50% + var(--y, 0px))) scale(0.2);
    opacity: 0;
  }
}

.droplet {
  position: absolute;
  z-index: 9;
}

/* The same burst, but for things positioned against the viewport rather than
   against the constellation: the discovery bubbles and the soap. */
.droplet-fixed {
  position: fixed;
  z-index: 40;
}

.droplet,
.droplet-fixed {
  border-radius: 50%;
  pointer-events: none;
  background: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.9), hsl(var(--accent) / 0.5));
  animation: splash 0.55s ease-out both;
}

@keyframes splash {
  to {
    transform: translate(var(--dx), var(--dy)) scale(0.2);
    opacity: 0;
  }
}

.add-btn svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
}

.sheet-backdrop {
  position: fixed;
  inset: 0;
  z-index: 54;
  background: light-dark(rgba(30, 24, 70, 0.3), rgba(2, 2, 8, 0.55));
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  animation: fade-in 0.25s ease both;
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
}

.sheet {
  position: fixed;
  z-index: 55;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: min(420px, calc(100vw - 32px));
  max-height: min(78vh, 720px);
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 20px 22px 24px;
  border-radius: 24px;
  animation: settle-in 0.35s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}

.sheet[hidden],
.sheet-backdrop[hidden] {
  display: none;
}

/* Settings slides out down the right-hand side instead of covering the page,
   so every change is visible on the constellation as you make it. */
.sheet[data-mode='settings'] {
  top: 76px;
  right: clamp(12px, 2vw, 24px);
  left: auto;
  bottom: 72px;
  transform: none;
  width: min(340px, calc(100vw - 24px));
  max-height: none;
  animation: slide-drawer 0.34s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}

@keyframes slide-drawer {
  from {
    opacity: 0;
    transform: translateX(26px);
  }
}

@media (max-width: 720px) {
  .sheet[data-mode='settings'] {
    top: auto;
    left: 12px;
    right: 12px;
    bottom: 12px;
    width: auto;
    max-height: 70vh;
  }
}

.sheet-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 4px;
}

.sheet-head h2 {
  margin: 0;
  font-size: 20px;
  font-weight: 500;
  letter-spacing: -0.01em;
}

.sheet-close {
  width: 30px;
  height: 30px;
  border: 1px solid var(--hairline);
  border-radius: 50%;
  background: none;
  color: var(--ink-soft);
  font-size: 17px;
  line-height: 1;
  cursor: pointer;
  transition: color 0.2s, border-color 0.2s;
}

.sheet-close:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
}

.sheet-section {
  margin-top: 20px;
}

.sheet-section h3 {
  margin: 0 0 3px;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.sheet-note {
  margin: 0 0 11px;
  font-size: 12px;
  line-height: 1.5;
  color: var(--ink-soft);
}

/* The widget library: tiles with a small abstract of the panel, the way a
   desktop widget gallery works. */
.widget-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
  gap: 10px;
}

.widget-tile {
  display: flex;
  flex-direction: column;
  gap: 9px;
  padding: 11px;
  border: 1px solid var(--hairline);
  border-radius: 16px;
  background: var(--glass-strong);
  transition: transform 0.28s cubic-bezier(0.34, 1.3, 0.5, 1), border-color 0.25s,
    box-shadow 0.25s;
}

.widget-tile:hover {
  transform: translateY(-2px);
  border-color: var(--glass-edge);
  box-shadow: 0 16px 32px -20px var(--shadow-tint);
}

.widget-tile[data-added='true'] {
  border-color: hsl(265 90% 62% / 0.45);
}

.widget-tile-body {
  display: flex;
  flex-direction: column;
  gap: 2px;
  font-size: 12.5px;
}

.widget-action {
  padding: 6px 10px;
  border: 1px solid var(--hairline);
  border-radius: 999px;
  background: none;
  color: var(--ink-soft);
  font: inherit;
  font-size: 11.5px;
  cursor: pointer;
  transition: color 0.2s, border-color 0.2s, background 0.2s;
}

.widget-action[aria-pressed='false'] {
  background: linear-gradient(135deg, hsl(265 90% 62%), hsl(210 92% 58%));
  border-color: transparent;
  color: #fff;
}

.widget-action:hover {
  border-color: var(--glass-edge);
  color: var(--ink);
}

.widget-action[aria-pressed='false']:hover {
  color: #fff;
}

/* The little abstracts. Each is a caricature of its panel, not a screenshot. */
.wp {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 4px;
  height: 62px;
  padding: 8px;
  border-radius: 11px;
  border: 1px solid var(--hairline);
  background: light-dark(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.04));
  overflow: hidden;
}

.wp-you {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-auto-rows: 1fr;
}

.wp-tile {
  border-radius: 5px;
  background: linear-gradient(135deg, hsl(265 90% 62% / 0.55), hsl(210 92% 58% / 0.3));
}

.wp-row {
  height: 5px;
  border-radius: 3px;
  background: linear-gradient(90deg, hsl(158 84% 50% / 0.85) 12%, var(--hairline) 12%);
}

.wp-note {
  height: 11px;
  border-radius: 4px;
  background: light-dark(rgba(70, 60, 140, 0.14), rgba(255, 255, 255, 0.1));
}

.wp-pill {
  height: 9px;
  border-radius: 999px;
  background: linear-gradient(
    90deg,
    hsl(210 90% 60% / 0.55) 26%,
    light-dark(rgba(70, 60, 140, 0.12), rgba(255, 255, 255, 0.08)) 26%
  );
}

.wp-bar {
  height: 5px;
  border-radius: 3px;
  background: linear-gradient(
    90deg,
    hsl(188 90% 55%) 0%,
    hsl(265 90% 68%) var(--w, 60%),
    light-dark(rgba(70, 60, 140, 0.1), rgba(255, 255, 255, 0.07)) var(--w, 60%)
  );
}

.wp-bar:nth-child(1) {
  --w: 80%;
}
.wp-bar:nth-child(2) {
  --w: 35%;
}
.wp-bar:nth-child(3) {
  --w: 55%;
}
.wp-bar:nth-child(4) {
  --w: 20%;
}

/* Remove control on the panel itself. */
.card-title {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

.card-remove {
  flex: none;
  width: 20px;
  height: 20px;
  border: 1px solid transparent;
  border-radius: 50%;
  background: none;
  color: var(--ink-faint);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  /* VISIBLE, always. This was opacity 0 until the card was hovered, which is a
     control you cannot find: it worked perfectly whenever it was clicked and
     was reported broken because there was nothing on screen to aim at. It is
     also unreachable that way on a touch screen, where there is no hover at
     all. Faint at rest, full when you approach it. */
  opacity: 0.45;
  transition: opacity 0.2s, color 0.2s, border-color 0.2s;
}

.card:hover .card-remove,
.card-remove:focus-visible {
  opacity: 1;
}

.card-remove:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
}

/* Keeping the page on Nostr, in the settings sheet. */
.sync-row {
  display: flex;
  gap: 8px;
  margin-top: 10px;
}

.sync-btn {
  flex: 1;
  padding: 9px 12px;
  border: 1px solid var(--hairline);
  border-radius: 12px;
  background: var(--glass-strong);
  color: var(--ink);
  font: inherit;
  font-size: 12.5px;
  cursor: pointer;
  transition: border-color 0.2s, background 0.2s, opacity 0.2s;
}

.sync-btn:hover:not(:disabled) {
  border-color: var(--glass-edge);
}

.sync-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.sync-push {
  border-color: hsl(265 90% 68% / 0.5);
}

/* The status line carries every refusal a relay or a signer can produce, so it
   wraps rather than truncating: "no relay accepted it" cut off at the edge of
   the sheet is indistinguishable from nothing having happened. */
.sync-status {
  margin: 9px 2px 0;
  font-size: 11.5px;
  line-height: 1.45;
  color: var(--ink-faint);
}

.widget-toggle {
  display: flex;
  /* Aligned to the title, not to the middle of a three-line block, which put
     the tick beside the description instead of the thing it ticks. */
  align-items: flex-start;
  gap: 11px;
  width: 100%;
  padding: 12px 13px;
  border: 1px solid var(--hairline);
  border-radius: 14px;
  background: var(--glass-strong);
  color: var(--ink);
  font: inherit;
  font-size: 12.5px;
  text-align: left;
  cursor: pointer;
  transition: border-color 0.2s, background 0.2s;
}

.widget-toggle:hover {
  border-color: var(--glass-edge);
}

/* The two sliders in the paint menu, alongside the palette and the light/dark
   choice, because all four answer the same question: what colour is this page
   and how much of it is there. */
.paint-range + .paint-range {
  margin-top: 10px;
}

.paint-range-label {
  display: block;
  font-size: 12px;
  color: var(--ink-soft);
}

.paint-range-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 5px;
}

.soap-level,
.dark-level {
  flex: 1 1 auto;
  min-width: 0;
  height: 22px;
  margin: 0;
  background: none;
  appearance: none;
  -webkit-appearance: none;
  cursor: pointer;
}

.soap-level::-webkit-slider-runnable-track,
.dark-level::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: 999px;
  background: linear-gradient(90deg, hsl(var(--rail-thumb-hover)), var(--halo));
}

.soap-level::-moz-range-track,
.dark-level::-moz-range-track {
  height: 4px;
  border-radius: 999px;
  background: linear-gradient(90deg, hsl(var(--rail-thumb-hover)), var(--halo));
}

/* A bubble, because that is what it is setting. */
.soap-level::-webkit-slider-thumb,
.dark-level::-webkit-slider-thumb {
  appearance: none;
  -webkit-appearance: none;
  width: 15px;
  height: 15px;
  margin-top: -5.5px;
  border-radius: 50%;
  border: 1px solid var(--glass-edge);
  background: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.95), var(--orb-1));
  box-shadow: 0 1px 6px -1px var(--shadow-tint);
  transition: transform 0.18s ease;
}

.soap-level::-moz-range-thumb,
.dark-level::-moz-range-thumb {
  width: 15px;
  height: 15px;
  border-radius: 50%;
  border: 1px solid var(--glass-edge);
  background: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.95), var(--orb-1));
  box-shadow: 0 1px 6px -1px var(--shadow-tint);
}

.soap-level:hover::-webkit-slider-thumb,
.soap-level:focus-visible::-webkit-slider-thumb,
.dark-level:hover::-webkit-slider-thumb,
.dark-level:focus-visible::-webkit-slider-thumb {
  transform: scale(1.15);
}

.soap-level:focus-visible,
.dark-level:focus-visible {
  outline: 2px solid var(--halo);
  outline-offset: 4px;
  border-radius: 999px;
}

.soap-level-value,
.dark-level-value {
  flex: none;
  min-width: 4ch;
  text-align: right;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-faint);
}

.widget-toggle span.widget-desc {
  display: block;
  margin-top: 3px;
  font-size: 11px;
  line-height: 1.5;
  color: var(--ink-faint);
}

.widget-mark {
  flex: none;
  margin-top: 1px;
  width: 20px;
  height: 20px;
  border-radius: 6px;
  border: 1px solid var(--hairline);
  display: grid;
  place-items: center;
  font-size: 12px;
  color: transparent;
}

.widget-toggle[aria-pressed='true'] .widget-mark {
  background: linear-gradient(135deg, hsl(265 90% 62%), hsl(210 92% 58%));
  border-color: transparent;
  color: #fff;
}

.service-form {
  display: flex;
  flex-direction: column;
  gap: 9px;
}

.service-form label {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.service-form input,
.service-form select {
  padding: 9px 12px;
  border: 1px solid var(--hairline);
  border-radius: 12px;
  background: var(--glass-strong);
  color: var(--ink);
  font: inherit;
  font-size: 13px;
  letter-spacing: normal;
  text-transform: none;
}

.service-form input:focus,
.service-form select:focus {
  outline: none;
  border-color: var(--focus);
  box-shadow: 0 0 0 3px var(--halo);
}

.service-form .btn {
  max-width: none;
  margin-top: 2px;
}

/* Segmented control, shared by the motion setting. */
.seg {
  display: inline-flex;
  padding: 3px;
  gap: 2px;
  border: 1px solid var(--hairline);
  border-radius: 999px;
  background: var(--glass-strong);
  margin-bottom: 10px;
}

.seg-btn {
  padding: 6px 15px;
  border: 0;
  border-radius: 999px;
  background: none;
  color: var(--ink-soft);
  font: inherit;
  font-size: 12.5px;
  cursor: pointer;
  transition: background 0.2s, color 0.2s;
}

.seg-btn:hover {
  color: var(--ink);
}

.seg-btn[aria-pressed='true'] {
  background: linear-gradient(135deg, hsl(265 90% 62%), hsl(210 92% 58%));
  color: #fff;
}

body.no-soap .soap {
  display: none;
}

/* Directory rows, with room to actually say what a thing is. */
.catalogue-search {
  width: 100%;
  padding: 10px 14px;
  margin-bottom: 10px;
  border: 1px solid var(--hairline);
  border-radius: 12px;
  background: var(--glass-strong);
  color: var(--ink);
  font: inherit;
  font-size: 13px;
}

.catalogue-search:focus {
  outline: none;
  border-color: var(--focus);
  box-shadow: 0 0 0 3px var(--halo);
}

.catalogue-results {
  display: flex;
  flex-direction: column;
  gap: 6px;
  max-height: 340px;
  overflow-y: auto;
  overscroll-behavior: contain;
}

.cat-row {
  display: flex;
  align-items: flex-start;
  gap: 11px;
  padding: 10px 11px;
  border: 1px solid var(--hairline);
  border-radius: 14px;
  background: var(--glass-strong);
}

.cat-tile {
  flex: none;
  width: 38px;
  height: 38px;
  border-radius: 11px;
  display: grid;
  place-items: center;
  font-size: 16px;
  font-weight: 600;
  color: #fff;
  background: linear-gradient(145deg, hsl(var(--accent)), hsl(var(--accent) / 0.55));
  box-shadow: 0 6px 14px -8px hsl(var(--accent) / 0.9);
}

.cat-text {
  min-width: 0;
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.cat-top {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 13px;
}

.cat-chip {
  padding: 1px 7px;
  border-radius: 999px;
  font-size: 9px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: hsl(var(--accent));
  background: hsl(var(--accent) / 0.14);
}

.cat-blurb {
  font-size: 12px;
  line-height: 1.4;
  color: var(--ink-soft);
}

.cat-host {
  font-size: 10.5px;
  color: var(--ink-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.cat-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-top: 3px;
}

.cat-tag {
  padding: 2px 7px;
  border: 1px solid var(--hairline);
  border-radius: 999px;
  background: none;
  color: var(--ink-faint);
  font: inherit;
  font-size: 9.5px;
  cursor: pointer;
  transition: color 0.2s, border-color 0.2s;
}

.cat-tag:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
}

.cat-add {
  flex: none;
  align-self: center;
  padding: 6px 13px;
  border: 0;
  border-radius: 999px;
  background: linear-gradient(135deg, hsl(265 90% 62%), hsl(210 92% 58%));
  color: #fff;
  font: inherit;
  font-size: 11.5px;
  cursor: pointer;
}

.cat-add:disabled {
  background: none;
  border: 1px solid var(--hairline);
  color: var(--ink-faint);
  cursor: default;
}

.cat-add-wide {
  width: 100%;
  margin-top: 6px;
  padding: 9px;
}

/* Panels are dragged by their title. */
.card-title {
  cursor: grab;
  user-select: none;
  touch-action: none;
}

.card.is-dragging {
  cursor: grabbing;
  z-index: 12;
  box-shadow: var(--shadow-lift), inset 0 1px 0 var(--glass-edge);
}

.custom-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 12px;
}

.custom-row {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 8px 11px;
  border: 1px solid var(--hairline);
  border-radius: 12px;
  font-size: 12px;
}

.custom-dot {
  width: 8px;
  height: 8px;
  flex: none;
  border-radius: 50%;
  background: hsl(var(--accent));
}

.custom-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.custom-host {
  margin-left: auto;
  font-size: 10px;
  color: var(--ink-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 40%;
}

.custom-remove {
  flex: none;
  border: 0;
  background: none;
  color: var(--ink-faint);
  font: inherit;
  font-size: 11px;
  cursor: pointer;
  padding: 2px 4px;
}

.custom-remove:hover {
  color: var(--danger);
}

/* ---------------------------------------------------------------- footer */

/* One line, centred, wrapping only when it genuinely cannot fit. */
.foot {
  position: relative;
  z-index: 5;
  padding: 10px clamp(18px, 4vw, 40px) 18px;
  display: flex;
  justify-content: center;
  font-size: 11px;
  line-height: 1.6;
  color: var(--ink-soft);
  text-align: center;
}

.foot p {
  margin: 0;
  max-width: 100%;
}

.foot .dot {
  padding: 0 3px;
  opacity: 0.5;
}

/* Its own line, so the attribution is not competing with the two notes. */
.foot-legal {
  flex: 1 0 100%;
  margin: 0;
  opacity: 0.85;
}

.foot a {
  color: var(--ink-soft);
  text-decoration: none;
  border-bottom: 1px solid var(--hairline);
}

.foot a:hover {
  color: var(--ink);
}

/* ---------------------------------------------------------- compact mode */

/* Kept in step with the `compact` media query in app.js: below this, the ring
   cannot hold twelve bubbles without collisions, so the layout becomes a grid
   and the spokes are dropped. Change both or neither. */
@media (max-width: 860px), (max-height: 760px) {
  /* There is no centre line to hold here: the profile rejoins the flow with
     everything else, so the search bar has nothing to be centred ON, and a
     three-column header only squeezes the two side columns until the controls
     overlap it. It takes a row of its own instead. */
  .chrome {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    gap: 12px;
  }

  .hub-search {
    order: 3;
    width: 100%;
  }

  /* The controls wrap rather than running off the edge. A fourth view was one
     more than the row could hold on a phone, and an un-wrapping row does not
     fail gracefully: it just puts Orbit past the right edge where there is no
     scrolling to reach it. */
  .chrome-right {
    flex-wrap: wrap;
    justify-content: flex-end;
    row-gap: 8px;
  }

  /* Nothing floats in the corners of a phone: the panels rejoin the flow and
     stack under the constellation. */
  .dashboard {
    display: flex;
    flex-direction: column;
    gap: 12px;
    width: 100%;
    max-width: 520px;
    margin: 0 auto;
  }

  /* Identity, then the themes, then the services. The panels are a sibling of
     the stage, so they follow all of this in the page rather than being
     ordered among it. */
  .core {
    order: 1;
  }
  .legend {
    order: 2;
  }
  .constellation {
    order: 3;
  }

  .card {
    position: static;
    width: auto;
    max-height: none;
    overflow: visible;
  }

  /* Dragging is a pointer affordance; the compact layout is a grid. */
  .legend {
    position: static;
    margin: 4px auto 0;
    width: 100%;
    max-width: 520px;
    flex-direction: row;
    flex-wrap: wrap;
    align-items: center;
    gap: 2px 4px;
  }

  .legend-title,
  .legend-count,
  .arrange-hint,
  .reset-layout {
    display: none;
  }

  /* The stage stops filling the viewport and takes its content height
     instead, so the page scrolls past it rather than the footer landing on
     top of the grid. */
  .stage {
    flex: 0 0 auto;
    height: auto;
    min-height: 0;
    padding: 6px clamp(16px, 5vw, 32px) 30px;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 26px;
  }

  .spokes {
    display: none;
  }

  .core {
    position: relative;
    top: auto;
    left: auto;
    transform: none !important;
    /* flex: none, or the column flex context shrinks the height and the
       circle becomes an ellipse. */
    flex: none;
    width: min(322px, 88vw);
    height: min(322px, 88vw);
  }

  .constellation {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
    gap: 16px 10px;
    width: 100%;
    max-width: 520px;
  }

  .node {
    position: relative;
    top: auto;
    left: auto;
    transform: none !important;
    width: auto;
    height: auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 9px;
  }

  /* Back into the flow: the grid stacks bubble and text vertically. */
  .node-text {
    position: static;
    transform: none;
    width: auto;
  }

  /* One size for everything in the list. Zones are a statement about distance
     from the centre, and there is no centre here: a grid that also varied the
     sizes would be saying something it cannot mean. */
  .node {
    --size: 68px;
    --icon: 25px;
    --label: 13px;
  }

  .node[data-tier='2'] .node-kind,
  .node[data-tier='3'] .node-kind {
    display: block;
  }

  .node[data-tier='3'] .node-label {
    color: inherit;
  }

  .node-blurb {
    display: block;
    max-width: 22ch;
    margin-top: 1px;
    font-size: 11px;
    line-height: 1.4;
    text-align: center;
    color: var(--ink-faint);
    text-wrap: balance;
  }

  .tip {
    display: none;
  }
}

/* ------------------------------------------------------- reduced motion */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.08s !important;
  }
}

/* The stage's children are interactive; the stage is not. */
.core,
.node,
.legend,
.dashboard {
  pointer-events: auto;
}

/* And the rail is not either. It is a full-height column, so its empty gaps
   between panels were an invisible hit area down each side of the screen: a
   bubble dragged in there could be seen but never picked up again. Only the
   panels themselves take pointer events. */
.rail {
  pointer-events: none;
}

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

/* Lore bubbles come to the front while you are reading one, and can be pulled
   out of the stream and left somewhere. */
.lore.is-front {
  z-index: 7;
}

.lore-bubble.is-held {
  cursor: grabbing;
  z-index: 3;
}

.lore-bubble.is-held > i {
  transform: scale(1.06);
  border-color: rgba(255, 255, 255, 0.45);
}

.lore-bubble.is-held .lore-card {
  opacity: 1;
  visibility: visible;
  transform: translateY(-50%) translateX(0);
}

/* Notifications */
.inbox-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 7px;
}

.inbox-item {
  display: flex;
  align-items: flex-start;
  gap: 9px;
  padding: 8px 10px;
  border-radius: 12px;
  background: var(--glass-strong);
  border: 1px solid var(--hairline);
}

.inbox-mark {
  flex: none;
  width: 22px;
  height: 22px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  font-size: 11px;
  background: light-dark(rgba(70, 60, 140, 0.1), rgba(255, 255, 255, 0.07));
  color: var(--ink-soft);
}

.inbox-mark[data-kind='7'] {
  color: hsl(352 90% 66%);
}
.inbox-mark[data-kind='9735'] {
  color: hsl(48 96% 60%);
}
.inbox-mark[data-kind='6'] {
  color: hsl(158 84% 52%);
}

/* Sealed messages: present, counted, and deliberately not opened. Marked out
   from the rest of the inbox so it is obvious this row is a different KIND of
   claim, one about an envelope rather than about its contents. */
.inbox-sealed {
  flex-wrap: wrap;
  border-color: hsl(var(--accent-sealed) / 0.4);
  background: linear-gradient(
    180deg,
    hsl(var(--accent-sealed) / 0.09),
    transparent
  ), var(--glass-strong);
}

.inbox-sealed .inbox-mark {
  color: hsl(var(--accent-sealed));
}

.inbox-text {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.inbox-meta {
  font-size: 10.5px;
  color: var(--ink-faint);
}

.inbox-why {
  flex-basis: 100%;
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px solid var(--hairline);
}

.inbox-open {
  margin-top: 7px;
  font-size: 11px;
}

/* An opened message. Indented under the sealed row it came out of, and given
   the message text room to be read rather than glanced at. */
.inbox-opened {
  margin-left: 14px;
  border-color: hsl(var(--accent-sealed) / 0.22);
}

.inbox-said {
  margin-top: 4px;
  font-size: 12px;
  line-height: 1.45;
  color: var(--ink);
  overflow-wrap: anywhere;
  white-space: pre-wrap;
}

.inbox-body {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.inbox-who {
  font-size: 11px;
  color: var(--ink);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  overflow: hidden;
  text-overflow: ellipsis;
}

.inbox-text {
  font-size: 11.5px;
  line-height: 1.45;
  color: var(--ink-soft);
  overflow-wrap: anywhere;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.inbox-when {
  font-size: 9.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

/* Dropped where you left it. Still hoverable, no longer forced open. */
.lore-bubble.is-pinned {
  cursor: grab;
}

.lore-bubble.is-pinned > i {
  border-color: rgba(255, 255, 255, 0.3);
}

/* Adding a widget from the desktop itself, rather than only from settings. */
.card-add {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  padding: 14px;
  border: 1px dashed var(--hairline);
  border-radius: 18px;
  background: none;
  color: var(--ink-faint);
  font: inherit;
  font-size: 12px;
  cursor: pointer;
  transition: color 0.25s, border-color 0.25s, background 0.25s;
}

.card-add:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
  background: var(--glass);
}

.card-add-plus {
  font-size: 15px;
  line-height: 1;
}

/* A grip on every panel title, so it is obvious they move. */
.card-title::before {
  content: '⠿';
  margin-right: 7px;
  font-size: 11px;
  letter-spacing: -1px;
  color: var(--ink-faint);
  opacity: 0;
  transition: opacity 0.2s;
}

.card:hover .card-title::before {
  opacity: 0.75;
}

/* A header button glows while the thing it opens is open, so it is obvious
   which panel is showing and which button closes it again. */
.settings-btn[aria-expanded='true'],
.add-btn[aria-expanded='true'],
.paint-btn[aria-expanded='true'] {
  color: #fff;
  border-color: transparent;
  background: linear-gradient(135deg, hsl(265 90% 62%), hsl(210 92% 58%));
  box-shadow: 0 0 0 3px hsl(265 90% 62% / 0.22), 0 10px 26px -12px hsl(265 90% 62%);
}

.paint-btn[aria-expanded='true'] .paint-swatch {
  inset: 3px;
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5);
}

/* Granny mode: the corpus takes the desktop. */
body[data-view='granny'] .card:not(.card-granny),
body[data-view='granny'] .card-add,
body[data-view='hub'] .card-granny {
  display: none;
}

body[data-view='granny'] .card-granny {
  display: block;
}

body[data-view='dashboard'] .card-granny {
  display: none;
}

@media (min-width: 1180px) {
  body[data-view='granny'] .card-granny {
    position: fixed;
    top: var(--rail-top, 84px);
    bottom: 78px;
    right: clamp(16px, 2.4vw, 30px);
    width: min(392px, 34vw);
    max-height: none;
    overflow-y: auto;
    z-index: 6;
  }
}

.granny-lead {
  margin: 0 0 4px;
  font-size: 13px;
  line-height: 1.55;
  color: var(--ink);
}

.granny-h {
  margin: 16px 0 5px;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.card-granny p {
  margin: 0;
  font-size: 12px;
  line-height: 1.6;
  color: var(--ink-soft);
}

.granny-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 18px 0 4px;
}

.granny-actions .btn {
  width: auto;
  max-width: none;
  text-decoration: none;
}

.granny-ask {
  border: 1px solid var(--hairline);
}

/* "What is this?" on every panel. */
.card-why-btn {
  flex: none;
  width: 19px;
  height: 19px;
  margin-left: auto;
  border: 1px solid var(--hairline);
  border-radius: 50%;
  background: none;
  color: var(--ink-faint);
  font: inherit;
  font-size: 10.5px;
  line-height: 1;
  cursor: pointer;
  transition: color 0.2s, border-color 0.2s, background 0.2s;
}

.card-why-btn:hover,
.card-why-btn[aria-expanded='true'] {
  color: var(--ink);
  border-color: var(--glass-edge);
  background: var(--glass-strong);
}

.card-remove {
  margin-left: 0;
}

.card-why {
  margin: 0 0 11px;
  padding: 10px 11px;
  border-radius: 12px;
  border: 1px solid var(--hairline);
  background: light-dark(rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.04));
  font-size: 11.5px;
  line-height: 1.6;
  color: var(--ink-soft);
  animation: fade-up 0.28s ease both;
}

.card-why[hidden] {
  display: none;
}

.inbox-meta {
  display: flex;
  align-items: center;
  gap: 7px;
}

.inbox-kind {
  padding: 1px 6px;
  border-radius: 999px;
  font-size: 9px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-faint);
  background: light-dark(rgba(70, 60, 140, 0.1), rgba(255, 255, 255, 0.07));
}

/* Every number in the middle explains itself in the line below the numbers,
   not in a floating box. See coreStat in app.js for why two floating versions
   were tried and abandoned. */
.core-stat[tabindex] {
  cursor: default;
}

.core-stat[tabindex]:focus-visible {
  outline: 2px solid var(--halo);
  outline-offset: 3px;
  border-radius: 10px;
}

/* The status line while it is borrowed. Reserved height, so handing it back and
   forth does not shunt the sign-out button up and down as you sweep across the
   row of bubbles. */
.identity-status {
  /* Exactly two lines, always. Reserved so sweeping across the numbers does
     not shunt the sign-out button, and clamped so a long hint cannot push the
     stack past the bottom of the circle it lives in. */
  /* FIXED, not a minimum. A minimum still moved by a pixel or two as the line
     count changed under text-wrap: balance, and the sign-out button moved with
     it. Two lines, always, whatever is in it. */
  height: 2.6em;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  font-size: 10px;
  line-height: 1.25;
  text-wrap: balance;
}

.identity-status.is-hint {
  color: var(--ink-soft);
}

/* The chooser: what the middle should show. Chips rather than a list, because
   ten switches in a circle needs to read as a palette and not as a form. */
.core-actions {
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Out of the bottom row and up beside the name. A circle gives you least room
   exactly where a row of controls wants to sit, and this one was 104px wide at
   the very foot of it. Up here the chord is nearly the full diameter. */
.core-panel {
  position: relative;
}

.core-config {
  position: absolute;
  top: 46px;
  right: 6px;
}

.core-config {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  padding: 0;
  border: 1px solid var(--hairline);
  border-radius: 50%;
  background: none;
  color: var(--ink-faint);
  cursor: pointer;
  transition: color 0.2s, border-color 0.2s;
}

.core-config:hover,
.core-config[aria-expanded='true'] {
  color: var(--ink);
  border-color: var(--glass-edge);
}

.core-config svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
}

.core-edit {
  width: 100%;
  max-width: 214px;
  margin-top: 2px;
}

.core-edit[hidden] {
  display: none;
}

.core-edit-title {
  margin: 0 0 6px;
  font-size: 9.5px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.core-edit-list {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 4px;
}

.core-chip {
  padding: 3px 8px;
  border: 1px solid var(--hairline);
  border-radius: 999px;
  background: none;
  color: var(--ink-faint);
  font: inherit;
  font-size: 10.5px;
  cursor: pointer;
  transition: color 0.18s, border-color 0.18s, background 0.18s;
}

.core-chip:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
}

.core-chip[aria-pressed='true'] {
  color: var(--ink);
  border-color: hsl(var(--halo-h, 258) 70% 70% / 0.6);
  background: var(--glass-strong);
}

.core-stat[tabindex]:hover span {
  color: var(--ink);
}

/* While a bubble is being dragged, the pin calls for it. Drop one on the pin
   and it pops, whether or not popping mode is on. */
body.is-dragging-bubble .pin-btn {
  animation: pin-call 0.9s ease-in-out infinite;
  border-color: hsl(352 90% 62% / 0.7);
  color: hsl(352 90% 68%);
}

@keyframes pin-call {
  0%,
  100% {
    box-shadow: 0 0 0 0 hsl(352 90% 62% / 0.55);
    transform: scale(1);
  }
  60% {
    box-shadow: 0 0 0 14px hsl(352 90% 62% / 0);
    transform: scale(1.09);
  }
}

body.is-dragging-bubble .pin-btn:hover {
  background: hsl(352 90% 62%);
  color: #fff;
  border-color: transparent;
}

/* Popped the same way a service bubble is: it swells, bursts and is gone. The
   animation runs on the skin rather than the bubble, because the bubble's own
   transform is what carries it up the screen. */
/* Poppable soap, while the mode is on. The layer is inert otherwise, and has
   to stay that way: it drifts across the whole page. */
body.popping .soap {
  pointer-events: none;
}

body.popping .soap-bubble {
  pointer-events: auto;
  cursor: crosshair;
}

body.popping .soap-bubble:hover {
  filter: brightness(1.5);
}

.soap-bubble.is-popped {
  animation: none;
  transform: scale(1.35);
  opacity: 0;
  transition: transform 0.3s ease-out, opacity 0.3s ease-out;
}

.lore-bubble.is-popped {
  animation: none !important;
  pointer-events: none;
}

.lore-bubble.is-popped > i {
  animation: lore-pop 0.34s cubic-bezier(0.3, 0, 0.2, 1) both;
}

.lore-bubble.is-popped .lore-card {
  opacity: 0;
  visibility: hidden;
}

@keyframes lore-pop {
  40% {
    transform: scale(1.32);
    opacity: 0.9;
  }
  to {
    transform: scale(0.18);
    opacity: 0;
  }
}

/* The kinship halo. --kin is set per frame from the distance to the nearest
   bubble of the same theme: touching neighbours pool into one glow, distant
   ones have none. */
.bubble::before {
  content: '';
  position: absolute;
  inset: -18px;
  z-index: -1;
  border-radius: 50%;
  background: radial-gradient(circle, hsl(var(--accent) / 0.55), transparent 68%);
  opacity: calc(var(--kin, 0) * 0.85);
  filter: blur(9px);
  pointer-events: none;
}

.node.is-dim .bubble::before {
  opacity: 0;
}

/* A sharp pin: a solid head and a needle tapering to a point, rather than an
   outline that read as a magnifying glass. */
.pin-btn .pin-icon {
  width: 16px;
  height: 16px;
  fill: currentColor;
  stroke: none;
}

.pin-btn[aria-pressed='true'] .pin-icon,
body.is-dragging-bubble .pin-btn .pin-icon {
  filter: drop-shadow(0 0 5px currentColor);
}

/* Move a service to another theme, from its own hover card. */
.tip {
  pointer-events: none;
}

.node:hover .tip,
.node:focus-visible .tip {
  pointer-events: auto;
}

.tip-themes {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  margin-top: 9px;
  padding-top: 9px;
  border-top: 1px solid var(--hairline);
}

.tip-themes-label {
  margin-right: 3px;
  font-size: 9px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.tip-theme {
  width: 13px;
  height: 13px;
  padding: 0;
  border: 1.5px solid transparent;
  border-radius: 50%;
  background: hsl(var(--dot));
  cursor: pointer;
  opacity: 0.55;
  transition: opacity 0.18s, transform 0.18s;
}

.tip-theme:hover {
  opacity: 1;
  transform: scale(1.25);
}

.tip-theme[data-current='true'] {
  opacity: 1;
  border-color: var(--ink);
}

/* The constellation was sitting at the same brightness as the backdrop behind
   it, so it read as part of the gradient rather than as objects on top of it.
   A soft scrim centred on the core gives it something to stand on, and the
   bubbles themselves take a firmer glass than the panels use. */
.stage::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: min(1180px, 104%);
  height: min(900px, 108%);
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: radial-gradient(
    closest-side,
    light-dark(rgba(255, 255, 255, 0.5), rgba(7, 6, 20, 0.52)),
    light-dark(rgba(255, 255, 255, 0.24), rgba(7, 6, 20, 0.26)) 58%,
    transparent 78%
  );
  filter: blur(26px);
  pointer-events: none;
}

.bubble {
  background: light-dark(rgba(255, 255, 255, 0.74), rgba(255, 255, 255, 0.105));
  border-color: hsl(var(--accent) / 0.42);
  box-shadow: 0 14px 30px -18px light-dark(rgba(46, 34, 106, 0.5), rgba(0, 0, 0, 0.8)),
    inset 0 1px 0 var(--glass-edge);
}

.node:hover .bubble,
.node:focus-visible .bubble {
  border-color: hsl(var(--accent) / 0.85);
}

.node-label {
  text-shadow: 0 1px 10px light-dark(rgba(255, 255, 255, 0.5), rgba(0, 0, 0, 0.55));
}

/* The core sits above the scrim and needs to read as the brightest thing. */
.core {
  background: light-dark(rgba(255, 255, 255, 0.78), rgba(24, 21, 56, 0.72));
}

/* An invisible bridge across the gap between a bubble and its hover card.
   The card sits ~62px below the bubble, and crossing that gap took the
   pointer off the node, which closed the card before it could be reached.
   The bridge is part of the card, and the card is inside the node, so the
   whole path stays hovered. */
.tip::before {
  content: '';
  position: absolute;
  left: -10px;
  right: -10px;
  top: -76px;
  height: 76px;
}

.node.tip-above .tip::before {
  top: auto;
  bottom: -18px;
  height: 18px;
}

/* The rest of a theme, while one of its bubbles is hovered: lit rather than
   merely undimmed, so the huddle announces itself. */
.node.is-kin .bubble {
  border-color: hsl(var(--accent) / 0.9);
  box-shadow: 0 0 26px -4px hsl(var(--accent) / 0.65), inset 0 1px 0 var(--glass-edge);
}

.node.is-kin .node-label {
  color: var(--accent-ink);
}

/* The identity refuses to pop: the constellation is blown outward and pulled
   back, and the core holds. */
.core[data-state='refuse'] .panel-refuse {
  display: flex;
}

.core.is-refusing {
  animation: core-hold 1.2s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}

@keyframes core-hold {
  0% {
    transform: translate(calc(-50% + var(--x, 0px)), calc(-50% + var(--y, 0px))) scale(1);
  }
  12% {
    transform: translate(calc(-50% + var(--x, 0px)), calc(-50% + var(--y, 0px))) scale(1.09);
  }
  26% {
    transform: translate(calc(-50% + var(--x, 0px) - 5px), calc(-50% + var(--y, 0px))) scale(0.97);
  }
  38% {
    transform: translate(calc(-50% + var(--x, 0px) + 5px), calc(-50% + var(--y, 0px))) scale(1.02);
  }
  100% {
    transform: translate(calc(-50% + var(--x, 0px)), calc(-50% + var(--y, 0px))) scale(1);
  }
}

.node.is-blown .bubble {
  animation: blast 1.2s cubic-bezier(0.2, 0.75, 0.3, 1) both;
}

@keyframes blast {
  0% {
    transform: translate(0, 0) scale(1);
  }
  20% {
    transform: translate(var(--bx, 0), var(--by, 0)) scale(0.82);
    opacity: 0.75;
  }
  100% {
    transform: translate(0, 0) scale(1);
    opacity: 1;
  }
}

.panel-refuse h1 {
  color: hsl(352 90% 68%);
}

/* Saved layouts: a bookmark of the whole desktop. */
.layouts {
  position: relative;
}

.layouts-btn {
  display: inline-grid;
  place-items: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 1px solid var(--chrome-edge);
  border-radius: 999px;
  background: var(--chrome-glass);
  color: var(--ink-soft);
  cursor: pointer;
  transition: color 0.25s, border-color 0.25s, background 0.25s, transform 0.25s;
}

.layouts-btn:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
  background: var(--glass);
  transform: translateY(-1px);
}

.layouts-btn[aria-expanded='true'] {
  color: #fff;
  border-color: transparent;
  background: linear-gradient(135deg, hsl(265 90% 62%), hsl(210 92% 58%));
  box-shadow: 0 0 0 3px hsl(265 90% 62% / 0.22);
}

.layouts-btn svg {
  width: 16px;
  height: 16px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linejoin: round;
}

.layouts-menu {
  position: absolute;
  top: calc(100% + 10px);
  right: 0;
  z-index: 22;
  width: 268px;
  padding: 13px;
  border-radius: 18px;
  border: 1px solid var(--hairline);
  background: var(--glass);
  backdrop-filter: blur(22px) saturate(160%);
  -webkit-backdrop-filter: blur(22px) saturate(160%);
  box-shadow: var(--shadow), inset 0 1px 0 var(--glass-edge);
  animation: paint-drop 0.24s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}

.layouts-menu[hidden] {
  display: none;
}

.layout-form {
  display: flex;
  gap: 7px;
  margin-bottom: 6px;
}

.layout-name {
  flex: 1;
  min-width: 0;
  padding: 8px 11px;
  border: 1px solid var(--hairline);
  border-radius: 11px;
  background: var(--glass-strong);
  color: var(--ink);
  font: inherit;
  font-size: 12.5px;
}

.layout-name:focus {
  outline: none;
  border-color: var(--focus);
}

.layout-form .btn {
  width: auto;
  max-width: none;
  padding: 8px 14px;
  font-size: 12px;
}

.layouts-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.layout-restore {
  flex: 1;
  min-width: 0;
  border: 0;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 12.5px;
  text-align: left;
  cursor: pointer;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.layout-restore:hover {
  color: var(--accent-ink, var(--ink));
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* Dragging a suggested app: your own bubble invites the drop. */
body.is-dragging-app .core {
  border-color: hsl(265 90% 68% / 0.85);
  box-shadow: 0 0 0 0 hsl(265 90% 62% / 0.5), var(--shadow), inset 0 1px 0 var(--glass-edge);
  animation: core-invite 1.1s ease-in-out infinite;
}

/* IT IS THE PROFILE THAT ASKS, in every view including these two. The pulse was
   moved onto the container here on the reasoning that the bucket is the drop
   target, and it read wrong: the tray beating away at the bottom of the screen
   is a large thing to have moving, and the gesture people already know is that
   your own bubble lights up when there is something to give it. The container
   is still a target and still says so, in a border that brightens and holds
   rather than one that flashes. */
body[data-view='shelf'].is-dragging-app .constellation,
body[data-view='tray'].is-dragging-app .constellation {
  border-color: hsl(265 90% 68% / 0.55);
  animation: none;
}

@keyframes core-invite {
  0%,
  100% {
    box-shadow: 0 0 0 0 hsl(265 90% 62% / 0.45), inset 0 1px 0 var(--glass-edge);
  }
  60% {
    box-shadow: 0 0 0 26px hsl(265 90% 62% / 0), inset 0 1px 0 var(--glass-edge);
  }
}

/* Music from anywhere. */
.music-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.music-item {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 7px 9px;
  border-radius: 12px;
  background: var(--glass-strong);
  border: 1px solid var(--hairline);
}

.music-play {
  flex: none;
  width: 26px;
  height: 26px;
  border: 0;
  border-radius: 50%;
  background: linear-gradient(135deg, hsl(48 96% 58%), hsl(14 90% 60%));
  color: #1a1200;
  font-size: 11px;
  cursor: pointer;
}

.music-text {
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.music-text b {
  font-size: 12px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.music-meta {
  font-size: 9.5px;
  color: var(--ink-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.music-now:empty {
  display: none;
}

.music-now {
  margin-bottom: 10px;
  padding: 10px 11px;
  border-radius: 13px;
  border: 1px solid hsl(48 96% 58% / 0.35);
  background: hsl(48 96% 58% / 0.08);
}

.music-now-title {
  margin: 0;
  font-size: 12.5px;
  font-weight: 600;
}

.music-now-who {
  margin: 2px 0 8px;
  font-size: 10.5px;
  color: var(--ink-faint);
}

.music-audio {
  width: 100%;
  height: 32px;
}

.music-actions {
  margin-top: 10px;
}

.music-shuffle {
  width: 100%;
  padding: 8px;
  border: 1px solid var(--hairline);
  border-radius: 999px;
  background: none;
  color: var(--ink-soft);
  font: inherit;
  font-size: 11.5px;
  cursor: pointer;
}

.music-shuffle:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
}

/* Facts you dragged onto yourself. */
.notes { position: relative; }

.notes-btn {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  height: 36px;
  padding: 0 13px;
  border: 1px solid var(--chrome-edge);
  border-radius: 999px;
  background: var(--chrome-glass);
  color: var(--ink-soft);
  font: inherit;
  font-size: 12px;
  cursor: pointer;
}

.notes-btn:hover { color: var(--ink); border-color: var(--glass-edge); }
.notes-btn[hidden] { display: none; }

.notes-menu {
  position: absolute;
  top: calc(100% + 10px);
  right: 0;
  z-index: 22;
  width: 290px;
  max-height: 60vh;
  overflow-y: auto;
  padding: 13px;
  border-radius: 18px;
  border: 1px solid var(--hairline);
  background: var(--glass);
  backdrop-filter: blur(22px) saturate(160%);
  -webkit-backdrop-filter: blur(22px) saturate(160%);
  box-shadow: var(--shadow), inset 0 1px 0 var(--glass-edge);
  animation: paint-drop 0.24s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}

.notes-menu[hidden] { display: none; }

.note-row {
  padding: 10px 11px;
  margin-bottom: 7px;
  border: 1px solid var(--hairline);
  border-radius: 13px;
  background: var(--glass-strong);
}

.note-row b { display: block; font-size: 12.5px; margin-bottom: 3px; }
.note-row p { margin: 0 0 6px; font-size: 11.5px; line-height: 1.5; color: var(--ink-soft); }

/* Gap between stacked toggles in settings: they were touching. */
.widget-toggle + .widget-toggle,
.seg + .widget-toggle {
  margin-top: 9px;
}

/* Lore cards were translucent enough to read the constellation through them,
   which made the text itself hard to read. Opaque ground, same glass edge. */
.lore-card {
  background: light-dark(rgba(255, 255, 255, 0.94), rgba(16, 14, 40, 0.94));
  border-color: var(--glass-edge);
}

/* In popping mode the cursor is the pin from the button, point-first. */
/* ONE pin, on everything poppable. The discovery bubbles were left out and so
   kept their grab cursor, which said they could be moved rather than burst —
   in a mode whose whole purpose is bursting. */
body.popping .node,
body.popping .bubble,
body.popping .core,
body.popping .lore-bubble,
body.popping .lore-bubble > i {
  cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 24 24'><circle cx='17.1' cy='6.9' r='3.5' fill='%23ff4d6d'/><path d='M14.6 9.4L16.1 10.9L3.6 20.4Z' fill='%23ff4d6d'/></svg>") 4 24, crosshair;
}

/* And the same red bloom on approach, so a discovery bubble looks as poppable
   as a service one. */
body.popping .lore-bubble:hover > i {
  border-color: hsl(352 90% 62% / 0.8);
  box-shadow: inset -6px -8px 16px rgba(255, 255, 255, 0.03),
    0 0 30px -4px hsl(352 90% 62% / 0.65);
}

/* The card would be in the way of the thing you are aiming at. */
body.popping .lore-card {
  display: none;
}

/* The shuffle button lived at the foot of a card that clips and scrolls, so
   it sat outside the visible area and could not be clicked. It belongs at the
   top, where it is always reachable. */
.card-music {
  display: flex;
  flex-direction: column;
}

.card-music .card-title {
  order: -2;
}

.music-actions {
  order: -1;
  margin: 0 0 10px;
}

/* Listening history, and the track you were on when the page was rebuilt. */
.music-history {
  list-style: none;
  margin: 6px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.music-history-heading[hidden] {
  display: none;
}

.music-history .music-play {
  background: var(--glass-strong);
  border: 1px solid var(--hairline);
  color: var(--ink-soft);
}

/* ------------------------------------------------------- shelf and tray */

/* Two containers with things loose in them: a bucket down the left, and a long
   tray along the bottom. Both are the same code — a box with a floor — so the
   only difference below is the shape of the box.

   The spokes go, because nothing is radiating from anything. The DISCOVERY
   BUBBLES STAY: they were hidden here at first and that was wrong, since they
   are the point of the page rather than decoration on the constellation. */
body[data-view='shelf'] .spokes,
body[data-view='tray'] .spokes {
  display: none;
}

body[data-view='shelf'] .stage,
body[data-view='tray'] .stage {
  display: flex;
  gap: clamp(18px, 3vw, 40px);
  padding: 8px clamp(16px, 3vw, 34px) 20px;
  min-height: 0;
}

body[data-view='shelf'] .stage {
  align-items: flex-start;
}

/* The tray runs along the bottom, so the profile sits above it. */
body[data-view='tray'] .stage {
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
}

/* The container. position: relative because the physics writes transforms
   against its own box, and overflow: hidden because a thrown bubble should
   stop at the wall rather than sail across the page. */
body[data-view='shelf'] .constellation,
body[data-view='tray'] .constellation {
  position: relative;
  overflow: hidden;
  border: 1px solid var(--hairline);
  border-radius: 22px;
  background: linear-gradient(180deg, transparent, hsl(var(--rail-thumb) / 0.16)), var(--glass);
  backdrop-filter: blur(22px) saturate(160%);
  -webkit-backdrop-filter: blur(22px) saturate(160%);
  box-shadow: var(--shadow), inset 0 1px 0 var(--glass-edge);
}

body[data-view='shelf'] .constellation {
  order: 1;
  flex: 0 0 auto;
  width: clamp(280px, 27vw, 380px);
  height: min(460px, calc(100vh - 230px));
}

body[data-view='shelf'] .core {
  order: 2;
  margin-top: 8px;
}

body[data-view='tray'] .constellation {
  order: 2;
  width: 100%;
  /* Long and shallow: as wide as the stage allows, and three bubbles high.
     56px each plus the padding is what "3 app bubbles high" comes to. */
  max-width: none;
  height: 190px;
  margin-top: auto;
}

body[data-view='tray'] .core {
  order: 1;
}

/* A floor you can see, so resting on it looks deliberate. */
body[data-view='shelf'] .constellation::after,
body[data-view='tray'] .constellation::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: linear-gradient(90deg, transparent, hsl(var(--rail-thumb-hover)), transparent);
  opacity: 0.7;
  pointer-events: none;
}

/* Loose in the box. The physics writes the transform, so nothing here may set
   one: top and left stay at zero and translate does all the work. */
body[data-view='shelf'] .node,
body[data-view='tray'] .node {
  position: absolute;
  top: 0;
  left: 0;
  width: auto;
  height: auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  cursor: grab;
  /* NO transform here, and none written by the physics: a transformed element
     becomes the containing block for fixed descendants, which broke the hover
     card's escape from the container. See placeBody in app.js. */
  transform: none;
  will-change: left, top;
}

body[data-view='shelf'] .node.is-dragging,
body[data-view='tray'] .node.is-dragging {
  cursor: grabbing;
  z-index: 9;
}

body[data-view='shelf'] .node .bubble,
body[data-view='tray'] .node .bubble {
  width: 56px;
  height: 56px;
}

body[data-view='shelf'] .node .bubble svg,
body[data-view='tray'] .node .bubble svg {
  width: 22px;
  height: 22px;
}

/* THE NAME IS PART OF THE PIECE, and always on. It used to appear only under
   the pointer, which meant a shelf of fifteen identical-sized balls was fifteen
   unlabelled balls until you went looking. It is taken out of the flow so it
   cannot widen the body the physics treats as a circle, and the floor in app.js
   is lowered by exactly this height so the piece rests on the text rather than
   pushing it out of the box. */
body[data-view='shelf'] .node-text,
body[data-view='tray'] .node-text {
  position: absolute;
  top: calc(100% + 5px);
  left: 50%;
  transform: translateX(-50%);
  width: max-content;
  /* fitPlates() in app.js lowers this when a row is over capacity, and only as
     far as it has to. */
  max-width: var(--plate-max, 88px);
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--glass-strong);
  border: 1px solid var(--hairline);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  opacity: 0.92;
  visibility: visible;
  transition: opacity 0.16s ease, border-color 0.16s ease;
  pointer-events: none;
  /* Above every ball rather than only its own: a pile is nothing but overlap,
     and a name behind a neighbour is a name you cannot read. */
  z-index: 4;
}

/* Brighter under the pointer, and lifted clear of the other names. */
body[data-view='shelf'] .node:hover .node-text,
body[data-view='tray'] .node:hover .node-text,
body[data-view='shelf'] .node:focus-visible .node-text,
body[data-view='tray'] .node:focus-visible .node-text,
body[data-view='shelf'] .node.is-dragging .node-text,
body[data-view='tray'] .node.is-dragging .node-text {
  opacity: 1;
  border-color: var(--glass-edge);
  z-index: 12;
}

body[data-view='shelf'] .node-label,
body[data-view='tray'] .node-label {
  font-size: 10px;
  line-height: 1.25;
  max-width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

body[data-view='shelf'] .node-kind,
body[data-view='tray'] .node-kind {
  display: none;
}

/* THE SWITCH, top left of the container and only in the two views that have
   one. The container inherits pointer-events: none from the stage, so this
   takes its own back, and it sits above the pieces because a piece can be
   dragged into the corner on top of it. */
.pieces-btn {
  display: none;
}

body[data-view='shelf'] .pieces-btn,
body[data-view='tray'] .pieces-btn {
  position: absolute;
  top: 10px;
  left: 12px;
  z-index: 20;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 9px 3px 6px;
  border: 1px solid var(--hairline);
  border-radius: 999px;
  background: var(--glass);
  color: var(--ink-faint);
  font: inherit;
  font-size: 9.5px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  cursor: pointer;
  pointer-events: auto;
  opacity: 0.55;
  transition: opacity 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}

body[data-view='shelf'] .pieces-btn:hover,
body[data-view='tray'] .pieces-btn:hover,
body[data-view='shelf'] .pieces-btn:focus-visible,
body[data-view='tray'] .pieces-btn:focus-visible {
  opacity: 1;
  color: var(--ink);
  border-color: var(--glass-edge);
}

/* A little plate, drawn the way the pieces wear theirs: filled when the names
   are on, an outline when they are not. */
.pieces-mark {
  width: 14px;
  height: 8px;
  border-radius: 999px;
  border: 1px solid currentColor;
  background: transparent;
  transition: background 0.18s ease;
}

.pieces-btn[aria-pressed='true'] .pieces-mark {
  background: hsl(265 90% 70% / 0.85);
  border-color: hsl(265 90% 70% / 0.85);
}

.pieces-btn[aria-pressed='true'] {
  opacity: 0.75;
}

/* BARE BUBBLES: what the container did before the names went on the pieces.
   The floor goes back under the ball (app.js returns a pad of zero), and the
   name goes back to appearing above the ball under the pointer only. */
body.is-bare-pieces[data-view='shelf'] .node-text,
body.is-bare-pieces[data-view='tray'] .node-text {
  top: auto;
  bottom: calc(100% + 5px);
  max-width: 130px;
  padding: 3px 8px;
  transform: translateX(-50%) translateY(3px);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.16s ease, transform 0.16s ease, visibility 0.16s;
}

body.is-bare-pieces[data-view='shelf'] .node:hover .node-text,
body.is-bare-pieces[data-view='tray'] .node:hover .node-text,
body.is-bare-pieces[data-view='shelf'] .node:focus-visible .node-text,
body.is-bare-pieces[data-view='tray'] .node:focus-visible .node-text,
body.is-bare-pieces[data-view='shelf'] .node.is-dragging .node-text,
body.is-bare-pieces[data-view='tray'] .node.is-dragging .node-text {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
}

/* The hover card stays, and it is placed against the VIEWPORT by floatTip in
   app.js rather than against the bubble: the container clips its contents so a
   thrown bubble stops at the wall, and that same clip was cutting the top off
   any card that opened near the rim. Only the appearance is set here. */
body[data-view='shelf'] .tip,
body[data-view='tray'] .tip {
  bottom: auto;
  z-index: 40;
  margin: 0;
}

/* The themes panel would sit on top of the bucket, and says nothing the
   coloured bubbles do not. */
body[data-view='shelf'] .legend,
body[data-view='tray'] .legend {
  display: none;
}

/* THE WIDGET RAILS. The left rail is fixed and was landing straight on top of
   the shelf: Raw events sat over Communications. In these two views the panels
   all go to the right, and the stage keeps clear of them. */
@media (min-width: 1180px) {
  /* SHELF: the left of the page is the bucket, so both rails share the right
     edge — the existing one keeps the top half, the displaced one takes the
     bottom half. Overlapping them, which is what "bottom: 78px" alone did,
     put Raw events straight on top of What each relay knew. */
  body[data-view='shelf'] .rail-left {
    left: auto;
    right: clamp(16px, 2.4vw, 30px);
    top: auto;
    bottom: 78px;
    height: calc(50vh - 80px);
  }

  body[data-view='shelf'] .rail-right {
    bottom: auto;
    height: calc(50vh - 20px);
  }

  body[data-view='shelf'] .stage {
    padding-right: clamp(240px, 21vw, 310px);
  }

  /* TRAY: the tray is along the bottom, so the left of the page is free and
     the rails stay exactly where they always are. */
  body[data-view='tray'] .stage {
    padding: 8px clamp(240px, 21vw, 310px) 20px;
  }
}

@media (max-width: 1024px) {
  body[data-view='shelf'] .stage {
    flex-direction: column;
    align-items: center;
  }

  body[data-view='shelf'] .constellation {
    width: 100%;
    max-width: 520px;
    height: 420px;
  }
}

/* The profile while it is being moved. */
.core.is-dragging {
  cursor: grabbing;
  z-index: 9;
}

/* Everything inside it keeps its own cursor: only the empty glass drags. */
.core button,
.core a,
.core input {
  cursor: pointer;
}

/* A hover card that has been moved out of its bubble and into the body, so no
   ancestor can clip it. Its usual .node:hover rules cannot reach it here, so it
   carries its own visible state. */
.tip.is-floating {
  position: fixed;
  z-index: 60;
  opacity: 1;
  visibility: visible;
  pointer-events: none;
  margin: 0;
  bottom: auto;
  right: auto;
}

/* A CARD BEING DRAGGED IS ALWAYS VISIBLE. Two things could hide it: the rail's
   fade mask, which is lifted for the duration of a drag, and the card's own
   settle-in entry animation, which starts at opacity 0 and fills backwards —
   so anything that restarts it mid-drag makes the card vanish under your
   pointer. Rather than hunt for what restarts it, the drag simply opts out of
   the animation and states its opacity. */
.card.is-dragging {
  animation: none !important;
  opacity: 1 !important;
  z-index: 60;
  position: relative;
}

/* Super bubbles: the switch, and what it does to the page. */
.super-btn {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  margin-top: 10px;
  padding: 9px 11px;
  border: 1px solid var(--hairline);
  border-radius: 14px;
  background: var(--glass);
  color: var(--ink);
  font: inherit;
  font-size: 13px;
  text-align: left;
  cursor: pointer;
  transition: border-color 0.2s, background 0.2s;
}

.super-btn:hover {
  border-color: var(--glass-edge);
}

.super-btn[aria-pressed='true'] {
  border-color: hsl(285 90% 70% / 0.75);
  background: linear-gradient(120deg, hsl(285 90% 62% / 0.2), hsl(190 90% 60% / 0.16));
}

/* The switch is a block of its own, so the next heading needs air rather than
   reading as its caption. Set here rather than on .paint-label, which is
   shared with every other menu in the page. */
.super-btn + .paint-label {
  margin-top: 18px;
}

.super-spark {
  flex: none;
  width: 22px;
  height: 22px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  font-size: 12px;
  color: var(--ink-faint);
  background: light-dark(rgba(70, 60, 140, 0.1), rgba(255, 255, 255, 0.07));
  transition: color 0.2s, transform 0.4s cubic-bezier(0.34, 1.5, 0.5, 1);
}

.super-btn[aria-pressed='true'] .super-spark {
  color: hsl(285 95% 78%);
  transform: rotate(180deg) scale(1.15);
}

/* The slider has nothing to say while super is on: it is all the way up and
   then some. Saying so is better than leaving a control that does nothing. */
.bubble-level:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

/* And the bubbles themselves get a little more colour and a little more
   presence, so the mode is visibly a mode. */
body.is-super .lore-bubble > i {
  border-color: hsl(285 90% 72% / 0.28);
  box-shadow: inset -6px -8px 16px rgba(255, 255, 255, 0.05),
    0 0 26px -8px hsl(285 90% 62% / 0.55);
}

body.is-super .lore-bubble:hover > i {
  border-color: hsl(285 95% 76% / 0.7);
}

/* THE WINDOW'S STANDING HINT. A frame the browser refused paints opaque white
   and is indistinguishable from a page that loaded: `contentWindow.location`
   throws for both, because the blocked frame is given an opaque origin, and a
   marker placed behind the frame is covered by that white. Both were tested
   against github.com, which refuses, and example.org, which does not.

   So the page stops guessing and says the true thing instead. The strip
   appears a few seconds after the window opens, whatever happened, and can be
   dismissed. On a service that loaded it is a thin line at the foot of the
   window; on one that refused it is the answer to the only question the
   reader has. */
.appwin-hint {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 3;
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0;
  padding: 7px 14px;
  font-size: 11.5px;
  line-height: 1.4;
  color: var(--ink-faint);
  background: var(--glass-strong);
  border-top: 1px solid var(--hairline);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  animation: hint-in 0.3s ease both;
}

/* One at a time. The line is true of every window, but a cascade of three
   showed three copies of it, and only the front one can be read anyway. */
.appwin:not(.is-front) .appwin-hint {
  display: none;
}

.appwin-hint[hidden] {
  display: none;
}

.appwin-hint-open {
  margin-left: auto;
  color: var(--ink);
  white-space: nowrap;
}

.appwin-hint-close {
  flex: none;
  width: 20px;
  height: 20px;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--ink-faint);
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
}

.appwin-hint-close:hover {
  color: var(--ink);
}

@keyframes hint-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
}

/* Where the host forbids framing there is no window to hint about. */
body.no-framing .appwin-hint {
  display: none;
}

/* THE DOCK. With several services open at once there has to be a way back to
   one you have buried, and raising a window by clicking it only works for the
   windows you can still see. */
.dock {
  position: fixed;
  left: 50%;
  bottom: 14px;
  transform: translateX(-50%);
  z-index: 50;
  display: flex;
  gap: 8px;
  max-width: min(92vw, 900px);
  padding: 7px 9px;
  overflow-x: auto;
  scrollbar-width: none;
  border: 1px solid var(--hairline);
  border-radius: 999px;
  background: var(--glass-strong);
  backdrop-filter: blur(20px) saturate(160%);
  -webkit-backdrop-filter: blur(20px) saturate(160%);
  box-shadow: var(--shadow);
  animation: hint-in 0.28s ease both;
}

.dock[hidden] {
  display: none;
}

.dock::-webkit-scrollbar {
  height: 0;
}

.dock-item {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 5px 9px 5px 8px;
  border: 1px solid transparent;
  border-radius: 999px;
  background: transparent;
  color: var(--ink-faint);
  font: inherit;
  font-size: 12px;
  white-space: nowrap;
  cursor: pointer;
  transition: color 0.18s, background 0.18s, border-color 0.18s;
}

.dock-item:hover {
  color: var(--ink);
  border-color: var(--glass-edge);
  background: var(--glass);
}

.dock-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: hsl(var(--accent));
}

.dock-close {
  margin-left: 2px;
  padding: 0 3px;
  border-radius: 50%;
  color: var(--ink-faint);
  font-size: 13px;
  line-height: 1;
}

.dock-close:hover {
  color: var(--ink);
}

/* A stack needs a front. The one you last touched keeps its full shadow; the
   rest recede rather than competing with it. */
.appwin:not(.is-front) {
  box-shadow: 0 14px 40px rgba(0, 0, 0, 0.4);
}

.appwin:not(.is-front) .appwin-bar {
  opacity: 0.72;
}

/* Room for the dock, so a window dragged to the bottom does not sit under it. */
body.has-window .foot {
  padding-bottom: 58px;
}
