/* ==========================================================================
   FREE WALL — the anonymous public wall that sits above the footer.

   MILESTONE 1: environment only. Three aligned image/art layers, a master
   coordinate space, mask infrastructure, and the mobile pan navigation.
   No text composer, no spray, no stamps, no persistence yet.

   This file SHIPS NORMALLY (~7KB); only js/freewall.js is lazy-loaded, by a
   small IntersectionObserver at the bottom of the section in index.html.

   That split is deliberate and was learned the hard way. When this file was
   lazy-loaded too, the section's two <img> tags laid out at their natural
   1915x821 until the visitor got close — the section stood 1966px tall
   instead of 715px, which is a phantom ~1250px of page height on a page
   whose hero is ScrollTrigger-pinned, and it made the images big enough
   that the browser fetched them immediately rather than honouring
   loading="lazy". The styles are what reserve the box; they have to be here
   from first paint. The interaction code is the expensive part, and that is
   what stays deferred.

   Everything is scoped under .freewall / .fw-*. It shares the site tokens
   (--gold, --cream, --ink) from css/style.css but defines no globals and
   overrides nothing outside this section.
   ========================================================================== */

.freewall {
  position: relative;
  padding: clamp(56px, 9vw, 104px) 0 clamp(40px, 6vw, 72px);
  background: var(--ink);
  overflow: hidden;
  /* NO content-visibility here. It was tried as a scroll-cost saving and it
     broke the picture: skipping the subtree meant the wall images were
     fetched but never painted, so the section came up showing only the
     semi-transparent foreground over bare background — and stayed that way
     until some unrelated repaint (dragging an item in) forced the base to
     appear. A rendering optimisation that can leave the hero image invisible
     is not worth the frames it saves. The transient will-change below is the
     part of that pass that was actually safe, and it stays. */
}

/* ---------- Intro copy -----------------------------------------------------
   Deliberately minimal. The real "LEAVE YOUR MARK" invitation is going to be
   painted INTO the wall art with the actual spray can, not set in HTML above
   it — so this stays a quiet eyebrow + one placeholder line that is trivial
   to delete once the painted title lands. Do not grow this into a headline. */
.fw-intro {
  max-width: min(680px, calc(100% - 2 * clamp(16px, 5vw, 48px)));
  margin: 0 auto clamp(14px, 2vw, 22px);
  text-align: center;
}
.fw-eyebrow {
  display: block;
  font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .28em;
  text-transform: uppercase;
  color: var(--gold);
  margin-bottom: 10px;
}
.fw-lede {
  margin: 0;
  font-family: 'Bricolage Grotesque', Georgia, serif;
  font-size: clamp(19px, 2.4vw, 28px);
  line-height: 1.32;
  color: var(--cream);
}

/* Reserved from first paint so docking the already-existing inventory never
   shifts the wall. Once committed, the tray is ordinary in-flow content:
   intro -> inventory -> wall -> tools, and therefore cannot cover the art. */
.fw-inventory-dock {
  min-height: 68px;
  margin: 0 auto clamp(18px, 2.8vw, 30px);
  display: grid;
  place-items: center;
}

/* ---------- Stage / viewport ----------------------------------------------
   .fw-stage is the framed box on the page. .fw-viewport is the WINDOW into
   the wall: it clips, and its height is the single source of truth for how
   big the wall is drawn.

   THE HEIGHT IS OWNED BY CSS, NOT JS — on purpose. If JS measured the page
   and then wrote a height, the section would change height after the module
   mounts, which shifts everything below it and forces a ScrollTrigger
   refresh on a page whose hero is pinned. Instead CSS resolves the height,
   JS READS viewport.clientHeight, and the box never moves. Same reasoning as
   frameHeight() in main.js: read the laid-out value, never compute it. */
.fw-stage {
  /* Full bleed. The wall is the hero, so it runs to both edges of the window
     with no gutter and no rounded corners; the bar and the hint below keep
     their own insets because they are UI, not world. */
  position: relative;
  margin: 0;
  width: 100%;
  padding: 0;
}
.fw-viewport {
  position: relative;
  width: 100%;
  overflow: hidden;
  background: #241e18;
  /* Phone default: a tall vertical crop into the wall, deliberately taller
     than "the whole wall scaled to fit" so the masonry reads at human scale
     and there is something to pan across. */
  height: clamp(340px, 58vh, 520px);
  touch-action: pan-y; /* browser keeps vertical page scroll; we take horizontal */
  overscroll-behavior-x: contain;
  cursor: default;
}
@supports (height: 58svh) {
  .fw-viewport { height: clamp(340px, 58svh, 520px); }
}
@media (min-width: 900px) {
  /* Desktop: the whole 1915x821 composition, undistorted, capped so it does
     not become a billboard on an ultrawide display. When max-height bites,
     the wall is narrower than the box and JS centres it. */
  .fw-viewport {
    height: auto;
    /* Aspect ratio and nothing else. No width or height cap: the box is then
       always exactly 1915:821, so the base, the foreground and the mask all
       fill the same rectangle at the same scale and cannot drift out of
       register. A max-height would force object-fit to crop the photographs
       while the mask kept stretching, and the mask would no longer line up
       with the broken edge it describes. */
    aspect-ratio: 1915 / 821;
    touch-action: auto;
  }
  /* No overview exists on desktop (travelFrac is always 0, so
     js/freewall.js never shows the overview button) and .fw-track already
     covers the viewport exactly at this aspect ratio — explicit guarantee
     the square art can never appear here. */
  .fw-overview-bg { display: none; }
}

/* ---------- The master coordinate space ------------------------------------
   .fw-track IS the wall. Its box is exactly the wall's display size, every
   layer inside fills it at 100%/100%, and horizontal panning is ONE transform
   on this element — so base art, baked marks, live marks and the foreground
   can never drift apart from each other no matter what the viewport does.

   isolation:isolate confines the multiply blending below to this stack, so
   the art layers darken the WALL and nothing else on the page. */
.fw-track {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  isolation: isolate;
  transform: translate3d(0, 0, 0);
  transform-origin: 0 0;
  /* NO permanent will-change here. It was promoting this layer to its own
     GPU surface for the whole session — including the entire time the wall
     is 30,000px below the fold on a page whose hero is already expensive.
     will-change is a hint for an imminent change, so it is added only while
     something is actually moving (see .is-gliding / .is-panning below) and
     dropped again the moment it stops. */
}
.fw-track.is-gliding { transition: transform .44s cubic-bezier(.2, .72, .22, 1); will-change: transform; }
.fw-track.is-overview { transition: transform .3s cubic-bezier(.2, .72, .22, 1); will-change: transform; }
.fw-viewport.is-panning .fw-track,
.fw-viewport.is-drawing-mode .fw-track { transition: none; will-change: transform; }

.fw-layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  user-select: none;
  -webkit-user-drag: none;
}

/* 0. OVERVIEW-BG — extended scenery, sitting BEHIND the interactive wall
      stack, filling the gap above/below it when showOverview()
      (js/freewall.js) scales .fw-track down. Outside .fw-track so the
      track's own transform never touches it: "full viewport width, a
      square, vertically centered in the viewport" is, by the same
      geometry showOverview() uses, exactly where the shrunk .fw-track
      lands, with no JS coordinating the two. Center band masked out
      (547px-1462px of the 1915px-square source, i.e. 28.564%-71.436% of
      this box) so the part that duplicates the wall itself never draws —
      only the top/bottom extension art shows. Fades in with the overview
      itself (see .is-overviewing below) instead of just being statically
      there, so the reveal eases in alongside the shrink. */
.fw-overview-bg {
  position: absolute;
  left: 0;
  top: 50%;
  width: 100%;
  height: auto; /* explicit auto: the height="1915" attribute otherwise blocks aspect-ratio's auto-sizing */
  aspect-ratio: 1 / 1;
  transform: translateY(-50%);
  object-fit: cover;
  z-index: 0;
  opacity: 0;
  transition: opacity .3s ease;
  pointer-events: none;
  user-select: none;
  -webkit-user-drag: none;
  -webkit-mask-image: linear-gradient(to bottom,
    #000 0%, #000 28.564%,
    transparent 28.564%, transparent 71.436%,
    #000 71.436%, #000 100%);
  mask-image: linear-gradient(to bottom,
    #000 0%, #000 28.564%,
    transparent 28.564%, transparent 71.436%,
    #000 71.436%, #000 100%);
}
.fw-viewport.is-overviewing .fw-overview-bg { opacity: 1; }

/* 1. BASE — the wall photograph. */
.fw-base { object-fit: cover; z-index: 1; }

/* 2. BAKED — submitted marks, and eventually the flattened history of the
      wall. One canvas, held at the art's native 1915x821 so it is resolution
      independent and never has to be redrawn on resize. multiply makes paint
      sink into the mortar lines instead of sitting on top of them. */
.fw-baked {
  z-index: 2;
  mix-blend-mode: multiply;
  opacity: .92;
  pointer-events: none;
}

/* 3. LIVE — the composition currently being made, as DOM elements so they can
      be dragged, rotated, typed into and focused (Milestone 2).

      THE MASK LIVES HERE, ON THE CONTAINER — not on each mark. That is the
      whole trick: because this box is in untransformed wall space, individual
      marks inside can rotate and scale freely without the mask rotating with
      them. One composited mask layer for the entire live set. */
.fw-live {
  /* MUST be a positioned, full-size box. The mask below is sized 100% 100%
     OF THIS ELEMENT, so if it collapses to height:0 — which it does by
     default, because every mark inside it is absolutely positioned — the
     mask becomes zero-height and silently hides every mark in the layer.
     Milestone 1 had no children here, so nothing revealed it; the first
     piece of text did. Unlike its sibling layers this div is not .fw-layer
     in the markup, so it gets its geometry here. */
  position: absolute;
  inset: 0;
  z-index: 3;
  mix-blend-mode: multiply;
  -webkit-mask-image: url(../assets/freewall/freewall_mask.webp);
  mask-image: url(../assets/freewall/freewall_mask.webp);
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  pointer-events: none;
}

/* 4. FOREGROUND — crumbling edges, crack depth, cast shadow. Sits ABOVE all
      visitor art so the wall keeps its dimension and the art reads as painted
      into the surface rather than pasted over the photo. */
.fw-fore { z-index: 4; object-fit: cover; pointer-events: none; }


/* Horizontal travel now lives in the first toolbar row. The buttons use the
   same quiet chrome as the rest of the composer and disappear when the wall
   fits, so desktop never gets inert arrows. */
.fw-pan-btn {
  -webkit-appearance: none;
  appearance: none;
  flex: 0 0 40px;
  width: 40px;
  height: 40px;
  border-radius: 3px;
  border: 1px solid rgba(241, 236, 225, .28);
  background: rgba(241, 236, 225, .04);
  color: var(--cream-dim);
  display: grid;
  place-items: center;
  cursor: pointer;
  padding: 0;
  touch-action: none;
  -webkit-touch-callout: none;
  user-select: none;
  transition: background .2s, border-color .2s, color .2s, transform .16s, opacity .2s;
}
.fw-pan-btn:hover:not([disabled]) { border-color: rgba(246, 186, 2, .6); color: var(--gold); }
.fw-pan-btn:active { transform: scale(.93); }
.fw-pan-btn:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
.fw-pan-btn[disabled] { opacity: .24; cursor: default; }
.fw-pan-btn[hidden] { display: none; }
.fw-pan-btn svg { width: 19px; height: 19px; }

/* A click-to-overview toggle. It is viewport chrome, never part of the
   panning track, and open/closed (.is-held) is its only state. */
.fw-overview {
  position: absolute;
  z-index: 8;
  top: 12px;
  right: 12px;
  width: 40px;
  height: 40px;
  display: grid;
  place-items: center;
  padding: 0;
  /* Resting state reads gold, same as :hover/.is-held below just dimmer —
     it used to fade to the same neutral cream-dim as everything else in the
     bar, which read as "off" against a busy wall photo. This is the one
     always-on control that needs to stay discoverable at a glance rather
     than earning its color on interaction. */
  border: 1px solid rgba(246, 186, 2, .45);
  border-radius: 3px;
  background: rgba(246, 186, 2, .05);
  color: var(--gold);
  cursor: zoom-out;
  touch-action: none;
  -webkit-touch-callout: none;
  user-select: none;
  transition: color .2s, border-color .2s, background .2s, opacity .2s;
}
.fw-overview[hidden] { display: none; }
.fw-overview:hover, .fw-overview.is-held { color: var(--gold); border-color: rgba(246, 186, 2, .6); background: rgba(246, 186, 2, .09); }
.fw-overview:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
/* Zoomed out, the cursor still reads "zoom out"; while the overview is open,
   the same click zooms back in, so the cursor and the icon both flip — the
   two outward carets become two inward ones, still built from the same
   glyph, so the flip is one transform rather than a swapped icon. */
.fw-overview.is-held { cursor: zoom-in; }
.fw-overview svg { width: 20px; height: 20px; transition: transform .3s ease; }
.fw-overview.is-held svg { transform: rotate(180deg); }

/* ---------- Status line ---------------------------------------------------- */
.fw-status {
  margin: clamp(14px, 2vw, 20px) auto 0;
  text-align: center;
  font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
  font-size: 11px;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--cream-dim);
  opacity: .62;
}

/* ---------- Loading / no-JS ------------------------------------------------
   Before the module mounts, the wall is still a correct static picture: base
   under foreground, no crop, no nav. If JS never runs at all, that is what a
   visitor gets, and it looks deliberate. */
.freewall:not(.is-live) .fw-track { width: 100%; }
.freewall:not(.is-live) .fw-overview { display: none; }

/* No-JS fallback: a plain picture of the wall, same box, no interaction. */
.fw-noscript-wall {
  display: block;
  width: 100%;
  height: auto;
  margin: 0 auto;
}

/* ---------- Motion / grabbing ---------------------------------------------- */
.fw-viewport.is-pannable { cursor: grab; }
.fw-viewport.is-panning { cursor: grabbing; }
.fw-viewport.is-panning .fw-track { transition: none; }

@media (prefers-reduced-motion: reduce) {
  .fw-track.is-gliding { transition: none; }
  .fw-pan-btn, .fw-overview, .fw-overview svg { transition: none; }
}

/* ==========================================================================
   MILESTONE 2 — THE TEXT COMPOSER

   Three new layers go into .fw-track, and WHICH layer a thing lives in is
   the whole design:

     .fw-live    unsent paint      masked + multiply, below the foreground
     .fw-edit    the node you type masked NO / multiply yes, below foreground
     .fw-chrome  handles + frame   no mask, no blend, ABOVE the foreground

   Chrome must not be masked, or a selection handle would fade out over the
   sky. Paint must be masked, or it would cross onto the desert. They cannot
   be the same layer, which is why there are three.

   Sizing is in em. Each layer's own font-size is set to the wall's display
   height in px by measure(), and a mark stores its size as a fraction of
   wall height — so "0.075em" IS 7.5% of the wall, and one assignment per
   layer rescales every mark on resize. No per-mark recalculation.
   ========================================================================== */

/* Unsent paint. opacity matches .fw-baked so a mark does not change
   appearance at the moment it is submitted. */
.fw-live { opacity: .92; }

/* The single node being typed into. Keeps multiply so it still reads as
   paint going onto concrete, drops the mask so the caret is never clipped,
   and stays under .fw-fore so the wall's shadows sit over live text. */
.fw-edit {
  position: absolute;
  inset: 0;
  z-index: 3;
  mix-blend-mode: multiply;
  opacity: .92;
  pointer-events: none;
}

/* Hit-boxes, selection frame and handles. Above the foreground so the grit
   never veils the controls. */
.fw-chrome {
  position: absolute;
  inset: 0;
  z-index: 6;
  pointer-events: none;
}

/* ---------- A mark ---------------------------------------------------------
   Paint node and hit-box share this geometry so they cannot drift. Position
   is a percentage of the wall — the master coordinate system, straight
   through to CSS, no pixels anywhere. */
.fw-mark,
.fw-hit,
.fw-editor {
  position: absolute;
  margin: 0;
  white-space: pre;
  text-align: center;
  transform-origin: 50% 50%;
}
.fw-mark, .fw-editor {
  pointer-events: none;
  padding: 0;
  border: 0;
  background: none;
}

/* Marks must not read as web typography: no antialiasing polish, a little
   tilt by default, and never the site's own type stack. */
.fw-mark, .fw-editor {
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* The invisible grab target. touch-action:none is what keeps a finger that
   starts HERE from panning the wall or scrolling the page — set statically,
   because the browser reads touch-action when the gesture begins, so
   flipping it in pointerdown would already be too late. */
.fw-hit {
  pointer-events: auto;
  touch-action: none;
  cursor: grab;
  border: 1px dashed transparent;
  border-radius: 2px;
}
.fw-hit.is-selected {
  border-color: rgba(246, 186, 2, .85);
  background: rgba(246, 186, 2, .06);
}
.fw-hit.is-dragging { cursor: grabbing; }
.fw-hit.is-hidden { display: none; }

/* ---------- Handles --------------------------------------------------------
   Two only: resize and rotate. 30px hit areas on a 13px dot, because a
   handle you cannot hit with a thumb is not a handle. They rotate with the
   mark, which is correct — the resize corner stays the bottom-right corner
   of the text no matter how it is tilted. */
.fw-handle {
  position: absolute;
  width: 30px;
  height: 30px;
  margin: -15px 0 0 -15px;
  padding: 0;
  border: 0;
  background: none;
  display: grid;
  place-items: center;
  cursor: pointer;
  touch-action: none;
  pointer-events: auto;
}
.fw-handle::before {
  content: '';
  width: 13px;
  height: 13px;
  border-radius: 50%;
  background: var(--gold);
  box-shadow: 0 0 0 2px rgba(13, 13, 12, .55);
  transition: transform .15s;
}
.fw-handle:hover::before { transform: scale(1.22); }
.fw-handle:focus-visible { outline: 2px solid var(--gold); outline-offset: 0; border-radius: 50%; }
.fw-handle-size   { left: 100%; top: 100%; cursor: nwse-resize; }
.fw-handle-rotate { left: 100%; top: 0;    cursor: grab; }
.fw-handle-rotate::before { background: var(--cream); }

/* ---------- The bar --------------------------------------------------------
   The wall is the hero, so there is no persistent toolbar. This appears
   under the wall only once there is something to control, and it sits
   OUTSIDE .fw-viewport on purpose: it never covers the art, and the mobile
   keyboard can slide over it without hiding the wall. */
.fw-bar {
  display: grid;
  align-items: center;
  justify-content: center;
  gap: 10px;
  margin: clamp(14px, 2.2vw, 20px) auto 0;
  padding: 0 16px;
  max-height: 0;
  opacity: 0;
  overflow: hidden;
  transition: max-height .32s ease, opacity .28s ease, margin .32s ease;
}
/* overflow must open up too, or the locked-tool hint — which sits above its
   button — gets clipped by the collapse mechanism it is nowhere near. */
.fw-bar.is-open { max-height: 260px; opacity: 1; overflow: visible; }
.fw-bar-row {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(8px, 2vw, 16px);
}
.fw-bar-primary { flex-wrap: nowrap; justify-content: space-between; }
.fw-bar-actions { flex-wrap: wrap; }
.fw-group { display: flex; align-items: center; gap: 8px; }
/* display:flex above out-ranks the user-agent rule for [hidden], so a group
   toggled off in JS would otherwise stay on screen. Contextual controls —
   the font swatches in text mode, the stamp shapes in stamp mode — depend
   on this actually hiding them. */
.fw-group[hidden] { display: none; }
.fw-sep { width: 1px; height: 22px; background: rgba(241, 236, 225, .16); }
@media (max-width: 600px) { .fw-sep { display: none; } }


/* Ink dots. The swatch is the ink itself over a scrap of wall colour, so
   what you pick is what lands. */
.fw-ink {
  -webkit-appearance: none; appearance: none;
  width: 34px; height: 34px;
  padding: 0;
  border: 1px solid rgba(241, 236, 225, .2);
  border-radius: 50%;
  background: #cfc4b3;
  cursor: pointer;
  display: grid; place-items: center;
  transition: border-color .2s, transform .2s;
}
.fw-ink::before {
  content: ''; width: 20px; height: 20px; border-radius: 50%;
  background: var(--fw-ink); mix-blend-mode: multiply;
}
.fw-ink:hover { transform: scale(1.08); }
.fw-ink.is-on { border-color: var(--gold); border-width: 2px; }

/* Session actions. */
.fw-act {
  -webkit-appearance: none; appearance: none;
  height: 40px; min-width: 40px;
  padding: 0 13px;
  border: 1px solid rgba(241, 236, 225, .22);
  border-radius: 3px;
  background: none;
  color: var(--cream-dim);
  font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
  font-size: 11px; font-weight: 600; letter-spacing: .12em; text-transform: uppercase;
  cursor: pointer;
  display: grid; place-items: center;
  transition: color .2s, border-color .2s, opacity .2s;
}
.fw-act:hover:not([disabled]) { color: var(--gold); border-color: rgba(246, 186, 2, .55); }
.fw-act[disabled] { opacity: .3; pointer-events: none; }
.fw-act svg { width: 17px; height: 17px; }
.fw-act-danger:hover:not([disabled]) { color: #d9553c; border-color: rgba(217, 85, 60, .6); }

.fw-post {
  border-color: var(--gold);
  color: var(--gold);
  padding: 0 20px;
}
.fw-post:hover:not([disabled]) { background: var(--gold); color: var(--ink); }

/* ---------- Hint + nudge ---------------------------------------------------
   One quiet line, replaced by a short-lived message when something is
   refused. Never a dialog, never a scold. */
.fw-hint {
  margin: clamp(12px, 1.8vw, 18px) auto 0;
  /* Needs its own gutter: this sits outside .fw-stage, so without one the
     longest hint runs off both edges of a phone. */
  padding: 0 18px;
  max-width: 40em;
  min-height: 1.2em;
  text-align: center;
  text-wrap: balance;
  font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
  font-size: 11.5px;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--cream-dim);
  opacity: .62;
  transition: color .25s, opacity .25s;
}
.fw-hint.is-nudge { color: var(--gold); opacity: 1; }
.fw-hint.is-changing { opacity: 0; }

/* ---------- Post confirmation ----------------------------------------------
   Deliberately not a site-wide modal: it covers the wall it is talking
   about, and nothing else on the page. */
.fw-confirm {
  position: absolute;
  inset: 0;
  z-index: 12;
  display: grid;
  place-items: center;
  padding: 20px;
  background: rgba(13, 13, 12, .74);
  -webkit-backdrop-filter: blur(3px);
  backdrop-filter: blur(3px);
  opacity: 0;
  visibility: hidden;
  transition: opacity .26s, visibility .26s;
}
.fw-confirm.is-open { opacity: 1; visibility: visible; }
.fw-confirm-card {
  width: min(400px, 100%);
  text-align: center;
  color: var(--cream);
}
.fw-confirm-title {
  margin: 0 0 14px;
  font-family: 'Bricolage Grotesque', Georgia, serif;
  font-size: clamp(20px, 3.2vw, 27px);
  line-height: 1.2;
}
.fw-confirm-body {
  margin: 0 0 22px;
  font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
  font-size: 13.5px;
  line-height: 1.65;
  color: var(--cream-dim);
}
.fw-confirm-actions { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; }
.fw-confirm-actions .fw-act { height: 44px; }

/* ---------- Suspended navigation -------------------------------------------
   While a mark is being typed into, the wall must not move under the
   visitor. The nav goes away and the pan handler stands down; both come
   straight back on commit. */
.freewall.is-editing .fw-pan-btn { opacity: .22; pointer-events: none; }
.freewall.is-editing .fw-viewport { cursor: text; touch-action: none; }
.freewall.is-editing .fw-overview { opacity: .22; pointer-events: none; }
.fw-viewport.is-overviewing .fw-track { pointer-events: none; }
.fw-viewport.is-holding { touch-action: none; }

@media (max-width: 899px) {
  .fw-tool.is-desktop-only { opacity: .28; }
  /* Without this, a mouse hovering the button at a narrow window width fell
     through to .fw-act's generic gold hover — the exact "looks available"
     read this state must avoid. Below 900px Text stays visually inert.
     Ordered BEFORE .is-telling below: the two rules are equal specificity
     (three classes each), so source order decides which wins when a mouse
     hovers the button at the moment it is also clicked — .is-telling's red
     press must win that, not this neutral hover. */
  .fw-tool.is-desktop-only:hover { color: var(--cream-dim); border-color: rgba(241, 236, 225, .22); }
  /* Same red "you can't use this" press as a locked tool's .is-telling —
     never gold, so a tap can never read as picking an available tool. */
  .fw-tool.is-desktop-only.is-telling { opacity: .85; color: #d9553c; border-color: rgba(217, 85, 60, .75); }
}

@media (prefers-reduced-motion: reduce) {
  .fw-bar, .fw-confirm, .fw-hint, .fw-handle::before, .fw-ink, .fw-style, .fw-act { transition: none; }
  .fw-tool.is-hinting { animation: none; }
}

/* ---------- Editor mechanics -----------------------------------------------
   The live editor is the only thing in .fw-edit that takes a pointer, and
   touch-action:none keeps a finger placing the caret from panning the wall.
   min-width gives an empty mark something to aim at before a single
   character exists. */
.fw-editor {
  outline: none;
  min-width: .55em;
  min-height: 1em;
  caret-color: currentColor;
}
.fw-editor.is-live { pointer-events: auto; touch-action: none; }

/* A mark whose text is currently being typed in .fw-edit. visibility, NOT
   display: the box has to keep laying out, because the hit-box reads its
   width and height to stay the right size as the sentence grows. */
.fw-mark.is-ghost { visibility: hidden; }

/* ==========================================================================
   MILESTONE 3 — COLLECTIBLE TOOLS

   Spray and stamps are unlocked by dragging the object the visitor picked
   up in the desert onto the wall. Everything here is placeholder chrome for
   that gesture; the artwork itself is token data in js/freewall.js.
   ========================================================================== */

/* The stroke being drawn right now. It lives in .fw-live, so it is masked
   and multiply-blended exactly like a finished mark — what you see while
   dragging is what you get when you let go. Full-wall box in absolute
   wall-height coordinates, because a stroke has no centre until it ends. */
.fw-spray-preview {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  display: block;
}

/* Spray draws with a drag, so nothing else may claim one. */
.fw-viewport.is-drawing-mode { touch-action: none; cursor: crosshair; }
.fw-viewport.is-drawing-mode .fw-hit { pointer-events: none; }

/* Marks that are drawings rather than words: the shared .fw-mark rules are
   type rules, and an SVG/canvas child needs none of them. Spray and stamp
   marks are raster now (a <canvas> per mark), text stays DOM type — both
   share this box. */
.fw-mark svg, .fw-hit svg,
.fw-mark canvas, .fw-hit canvas { display: block; width: 100%; height: 100%; overflow: visible; }

/* ---------- Carrying an object ---------------------------------------------
   The ghost that follows the pointer between the item tray and the wall.
   position:fixed and pointer-events:none so it never blocks the drop it is
   heading for. */
.fw-carry {
  position: fixed;
  z-index: 200;
  width: 62px;
  height: 62px;
  margin: -31px 0 0 -31px;
  pointer-events: none;
  filter: drop-shadow(0 10px 18px rgba(0, 0, 0, .55));
  transform: rotate(-7deg) scale(1.08);
}
.fw-carry img { width: 100%; height: 100%; object-fit: contain; }
.fw-carry[hidden] { display: none; }

/* While something is in hand, nothing on the page should look selectable. */
body.fw-is-carrying { cursor: grabbing; }
body.fw-is-carrying * { user-select: none; }

/* The wall answers when it is a valid place to put the thing down. */
.fw-viewport.is-drop-target { box-shadow: inset 0 0 0 2px var(--gold), 0 0 34px rgba(246, 186, 2, .3); }

/* Placeholder acknowledgement of the drop: a ring opening where it landed. */
.fw-drop-ring {
  position: absolute;
  width: 14px; height: 14px;
  margin: -7px 0 0 -7px;
  border-radius: 50%;
  border: 2px solid var(--gold);
  pointer-events: none;
  animation: fw-drop-open .62s cubic-bezier(.16, .84, .44, 1) forwards;
}
@keyframes fw-drop-open {
  0%   { opacity: 0; transform: scale(.4); }
  22%  { opacity: 1; }
  100% { opacity: 0; transform: scale(11); border-width: 1px; }
}

/* ---------- Tool strip -----------------------------------------------------
   Shown only when there is more than one tool to choose between, so a
   visitor who never found anything never sees a tool selector at all. */
.fw-tool.is-on { color: var(--gold); border-color: var(--gold); background: rgba(246, 186, 2, .09); }
.fw-tool svg { width: 18px; height: 18px; }


@media (prefers-reduced-motion: reduce) {
  .fw-drop-ring { animation-duration: 1ms; }
  .fw-carry { transform: none; }
}





/* ==========================================================================
   THE OBJECTS COMING BACK

   Centred at the bottom of the screen, exactly where they were during the
   hero pan — the same tray in the same place, so it reads as the things
   returning rather than a new piece of UI. The pop is deliberately
   overshot and staggered: it has to be noticed, because noticing it is the
   reason someone keeps scrolling past the bio.

   Empty boxes stay visible on purpose. The gaps are the puzzle.
   ========================================================================== */
body.fw-tray-hold .find-inventory.is-visible .find-slot {
  animation: fw-slot-drop .58s cubic-bezier(.2, 1.5, .42, 1) backwards;
}
body.fw-tray-hold .find-inventory.is-visible .find-slot:nth-child(1) { animation-delay: .02s; }
body.fw-tray-hold .find-inventory.is-visible .find-slot:nth-child(2) { animation-delay: .11s; }
body.fw-tray-hold .find-inventory.is-visible .find-slot:nth-child(3) { animation-delay: .2s; }

@keyframes fw-slot-drop {
  0%   { opacity: 0; transform: translateY(-46px) scale(.34) rotate(-14deg); }
  55%  { opacity: 1; transform: translateY(7px)  scale(1.16) rotate(4deg); }
  78%  { transform: translateY(-3px) scale(.97) rotate(-1.5deg); }
  100% { opacity: 1; transform: none; }
}

/* An empty box should read as a gap waiting to be filled, not as a disabled
   control — it is the only clue that there is anything to go and find. */
body.fw-tray-hold .find-slot:disabled {
  border-style: dashed;
  border-color: rgba(244, 224, 187, .3);
  background: rgba(29, 23, 21, .42);
}

/* ---------- Tool buttons: always three, two usually locked ----------------- */
.fw-tool { position: relative; }
.fw-tool.is-locked { opacity: .34; }
.fw-tool.is-locked.is-telling { opacity: .85; color: #d9553c; border-color: rgba(217, 85, 60, .75); }

/* A small "there is something to find" nudge, timed from JS
   (pulseLockedTools) — brief and gold, so it reads as a game hint rather
   than the red .is-telling click/hover feedback above, which stays reserved
   for "you tried to use this and can't". Locked state itself is untouched;
   this only plays a few frames of colour over it. */
@keyframes fw-tool-hint {
  0%, 100% { opacity: .34; color: inherit; border-color: rgba(241, 236, 225, .22); }
  45%      { opacity: .85; color: var(--gold); border-color: rgba(246, 186, 2, .65); }
}
.fw-tool.is-locked.is-hinting { animation: fw-tool-hint 1.1s ease-in-out; }

/* The hint. Hover on a pointer, and on a phone a tap turns .is-telling on
   for a couple of seconds, so the same line is reachable without a cursor. */
.fw-locktip {
  position: absolute;
  bottom: calc(100% + 9px);
  /* Anchored to the button's LEFT edge, not centred on it. The tool strip is
     always the leftmost thing in the bar, so a centred bubble on the first
     or second button runs off the side of the page — measured at -6px. */
  left: 0;
  transform: translate(0, 5px);
  white-space: nowrap;
  padding: 7px 11px;
  border: 1px solid rgba(217, 85, 60, .5);
  border-radius: 3px;
  background: var(--ink-raised);
  color: #e8836c;
  font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: .04em;
  text-transform: none;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity .18s, transform .18s, visibility .18s;
  z-index: 30;
}
.fw-tool.is-locked.is-telling .fw-locktip { opacity: 1; visibility: visible; transform: translate(0, 0); }
/* Text/Marker isn't "locked" — it's always in the unlocked set, just inert
   below 900px for an unrelated reason (see js/freewall.js) — so it never
   carries .is-locked and this selector needed its own line, or the tap
   handler's .is-telling toggle had a tooltip span to reveal but no rule
   that ever showed it. */
.fw-tool.is-desktop-only.is-telling .fw-locktip { opacity: 1; visibility: visible; transform: translate(0, 0); }
@media (hover: hover) and (pointer: fine) {
  .fw-tool.is-locked:hover { opacity: .85; color: #d9553c; border-color: rgba(217, 85, 60, .75); }
  .fw-tool.is-locked:hover .fw-locktip { opacity: 1; visibility: visible; transform: translate(0, 0); }
}
/* On a phone the hint has to wrap rather than run off the side. */
@media (max-width: 620px) {
  .fw-locktip { white-space: normal; width: min(64vw, 240px); }
}

/* ---------- One style group for all three tools --------------------------- */
.fw-style {
  -webkit-appearance: none; appearance: none;
  min-width: 46px; height: 40px;
  padding: 0 10px;
  border: 1px solid rgba(241, 236, 225, .22);
  border-radius: 3px;
  background: rgba(241, 236, 225, .04);
  color: var(--cream-dim);
  font-size: 17px; line-height: 1;
  cursor: pointer;
  display: grid; place-items: center;
  transition: border-color .2s, color .2s, background .2s;
}
.fw-style:hover { border-color: rgba(246, 186, 2, .6); color: var(--cream); }
.fw-style.is-on { border-color: var(--gold); color: var(--gold); background: rgba(246, 186, 2, .09); }
.fw-style-font { color: var(--cream); }
.fw-style-font.is-on { color: var(--gold); }
/* Font buttons on a device where Text itself is unavailable — same opacity
   as .fw-tool.is-desktop-only, so the two read as one disabled state. */
.fw-style.is-unavailable { opacity: .28; pointer-events: none; }
.fw-style-stamp { width: 42px; padding: 9px; }
.fw-style-stamp img { width: 100%; height: 100%; display: block; object-fit: contain; }
/* Round tips preview as dots; the fan previews as a flat directional cap. */
.fw-tip { display: block; border-radius: 50%; background: currentColor; }
.fw-style-spray { color: var(--cream); }
.fw-style-spray.is-on { color: var(--gold); }

@media (prefers-reduced-motion: reduce) {
  body.fw-tray-hold .find-inventory.is-visible .find-slot { animation: none; }
  .fw-locktip { transition: none; }
}

/* The tray becomes ordinary section content after its approach animation. */
body.fw-tray-docked .find-inventory {
  position: relative;
  inset: auto;
  left: auto;
  bottom: auto;
  transform: none;
  z-index: 2;
}
body.fw-tray-docked .find-inventory.is-visible { transform: none; }

@media (max-width: 600px) {
  .fw-bar { padding-inline: 8px; }
  .fw-bar-primary { gap: 4px; }
  .fw-bar-primary .fw-group { gap: 5px; }
  .fw-tool, .fw-style { width: 34px; min-width: 34px; padding-inline: 0; }
  .fw-pan-btn { flex-basis: 32px; width: 32px; }
}

/* Desktop keeps the established single-line composer. Its three semantic
   rows become transparent wrappers, allowing the existing control clusters
   to wrap naturally only if the available width genuinely runs out. */
@media (min-width: 900px) {
  .fw-bar {
    display: flex;
    flex-wrap: wrap;
    gap: clamp(10px, 2.2vw, 20px);
  }
  .fw-bar-row { display: contents; }
}

/* ==========================================================================
   HEAT — PLACEHOLDER TREATMENT

   Everything below is deliberately provisional. The state machine in
   js/freewall.js only ever writes [data-heat="inactive|active|relieved"] on
   the section; it decides nothing about how that looks. Retune, replace or
   throw away this whole block without touching a line of JS.

   Performance rules this block keeps to, because the page is already busy:
     - ONE element, and only `opacity` is ever animated
     - no filter, no backdrop-filter, no animated gradients or shadows
     - no animation at all unless the heat is actually active, so idle cost
       is zero
   ========================================================================== */
.fw-heat {
  position: absolute;
  inset: 0;
  z-index: 5;            /* over .fw-fore (4), under .fw-chrome (6) */
  pointer-events: none;
  opacity: 0;
  transition: opacity 1.8s ease;
  /* Warm at the edges, hot light spilling in from above. Amber-leaning
     rather than red, so it does not fight the oxblood ink at the margins. */
  background:
    radial-gradient(122% 94% at 50% 54%, rgba(0, 0, 0, 0) 34%, rgba(158, 44, 14, .62) 100%),
    radial-gradient(112% 64% at 50% 0%, rgba(255, 158, 52, .34), rgba(0, 0, 0, 0) 64%);
}

.freewall[data-heat="active"] .fw-heat {
  opacity: 1;
  /* --fw-heat-interval is written by js/freewall-sound.js from the same
     SoundMix.getParam('heatInterval') its own audio pulse reads — the CSS
     animation is the one clock, and the audio schedules itself off this
     animation's real events rather than keeping a second timer in sync.
     5.2s if that JS never runs (no-JS fallback / load-order edge). */
  animation: fw-heat-breathe var(--fw-heat-interval, 5.2s) ease-in-out infinite;
}
/* Once the cola has been drunk the sun is GONE — not faded, not lingering at
   some low value. Killed outright after the cool-down so nothing warm is
   left over the wall. */
.freewall[data-heat="relieved"] .fw-heat {
  opacity: 0;
  animation: none;
  visibility: hidden;
  transition: opacity 1.6s ease, visibility 0s linear 1.6s;
}

/* A real swing, not a shimmer — it has to be visible as breathing. */
@keyframes fw-heat-breathe {
  0%, 100% { opacity: .40; }
  50%      { opacity: 1; }
}

/* ---------- The bead -------------------------------------------------------
   Occasional and brief, never a readout. One corner, no text, no meter. JS
   spawns it on a timer and it removes itself on animationend. */
/* Its own layer, ABOVE the heat, so condensation never breathes with the
   weather behind it. */
.fw-drops {
  position: absolute;
  inset: 0;
  z-index: 5;          /* same band as .fw-heat, after it in the markup */
  pointer-events: none;
  overflow: hidden;
}
.freewall[data-heat="relieved"] .fw-drops { display: none; }

.fw-bead {
  position: absolute;
  top: 10px;
  width: 14px;
  height: 19px;
  /* Teardrop: rounded belly, pinched top. */
  border-radius: 52% 52% 58% 58% / 64% 64% 36% 36%;
  background:
    radial-gradient(60% 45% at 36% 30%, rgba(255, 255, 255, .95), rgba(255, 255, 255, 0) 70%),
    linear-gradient(170deg, rgba(186, 232, 250, .95), rgba(116, 190, 225, .85));
  box-shadow: 0 0 12px rgba(150, 216, 245, .6), inset 0 -3px 5px rgba(80, 150, 190, .5);
  opacity: 0;
  animation: fw-bead-run 3.2s ease-in forwards;
}
@keyframes fw-bead-run {
  0%   { opacity: 0; transform: translateY(-8px) scale(.6); }
  14%  { opacity: 1; transform: translateY(0) scale(1); }
  100% { opacity: 0; transform: translateY(54px) scale(.92); }
}
/* Reduced motion keeps the cue and drops the travel: it fades in place. */
@keyframes fw-bead-still {
  0%, 100% { opacity: 0; }
  22%, 68% { opacity: .68; }
}

@media (prefers-reduced-motion: reduce) {
  /* A steady, fainter warmth instead of a pulse — the cue survives, the
     motion does not. */
  .freewall[data-heat="active"] .fw-heat { animation: none; opacity: .72; }
  .fw-bead { animation: fw-bead-still 2.4s ease forwards; }
}

/* ---------- Relief, previewed -----------------------------------------------
   While the cola is held over a paintable part of the wall the heat lifts, so
   the visitor can see this is the right object before they commit to it. The
   section's [data-heat] is untouched — let go somewhere else and the sun comes
   straight back.

   `animation: none` is load-bearing: a running keyframe animation beats a
   plain declaration in the cascade, so without killing the pulse the opacity
   below would never take effect. The extra attribute in the selector also
   keeps this ahead of the [data-heat="active"] rule on specificity rather
   than relying on source order. */
.freewall.is-heat-preview[data-heat="active"] .fw-heat {
  animation: none;
  opacity: 0;
  transition: opacity .5s ease;
}
.freewall.is-heat-preview .fw-drops {
  opacity: 0;
  transition: opacity .4s ease;
}
