/* =========================================================
   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;
}

/* The paper outline, positioned in screen space by canvas/paper.js for the same
   reason the axes are: a border inside the transformed #world would be scaled
   by the zoom, and a page edge is one device pixel at every zoom or it is not a
   drawn edge. The faint fill is what makes it read as a surface rather than as
   four more rules on a board that already has plenty. */
#paper {
  position: absolute;
  border: var(--hairline) solid var(--sheet-line);
  background: var(--sheet-fill);
  pointer-events: none;
}

/* The four corner grips, which is how the board's scale is actually set: drag
   an A4 until it looks right against the photographs, and the scale follows
   from where you let go. See canvas/paper.js.

   pointer-events go back on for these alone - the sheet itself must stay
   transparent to the pan, the marquee and every card sitting on it, and only
   these four squares are allowed to intercept anything. */
.paper-grip {
  position: absolute;
  width: var(--sheet-grip);
  height: var(--sheet-grip);
  margin: calc(var(--sheet-grip) / -2) 0 0 calc(var(--sheet-grip) / -2);
  box-sizing: border-box;
  border: var(--hairline) solid var(--sheet-line);
  border-radius: var(--radius-xs);
  background: var(--paper-card);
  pointer-events: auto;
  touch-action: none;
  transition: background var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease),
              transform var(--dur-base) var(--ease-back);
}
.paper-grip:hover {
  background: var(--accent);
  border-color: var(--accent-deep);
  transform: scale(1.25);
}
/* Placed by their own corner so the margin above centres each one on the edge
   it belongs to, rather than hanging off it. */
.paper-grip[data-grip="nw"] { left: 0; top: 0; cursor: nwse-resize; }
.paper-grip[data-grip="ne"] { left: 100%; top: 0; cursor: nesw-resize; }
.paper-grip[data-grip="sw"] { left: 0; top: 100%; cursor: nesw-resize; }
.paper-grip[data-grip="se"] { left: 100%; top: 100%; cursor: nwse-resize; }
/* A sheet too small on screen for four separate targets. The outline stays -
   it is legible as a rectangle long before it is grabbable - and only the
   handles go, rather than the whole thing disappearing at a zoom threshold. */
#paper.no-grips .paper-grip { display: none; }

/* Held for the length of the drag, so the resize cursor survives the pointer
   leaving the eleven pixels it started in - which it does immediately. */
:root.is-sizing-paper,
:root.is-sizing-paper * { cursor: nwse-resize !important; }
/* Outside the sheet, above its top-left corner, so it labels the edge without
   sitting on the area the edge is drawn to enclose - the one part of the board
   that has to stay clear for what is being laid out on it. */
#paper-label {
  position: absolute;
  bottom: 100%;
  left: 0;
  margin-bottom: 4px;
  font-size: var(--t-tiny);
  font-variant-numeric: tabular-nums;
  letter-spacing: calc(0.04em * var(--ls-scale));
  color: var(--ink-3);
  white-space: nowrap;
}

/* World axes, drawn in screen space so they stay hairline at any zoom - and on
   a canvas, in whole device pixels, so a hairline is a hairline rather than a
   fraction of a row the rasteriser is free to round away. canvas/grid.js draws
   it; index.html says why it is not two <div>s. Sized in device pixels there
   and stretched back to CSS ones here, exactly like #grid-ink. */
#axis-ink {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* Mobile is still the same infinite canvas, but interaction is constrained to
   this six-column strip. viewport.js keeps these screen-space edges aligned
   with the fixed world width as the window changes size. */
#mobile-board-frame {
  display: none;
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--mobile-board-left, 0);
  width: var(--mobile-board-width, 0);
  box-sizing: border-box;
  border-inline: var(--hairline) dashed var(--grid-axis);
  background: color-mix(in srgb, var(--paper-card) 8%, transparent);
  box-shadow: 0 0 0 100vmax color-mix(in srgb, var(--ink) 4%, transparent);
  pointer-events: none;
}
#mobile-board-ceiling {
  display: none;
  position: absolute;
  inset: 0 0 auto;
  height: max(0px, var(--mobile-board-top, 0px));
  background: color-mix(in srgb, var(--ink) 4%, transparent);
  pointer-events: none;
}
#mobile-board-ceiling::after {
  content: "";
  position: absolute;
  left: var(--mobile-board-left, 0);
  bottom: 0;
  width: var(--mobile-board-width, 0);
  border-top: var(--hairline) dashed var(--grid-axis);
  opacity: var(--mobile-board-ceiling-opacity, 0);
}
:root[data-board-mode="mobile"] #mobile-board-frame,
:root[data-board-mode="mobile"] #mobile-board-ceiling { display: block; }
:root[data-board-mode="mobile"] #axis-ink,
:root[data-board-mode="mobile"] #origin-mark,
:root[data-board-mode="mobile"] #web { display: none !important; }

/* 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));
  /* The three-dot handle's width, named because two things need it: the handle
     itself, and anything drawn along the foot of a card that has to stop before
     reaching it. It is a corner of every card that is already spoken for. */
  --handle-w: 30px;
  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, collapsed flat. A card that is not selected
     wears its ring against its own edge, at no thickness, where nothing can be
     seen and nothing has to be worked out - see .item.is-selected below, which
     is where these three mean something and why they are not simply written
     there once. */
  --sel-edge: 0px;
  --mark-radius: var(--own-radius);
  --mark-size: 0px;
  /* 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);
}
/* Snapped to the grid: nothing leans, at any whimsy tier.

   A lattice is a promise about edges, and a card resting a degree and a half
   off square breaks it more visibly than being a few pixels out of place ever
   did - two neighbours in adjacent cells make a wedge between them that reads
   as a mistake in the snapping rather than as charm. So the same switch that
   lines the board up also stands the cards up.

   `rotate` outright rather than zeroing --tilt-max: the tilt tokens are on the
   Appearance panel's list and can be written inline on :root, which no selector
   can outrank. This is on .item instead, where nothing inline competes - and it
   still leaves item.rot alone, which lives in `transform` (see below). It also
   inherits the rotate transition above, so switching snapping on straightens
   the board rather than jumping it. */
:root[data-snap] .item { rotate: 0deg; }

/* 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;
  /* The delays are what let the ring stand off the card while it is being worn
     and lie flat against it the rest of the time, without the change ever being
     seen. A transition is written on the state it is going *to*, so this rule
     governs the trip back to unselected: the border thins over --dur-fast, and
     the offset and the curve are held at their old values until it is over, then
     drop. By that point the border has no width, so there is nothing on screen
     for the drop to move. The rule below has no such delay, so selecting still
     puts the ring in its proper place at once and merely thickens it. */
  transition: border-width var(--dur-fast) var(--ease),
              inset 0s var(--dur-fast),
              border-radius 0s var(--dur-fast);
}
.item.is-selected::before {
  border-width: calc(var(--sel-line) * var(--iz));
  transition: border-width var(--dur-fast) var(--ease);
}

/* The ring's geometry, in two versions of the same three names.
     --sel-edge    how far the ring's outer edge sits from the item box
     --mark-radius the curve both it and the corner marks 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
                   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.

   The unselected version is the whole point of the split, and it is a
   performance one. Every one of these is written in terms of --iz, which changes
   on every frame of a zoom - so a card that is not selected was re-deriving
   three values it does not use, and re-laying out a pseudo-element whose offset
   is one of them, for every card on the board sixty times a second. Collapsed to
   constants, an unselected card has nothing here that the zoom can move, and its
   ring simply lies flat against its edge where it cannot be seen. The version
   that means something arrives with the selection, which is also the only time
   anything reads it: the ring and the grips, and neither is drawn otherwise. */
.item.is-selected {
  --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));
}

.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; }

/* The zoomed-out stand-in. Two things arrive here by the same door: a frozen
   GIF frame shot by canvas/stills.js, and a photograph's hundred-pixel
   thumbnail made at import. Both are a sibling <img class="still">, and both
   are swapped in by the same pair of rules - see the note in the image
   renderer.

   is-ready, not merely [src]: the attribute lands the moment it is assigned but
   the bytes still have to decode, and swapping then would show a blank where
   the picture was. Until it is ready the full picture stays up, which is the
   harmless failure in both cases.

   The primary is matched as :not(.still) rather than by [data-gif], so a still
   photograph gets the swap as well as an animated one. */
.item-body img.still { display: none; }
#world.is-stilled .item-body img.still.is-ready { display: block; }
#world.is-stilled .item-body img:not(.still):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%;
  /* Named, because the sleeve on an audio card is positioned against the
     padding box and has to sit *inside* the padding rather than on its edge.
     Absolute positioning measures from that edge, so the inset has to be the
     padding itself - and a number repeated in two rules is a number that drifts
     apart in two rules. */
  --card-pad-y: 15px;
  --card-pad-x: 16px;
  padding: var(--card-pad-y) var(--card-pad-x);
  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.

   Not on an audio card, where the sleeve is a stamp in the corner rather than
   the top half of the card: there the kicker is still the only thing naming the
   type, and hiding it made a row of records where some said AUDIO and some did
   not, for a reason - whether the file happened to carry artwork - that is
   invisible from the outside. */
.card:not(.card-audio).has-cover .card-icon { display: none; }

/* An audio card's sleeve: square, and up in the corner.

   Square because that is what a record is, and because the alternative is what
   this used to do - hand the picture a full-width band and let it be whatever
   shape it liked. Album art is square often enough that the band was mostly
   empty card, and the one time it is not - a wide still off a video, a tall
   poster - a card of this kind still wants a stamp rather than a header.

   Cropped to the square rather than fitted inside it. A sleeve is legible from
   its middle; letterboxing it would put the white bars back that the band had.

   Out of the flow, so the words keep the full height of the card and the
   transport stays where it was. The room it needs is bought back as padding on
   the three text lines below, which is what keeps a long title from running
   under it. */
.card-audio .card-cover {
  position: absolute;
  top: var(--card-pad-y);
  right: var(--card-pad-x);
  width: var(--sleeve);
  height: var(--sleeve);
  flex: none;
}
.card-audio {
  /* Half the card's short side, so the sleeve is a stamp on a big card and most
     of a small one, with a floor and a ceiling: below about 40px it is a smudge,
     and above 108 it stops being a corner and starts being the card. */
  --sleeve: clamp(40px, calc(var(--half-min, 100px) * 0.5), 108px);
  /* The ⋮ is not part of the card but sits over its bottom-right corner - it is
     the item's own handle and every type has it - and on a card whose last row
     is a control of its own the two crowded each other. This used to be settled
     by reserving 16px of *height* at the foot, which worked and cost the one
     thing an audio card has least of: the transport is pinned to the bottom by
     margin-top: auto, so the reservation showed as an empty band under the
     waveform on every card, and it is height that decides what the card can
     still afford to say about itself.
     The room is taken sideways instead, the way a video's transport does it -
     the bar stops short of the corner and the handle stands beside the time
     rather than under it. Same collision, same fix, and 16px of card back. */
  padding-bottom: var(--card-pad-y);
  /* Queried by the rules further down, which is how the card decides what to
     drop as it gets shorter. Its own size is fixed by the item it fills, so
     containing it costs nothing: `size` containment says the box does not
     depend on its contents, and this one never did. */
  container: audio-card / size;
}
.card-audio.has-cover .card-icon,
.card-audio.has-cover .card-name,
.card-audio.has-cover .card-meta {
  padding-right: calc(var(--sleeve) + 10px);
}

/* The title is clamped rather than left to wrap.
   The transport is pinned to the foot of the card by `margin-top: auto`, which
   only works while there is a foot left to pin it to: a name that wrapped to
   five or six lines pushed the play button out through the bottom of the card,
   which clips. So it gets a line budget and an ellipsis after it. */
.card-audio .card-name {
  flex: 0 1 auto;
  min-height: 0;
  overflow: hidden;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
          line-clamp: 3;
}
/* The transport keeps its height whatever else is on the card, and stops short
   of the corner the handle owns - see the padding note on .card-audio above. */
.card-audio .transport {
  flex: none;
  padding-right: var(--handle-w);
}

/* What a shrinking card gives up, and in what order.

   The name is the card. Everything else on it is either recoverable from
   somewhere else or not worth the room, and the name keeps the space they leave
   instead of being clamped to one line while a byte count sits underneath it.

   The kicker goes first. It reads AUDIO over a play button, a waveform and a
   running time, on a card shaped like nothing else on the board - it is the one
   line here that says something already said three times. The size and the
   format are a footnote, but they are a footnote nothing else on the card
   carries, so they outlast it. That is the reverse of the order this dropped
   them in for a long time, and the reason given for that order was the argument
   against it: the kicker was described as the redundant one and then kept the
   longer.

   Heights rather than widths, because it is height the layout runs out of -
   the title is what grows when a card is narrowed, and the transport at the
   foot is a fixed strip that the growth pushes against.

   Written with the same selectors as the rules above rather than the short
   ones they could be: a container query carries no specificity of its own, so
   `.card-name` inside one loses to `.card-audio .card-name` outside it and the
   clamp would never move. */
@container audio-card (max-height: 190px) {
  .card-audio .card-icon { display: none; }
}
@container audio-card (max-height: 148px) {
  .card-audio .card-name { -webkit-line-clamp: 2; line-clamp: 2; }
}
/* Just under the clamp above, and that is deliberate rather than a near miss.
   By this height the name is on two lines, not three, which hands back a line's
   worth of room - so the footnote survives a little way past the point a
   three-line card would have had to drop it. Below here a two-line name, a
   footnote and the transport genuinely do not fit between the paddings. */
@container audio-card (max-height: 140px) {
  .card-audio .card-meta { display: none; }
}
@container audio-card (max-height: 116px) {
  .card-audio .card-name { -webkit-line-clamp: 1; line-clamp: 1; }
}

/* 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);
}
/* Enough ink to read as a waveform on paper. At 22% it was a grey suggestion
   next to a play button in full accent, and the two did not look like parts of
   one control; a track that is visible is also a target that looks clickable,
   which this one has always been. */
.wave-base i { background: color-mix(in srgb, var(--ink) 38%, 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;
  /* The handle is hidden down here (see #world.zoom-far .item-bar), so there is
     no corner to keep clear - and the reservation would push the one button
     left on the card off the middle of it, which is the one place it has to be. */
  padding-right: 0;
}
#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;
  /* Both, and not only the height: the corner sleeve above sets a width, and a
     box given left, right and width at once resolves the conflict by throwing
     away `right` - so the picture would have filled the card's height and kept
     the corner stamp's width. */
  width: 100%;
  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 */
  /* Stops short of the handle rather than running under it. The three dots are
     pinned to the card's bottom-right corner and this bar spans the whole foot,
     so the mute button and the item menu were sharing one square: the pointer
     got whichever was on top, which is the handle, and the volume control was
     simply unreachable on every video on the board.
     The bar gives way rather than the handle moving, because the handle is in
     the same corner on every card of every type and a video is not a reason for
     it to be somewhere else. --handle-w rather than a 30 written again here, so
     the reservation cannot drift away from the thing being reserved for. */
  padding: 6px var(--handle-w) 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.

   The caption goes, and the sheet under it with it - the handle stays, because
   it is the only way into the item's menu on a touchscreen and a playing video
   is no reason to take it away. The `:has()` tests .item and not .item-body:
   the bar is a sibling of the body, not a child of it. */
.item[data-type="video"] .item-label,
.item[data-type="video"] .item-bar {
  transition: opacity var(--dur-fast) var(--ease),
              background var(--dur-fast) var(--ease);
}
.item[data-type="video"]:hover .item-label,
.item[data-type="video"]:focus-within .item-label,
.item:has(.vplayer.is-playing) .item-label { opacity: 0; }
.item[data-type="video"]:hover .item-bar,
.item[data-type="video"]:focus-within .item-bar,
.item:has(.vplayer.is-playing) .item-bar {
  background: none;
  border-top-color: transparent;
  backdrop-filter: none;
}
/* The divider goes with the sheet - see the exceptions under .item-menu, which
   is where it has to be written to outrank the rule that draws it. */

/* 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.

     Prefixed only, deliberately. The unprefixed `line-clamp` was here as
     future-proofing and bought nothing: there is no engine that supports it and
     not the -webkit- form, Firefox aliases the prefixed one, and the unprefixed
     declaration's only observable effect was a dropped-declaration warning in
     every Firefox console that opened this page. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
}
/* ---- A model on a card (canvas/model.js) ---------------------------------
   The canvas fills the card above the filename and takes the pointer, because
   dragging it turns the model rather than moving the item. Everything around
   it - the padding, the name - still drags the card, which is the same bargain
   the audio scrubber and the link anchor make. */
.card-model { gap: 6px; }
.model-stage {
  flex: 1;
  min-height: 0;
  width: 100%;
  pointer-events: auto;
  cursor: grab;
  /* A pinch on a phone must reach the board, and a drag must not scroll the
     page out from under the model. */
  touch-action: none;
}
.model-stage.is-turning { cursor: grabbing; }
/* The photograph the card shows when nobody is turning it - see
   canvas/model.js. It stands in the stage's place and has to sit in the box the
   same way: the still was taken at the card's own proportions, so `contain`
   changes nothing on a card that has not been reshaped since, and keeps the
   model whole rather than cropping it on one that has.
   No cursor and no pointer: this is a picture, and a drag on it moves the card
   like a drag on any other picture does. Turning it over is a menu away. */
.model-still {
  flex: 1;
  min-height: 0;
  width: 100%;
  object-fit: contain;
  /* The stage draws on a transparent buffer, so a still of a pale model on a
     pale card is the same picture the canvas would have shown. */
  background: none;
}
#world.zoom-far .model-still { visibility: hidden; }
.card-model .card-name {
  flex: none;
  font-size: var(--t-tiny);
  color: var(--ink-3);
  font-style: var(--display-italic);
}
/* Reading a large STL is a real pause, and the card says so rather than
   sitting empty. Replaced by the drawing, or by the reason there is not one. */
.model-note {
  display: grid;
  place-items: center;
  flex: 1;
  min-height: 0;
  font-family: var(--font-display);
  font-size: var(--t-tiny);
  font-style: var(--display-italic);
  color: var(--ink-3);
  text-align: center;
  padding: 0 8px;
}
.model-note.is-error { color: var(--danger); }
/* Far out, a turned model is a grey smudge and the canvas is the most
   expensive thing on the card. Same rung as every other detail (viewport.js). */
#world.zoom-far .model-stage { visibility: hidden; }

/* ---- The links that can become players (canvas/embed.js) -----------------
   The button is a standing offer, not a state: nothing has been requested from
   anyone until it is pressed, and it is styled to say so - the same accent a
   link wears, on the card's own paper, rather than the filled red of a control
   that is already doing something. One rule for both providers: a card that
   dressed itself in YouTube red or Spotify green would be two more palettes on
   a board whose whole premise is that you chose the pigments. */
.embed-go {
  align-self: flex-start;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-top: auto;
  padding: 5px 11px 5px 9px;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--leaf);
  background: var(--paper-2);
  color: var(--accent-deep);
  font-family: var(--font-display);
  font-size: var(--t-tiny);
  letter-spacing: calc(0.04em * var(--ls-scale));
  cursor: pointer;
  pointer-events: auto;   /* same bargain the anchor above makes */
  transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.embed-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
.embed-go svg { width: 11px; height: 11px; }
/* Far out, the offer is unreadable and un-pressable, and it would be the only
   thing left saying anything on a card whose text has already stood down. */
#world.zoom-far .embed-go { display: none; }

/* Taken. The name row survives as the only part of the card still ours to
   grab - see embed.js - and the frame takes everything under it. */
.card.has-embed { gap: 6px; }
.card.has-embed .card-name { flex: none; }
.embed-frame {
  flex: 1;
  min-height: 0;
  width: 100%;
  border: 0;
  border-radius: var(--radius-xs);
  pointer-events: auto;
}
/* A video is letterboxed against black because that is what a film is shown
   against, and any other colour reads as a bug in the player. */
.embed-frame[data-provider="youtube"] { background: #000; }

/* Spotify's player, made to sit on this board rather than on Spotify.
   ---------------------------------------------------------------------------
   Only so much of this is ours to decide, and it is worth being exact about
   which: inside the frame is Spotify's document on Spotify's origin, and no
   stylesheet of ours reaches across that boundary - by design, and there is no
   version of the web where it does not. `?theme=0` in the src is the one lever
   the player offers, and it swaps their black slab for a light variant that at
   least shares this board's brightness.

   Everything else here is the ring around it, which is all ours: the card's own
   paper behind the frame so the corners are this app's radius and not the
   twelve-pixel one their generator writes inline, the hairline that every other
   card wears, and a height chosen so the player's own compact layout lands
   exactly in the box with no dead band above or below it. The result is a
   Spotify card that reads as one of ours holding a player, rather than a
   rectangle of somebody else's interface pasted on top. */
.embed-frame[data-provider="spotify"] {
  background: var(--paper-2);
  border-radius: var(--leaf);
  /* The player draws its own rounded corners at a radius it picked. Clipping to
     ours means the two never disagree at the corner, which is the single most
     obviously-foreign thing about an embed at rest. */
  overflow: hidden;
}
/* The card behind a Spotify player closes up: their layout carries its own
   padding, so ours on top of it reads as a mat around a picture that did not
   ask for one. */
.card.has-embed[data-provider="spotify"] { gap: 4px; }

/* Nothing to see at a distance, and a cross-origin frame is the most expensive
   thing on the board to keep composited. */
#world.zoom-far .embed-frame { visibility: 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. */
/* The strip across the foot of a card: caption on the left, menu handle on the
   right, one sheet under both.

   It is a flex row and not two absolutely positioned boxes, and that is the
   whole fix: the first version sized the handle to the caption's height by
   copying its padding and line-height into a magic 28px, which was off by the
   caption's own border and drew a one-pixel step between two things that are
   meant to be one bar. A row cannot disagree with itself.

   Anchored right rather than stretched, so an item with no caption gets a strip
   the width of the handle instead of an empty bar across the whole card. */
.item-bar {
  position: absolute;
  right: 0; bottom: 0;
  max-width: 100%;
  display: flex;
  align-items: stretch;
  /* The plate. It is here rather than on the captioned bar alone because it is
     not only the caption that needs holding off the picture - an unnamed
     photograph gets three dots laid straight on it otherwise. The card types
     that draw their own name inside the card take it back off again below:
     their background is opaque paper already, and there the plate was a pale
     tab bolted into the corner, ruled off at a curve of its own, cutting
     whatever the card had drawn along its own foot. */
  background: color-mix(in srgb, var(--paper-card) 88%, transparent);
  border-top: 1px solid var(--rule);
  backdrop-filter: blur(3px);
  border-radius: var(--radius-xs) 0 var(--radius) 0;
  overflow: hidden;
}
/* With a caption, the bar spans the card and squares off on the left. */
.item-bar:has(> .item-label:not([hidden])) {
  left: 0;
  border-radius: 0 0 var(--radius) var(--radius);
}
.item-label {
  flex: 1;
  min-width: 0;
  padding: 6px 11px 7px;
  font-size: 12px;
  font-style: var(--display-italic);
  line-height: 1.25;
  color: var(--ink-2);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  pointer-events: none;
}
.item-label[hidden] { display: none; }

/* The types that draw their own name inside the card, and so want no caption on
   the bar - only the handle. A sticky note is the clearest case: it carries a
   name (the first line of its text) that nothing on the board ever draws. */
.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="model"] .item-label,
.item[data-type="generic"] .item-label { display: none; }
/* ...and the bar goes back to being a bare handle with them. The `:has()` above
   tests the `hidden` attribute, which these do not set - they are painted out by
   type, not by having no name - so without this they would take the plate, span
   the whole card, and strand the dots at the end of an empty strip. */
.item[data-type="audio"] .item-bar,
.item[data-type="text"] .item-bar,
.item[data-type="note"] .item-bar,
.item[data-type="link"] .item-bar,
.item[data-type="model"] .item-bar,
.item[data-type="generic"] .item-bar {
  left: auto;
  background: none;
  border-top: 0;
  backdrop-filter: none;
  border-radius: 0;
  /* Inside the card's inner rule, not on it.
     A captioned bar is a plate along the foot and belongs flush to the edge;
     this one is a bare button standing on the card's own paper, so it owes that
     paper the same margin everything else inside the card keeps.
     The number is the distance the rule has moved in from the width these
     fittings were drawn against - the 5px of Middle and Harsh. It is zero at
     both of those, and 3px at Softish, where the rule stands 8px in and the
     dots stood 8.8px in: eight tenths of a pixel of daylight, which is not
     daylight, and the dots read as sitting on the line rather than beside it.
     Written as a difference rather than as the gap itself because the handle
     was never flush to begin with - the icon is 13px centred in a 30px button,
     so it already stands most of the way clear on its own.

     This measures along the straight edges, which is the whole of the answer
     only while the corner is tight. Softish overrides it - see the whimsy
     section at the foot of this file for why a 26px radius needs more. */
  right: max(0px, calc(var(--card-rule-gap) - 5px));
  bottom: max(0px, calc(var(--card-rule-gap) - 5px));
}
/* :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; }

/* ---------------------------------------------------------------------------
   Cheap mode: what the board stops paying for while it is being moved.

   Set by canvas/viewport.js for as long as the view keeps changing, and taken
   off 140ms after it stops. Everything here is an effect that is free on a
   still board and priced per item per frame on a moving one - so what it buys
   scales with exactly the thing that makes zooming heavy, which is how many
   cards are on screen.

   `backdrop-filter` is the whole reason this exists. Every photograph and every
   video on the board carries a caption plate that blurs what is behind it, and
   a backdrop blur cannot be composited once and reused the way a shadow can:
   the pixels behind the box change on every frame of a zoom, so every plate on
   screen is re-blurred every frame. The plate keeps its translucent paper and
   its rule, so it is still a plate - it simply stops sampling through itself
   for the tenth of a second nobody is reading it.

   The blur returning is not animated. A transition here would put the cost back
   on the frame after the gesture, spread over a few hundred milliseconds, which
   is precisely the frames a zoom lands on and the user is looking at. */
#world.is-viewing .item-bar,
#world.is-viewing .transport-video,
#world.is-viewing .vbig,
#world.is-viewing .card-audio.has-cover .play {
  backdrop-filter: 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. */
/* The three-dot handle: every item's way into its own menu, and the only way in
   at all without a right-click.

   Always on, never revealed by hover. It was hover-only first, which is the
   habit, and the habit is wrong here: hover does not exist on a touchscreen,
   and this is the *only* route to an item's actions there. A control that is
   the sole way to do something cannot be one you have to discover.

   It is the right-hand end of the bar above, so it takes its height from the
   row rather than naming one. */
.item-menu {
  flex: none;
  display: grid;
  place-items: center;
  /* The width, and a floor under the height. The height itself comes from the
     row, which takes it from the caption beside it - so where there is a
     caption the two agree by construction rather than by arithmetic. Where
     there is not, the row has nothing to take a height from and collapsed onto
     the icon: a 30x15 sliver on every sticky note. The floor makes a bar with
     no caption square, and it is the same 30 as the captioned bar ends up at,
     so the handle is one size wherever it appears. */
  width: var(--handle-w);
  min-height: var(--handle-w);
  padding: 0;
  border: 0;
  background: none;
  color: var(--ink-3);
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease);
}
/* Only when there is something to be divided from. */
.item-bar:has(> .item-label:not([hidden])) .item-menu {
  border-left: 1px solid var(--rule);
}
/* ...and on the types whose caption is painted out by type rather than by
   having no name, there is not. `:has()` above tests the attribute, which those
   do not set, so it matches on a note and ruled a line down the side of the
   dots with nothing on the other side of it.
   Written through .item-bar and placed after the rule it answers, because it
   has to beat it: the `:has()` argument counts towards specificity, and the
   plain type selector came out lower. */
.item[data-type="audio"] .item-bar .item-menu,
.item[data-type="text"] .item-bar .item-menu,
.item[data-type="note"] .item-bar .item-menu,
.item[data-type="link"] .item-bar .item-menu,
.item[data-type="model"] .item-bar .item-menu,
.item[data-type="generic"] .item-bar .item-menu { border-left: 0; }
/* And a video whose transport has taken the strip: the sheet went with it, and
   a rule with nothing either side of it would be all that was left. */
.item[data-type="video"]:hover .item-bar .item-menu,
.item[data-type="video"]:focus-within .item-bar .item-menu,
.item:has(.vplayer.is-playing) .item-bar .item-menu { border-left: 0; }
.item-menu svg { width: 13px; height: 13px; }
/* Ink only, hovered and pressed alike - no fill behind the dots.
   A fill would be a 30px block sitting in the corner of the bar, and the bar's
   bottom-right corner is cut to the card's own 13px curve: on a bar with no
   caption that is nearly half the box, so the fill came out as a quarter-disc
   swept across the plate. The dots changing colour says the same thing and has
   no shape to get wrong.
   The lift-and-squash every other control uses is out for a different reason -
   this sits on a card that is itself being dragged, so a moving button reads as
   the card moving. */
.item-menu:hover { color: var(--accent-deep); }
.item-menu:active,
.item-menu[aria-expanded="true"] { color: var(--accent); }
.item-menu:focus-visible { outline: var(--sel-line) solid var(--select); outline-offset: calc(var(--sel-gap) * -1); }
/* At that scale a card is a swatch, not a thing you operate. */
#world.zoom-far .item-bar { display: none; }
/* Nothing but the text while a name or a note is being edited. */
.item.is-editing .item-menu { display: none; }

.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(--grip-swell, 1) * var(--iz)) solid var(--select);
  pointer-events: none;
  transition: border-width var(--dur-fast) var(--ease);
}
/* Under the pointer, and for as long as it is being dragged. The mark grows
   inwards from the ring's outer edge, so thickening it does not move it - the
   line the eye is following stays exactly where it was and simply gets heavier.
   Written as a multiplier on the whole corner rather than as a border-width of
   its own, because the four corners each blank two of their own borders and a
   `border-width` here would put those back. */
.grip:not(.grip-edge):hover,
.item.is-resizing .grip:not(.grip-edge) { --grip-swell: 1.6; }
/* 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; }

/* The grab area, which is not the mark.
   The honest fix for a target that is hard to hit is to make the target bigger
   without making the drawing bigger - so the grab area is an invisible box of
   its own, and .grip itself takes no pointer at all. In --iz units like
   everything else in the ring, so every number here is a constant size on
   screen at any zoom. */
.grip { pointer-events: none; }
.grip::before {
  content: '';
  position: absolute;
  /* Edges only; the corner rule below replaces this outright. Non-zero on a
     mouse now: four pixels of forgiveness costs nothing and a hairline strip
     that has to be hit exactly is a strip people miss. The touch block at the
     foot of this file opens it further. */
  inset: calc(-1 * var(--grip-slop, 4px) * var(--iz));
  pointer-events: auto;
}

/* The corner target: a constant square, laid *outside* the item.
   Two things wrong with using the mark's own box, which is what this used to
   do. It was sized from --mark-size, which is built out of the item's own
   corner radius - so the same handle on the same card was a 30px target when
   the whimsy axis rounded it and a 10px one at the plain end, where the radius
   is zero. And it straddled the corner, so the outer third of the card was a
   resize and the rest of it a drag, with nothing to tell you where the line
   was.
   Now it ends where the item does. The inner corner of this square sits
   exactly on the item's corner, so what you grab to resize is the outline and
   the space beyond it, and the card itself is always the card. Placed by
   geometry rather than by putting the box behind the card, because "behind"
   depends on what is painted in front - a transparent PNG, a card with no
   background - and this does not. */
.grip:not(.grip-edge)::before {
  inset: auto;
  width:  calc(var(--grip-corner, 24px) * var(--iz));
  height: calc(var(--grip-corner, 24px) * var(--iz));
}
.grip[data-g="nw"]::before,
.grip[data-g="sw"]::before { left: calc(var(--sel-edge) - var(--grip-corner, 24px) * var(--iz)); }
.grip[data-g="ne"]::before,
.grip[data-g="se"]::before { right: calc(var(--sel-edge) - var(--grip-corner, 24px) * var(--iz)); }
.grip[data-g="nw"]::before,
.grip[data-g="ne"]::before { top: calc(var(--sel-edge) - var(--grip-corner, 24px) * var(--iz)); }
.grip[data-g="sw"]::before,
.grip[data-g="se"]::before { bottom: calc(var(--sel-edge) - var(--grip-corner, 24px) * var(--iz)); }

/* 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.

   Laid *outside* the item, for the reason the corner squares above are: these
   used to be anchored at the ring and run inward, so the outer ten pixels of a
   card's own face resized it while the corners of the same card dragged. One
   card, two rules, and nothing on screen to say where the line was. Now both
   end where the item does, and the whole face is always a drag.

   One length for the thickness and the offset, so the touch block at the foot
   of this file widens the strip without sliding it back over the card. */
.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(var(--grip-edge, 14px) * var(--iz));
  cursor: ns-resize;
}
.grip[data-g="n"] { top:    calc(-1 * var(--grip-edge, 14px) * var(--iz)); }
.grip[data-g="s"] { bottom: calc(-1 * var(--grip-edge, 14px) * var(--iz)); }
.grip[data-g="w"], .grip[data-g="e"] {
  top:    min(var(--mark-size), 40%);
  bottom: min(var(--mark-size), 40%);
  height: auto;
  width: calc(var(--grip-edge, 14px) * var(--iz));
  cursor: ew-resize;
}
.grip[data-g="w"] { left:  calc(-1 * var(--grip-edge, 14px) * var(--iz)); }
.grip[data-g="e"] { right: calc(-1 * var(--grip-edge, 14px) * var(--iz)); }

/* ---------------------------------------------------------
   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: var(--chrome-button-w);
  height: var(--chrome-button-h);
  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),
              opacity var(--dur-base) 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);
}
/* The wordmark, set in whatever the display face is - and when that face is a
   sans, set as heavy as it goes.

   900 rather than 700, and it is not a typo. Geist is a variable font with a
   weight axis that stops at 700, and asking a variable face for more than its
   axis holds clamps to the axis - so this asks for "the top of whatever this
   font has" and gets 700 from Geist, 900 from a dropped face that carries one,
   and the same synthetic bold either number would have produced from a face
   with a single master. There is no way to read a face's maximum in CSS, and
   this is the question phrased so it does not need to be read.
   ui/appearance.js sets the attribute; see markDisplayFace() for how a stack is
   judged to be a sans. */
:root[data-display-sans] .wordmark h1 { font-weight: 900; }

#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;
  /* 400 is right for the serif: Fraunces at 30px is a display cut already, and
     bolding a high-contrast face thickens the stems without making it any more
     of a title. A sans has nothing else to give - see the override below. */
  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);
}

/* The board's name, which is also the field you rename it in.
   Chromeless until you reach for it: an input that draws itself as a text field
   at rest would make the panel's first line look like a form to fill in, when
   almost always it is a label to read. The affordance arrives on hover and the
   box on focus, which is the same bargain the item labels on the board make. */
.board-title {
  display: block;
  width: 100%;
  margin: 0 0 11px;
  padding: 3px 6px;
  border: 1.5px solid transparent;
  border-radius: var(--radius-xs);
  background: none;
  font: inherit;
  font-size: 18px;
  color: inherit;
  cursor: text;
  transition: border-color var(--dur-fast) var(--ease),
              background-color var(--dur-fast) var(--ease);
}
/* An unnamed board reads as an aside, not as a filename. This is the placeholder
   now rather than an <em>, so the field is empty and typing starts a name
   instead of editing the words "Untitled board". */
.board-title::placeholder { color: var(--ink-3); font-style: var(--display-italic); opacity: 1; }
.board-title:hover { border-color: var(--rule); }
.board-title:focus {
  outline: none;
  border-color: var(--accent);
  background: var(--paper-card);
}

/* Two controls that are one decision. Equal columns rather than auto, so the
   pair does not jump about as the face names change length - and `minmax(0,1fr)`
   rather than `1fr`, because a grid track's default minimum is the content's
   own width and a <select> holding "Times New Roman" would otherwise push the
   column past its share and out of the panel. */
.field-pair {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 0 10px;
}

/* The fold. Everything here is something you set once - or never - and it was
   crowding the three controls that get used. */
.advanced { margin-top: 4px; }
.advanced > summary {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 5px 0;
  cursor: pointer;
  list-style: none;
  font-size: var(--t-tiny);
  font-style: var(--display-italic);
  letter-spacing: calc(0.24em * var(--ls-scale));
  text-transform: uppercase;
  color: var(--ink-3);
  transition: color var(--dur-fast) var(--ease);
}
.advanced > summary::-webkit-details-marker { display: none; }
.advanced > summary:hover { color: var(--accent); }
/* A drawn caret, so it turns with the disclosure rather than depending on a
   glyph whose ink is not centred in its line box. */
.advanced > summary::before {
  content: "";
  flex: none;
  width: 0; height: 0;
  border-left: 4px solid currentColor;
  border-top: 3.5px solid transparent;
  border-bottom: 3.5px solid transparent;
  transition: transform var(--dur-fast) var(--ease);
}
.advanced[open] > summary::before { transform: rotate(90deg) translateX(1px); }
.advanced[open] > summary { margin-bottom: 7px; }

/* Wraps, because the buttons do not. Every one of these is `white-space:
   nowrap` on purpose - a command broken across two lines reads as two commands -
   and the row had no way to give, so a label longer than its share of the panel
   pushed its neighbour out past the edge and clipped it. The panel is also
   user-resizable down to 260px, which turns that from an unlucky label into a
   drag anybody can do. Wrapping costs nothing while the row fits. */
.btn-row { display: flex; flex-wrap: wrap; 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); }
/* A pair of buttons behaving as a radio group - Portrait / Landscape. Filled
   rather than merely outlined, because the question these answer is "which of
   these two is it", and an outline reads as a hover on the wrong one just as
   readily as it reads as a selection. The hover rule above would otherwise
   repaint the chosen one as unchosen the moment the pointer crossed it. */
.btn-row button[aria-pressed="true"],
.btn-row button[aria-pressed="true"]:hover {
  background: var(--accent);
  border-color: var(--accent-deep);
  color: var(--accent-fg);
}
/* The same bargain the dialog's destructive button strikes, and worn for the
   same reason: outlined until you are over it, so it reads as available rather
   than as the thing the panel wants you to press. */
.btn-row button.danger { border-color: var(--danger); color: var(--danger); }
.btn-row button.danger:hover {
  background: var(--danger);
  border-color: var(--danger);
  color: var(--paper-card);
}

.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); }
/* The chevron on a menu, drawn rather than left to the browser.

   Padding was tried first and cannot do it: the native dropmarker is part of
   the widget, and Firefox places it at its own fixed distance from the border
   edge no matter what padding-right says. So it sat about eight pixels off the
   frame - and at the soft end of the axis, where a 35px control with a 20px
   radius is very nearly a pill, the curve is still turning at that point and
   the arrow reads as falling out of the side of its own button.

   `appearance: none` drops the widget's arrow and this puts one back where it
   was asked for. A mask rather than a background image, because a data URI
   cannot see currentColor and this app has four palettes: the mark is the
   shape, and --ink-2 is the ink, so it belongs to whichever sheet it is on. */
.field:has(select) {
  position: relative;
  /* How far the mark's own box sits from the frame. Follows the corner, so the
     rounder the control the further in it comes. */
  --lane: max(13px, calc(var(--leaf) * 0.75));
}
.field:has(select)::after {
  content: '';
  position: absolute;
  right: var(--lane);
  bottom: 0;
  width: 11px;
  height: calc(35px * var(--density));   /* the select's own height, so centred */
  background: var(--ink-2);
  --chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1.5 2.4 6 6.2 10.5 2.4'/%3E%3C/svg%3E");
  -webkit-mask: var(--chevron) center / 11px 7px no-repeat;
  mask: var(--chevron) center / 11px 7px no-repeat;
  /* The menu underneath still takes the click, including on the arrow. */
  pointer-events: none;
  transition: background var(--dur-fast) var(--ease);
}
.field:has(select):hover::after { background: var(--accent); }
.field select {
  width: 100%;
  min-height: calc(35px * var(--density));
  /* The lane, the mark, and a word's worth of air before the text runs into it. */
  padding: 0 calc(var(--lane) + 19px) 0 10px;
  appearance: none;
  -webkit-appearance: none;
  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;
  align-items: baseline;
  /* The credit on the left, the version on the right, which is the arrangement
     this row was built for before the version was briefly alone in it. */
  justify-content: space-between;
  gap: 10px;
}
/* Quiet by construction: the same size and colour as the version number beside
   it, underlined only on the way past. Loud is one edit away - a mark in the
   .side-head next to the wordmark - and that is a decision rather than a
   default, so it is not taken here. */
.side-foot .by {
  color: inherit;
  text-decoration: none;
  transition: color var(--dur-fast) var(--ease);
}
.side-foot .by:hover { color: var(--accent); text-decoration: underline; }

/* ---------------------------------------------------------
   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;
  transition: opacity var(--dur-base) var(--ease);
}

#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; }
/* And the same double rule again before the padlock, for the same reason read
   the other way: everything to its left does something, and it decides whether
   they can. Three groups in one pill - zoom, view, and the switch over both. */
#zoom-lock { border-left-width: 3px; border-left-style: double; }
/* Only one shackle is ever drawn. */
#zoom-lock .lock-shut, #zoom-lock[aria-pressed="true"] .lock-open { display: none; }
#zoom-lock[aria-pressed="true"] .lock-shut { display: inline; }
/* Pressed is a state and not a hover, so it is the ink that carries it rather
   than a wash: the accent at full strength against the muted grey the rest of
   the row sits at, which is the same way the bin says it has something in it.
   No fill behind it - the row is one pill on glass, and a filled segment in the
   middle of it reads as a second object. */
#zoom-lock[aria-pressed="true"] { color: var(--accent-deep); }
#zoom-lock[aria-pressed="true"] .lock-body { fill: color-mix(in srgb, var(--accent) 18%, transparent); }

/* The phone's add bar. Nothing but its own shape here - where it is pinned, and
   the fact that it exists at all, are both in the width query at the foot of
   this sheet. A mouse has the whole sidebar a click away and does not need the
   bottom edge of the window spent on two of its buttons. */
#add-bar {
  display: none;
  position: fixed;
  z-index: 20;
  align-items: stretch;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--ogee);
  overflow: hidden;
  background: var(--paper-card);
  box-shadow: var(--shadow-2);
  user-select: none;
  transition: opacity var(--dur-base) var(--ease), visibility 0s;
}
#add-bar button {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
  /* 54, not 44. This is the one control on the screen that is aimed at rather
     than pressed on the way past, and it is the bottom of a phone - the strip
     a browser's own chrome shares with the home indicator. */
  min-height: 54px;
  padding: 0 14px;
  border: 0;
  background: none;
  color: var(--ink);
  font-size: var(--t-small);
  letter-spacing: calc(0.02em * var(--ls-scale));
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
/* The two halves are one object, so the seam is a rule and not a gap. */
#add-bar button + button { border-left: 1px solid var(--rule); }
#add-bar button:active { background: var(--paper-3); color: var(--accent-deep); }
#add-bar svg { width: 19px; height: 19px; color: var(--accent); }
/* Same bargain as #bin: the panel covers this whole bar at this width, and a
   control you cannot see should not still be in the tab order underneath it. */
#sidebar.is-open ~ #add-bar {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity var(--dur-base) var(--ease), visibility 0s var(--dur-base);
}

/* Idle - fifteen seconds of nothing, and the furniture goes.
   ui/idle.js puts the class on and takes it off; what counts as "something
   happened" is decided there.

   pointer-events with it, and that pairing is the whole safety of this: a
   control faded to nothing that could still be pressed would be a mine, and
   the first tap has to be the one that brings the bar back rather than one
   that lands on an invisible Empty.

   Much slower going than coming, and the gap is the point. Leaving is the app
   getting out of the way: a drift long enough that the eye never catches the
   moment it starts, so the board is simply clear rather than visibly emptied.
   Arriving answers something the user just did, and an answer is late at a
   quarter of a second - the base rules on each element carry that quick half.
   A multiple of --dur-base rather than a fixed time, so the three whimsy tiers
   keep their own tempo instead of all drifting off at the middle one's. */
:root.is-idle #menu-btn,
:root.is-idle #bin,
:root.is-idle #corner,
:root.is-idle #add-bar {
  opacity: 0;
  pointer-events: none;
  transition: opacity calc(var(--dur-base) * 6) var(--ease);
}

/* The scale bar. A label over a stick, both right-aligned, so the stick's right
   end lines up with the edge of the zoom control beneath it - the eye then has
   a straight edge to measure from rather than a floating rule.

   No card, no background: it is a measurement laid on the board, not another
   control sitting on top of it. That also keeps the corner from becoming three
   stacked panels. */
#scale-bar {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 3px;
  padding: 0 2px 2px;
  user-select: none;
  pointer-events: none;
}
#scale-bar[hidden] { display: none; }
#scale-bar-label {
  font-family: var(--font-mono);
  font-size: var(--t-tiny);
  font-variant-numeric: tabular-nums;
  color: var(--ink-2);
  /* Enough of the paper behind it to stay legible over a photograph, without
     drawing a box: the label sits in a wash, the stick does not need one. */
  padding: 0 3px;
  border-radius: var(--radius-xs);
  background: color-mix(in srgb, var(--paper-card) 78%, transparent);
}
/* Serifed at both ends, the way a scale bar on a drawing is: the two tick marks
   are what make it a measurement between two points rather than a line of
   unclear extent. Borders rather than pseudo-elements so the whole thing is one
   element whose width is the only thing the module writes. */
#scale-bar-stick {
  height: 7px;
  border: solid var(--ink-2);
  border-width: 0 var(--hairline);
  position: relative;
}
#scale-bar-stick::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: var(--hairline);
  background: var(--ink-2);
}

#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;
}
/* Nowrap because each slot now carries both readings - board units and the real
   measurement - and a pair broken across two lines reads as four numbers. The
   strip grows sideways instead; it is hidden outright on a narrow screen. */
#hud span { padding: 6px 11px; white-space: nowrap; }
#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: var(--chrome-button-w);
  height: var(--chrome-button-h);
  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); }

/* The autosave mark. Sits above the row, out of the flow, so it can appear and
   go without anything else on the glass moving an inch.

   Deliberately the quietest thing in the corner: no card, no shadow, no border
   - it is a note to yourself, not a control, and the moment it looks like a
   button somebody will try to press it. It leaves slowly and arrives quickly,
   the same asymmetry #add-bar uses when the furniture idles away: arriving
   answers something you just did and is late at a quarter of a second, whereas
   leaving is only the app getting out of the way again. */
#saved {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 0;
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 3px 8px 3px 6px;
  border-radius: var(--leaf);
  background: color-mix(in oklab, var(--paper-card) 82%, transparent);
  color: var(--ink-3);
  font-size: var(--t-tiny);
  letter-spacing: calc(0.02em * var(--ls-scale));
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  /* visibility with it, so a mark faded to nothing is not still a hit target
     over the board - the same pairing #bin makes when the panel covers it. */
  visibility: hidden;
  transition: opacity 640ms var(--ease), visibility 0s 640ms;
}
#saved.is-on {
  opacity: 1;
  visibility: visible;
  transition: opacity var(--dur-fast) var(--ease), visibility 0s;
}
#saved svg { width: 12px; height: 12px; color: var(--accent); }

/* The bin and the history pair, on one line. The bin stays flush in the corner
   and the pair grows to its right, so nothing that was already there moves. */
.bin-row {
  display: flex;
  align-items: stretch;
  gap: 8px;
}

/* The same segmented pill as #zoom-ctl in the opposite corner - one object with
   a hairline seam, not two buttons that happen to be adjacent. Each half takes
   exactly the space of one permanent chrome button, so the bin, undo, redo and
   menu all carry the same visual weight. */
#history-ctl {
  display: flex;
  align-items: stretch;
  width: calc(var(--chrome-button-w) + var(--chrome-button-w));
  height: var(--chrome-button-h);
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--leaf);
  overflow: hidden;
  background: var(--paper-card);
  box-shadow: var(--shadow-1);
}
#history-ctl button {
  display: grid;
  place-items: center;
  flex: 1 1 50%;
  min-width: 0;
  padding: 0;
  border: 0;
  background: none;
  color: var(--ink-2);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
#history-ctl button + button { border-left: 1px solid var(--rule); }
#history-ctl button:hover:not(:disabled) { background: var(--paper-2); color: var(--accent-deep); }
#history-ctl button:active:not(:disabled) { background: var(--paper-3); }
/* Nothing to take back. The faintest ink and no pointer reaction, which is the
   disabled treatment the zoom controls already use - and unlike the empty bin
   this one really is inert, so it says so. */
#history-ctl button:disabled { color: var(--ink-3); cursor: default; }
#history-ctl svg {
  width: 16px; height: 16px;
  /* The glyph takes the reaction, not the button: scaling the button would drag
     the seam between the two around. Same bargain as #zoom-ctl. */
  transition: transform var(--dur-base) var(--ease-back);
}
#history-ctl button:hover:not(:disabled) svg { transform: scale(var(--btn-grow)) translateY(var(--btn-lift)); }
#history-ctl button:active:not(:disabled) svg { transform: scale(calc(var(--btn-press) - 0.08)); }

/* 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);
}

/* ---------------------------------------------------------
   The waiting strip (busy() in util.js)

   A card of the same stock as everything else, slid down from the top edge
   while the app cannot answer. Deliberately not a modal, not a veil over the
   board and not a spinner in the middle of the screen: none of the work it
   covers stops you from panning, reading what is already there, or writing a
   note while it finishes. It reports; it does not detain.

   Top centre, opposite the toast at the foot - see the note in index.html.
   --------------------------------------------------------- */
#busy {
  position: fixed;
  z-index: 55;
  top: max(14px, env(safe-area-inset-top));
  left: 50%;
  width: min(330px, calc(100vw - 28px));
  padding: 10px 14px 11px;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--leaf);
  background: var(--paper-card);
  box-shadow: var(--shadow-2);
  /* The entrance and the exit are one declaration: is-up moves it to rest and
     removing the class sends it back up under the top edge it came from. */
  translate: -50% -14px;
  opacity: 0;
  transition: translate var(--dur-base) var(--ease-back),
              opacity var(--dur-base) var(--ease);
}
#busy.is-up { translate: -50% 0; opacity: 1; }
#busy[hidden] { display: none; }

.busy-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 8px;
}
#busy-label {
  min-width: 0;
  /* One line, and the end cut rather than wrapped: the strip is a fixed shape
     and a filename long enough to wrap would change its height mid-job. */
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: var(--t-small);
  font-style: var(--display-italic);
  color: var(--ink);
}
#busy-count {
  flex: none;
  font-family: var(--font-mono);
  font-size: var(--t-tiny);
  font-variant-numeric: tabular-nums;
  color: var(--ink-3);
}

#busy-track {
  position: relative;
  overflow: hidden;
  height: 3px;
  border-radius: var(--radius-xs);
  background: var(--rule);
}
#busy-fill {
  height: 100%;
  border-radius: inherit;
  background: var(--accent);
}

/* Not counting: a short bar sliding the length of the track and back.

   A CSS animation rather than anything this app drives, and that is the whole
   reason this element exists in two modes instead of one. Most of what the
   strip covers - hashing a video by hand where crypto.subtle is missing,
   decoding a folder of images, packing an archive - is synchronous work on the
   thread that draws. Anything animated from JavaScript stops dead underneath
   it, at precisely the moment somebody is watching the bar to find out whether
   the app has hung. A transform animation is the compositor's to run, so it
   keeps moving while this thread is fully occupied, which is the one honest
   thing it can do.

   Which also decides the shape: `translate` and nothing else. Animating width
   or left would put it back on the main thread and undo the point. */
#busy:not(.is-counting) #busy-fill {
  width: 38%;
  animation: busy-slide 1.15s var(--ease) infinite;
}
@keyframes busy-slide {
  0%   { translate: -100% 0; }
  50%  { translate: 163% 0; }
  100% { translate: -100% 0; }
}

/* Counting: a width this app sets, eased so a batch that finishes several files
   between frames fills smoothly instead of jumping. */
#busy.is-counting #busy-fill {
  width: 0%;
  transition: width var(--dur-base) var(--ease);
}

/* 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);
}
/* The question dialog. Dressed as the app rather than as the browser: the same
   sheet, hairline, leaf radius and lift budget every other surface uses, so the
   one moment this thing interrupts you it still looks like the thing you were
   using. */
#ask {
  max-width: min(420px, calc(100vw - 32px));
  padding: 22px 24px 18px;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--ogee);
  background: var(--paper-card);
  box-shadow: var(--shadow-2);
  color: var(--ink);
  /* A <dialog> in the top layer is centred by its own margin:auto, which the
     reset's `margin: 0` takes away. */
  margin: auto;
  animation: ask-in var(--dur-base) var(--ease-back);
}
#ask::backdrop {
  /* Tinted with the board's own ink rather than plain black, so the sheet
     behind it dims into the palette instead of going grey. */
  background: color-mix(in srgb, var(--ink) 42%, transparent);
  backdrop-filter: blur(2px);
}
#ask h2 {
  margin: 0 0 8px;
  font-family: var(--font-display);
  font-size: var(--t-title);
  font-style: var(--display-italic);
  font-weight: 400;
  color: var(--ink);
}
#ask p {
  margin: 0 0 18px;
  font-size: var(--t-body);
  color: var(--ink-2);
  text-wrap: pretty;
}
#ask p[hidden] { display: none; }
/* The box on the questions that want a value rather than a choice. Dressed as
   the panel's own controls are, and given the row of buttons' full width -
   there is only ever one of these, and a short box beside a long sentence reads
   as an afterthought. */
#ask-field {
  display: block;
  width: 100%;
  min-height: calc(35px * var(--density));
  margin: 0 0 14px;
  padding: 0 10px;
  border: var(--hairline) solid var(--rule-2);
  border-radius: var(--leaf);
  background: var(--paper);
  color: var(--ink);
  font: inherit;
}
#ask-field:focus { outline: none; border-color: var(--accent); }
#ask-field[hidden] { display: none; }

.ask-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
/* Pushes the two paired answers to the far end, away from the one that keeps
   your work. Collapses to nothing when the row wraps on a narrow screen. */
.ask-gap { flex: 1 1 0; min-width: 0; }
.ask-row button {
  min-height: calc(35px * var(--density));
  padding: 0 14px;
  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);
}
.ask-row button:hover {
  background: var(--paper-2);
  border-color: var(--accent);
  color: var(--accent-deep);
  transform: translateY(var(--btn-lift)) scale(var(--btn-grow));
}
.ask-row button:active { transform: scale(var(--btn-press)); background: var(--paper-3); }
/* The dialog opens with a button already focused, so whatever ring this is
   lands on screen every single time rather than only for people who tab - which
   made the browser's default black outline the most prominent thing in the
   panel. Suppressed for the plain case and drawn in the app's own ring when the
   focus is one somebody navigated to. Same ring the search hits and the context
   menu use. */
.ask-row button:focus { outline: none; }
.ask-row button:focus-visible {
  outline: var(--sel-line) solid var(--select);
  outline-offset: var(--sel-gap);
}
.ask-row button[hidden] { display: none; }
/* The irreversible one is the only thing in the panel wearing the danger
   colour, and it is not the one that has focus when the dialog opens. */
.ask-row button.danger { border-color: var(--danger); color: var(--danger); }
.ask-row button.danger:hover {
  background: var(--danger);
  border-color: var(--danger);
  color: var(--paper-card);
}
@keyframes ask-in { from { opacity: 0; transform: translateY(8px) scale(0.97); } }

#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; }

  /* One exception, and it is about honesty rather than about motion. The
     indeterminate bar is a short block that means "something is happening"
     only because it is travelling; stopped dead by the rule above it becomes a
     short block sitting at 38%, which is a progress reading, and a false one.
     So with motion off it is drawn as a full dim track instead - the label and
     the strip's presence carry the message, and nothing on screen claims to
     know how far along the work is. The counting bar is unaffected: its width
     is a fact and it can be shown standing still. */
  #busy:not(.is-counting) #busy-fill {
    width: 100%;
    opacity: 0.4;
    animation: none;
  }
}

/* ---------------------------------------------------------
   Touch, and small screens

   Two separate questions that are usually confused for one.

   *Touch* is about the input: a finger has no hover state and covers about ten
   millimetres, so anything that only appears on hover is unreachable and
   anything under about 44px is a coin toss. That is `pointer: coarse`, and it
   is true of a large tablet as much as a phone.

   *Small* is about the room: the chrome sits in four corners, and on a narrow
   screen those corners are the parts of the glass a thumb cannot reach while
   the hand is holding the device. That is a width query, and it is true of a
   phone whether or not it is being touched.

   Written in that order, and deliberately not merged.
   --------------------------------------------------------- */

@media (pointer: coarse) {
  :root {
    --chrome-button-w: var(--chrome-button-touch);
    --chrome-button-h: var(--chrome-button-touch);
    /* Still not 44px: four of these sit on the corners of a rectangle you are
       looking *through*, so a target that size would hide a card each. Grown to
       where a finger can find it without the sheet becoming mostly handle. */
    --sheet-grip: 20px;
  }

  /* Marks keep their drawn size and the targets around them grow. A finger is
     not a pointer: 9px of slop each way on the edge strips, and a corner square
     of 40 rather than 24 - still laid outside the item, so a thumb on the card
     is still a drag. */
  /* The edge strips are pure hit area, so --grip-edge is the target itself -
     and it sets the offset as well as the thickness, so a wider strip grows
     away from the card rather than over it. */
  .item { --grip-slop: 9px; --grip-corner: 40px; --grip-edge: 24px; }
  /* Nothing to hover with, and a swollen mark under a finger is a mark under a
     finger. It stays for the drag, which is the part you can see. */
  .grip:not(.grip-edge):hover { --grip-swell: 1; }

  /* A video's transport is revealed by hovering the card, which on a touch
     screen means it is revealed by nothing at all. Selection is the touch
     equivalent of "the one I am working on", so it takes over the job. */
  .item.is-selected .transport-video { opacity: 1; }

  /* Every hover reaction in the chrome fires on the *first* tap on a touch
     screen and then sticks until something else is tapped, so a bin button
     stays lit long after you have finished with it. The reactions are
     decoration; the pressed state below is the one that carries information. */
  #menu-btn:hover, #bin-btn:hover, #zoom-ctl button:hover,
  .btn-row button:hover, #side-close:hover {
    background: var(--paper-card);
    border-color: var(--rule-2);
    color: inherit;
    transform: none;
  }
  #zoom-ctl button:hover:not(:disabled) svg,
  #bin-btn:hover svg { transform: none; }
  #menu-btn:active, #bin-btn:active, #zoom-ctl button:active,
  .btn-row button:active { background: var(--paper-3); }

  /* 44px, everywhere a finger has to land. This is the *input* question, so it
     is here rather than in the width query below: a 10" tablet has all the room
     in the world and the same finger. */
  #menu-btn span { width: 19px; }
  #bin-btn svg { width: 20px; height: 20px; }
  #zoom-ctl button { min-width: 40px; height: 46px; }
  #zoom-ctl svg { width: 17px; height: 17px; }
  .check input { width: 20px; height: 20px; }

  /* A long press on the board opens the context menu (canvas/input.js). iOS
     answers the same gesture with its own callout - "Copy / Look Up" over a
     card - so the platform's has to be turned off or the two fight. */
  #world, #viewport { -webkit-touch-callout: none; }
}

@media (max-width: 700px) {
  /* Two bands instead of four corners.

     The foot is for the hand: the add bar along the very bottom, with the bin,
     undo and redo on the row above it. The view controls go up to the top
     edge, because they are the odd one out - zoom is something you set and
     then leave, not something you reach for mid-gesture, and it was the piece
     most often pressed by accident while dragging an item towards the bottom
     of the screen. The menu shares that top edge from the other corner, for
     the reason written over its own rule below.

     Nothing moves in the markup - these are four independently fixed elements
     and always were, so this is only a question of where each one is pinned. */
  /* The add bar takes the bottom edge and the row above steps up over it.
     One number for the step: the bar's own 54px plus the 12 between the two
     rows. On :root rather than written into the rule that uses it, because
     this is the bar's height and not the bin's offset - anything else that
     ever has to clear the bar wants the same number, and two copies of it is
     exactly how the four corners this bar replaced drifted apart. */
  :root { --bar-lift: 66px; }

  #add-bar {
    display: flex;
    bottom: max(12px, env(safe-area-inset-bottom));
    left: max(12px, env(safe-area-inset-left));
    right: max(12px, env(safe-area-inset-right));
  }

  /* The menu goes back to the top left, which is where it is at every other
     width. It came down here to be reachable by a thumb, and the row it came
     down into has since filled up: the bin brought undo and redo with it, and
     three controls plus the menu at the two far ends of the same strip is a
     row you have to aim at. The panel it opens is a two-handed job anyway -
     you go to it to rename a board or change the look, not mid-gesture - so it
     is the one thing in the corner that can afford the reach. */
  #menu-btn {
    top: max(12px, env(safe-area-inset-top));
    left: max(12px, env(safe-area-inset-left));
  }

  /* The row above the add bar, where the view controls used to be. It has the
     strip to itself now that the menu has gone back up to the top edge.

     The bin takes the left corner, which is the one it holds at every other
     width. It sat on the right here for a while, from back when the menu button
     owned the bottom left and the two could not share the strip; the menu has
     since moved, so the only thing the right-hand pin still bought was a bin
     that changes sides between a phone and the laptop it syncs with. */
  #bin {
    bottom: calc(max(12px, env(safe-area-inset-bottom)) + var(--bar-lift));
    left: max(12px, env(safe-area-inset-left));
  }
  /* And undo and redo stay at the other end, which is the one thing about this
     row that does not match the desktop. On a laptop they sit against the bin
     because they are the same thought - the bin is the one mistake you can walk
     back by hand and these are every other one - and the pointer costs nothing
     to move between them. Here the cost is a thumb: both ends of this strip are
     comfortable and the middle is the stretch, so a pair pressed as often as
     these belongs in a corner rather than beside one.
     It stays a child of #bin, so the idle fade and the sidebar-open hide still
     take it with everything else; only where it is pinned changes. */
  #history-ctl {
    position: fixed;
    bottom: calc(max(12px, env(safe-area-inset-bottom)) + var(--bar-lift));
    right: max(12px, env(safe-area-inset-right));
  }
  /* Only the width is narrowed - the panel hangs off the left edge here just as
     it does on a desktop, so nothing about which way it opens changes. */
  #bin-panel { width: min(268px, calc(100vw - 24px)); max-height: min(60vh, 420px); }

  /* Up at the top edge, clear of everything the thumb is doing. Right-aligned
     rather than centred: #corner is a column that grows downwards from its
     pin, and the top right is the one part of the screen nothing else claims. */
  #corner {
    top: max(12px, env(safe-area-inset-top));
    bottom: auto;
    right: max(12px, env(safe-area-inset-right));
  }
  /* The readout can afford to be tighter than it is on a desktop: there is no
     room for 58px of it, and the + and - shifting by a few pixels as the zoom
     crosses 100% matters less than the whole bar fitting. */
  #zoom-level { min-width: 46px; }

  /* The ruler goes under the view controls here rather than over them, and it
     turns over with the move.

     Both halves are the same decision. #corner grows downwards from a pin at
     the top of the screen at this width, where on a desktop it grows upwards
     from the bottom - so what was the outside edge of the group is now the
     inside one, and a stick drawn with its baseline at the bottom and its
     serifs rising into the pill above reads as a bracket around the wrong
     thing. Turned over, the baseline is the edge it shares with the controls,
     the serifs hang from it, and the number sits at the foot: controls, rule,
     reading, which is the order a scale bar has under a drawing. */
  #corner { flex-direction: column; }
  #scale-bar { order: 1; flex-direction: column-reverse; padding: 2px 2px 0; }
  #scale-bar-stick::before { bottom: auto; top: 0; }

  /* The coordinate readout is the first thing to go. It is a thing to glance
     at while working with a mouse, and there is no cursor to report. */
  #hud { display: none; }

  /* And with the panel open, the view controls go too - so nothing is left on
     the glass but the panel.

     Only at this width, and that is the whole of the reasoning. On a desktop
     the sidebar is a sheet laid over one edge of a board you can still see and
     still use, so the zoom controls in the far corner are as live as they ever
     were. Here the panel *is* the screen: the board behind it is not visible,
     the scale bar is measuring something nobody can look at, and a zoom control
     floating over a full-screen panel reads as a piece of the panel - one that
     does nothing you would expect a control on this screen to do. The menu
     button, the bin and the add bar already leave for exactly this reason; this
     is the last corner that stayed.

     visibility with the opacity, and delayed by the fade's own length, so the
     controls finish fading before they leave the tab order and come back the
     instant the panel starts to close. The same pairing #bin and #add-bar use -
     a control faded to nothing that can still be pressed is worse than one that
     is simply there. */
  #sidebar.is-open ~ #corner {
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    transition: opacity var(--dur-base) var(--ease), visibility 0s var(--dur-base);
  }

  /* Under the view controls rather than beside them: at this width a centred
     330px strip and a right-pinned zoom bar are the same piece of screen. */
  #busy { top: calc(max(12px, env(safe-area-inset-top)) + 46px); }

  /* The panel becomes the screen rather than a sheet laid on it. At this width
     the board behind it is not usable anyway, and half a panel is worse than a
     whole one. Insets on all four sides so a notch or a home indicator does not
     eat anything.

     It used to stop 58px short of the bottom to leave the zoom bar showing.
     That was the wrong trade twice over: it spent the scarcest thing on a phone
     - vertical room in a panel that is mostly a scrolling list - on a strip of
     controls that do nothing while the panel is up, and it left a band of live
     board at the foot of a screen you are scrolling with your thumb. */
  #sidebar {
    top: max(10px, env(safe-area-inset-top));
    bottom: max(10px, env(safe-area-inset-bottom));
    left: max(10px, env(safe-area-inset-left));
    right: max(10px, env(safe-area-inset-right));
    width: auto;
    /* Just a fade here. The slide-with-overshoot is a sheet arriving from off
       to the left, which reads only while there is board either side of it to
       arrive over; at this width the panel *is* the screen, so the same motion
       is the whole view lurching sideways. Closed state keeps the transform at
       none so nothing is being animated but the opacity. */
    transform: none;
    transition: opacity var(--dur-base) var(--ease),
                visibility 0s linear var(--dur-base);
  }
  /* A flick that runs off the end of the panel would otherwise carry on into
     the page behind it, which on a phone is the browser's own overscroll. */
  .side-body { overscroll-behavior: contain; }

  /* The find palette is a desktop-width strip; here it wants the width it can
     get, and to sit higher up so the keyboard does not cover its own results. */
  #search {
    top: max(10px, env(safe-area-inset-top));
    left: 10px;
    right: 10px;
    width: auto;
    /* The centring is a `translate`, not a `transform` - cancelling the wrong
       one of those leaves the palette half a screen to the left. */
    translate: none;
  }
}

/* Landscape on a phone: the bar is still at the bottom but there is barely any
   bottom to put it in, so the panel gives up the inset and takes the height. */
@media (max-width: 900px) and (max-height: 480px) {
  #sidebar { top: 6px; bottom: 6px; }
  .side-head { padding: 10px 14px 8px; }
  /* Height is the scarce thing here, and 54px of it is a tenth of the screen.
     The finger has not changed size, so this stops at 44 rather than going
     further. Harmless above 700px, where there is no bar to make room for and
     nothing reads the step. */
  :root { --bar-lift: 56px; }
  #add-bar button { min-height: 44px; }
}

/* ---------------------------------------------------------
   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 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: the bar
   sits against .item's own edges, not the picture's, so insetting the picture
   has already left it on the mat. All it has to do is stop looking like a plate
   - the translucent paper, the rule, the blur and the divider between caption
   and handle were all there to hold the bar off a photograph, and there is no
   photograph behind it now. */
:root[data-whimsy="0"] .item[data-type="image"] .item-bar {
  background: none;
  border-top: 0;
  backdrop-filter: none;
}
:root[data-whimsy="0"] .item[data-type="image"] .item-menu { border-left: 0; }
/* line-height rather than a flex centre, because the caption 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;
  font-size: clamp(11px, calc(var(--half-min, 120px) * 0.09), 20px);
}

/* The handle on the card types that have no caption, at this tier only.
   A song card is the clearest case, and the reason is geometry rather than
   taste. The offset those types take is measured along the straight edges: the
   dots stand 8.5px inside a bar pushed (--card-rule-gap - 5px) off the corner,
   which at Middle leaves them comfortably within the card's inner rule. At this
   end the radius is twice Middle's, and a rounded corner curves away faster
   than its edges suggest - so the *corner* of the dots' box crossed back out
   through the rule while all four of its sides were still inside it. What you
   saw was three dots wedged into the crescent between two curves at the tightest
   point of the card, reading as something that had slipped off the panel.

   So the corner is measured instead of the edges. The rule's arc has radius
   (own-radius - gap) about a centre own-radius in from each side; the nearest
   point of that arc to the corner stands (own-radius * (1 - 1/root2) + gap/root2)
   in, which is the 0.293 and the 0.707. Then 3px of daylight past it, less the
   8.5px the icon is already inset inside its own button.

   Read off --own-radius rather than the tier's 26, so the Corner radius slider
   carries it, and so a card small enough for the browser to have flattened its
   corners gets the flattened figure and not the asked-for one. */
:root[data-whimsy="0"] .item[data-type="audio"] .item-bar,
:root[data-whimsy="0"] .item[data-type="text"] .item-bar,
:root[data-whimsy="0"] .item[data-type="note"] .item-bar,
:root[data-whimsy="0"] .item[data-type="link"] .item-bar,
:root[data-whimsy="0"] .item[data-type="model"] .item-bar,
:root[data-whimsy="0"] .item[data-type="generic"] .item-bar {
  --handle-inset: max(0px, calc(
    var(--own-radius) * 0.293 + var(--card-rule-gap) * 0.707 + 3px - 8.5px));
  right: var(--handle-inset);
  bottom: var(--handle-inset);
}

/* 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); }
