/* EB Garamond, self-hosted (OFL — see public/fonts/OFL.txt). Variable weight
   400-800 in a single file per style, so ⌘B gets a real bold and ⌘I a real
   italic rather than the browser's synthetic slant.
   Split latin / latin-ext by unicode-range exactly as Google Fonts serves it:
   plain English text then costs 44KB instead of 158KB, and the extended file
   is fetched only if a glyph actually needs it. */
@font-face {
  font-family: 'EB Garamond';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url('/fonts/ebgaramond-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'EB Garamond';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url('/fonts/ebgaramond-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
    U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: 'EB Garamond';
  font-style: italic;
  font-weight: 400 800;
  font-display: swap;
  src: url('/fonts/ebgaramond-italic-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'EB Garamond';
  font-style: italic;
  font-weight: 400 800;
  font-display: swap;
  src: url('/fonts/ebgaramond-italic-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
    U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  /* Wrap width for a text entry, world px. One definition, used by both the
     placed entry and the placement ghost. */
  --text-wrap-width: 360px;

  /* The selectable board faces, one variable per key in pin-pure.js's
     FONT_FAMILIES. A stored entry carries the KEY ('garamond'), never a font
     stack, so restyling a face is a change here and nowhere else.
     UI chrome (toolbar, hint strip, peer labels) deliberately stays sans. */
  --entry-font-garamond: 'EB Garamond', Georgia, 'Times New Roman', serif;
  /* Helvetica is a system face on macOS; the fallbacks cover everywhere else,
     where it either isn't installed or is a metric-compatible substitute. */
  --entry-font-helvetica: Helvetica, 'Helvetica Neue', Arial, sans-serif;

    /* What a text object gets when it names no font — legacy rows from before
      font_family existed, and the starting face for a fresh pin. */
    --entry-font: var(--entry-font-helvetica);

  /* The twelve island grounds, one variable per key in world-colors.js. An
     island stores the KEY ('moss'), never a colour, so repainting a preset is
     a change here and nowhere else — and no island can be a colour that isn't
     on this list.

     All mid-to-light on purpose: entries render in a fixed dark ink over this,
     and a ground dark enough to swallow them would make an island unreadable
     to every visitor, not just to the owner who picked it. */
  --world-sand: #cbb994;
  --world-clay: #c39a72;
  --world-wheat: #e2cf9a;
  --world-moss: #9aab7c;
  --world-fern: #7f9e6a;
  --world-sage: #adbfa2;
  --world-lagoon: #86b3ac;
  --world-sky: #a8c4d8;
  --world-slate: #8d9aa8;
  --world-lilac: #b7a8cc;
  --world-rose: #d9a8a8;
  --world-ash: #b3b0a6;
}

/* One rule per preset, read by two consumers: the ground itself (below) and
   the Settings swatches, which are the same colour in a small box. Setting a
   variable rather than `background` directly is what lets both share this
   list instead of restating all twelve twice. */
.world-sand { --ground: var(--world-sand); }
.world-clay { --ground: var(--world-clay); }
.world-wheat { --ground: var(--world-wheat); }
.world-moss { --ground: var(--world-moss); }
.world-fern { --ground: var(--world-fern); }
.world-sage { --ground: var(--world-sage); }
.world-lagoon { --ground: var(--world-lagoon); }
.world-sky { --ground: var(--world-sky); }
.world-slate { --ground: var(--world-slate); }
.world-lilac { --ground: var(--world-lilac); }
.world-rose { --ground: var(--world-rose); }
.world-ash { --ground: var(--world-ash); }

html, body {
  margin: 0;
  height: 100%;
}

/* The viewport lock, and it belongs to the WORLD ALONE. `overflow: hidden` was
   on the bare `html, body` above, which is right for a fixed 100vh plane you
   pan with the arrow keys and catastrophic for every other page: this file is
   loaded by all eight of them, so the feed, the profile page and the three
   machines could not be scrolled at all. A long feed simply ended at the bottom
   of the screen with no way to reach the rest.
   Scoped with `:has()` rather than by adding a class to <html>, because the
   scrolling element is <html> and no script runs early enough to mark it
   without a flash of scrollable layout first. */
html:has(body.world-body),
body.world-body {
  overflow: hidden;
}

#viewport {
  position: relative;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
}

/* The island's ground. The fallback is what paints until app.js has learned
   whose island this is and added the `world-<id>` class — sand, i.e. exactly
   what the board looked like before any of this was choosable, so the common
   case never flashes a colour it is about to replace. */
body {
  background: var(--ground, var(--world-sand));
  font-family: "Helvetica Neue", Arial, sans-serif;
}

/* A Settings swatch: the same ground, boxed. It carries a `world-<id>` class
   of its own, so it reads its colour from the same twelve rules above. */
.world-swatch {
  display: block;
  width: 34px;
  height: 20px;
  background: var(--ground, var(--world-sand));
  border: 1px solid #8a7752;
}

#plane {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0;
  height: 0;
}

/* An entry is the object itself — an image at its own footprint, or bare
   styled text. No card, no background, no border: the chrome that used to make
   every entry a uniform 200x80 tile is gone along with the detail overlay it
   existed to tease. x/y stays the CENTRE via the translate below, which is what
   both the placement ghost and the server contract assume.

   z-index 0 puts the whole corkboard UNDER the y-sorted band (objects, peers,
   avatar all sit at 1..100001 — see Z_ORIGIN in objects-pure.js). Entries have
   no base, so they never join the sort; this preserves exactly the behaviour
   the board had before objects existed, where the avatar was always drawn on
   top of every entry. */
.entry {
  position: absolute;
  transform: translate(-50%, -50%);
  z-index: 0;
}

.entry--image {
  display: block;
  object-fit: contain;
}

.entry--text {
  max-width: var(--text-wrap-width);
  white-space: pre-wrap;
  font-family: var(--entry-font);
  color: #2b2b2b;
  /* Looser than the sans it replaced: Garamond has long descenders and a small
     x-height, so 1.25 crowded the lines. */
  line-height: 1.35;
}

/* An object: an image standing in the world. Positioned by its top-left, which
   objects.js derives from the base bottom-centre anchor via objectGeometry() —
   NOT centre-translated the way an entry is. z-index is inline and comes from
   its world y. */
.island-object {
  position: absolute;
  display: block;
  object-fit: contain;
  user-select: none;
  -webkit-user-drag: none;
}

/* Eraser target. interaction.js applies this ONLY while the eraser is
   equipped, so the board carries no highlight the rest of the time. Offset
   outward so it frames the object instead of cropping into it. Scoped to
   #plane and shared by both target kinds rather than duplicated per kind. */
#plane .active {
  outline: 2px dashed #d64545;
  outline-offset: 4px;
}

/* The local player's avatar lives INSIDE #plane, at world coordinates, exactly
   like a peer or an object — avatar.js writes its left/top and its z-index
   every frame. It used to be a viewport-pinned sibling of #plane at a fixed
   z-index, which made interleaving it with objects impossible: #plane carries a
   transform, so it is a stacking context, and an element outside a stacking
   context can never be painted between its children.
   No `top`/`left` here on purpose — they are inline, per frame. Appearance
   lives on `.avatar`, which remote peers reuse. */

.avatar {
  position: absolute;
  width: 24px;
  height: 24px;
  margin: -12px 0 0 -12px;
  background: #d64545;
  border: 2px solid #7a1f1f;
}

.avatar.facing-up,
.avatar.facing-up-left,
.avatar.facing-up-right { border-radius: 4px 4px 12px 12px; }
.avatar.facing-down,
.avatar.facing-down-left,
.avatar.facing-down-right { border-radius: 12px 12px 4px 4px; }
.avatar.facing-left { border-radius: 12px 4px 4px 12px; }
.avatar.facing-right { border-radius: 4px 12px 12px 4px; }

.avatar:not(.sprited).walking {
  animation: corkboard-avatar-bob 0.3s infinite alternate;
}

@keyframes corkboard-avatar-bob {
  from { transform: scale(1); }
  to { transform: scale(0.85, 1.1); }
}

/* Sprite-sheet avatar convention: every sheet in public/sprites/ is a
   2-column x 8-row grid, every cell the same size. Column 0 = frame A (also
   the idle frame), column 1 = frame B. Row order (counterclockwise from down):
   0=down, 1=down-left, 2=left, 3=up-left, 4=up, 5=up-right, 6=right,
   7=down-right. Because every avatar shares that grid, which character a
   person is amounts to one variable: `--sheet`, set per element by
   avatar-sprites.js. avatar-sprites.js probes each sheet at load and the
   renderers add `sprited` only once it resolves; otherwise the plain red-box
   rules above are the fallback. */
.avatar.sprited {
  --cell-w: 32px;
  --cell-h: 48px;
  width: var(--cell-w);
  height: var(--cell-h);
  margin: calc(var(--cell-h) / -2) 0 0 calc(var(--cell-w) / -2);
  background: var(--sheet, url('/sprites/sprite_sheet.png')) no-repeat;
  background-size: calc(var(--cell-w) * 2) calc(var(--cell-h) * 8);
  background-position: 0 calc(var(--row, 0) * -1 * var(--cell-h));
  border: none;
  border-radius: 0;
  image-rendering: pixelated;
}
.avatar.sprited.facing-down { --row: 0; }
.avatar.sprited.facing-up { --row: 4; }
.avatar.sprited.facing-left { --row: 2; }
.avatar.sprited.facing-right { --row: 6; }
.avatar.sprited.facing-down-left { --row: 1; }
.avatar.sprited.facing-down-right { --row: 7; }
.avatar.sprited.facing-up-left { --row: 3; }
.avatar.sprited.facing-up-right { --row: 5; }
.avatar.sprited.walking {
  animation: sprite-walk 0.4s steps(2) infinite;
}
@keyframes sprite-walk {
  from { background-position-x: 0; }
  to { background-position-x: calc(var(--cell-w) * -2); }
}

/* Presence and object layers. Zero-size and pinned to #plane's origin so a
   peer's translate(x, y) is in the same world coordinates as an entry card's
   left/top.

   NEITHER may carry a z-index. A positioned element with `z-index: auto` does
   not create a stacking context, so its positioned children participate in
   #plane's context directly — which is the only reason a peer can be painted
   between two objects. #peers used to be `z-index: 1`, and that one line was
   enough to trap every peer in a nested context where no object could ever come
   between them. */
#peers,
#objects {
  position: absolute;
  left: 0;
  top: 0;
  width: 0;
  height: 0;
}

.peer {
  position: absolute;
  left: 0;
  top: 0;
}

.peer-label {
  position: absolute;
  left: 0;
  bottom: 28px;
  transform: translateX(-50%);
  white-space: nowrap;
  font-family: sans-serif;
  font-size: 11px;
  color: #2b2b2b;
  pointer-events: none;
  z-index: 11;
}

.peer-bubble,
#own-bubble {
  position: absolute;
  left: 0;
  transform: translateX(-50%);
  width: max-content;
  max-width: min(220px, calc(100vw - 32px));
  padding: 4px 8px;
  background: #fff8e7;
  border: 1px solid #8a7752;
  border-radius: 6px;
  font-family: sans-serif;
  font-size: 12px;
  color: #2b2b2b;
  overflow-wrap: break-word;
  pointer-events: none;
  z-index: 12;
}

.peer-bubble {
  bottom: 48px;
}

.peer-bubble[hidden],
#own-bubble[hidden] {
  display: none;
}

/* Hotbar: a Q-toggled modal slot bar (see hotbar.js). Sits above the plane. */
#hotbar {
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: row;
  gap: 6px;
  z-index: 50;
}

#hotbar[hidden] {
  display: none;
}

.hotbar-slot {
  width: 60px;
  height: 64px;
  background-image: url('/sprites/hotbar.png');
  background-size: 100% 100%;
  image-rendering: pixelated;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}

.hotbar-slot.selected {
  box-shadow: 0 0 0 3px #f5d90a;
  transform: scale(1.08);
}

.hotbar-slot .item-icon {
  width: 36px;
  height: 40px;
  background-image: url('/sprites/eraser.png');
  background-size: 100% 100%;
  image-rendering: pixelated;
}

/* Equipped item floating above the avatar's head. */
#held-item {
  position: fixed;
  top: calc(50% - 64px);
  left: 50%;
  margin-left: -13px;
  width: 27px;
  height: 30px;
  background-image: url('/sprites/eraser.png');
  background-size: 100% 100%;
  image-rendering: pixelated;
  z-index: 10;
  animation: held-item-bob 1s ease-in-out infinite alternate;
}

#held-item[hidden] {
  display: none;
}

@keyframes held-item-bob {
  from { transform: translateY(0); }
  to { transform: translateY(-6px); }
}

/* Armed target: about to be erased. Must visually coexist with .active, the
   eraser's proximity outline. */
#plane .armed {
  outline: 3px solid #d33;
  animation: armed-shake 0.25s infinite;
}

@keyframes armed-shake {
  0%, 100% { translate: 0 0; }
  25% { translate: -2px 0; }
  75% { translate: 2px 0; }
}

/* Placement ghost (public/pin.js). Lives inside #plane so it rides the camera
   with everything else — no second transform to keep in sync with avatar.js.
   pointer-events:none so it can never swallow the click that places it.

   Sits ABOVE the whole y-sorted band (which tops out at 100001) rather than
   inside it: a preview must never be occluded by the very thing you are
   positioning it against. */
#pin-ghost {
  position: absolute;
  transform: translate(-50%, -50%);
  pointer-events: none;
  z-index: 200000;
}

/* The object ghost anchors at its BASE bottom-centre, like a placed object, so
   it cannot reuse #pin-ghost's centre translate. The image inside is offset by
   objects.js, matching objectGeometry(). */
#pin-ghost.pin-ghost--object {
  transform: none;
}

/* The actual pixels at reduced opacity inside a dashed outline: strictly more
   informative than a bare outline at the same footprint, so what you see is
   what lands. */
.pin-ghost--image {
  outline: 1px dashed #2b2b2b;
}

.pin-ghost--image img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  opacity: 0.6;
  display: block;
}

/* The object ghost is a zero-size node AT the base anchor; both children hang
   off it at the offsets objectGeometry() computes, so the preview and the
   placed object cannot disagree about where it lands. */
.pin-ghost--object img {
  position: absolute;
  object-fit: contain;
  opacity: 0.65;
  display: block;
}

/* The patch of ground the object will occupy. Filled, not merely outlined:
   "where it stands" has to be legible at a glance, and an outline reads as a
   selection rather than a footprint. The bottom edge is the sort line. */
.pin-ghost-base {
  position: absolute;
  background: rgba(214, 69, 69, 0.28);
  border-bottom: 2px solid #d64545;
  outline: 1px dashed #7a1f1f;
}

/* A passable object still sorts, it just never blocks — shown as an empty
   footprint so it cannot be mistaken for a solid one. */
.pin-ghost-base--passable {
  background: rgba(43, 43, 43, 0.1);
  border-bottom-style: dashed;
}

/* Text renders live in its FINAL style — same font stack, wrap width and
   line-height as a placed .entry--text, just slightly translucent. */
.pin-ghost--text {
  max-width: var(--text-wrap-width);
  white-space: pre-wrap;
  font-family: var(--entry-font);
  color: #2b2b2b;
  line-height: 1.35;
  opacity: 0.75;
}

/* Single hint line replacing the old composer chrome. Low-contrast and
   pointer-transparent: it's a reminder, not a control. Revealed by
   initPinning(), so a visitor never sees it. */
#pin-hint {
  position: fixed;
  bottom: 10px;
  left: 12px;
  z-index: 150;
  font-family: sans-serif;
  font-size: 11px;
  color: rgba(43, 43, 43, 0.6);
  pointer-events: none;
}

#pin-hint[hidden] {
  display: none;
}

/* Offscreen rather than display:none — a display:none element cannot take
   focus, and text mode's whole guard story depends on the text input
   actually being focused. */
#pin-file,
#pin-object-file,
#pin-text-input {
  position: fixed;
  left: -9999px;
  top: 0;
  width: 1px;
  height: 1px;
  opacity: 0;
}

/* Per-entry font modifiers.
   MUST stay at the end of this file: these are single-class selectors, the
   same specificity as the `font-family: var(--entry-font)` in .entry--text and
   .pin-ghost--text, so source order is the only thing that makes them win.
   Move them above those rules and they silently stop working.
   Both the placed entry and the ghost are listed together so the live preview
   can never drift from what actually lands. */
.entry--font-garamond,
.pin-ghost--font-garamond {
  font-family: var(--entry-font-garamond);
}

.entry--font-helvetica,
.pin-ghost--font-helvetica {
  font-family: var(--entry-font-helvetica);
}
