/* =========================================================
   mbrd - layout, canvas chrome, sidebar, item cards

   Structure only. Every colour, corner, duration, typeface and ornament comes
   from a custom property in tokens.css - nothing below names a hue, and the
   handful of values that are written literally here are proportions rather
   than style (a 50% centre, a 100% fill).

   That is what lets the whimsy slider re-skin the whole interface at runtime
   without touching this file, and what will let the board re-skin itself from
   the pictures dropped on it later. The rules gated on [data-whimsy] at the
   bottom are the exceptions: the few decisions that are structural rather than
   numeric, and so cannot live in a token.
   ========================================================= */

* { box-sizing: border-box; }

/* Selected text, in this board's own pigment rather than the browser's blue.
   Unqualified on purpose: it covers the canvas, the sidebar and the menus in
   one rule, because a highlight is a property of the board's palette and not
   of any one surface on it. See --highlight in tokens.css for why it is a
   wash at two of the three whimsy levels and a solid block at the third. */
::selection {
  background: var(--highlight);
  color: var(--highlight-ink);
}

html, body {
  margin: 0;
  height: 100%;
  overflow: hidden;
  color: var(--ink);
  font-family: var(--font-body);
  font-size: var(--t-body);
  -webkit-font-smoothing: antialiased;
  /* Kill rubber-band / pull-to-refresh: every gesture on this page is ours. */
  overscroll-behavior: none;
  -webkit-tap-highlight-color: transparent;
}

/* The small print goes in the display face.
   Every selector here is an aside - a caption, a section head, a keyboard
   legend, a toast - and an aside is the display voice's job, not the body's.
   In the middle of the whimsy axis, where the two tokens name different
   families, this is the split doing its work; at either end both name the
   same family and the whole rule is a no-op. Grouped rather than repeated
   thirteen times, and kept next to the body rule it is the counterweight to. */
.card-icon, .card-meta, .item-label, .wordmark h1, .wordmark .sub,
.side-sec h2, .field > span, .hint, .keys li, .side-foot,
#bin-panel h2, #bin-none, #bin-hint, #drop-overlay div, #toast {
  font-family: var(--font-display);
}

/* The sheet. #viewport paints the grid on top of this and stays transparent,
   so the paper shows through at every zoom without the grid painter having to
   know about it.
   Soft blooms and a vignette only - no fibre hatching. Fine repeating lines
   under a dot grid read as a second, competing grid, and at fractional zoom the
   two beat against each other into moire. */
body {
  background-color: var(--paper);
  /* --wash scales all three at once, so the whimsy axis can flatten the paper
     to nothing without a second background declaration to keep in sync. */
  background-image:
    radial-gradient(58% 44% at 18% 12%, color-mix(in srgb, var(--accent-warm) calc(5% * var(--wash)), transparent), transparent 70%),
    radial-gradient(52% 40% at 84% 78%, color-mix(in srgb, var(--leafy) calc(4% * var(--wash)), transparent), transparent 72%),
    radial-gradient(120% 100% at 50% 50%, transparent 55%, color-mix(in srgb, var(--ink) calc(5% * var(--wash)), transparent));
}

button, input, select, textarea { font: inherit; color: inherit; }

/* ---------------------------------------------------------
   Canvas
   --------------------------------------------------------- */

#viewport {
  position: fixed;
  inset: 0;
  /* Pointer Events only - the browser must never claim a pan/pinch for itself. */
  touch-action: none;
  cursor: default;
  overflow: hidden;
  background-color: transparent;   /* the paper on <body> shows through */
  background-repeat: repeat;
  contain: layout paint;
}
#viewport.is-panning { cursor: grabbing; }
#viewport.can-pan    { cursor: grab; }

/* The one transformed layer. Sits at the viewport centre so world (0,0) is the
   centre of the screen at pan (0,0); viewport.js writes the transform and --iz
   (= 1/zoom), which item chrome multiplies by to stay a constant screen size. */
#world {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 0;
  height: 0;
  transform-origin: 0 0;
  will-change: transform;
  --iz: 1;
}

/* The Harsh lattice. Empty at the other two tiers, where #viewport's own
   background-image draws the dots - so this is either the whole grid or
   nothing, never a second layer under one.
   Sized in device pixels by canvas/grid.js and stretched back to CSS pixels
   here, which is what lets a mark be snapped to the device grid rather than to
   a CSS pixel that may be two thirds of one. */
#grid-ink {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* World axes, drawn in screen space so they stay hairline at any zoom. */
.axis {
  position: absolute;
  background: var(--grid-axis);
  pointer-events: none;
}
#axis-x { left: 0; width: 100%; height: 1px; }
#axis-y { top: 0; height: 100%; width: 1px; }
#viewport.no-axes .axis { display: none; }

/* The origin, marked like a compass rose rather than just a crossing. Drawn a
   shade stronger than the axes so (0,0) reads as a place, not an accident. */
#origin-mark {
  position: absolute;
  width: 36px;
  height: 36px;
  margin: -18px 0 0 -18px;
  color: var(--accent);
  opacity: 0.7;
  pointer-events: none;
}
#viewport.no-axes #origin-mark { display: none; }

#marquee {
  position: absolute;
  border: 1.5px dashed var(--select);
  background: var(--select-fill);
  border-radius: var(--radius-sm);
  pointer-events: none;
  z-index: 5;
}

/* The web of threads between items (canvas/web.js). Inside #world, so pan and
   zoom are the layer's transform rather than anything this has to redraw.
   Negative z-index puts it under every item; #world paints no background of
   its own, so there is nothing for it to disappear behind. */
#web {
  position: absolute;
  overflow: visible;
  pointer-events: none;
  z-index: -1;
}
/* Two kinds of element, one appearance. The bulk <path> carries every settled
   thread as subpaths of a single `d`, which is what keeps a redraw to one
   attribute write on a board with hundreds of them. A thread only gets its own
   <line> for as long as it is fading, because opacity cannot be applied to a
   subpath - and it is handed back to the path the moment it lands. */
#web path,
#web line.thread {
  stroke: var(--web-line);
  stroke-width: var(--web-weight);
  stroke-linecap: round;
  /* A thread, not a beam: without this the stroke would be multiplied by the
     zoom along with everything else, and reach ten pixels wide at 8x. */
  vector-effect: non-scaling-stroke;
}

/* ---------------------------------------------------------
   Items
   --------------------------------------------------------- */

.item {
  position: absolute;
  background: var(--item-bg);
  /* The corner this item actually draws, which is not simply --radius.
     Two things bend it. A note is cut from a pad and carries --note-radius
     instead, so --shape-radius is overridden per type. And the browser caps a
     border-radius at half the box's shorter side - an item narrower than twice
     its radius comes out a lozenge, whatever was asked for - so --half-min
     (written by items.js/place(), which is where the size is known) applies
     that same cap here.
     Everything that has to trace this corner reads --own-radius rather than
     guessing, so nothing ends up sweeping a 26px curve around a corner the
     browser quietly flattened to 10. */
  --shape-radius: var(--radius);
  --own-radius: min(var(--shape-radius), var(--half-min, 9999px));
  border-radius: var(--own-radius);
  box-shadow: var(--item-shadow);
  /* Deliberately not clipped: the resize handles and their brackets sit
     *outside* this box, and a clip here would slice every one of them in half.
     .item-body does the clipping instead - it fills the item and carries the
     same radius, so content still rounds off at the corners. */
  overflow: visible;
  cursor: grab;
  user-select: none;
  -webkit-user-select: none;
  /* The selection ring's geometry, derived once here so that the ring and its
     corner marks cannot drift apart - which they had, in two separate ways.
       --sel-edge    how far the ring's outer edge sits from the item box
       --mark-radius the curve both of them trace: the item's own corner,
                     pushed outwards by that gap. A curve of radius R offset
                     outwards by d is a curve of radius R + d, and both terms
                     are in the item's own units, so it holds at any zoom
       --mark-size   how big a corner mark has to be. Not merely --sel-reach:
                     a border-radius larger than its own box is scaled down by
                     the browser, which flattened the marks on a heavily
                     rounded card while a note's - with a much smaller radius -
                     came out true. Sized from the curve *plus* the straight
                     run, so --sel-reach now means what it says: how far the
                     heavy part continues past the end of the curve.

     R + d is the true parallel of a *rounded* corner. A square corner's true
     parallel is an arc of radius d - geometrically correct, and visibly wrong:
     a rounded ring around a sharp card reads as a mistake in a language whose
     whole premise is that corners are square. So the growth is switched off
     when there is no corner to grow. Multiplying by a large factor saturates
     to R + d for any real radius and collapses to zero at exactly zero, which
     is a branch CSS has no other way to express. */
  --sel-edge: calc((var(--sel-gap) + var(--sel-line)) * var(--iz));
  --mark-radius: min(calc(var(--own-radius) + var(--sel-edge)),
                     calc(var(--own-radius) * 1000));
  --mark-size: calc(var(--mark-radius) + var(--sel-reach) * var(--iz));
  /* The lift and the tilt are zero at the plain end of the whimsy axis, so the
     same declaration covers "springs up to meet you" and "does not move".
     The resting tilt is each item's own factor in [-1, 1] (written by
     canvas/items.js, dealt from a bag so a third of the board hangs straight) times
     whatever the axis currently allows - and the drag tilt adds on top of it,
     rather than replacing it, so picking a crooked item up keeps it crooked. */
  translate: 0 calc(var(--lift) * var(--iz));
  rotate: calc(var(--item-tilt, 0) * var(--tilt-max) + var(--tilt));
  scale: var(--grow);
  --lift: 0px;
  --tilt: 0deg;
  --grow: 1;
  transition: box-shadow var(--dur-base) var(--ease),
              translate var(--dur-base) var(--ease-back),
              rotate var(--dur-base) var(--ease-back),
              scale var(--dur-base) var(--ease-back);
}
/* translate/rotate/scale rather than transform, because items.js already owns
   `transform` for the item's own rotation - these are independent properties
   and compose with it instead of fighting over one declaration. */
.item:hover {
  box-shadow: var(--item-shadow), 0 0 0 calc(1px * var(--iz)) var(--rule-2);
  --lift: var(--lift-hover);
  --grow: var(--grow-hover);
}
.item.is-dragging {
  cursor: grabbing;
  --lift: var(--lift-drag);
  --tilt: var(--tilt-drag);
  --grow: var(--grow-hover);
}
/* The selection ring, drawn rather than outlined.
   `outline` would be the obvious tool and is the wrong one: engines disagree
   about whether outline-offset grows the outline's corner radius along with
   it. Firefox keeps the element's radius, Blink adds the offset - so a ring
   and a corner mark computed from the same numbers still came out as two
   different curves, doubling visibly at every corner. A border on a
   pseudo-element has one interpretation everywhere.
   The box's outer edge is --sel-edge out from the item and the border grows
   inwards from it, so the ring occupies exactly --sel-gap to --sel-edge - and
   its radius is the item's own, offset by the same amount, which is the same
   expression the corner marks use. */
.item::before {
  content: '';
  position: absolute;
  inset: calc(-1 * var(--sel-edge));
  border: 0 solid var(--select);
  border-radius: var(--mark-radius);
  pointer-events: none;
  transition: border-width var(--dur-fast) var(--ease);
}
.item.is-selected::before { border-width: calc(var(--sel-line) * var(--iz)); }

.item-body {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  /* Carries .item's radius down the tree. `border-radius: inherit` copies the
     *parent's* value, so without this link the card inside inherits 0 and its
     border and inset ruling turn up square inside a rounded card. */
  border-radius: inherit;
  pointer-events: none;   /* the whole card is one drag target */
}
.item-body img,
.item-body video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.item[data-fit="contain"] .item-body img,
.item[data-fit="contain"] .item-body video { object-fit: contain; }

/* Frozen GIFs (canvas/stills.js). The twin is out of the flow until it holds a
   decoded frame - is-ready, not merely [src], because the attribute lands the
   moment it is assigned and swapping then would show a blank where the picture
   was. Until it is ready the GIF just keeps playing, which is the harmless
   failure. */
.item-body img.still { display: none; }
#world.is-stilled .item-body img.still.is-ready { display: block; }
#world.is-stilled .item-body img[data-gif]:has(+ .still.is-ready) { display: none; }

/* Cards for the non-visual types: audio, text, and the generic fallback.
   Ruled just inside the edge, the way a plate or a bookplate is. */
.card {
  position: relative;
  width: 100%;
  height: 100%;
  padding: 15px 16px;
  display: flex;
  flex-direction: column;
  gap: 7px;
  border-radius: inherit;
  background: var(--paper-card);
  /* The card fills .item exactly, and .item clips at the same radius - so a
     real border would be shaved away at the corners by that clip. An inset
     shadow draws inside the padding box and survives it. */
  box-shadow: inset 0 0 0 var(--hairline) var(--item-border);
  overflow: hidden;
}

/* The inner rule. Two nested rounded rectangles only look concentric when the
   inner radius is the outer radius *minus* the gap between them - otherwise
   the corners bow outward and the gap reads wider at the corners than along
   the sides. That subtraction is the whole point of this rule. */
.card::after {
  content: '';
  position: absolute;
  inset: var(--card-rule-gap);
  border: var(--hairline) solid var(--rule);
  border-radius: max(0px, calc(var(--own-radius) - var(--card-rule-gap)));
  pointer-events: none;
}
.card-icon {
  font-family: var(--font-display);
  font-size: 11px;
  font-style: var(--display-italic);
  letter-spacing: calc(0.16em * var(--ls-scale));
  text-transform: uppercase;
  color: var(--accent);
}
.card-name {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--t-title);
  line-height: 1.2;
  word-break: break-word;
}
.card-meta {
  color: var(--ink-3);
  font-size: var(--t-small);
  font-style: var(--display-italic);
}
/* The body of a text card. Written before the renderer that uses it existed;
   canvas/renderers.js/RENDERERS.text is what fills it. */
.card-text {
  flex: 1;
  min-height: 0;
  overflow: hidden;
  margin: 0;
  white-space: pre-wrap;
  /* Source and CSV routinely run past the card; wrapping mid-word is what keeps
     a minified line or a long path from widening the card's own scroll box. */
  overflow-wrap: anywhere;
  tab-size: 2;
  font-family: var(--font-mono);
  font-size: var(--t-small);
  line-height: 1.5;
  color: var(--ink-2);
  -webkit-mask-image: linear-gradient(180deg, #000 80%, transparent);
          mask-image: linear-gradient(180deg, #000 80%, transparent);
}
/* A picture given to a card that is not itself one - album art off an mp3's
   tags, or anything chosen by hand. See coverEl() in canvas/renderers.js, which
   prepends it inside the card rather than behind it.

   Inside the padding, not bled to the edges. A card here is a plate: ruled
   just in from its own border, with the rule's radius derived from the card's
   so the two curves stay concentric. A full-bleed cover would cross that rule
   and turn the plate into a poster with a line drawn across it - and the rule
   is what tells a filed card apart from a photograph on this board.

   flex rather than a fixed aspect, because a cover has no aspect it is
   entitled to. Square for album art, wide for a screenshot, tall for a poster;
   the card was sized by its type and its own resizing, so the picture takes the
   room the words leave and crops to it. */
.card-cover {
  flex: 1;
  min-height: 0;
  width: 100%;
  object-fit: cover;
  display: block;
  border-radius: var(--radius-sm);
  /* A pale cover on pale card stock has no edge of its own - the same problem
     the polaroid mat has, and the same hairline answers it. */
  box-shadow: 0 0 0 var(--hairline) color-mix(in srgb, var(--ink) 10%, transparent);
}

/* The kicker earns its place on a card that is only words. Above a picture of
   the record it is labelling a card AUDIO that is plainly a record sleeve. */
.card.has-cover .card-icon { display: none; }

/* The native player is a fixed-height control, so it is pinned to the foot of
   the card rather than left to be squeezed by the text above it. */
/* The audio card's own transport. The <audio> element is present but never
   laid out - it is the engine, not the interface. */
.card-audio audio { display: none; }

.transport {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: auto;
  pointer-events: auto;
}
.transport .play {
  flex: none;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: var(--hairline) solid var(--accent);
  border-radius: 50%;
  background: none;
  color: var(--accent);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease),
              transform var(--dur-base) var(--ease-back);
}
.transport .play svg { width: 13px; height: 13px; }
.transport .play:hover { background: var(--accent); color: var(--accent-fg); transform: scale(var(--btn-grow)); }
.transport.is-playing .play { background: var(--accent); color: var(--accent-fg); }

/* The waveform. Each bar is an RMS reading of that slice of the file, mirrored
   about the centre line - a signal has no up and no down, and half of one
   drawn on a baseline is a bar chart rather than a picture of a sound.
   Rounded off with the rest of the interface, so a plain board gets square
   bars and a soft one gets capsules. See canvas/audio.js. */
.wave {
  position: relative;
  flex: 1;
  min-width: 0;
  height: 40px;
  cursor: pointer;
}
/* Two identical lanes, stacked. The quiet one is the whole waveform; the
   accent one is the same waveform revealed by a clip that follows the
   playhead, which is what lets the fill cut a bar in half instead of stepping
   whole bars at a time. */
.wave-lane {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  gap: 1px;
}
.wave-lane i {
  flex: 1;
  min-width: 1px;
  border-radius: min(var(--radius-xs), 2px);
}
.wave-base i { background: color-mix(in srgb, var(--ink) 22%, transparent); }
.wave-fill {
  clip-path: inset(0 100% 0 0);
  will-change: clip-path;
}
.wave-fill i { background: var(--accent); }
.transport-time {
  flex: none;
  font-family: var(--font-mono);
  font-size: var(--t-tiny);
  color: var(--ink-3);
  font-variant-numeric: tabular-nums;
}

/* Zoomed out past the ladder's threshold, an audio card stops being a player
   and becomes a button.

   The waveform is the first thing that has to go. At this zoom a bar is under
   half a device pixel and the gaps between them are less than that, so eighty
   readings composite down to one flat grey block - noise wearing the shape of
   information, and the most expensive thing on the card to keep painting. The
   name and the clock follow it for the same reason the caption plates and the
   resize handles already stand down here: type five pixels tall is a smudge,
   and a smudge that is only there to be unreadable is worse than a gap.

   What survives is the one control still worth hitting from across the board,
   grown to fill the card it is on. Sized from --half-min (min(w, h) / 2, per
   item from canvas/items.js/place()) rather than from --iz, because this is
   part of the card rather than chrome laid over it: it belongs to the board's
   scale, and taking half the shorter side as its bound is what stops it
   outgrowing the card on anything that has been resized to a strip. */
#world.zoom-far .card-audio .card-icon,
#world.zoom-far .card-audio .card-name,
#world.zoom-far .card-audio .card-meta,
#world.zoom-far .card-audio .wave,
#world.zoom-far .card-audio .transport-time { display: none; }

#world.zoom-far .card-audio .transport {
  flex: 1;
  margin-top: 0;           /* nothing above it left to be pushed away from */
  justify-content: center;
}
#world.zoom-far .card-audio .play {
  width:  calc(var(--half-min, 98px) * 0.86);
  height: calc(var(--half-min, 98px) * 0.86);
  /* The ring has to thicken with the button or it antialiases away exactly
     when the button is all there is left to see. */
  border-width: max(var(--hairline), calc(var(--half-min, 98px) * 0.03));
}
#world.zoom-far .card-audio .play svg { width: 38%; height: 38%; }

/* An audio card that has album art is the one case where zooming out should
   leave *more* than a button. A sleeve is recognisable at a size a name is not
   - it is the only thing on a card of this kind that still says which record
   this is from across the board - so down here the picture takes the whole card
   and the button sits on it, which is what a sleeve with a play button on it
   has looked like for fifteen years.

   .card is already position: relative, and .item-body clips at the item's
   radius, so the cover can simply fill the box. */
#world.zoom-far .card-audio.has-cover { padding: 0; }
#world.zoom-far .card-audio.has-cover::after { content: none; }   /* no rule left to be inside of */
#world.zoom-far .card-audio.has-cover .card-cover {
  position: absolute;
  inset: 0;
  height: 100%;
  border-radius: inherit;
  box-shadow: none;
}
/* Over the sleeve rather than beside it. */
#world.zoom-far .card-audio.has-cover .transport { position: relative; }
/* And it needs a plate under it once it is over a picture. The button is an
   accent ring on nothing, which is legible on card stock and disappears on a
   dark sleeve - artwork is the one background this app does not choose. Same
   answer .vbig uses over a video's poster frame, and for the same reason. */
#world.zoom-far .card-audio.has-cover .play {
  background: color-mix(in srgb, var(--paper-card) 84%, transparent);
  backdrop-filter: blur(3px);
}

/* The video transport (canvas/video.js).
   Laid over the picture rather than under it, because on a video card the
   picture *is* the card - every row of chrome given its own space is a row of
   the thing you pinned up, taken away. So it floats, and it stays out of the
   way until the pointer is on the card or the clip is running. */
.vplayer {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  /* Same bargain .transport and the link anchor make: the layer refuses the
     pointer so the whole card stays one drag target, and only the controls on
     it take the gesture back. */
  pointer-events: none;
}

/* The one thing on a parked video that says it is a video. A clip sitting on
   its poster frame is otherwise a photograph, which is exactly the state the
   old bare <video> left every one of them in.
   Sized from --half-min (min(w, h) / 2, per item from items.js/place()) so it
   belongs to the card rather than to the screen, with a floor and a ceiling
   because a play button has a size that reads as a play button. */
.vbig {
  position: absolute;
  left: 50%;
  top: 50%;
  translate: -50% -50%;
  display: grid;
  place-items: center;
  width:  clamp(30px, calc(var(--half-min, 100px) * 0.42), 92px);
  height: clamp(30px, calc(var(--half-min, 100px) * 0.42), 92px);
  padding: 0;
  border: var(--hairline) solid var(--accent);
  border-radius: 50%;
  background: color-mix(in srgb, var(--paper-card) 84%, transparent);
  backdrop-filter: blur(3px);
  color: var(--accent);
  cursor: pointer;
  pointer-events: auto;
  transition: opacity var(--dur-fast) var(--ease),
              background var(--dur-fast) var(--ease),
              color var(--dur-fast) var(--ease),
              scale var(--dur-base) var(--ease-back);
}
.vbig svg { width: 40%; height: 40%; }
/* scale, not transform: translate above is doing the centring, and a transform
   here would overwrite it and throw the button off the middle of the card. */
.vbig:hover { background: var(--accent); color: var(--accent-fg); scale: var(--btn-grow); }
.vplayer.is-playing .vbig { opacity: 0; pointer-events: none; }

.transport-video {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  margin: 0;                 /* the audio bar's margin-top: auto means nothing here */
  padding: 6px 9px;
  gap: 8px;
  background: color-mix(in srgb, var(--paper-card) 88%, transparent);
  border-top: var(--hairline) solid var(--rule);
  backdrop-filter: blur(3px);
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease);
}
/* :focus-within is not a nicety - without it the bar vanishes the moment the
   pointer leaves, taking the scrubber the keyboard is currently on with it. */
.item:hover .transport-video,
.transport-video:focus-within,
.transport-video.is-playing { opacity: 1; }
/* Smaller than the audio card's, which is the only control on a card of its
   own and can afford to be the size it is. */
.transport-video .play { width: 25px; height: 25px; }
.transport-video .play svg { width: 11px; height: 11px; }

/* A plain line, deliberately, where the audio card gets a measured waveform.
   A waveform is worth its space when it is the only picture of the file there
   is; over a video it would be a drawing of the soundtrack laid across the
   picture, competing with it to say something you can already see. */
.vtrack {
  position: relative;
  flex: 1;
  min-width: 0;
  /* Taller than the line it draws: 3px is not a thing anyone can hit, and the
     hit area is what the pointer is actually aiming at. */
  height: 14px;
  cursor: pointer;
}
.vtrack::before,
.vtrack-fill {
  content: '';
  position: absolute;
  left: 0; right: 0;
  top: 50%;
  height: 3px;
  margin-top: -1.5px;
  border-radius: min(var(--radius-xs), 2px);
}
.vtrack::before { background: color-mix(in srgb, var(--ink) 22%, transparent); }
.vtrack-fill {
  background: var(--accent);
  /* Scaled rather than resized: this is written on every frame of playback,
     and a transform is the one way to move it that never touches layout. */
  transform-origin: 0 50%;
  transform: scaleX(0);
}
.vtrack:focus-visible { outline: var(--sel-line) solid var(--select); outline-offset: 2px; }

.vmute {
  flex: none;
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  padding: 0;
  border: 0;
  background: none;
  color: var(--ink-3);
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease);
}
.vmute svg { width: 15px; height: 15px; }
.vmute:hover { color: var(--accent); }
.vplayer.is-muted .vmute { color: var(--danger); }

/* The caption plate and the control bar want the same strip of card, so they
   take turns: the name while you are looking, the transport while you are
   using it. */
.item[data-type="video"] .item-label { transition: opacity var(--dur-fast) var(--ease); }
.item[data-type="video"]:hover .item-label,
.item[data-type="video"]:focus-within .item-label,
.item-body:has(.vplayer.is-playing) .item-label { opacity: 0; }

/* Far zoom leaves the same thing behind that an audio card is left with: the
   one control worth hitting from across the board. The bar's type and its
   3px line are both under a pixel down here. */
#world.zoom-far .transport-video { display: none; }

/* Notes: the one item type born on the board, so it is drawn as a sticky
   rather than as a filed card - a square of coloured pad paper, cut corners,
   pressed flat to the board.
   The tint comes from data-tint (1-4, cycled at creation and stored in
   meta.tint), so a wall of notes reads as a pack rather than a monotone. */
.item[data-type="note"] {
  --item-shadow: var(--note-shadow);
  --shape-radius: var(--note-radius);
  --note-paper: var(--note-1);
  /* Every measurement on a sticky is a fraction of the sticky.
     A note was 240px square and its furniture was written as the fixed numbers
     that suited a note that size - 20px of margin, a 30px adhesive band, a
     title off the display ramp. Halve the square and none of that halves with
     it: the title alone takes a third of the sheet, the margins take most of
     what is left, and a note that holds 512 characters shows about three lines
     of them before .note-body's clip quietly eats the rest. A small note has
     to be a small note, not a big note's fittings crammed into a smaller
     square.

     --half-min is min(w, h) / 2, written per item by canvas/items.js/place().
     Every coefficient below is calibrated on the 240px default, so a note that
     size still resolves to exactly the number it was drawn with and nothing on
     an existing board moves; only the smaller ones change. The clamps are
     floors and ceilings, not the operative value - the ceiling is what keeps a
     note that has been dragged out to 900px from setting its title in 110pt,
     and the floor is the size below which type stops being type. */
  --note-half:    var(--half-min, 120px);
  --note-pad:     clamp(9px,  calc(var(--note-half) * 0.167),  20px);
  --note-gap:     clamp(4px,  calc(var(--note-half) * 0.075),  9px);
  --note-band:    clamp(12px, calc(var(--note-half) * 0.25),   30px);
  --note-t-title: clamp(12px, calc(var(--note-half) * 0.25),   var(--t-display));
  --note-t-body:  clamp(10px, calc(var(--note-half) * 0.1333), 16px);
  /* The item under the card takes the pad colour too. Two rounded boxes of the
     same radius stacked exactly on top of each other still leave a hairline of
     the lower one showing along the curve, where the antialiasing of the two
     edges does not quite agree - and a near-white hairline around a coloured
     sticky reads as a printing misregistration. */
  background: var(--note-paper);
}
.item[data-type="note"][data-tint="2"] { --note-paper: var(--note-2); }
.item[data-type="note"][data-tint="3"] { --note-paper: var(--note-3); }
.item[data-type="note"][data-tint="4"] { --note-paper: var(--note-4); }

.item[data-type="note"] .card {
  padding: var(--note-pad);
  /* The band at the head is the adhesive strip reading through the sheet. It
     is a fixed share of the sheet rather than a fixed depth, because a strip
     of glue is as wide as the pad it came off. */
  background:
    linear-gradient(180deg,
      color-mix(in srgb, var(--ink) 6%, transparent) 0,
      transparent var(--note-band)),
    var(--note-paper);
  /* No ruled border - pad paper has no printed edge. Just enough of an inset
     line to keep a pale note off a pale board. */
  box-shadow: inset 0 0 0 var(--hairline) color-mix(in srgb, var(--ink) 7%, transparent);
}
/* The filed-card inner rule has no business on a sticky. */
.item[data-type="note"] .card::after { content: none; }

/* Title and body are separate fields (renderers.js/note), ruled apart. The
   rule is a hairline of the ink at very low strength rather than --rule: on
   coloured pad paper the shared rule colour reads as a different, greyer
   material laid across the sheet, where a tint of the note's own ink reads as
   a line drawn on it. */
.item[data-type="note"] .note-title {
  font-family: var(--font-display);
  font-size: var(--note-t-title);
  font-weight: 700;
  line-height: 1.2;
  color: var(--ink);
  padding-bottom: var(--note-gap);
  border-bottom: var(--hairline) solid color-mix(in srgb, var(--ink) 14%, transparent);
  word-break: break-word;
}
.item[data-type="note"] .note-body {
  flex: 1;
  min-height: 0;
  overflow: hidden;
  padding-top: var(--note-gap);
  white-space: pre-wrap;
  font-family: var(--font-display);
  font-size: var(--note-t-body);
  line-height: 1.45;
  color: var(--ink-2);
  word-break: break-word;
}
/* A note that is only a title is only a title: no rule under it, and no empty
   band of body below. While it is being edited the body has to stay reachable,
   so the collapse waits until you are done. */
.item[data-type="note"]:not(.is-editing) .note-body:empty { display: none; }
.item[data-type="note"]:not(.is-editing) .note-title:has(+ .note-body:empty) {
  padding-bottom: 0;
  border-bottom: 0;
}
/* A rule divides a title from a body, so with no title there is nothing to
   divide and the line is just a stray mark across the top of the note.
   No :not(.is-editing) here, and that falls out rather than being a
   compromise: an empty field being typed into holds a <br>, so it stops
   matching :empty the moment it has focus and the rule comes back to show
   where the title goes. It is only gone when the title is genuinely left
   empty, which is what was asked for. */
.item[data-type="note"] .note-title:empty {
  padding-bottom: 0;
  border-bottom: 0;
}

/* How much room is left, while a note is being written. Outside .item-body so
   the body's clip cannot cut it off, and scaled by --iz like the rest of the
   item chrome so it stays the same size on screen at any zoom. */
.note-count {
  position: absolute;
  right: 0;
  top: 100%;
  margin-top: calc(6px * var(--iz));
  transform: scale(var(--iz));
  transform-origin: 100% 0;
  font-family: var(--font-display);
  font-size: var(--t-tiny);
  font-style: var(--display-italic);
  color: var(--ink-3);
  white-space: nowrap;
  pointer-events: none;
}
.note-count.is-low { color: var(--danger); }

/* Links: the other item born on the board, and the only one whose subject is
   somewhere else entirely. Drawn as an ordinary filed card, because that is
   what it is - a name, an address, and no body. The one part that behaves
   differently is the name, which is the anchor. */
.card-link .card-name {
  color: var(--accent);
  text-decoration: underline;
  text-decoration-thickness: from-font;
  text-underline-offset: 2px;
  /* .item-body refuses the pointer so that the whole card is one drag target;
     the anchor is the exception, the same bargain .transport makes for the
     audio scrubber. Everything around it - the badge, the address, the card's
     own padding - still drags, selects and resizes as usual. */
  pointer-events: auto;
  cursor: pointer;
  /* An address has no spaces to break at, so a long one is allowed to break
     anywhere rather than shouldering the card's edge out of the way. */
  overflow-wrap: anywhere;
}
.card-link .card-name:hover { text-decoration-thickness: 2px; }
/* The address itself: monospaced, so that a URL reads as a URL and not as
   prose, and upright rather than in the italic the other card metadata wears -
   an address is a literal, and slanting one is a small lie about it. */
.card-link .card-meta {
  font-family: var(--font-mono);
  font-style: normal;
  overflow-wrap: anywhere;
  /* Two lines of address is as much orientation as anyone wants, and the whole
     of it is on the anchor's tooltip for the rare time it is not. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
          line-clamp: 2;
  overflow: hidden;
}
/* The anchor stands down at far zoom, for the reason the grips and the caption
   plates do: below a third of size a card is a coloured tile, and a click on
   one is a click on the board rather than a considered press on a line of text
   five pixels tall. Opening a tab is not a thing to do by accident. */
#world.zoom-far .card-link .card-name { pointer-events: none; }

.item.is-editing { cursor: text; }
.item.is-editing .item-body { pointer-events: auto; }
.item.is-editing .card-text,
.item.is-editing .card-name,
.item.is-editing .item-label,
.item.is-editing .note-title,
.item.is-editing .note-body { outline: none; overflow: auto; cursor: text; }
/* The caption plate refuses the pointer and ellipsises whatever it cannot fit -
   both right for a label, both wrong for a field. While it is being renamed it
   takes the caret and scrolls instead of truncating. */
.item.is-editing .item-label { pointer-events: auto; text-overflow: clip; }
/* An empty field collapses to nothing and becomes unclickable, so both keep a
   line's worth of height while the note is open for editing. */
.item.is-editing .note-title:empty,
.item.is-editing .note-body:empty { min-height: 1em; }

/* Caption plate: a strip of paper across the foot of a picture, not a black
   gradient. Reads like a museum label. */
.item-label {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  padding: 6px 11px 7px;
  font-size: 12px;
  font-style: var(--display-italic);
  line-height: 1.25;
  color: var(--ink-2);
  background: color-mix(in srgb, var(--paper-card) 88%, transparent);
  border-top: 1px solid var(--rule);
  backdrop-filter: blur(3px);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  pointer-events: none;
}
.item[data-type="audio"] .item-label,
.item[data-type="text"] .item-label,
.item[data-type="note"] .item-label,
.item[data-type="link"] .item-label,
.item[data-type="generic"] .item-label { display: none; }
/* :not(), rather than an override that unhides it again - winning on
   specificity here would also unhide the plates that particular item types
   hide on purpose. A plate being typed into is the one that must survive. */
#world.zoom-far .item:not(.is-editing) .item-label { display: none; }

/* Resize handles. Everything on the board takes all eight - four corners that
   move both axes, four edges that move one.
   Every size is multiplied by --iz (= 1/zoom) so the handles stay a constant
   size on screen and remain grabbable at any zoom instead of shrinking away.

   The grip itself is an invisible hit area; the only thing drawn is a heavier
   stretch of the selection ring at each corner - see .item::before, which the
   marks share their geometry with. */
.grip {
  position: absolute;
  /* Capped at half the *ring*, not half the item: opposite marks then meet
     exactly at the middle of each side without crossing, and a small item at
     low zoom - where the ring stands off further in world units than the item
     is wide - still gets a box big enough to hold its own curve. */
  width:  min(var(--mark-size), calc(50% + var(--sel-edge)));
  height: min(var(--mark-size), calc(50% + var(--sel-edge)));
  display: none;
  z-index: 2;
}
.item.is-selected .grip { display: block; }
#world.zoom-far .grip { display: none; }

/* The corner mark. Not a separate line near the ring - the *same* line, drawn
   heavier for a stretch either side of each corner.
   The ring's outer edge sits --sel-edge out from the item, so this box is
   inset by exactly that much and its border grows inwards from the same edge,
   over the same radius. The two are flush, and a corner reads as the ring
   swelling. Only the two outward-facing borders are drawn; the inward pair
   would close it into a box floating around the handle. */
.grip:not(.grip-edge)::after {
  content: '';
  position: absolute;
  inset: 0;
  border: calc(var(--sel-corner) * var(--iz)) solid var(--select);
  pointer-events: none;
}
/* The mark follows the corner it traces rather than turning a hard 90 degrees
   against a rounded item. A curve of radius R offset outwards by d is a curve
   of radius R + d, so the two stay concentric - and because both terms are in
   the item's own units, they stay concentric at every zoom, even though the
   offset is a constant size on screen and the item's radius is not. Only the
   corner where the two strokes meet is curved; a radius on the open ends would
   hook them inwards. --radius comes off the whimsy axis, so this rounds and
   squares up along with everything else. */
.grip[data-g="nw"]::after {
  border-right-width: 0; border-bottom-width: 0;
  border-radius: var(--mark-radius) 0 0 0;
}
.grip[data-g="ne"]::after {
  border-left-width: 0; border-bottom-width: 0;
  border-radius: 0 var(--mark-radius) 0 0;
}
.grip[data-g="se"]::after {
  border-left-width: 0; border-top-width: 0;
  border-radius: 0 0 var(--mark-radius) 0;
}
.grip[data-g="sw"]::after {
  border-right-width: 0; border-top-width: 0;
  border-radius: 0 0 0 var(--mark-radius);
}

/* Straddling the corner: the box's outer corner lands exactly on the
   ring's, so the drawn mark and the grab area are the same place. */
.grip[data-g="nw"] { left:  calc(-1 * var(--sel-edge)); top:    calc(-1 * var(--sel-edge)); cursor: nwse-resize; }
.grip[data-g="ne"] { right: calc(-1 * var(--sel-edge)); top:    calc(-1 * var(--sel-edge)); cursor: nesw-resize; }
.grip[data-g="sw"] { left:  calc(-1 * var(--sel-edge)); bottom: calc(-1 * var(--sel-edge)); cursor: nesw-resize; }
.grip[data-g="se"] { right: calc(-1 * var(--sel-edge)); bottom: calc(-1 * var(--sel-edge)); cursor: nwse-resize; }

/* Edge handles are invisible strips running between the corners. Drawing them
   would put eight marks around a small item and make the selection read as a
   dotted ring; leaving them bare keeps the corners as the only marks while
   the sides are still grabbable. They stop short of the corners so a corner
   drag is never stolen by the edge next to it. */
.grip-edge { background: none; }
.grip[data-g="n"], .grip[data-g="s"] {
  left:  min(var(--mark-size), 40%);
  right: min(var(--mark-size), 40%);
  width: auto;
  height: calc(14px * var(--iz));
  cursor: ns-resize;
}
.grip[data-g="n"] { top: calc(-1 * var(--sel-edge)); }
.grip[data-g="s"] { bottom: calc(-1 * var(--sel-edge)); }
.grip[data-g="w"], .grip[data-g="e"] {
  top:    min(var(--mark-size), 40%);
  bottom: min(var(--mark-size), 40%);
  height: auto;
  width: calc(14px * var(--iz));
  cursor: ew-resize;
}
.grip[data-g="w"] { left: calc(-1 * var(--sel-edge)); }
.grip[data-g="e"] { right: calc(-1 * var(--sel-edge)); }

/* ---------------------------------------------------------
   Menu button + sidebar
   --------------------------------------------------------- */

#menu-btn {
  position: fixed;
  top: max(16px, env(safe-area-inset-top));
  left: max(16px, env(safe-area-inset-left));
  z-index: 30;
  width: 42px;
  height: 40px;
  display: grid;
  align-content: center;
  justify-items: center;
  gap: 4px;
  padding: 0;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--radius-sm);
  background: var(--paper-card);
  box-shadow: var(--shadow-1);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
#menu-btn:hover { background: var(--paper-2); border-color: var(--accent); }
#menu-btn span {
  width: 17px;
  height: 1.5px;
  border-radius: var(--radius-xs);
  background: var(--ink);
}
#sidebar.is-open ~ #menu-btn { opacity: 0; pointer-events: none; }

/* A panel of paper laid over the board. Deliberately NOT modal: no scrim, no
   blocked canvas - you can pan, drop and drag with it open, which is the whole
   point of putting the controls on a floating sheet instead of in a dialog. */
#sidebar {
  position: fixed;
  z-index: 40;
  top: max(14px, env(safe-area-inset-top));
  bottom: max(14px, env(safe-area-inset-bottom));
  left: max(14px, env(safe-area-inset-left));
  width: min(var(--sidebar-w), calc(100vw - 28px));
  display: flex;
  flex-direction: column;
  background: var(--paper-card);
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--ogee);
  box-shadow: var(--shadow-2);
  transform: translateX(calc(-100% - 22px));
  opacity: 0;
  visibility: hidden;
  transition: transform var(--dur-base) var(--ease-back),
              opacity var(--dur-fast) var(--ease),
              visibility 0s linear var(--dur-base);
}
#sidebar.is-open {
  transform: none;
  opacity: 1;
  visibility: visible;
  transition-delay: 0s;
}

.side-head {
  position: relative;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 10px;
  padding: 16px 18px 14px;
  border-bottom: 1px solid var(--rule);
}
.wordmark { margin: 0; }
.wordmark h1 {
  margin: 0;
  font-size: 30px;
  font-weight: 400;
  letter-spacing: calc(0.14em * var(--ls-scale));
  line-height: 1;
  color: var(--ink);
}
.wordmark .sub {
  display: block;
  margin-top: 3px;
  font-size: 10px;
  font-style: var(--display-italic);
  letter-spacing: calc(0.22em * var(--ls-scale));
  text-transform: uppercase;
  color: var(--ink-3);
}
#side-close {
  flex: none;
  width: 30px; height: 30px;
  display: grid; place-items: center;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--ink-3);
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease), transform var(--dur-base) var(--ease-back);
}
/* The cross is symmetric about the middle of its viewBox, so the default
   50%/50% origin spins it in place. */
#side-close svg { width: 15px; height: 15px; display: block; }
#side-close:hover { color: var(--accent); background: var(--paper-2); transform: rotate(90deg); }

.side-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 0 18px 16px;
  scrollbar-width: thin;
  scrollbar-color: var(--rule-2) transparent;
}

.side-sec {
  padding: calc(15px * var(--density)) 0;
  border-top: 1px solid var(--rule);
}
.side-sec:first-child { border-top: 0; }
.side-sec h2 {
  margin: 0 0 11px;
  font-size: var(--t-tiny);
  font-weight: 400;
  font-style: var(--display-italic);
  letter-spacing: calc(0.24em * var(--ls-scale));
  text-transform: uppercase;
  color: var(--ink-3);
}

.board-title {
  margin: 0 0 11px;
  font-size: 18px;
  word-break: break-word;
}
.board-title em { color: var(--ink-3); }

.btn-row { display: flex; gap: 8px; margin-bottom: 8px; }
.btn-row:last-child { margin-bottom: 0; }
.btn-row button {
  flex: 1;
  min-height: calc(35px * var(--density));
  padding: 0 12px;
  color: var(--ink);
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--leaf);
  background: var(--paper);
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease),
              transform var(--dur-base) var(--ease-back),
              color var(--dur-fast) var(--ease);
}
/* Every control reacts by the same budget, set by the whimsy axis: it springs
   towards you on hover and squashes under a press at the playful end, and does
   neither at the plain end, without either end needing its own rule. */
.btn-row button:hover {
  background: var(--paper-2);
  border-color: var(--accent);
  color: var(--accent-deep);
  transform: translateY(var(--btn-lift)) scale(var(--btn-grow));
}
.btn-row button:active { transform: scale(var(--btn-press)); background: var(--paper-3); }
.btn-row button.primary {
  background: var(--accent);
  border-color: var(--accent-deep);
  color: var(--accent-fg);
  letter-spacing: calc(0.03em * var(--ls-scale));
}
.btn-row button.primary:hover { background: var(--accent-deep); color: var(--accent-fg); }

.field { display: block; margin-bottom: 11px; }
.field > span {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  font-size: var(--t-small);
  font-style: var(--display-italic);
  color: var(--ink-2);
  margin-bottom: 5px;
}
.field output { font-family: var(--font-mono); font-style: normal; color: var(--ink); }
.field select {
  width: 100%;
  min-height: calc(35px * var(--density));
  padding: 0 10px;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--leaf);
  background: var(--paper);
  cursor: pointer;
}
.field select:hover { border-color: var(--accent); }

/* Ruled slider: a hairline track with a terracotta lozenge riding it. */
.field input[type="range"] {
  width: 100%;
  height: 22px;
  -webkit-appearance: none;
          appearance: none;
  background: transparent;
  cursor: pointer;
}
.field input[type="range"]::-webkit-slider-runnable-track {
  height: 3px;
  border-radius: 2px;
  background: linear-gradient(90deg, var(--rule-2), var(--rule));
}
.field input[type="range"]::-moz-range-track {
  height: 3px;
  border-radius: 2px;
  background: linear-gradient(90deg, var(--rule-2), var(--rule));
}
.field input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 15px; height: 15px;
  margin-top: -6px;
  border: 1.5px solid var(--paper-card);
  border-radius: var(--radius-xs);
  background: var(--accent);
  transform: rotate(45deg);
}
.field input[type="range"]::-moz-range-thumb {
  width: 13px; height: 13px;
  border: 1.5px solid var(--paper-card);
  border-radius: var(--radius-xs);
  background: var(--accent);
}
/* Colour wells.
   A native colour input paints its swatch as an inner element that ignores the
   host's border-radius, so a rounded well ends up with a square block of
   colour poking into its corners. The two pseudo-elements below are the only
   way to reach that inner element: drop its inset and its own border, then
   give it the concentric inner radius (outer minus the frame it sits in). */
.field input[type="color"] {
  -webkit-appearance: none;
          appearance: none;
  width: 100%;
  height: calc(30px * var(--density));
  padding: var(--well-pad);
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--radius-sm);
  background: var(--paper);
  cursor: pointer;
  --well-pad: 3px;
  --well-radius: max(0px, calc(var(--radius-sm) - var(--well-pad)));
}
.field input[type="color"]:hover { border-color: var(--accent); }
.field input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
.field input[type="color"]::-webkit-color-swatch {
  border: none;
  border-radius: var(--well-radius);
}
.field input[type="color"]::-moz-color-swatch {
  border: none;
  border-radius: var(--well-radius);
}

/* Checkboxes as inked boxes with a hand-drawn tick. */
.check {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 5px 0;
  cursor: pointer;
}
.check input {
  -webkit-appearance: none;
          appearance: none;
  flex: none;
  width: 17px; height: 17px;
  margin: 0;
  border: 1.5px solid var(--rule-2);
  border-radius: var(--radius-xs);
  background: var(--paper);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.check input:hover { border-color: var(--accent); }
.check input:checked {
  background-color: var(--accent);
  border-color: var(--accent-deep);
  /* A real tick, as an SVG.
     It was two clipped diagonal gradient bars before, which is a neat trick and
     a bad checkmark: each bar is an infinite 45-degree line cropped to a box,
     so the two strokes meet only at one exact size and drift apart into a pair
     of disconnected slashes at any other. An SVG scales.
     The stroke is white rather than var(--accent-fg) because a data URI cannot
     read a custom property - and every palette's --accent-fg is a near-white
     that is indistinguishable from it on a saturated chip this small. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='white' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.6 8.4 6.6 11.4 12.4 4.9'/%3E%3C/svg%3E");
  background-size: 100% 100%;
  background-repeat: no-repeat;
}

.hint {
  margin: 9px 0 0;
  font-size: var(--t-small);
  font-style: var(--display-italic);
  line-height: 1.5;
  color: var(--ink-3);
}
.hint em { font-style: normal; color: var(--accent); }

.keys { margin: 0; padding: 0; list-style: none; font-size: var(--t-small); color: var(--ink-2); }
.keys li {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  align-items: baseline;
  padding: 4px 0;
  font-style: var(--display-italic);
}
.keys li span { color: var(--ink-3); text-align: right; }
kbd {
  font-family: var(--font-mono);
  font-style: normal;
  font-size: 10px;
  padding: 2px 6px;
  border: 1px solid var(--rule-2);
  border-bottom-width: 2px;
  border-radius: var(--radius-xs);
  background: var(--paper);
  color: var(--ink);
  white-space: nowrap;
}

.side-foot {
  padding: 10px 18px calc(12px + env(safe-area-inset-bottom));
  border-top: 1px solid var(--rule);
  font-size: var(--t-tiny);
  font-style: var(--display-italic);
  letter-spacing: calc(0.1em * var(--ls-scale));
  color: var(--ink-3);
  display: flex;
  /* The version is the only thing down here now, and it stays on the right
     where it has always been - space-between would have walked it to the left
     the moment its neighbour went away. */
  justify-content: flex-end;
}

/* ---------------------------------------------------------
   HUD, drop overlay, toast
   --------------------------------------------------------- */

/* The corner stack: zoom controls over the coordinate readout. */
#corner {
  position: fixed;
  z-index: 20;
  right: max(16px, env(safe-area-inset-right));
  bottom: max(16px, env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 8px;
  user-select: none;
}

#zoom-ctl {
  display: flex;
  align-items: stretch;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--leaf);
  overflow: hidden;
  background: var(--paper-card);
  box-shadow: var(--shadow-1);
}
#zoom-ctl button {
  display: grid;
  place-items: center;
  min-width: 34px;
  height: 32px;
  padding: 0 7px;
  border: 0;
  background: none;
  color: var(--ink-2);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
#zoom-ctl button + button { border-left: 1px solid var(--rule); }
#zoom-ctl button:hover { background: var(--paper-2); color: var(--accent-deep); }
#zoom-ctl button:active { background: var(--paper-3); }
#zoom-ctl button:disabled { color: var(--ink-3); cursor: default; background: none; }
#zoom-ctl svg {
  width: 15px; height: 15px;
  /* The glyph takes the reaction, not the button: scaling the button itself
     would drag the hairline dividers between them around. */
  transition: transform var(--dur-base) var(--ease-back);
}
#zoom-ctl button:hover:not(:disabled) svg { transform: scale(var(--btn-grow)) translateY(var(--btn-lift)); }
#zoom-ctl button:active:not(:disabled) svg { transform: scale(calc(var(--btn-press) - 0.08)); }
/* The readout doubles as the reset-to-100% button, so it needs to be wide
   enough that 12% and 1600% don't shove the + and - around as you zoom. */
#zoom-level {
  min-width: 58px;
  font-family: var(--font-mono);
  font-size: var(--t-tiny);
  color: var(--ink);
  letter-spacing: calc(0.02em * var(--ls-scale));
}
/* A hairline gap before the fit/home pair - they move the view, not the zoom. */
#zoom-ctl button[data-zoom="fit"] { border-left-width: 3px; border-left-style: double; }

#hud {
  display: flex;
  align-items: stretch;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--leaf);
  overflow: hidden;
  background: var(--paper-card);
  box-shadow: var(--shadow-1);
  font-family: var(--font-mono);
  font-size: var(--t-tiny);
  color: var(--ink-2);
  pointer-events: none;
}
#hud span { padding: 6px 11px; }
#hud span + span { border-left: 1px solid var(--rule); }
#hud-xy { color: var(--accent-deep); }
#hud[hidden] { display: none; }

/* ---------------------------------------------------------
   The bin (ui/trash.js)
   --------------------------------------------------------- */

#bin {
  position: fixed;
  z-index: 20;
  left: max(16px, env(safe-area-inset-left));
  bottom: max(16px, env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
  user-select: none;
  transition: opacity var(--dur-base) var(--ease), visibility 0s;
}

/* The panel opens over this corner, so the bin gets out of the way - the same
   move #menu-btn makes, and for the same reason.

   visibility, not opacity alone: a button faded to nothing is still in the tab
   order and still announced, so it would be a control you can reach but cannot
   see. Delaying it by the fade's own duration lets it hide only once it has
   finished fading, and drop instantly on the way back in. The whole #bin goes
   rather than just the button, so a panel left open goes with it - and comes
   back open when the sidebar closes, which is what leaving it open meant. */
#sidebar.is-open ~ #bin {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity var(--dur-base) var(--ease), visibility 0s var(--dur-base);
}

#bin-btn {
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--leaf);
  background: var(--paper-card);
  box-shadow: var(--shadow-1);
  color: var(--ink-2);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease);
}
/* An empty bin has nothing to offer, and says so by receding: the faintest ink
   and the fainter of the two hairlines, so it sits on the glass at about the
   weight of the board's own furniture rather than asking to be pressed. It is
   deliberately not the disabled treatment used elsewhere (no dimmed cursor, no
   half opacity) - the button still works, and pressing it opens a panel that
   tells you in words that there is nothing in there.

   This has to come before :hover, which it ties with on specificity: source
   order is what lets the hover colour win over the muted one. */
#bin-btn:not(.has-things) { color: var(--ink-3); border-color: var(--rule); }
#bin-btn:hover { background: var(--paper-2); color: var(--accent-deep); }
/* Hovering an empty bin brings its edge back up to the normal hairline, so the
   pointer gets the same "this is a control" answer it gets from a full one. */
#bin-btn:not(.has-things):hover { border-color: var(--rule-2); }
#bin-btn svg {
  width: 17px; height: 17px;
  transition: transform var(--dur-base) var(--ease-back);
}
#bin-btn:hover svg { transform: scale(var(--btn-grow)) translateY(var(--btn-lift)); }
/* Something is in there. Not a number in a badge - the whole button simply
   comes up to full strength, the way a physical bin tells you it is filling up
   by being visibly not empty. The icon inherits currentColor, so this carries
   the state without anything extra being drawn on top of it. */
#bin-btn.has-things { color: var(--accent-deep); border-color: var(--accent); }

/* Open while a drag is in flight: you are dragging *out* of the panel and onto
   the board, so the panel must not shut the moment the pointer leaves it. */
#bin-panel {
  width: 268px;
  max-height: min(52vh, 420px);
  display: flex;
  flex-direction: column;
  padding: 12px;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--radius-sm);
  background: var(--paper-card);
  box-shadow: var(--shadow-2);
}
#bin-panel[hidden] { display: none; }
#bin-panel header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 9px;
}
#bin-panel h2 {
  margin: 0;
  font-size: var(--t-small);
  font-style: var(--display-italic);
  font-weight: 400;
  letter-spacing: calc(0.1em * var(--ls-scale));
  text-transform: uppercase;
  color: var(--ink-2);
}
#bin-empty {
  padding: 2px 7px;
  border: var(--hairline) solid transparent;
  border-radius: var(--radius-xs);
  background: none;
  color: var(--danger);
  font-size: var(--t-tiny);
  cursor: pointer;
}
#bin-empty:hover { border-color: var(--danger); }
#bin-empty:disabled { color: var(--ink-3); cursor: default; border-color: transparent; }

#bin-list {
  display: flex;
  flex-direction: column;
  gap: 5px;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--rule-2) transparent;
}
#bin-none, #bin-hint {
  margin: 0;
  font-size: var(--t-tiny);
  font-style: var(--display-italic);
  color: var(--ink-3);
}
#bin-hint { margin-top: 9px; }
#bin-none[hidden], #bin-hint[hidden] { display: none; }

.bin-item {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 5px;
  border: var(--hairline) solid transparent;
  border-radius: var(--radius-xs);
  cursor: grab;
  touch-action: none;
}
.bin-item:hover { border-color: var(--rule-2); background: var(--paper-2); }
.bin-item.is-lifting { opacity: 0.4; cursor: grabbing; }
.bin-thumb {
  flex: none;
  width: 38px;
  height: 38px;
  display: grid;
  place-items: center;
  overflow: hidden;
  border: var(--hairline) solid var(--rule);
  border-radius: var(--radius-xs);
  background: var(--paper-2);
  font-size: 8px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-3);
}
.bin-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.bin-thumb.is-note { background: var(--note-1); color: var(--ink-2); }
.bin-name {
  flex: 1;
  min-width: 0;
  font-size: var(--t-tiny);
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.bin-when { flex: none; font-size: 10px; color: var(--ink-3); font-family: var(--font-mono); }

/* The thing under the pointer while it is being carried back to the board. */
#bin-ghost {
  position: fixed;
  z-index: 60;
  width: 54px;
  height: 54px;
  margin: -27px 0 0 -27px;
  overflow: hidden;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--radius-xs);
  background: var(--paper-card);
  box-shadow: var(--shadow-2);
  pointer-events: none;
  rotate: -4deg;
}
#bin-ghost img { width: 100%; height: 100%; object-fit: cover; display: block; }

#drop-overlay {
  position: fixed;
  inset: 0;
  z-index: 25;
  display: grid;
  place-items: center;
  background: color-mix(in srgb, var(--accent-warm) 14%, transparent);
  pointer-events: none;
}
/* An explicit display value beats the UA's [hidden] rule, so it needs saying. */
#drop-overlay[hidden] { display: none; }
#drop-overlay div {
  padding: 16px 30px;
  border: 2px dashed var(--accent);
  border-radius: var(--ogee);
  background: var(--paper-card);
  box-shadow: var(--shadow-2);
  font-size: 19px;
  font-style: var(--display-italic);
  color: var(--accent-deep);
  animation: bloom var(--dur-base) var(--ease-back);
}
@keyframes bloom { from { opacity: 0; transform: scale(0.94) rotate(-1deg); } }

/* ---------------------------------------------------------
   Right-click menu
   --------------------------------------------------------- */

/* ---------------------------------------------------------
   Find (Ctrl+K)

   Pinned near the top rather than centred, and that is the one layout decision
   here worth defending: the answers are places on the board, and the moment a
   row is picked the viewport flies to it. A panel in the middle of the screen
   would be sitting on the very thing it is about to show you. Up here it takes
   the strip of board you are least likely to be looking at, and gives it back.
   --------------------------------------------------------- */
#search {
  position: fixed;
  z-index: 70;               /* over the context menu, which it can be opened from */
  top: 12vh;
  left: 50%;
  translate: -50% 0;
  width: min(560px, calc(100vw - 32px));
  padding: 6px;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--ogee);
  background: var(--paper-card);
  box-shadow: var(--shadow-2);
  animation: ctx-in var(--dur-fast) var(--ease);
}
#search-field {
  width: 100%;
  padding: 11px 13px;
  border: 0;
  border-radius: calc(var(--ogee) - 6px);
  background: none;
  font-family: var(--font-display);
  font-size: var(--t-title);
  color: var(--ink);
}
#search-field::placeholder { color: var(--ink-3); font-style: var(--display-italic); }
#search-field:focus { outline: none; }
/* Cleared by pressing Escape, which this palette already answers to. The
   engine's own X is a second, differently-shaped affordance for the same verb,
   drawn in a style no token here can reach. */
#search-field::-webkit-search-cancel-button { display: none; }

#search-hits {
  max-height: min(46vh, 420px);
  overflow-y: auto;
  overscroll-behavior: contain;
}
#search-hits:not(:empty) {
  margin-top: 6px;
  padding-top: 6px;
  border-top: var(--hairline) solid var(--rule);
}

.search-hit {
  display: grid;
  /* The kind column is fixed so the names line up down the list - a ragged
     left edge on a list you are scanning is what makes you read it twice.
     Wide enough for "generic", which is the longest of the seven and was
     coming out as "GENER…". */
  grid-template-columns: 68px 1fr;
  align-items: baseline;
  gap: 2px 10px;
  width: 100%;
  padding: 7px 9px;
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  text-align: left;
  cursor: pointer;
}
.search-kind {
  grid-row: 1 / span 2;
  font-family: var(--font-display);
  font-size: var(--t-tiny);
  font-style: var(--display-italic);
  letter-spacing: calc(0.14em * var(--ls-scale));
  text-transform: uppercase;
  color: var(--accent);
  overflow: hidden;
  text-overflow: ellipsis;
}
.search-name {
  font-family: var(--font-display);
  font-size: var(--t-body);
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.search-where {
  font-size: var(--t-small);
  color: var(--ink-3);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* One highlight, driven by the keyboard, and hover only *shows* it rather than
   moving it - the pointer is usually resting wherever it was left, and letting
   that win would drag the selection back every time the list reflowed. */
.search-hit.is-at { background: var(--paper-2); }
.search-hit:hover { background: var(--paper-3); }
.search-hit:focus-visible { outline: var(--sel-line) solid var(--select); outline-offset: -2px; }

.search-note {
  margin: 0;
  padding: 9px 10px 10px;
  font-size: var(--t-small);
  font-style: var(--display-italic);
  color: var(--ink-3);
}

#ctx-menu {
  position: fixed;
  z-index: 60;
  min-width: 208px;
  padding: 5px;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--radius);
  background: var(--paper-card);
  box-shadow: var(--shadow-2);
  animation: ctx-in var(--dur-fast) var(--ease);
  user-select: none;
}
@keyframes ctx-in { from { opacity: 0; transform: translateY(-3px); } }

.ctx-item {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 20px;
  width: 100%;
  padding: 7px 10px;
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--ink);
  font-size: var(--t-small);
  text-align: left;
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.ctx-item:hover,
.ctx-item:focus-visible {
  background: var(--paper-2);
  color: var(--accent-deep);
  outline: none;
}
.ctx-item.is-danger:hover,
.ctx-item.is-danger:focus-visible { color: var(--danger); }
.ctx-item kbd {
  border: 0;
  background: none;
  padding: 0;
  color: var(--ink-3);
  font-size: 10px;
}
/* Toggle entries reserve a tick column, so the label does not shift sideways
   when the option flips on. Plain entries have no column at all. */
.ctx-item.is-toggle > span::before {
  content: "";
  display: inline-block;
  width: 15px;
  color: var(--accent);
}
.ctx-item.is-toggle.is-checked > span::before { content: "\2713"; }

.ctx-sep {
  height: 1px;
  margin: 5px 8px;
  background: var(--rule);
}

/* A slip of paper, pinned briefly at the foot of the sheet. */
#toast {
  position: fixed;
  z-index: 50;
  left: 50%;
  bottom: max(24px, env(safe-area-inset-bottom));
  transform: translateX(-50%) rotate(-0.5deg);
  max-width: min(520px, 90vw);
  padding: 10px 20px;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--leaf);
  background: var(--paper-card);
  box-shadow: var(--shadow-2);
  font-size: var(--t-small);
  font-style: var(--display-italic);
  animation: rise var(--dur-base) var(--ease-back);
  /* Paired with TOAST_FADE_MS in util.js, which is what decides when the
     element is actually hidden. Kept as a literal on both sides rather than a
     token: a duration the axis could flatten to zero would leave the JS timer
     holding an invisible toast on screen for its own 300ms, and the two
     disagreeing is worse than neither following the axis. */
  transition: opacity 300ms var(--ease);
}
#toast.is-going { opacity: 0; }
#toast.is-error { border-color: var(--danger); color: var(--danger); }
@keyframes rise { from { opacity: 0; transform: translate(-50%, 10px) rotate(-3deg); } }

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

/* ---------------------------------------------------------
   Whimsy axis - the parts a token cannot express

   Almost everything the slider moves is a value, and values live in
   tokens.css. What is left here is the handful of decisions that are
   *structural*: an ornament that has to stop existing rather than shrink to
   zero, and a couple of shapes that are drawn rather than declared.

   The middle needs none of it: it keeps its ornaments and just turns them
   down, which the tokens already do on their own. Both ends have one thing
   each that a number cannot say - the plain end wants ornaments gone rather
   than small, and the soft end wants a photograph to become a print.
   --------------------------------------------------------- */

/* ---- 0: softish - a photograph becomes a print ---------------------------
   White stock all round the picture, deeper at the foot, and the caption
   written on that chin where a caption belongs. The tier was already most of
   the way there: --tilt-max is 3deg at this end, so the cards sit crooked and
   the scattered-prints reading was there with only the frame missing.

   The mat is drawn *inside* the item's own box, by insetting the picture -
   not by growing the card, and not on a pseudo-element hanging outside it.
   Both of those were considered and both are worse. Growing the card would
   mean the whimsy slider silently resized every item on a saved board, so
   sliding to this end and back would not return you to the board you started
   with. Drawing the mat outside would leave the white border outside the
   item's hit area: the visible edge of a photograph would not be draggable,
   the selection ring and all eight resize handles would trace the picture
   rather than the print, and the item's shadow would fall from the picture's
   edge into the middle of its own frame.

   What insetting costs, said plainly: the picture keeps object-fit: cover and
   the inner box is proportionally wider than the item, so a photograph loses
   roughly a tenth of its height to the crop. That is the trade, and it is a
   small one on a board that already crops on every resize.

   Every measure is a fraction of --half-min (min(w, h) / 2, per item from
   canvas/items.js/place()). A fraction rather than a fixed border because a
   print scales as a print: 12px of mat is a frame on a thumbnail and a
   hairline on a photograph two thousand across. Taking it off the shorter
   side is what makes the mat the same real width on all four edges. */
:root[data-whimsy="0"] .item[data-type="image"] {
  --mat:  calc(var(--half-min, 120px) * 0.08);
  --chin: calc(var(--half-min, 120px) * 0.26);
  /* A print has a cut edge, not a mathematical one - so not zero, but a long
     way under this level's 26px, which would read as a rounded sticker. A
     fraction of --radius rather than a literal, so the Corner radius slider
     still moves it along with everything else. */
  --shape-radius: calc(var(--radius) * 0.12);
}
/* Absolute, positioned off the top-left corner and *sized* to the rest, rather
   than stretched between four insets.
   That distinction is the whole rule. An `inset: mat; bottom: chin` with auto
   width and height is the obvious way to write this and it does not work: an
   <img> is a replaced element, and an absolutely positioned replaced box with
   auto width resolves it to the picture's own intrinsic size rather than to
   the gap between the offsets - which leaves `right` and `bottom` merely
   over-constrained and dropped. The picture then hangs off the corner at its
   natural size and .item-body's clip cuts it at the item's edge, so the mat
   comes out on the top and left only and the caption is back over the photo.
   Widths written out are unambiguous for a replaced box.
   Both pictures of an animated pair are matched, so a frozen GIF sits in its
   frame exactly where the moving one did. */
:root[data-whimsy="0"] .item[data-type="image"] .item-body img {
  position: absolute;
  left: var(--mat);
  top:  var(--mat);
  width:  calc(100% - var(--mat) * 2);
  height: calc(100% - var(--mat) - var(--chin));
  border-radius: 0;
  /* A pale photograph on pale stock has no edge of its own. This is the one
     the print would have. */
  box-shadow: 0 0 0 var(--hairline) color-mix(in srgb, var(--ink) 10%, transparent);
}
/* The caption plate stops being a strip laid across the foot of the picture
   and becomes what is written on the chin. It needs no moving to get there:
   .item-label is a child of .item-body and positions against *its* edges, not
   the picture's, so insetting the picture has already left it sitting on the
   mat. All it has to do is stop looking like a plate - the translucent paper,
   the rule and the blur were all there to hold it off a photograph, and there
   is no photograph behind it now.

   line-height rather than a flex centre, because the plate keeps its ellipsis
   and its single line, and both of those want it to stay a block. */
:root[data-whimsy="0"] .item[data-type="image"] .item-label {
  height: var(--chin);
  line-height: var(--chin);
  padding: 0 var(--mat);
  text-align: center;
  background: none;
  border-top: 0;
  backdrop-filter: none;
  font-size: clamp(11px, calc(var(--half-min, 120px) * 0.09), 20px);
}

/* A card at radius 0 with a hairline border already reads as ruled - a second
   rule inside it is just noise. */
/* The origin stops being a compass rose and becomes a registration mark: the
   ring squares off to match the crosses the lattice is drawn with at this end
   of the axis, and the pip with it. The square pair is inert everywhere else. */
#origin-mark .om-ring-sq,
#origin-mark .om-pip-sq { display: none; }
:root[data-whimsy="2"] #origin-mark .om-ring,
:root[data-whimsy="2"] #origin-mark .om-pip { display: none; }
:root[data-whimsy="2"] #origin-mark .om-ring-sq,
:root[data-whimsy="2"] #origin-mark .om-pip-sq { display: inline; }

:root[data-whimsy="2"] .card::after { content: none; }

/* Stickies go flat: no adhesive band showing through, just a ruled edge. */
:root[data-whimsy="2"] .item[data-type="note"] .card {
  background: var(--note-paper);
  box-shadow: inset 0 0 0 var(--hairline) var(--ink);
}

/* Lozenges become squares. Same target size, no tilt. */
:root[data-whimsy="2"] .grip,
:root[data-whimsy="2"] .field input[type="range"]::-webkit-slider-thumb { transform: none; }

/* The toast sits straight rather than tossed onto the page. */
:root[data-whimsy="2"] #toast {
  transform: translateX(-50%);
  animation-name: rise-plain;
}
@keyframes rise-plain { from { opacity: 0; transform: translate(-50%, 6px); } }

/* The close button stops spinning; it still lights up. */
:root[data-whimsy="2"] #side-close:hover { transform: none; }

/* The stop labels under the whimsy slider. Not a legend - the ends of a scale,
   which is the only way a three-position slider says what it does. */
.field-stops {
  display: flex;
  justify-content: space-between;
  margin-top: 3px;
  color: var(--ink-3);
  font-size: var(--t-tiny);
  font-style: var(--display-italic);
}
.field-stops span:nth-child(2) { color: var(--ink-2); }
