/* ============================================================
   Superus — component styles
   Only semantic tokens from tokens.css are used here.
   ============================================================ */
* {
  box-sizing: border-box;
}
html,
body {
  height: 100%;
  margin: 0;
}
body {
  font-family: var(--font-ui);
  font-size: var(--fs-md);
  letter-spacing: var(--ls-13);
  line-height: var(--lh-base);
  color: var(--text-primary);
  background: var(--surface-sunken);
  overflow: hidden;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  font-feature-settings: 'cv11', 'ss01';
}
/* UA 样式对表单控件写死 letter-spacing: normal，`font:` 简写又会重置
   font-optical-sizing —— 不显式继承的话，整个交互层都会掉出 §4.2 的字距阶梯。 */
button {
  font: inherit;
  letter-spacing: inherit;
  font-optical-sizing: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}
button:disabled {
  cursor: default;
}
input,
textarea,
select {
  font: inherit;
  letter-spacing: inherit;
  font-optical-sizing: inherit;
}
kbd {
  font: var(--fw-medium) var(--fs-xs)/1 var(--font-ui);
  letter-spacing: var(--ls-11);
  color: var(--text-tertiary);
  background: var(--fill-subtle);
  border: 1px solid var(--border-default);
  border-bottom-width: 2px;
  border-radius: var(--r-xs);
  padding: 2px 5px;
}
svg {
  width: var(--icon-lg);
  height: var(--icon-lg);
  display: block;
  flex: none;
}
[hidden] {
  display: none !important;
}
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
mark {
  background: rgba(255, 255, 255, 0.16);
  color: inherit;
  border-radius: var(--r-xs);
}
.grow {
  flex: 1;
}

/* ---------- type utilities ---------- */
.t-eyebrow {
  font-size: var(--fs-2xs);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
}
.t-label {
  font-size: var(--fs-xs);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-secondary);
}
.t-meta {
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  color: var(--text-tertiary);
}

/* ---------- motion primitives ---------- */
@keyframes fade-in {
  from {
    opacity: 0;
  }
}
@keyframes rise-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
}
@keyframes drop-in {
  from {
    opacity: 0;
    transform: translateY(-6px);
  }
}
@keyframes scale-in {
  from {
    opacity: 0;
    transform: scale(0.97);
  }
}
@keyframes card-pop {
  from {
    opacity: 0;
    scale: 0.94;
  }
}
@keyframes card-right {
  from {
    opacity: 0;
    translate: 56px 0;
    scale: 0.98;
  }
}
@keyframes card-up {
  from {
    opacity: 0;
    translate: 0 18px;
    scale: 0.98;
  }
}
@keyframes fade-out {
  to {
    opacity: 0;
  }
}
@keyframes sink-out {
  to {
    opacity: 0;
    transform: translateY(4px);
  }
}
@keyframes scale-out {
  to {
    opacity: 0;
    transform: scale(0.98);
  }
}
@keyframes draw-in {
  from {
    stroke-dashoffset: 1;
  }
  to {
    stroke-dashoffset: 0;
  }
}
@keyframes tip-in {
  from {
    opacity: 0;
    transform: translateY(3px);
  }
}
@keyframes toast-out {
  to {
    opacity: 0;
    transform: translate(-50%, 6px);
  }
}
@keyframes flash-ring {
  0% {
    box-shadow: 0 0 0 0 var(--accent-ring);
  }
  100% {
    box-shadow: 0 0 0 18px transparent;
  }
}
@keyframes shimmer {
  to {
    background-position: -200% 0;
  }
}
@keyframes march {
  to {
    stroke-dashoffset: -12;
  }
}
@keyframes pulse {
  50% {
    opacity: 0.4;
  }
}
@keyframes shake {
  25% {
    transform: translateX(-4px);
  }
  75% {
    transform: translateX(4px);
  }
}

/* ---------- shared control primitives ---------- */
.icon-btn {
  width: var(--ctl-md);
  height: var(--ctl-md);
  border-radius: var(--r-sm);
  display: grid;
  place-items: center;
  color: var(--text-secondary);
  transition:
    background var(--dur-fast) var(--ease-out),
    color var(--dur-fast) var(--ease-out),
    transform var(--dur-fast) var(--ease-out);
}
.icon-btn:hover {
  background: var(--state-hover);
  color: var(--text-primary);
}
.icon-btn:active {
  transform: scale(0.94);
}
.icon-btn.on {
  color: var(--text-primary);
  background: var(--state-selected);
}
.icon-btn.small {
  width: var(--ctl-sm);
  height: var(--ctl-sm);
}
.icon-btn.small svg {
  width: var(--icon-md);
  height: var(--icon-md);
}
.icon-btn:disabled {
  opacity: 0.32;
}
.icon-btn:disabled:hover {
  background: none;
  color: var(--text-secondary);
}
.text-btn {
  height: var(--ctl-md);
  padding: 0 var(--sp-3);
  border-radius: var(--r-sm);
  color: var(--text-primary);
  font-weight: var(--fw-medium);
  transition:
    background var(--dur-fast) var(--ease-out),
    transform var(--dur-fast) var(--ease-out),
    filter var(--dur-fast);
}
.text-btn:hover {
  background: var(--state-hover);
}
.text-btn:active {
  transform: scale(0.97);
}
.text-btn.primary {
  background: var(--accent);
  color: var(--text-on-accent);
}
.text-btn.primary:hover {
  background: var(--accent-hover);
}
.text-btn.primary:active {
  background: var(--accent-active);
}
.text-btn.ghost {
  background: var(--fill-subtle);
}
.text-btn.ghost:hover {
  background: var(--fill-strong);
}
.text-btn.danger {
  background: var(--danger);
  color: var(--text-on-accent);
}
.text-btn.danger:hover {
  filter: brightness(1.08);
}
.link-btn {
  color: var(--text-secondary);
  font-weight: var(--fw-medium);
}
.link-btn:hover {
  color: var(--text-primary);
}
.link-btn:hover {
  text-decoration: underline;
}
.fav {
  border-radius: var(--r-xs);
  flex: none;
}

.app {
  display: flex;
  flex-direction: row;
  height: 100vh;
}
.main-col {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
}
.app.outline-open .topbar .traffic,
.app.outline-open .topbar .traffic + .divider,
.app.outline-open .topbar #btn-outline {
  display: none;
}

/* ---------- top bar ---------- */
.topbar {
  height: 44px;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  padding: 0 var(--sp-3);
  background: var(--surface-chrome);
  border-bottom: 1px solid var(--border-default);
  position: relative;
  z-index: var(--z-chrome);
}
.topbar-left,
.topbar-right {
  display: flex;
  align-items: center;
  gap: var(--sp-1);
}
.topbar-right {
  justify-content: flex-end;
}
.traffic {
  display: flex;
  gap: 7px;
  padding: 0 6px 0 2px;
}
.tl {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--text-disabled);
}
.tl.red {
  background: var(--traffic-red);
}
.tl.yellow {
  background: var(--traffic-yellow);
}
.tl.green {
  background: var(--traffic-green);
}
.divider {
  width: 1px;
  height: 18px;
  background: var(--border-default);
  margin: 0 6px;
}
.board-name {
  display: flex;
  align-items: center;
  gap: var(--sp-1);
  height: var(--ctl-md);
  padding: 0 var(--sp-2);
  border-radius: var(--r-sm);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-13);
  transition: background var(--dur-fast) var(--ease-out);
}
.board-name #board-name-text {
  max-width: 220px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.board-name:hover {
  background: var(--state-hover);
}
/* The disclosure chevron is an answer to a question nobody is asking until they
   point at the name, so it waits until they do. It fades rather than being
   removed from the box, so the title never shifts under the pointer — and it
   stays up while the menu it opened is open. */
.board-name .chev {
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease-out);
}
.board-name:hover .chev,
.board-name:focus-visible .chev,
.board-name.open .chev {
  opacity: 1;
}
.board-name .chev svg {
  width: var(--icon-sm);
  height: var(--icon-sm);
  color: var(--text-tertiary);
}
/* Sits last in the top bar's left group, so appearing and disappearing moves
   nothing beside it. */
.save-state {
  margin-left: var(--sp-1);
  flex: none;
  white-space: nowrap;
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  color: var(--text-tertiary);
  transition:
    color var(--dur-base),
    opacity var(--dur-base) var(--ease-in);
  animation: fade-in var(--dur-base) var(--ease-out);
}
.save-state.busy {
  color: var(--text-secondary);
}
.save-state.leaving {
  opacity: 0;
}
/* The only state worth a permanent place in the chrome is the one that means
   the board is not where the user thinks it is. It gets a chip, not a word. */
.save-state.failed {
  color: var(--danger-text);
  background: var(--danger-soft);
  border: 1px solid var(--danger-border);
  border-radius: var(--r-sm);
  padding: 2px var(--sp-2);
  font-weight: var(--fw-medium);
  animation: shake 0.4s var(--ease-out);
}
.avatars {
  display: flex;
  margin-right: 6px;
}
.avatar {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  font-weight: var(--fw-bold);
  color: var(--text-on-accent);
  background: var(--avatar-gradient);
  border: 2px solid var(--surface-chrome);
}

/* ---------- layout ---------- */
.main {
  flex: 1;
  display: flex;
  min-height: 0;
  position: relative;
}

/* ---------- outline ---------- */
.outline {
  width: 260px;
  flex: none;
  background: var(--surface-chrome);
  border-right: 1px solid var(--border-default);
  display: flex;
  flex-direction: column;
  transition:
    width var(--dur-slow) var(--ease-in-out),
    opacity var(--dur-base) var(--ease-out);
  overflow: hidden;
}
.outline.collapsed {
  width: 0;
  border-right-width: 0;
  opacity: 0;
}
/* The sidebar is a full-height rail, so its first and last bands sit beside the
   top and bottom bars and the four of them read as two horizontal bands across
   the window. They therefore have to be the SAME height and carry the SAME
   hairline, or the window's own edges break at x = the sidebar's width.
   They didn't: this band was 46px against the top bar's 44, which put the
   traffic lights 1.5px below everything in the top bar and moved them when the
   sidebar was toggled; the footer started 6.5px lower than the bottom bar and
   drew its rule in --border-subtle against the bar's --border-default, so the
   bottom edge stepped in both position and colour. */
.outline-top {
  height: 44px;
  flex: none;
  display: flex;
  align-items: center;
  padding: 0 var(--sp-2) 0 var(--sp-3);
  min-width: 260px;
  border-bottom: 1px solid var(--border-default);
}
.outline-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  padding: var(--sp-2) 10px 6px var(--sp-4);
  color: var(--text-secondary);
  font-weight: var(--fw-semibold);
  font-size: var(--fs-md);
  letter-spacing: var(--ls-13);
  min-width: 260px;
}
.outline-head-actions {
  display: flex;
  gap: 2px;
}
/* The sidebar's own scrollbar is the canvas overlay scrollbar in a track: same
   tokens, same alpha, same radius. A native bar next to the ones on the board
   would be the only piece of unstyled chrome in the app. */
/* No padding at the top: it is inside the scrollport, so a sticky section header
   pinned at top:0 leaves it uncovered and rows scroll through the gap. The 6px
   under the "Outline" heading already separates the first row. */
.outline-list {
  flex: 1;
  overflow: auto;
  overflow-x: hidden;
  padding: 0 var(--sp-2) var(--sp-1);
  min-width: 260px;
  scrollbar-width: thin;
  scrollbar-color: var(--sb-thumb) transparent;
}
.outline-list::-webkit-scrollbar {
  width: 11px;
}
.outline-list::-webkit-scrollbar-track {
  background: transparent;
}
.outline-list::-webkit-scrollbar-thumb {
  background: var(--sb-thumb);
  background-clip: padding-box;
  border: 3px solid transparent;
  border-radius: var(--r-full);
}
.outline-list::-webkit-scrollbar-thumb:hover {
  background: var(--sb-thumb-hover);
  background-clip: padding-box;
}

/* One height for every row, whatever it holds. The action button used to be a
   grid column, so a row with one stood 34px tall and a row without stood 31 —
   the list never sat on a grid, and the eye reads that as slop long before it
   can name it. The button overlays now and the row is 28px, full stop.

   Depth is a grid SPACER COLUMN inside a row that always spans the full panel,
   which is GitHub's technique (per Ahmad Shadeed's survey of how the design
   systems do this) and the reason hover and selection line up at every level.
   Indenting with margin — what this used to do — moved the row box itself, so a
   selected child's tint started 18px in while its parent's started at 0.
   Photoshop's hardcoded per-level padding and Carbon's negative margins are the
   other two ways to get this wrong; one does not scale past its table of
   values, the other breaks the click target.

   12px a level, not 18: Primer's guidance is to keep a tree horizontally
   compact so it can afford both depth and long labels, and 260px does not have
   room to be generous about either. */
.ol-item {
  --indent: 12px;
  --gutter: var(--sp-2);
  position: relative;
  height: 28px;
  display: grid;
  grid-template-columns: 18px 1fr auto;
  align-items: center;
  column-gap: var(--sp-2);
  border-radius: var(--r-sm);
  /* The indent is padding on a row that always spans the full panel. A grid
     spacer column would do the same job, but its column-gap still applies at
     level 0, which pushes every root row 8px right of where the guides expect
     it — one variable in one place is easier to keep honest. */
  padding: 0 var(--gutter) 0
    calc(var(--gutter) + var(--level, 0) * var(--indent));
  color: var(--text-primary);
  cursor: default;
  user-select: none;
  min-width: 0;
  transition:
    background var(--dur-fast) var(--ease-out),
    color var(--dur-fast) var(--ease-out);
  /* One hairline per ancestor: a 1px stripe repeating on the indent, clipped to
     the width of the indent itself, so a row at level N draws exactly N of them.
     It scales to any depth without a rule per level, and because the stripe
     period and the indent are the same number, every guide lands dead on the
     centre of the icon column of the level it belongs to — a guide always points
     at the row that owns it. */
  background-image: repeating-linear-gradient(
    to right,
    var(--tree-guide) 0 1px,
    transparent 1px var(--indent)
  );
  background-size: calc(var(--level, 0) * var(--indent)) 100%;
  background-position: calc(var(--gutter) + 9px) 0;
  background-repeat: no-repeat;
}
.ol-item .fav,
.ol-item .ti {
  justify-self: center;
}
.ol-item:hover {
  background-color: var(--state-hover);
}
/* Selection is a translucent layer, like every other state in the system, not a
   solid accent slab. Two reasons it cannot be solid here: the canvas card
   already spends the one solid accent this view is allowed on its selection
   ring, and a marquee over ten cards would turn the whole sidebar into a block
   of indigo. The tint carries the state; the accent icon and the medium weight
   make sure it still reads as *the* selected row and not merely a warm one. */
.ol-item.on {
  background-color: var(--state-selected);
}
.ol-item.on .ol-title {
  font-weight: var(--fw-medium);
}
.ol-item.on .ti svg {
  color: var(--accent);
}
.ol-item .fav {
  width: var(--icon-md);
  height: var(--icon-md);
  border-radius: var(--r-xs);
}
.ol-item .ti svg {
  width: var(--icon-md);
  height: var(--icon-md);
  color: var(--text-secondary);
}
/* How many cards a frame holds, or how many items are inside a sub-canvas —
   the one piece of metadata that answers "what is in there" for a row whose
   contents are not on screen. Quiet: it is a footnote to the name. */
.ol-count {
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  color: var(--text-tertiary);
  font-variant-numeric: tabular-nums;
  padding-left: var(--sp-2);
}
.ol-item.on .ol-count {
  color: var(--text-secondary);
}
/* A frame is a section, so it is set as one — and it sticks, because on a board
   with several frames the thing you most need while scrolling a long list is
   which region you are currently inside. The list's 8px gutters stay empty at
   every scroll position, so an opaque row inset by the same 8px covers
   everything that could pass behind it. */
/* Nested frames all stick, so they have to stack rather than land on top of one
   another: each parks one row-height lower than its parent. Capped at three deep
   — past that the stack would eat the list it is meant to label. */
.ol-item.t-frame {
  position: sticky;
  top: calc(min(var(--level, 0), 3) * 28px);
  /* Shallower headers park higher AND paint above, so a parent is never pushed
     behind the child it contains as the two meet. */
  z-index: calc(4 - min(var(--level, 0), 3));
  background-color: var(--surface-chrome);
}
.ol-item.t-frame:not(:first-child) {
  margin-top: var(--sp-2);
}
.ol-item.t-frame:hover {
  background-color: var(--state-hover);
}
.ol-item.t-frame.on {
  background-color: var(--state-selected);
}
.ol-item.t-frame .ol-title {
  font-size: var(--fs-xs);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-secondary);
}
.ol-item.t-frame .ti svg {
  color: var(--text-tertiary);
}
.ol-item.t-frame.on .ti svg {
  color: var(--accent);
}
.ol-title {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* The title owns the full width of the row. Reserving a column for a button
   that is invisible 95% of the time cost every source row 32px of name — which
   is why "Visit Canada - Canada.ca" had to truncate at 143px inside a 260px
   panel. On hover the title is masked out from under the button instead, so
   nothing moves and no background has to be matched. */
.ol-item:hover .ol-title,
.ol-item:focus-within .ol-title {
  mask-image: linear-gradient(
    90deg,
    #000 calc(100% - 20px),
    transparent calc(100% - 4px)
  );
  -webkit-mask-image: linear-gradient(
    90deg,
    #000 calc(100% - 20px),
    transparent calc(100% - 4px)
  );
}
.ol-item.t-frame:hover .ol-title {
  mask-image: none;
  -webkit-mask-image: none;
}
.ol-act {
  position: absolute;
  right: 2px;
  top: 50%;
  translate: 0 -50%;
  width: 20px;
  height: 20px;
  border-radius: var(--r-xs);
  display: grid;
  place-items: center;
  color: var(--text-tertiary);
  opacity: 0;
  transition:
    opacity var(--dur-fast),
    background var(--dur-fast),
    color var(--dur-fast);
}
.ol-act svg {
  width: var(--icon-sm);
  height: var(--icon-sm);
}
.ol-item:hover .ol-act,
.ol-act:focus-visible {
  opacity: 1;
}
.ol-act:hover {
  background: var(--state-active);
  color: var(--text-primary);
}
.ol-act:active {
  transform: scale(0.92);
}
/* A pressed toggle has to stay visible when the pointer leaves, or the only
   sign that the panel belongs to this row vanishes the moment you move away —
   and then the button that closes Side Peek is one you cannot see. */
.ol-item .ol-act.on {
  opacity: 1;
  background: var(--state-selected);
  color: var(--accent);
}
.card.peek-source .card-head-actions {
  opacity: 1;
}
.chb.on,
.card-toolbar button.on {
  background: var(--state-selected);
  color: var(--accent);
}
.ol-empty {
  padding: var(--sp-6) var(--sp-3);
  color: var(--text-tertiary);
  line-height: var(--lh-relaxed);
  text-align: center;
}
.outline-foot {
  height: 44px;
  flex: none;
  display: flex;
  align-items: center;
  padding: 0 var(--sp-4);
  border-top: 1px solid var(--border-default);
  color: var(--text-tertiary);
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  white-space: nowrap;
  min-width: 260px;
  font-variant-numeric: tabular-nums;
}

/* ---------- viewport ---------- */
.viewport {
  flex: 1;
  position: relative;
  overflow: hidden;
  background-color: var(--surface-sunken);
  cursor: default;
  user-select: none;
  box-shadow:
    inset 0 7px 12px -8px var(--canvas-well),
    inset 7px 0 12px -8px var(--canvas-well);
}
/* Grid on its own layer, beneath .world: two dot levels an octave apart,
   crossfaded by app.js. Each layer's position is pulled back half a tile so
   both cascades share a phase and the coarse dots land on every second fine
   dot instead of interleaving with them. */
.canvas-grid {
  position: absolute;
  inset: 0;
  pointer-events: none;
  contain: layout paint;
  background-image:
    radial-gradient(
      circle at center,
      color-mix(
          in oklch,
          var(--canvas-grid-dot) calc(var(--grid-aa, 1) * 100%),
          transparent
        )
        0 var(--canvas-dot-r),
      transparent var(--canvas-dot-feather)
    ),
    radial-gradient(
      circle at center,
      color-mix(
          in oklch,
          var(--canvas-grid-dot) calc(var(--grid-ab, 0) * 100%),
          transparent
        )
        0 var(--canvas-dot-r),
      transparent var(--canvas-dot-feather)
    );
  background-size:
    var(--grid-sa, 24px) var(--grid-sa, 24px),
    var(--grid-sb, 48px) var(--grid-sb, 48px);
  background-position:
    calc(var(--grid-x, 0px) - var(--grid-sa, 24px) / 2)
      calc(var(--grid-y, 0px) - var(--grid-sa, 24px) / 2),
    calc(var(--grid-x, 0px) - var(--grid-sb, 48px) / 2)
      calc(var(--grid-y, 0px) - var(--grid-sb, 48px) / 2);
  mask-image:
    linear-gradient(to bottom, transparent, #000 var(--canvas-edge-fade)),
    linear-gradient(to right, transparent, #000 var(--canvas-edge-fade));
  mask-composite: intersect;
  -webkit-mask-image:
    linear-gradient(to bottom, transparent, #000 var(--canvas-edge-fade)),
    linear-gradient(to right, transparent, #000 var(--canvas-edge-fade));
  -webkit-mask-composite: source-in;
  transition: opacity var(--dur-slow) var(--ease-out);
}
.app.present .canvas-grid {
  opacity: 0;
}
.viewport[data-tool='hand'],
.viewport.space {
  cursor: grab;
}
.viewport.panning {
  cursor: grabbing !important;
}
.viewport.dragging,
.viewport.dragging * {
  cursor: grabbing !important;
}
.viewport[data-tool='line'],
.viewport[data-tool='text'],
.viewport[data-tool='frame'] {
  cursor: crosshair;
}
.world {
  position: absolute;
  left: 0;
  top: 0;
  transform-origin: 0 0;
  will-change: transform;
}
/* The camera is interpolated in JS (animateTransform / camStep) rather than by a
   CSS transition: a transition moves only .world, and the dot grid, selection
   toolbar, connect anchors and minimap viewport would all snap to the destination
   on frame one and wait there for the board to catch up. */

.links,
.guides {
  position: absolute;
  left: 0;
  top: 0;
  width: 1px;
  height: 1px;
  overflow: visible;
  pointer-events: none;
}
.guides line {
  stroke: var(--guide);
}
.guides .glabel rect {
  fill: var(--guide);
}
.guides .glabel text {
  fill: #fff;
  font-family: var(--font-ui);
  font-weight: var(--fw-semibold);
  font-variant-numeric: tabular-nums;
}
.links .link {
  pointer-events: auto;
  cursor: pointer;
  color: var(--link-stroke);
  transition: color var(--dur-fast);
}
.links .link.related {
  color: var(--link-stroke-related);
}
.links .link.c-blue {
  color: var(--note-blue-solid);
}
.links .link.c-teal {
  color: var(--note-teal-solid);
}
.links .link.c-amber {
  color: var(--note-amber-solid);
}
.links .link.c-rose {
  color: var(--note-rose-solid);
}
.links .link.c-default:hover,
.links .link.c-default.selected {
  color: var(--accent);
}
.links .link:hover,
.links .link.selected {
  filter: brightness(1.15) saturate(1.1);
}
.links .hit {
  fill: none;
  stroke: transparent;
  stroke-width: 20;
}
.links .halo {
  fill: none;
  stroke: currentColor;
  stroke-width: 10;
  stroke-linecap: round;
  opacity: 0;
  transition: opacity var(--dur-fast);
}
.links .link:hover .halo {
  opacity: 0.16;
}
.links .link.selected .halo {
  opacity: 0.24;
}
.links .stroke {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.links .stroke.dashed {
  stroke-dasharray: 7 6;
}
.links .cap {
  fill: currentColor;
  stroke: none;
}
.links .link.fresh .stroke {
  stroke-dasharray: 1;
  animation: draw-in 0.55s var(--ease-in-out) both;
}
.links .link.fresh .cap {
  animation: fade-in 0.2s 0.4s both;
}
.links .link.preview {
  color: var(--accent);
  pointer-events: none;
}
.links .link.preview .stroke {
  stroke-dasharray: 6 6;
  animation: march 0.6s linear infinite;
}
.cards {
  position: absolute;
  left: 0;
  top: 0;
}

/* connector labels (world space) */
.link-labels {
  position: absolute;
  left: 0;
  top: 0;
}
.link-label {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 9000;
  pointer-events: auto;
}
.link-label-in {
  transform: translate(-50%, -50%);
  display: inline-block;
  max-width: 240px;
  padding: 3px 9px;
  background: var(--surface-overlay);
  border: 1px solid var(--border-default);
  border-radius: var(--r-sm);
  color: var(--text-primary);
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
  font-weight: var(--fw-medium);
  line-height: var(--lh-snug);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  box-shadow: var(--shadow-sm);
  cursor: default;
  outline: 0;
  transition:
    border-color var(--dur-fast),
    box-shadow var(--dur-fast);
}
.link-label:hover .link-label-in {
  border-color: var(--border-strong);
}
.link-label.selected .link-label-in {
  border-color: var(--accent);
}
.link-label-in[contenteditable='true'] {
  white-space: normal;
  min-width: 60px;
  cursor: text;
  border-color: var(--accent);
  box-shadow: var(--ring-focus);
}
.link-label-in[contenteditable='true']:empty::before {
  content: 'Label';
  color: var(--text-tertiary);
}

/* connection anchors + endpoint handles (screen-constant size) */
.anchors {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 9500;
  pointer-events: none;
}
.anchor {
  position: absolute;
  left: 0;
  top: 0;
  width: 22px;
  height: 22px;
  margin: -11px 0 0 -11px;
  display: grid;
  place-items: center;
  pointer-events: auto;
  cursor: crosshair;
  transform-origin: 11px 11px;
  animation: fade-in var(--dur-fast);
}
.anchor span {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--anchor-fill);
  border: 1.1px solid var(--anchor-ring);
  opacity: 0.6;
  transition:
    transform var(--dur-fast) var(--ease-spring),
    background var(--dur-fast),
    border-color var(--dur-fast),
    opacity var(--dur-fast);
}
.anchor:hover span {
  transform: scale(1.3);
  background: var(--accent);
  border-color: var(--accent);
  opacity: 1;
}
.link-end {
  position: absolute;
  left: 0;
  top: 0;
  width: 12px;
  height: 12px;
  margin: -6px 0 0 -6px;
  border-radius: 50%;
  background: var(--handle-fill);
  border: 2px solid var(--handle-ring);
  box-shadow: 0 0 0 2px var(--handle-halo);
  pointer-events: auto;
  cursor: grab;
  transform-origin: 6px 6px;
}
.link-end:hover {
  background: var(--accent);
}
/* Square = resize, circle = connect. One shape per verb, both screen-constant. */
.rz-handle {
  position: absolute;
  left: 0;
  top: 0;
  width: 8px;
  height: 8px;
  margin: -4px 0 0 -4px;
  background: var(--handle-fill);
  border: 1px solid var(--accent);
  border-radius: 2px;
  transform-origin: 4px 4px;
  pointer-events: auto;
  box-shadow: 0 1px 2px oklch(0% 0 0 / 0.2);
  animation: fade-in var(--dur-fast);
}
.rz-handle:hover {
  background: var(--accent);
}
.rz-h-nw,
.rz-h-se {
  cursor: nwse-resize;
}
.rz-h-ne,
.rz-h-sw {
  cursor: nesw-resize;
}
.viewport.connecting,
.viewport.connecting * {
  cursor: crosshair !important;
}
.marquee {
  position: absolute;
  border: 1px solid var(--accent);
  background: var(--accent-soft);
  pointer-events: none;
  z-index: 5;
  border-radius: var(--r-xs);
}
.canvas-hint {
  position: absolute;
  top: var(--sp-4);
  left: 50%;
  transform: translateX(-50%);
  background: var(--surface-overlay);
  border: 1px solid var(--overlay-border);
  padding: 7px var(--sp-3);
  border-radius: var(--r-md);
  color: var(--text-secondary);
  z-index: 6;
  pointer-events: none;
  box-shadow: var(--shadow-md);
  animation: drop-in var(--dur-base) var(--ease-out);
}
.canvas-hint {
  transform: translateX(-50%);
}
@keyframes hint-in {
  from {
    opacity: 0;
    transform: translate(-50%, -6px);
  }
}
.canvas-hint {
  animation-name: hint-in;
}

/* ---------- cards ---------- */
/* container-type: size lets .lod-cover size its whole composition from the card
   it is in (cqw / cqh). The card already has an explicit width and height, so the
   size containment this implies changes nothing about layout. */
.card {
  container-type: size;
  contain: layout;
  position: absolute;
  left: 0;
  top: 0;
  border-radius: var(--r-lg);
  background: var(--surface-raised);
  box-shadow: var(--shadow-card);
  outline: 1.5px solid transparent;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  transition:
    outline-color var(--dur-fast) var(--ease-out),
    box-shadow var(--dur-base) var(--ease-out);
}
.card.enter-pop {
  animation: card-pop var(--dur-lazy) var(--ease-spring);
}
.card.enter-right {
  animation: card-right 480ms var(--ease-out);
}
.card.enter-up {
  animation: card-up var(--dur-lazy) var(--ease-out);
}
.card.leave {
  opacity: 0;
  scale: 0.96;
  transition:
    opacity var(--dur-base) var(--ease-in),
    scale var(--dur-base) var(--ease-in);
  pointer-events: none;
}
.card {
  transition:
    outline-color var(--dur-fast) var(--ease-out),
    box-shadow var(--dur-base) var(--ease-out),
    scale var(--dur-base) var(--ease-out);
}
/* Set by cull(): the box stays (geometry, hit testing and the drop shadow are all
   unchanged), the contents stop being rendered. content-visibility is the cheap half
   of virtualisation without any of the scroll-position bookkeeping. */
.card.offscreen {
  content-visibility: hidden;
}
.card.lifted {
  scale: 1.012;
  box-shadow: var(--ring-selected), var(--shadow-lg);
  z-index: 9999 !important;
  will-change: transform;
}
.card.lifted .card-head {
  cursor: grabbing;
}
.card.pulse {
  animation: flash-ring 0.8s var(--ease-out);
}
/* Hover is neutral; accent is reserved for states the user caused. */
/* One channel per state, so nothing stacks:
     elevation (shadow) = hovered   — the pointer is on this
     outline  (accent)  = selected  — this is the chosen one
     handles            = selected  — here is where to grab
     fill               = identity  — note colour, frame region
   Hover used to fire four of these at once. The pointer crosses many
   objects, so hover has to be the cheapest signal of the set. */
.card.selected {
  outline-color: var(--accent);
  box-shadow: var(--ring-selected), var(--shadow-card);
}
.card.peek-source {
  outline-color: var(--accent);
  outline-style: dashed;
}
.card.link-target {
  outline: 2px solid var(--accent);
  box-shadow:
    0 0 0 6px var(--accent-ring),
    var(--shadow-card);
}
.card.flash {
  animation: flash-ring 0.9s var(--ease-out);
}
/* The head names the plane it is on (--head-fill) and the seam that plane
   wants (--head-seam); the rules below re-point those two per card kind, so
   the shape of the seam is written once.
   The seam is a painted rule, not a border, and it fades out before either
   rounded corner. A full-bleed 1px border reads as a CUT across the card —
   it terminates hard into the corner arc, and being in world units it lands
   on 1.21 device px at 121% zoom, which smears it across two rows. Fading
   the ends and pinning it to one screen pixel makes it a seam again. */
.card-head {
  --head-fill: var(--card-chrome);
  --head-seam: var(--card-chrome-divider);
  height: 32px;
  flex: none;
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 0 6px 0 10px;
  background-color: var(--head-fill);
  background-image: linear-gradient(
    90deg,
    transparent,
    var(--head-seam) 7%,
    var(--head-seam) 93%,
    transparent
  );
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 100% 1px;
  color: var(--text-secondary);
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
  font-weight: var(--fw-medium);
  cursor: grab;
  transition: background-color var(--dur-fast);
}
.card.selected .card-head {
  --head-fill: var(--card-chrome-selected);
}
/* A web card's head sits on the PAGE plane: page white, page ink, a seam in
   page ink. That is what closes the dark-mode gap — the head no longer has
   an app-themed colour to disagree with the white page underneath it. */
.card.web .card-head {
  --head-fill: var(--card-chrome-page);
  --head-seam: var(--card-chrome-page-divider);
  color: var(--card-chrome-page-ink);
}
.card.web.selected .card-head {
  --head-fill: var(--card-chrome-page-selected);
}
.card.web .card-title {
  color: var(--text-page);
}
.card.web .chb {
  color: var(--card-chrome-page-ink);
}
.card.web .chb:hover {
  background: var(--card-chrome-page-hover);
  color: var(--text-page);
}
.card.web .chb:active {
  background: var(--card-chrome-page-active);
}
.card.web .rename-input {
  background: var(--card-chrome-page-active);
  color: var(--text-page);
}
.card-head:active {
  cursor: grabbing;
}
.card-head .fav {
  width: var(--icon-md);
  height: var(--icon-md);
  flex: none;
}
.card-head .ti svg {
  width: var(--icon-md);
  height: var(--icon-md);
  color: currentColor;
  opacity: 0.85;
}
/* the head is flush with the body now, so the title carries the weight the
   old grey band used to: full-strength ink, not the secondary rung. */
.card-title {
  flex: 1;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--text-primary);
}
.card-head-actions {
  display: flex;
  gap: 2px;
  opacity: 0;
  transition: opacity var(--dur-fast);
}
.card:hover .card-head-actions,
.card.selected .card-head-actions {
  opacity: 1;
}
.chb {
  width: 24px;
  height: 24px;
  border-radius: var(--r-sm);
  display: grid;
  place-items: center;
  color: var(--text-secondary);
  transition:
    background var(--dur-fast),
    color var(--dur-fast);
}
.chb svg {
  width: var(--icon-md);
  height: var(--icon-md);
}
.chb:hover {
  background: var(--state-hover);
  color: var(--text-primary);
}
.chb:active {
  background: var(--state-active);
}
.card-body {
  flex: 1;
  min-height: 0;
  position: relative;
}
.card.web .card-body {
  background: var(--surface-page);
  color: var(--text-page);
  cursor: default;
}
/* peripheral page content is veiled so app chrome stays above it in the
   hierarchy; focused reading surfaces (peek, expand) never are.
   The veil covers the whole card, title bar included — it used to sit on the
   body alone, which meant the head kept its own colour while the page under
   it dimmed, and the two could only agree at one veil strength. Over both,
   they are always the same colour, dimmed and lit together. */
.card.web::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: var(--page-veil);
  transition: background var(--dur-base) var(--ease-out);
  z-index: 2;
}
.card.web:hover::after,
.card.web.selected::after {
  background: transparent;
}
.page-host {
  overflow: hidden;
}
.page-scroll {
  position: absolute;
  inset: 0;
  overflow: auto;
  overscroll-behavior: contain;
  animation: fade-in var(--dur-base) var(--ease-out);
}
.rename-input {
  flex: 1;
  min-width: 0;
  background: var(
    --surface-sunken
  ); /* 这个槽开在标题栏上，而标题栏现在与卡片同面（浅色为白）；用最深的那一层才能在两种状态下都看出是个可输入的凹槽 */
  border: 1px solid var(--accent);
  border-radius: var(--r-xs);
  color: var(--text-primary);
  padding: 2px 6px;
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
  outline: 0;
}
.resize {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 18px;
  height: 18px;
  cursor: nwse-resize;
  z-index: 3;
}
.resize::after {
  content: '';
  position: absolute;
  right: 4px;
  bottom: 4px;
  width: 7px;
  height: 7px;
  border-right: 2px solid var(--border-strong);
  border-bottom: 2px solid var(--border-strong);
  border-radius: 1px;
  opacity: 0;
  transition: opacity var(--dur-fast);
}
.card.web .resize::after {
  border-color: var(--on-page-ink);
}
.card.selected .resize::after {
  opacity: 1;
}

/* skeleton / loading */
.skeleton {
  position: absolute;
  inset: 0;
  padding: 18px;
  background: var(--surface-page);
  display: none;
  flex-direction: column;
  gap: var(--sp-3);
}
.card.loading .skeleton,
.peek.loading .skeleton,
.skeleton.show {
  display: flex;
}
.card.loading .card-body {
  visibility: hidden;
}
.peek .skeleton {
  position: relative;
}
.sk-bar,
.sk-block {
  border-radius: var(--r-xs);
  background: linear-gradient(
    90deg,
    var(--skeleton-base) 25%,
    var(--skeleton-shine) 50%,
    var(--skeleton-base) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.2s linear infinite;
}
.sk-bar {
  height: 12px;
}
.sk-bar.w1 {
  width: 60%;
}
.sk-bar.w2 {
  width: 85%;
}
.sk-bar.w3 {
  width: 40%;
}
.sk-block {
  height: 90px;
}
.card.loading .card-head .fav {
  animation: pulse 1s ease infinite;
}

/* ---------------- LOD (zoomed out) ----------------
   Below the threshold a card stops being "chrome plus content" and becomes a
   single label. Three rules, in the order they matter:

   1. Say each thing once. The title bar used to print the card's title in
      small type with an ellipsis, directly above the LOD title printing the
      same string in full — two renderings of one string, which is the most
      expensive thing a thumbnail can spend its space on. The bar is hidden and
      the label takes the whole card.
   2. One structure for every kind, so the eye lands in the same place on every
      card: an identity row (icon, and the host when there is one) pinned to the
      top-left, then the title as the hero directly under it. Centred titles on
      cards of five different heights put the text at five different places;
      NN/g's card eye-tracking is explicit that irregular placement turns a
      sweep into a semi-random hunt.
   3. The fill still says what the card IS. A pinned page keeps the page plane
      (white, veiled at rest like at any other zoom); a note keeps its colour; a
      frame keeps its wash. Squint at the board and the kinds are still sorted
      without reading a word — which is the same test a favicon has to pass.

   What is dropped is decoration, never identity: the bar, the second copy of
   the favicon, the resize handles, the head actions. */

/* One proportional unit drives the whole label, so a 300x150 note and a
   660x300 page get the same composition rather than the same 34px type dropped
   onto wildly different amounts of room. min() of the two axes means a short
   wide card is governed by its height and cannot grow a title taller than
   itself, and the clamp is doing the real work at both ends: the floor is a
   legibility floor (at the 42% threshold 26 world px is ~11 screen px, and a
   small card must not be the illegible one), the ceiling stops a very large
   card from turning its name into a billboard. The band is narrow on purpose — this
   is a proportional nudge around one size, not a fluid type scale. */
.lod-cover {
  --u: clamp(26px, min(6.2cqw, 17cqh), 46px);
  position: absolute;
  inset: 32px 0 0 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: flex-start;
  gap: calc(var(--u) * 0.42);
  padding: clamp(calc(var(--u) * 0.6), 4.2cqw, calc(var(--u) * 1.05));
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-slow) var(--ease-out);
  background: var(--lod-bg);
  color: var(--lod-fg);
}
/* The anchor is read before the title, so it has to survive the same squint the
   title does — a 16px favicon scaled down is noise, not a mark. */
.lod-id {
  display: flex;
  align-items: center;
  gap: calc(var(--u) * 0.3);
  max-width: 100%;
  min-width: 0;
  color: var(--lod-meta);
}
.lod-cover .fav {
  width: calc(var(--u) * 0.92);
  height: calc(var(--u) * 0.92);
  border-radius: calc(var(--u) * 0.2);
  flex: none;
}
.lod-cover .ti svg {
  width: calc(var(--u) * 0.92);
  height: calc(var(--u) * 0.92);
}
.lod-host {
  font-size: calc(var(--u) * 0.5);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-12);
  line-height: 1;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.lod-title {
  font-size: var(--u);
  font-weight: var(--fw-bold);
  line-height: var(--lh-tight);
  letter-spacing: var(--ls-34);
  max-width: 100%;
  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 4;
  -webkit-box-orient: vertical;
}
.card.web .card-body,
.card.text .text-body {
  transition: opacity var(--dur-slow) var(--ease-out);
}
.cards.lod .lod-cover {
  opacity: 1;
  pointer-events: auto;
  inset: 0;
}
.cards.lod .card.web .card-body,
.cards.lod .card.text .text-body {
  opacity: 0;
  pointer-events: none;
}
/* The cover is opaque, so below the threshold the stand-in page is not merely
   invisible, it is unreachable. Skipping its layout and paint is the difference
   between 60 pages being rendered off-screen and none of them being. */
.cards.lod .card.web .card-body,
.cards.lod .card.text .text-body {
  content-visibility: hidden;
}
/* The LOD switch used to animate height and font-size on every card at once, which
   is a layout pass per frame per card for 280ms. The crossfade on .lod-cover is what
   the eye actually reads as the transition, so the chrome geometry changes on the
   frame the threshold is crossed and the fade carries the rest. */
.card-head {
  transition: background var(--dur-fast);
}
.cards.lod .card-head,
.cards.lod .card-head-actions,
.cards.lod .resize {
  display: none;
}
.card.frame:not(.lod) .lod-cover {
  display: none;
}
/* A pinned page is still a page at any zoom: page white, page ink, page veil.
   That is also what separates it from the canvas — the canvas is deliberately
   the deepest plane in the app precisely so a white page clears it. */
.cards.lod .card.web .lod-cover {
  background: var(--surface-page);
  color: var(--text-page);
}
.cards.lod .card.web .lod-id {
  color: var(--card-chrome-page-ink);
}
/* A note is already wearing its colour; the label only needs the ink. */
.cards.lod .card.text .lod-cover {
  background: transparent;
  color: var(--text-primary);
}
.cards.lod .card.text .lod-id {
  color: var(--note-fg);
}
/* A sub-canvas holds a board, so at a distance it should read as one: the label
   sits on the canvas plane, dot texture and all. Without this it wears
   --surface-raised and is indistinguishable from a pinned page — two kinds that
   look identical is exactly the failure the fill rule exists to prevent. */
.cards.lod .card.subcanvas .lod-cover {
  background: var(--surface-sunken);
  background-image: radial-gradient(var(--grid-dot) 1px, transparent 1.2px);
  background-size: calc(var(--u) * 0.62) calc(var(--u) * 0.62);
  color: var(--text-primary);
}
/* Below the threshold a connector label renders at three or four screen pixels.
   It cannot be read and it sits on top of the titles that can, so it goes —
   the same reason the head, the handles and the page content go. */
.link-labels {
  transition: opacity var(--dur-slow) var(--ease-out);
}
.cards.lod ~ .link-labels {
  opacity: 0;
  pointer-events: none;
}
.cards.lod .frame-label {
  font-size: var(--fs-2xl);
  letter-spacing: var(--ls-20);
  top: -40px;
  padding: 6px var(--sp-3);
}
.cards.lod .frame-label .ti svg {
  width: 22px;
  height: 22px;
}

/* text card */
.card.text .card-head {
  background: transparent;
  color: var(--text-secondary);
  font-weight: var(--fw-semibold);
}
.card.text .text-body {
  padding: var(--sp-1) 14px 14px;
  line-height: var(--lh-base);
  font-size: var(--fs-md);
  letter-spacing: var(--ls-13);
  color: var(--text-primary);
  overflow: auto;
  outline: 0;
  cursor: text;
  white-space: normal;
}
.card.text .text-body:empty::before {
  content: 'Type a thought…';
  color: var(--text-tertiary);
}
.card.text .text-body ul {
  padding-left: 18px;
  margin: var(--sp-1) 0;
}
.card.text.editing {
  outline-color: var(--accent);
  box-shadow: var(--ring-selected), var(--shadow-card);
}
.card.text.editing .card-head {
  cursor: default;
}
.fmt-bar {
  display: none;
  align-items: center;
  gap: 2px;
  padding: var(--sp-1) 6px;
  border-top: 1px solid var(--border-default);
}
.card.text.editing .fmt-bar {
  display: flex;
  animation: fade-in var(--dur-base);
}
.fmt-bar button {
  width: 26px;
  height: 24px;
  border-radius: var(--r-sm);
  display: grid;
  place-items: center;
  color: var(--text-secondary);
}
.fmt-bar button svg {
  width: 15px;
  height: 15px;
}
.fmt-bar button:hover {
  background: var(--state-active);
  color: var(--text-primary);
}
.fmt-bar button:active {
  transform: scale(0.94);
}
.fmt-hint {
  margin-left: auto;
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  color: var(--text-tertiary);
  padding-right: var(--sp-1);
}
/* Content tones carry the fill, a matching border and a label colour that
   is guaranteed readable on that fill in both themes. */
.card.text.color-dark {
  --note-fg: var(--note-dark-fg);
  background: var(--note-dark-bg);
  box-shadow:
    var(--card-shadow),
    inset 0 0 0 calc(1 * var(--px)) var(--note-dark-border),
    var(--card-top);
}
.card.text.color-dark .card-head {
  color: var(--note-fg);
}
.card.text.color-blue {
  --note-fg: var(--note-blue-fg);
  background: var(--note-blue-bg);
  box-shadow:
    var(--card-shadow),
    inset 0 0 0 calc(1 * var(--px)) var(--note-blue-border),
    var(--card-top);
}
.card.text.color-blue .card-head {
  color: var(--note-fg);
}
.card.text.color-teal {
  --note-fg: var(--note-teal-fg);
  background: var(--note-teal-bg);
  box-shadow:
    var(--card-shadow),
    inset 0 0 0 calc(1 * var(--px)) var(--note-teal-border),
    var(--card-top);
}
.card.text.color-teal .card-head {
  color: var(--note-fg);
}
.card.text.color-amber {
  --note-fg: var(--note-amber-fg);
  background: var(--note-amber-bg);
  box-shadow:
    var(--card-shadow),
    inset 0 0 0 calc(1 * var(--px)) var(--note-amber-border),
    var(--card-top);
}
.card.text.color-amber .card-head {
  color: var(--note-fg);
}
.card.text.color-rose {
  --note-fg: var(--note-rose-fg);
  background: var(--note-rose-bg);
  box-shadow:
    var(--card-shadow),
    inset 0 0 0 calc(1 * var(--px)) var(--note-rose-border),
    var(--card-top);
}
.card.text.color-rose .card-head {
  color: var(--note-fg);
}

/* frame */
/* Region = plane, not outline. No border; the fill does the work. */
.card.frame {
  background: var(--frame-fill);
  box-shadow: none;
  border: 0;
  overflow: visible;
  outline-offset: 2px;
  transition:
    background var(--dur-fast),
    outline-color var(--dur-fast);
}
.card.frame:hover {
  background: var(--frame-fill-hover);
}
.card.frame.selected {
  background: var(--frame-fill-selected);
}
.frame-label {
  position: absolute;
  top: -28px;
  left: -1px;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px var(--sp-2) 4px 6px;
  border-radius: var(--r-sm);
  color: var(--text-secondary);
  font-size: var(--fs-sm);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-12);
  cursor: grab;
  white-space: nowrap;
  transition:
    background var(--dur-fast),
    color var(--dur-fast);
}
.frame-label:hover,
.card.frame.selected .frame-label {
  background: var(--state-hover);
  color: var(--text-primary);
}
.frame-label .ti svg {
  width: var(--icon-sm);
  height: var(--icon-sm);
  color: currentColor;
}
.frame-count {
  font-weight: var(--fw-medium);
  color: var(--text-tertiary);
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  padding-left: 2px;
}
.card.frame .resize::after {
  border-color: var(--border-strong);
}

/* card / link toolbars */
.card-toolbar,
.link-toolbar {
  position: absolute;
  z-index: var(--z-toolbar);
  display: flex;
  align-items: center;
  gap: 2px;
  padding: var(--sp-1);
  background: var(--surface-overlay);
  border: 1px solid var(--overlay-border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-md);
}
.card-toolbar.show,
.link-toolbar {
  animation: rise-in var(--dur-base) var(--ease-out);
}
.card-toolbar button,
.link-toolbar button {
  width: 28px;
  height: 28px;
  border-radius: var(--r-sm);
  display: grid;
  place-items: center;
  color: var(--text-secondary);
  transition:
    background var(--dur-fast),
    color var(--dur-fast);
}
.card-toolbar button svg,
.link-toolbar button svg {
  width: var(--icon-md);
  height: var(--icon-md);
}
.card-toolbar button:hover,
.link-toolbar button:hover {
  background: var(--state-hover);
  color: var(--text-primary);
}
.card-toolbar button:active,
.link-toolbar button:active {
  background: var(--state-active);
  transform: scale(0.94);
}
.card-toolbar button.danger:hover,
.link-toolbar button.danger:hover {
  color: var(--danger);
  background: var(--danger-soft);
}
.tb-sep {
  width: 1px;
  height: 18px;
  background: var(--border-default);
  margin: 0 3px;
}
.seg {
  display: flex;
  gap: 1px;
  padding: 2px;
  background: var(--fill-subtle);
  border-radius: var(--r-sm);
}
.seg button {
  width: 30px;
  height: 24px;
  border-radius: var(--r-xs);
}
.seg-text {
  flex: none;
}
.seg-text button {
  width: auto;
  padding: 0 10px;
  height: 24px;
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  font-weight: var(--fw-medium);
  color: var(--text-secondary);
  transition:
    background var(--dur-fast),
    color var(--dur-fast);
}
.seg-text button:hover {
  color: var(--text-primary);
}
.seg-text button[aria-pressed='true'] {
  background: var(--surface-overlay);
  color: var(--text-primary);
  box-shadow: var(--shadow-sm);
}
.pop-field {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  padding: 6px 10px 6px var(--sp-3);
}
.pop-field-label {
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
  color: var(--text-secondary);
}
.seg button.on,
.link-toolbar button.on {
  background: var(--state-selected);
  color: var(--text-primary);
}
.link-toolbar button.flip .mini {
  transform: scaleX(-1);
}
.mini {
  width: 22px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.mini .f {
  fill: currentColor;
  stroke: none;
}
.lsw-default {
  background: var(--link-stroke);
}
.lsw-blue {
  background: var(--note-blue-solid);
}
.lsw-teal {
  background: var(--note-teal-solid);
}
.lsw-amber {
  background: var(--note-amber-solid);
}
.lsw-rose {
  background: var(--note-rose-solid);
}
.tb-count {
  padding: 0 var(--sp-2) 0 6px;
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
  color: var(--text-secondary);
  font-weight: var(--fw-medium);
  white-space: nowrap;
}
.swatches {
  display: flex;
  gap: var(--sp-1);
  padding: 0 var(--sp-1);
}
.card-toolbar .sw {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 2px solid transparent;
  transition: transform var(--dur-fast) var(--ease-spring);
}
.card-toolbar .sw:hover {
  transform: scale(1.18);
}
.card-toolbar .sw.on {
  border-color: var(--text-primary);
}
.sw-dark {
  background: var(--note-dark-solid);
}
.sw-blue {
  background: var(--note-blue-solid);
}
.sw-teal {
  background: var(--note-teal-solid);
}
.sw-amber {
  background: var(--note-amber-solid);
}
.sw-rose {
  background: var(--note-rose-solid);
}

/* empty state */
.empty {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: var(--sp-10);
  z-index: 4;
  pointer-events: none;
  animation: fade-in var(--dur-slow);
}
.empty > * {
  pointer-events: auto;
  animation: rise-in var(--dur-slow) var(--ease-out) both;
}
.empty > *:nth-child(2) {
  animation-delay: 60ms;
}
.empty > *:nth-child(3) {
  animation-delay: 120ms;
}
.empty > *:nth-child(4) {
  animation-delay: 180ms;
}
.empty > *:nth-child(5) {
  animation-delay: 260ms;
}
.empty h2 {
  margin: 0 0 var(--sp-2);
  font-size: var(--fs-2xl);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-20);
  line-height: var(--lh-tight);
}
.empty p {
  margin: 0 0 22px;
  color: var(--text-secondary);
  max-width: 420px;
  line-height: var(--lh-relaxed);
  font-size: var(--fs-md);
  letter-spacing: var(--ls-13);
}
.empty-actions {
  display: flex;
  gap: var(--sp-2);
}
.empty-actions .text-btn {
  height: var(--ctl-lg);
  padding: 0 var(--sp-4);
}
.empty-hint {
  margin-top: 26px;
  color: var(--text-tertiary);
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
}
.empty-art {
  position: relative;
  width: 180px;
  height: 110px;
  margin-bottom: 22px;
}
.empty-art span {
  position: absolute;
  border-radius: var(--r-md);
  background: var(--surface-raised);
  border: 1px solid var(--border-default);
  box-shadow: var(--shadow-card);
}
.empty-art span:nth-child(1) {
  left: 0;
  top: 10px;
  width: 90px;
  height: 70px;
  background: var(--lod-bg);
}
.empty-art span:nth-child(2) {
  left: 70px;
  top: 30px;
  width: 100px;
  height: 76px;
  background: var(--fill-subtle);
}
.empty-art span:nth-child(3) {
  left: 118px;
  top: 0;
  width: 62px;
  height: 46px;
  background: var(--surface-overlay);
}
.empty-art span::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 12px;
  border-radius: var(--r-md) var(--r-md) 0 0;
  background: var(--card-chrome);
  border-bottom: 1px solid var(--card-chrome-divider);
}
.empty-art span:nth-child(2)::before {
  background: transparent;
}

/* expanded card */
.expand {
  position: absolute;
  inset: 0;
  z-index: var(--z-overlay);
  background: var(--surface-raised);
  display: flex;
  flex-direction: column;
  border-radius: var(--r-lg);
  overflow: hidden;
  transition:
    transform 300ms var(--ease-in-out),
    opacity 300ms var(--ease-in-out),
    border-radius 300ms;
  will-change: transform;
}
.expand.in {
  border-radius: 0;
}
.expand-head,
.expand-body {
  animation: fade-in 320ms var(--ease-out) both;
  animation-delay: 120ms;
}
.expand-head {
  height: 46px;
  flex: none;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 0 10px;
  border-bottom: 1px solid var(--border-subtle);
  background: var(--surface-raised);
}
.expand-head .peek-url {
  max-width: 720px;
}
.expand-body {
  flex: 1;
  position: relative;
  background: var(--surface-page);
  color: var(--text-page);
}
.expand-body .pg {
  max-width: 1100px;
  margin: 0 auto;
}
.expand-collapse {
  position: absolute;
  right: var(--sp-4);
  bottom: var(--sp-4);
  width: 34px;
  height: 34px;
  border-radius: var(--r-md);
  background: var(--on-page-scrim);
  color: var(--on-page-fg);
  display: grid;
  place-items: center;
  box-shadow: var(--shadow-md);
  transition: background var(--dur-fast);
}
.expand-collapse:hover {
  background: var(--on-page-scrim-hover);
}
.expand-collapse svg {
  width: var(--icon-lg);
  height: var(--icon-lg);
}
.app.expanded .card-toolbar,
.app.expanded .link-toolbar {
  display: none !important;
}
.app.expanded .peek {
  display: none;
}

/* present */
.present-exit {
  position: absolute;
  top: var(--sp-4);
  right: var(--sp-4);
  z-index: 12;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 6px 6px var(--sp-3);
  background: var(--surface-overlay);
  border: 1px solid var(--overlay-border);
  border-radius: var(--r-md);
  color: var(--text-secondary);
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
  box-shadow: var(--shadow-md);
  animation: drop-in var(--dur-base) var(--ease-out);
}
.present-exit button {
  display: flex;
  align-items: center;
  gap: 6px;
  height: 26px;
  padding: 0 var(--sp-2) 0 10px;
  border-radius: var(--r-sm);
  background: var(--fill-subtle);
  color: var(--text-primary);
  font-weight: var(--fw-medium);
}
.present-exit button:hover {
  background: var(--fill-strong);
}
.app.present .topbar,
.app.present .outline,
.app.present .bottombar,
.app.present .card-toolbar,
.app.present .link-toolbar {
  display: none !important;
}
.app.present .card-head-actions,
.app.present .resize {
  display: none;
}

/* bottom bar */
.bottombar {
  height: 44px;
  flex: none;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  padding: 0 var(--sp-3);
  background: var(--surface-chrome);
  border-top: 1px solid var(--border-default);
  position: relative;
  z-index: var(--z-chrome);
}
.bl {
  display: flex;
  align-items: center;
}
.br {
  display: flex;
  align-items: center;
  justify-content: flex-end;
}
.add-btn {
  display: flex;
  align-items: center;
  gap: 2px;
  height: var(--ctl-md);
  padding: 0 6px 0 var(--sp-2);
  border-radius: var(--r-sm);
  color: var(--text-primary);
  transition:
    background var(--dur-fast),
    transform var(--dur-fast) var(--ease-out);
}
.add-btn:hover,
.add-btn.open {
  background: var(--state-hover);
}
.add-btn:active {
  transform: scale(0.96);
}
.add-btn .caret svg {
  width: 12px;
  height: 12px;
  color: var(--text-tertiary);
  transition: transform var(--dur-base) var(--ease-out);
}
.add-btn.open .caret svg {
  transform: rotate(180deg);
}
.add-btn .plus svg {
  transition: transform var(--dur-base) var(--ease-spring);
}
.add-btn.open .plus svg {
  transform: rotate(45deg);
}
.zoom-ctl {
  display: flex;
  align-items: center;
  gap: 2px;
}
.zoom-ctl .icon-btn.on {
  background: var(--state-selected);
  box-shadow: inset 0 0 0 1px var(--border-default);
}
.zoom-pct {
  height: 26px;
  padding: 0 6px;
  border-radius: var(--r-sm);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
  min-width: 48px;
  text-align: center;
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
  font-weight: var(--fw-medium);
  transition:
    background var(--dur-fast),
    color var(--dur-fast);
}
.zoom-pct:hover {
  background: var(--state-hover);
  color: var(--text-primary);
}
.vsep {
  width: 1px;
  height: 16px;
  background: var(--border-default);
  margin: 0 var(--sp-1);
}
/* ---------- canvas scrollbars ----------
   An infinite canvas has no document to measure, so the track stands for the
   union of everything on the board and wherever the camera currently is — the
   same extent the minimap draws, so the two can never disagree. Pan past the
   last card and the extent grows to include the empty space you moved into,
   which is what makes the thumb shrink and slide to the end instead of pinning.

   Figma's canvas scrollbars are the model, with one deliberate departure: the
   most common complaint about them is that you have to aim precisely to grab
   one. The painted bar here is the same 8px hairline, but the hit target is
   22px and reaches the very edge of the viewport, so throwing the pointer at
   the edge catches it. Visible weight and clickable weight are separate
   problems and only the first one wants to be small. */
.scrollbar {
  position: absolute;
  z-index: var(--z-canvas-ui);
  opacity: 0;
  transition: opacity var(--dur-slow) var(--ease-out);
  /* The TRACK never takes the pointer. A full-length strip along two edges of
     the canvas would be 22px of board you can no longer click a card in, and —
     worse — it would swallow the start of a marquee dragged out of the
     bottom-right corner, which is an ordinary gesture. The thumb takes the
     pointer instead, and carries the fat hit area on itself. Clicking the empty
     track to jump is the one thing given up; on a canvas with no pages to step
     through it was the weakest of the three behaviours anyway. */
  pointer-events: none;
  --sb-bar: 8px;
  --sb-lip: 3px;
  --sb-end: 14px;
}
/* Shown while the camera is moving, while the pointer is on the thumb, and for
   as long as one is being dragged. Idle, this is not information the user
   asked for. */
.scrollbar.on,
.scrollbar.dragging,
.scrollbar:has(.sb-thumb:hover) {
  opacity: 1;
}
.app.present .scrollbar {
  opacity: 0 !important;
}
.sb-x {
  left: 0;
  right: var(--sb-end);
  bottom: 0;
  height: calc(var(--sb-bar) + var(--sb-lip));
}
.sb-y {
  top: 0;
  bottom: var(--sb-end);
  right: 0;
  width: calc(var(--sb-bar) + var(--sb-lip));
}
/* The minimap owns the bottom-right corner; the tracks stop short of it rather
   than sliding a thumb underneath a panel. */
.viewport.has-minimap .sb-x {
  right: calc(228px + var(--sp-3) * 2);
}
.viewport.has-minimap .sb-y {
  bottom: calc(148px + var(--sp-3) * 2);
}
.sb-thumb {
  position: absolute;
  border-radius: var(--r-full);
  pointer-events: auto;
  background: var(--sb-thumb);
  transition: background var(--dur-fast) var(--ease-out);
}
/* Visible weight and grabbable weight are separate problems and only the first
   one wants to be small. The painted bar is Figma's 8px hairline; the target is
   ~20px and runs to the very edge of the viewport, so throwing the pointer at
   the edge catches the thumb instead of sliding past it — which is the most
   common complaint about Figma's own canvas scrollbars. */
.sb-thumb::before {
  content: '';
  position: absolute;
}
.sb-x .sb-thumb {
  left: 0;
  bottom: var(--sb-lip);
  height: var(--sb-bar);
  will-change: transform;
}
.sb-x .sb-thumb::before {
  inset: -9px -2px calc(var(--sb-lip) * -1) -2px;
}
.sb-y .sb-thumb {
  top: 0;
  right: var(--sb-lip);
  width: var(--sb-bar);
  will-change: transform;
}
.sb-y .sb-thumb::before {
  inset: -2px calc(var(--sb-lip) * -1) -2px -9px;
}
.sb-thumb:hover {
  background: var(--sb-thumb-hover);
}
.scrollbar.dragging .sb-thumb {
  background: var(--sb-thumb-active);
}
/* Nothing on the edge competes with a card drag or a marquee in progress. */
.viewport.dragging .sb-thumb,
.viewport.panning .sb-thumb {
  pointer-events: none;
}
@media (pointer: coarse) {
  .scrollbar {
    --sb-bar: 10px;
  }
  .sb-x .sb-thumb::before {
    inset: -13px -4px calc(var(--sb-lip) * -1) -4px;
  }
  .sb-y .sb-thumb::before {
    inset: -4px calc(var(--sb-lip) * -1) -4px -13px;
  }
}

.minimap {
  position: absolute;
  right: var(--sp-3);
  bottom: var(--sp-3);
  z-index: var(--z-canvas-ui);
  background: var(--surface-overlay);
  border: 1px solid var(--overlay-border);
  border-radius: var(--r-md);
  padding: var(--sp-1);
  cursor: crosshair;
  box-shadow: var(--shadow-sm);
  animation: rise-in var(--dur-base) var(--ease-out);
}
.minimap canvas {
  display: block;
  border-radius: var(--r-xs); /* 同心：--r-md 7 − sp-1 4 */
}

/* ---------- side peek ---------- */
/* The drawer is anchored to the right edge and floats clear of the chrome above and
   below it. Bottom used to be 0, so the footnote row butted straight into the bottom
   bar: three hairlines stacked with nothing between them, and the panel read as a
   piece of the window frame instead of a sheet slid in over the board. */
.peek {
  position: absolute;
  top: var(--sp-3);
  right: 0;
  bottom: var(--sp-3);
  width: 560px;
  max-width: calc(100% - 24px);
  z-index: var(--z-toolbar);
  background: var(--surface-raised);
  border: 1px solid var(--overlay-border);
  border-right: 0;
  border-radius: var(--r-lg) 0 0 var(--r-lg);
  box-shadow: var(--shadow-lg);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  transform: translateX(calc(100% + 24px));
  opacity: 0;
  transition:
    transform var(--dur-slow) var(--ease-in-out),
    opacity var(--dur-slow) var(--ease-out),
    width var(--dur-slow) var(--ease-in-out);
}
.app.peek-open .peek {
  transform: none;
  opacity: 1;
}
.peek.wide {
  width: 880px;
}
.peek > * {
  opacity: 0;
  transition: opacity var(--dur-slow) var(--ease-out);
}
.app.peek-open .peek > * {
  opacity: 1;
  transition-delay: 80ms;
}
.peek-head {
  height: 52px;
  flex: none;
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 0 var(--sp-2) 0 var(--sp-3);
  border-bottom: 1px solid var(--border-subtle);
}
.peek-url {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  height: 34px;
  padding: 0 4px 0 10px;
  background: var(--fill-subtle);
  border: 1px solid transparent;
  border-radius: var(--r-md);
  transition: border-color var(--dur-fast);
}
.peek-url:hover {
  border-color: var(--border-default);
}
.peek-url .peek-nav {
  margin-left: auto;
  flex: none;
}
.peek-url-text .scheme {
  color: var(--text-tertiary);
}
.peek-url .fav {
  width: var(--icon-md);
  height: var(--icon-md);
}
.peek-url-text {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
}
.peek-url-text .host {
  color: var(--text-primary);
}
.peek-url-text .path {
  color: var(--text-tertiary);
}
.peek-nav,
.peek-actions {
  display: flex;
  gap: 2px;
  align-items: center;
}
.peek-actions {
  flex: none;
}
#peek-pin.on-canvas,
#peek-pin.on-canvas:hover {
  color: var(--text-primary);
}
#peek-reader.on {
  color: var(--text-primary);
  background: var(--state-selected);
}
.peek-body.reader .page-scroll {
  background: var(--reader-bg);
}
.peek-body.reader .pg {
  max-width: 620px;
  margin: 0 auto;
  font-size: var(--fs-xl);
  letter-spacing: var(--ls-16);
  line-height: var(--lh-relaxed);
  padding: var(--sp-4) 0;
}
.peek-body.reader .wiki-top,
.peek-body.reader .wiki-tabs,
.peek-body.reader .wiki-lang,
.peek-body.reader .wiki-toc,
.peek-body.reader .g-top,
.peek-body.reader .ca-lang,
.peek-body.reader .ca-sig,
.peek-body.reader .ca-menubar,
.peek-body.reader .ca-crumbs,
.peek-body.reader .ca-gcfoot,
.peek-body.reader .ca-details,
.peek-body.reader .li-nav,
.peek-body.reader .ge-nav,
.peek-body.reader .mi-side {
  display: none;
}
.peek-body.reader .wiki-cols {
  flex-direction: column;
}
.peek-body.reader .wiki-box {
  width: 100%;
}
.peek-body {
  flex: 1;
  background: var(--surface-page);
  color: var(--text-page);
  position: relative;
}
.peek-foot {
  flex: none;
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 7px var(--sp-3);
  border-top: 1px solid var(--border-subtle);
  color: var(--text-tertiary);
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  white-space: nowrap;
}
/* The state used to be an amber chip that never changed — warning colour spent on a
   neutral fact, and a lie once the page was pinned. It is a word now, and it flips. */
.peek-state {
  flex: none;
  color: var(--text-secondary);
  font-weight: var(--fw-medium);
}
.peek-state.on-canvas {
  color: var(--success-text);
}
.peek-from {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}
.peek-from::before {
  content: '·';
  color: var(--text-tertiary);
}
/* Tonal, not solid. Side Peek opens on every link click, so a filled accent button
   would put the app's one solid on screen almost permanently — spent on a standing
   suggestion, while the accent the user actually caused (the selected card's ring,
   an anchor being dragged) is out on the board competing with it. Reading is what
   this panel is for; pinning is an offer. Tonal keeps the label findable without
   claiming the view's primary slot. */
.peek-pin-btn {
  display: flex;
  align-items: center;
  gap: 6px;
  height: 26px;
  padding: 0 10px 0 var(--sp-2);
  border-radius: var(--r-sm);
  background: var(--action-tonal-bg);
  color: var(--action-tonal-fg);
  font-weight: var(--fw-semibold);
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
  white-space: nowrap;
  transition:
    background var(--dur-fast),
    transform var(--dur-fast);
}
.peek-pin-btn:hover {
  background: var(--action-tonal-bg-hover);
}
.peek-pin-btn:active {
  transform: scale(0.97);
}
.peek-pin-btn .pi svg {
  width: 15px;
  height: 15px;
}
/* Already on the board: this is navigation, not a create action — one rung quieter. */
.peek-pin-btn.on-canvas {
  background: none;
  color: var(--text-secondary);
}
.peek-pin-btn.on-canvas:hover {
  background: var(--state-hover);
  color: var(--text-primary);
}
.peek-pin-btn.on-canvas .pi svg {
  color: currentColor;
}

/* ---------- popovers ---------- */
.popover {
  position: fixed;
  z-index: var(--z-popover);
  min-width: 220px;
  padding: var(--sp-1);
  background: var(--surface-overlay);
  border: 1px solid var(--overlay-border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-lg);
  animation: rise-in var(--dur-base) var(--ease-out);
}
.popover.closing {
  animation: sink-out var(--dur-fast) var(--ease-in) forwards;
  pointer-events: none;
}
.popover button,
.pop-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  height: 32px;
  padding: 0 10px;
  border-radius: var(--r-sm);
  color: var(--text-primary);
  text-align: left;
  white-space: nowrap;
  transition: background var(--dur-fast);
}
.popover button:hover,
.pop-item:hover {
  background: var(--state-hover);
}
/* A menu item that cannot run says so and stops responding to the pointer —
   it is still listed so the menu does not reshuffle between openings. */
.popover button:disabled,
.ctx-menu button:disabled {
  opacity: 0.38;
}
.popover button:disabled:hover,
.ctx-menu button:disabled:hover {
  background: none;
}
.popover button:active,
.pop-item:active {
  background: var(--state-active);
}
.popover button.danger:hover {
  background: var(--danger-soft);
  color: var(--danger);
}
.popover .pi {
  width: var(--icon-md);
  display: grid;
  place-items: center;
}
.popover .pi svg {
  width: var(--icon-md);
  height: var(--icon-md);
  color: var(--text-secondary);
}
.popover button.danger:hover .pi svg {
  color: var(--danger);
}
.popover kbd {
  margin-left: auto;
}
.menu-right {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}
.menu-right .chev-r svg {
  width: 12px;
  height: 12px;
  color: var(--text-tertiary);
}
.popover .menu-right kbd {
  margin-left: 0;
}
.add-menu {
  min-width: 236px;
}
.add-menu button.soon {
  color: var(--text-secondary);
}
.add-menu button.soon .pi svg {
  color: var(--text-tertiary);
}
.beta {
  font-size: var(--fs-2xs);
  font-weight: var(--fw-semibold);
  color: var(--text-on-accent);
  background: var(--accent);
  padding: 2px 5px;
  border-radius: var(--r-xs);
  letter-spacing: 0.02em;
}
.pop-sep {
  height: 1px;
  background: var(--border-subtle);
  margin: 6px var(--sp-1);
}
.pop-title {
  font-weight: var(--fw-semibold);
  padding: 6px 10px var(--sp-2);
  letter-spacing: var(--ls-13);
}
.pop-note {
  padding: var(--sp-1) 10px 6px;
  color: var(--text-tertiary);
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  white-space: normal;
  line-height: var(--lh-base);
}
.ctx-menu {
  min-width: 210px;
}
.connect-pop {
  min-width: 200px;
}
.share-pop,
.board-pop {
  width: 340px;
}
.share-row {
  display: flex;
  gap: 6px;
  padding: 0 var(--sp-1) 6px;
}
.share-row input {
  flex: 1;
  min-width: 0;
  height: var(--ctl-md);
  padding: 0 10px;
  background: var(--fill-subtle);
  border: 1px solid var(--border-default);
  border-radius: var(--r-sm);
  color: var(--text-primary);
  outline: 0;
  transition:
    border-color var(--dur-fast),
    box-shadow var(--dur-fast);
}
.share-row input:focus {
  border-color: var(--accent);
  box-shadow: var(--ring-focus);
}
.share-row .text-btn {
  flex: none;
  width: auto;
  height: var(--ctl-md);
  padding: 0 var(--sp-3);
}
.share-access {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-1) var(--sp-2);
  color: var(--text-secondary);
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
}
.share-access .pi svg {
  color: var(--text-tertiary);
}
.share-access b {
  color: var(--text-primary);
  font-weight: var(--fw-semibold);
}
.share-access .link-btn {
  margin-left: auto;
  width: auto;
  height: auto;
  padding: 0;
}
.share-access .link-btn:hover {
  background: none;
}

/* ---------- modals ---------- */
.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  background: var(--scrim);
  display: grid;
  place-items: center;
  backdrop-filter: blur(3px);
  animation: fade-in var(--dur-base);
}
.modal-backdrop.top {
  place-items: start center;
  padding-top: 14vh;
}
.modal-backdrop.closing {
  animation: fade-out var(--dur-base) var(--ease-in) forwards;
  pointer-events: none;
}
.modal-backdrop.closing .modal,
.modal-backdrop.closing .palette {
  animation: scale-out var(--dur-base) var(--ease-in) forwards;
}
.modal {
  width: 560px;
  max-width: calc(100vw - 32px);
  background: var(--surface-top);
  border: 1px solid var(--overlay-border);
  border-radius: var(--r-xl);
  box-shadow: var(--shadow-modal);
  overflow: hidden;
  animation: scale-in var(--dur-base) var(--ease-out);
}
.modal.narrow {
  width: 440px;
}
.modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px var(--sp-3) var(--sp-3) 18px;
  font-weight: var(--fw-semibold);
  font-size: var(--fs-xl);
  letter-spacing: var(--ls-16);
  border-bottom: 1px solid var(--border-subtle);
}
.modal-body {
  padding: var(--sp-4) 18px;
}
.modal-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  padding: var(--sp-3) 18px;
  border-top: 1px solid var(--border-subtle);
}
.modal-foot.right {
  justify-content: flex-end;
}
.modal.confirm h3 {
  margin: var(--sp-1) 0 var(--sp-2);
  font-size: var(--fs-xl);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-16);
}
.modal.confirm p {
  margin: 0;
  color: var(--text-secondary);
  line-height: var(--lh-relaxed);
}
.hint {
  color: var(--text-tertiary);
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
}
.field {
  display: block;
  margin-bottom: var(--sp-3);
}
.field-label {
  display: block;
  font-size: var(--fs-xs);
  font-weight: var(--fw-semibold);
  color: var(--text-secondary);
  text-transform: uppercase;
  letter-spacing: var(--ls-caps);
  margin-bottom: var(--sp-2);
}
.field input {
  width: 100%;
  height: 40px;
  padding: 0 var(--sp-3);
  background: var(--fill-subtle);
  border: 1px solid var(--border-default);
  border-radius: var(--r-md);
  color: var(--text-primary);
  outline: 0;
  font-size: var(--fs-md);
  letter-spacing: var(--ls-13);
  transition:
    border-color var(--dur-fast),
    box-shadow var(--dur-fast);
}
.field input:focus {
  border-color: var(--accent);
  box-shadow: var(--ring-focus);
}
.field input.err {
  border-color: var(--danger);
  animation: shake 0.3s;
}
.url-preview {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: var(--sp-2) 10px;
  margin: -4px 0 var(--sp-4);
  background: var(--fill-subtle);
  border: 1px solid var(--border-default);
  border-radius: var(--r-md);
  cursor: pointer;
  transition: border-color var(--dur-fast);
  animation: rise-in var(--dur-base) var(--ease-out);
}
.url-preview:hover {
  border-color: var(--border-strong);
}
.url-preview .fav {
  width: 24px;
  height: 24px;
  border-radius: var(--r-sm);
}
.url-preview > div {
  flex: 1;
  min-width: 0;
}
.url-preview b {
  display: block;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  font-weight: var(--fw-semibold);
}
.url-preview small {
  display: block;
  color: var(--text-tertiary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
}
.url-preview-cta {
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  color: var(--text-tertiary);
  font-weight: var(--fw-semibold);
}
.site-grid {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: var(--sp-2);
  margin-bottom: 14px;
}
.site {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-3) 6px 10px;
  border-radius: var(--r-lg);
  background: var(--fill-subtle);
  border: 1px solid var(--border-subtle);
  color: var(--text-secondary);
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
  transition:
    background var(--dur-fast),
    transform var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast);
}
.site:hover {
  background: var(--state-hover);
  color: var(--text-primary);
  border-color: var(--border-default);
  transform: translateY(-1px);
}
.site img {
  width: 32px;
  height: 32px;
  border-radius: var(--r-md);
}
.recent-list {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
}
.recent {
  display: grid;
  grid-template-columns: 18px 1fr auto;
  align-items: center;
  gap: 10px;
  padding: 7px 10px;
  border-radius: var(--r-md);
  color: var(--text-primary);
  text-align: left;
  transition: background var(--dur-fast);
}
.recent:hover {
  background: var(--state-hover);
}
.recent img {
  width: var(--icon-md);
  height: var(--icon-md);
  border-radius: var(--r-xs);
}
.recent-title {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.recent-host {
  color: var(--text-tertiary);
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
}
.shortcuts {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 9px var(--sp-5);
  color: var(--text-secondary);
}
.shortcuts kbd {
  margin-right: 6px;
  color: var(--text-primary);
}
.sc-group {
  grid-column: 1 / -1;
  font-size: var(--fs-xs);
  font-weight: var(--fw-semibold);
  text-transform: uppercase;
  letter-spacing: var(--ls-caps);
  color: var(--text-tertiary);
  margin-top: 6px;
}
.sc-group:first-child {
  margin-top: 0;
}

/* ---------- search palette ---------- */
.palette {
  width: 620px;
  max-width: calc(100vw - 32px);
  background: var(--surface-top);
  border: 1px solid var(--overlay-border);
  border-radius: var(--r-xl);
  box-shadow: var(--shadow-modal);
  overflow: hidden;
  animation: scale-in var(--dur-base) var(--ease-out);
}
.palette-input {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: var(--sp-3) 14px;
  border-bottom: 1px solid var(--border-subtle);
}
.palette-input .pi svg {
  color: var(--text-tertiary);
}
.palette-input input {
  flex: 1;
  background: none;
  border: 0;
  outline: 0;
  color: var(--text-primary);
  font-size: var(--fs-xl);
  letter-spacing: var(--ls-16);
}
.palette-list {
  max-height: 360px;
  overflow: auto;
  padding: 5px;
}
.pl-item {
  display: grid;
  grid-template-columns: 18px 1fr auto;
  align-items: center;
  gap: 10px;
  padding: 9px 10px;
  border-radius: var(--r-md);
  cursor: default;
  transition: background var(--dur-instant);
}
.pl-item.on {
  background: var(--state-selected);
}
.pl-item .fav {
  width: var(--icon-md);
  height: var(--icon-md);
}
.pl-item .ti svg {
  width: var(--icon-md);
  height: var(--icon-md);
  color: var(--text-secondary);
}
.pl-label {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: var(--fs-md);
  letter-spacing: var(--ls-13);
}
.pl-sub {
  color: var(--text-tertiary);
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
}
.pl-empty {
  padding: var(--sp-6);
  text-align: center;
  color: var(--text-tertiary);
}
.palette-foot {
  display: flex;
  gap: var(--sp-4);
  padding: var(--sp-2) 14px;
  border-top: 1px solid var(--border-subtle);
  color: var(--text-tertiary);
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
}
.palette-foot kbd {
  margin-right: var(--sp-1);
}

/* ---------- feedback ---------- */
.toast {
  position: fixed;
  left: 50%;
  bottom: 64px;
  transform: translateX(-50%);
  z-index: var(--z-toast);
  background: var(--surface-inverted);
  color: var(--text-on-inverted);
  padding: 9px 14px;
  border-radius: var(--r-md);
  font-weight: var(--fw-medium);
  box-shadow: var(--shadow-lg);
}
@keyframes toast-in {
  from {
    opacity: 0;
    transform: translate(-50%, 8px);
  }
}
.toast.pop {
  animation: toast-in var(--dur-base) var(--ease-out);
}
.toast.hide {
  animation: toast-out var(--dur-base) var(--ease-in) forwards;
}
/* A source row's tip is its URL, which can be long; the tip is a label, not a
   paragraph, so it caps and ellipsises rather than stretching across the window. */
.tip {
  max-width: min(460px, 88vw);
}
.tip-text {
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}
.tip {
  position: fixed;
  z-index: var(--z-tip);
  pointer-events: none;
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  background: var(--surface-inverted);
  color: var(--text-on-inverted);
  padding: 5px var(--sp-2);
  border-radius: var(--r-sm);
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  box-shadow: var(--shadow-md);
  white-space: nowrap;
  animation: tip-in var(--dur-fast) var(--ease-out);
}
.tip kbd {
  background: color-mix(in oklch, var(--text-on-inverted) 13%, transparent);
  border-color: color-mix(in oklch, var(--text-on-inverted) 24%, transparent);
  color: var(--text-on-inverted);
  padding: 1px 5px;
}

/* ---------- notifications ---------- */
.has-badge {
  position: relative;
}
.badge-dot {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--danger);
  box-shadow: 0 0 0 2px var(--surface-chrome);
}
.notify-pop {
  width: 360px;
  padding: 6px;
}
.pop-title-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-right: 10px;
}
.pop-title-row .link-btn {
  width: auto;
  height: auto;
  padding: 0;
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
}
.pop-title-row .link-btn:hover {
  background: none;
}
.notify-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 320px;
  overflow: auto;
}
.note-item {
  display: grid;
  grid-template-columns: 28px 1fr auto;
  gap: 10px;
  align-items: start;
  padding: 8px 10px;
  border-radius: var(--r-md);
  cursor: default;
  transition: background var(--dur-fast);
}
.note-item:hover {
  background: var(--state-hover);
}
.note-icon {
  width: 28px;
  height: 28px;
  border-radius: var(--r-sm);
  display: grid;
  place-items: center;
  background: var(--fill-subtle);
  color: var(--text-secondary);
}
.note-icon svg {
  width: var(--icon-md);
  height: var(--icon-md);
}
.note-icon.board {
  background: var(--fill-subtle);
  color: var(--text-secondary);
}
.note-body {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  white-space: normal;
}
.note-body b {
  font-weight: var(--fw-semibold);
  font-size: var(--fs-md);
  letter-spacing: var(--ls-13);
  line-height: var(--lh-snug);
}
.note-body span {
  color: var(--text-secondary);
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
  line-height: var(--lh-snug);
}
.note-time {
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  color: var(--text-tertiary);
  padding-top: 2px;
  white-space: nowrap;
}
.note-item.unread b {
  color: var(--text-primary);
}
.note-item:not(.unread) b,
.note-item:not(.unread) .note-body span {
  color: var(--text-tertiary);
}
.note-item.unread .note-time::before {
  content: '';
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
  margin-right: 6px;
  vertical-align: middle;
}

/* ============================================================
   Card interaction spec — §4.4 sizing, §4.1 live-page mode,
   §4.3 scroll boundary, §4.7 chrome, §4.8 access & touch.

   The rule underneath all of this: content lives in the world and
   scales with it; the chrome you use to *operate* a card lives on
   the screen and does not. `--px` is one screen pixel expressed in
   world units, capped so the compensation stops at the LOD threshold
   in one direction and at 400% in the other.

   applyTransform() writes the capped reciprocal `--ui` directly rather
   than the raw zoom. --ui is inherited by everything in the world, so
   every write re-resolves the computed style of the whole board; writing
   the *capped* value means that outside 42%-400% — which is exactly
   where the most cards are on screen at once — a zoom changes nothing
   and costs nothing.
   ============================================================ */
.world {
  --ui: 1;
  --px: calc(1px * var(--ui));
  /* --px deliberately stops compensating below ~42% zoom so card furniture
     cannot outgrow its card. A frame's edge has no such problem and must not
     disappear exactly when you zoom out to see regions, so it gets the
     uncapped reciprocal and stays 1.5 screen px at every zoom. */
  /* A region wants less corner than a card, not more. */
  --frame-r: calc(3 * var(--px));
  /* A card's corner and its depth are chrome, not geometry the user set,
     so both are measured in screen pixels like the rest of the card's
     furniture. Left in world units a 10px corner renders 0.8px at 8%
     zoom and 40px at 400%, around a title bar that stayed 32px. */
  --card-r: calc(7 * var(--px));
  --card-shadow:
    0 calc(1 * var(--px)) calc(2 * var(--px)) var(--shadow-contact),
    0 calc(4 * var(--px)) calc(8 * var(--px)) var(--shadow-direct),
    0 calc(10 * var(--px)) calc(20 * var(--px)) var(--shadow-ambient);
  --card-shadow-hover:
    0 calc(2 * var(--px)) calc(4 * var(--px)) var(--shadow-contact),
    0 calc(8 * var(--px)) calc(16 * var(--px)) var(--shadow-direct),
    0 calc(18 * var(--px)) calc(36 * var(--px)) var(--shadow-ambient);
  --card-shadow-drag:
    0 calc(6 * var(--px)) calc(12 * var(--px)) var(--shadow-contact),
    0 calc(20 * var(--px)) calc(40 * var(--px)) var(--shadow-direct),
    0 calc(44 * var(--px)) calc(88 * var(--px)) var(--shadow-ambient);
  --card-ring: inset 0 0 0 calc(1 * var(--px)) var(--border-default);
  --card-ring-hover: inset 0 0 0 calc(1 * var(--px)) var(--border-strong);
  --card-top: inset 0 calc(1 * var(--px)) 0 var(--shadow-edge);
  --card-elev: var(--card-shadow), var(--card-ring), var(--card-top);
  --card-elev-hover:
    var(--card-shadow-hover), var(--card-ring-hover), var(--card-top);
  --card-elev-drag:
    var(--card-shadow-drag), var(--card-ring-hover), var(--card-top);
}

/* ---------- card chrome, screen-constant ---------- */
.card {
  outline-width: calc(1.5 * var(--px));
  border-radius: var(--card-r);
  box-shadow: var(--card-elev);
}
.card:not(.frame):hover {
  box-shadow: var(--card-elev-hover);
}
/* The halo has to be translucent: it sits on the canvas, not on the card.
   (--accent-soft is an opaque ramp step and painted a solid indigo slab.) */
/* A thin frame set one pixel off the card reads as precision; a thick one reads as
   loud. That 1px of ground between card and frame is what makes it look measured —
   and it stops the frame from covering the card's own hairline. The soft halo is
   gone from plain selection: it made the object look fuzzy at exactly the moment
   the user wants to see its edges. Lifting and live mode still get a glow, because
   there the glow is saying something the frame cannot. */
.card.selected {
  outline-width: calc(1 * var(--px));
  outline-offset: calc(1 * var(--px));
  box-shadow: var(--card-elev);
}
.card.selected:hover {
  box-shadow: var(--card-elev-hover);
}
.card.lifted {
  box-shadow:
    0 0 0 calc(4 * var(--px)) var(--state-selected),
    var(--card-elev-drag);
}
.card.link-target {
  outline-width: calc(2 * var(--px));
  box-shadow:
    0 0 0 calc(6 * var(--px)) var(--accent-ring),
    var(--card-elev);
}
.card.frame {
  border-radius: var(--frame-r);
}
.chb:hover,
.chb:active {
  border-radius: calc(5 * var(--px));
}
.card-head {
  height: calc(32 * var(--px));
  gap: calc(8 * var(--px));
  padding: 0 calc(6 * var(--px)) 0 calc(10 * var(--px));
  font-size: calc(12 * var(--px));
  letter-spacing: var(--ls-12);
  background-size: 100% calc(1 * var(--px));
}
.card-head .fav,
.card-head .ti svg {
  width: calc(16 * var(--px));
  height: calc(16 * var(--px));
}
.card-head-actions {
  gap: calc(2 * var(--px));
}
.chb {
  width: calc(24 * var(--px));
  height: calc(24 * var(--px));
  border-radius: calc(5 * var(--px));
}
.chb svg {
  width: calc(16 * var(--px));
  height: calc(16 * var(--px));
}
.frame-label {
  top: calc(-28 * var(--px));
  height: calc(28 * var(--px));
  gap: calc(6 * var(--px));
  padding: 0 calc(8 * var(--px)) 0 calc(6 * var(--px));
  font-size: calc(12 * var(--px));
  letter-spacing: var(--ls-12);
  border-radius: calc(5 * var(--px));
}
.frame-label .ti svg {
  width: calc(14 * var(--px));
  height: calc(14 * var(--px));
}
.frame-count {
  font-size: calc(11 * var(--px));
  letter-spacing: var(--ls-11);
}
.lod-cover {
  inset: calc(32 * var(--px)) 0 0 0;
}
/* Nothing else here for LOD: below the threshold the label owns the whole card
   and is sized from the card (see the LOD block above), not from the screen.
   It is content, and content scales with the world. */
.cards.lod .frame-label {
  top: calc(-28 * var(--px));
  font-size: calc(12 * var(--px));
  letter-spacing: var(--ls-12);
  padding: 0 calc(8 * var(--px)) 0 calc(6 * var(--px));
}
.cards.lod .frame-label .ti svg {
  width: calc(14 * var(--px));
  height: calc(14 * var(--px));
}
.cards.lod .rz {
  display: none;
}
.app.present .rz {
  display: none;
}
/* clicking a connector is a frequent action: keep its hit area 16 screen px wide */
.links .hit {
  stroke-width: 16;
  vector-effect: non-scaling-stroke;
}

/* ---------- eight resize handles (§4.4) ---------- */
.resize {
  display: none;
}
.rz {
  position: absolute;
  z-index: 4;
}
.rz-n,
.rz-s {
  left: calc(14 * var(--px));
  right: calc(14 * var(--px));
  height: calc(8 * var(--px));
  cursor: ns-resize;
}
.rz-e,
.rz-w {
  top: calc(14 * var(--px));
  bottom: calc(14 * var(--px));
  width: calc(8 * var(--px));
  cursor: ew-resize;
}
.rz-n {
  top: 0;
}
.rz-s {
  bottom: 0;
}
.rz-w {
  left: 0;
}
.rz-e {
  right: 0;
}
.rz-nw,
.rz-ne,
.rz-se,
.rz-sw {
  width: calc(14 * var(--px));
  height: calc(14 * var(--px));
}
.rz-nw {
  top: 0;
  left: 0;
  cursor: nwse-resize;
}
.rz-ne {
  top: 0;
  right: 0;
  cursor: nesw-resize;
}
.rz-se {
  bottom: 0;
  right: 0;
  cursor: nwse-resize;
}
.rz-sw {
  bottom: 0;
  left: 0;
  cursor: nesw-resize;
}
/* .rz elements are hit zones only now — they carry no mark. The visible handle is
   drawn on the selection layer (.rz-handle) so it can straddle the card's edge;
   drawn in here it had to hide inside the corner, where it read as a printer's
   crop mark sitting on top of the page. */

/* ---------- live-page mode (§4.1) ---------- */
/* Not interacting: the card is an object. The page underneath is inert and the whole
   surface is a drag grip, so a stray click can never fire a link. */
.card.web:not(.interacting) .card-body {
  cursor: grab;
  user-select: none;
}
.card.web:not(.interacting) .card-body a {
  cursor: grab;
}
.card.web.interacting .card-body {
  cursor: auto;
  user-select: text;
}
.card.interacting {
  outline-width: calc(1 * var(--px));
  outline-offset: calc(1 * var(--px));
  outline-color: var(--accent);
  box-shadow:
    0 0 0 calc(4 * var(--px))
      color-mix(in oklch, var(--accent) 24%, transparent),
    var(--card-elev-hover);
}
/* The title bar is ours; the page below it is not. Tinting the bar says "this window
   is live" without putting a sticker on top of someone's content. */
/* Only web cards ever go live, and a web card's head is painted from the PAGE
   palette by .card.web.selected — so the tint has to answer at that same
   specificity and mix into the page's own head colour, or it silently loses. */
.card.web.interacting .card-head {
  --head-fill: color-mix(
    in oklch,
    var(--accent) 16%,
    var(--card-chrome-page-selected)
  );
}
.live-badge {
  display: none;
  flex: none;
  align-items: center;
  height: calc(15 * var(--px));
  padding: 0 calc(6 * var(--px));
  border-radius: var(--r-full);
  background: var(--accent);
  color: var(--text-on-accent);
  font-size: calc(9 * var(--px));
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  pointer-events: none;
  animation: fade-in var(--dur-base) var(--ease-out);
}
.card.interacting .live-badge {
  display: flex;
}
.card {
  transition:
    outline-color var(--dur-fast) var(--ease-out),
    box-shadow var(--dur-base) var(--ease-out),
    scale var(--dur-base) var(--ease-out),
    opacity var(--dur-base) var(--ease-out);
}
.app.interacting .card:not(.interacting) {
  opacity: 0.85;
}

/* ---------- scroll boundary glow (§4.3) ---------- */
.card.edge-top::after,
.card.edge-bottom::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  height: calc(2 * var(--px));
  z-index: 6;
  background: var(--accent);
  box-shadow: 0 0 calc(10 * var(--px)) var(--accent);
  pointer-events: none;
  animation: edge-glow var(--dur-lazy) var(--ease-in-out) both;
}
.card.edge-top::after {
  top: calc(32 * var(--px));
}
.card.edge-bottom::after {
  bottom: 0;
}
@keyframes edge-glow {
  0% {
    opacity: 0;
  }
  35% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

/* ---------- back-to-the-board beacon (§4.3) ---------- */
.offscreen-beacon {
  position: absolute;
  z-index: var(--z-toolbar);
  transform: translate(-50%, -50%);
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  height: 32px;
  padding: 0 10px 0 8px;
  border-radius: var(--r-full);
  background: var(--surface-overlay);
  border: 1px solid var(--overlay-border);
  box-shadow: var(--shadow-md);
  color: var(--text-primary);
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-12);
  white-space: nowrap;
  animation: rise-in var(--dur-base) var(--ease-out);
}
.offscreen-beacon:hover {
  background: var(--state-hover);
}
.beacon-arrow {
  display: grid;
  place-items: center;
  color: var(--accent);
}
.beacon-arrow svg {
  width: var(--icon-md);
  height: var(--icon-md);
}
.app.present .offscreen-beacon,
.app.expanded .offscreen-beacon {
  display: none;
}

/* ---------- Side Peek footnote (§4.7) ---------- */
.peek-foot {
  color: var(--text-secondary);
}
.peek-src {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  max-width: 240px;
  height: 24px;
  padding: 0 8px;
  border-radius: var(--r-sm);
  color: var(--text-secondary);
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-11);
  transition:
    background var(--dur-fast),
    color var(--dur-fast);
}
.peek-src:hover {
  background: var(--state-hover);
  color: var(--text-primary);
}
.peek-src img {
  width: 14px;
  height: 14px;
  flex: none;
}
.peek-src span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.peek-foot .peek-pin-btn {
  margin-left: auto;
}
.peek-pin-btn.on-canvas {
  background: none;
  color: var(--text-secondary);
}

/* ---------- keyboard reach (§4.5) ---------- */
.viewport:focus {
  outline: none;
}
/* No focus ring on the canvas itself. It is the application's default focus
   target and is focused almost all the time, so a full-viewport accent frame
   is permanent noise that says nothing — there is nothing on screen for it to
   distinguish the canvas from. Keyboard focus stays visible where it carries
   information: on the cards, via .card:focus-visible below. */
.viewport:focus-visible {
  outline: none;
}
.card:focus {
  outline-color: var(--accent);
}
/* one visual for focus and selection, plus an offset ring when the keyboard put it there */
.card:focus-visible {
  outline-color: var(--accent);
  outline-offset: calc(2 * var(--px));
  box-shadow:
    0 0 0 calc(4 * var(--px)) var(--state-selected),
    var(--card-elev);
}
.ctx-menu button:focus,
.ctx-menu button:focus-visible {
  outline: none;
  background: var(--state-hover);
}

/* ---------- contrast: 11px meta text is never tertiary (§4.8, WCAG 1.4.3) ---------- */
.outline-foot,
.note-time,
.recent-host,
.url-preview small,
.pl-sub,
.palette-foot,
.pop-note,
.fmt-hint {
  color: var(--text-secondary);
}

/* ---------- touch (§4.8) ---------- */
@media (pointer: coarse) {
  .viewport {
    touch-action: none;
  }
  .card-head {
    height: calc(44 * var(--px));
  }
  .chb {
    width: calc(40 * var(--px));
    height: calc(40 * var(--px));
  }
  .chb svg {
    width: calc(20 * var(--px));
    height: calc(20 * var(--px));
  }
  .card-head-actions {
    opacity: 1;
  }
  .lod-cover {
    inset: calc(44 * var(--px)) 0 0 0;
  }
  .card.edge-top::after {
    top: calc(44 * var(--px));
  }
  .live-badge {
    top: calc(52 * var(--px));
  }
  .rz-nw,
  .rz-ne,
  .rz-se,
  .rz-sw {
    width: calc(24 * var(--px));
    height: calc(24 * var(--px));
  }
  .rz-n,
  .rz-s {
    height: calc(14 * var(--px));
    left: calc(24 * var(--px));
    right: calc(24 * var(--px));
  }
  .rz-e,
  .rz-w {
    width: calc(14 * var(--px));
    top: calc(24 * var(--px));
    bottom: calc(24 * var(--px));
  }
  .rz-handle {
    width: 13px;
    height: 13px;
    margin: -6.5px 0 0 -6.5px;
    transform-origin: 6.5px 6.5px;
    border-radius: 3px;
  }
}

/* Touch has no hover: without this the last-tapped card keeps its lift. */
@media (hover: none) {
  .card:not(.frame):hover {
    box-shadow: var(--card-elev);
  }
  .card.frame:hover {
    background: var(--frame-fill);
  }
  .card.selected:hover {
    box-shadow:
      0 0 0 calc(4 * var(--px)) var(--state-selected),
      var(--card-elev);
  }
}

/* ============================================================
   质感规范 §4.3 / §4.6 / §4.7 —— 光学、材质、微观基元
   （见「视觉质感规范」文档；每条都对应一处实测差距）
   ============================================================ */

/* ---------- §4.3 图标描边：渲染宽度恒定 ----------
   整套图标都是 stroke 3.2 画在 48 的 viewBox 上，渲染到 12–18px 时
   实际描边落在 0.80–1.20px，同一屏里重量差 50%。non-scaling-stroke
   把描边从 viewBox 缩放里摘出来，于是 stroke-width 的数值就是最终
   屏幕像素 —— 一条规则同时管住 chrome 里的图标和画布里随缩放的图标。
   选择器锚在 viewBox 上：连线工具栏的 24×14 迷你图和模拟网页里的
   图形都不是这个 viewBox，不会被波及。                              */
svg[viewBox='0 0 48 48'] * {
  stroke-width: var(--icon-stroke);
  vector-effect: non-scaling-stroke;
}

/* ---------- §4.7 滚动条 ----------
   15px 的系统滚动条压在每张白网页右缘上。拇指用 alpha 而不是固定灰，
   同一条规则才能同时成立于深色面板和白网页。                        */
* {
  scrollbar-width: thin;
  scrollbar-color: var(--alpha-4) transparent;
}
::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}
::-webkit-scrollbar-track {
  background: transparent;
}
::-webkit-scrollbar-corner {
  background: transparent;
}
::-webkit-scrollbar-thumb {
  background: var(--alpha-3);
  border: 3px solid transparent; /* 内缩出留白，拇指看起来是浮在槽里的 */
  background-clip: padding-box;
  border-radius: var(--r-full);
}
::-webkit-scrollbar-thumb:hover {
  background: var(--alpha-4);
  background-clip: padding-box;
}
/* 网页内容是白底且不跟随主题，滚动条也不能跟 */
.card.web .page-scroll,
.peek-body .page-scroll,
.expand-body .page-scroll {
  scrollbar-color: var(--on-page-ink) transparent;
}
.card.web .page-scroll::-webkit-scrollbar-thumb,
.peek-body .page-scroll::-webkit-scrollbar-thumb,
.expand-body .page-scroll::-webkit-scrollbar-thumb {
  background: var(--on-page-ink);
  background-clip: padding-box;
}
/* 出现时不推动布局 */
.outline-list,
.peek-body,
.search-list,
.modal-body {
  scrollbar-gutter: stable;
}

/* ---------- §4.7 微观基元 ---------- */
:root {
  accent-color: var(--accent);
  caret-color: var(--accent);
}
::selection {
  background: color-mix(in oklch, var(--accent) 26%, transparent);
}
/* 网页内容有自己的世界，选中色用页面墨色而不是应用强调色 */
.page-scroll ::selection {
  background: color-mix(in oklch, var(--accent) 22%, transparent);
  color: var(--text-page);
}
body {
  font-optical-sizing: auto;
}
.card.text .text-body,
.pg p,
.peek-foot,
.empty p {
  text-wrap: pretty;
}
.empty h2,
.lod-title,
.modal-head,
.modal.confirm h3 {
  text-wrap: balance;
}

/* ---------- §4.6 浮层：压在内容上的那几层用玻璃 ----------
   只给真正盖住内容的层加：顶栏底栏后面是不透明画布，模糊没有信息可透。
   saturate 是关键 —— 只做 blur 会让透出的颜色发灰。                 */
.popover,
.card-toolbar,
.link-toolbar,
.tip,
.present-exit,
.offscreen-beacon,
.canvas-hint,
.minimap {
  background: color-mix(
    in oklch,
    var(--surface-overlay) var(--overlay-opacity),
    transparent
  );
  backdrop-filter: blur(20px) saturate(1.7);
  -webkit-backdrop-filter: blur(20px) saturate(1.7);
}
.tip {
  background: color-mix(in oklch, var(--surface-inverted) 90%, transparent);
}
.peek-head,
.expand-head {
  background: color-mix(
    in oklch,
    var(--surface-raised) var(--overlay-opacity),
    transparent
  );
  backdrop-filter: blur(20px) saturate(1.7);
  -webkit-backdrop-filter: blur(20px) saturate(1.7);
}
.modal-backdrop {
  backdrop-filter: blur(6px) saturate(1.1);
  -webkit-backdrop-filter: blur(6px) saturate(1.1);
}
/* 系统关掉透明效果时退回实色 —— 无障碍要求，不是可选项 */
@media (prefers-reduced-transparency: reduce) {
  .popover,
  .card-toolbar,
  .link-toolbar,
  .present-exit,
  .offscreen-beacon,
  .canvas-hint,
  .minimap {
    background: var(--surface-overlay);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
  .tip {
    background: var(--surface-inverted);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
  .peek-head,
  .expand-head {
    background: var(--surface-raised);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
  .modal-backdrop {
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
}

/* ---------- §4.4 卡片体与标题栏的接缝 ----------
   这里原本有一条 inset 0 1px 的 --on-page-ink（40% 墨）压在卡片体顶
   上，注释写的是「深色标题栏直接切到纯白页面，接缝是硬的；给白色一个
   落脚点」—— 那是给症状打的补丁：标题栏站在 app 平面、页面站在页面平
   面，两块颜色对不上，只好拿一条重线把断口盖住。
   现在标题栏已经归到页面平面，两边同色，断口本身不存在了，这条线就只
   剩「一道 40% 的黑杠」这一个作用。接缝交给 .card-head 自己那条 8% 、
   两端渐隐的 --head-seam 就够了。                                 */

/* ---------- §4.8 hover 快进慢出 ----------
   响应要快，消失要慢 —— 这是界面「跟手」最省力的一招。            */
.icon-btn,
.text-btn,
.chb,
.ol-item,
.popover button,
.pop-item,
.card-toolbar button,
.link-toolbar button,
.site,
.note-item,
.peek-src,
.offscreen-beacon {
  transition-duration: var(--dur-base);
}
.icon-btn:hover,
.text-btn:hover,
.chb:hover,
.ol-item:hover,
.popover button:hover,
.pop-item:hover,
.card-toolbar button:hover,
.link-toolbar button:hover,
.site:hover,
.note-item:hover,
.peek-src:hover,
.offscreen-beacon:hover {
  transition-duration: var(--dur-fast);
}

/* ---------- §4.8 popover 从锚点长出来 ----------
   统一的「从下往上飘 6px」让每个菜单都像是从同一个地方来的；
   按放置方向设原点后，菜单是从按下的那个按钮里展开的。            */
@keyframes pop-open {
  from {
    opacity: 0;
    transform: scale(0.96);
  }
}
.popover {
  animation: pop-open var(--dur-base) var(--ease-out);
  transform-origin: top left;
}
.popover.closing {
  animation: scale-out var(--dur-fast) var(--ease-in) forwards;
}
.popover[data-place='above'] {
  transform-origin: bottom left;
}
.popover[data-place='above-right'] {
  transform-origin: bottom right;
}
.popover[data-place='below-right'] {
  transform-origin: top right;
}

/* ---------- add-menu submenus ---------- */
.submenu {
  min-width: 220px;
}
.add-menu button.sub-open {
  background: var(--state-hover);
}
.add-menu button.sub-open .chev-r svg {
  color: var(--text-primary);
}
.submenu .fav {
  width: var(--icon-md);
  height: var(--icon-md);
  border-radius: var(--r-xs);
}
.sw-dot {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  flex: none;
  border: 1px solid rgba(255, 255, 255, 0.12);
}
.sw-dot.sw-dark {
  background: var(--fill-strong);
}
.sw-dot.sw-blue {
  background: #4557b8;
}
.sw-dot.sw-teal {
  background: #2f8f83;
}
.sw-dot.sw-amber {
  background: #c9962e;
}
.sw-dot.sw-rose {
  background: #b8506f;
}
.submenu .t-meta {
  font-size: var(--fs-xs);
  color: var(--text-tertiary);
}

/* ---------- sub-canvas card ---------- */
.card.subcanvas {
  background: var(--surface-raised);
}
.card.subcanvas .card-head {
  color: var(--text-secondary);
}
.sub-body {
  background: var(--surface-sunken);
  background-image: radial-gradient(var(--grid-dot) 1px, transparent 1.2px);
  background-size: 16px 16px;
  display: flex;
  flex-direction: column;
  cursor: default;
}
.sub-preview {
  display: block;
  width: 100%;
  flex: 1;
  min-height: 0;
}
.sub-meta {
  flex: none;
  height: 26px;
  display: flex;
  align-items: center;
  padding: 0 12px;
  font-size: var(--fs-xs);
  color: var(--text-tertiary);
  border-top: 1px solid var(--border-subtle);
}
.card.subcanvas .lod-cover {
  background: var(--surface-raised);
  color: var(--text-primary);
}
.cards.lod .card.subcanvas .sub-body {
  opacity: 0;
  content-visibility: hidden;
}

/* ---------- the board trail (top bar, left) ----------
   The board name and every breadcrumb are the SAME chip: one height, one inner
   padding, one radius. The trail's gap is the top bar's own gap, so there is
   nothing to compensate for — and that is what makes each chevron sit optically
   centred, because text→chevron and chevron→text are both `padding + gap`.
   The old rule had three values for one rhythm (name `0 8px 0 10px`, crumbs
   `0 6px`, and a `margin-left: -2px` on the trail to undo the parent's gap),
   which put every chevron 2px closer to the label on its right. */
.crumbs {
  display: flex;
  align-items: center;
  gap: var(--sp-1);
  min-width: 0;
}
.crumb-sep {
  display: flex;
  flex: none;
}
.crumb-sep svg {
  width: var(--icon-sm);
  height: var(--icon-sm);
  color: var(--text-tertiary);
}
.crumb {
  display: inline-flex;
  align-items: center;
  height: var(--ctl-md);
  padding: 0 var(--sp-2);
  border-radius: var(--r-sm);
  color: var(--text-secondary);
  font-weight: var(--fw-medium);
  max-width: 220px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
button.crumb:hover {
  background: var(--state-hover);
  color: var(--text-primary);
}
.crumb.current {
  color: var(--text-primary);
  font-weight: var(--fw-semibold);
}
/* Inside a sub-canvas the trail has a current item, so the ancestors above it —
   the board name included — step down to medium. Two semibold labels in one
   trail is two answers to "where am I". */
.app.in-sub .board-name {
  color: var(--text-secondary);
  font-weight: var(--fw-medium);
}
.app.in-sub .board-name:hover {
  color: var(--text-primary);
}
/* Nested, the name is not a menu — clicking it goes back to the root board — so
   the disclosure chevron has to go, or it promises a dropdown and navigates. */
.app.in-sub .board-name .chev {
  display: none;
}
/* Same grid as a row, so the chevron lands in the icon column and the label
   starts on the same x as every title under it. */
.ol-back {
  display: grid;
  grid-template-columns: 18px 1fr;
  align-items: center;
  gap: var(--sp-2);
  width: 100%;
  height: 28px;
  padding: 0 var(--sp-2);
  margin-bottom: var(--sp-2);
  border-radius: var(--r-sm);
  color: var(--text-secondary);
  font-weight: var(--fw-medium);
  text-align: left;
  transition:
    background var(--dur-fast) var(--ease-out),
    color var(--dur-fast) var(--ease-out);
}
.ol-back:hover {
  background: var(--state-hover);
  color: var(--text-primary);
}
.ol-back svg {
  width: var(--icon-sm);
  height: var(--icon-sm);
  justify-self: center;
}
.ol-back span {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* templates gallery */
.modal.wide {
  width: 820px;
}
.tpl-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--sp-3);
}
.tpl {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 10px;
  border-radius: var(--r-lg);
  background: var(--fill-subtle);
  border: 1px solid var(--border-subtle);
  text-align: left;
  transition:
    background var(--dur-fast),
    border-color var(--dur-fast),
    transform var(--dur-fast) var(--ease-out);
}
.tpl:hover {
  background: var(--state-hover);
  border-color: var(--border-strong);
  transform: translateY(-1px);
}
.tpl-preview {
  border-radius: var(--r-sm);
  background: var(--surface-sunken);
  background-image: radial-gradient(var(--grid-dot) 1px, transparent 1.2px);
  background-size: 12px 12px;
  display: grid;
  place-items: center;
  overflow: hidden;
}
.tpl-preview svg {
  display: block;
  width: 100%;
  height: auto;
}
.tpl-name {
  font-weight: var(--fw-semibold);
  margin-top: 2px;
}
.tpl-desc {
  font-size: var(--fs-xs);
  color: var(--text-secondary);
  line-height: var(--lh-snug);
}
.modal-head .beta {
  margin-left: 6px;
  vertical-align: 2px;
}
