/* Katong Vibe prototype — v0 shell.
   Mobile-first. The phone frame exists so this reads honestly on a desktop screen. */

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--ink);
  font-family: var(--font-body);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
}

/* ---------- frame ---------- */

.frame {
  position: relative;
  width: min(100vw, 420px);
  height: min(100dvh, 880px);
  margin: 0 auto;
  overflow: hidden;
  background: var(--canvas);
  display: flex;
  flex-direction: column;
}

@media (min-width: 480px) {
  body { display: grid; place-items: center; min-height: 100dvh; }
  .frame { border-radius: 34px; box-shadow: 0 24px 70px rgba(0,0,0,.5); }
}

/* ---------- chrome ---------- */

.chrome {
  position: absolute;
  inset: 0 0 auto 0;
  z-index: 40;
  transition: opacity .3s ease;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 8px;
  padding: 14px 14px;
  background: linear-gradient(to bottom, rgba(31,44,38,.55), transparent);
  pointer-events: none;
}

.chrome > * { pointer-events: auto; }

/* The controls keep their size and the name gives way — cycle 12, review round 2, when a
   fourth control arrived in this row and "Koon Seng Rd" started wrapping to two lines. The
   name is the orientation cue (design-01 §4.3) and it has to stay readable, but a wrapped
   heading pushing the subtitle onto three lines reads as a layout accident. */
.chrome > :first-child { min-width: 0; flex: 1 1 auto; }
.chrome > .minimap,
.chrome > .record-open,
.chrome > .reveal-toggle,
.chrome > .view-toggle { flex: none; }


/* WHERE YOU ARE, AND THE WAY OUT, AS ONE SMALL CARD — cycle 40, review round 4.

   Round 2 stacked the mark above the name because the row could not spare the width. Round 3
   moved the reveal toggle out of this row and gave the column about forty pixels back, so the
   mark is 28px and inline now and the name is on the first line where it belongs.

   IT IS A BUTTON THAT LOOKS LIKE A LABEL, on purpose. The gold tile carries the affordance;
   the words are the orientation cue §4.3 asks for and should not be dressed as a control. */
.place-col { min-width: 0; }

.place-card {
  display: flex;
  align-items: center;
  gap: 9px;
  max-width: 100%;
  padding: 5px 9px 5px 5px;
  border: 0;
  border-radius: 14px;
  background: color-mix(in srgb, var(--ink) 38%, transparent);
  color: var(--canvas);
  text-align: left;
  cursor: pointer;
}

.place-card:hover { background: color-mix(in srgb, var(--ink) 52%, transparent); }

.place-card:focus-visible { outline: 2px solid var(--canvas); outline-offset: 2px; }

.place-words { min-width: 0; }

/* Gold because design-01 §6.1 already makes gold "there is something here", which is what this
   is for. Rounded-square rather than round: the three controls to its right are circles and are
   all about THIS street, and this is the way out to the whole region. */
.portal-mark {
  flex: none;
  width: 28px;
  height: 28px;
  padding: 4px;
  border-radius: 9px;
  background: var(--gold);
  fill: var(--ink);
  box-sizing: border-box;
}

.place-name {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-family: var(--font-display);
  /* 17px rather than 19px since review round 2, when a fourth control arrived in this row
     and the name started truncating. The name is the orientation cue design-01 §4.3 asks
     for and it has to be readable in full; two points of type is a cheaper price than an
     ellipsis in the one piece of chrome that says where you are. */
  font-size: 17px;
  line-height: 1.15;
  color: var(--canvas);
  text-shadow: 0 1px 6px rgba(31,44,38,.5);
}

.place-sub {
  display: block;
  /* The subtitle WRAPS where the name does not. Truncating a name loses which street you
     are on; truncating this loses half a description, and the two are not the same loss. */
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: .09em;
  text-transform: uppercase;
  opacity: .82;
  margin-top: 3px;
}

/* WHERE YOU CAME FROM WAS HERE — `.place-back`, a line under the name reading *back to …* or *jumped
   from …*, and inside a room the control out. REMOVED AT CYCLE 41'S REVIEW ROUND 1 on the human's
   word that it *"serves not much purpose"*; its row now holds the Explore / Notes switch (notes.css).
   A room's way out is still in reach of a thumb: the bottom-left corner reads Leave inside
   (foot.js, cycle 24), and Escape. design-06 §3.4 and design-01 §4.3 carry the amendment. */

.reveal-toggle {
  flex: none;
  width: var(--tap-min);
  height: 34px;
  display: grid;
  place-items: center;
  border: 1px solid rgba(246,239,225,.4);
  border-radius: 999px;
  background: rgba(31,44,38,.35);
  cursor: pointer;
}

.reveal-toggle .reveal-dot {
  width: 9px; height: 9px;
  border-radius: 50%;
  background: var(--reveal-dot);
  opacity: .45;
  transition: opacity .2s ease, box-shadow .2s ease;
}

.reveal-toggle[aria-pressed="true"] { background: var(--canvas); }
.reveal-toggle[aria-pressed="true"] .reveal-dot {
  opacity: 1;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--reveal-dot) 30%, transparent);
}

/* Reveal mode: every tappable region shows a small dot at its centre. Exploration is
   the default and the point; this is the way out for anyone who would rather be shown. */
.region::after {
  content: "";
  position: absolute;
  left: 50%; top: 50%;
  width: 8px; height: 8px;
  margin: -4px 0 0 -4px;
  border-radius: 50%;
  background: var(--reveal-dot);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--canvas) 75%, transparent);
  opacity: 0;
  transform: scale(.4);
  transition: opacity .22s ease, transform .22s ease;
  pointer-events: none;
}

body[data-reveal="true"] .region::after { opacity: 1; transform: scale(1); }

.view-toggle {
  display: flex;
  border: 1px solid rgba(246,239,225,.4);
  border-radius: 999px;
  overflow: hidden;
  flex: none;
}

.view-toggle button {
  min-width: var(--tap-min);
  min-height: 34px;
  border: 0;
  background: rgba(31,44,38,.35);
  color: var(--canvas);
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: .08em;
  cursor: pointer;
}

.view-toggle button[aria-pressed="true"] {
  background: var(--canvas);
  color: var(--ink);
}

/* ---------- scene ---------- */

.scene {
  position: absolute;
  inset: 0;
  overflow: hidden;
  touch-action: pan-y;
}

/* One frame per scene. Two exist at once only while walking, which is what lets the
   transition carry a direction instead of a fade (design-01 §4.3). */
.scene-frame {
  position: absolute;
  inset: 0;
  overflow: hidden;
  will-change: transform;
}

/* The stage is wider than the frame. Panning within a scene is looking around;
   moving between scenes is walking (design-01 §4.3). */
.stage {
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  width: var(--stage-width, 150%);
}

.layer {
  position: absolute;
  inset: 0;
  will-change: transform;
}

.layer svg { width: 100%; height: 100%; display: block; }

.slot-layer {
  position: absolute;
  inset: 0 auto 0 0;
  height: 100%;
  pointer-events: none;
}

.slot-layer .slot { pointer-events: auto; }

/* ---------- micro-motion ----------
   Slow, ambient, never competing with a pulsing marker for attention. */

.sway {
  animation: sway var(--sway-duration) ease-in-out infinite alternate;
  animation-delay: var(--phase, 0s);
}

@keyframes sway {
  from { transform: rotate(-1.4deg); }
  to   { transform: rotate(1.6deg); }
}

.drift {
  animation: drift var(--drift-dur, var(--drift-duration)) linear infinite;
}

@keyframes drift {
  from { transform: translateX(var(--drift-from, -140px)); }
  to   { transform: translateX(var(--drift-to, 820px)); }
}

.birds {
  animation: flyby 47s linear infinite;
}

@keyframes flyby {
  0%       { transform: translate(-60px, 300px); opacity: 0; }
  6%       { opacity: .5; }
  34%      { opacity: .5; }
  40%, 100% { transform: translate(760px, 190px); opacity: 0; }
}

.window-light {
  animation: glow 9s ease-in-out infinite alternate;
}

@keyframes glow {
  from { opacity: .28; }
  to   { opacity: .62; }
}

/* All ambient motion stops. Lives here, beside the animations it governs — cycle 3
   deleted it by accident when the marker block below was rewritten, and the tests
   caught it. */
@media (prefers-reduced-motion: reduce) {
  .sway, .drift, .birds, .window-light { animation: none !important; }
}

/* ---------- regions: the object is the button ----------
   Cycle 3 removed the marker chrome. A region is a transparent hit area over the
   authored extent of the object; the only visible signal is light painted on the
   artwork beneath it. */

.marker-layer {
  position: absolute;
  inset: 0 auto 0 0;
  height: 100%;
  pointer-events: none;
  transform-origin: 50% 50%;
}

.region {
  position: absolute;
  transform: translateX(var(--pan, 0px));
  min-width: var(--tap-min);
  min-height: var(--tap-min);
  border: 0;
  padding: 0;
  background: none;
  cursor: pointer;
  pointer-events: auto;
  z-index: 10;
}

/* A building's name, on the building. Fact-layer text over the illustration, which is
   what spine §1.1 asks for — the SVG stays text-free. It rides the artwork's plane and
   scales with it (no --unzoom), because a sign is mounted, not floating. */
.signage {
  position: absolute;
  transform: translate(-50%, -50%) translateX(var(--pan, 0px));
  display: grid;
  justify-items: center;
  gap: 1px;
  pointer-events: none;
  color: var(--sign-red);
  text-shadow: 0 1px 0 rgba(31,44,38,.18);
  white-space: nowrap;
  z-index: 6;
}

.signage-name {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 15px;
  letter-spacing: .04em;
  text-transform: uppercase;
  line-height: 1;
}

.signage[data-tone="light"] {
  color: var(--canvas);
  text-shadow: 0 1px 2px rgba(31,44,38,.35);
}

/* A painted board: the historic bakery sign. WHITE letters on the building's own red,
   inside a thin white border — the first version had it inverted, white band with red
   text, and the close-up shows the board is painted onto the facade rather than
   applied to it. */
.signage[data-tone="board"] { color: var(--canvas); gap: 2px; }
.signage[data-tone="board"] .signage-name {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 12px;
  letter-spacing: .18em;
  text-transform: none;
}
/* Individually mounted characters, spaced along the wall — Chin Mee Chin's Chinese
   name, below the English one and in the same red, because it is the same mounted
   lettering rather than a painted board. Two things come off: the display face, which
   has no CJK glyphs and would silently fall back mid-sign, and the uppercase, which
   means nothing here. The negative margin cancels the trailing letter-space so the
   sign still centres on the opening it sits over — `board` above has the same gap and
   is centred over nothing in particular, which is why it never showed. */
.signage[data-tone="characters"] .signage-name {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 11px;
  letter-spacing: .34em;
  margin-right: -.34em;
  text-transform: none;
}

.signage[data-tone="board"] .signage-sub {
  color: var(--canvas);
  font-family: var(--font-mono);
  font-style: normal;
  font-size: 5.6px;
  letter-spacing: .07em;
  text-transform: uppercase;
  opacity: .88;
}

/* ===== memory figures — cycle 7 =====
   A drawn person standing where a real person stood (design-03 §2).

   Anchored by the FEET, because that is what "standing here" means: the element is
   placed at the spot and then pulled up by the drawing's own foot line, so a pose with
   different proportions still lands on the pavement rather than hovering over it.

   It rides --pan like everything else with a data-layer, and deliberately does NOT use
   --unzoom. A person in the street is painted on the world and grows with it, the same
   as signage; only type has to stay readable while the artwork scales. */
.memory-figure {
  position: absolute;
  aspect-ratio: 1 / var(--fig-aspect);
  transform: translateX(var(--pan, 0px))
             translate(calc(-50% + var(--fig-step, 0) * 100%), calc(-100% * var(--fig-foot)));
  border: 0;
  padding: 0;
  background: none;
  cursor: pointer;
  pointer-events: auto;
  /* Above the regions: a person on the pavement is nearer than the wall behind them,
     and tapping a person should open the person. */
  z-index: 11;
}

.memory-figure svg { display: block; width: 100%; height: 100%; overflow: visible; }

/* A TAP TARGET THAT DOES NOT SHRINK WITH THE DRAWING — cycle 25's review rounds.

   The indoor marker is a photograph rather than a person, and the human asked for its size
   to follow the depth it was placed at. Inside Chin Mee Chin that runs to 0.44, so the far
   end of the room draws about sixteen pixels of icon. The PICTURE should be small there —
   that is the whole of the note. What should not shrink is the part a thumb has to find.

   INDOORS ONLY, and the scoping is the point rather than caution. Round 3 put the street
   back to the human figures it always had, and several of those stand close together on one
   pavement — a 44px floor under each would overlap their neighbours' and make picking one
   HARDER, which is the opposite of what this is for. A room has one or two photographs in
   it and nothing to collide with.

   Centred on the drawing rather than on its feet, because that is where a thumb aims. */
.memory-figure[data-marker="frame"]::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: max(100%, 44px);
  height: max(100%, 44px);
  transform: translate(-50%, -50%);
  pointer-events: auto;
}

/* The only thing that separates a memory from the eight people already drawn waiting
   outside Chin Mee Chin: a warmth on the figure itself. Cycle 3's grammar — signal
   painted on the object, never a badge floating above it (delivery-plan §3.3.2).

   Note what this does NOT mean. Every memory in v0 is the holder's own, because the
   device is the identity and there are no accounts (design-03 §11), so this marks
   "a memory, and you can tap it" rather than "yours as opposed to someone else's".
   Telling those apart is a job for a product with more than one user.

   A pool of light on the ground the figure is standing in, NOT a wash over the drawing.
   The first attempt haloed the whole figure and read as a smudge on the pavement — at
   1.7m on a phone there is not enough figure for a halo to sit around without swallowing
   it. Light at the feet is legible at this size, leaves the drawing untouched, and says
   the thing a contact shadow already says in this world: something is standing here. */
.memory-figure::before {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 190%;
  height: 34%;
  transform: translate(-50%, 38%);
  border-radius: 50%;
  background: radial-gradient(
    ellipse at 50% 50%,
    color-mix(in srgb, var(--gold) 62%, transparent),
    color-mix(in srgb, var(--gold) 18%, transparent) 52%,
    transparent 72%
  );
  pointer-events: none;
  z-index: -1;
}

/* Reveal mode is the way out for anyone who would rather be shown than explore, so the
   pool brightens with everything else rather than being the one thing it misses. */
body[data-reveal="true"] .memory-figure::before {
  background: radial-gradient(
    ellipse at 50% 50%,
    color-mix(in srgb, var(--gold) 88%, transparent),
    color-mix(in srgb, var(--gold) 34%, transparent) 54%,
    transparent 74%
  );
}

/* ===== somebody else's figure — cycle 8 =====
   design-03 §6.1: your own figures warm, other people's muted.

   The DRAWING does not change. Pose and palette still come from the memory id and from
   nothing else (§2.3), so a muted figure discloses nothing a warm one would not — the
   only difference is the light on the ground they are standing in. Yours is the gold
   pool above; theirs is a plain contact shadow, the same one every other object in this
   world casts, which says "somebody stood here" without saying "come and look".

   Deliberately NOT the muted teal of design-01 §6.1. That vocabulary is about content
   tier and commercial separation, and §6.1 is explicit that borrowing it here would make
   a person look like a listing.

   MUTED, NOT ABSENT, and the first attempt got this wrong. Ink at 30% on a pavement this
   pale was invisible in the screenshot, which does not read as "somebody else's memory" —
   it reads as one of the drawn passers-by already standing in the artwork. Cycle 7's
   comment above says exactly what the pool is for: it is the ONLY thing separating a
   memory from the eight people drawn outside Chin Mee Chin, and a figure you can tap has
   to look like a figure you can tap (cycle 3's grammar, signal on the object). So it is
   dimmer and cooler than yours, and still there. */
.memory-figure[data-mine="false"]::before {
  background: radial-gradient(
    ellipse at 50% 50%,
    color-mix(in srgb, var(--ink) 50%, transparent),
    color-mix(in srgb, var(--ink) 20%, transparent) 54%,
    transparent 72%
  );
}

body[data-reveal="true"] .memory-figure[data-mine="false"]::before {
  background: radial-gradient(
    ellipse at 50% 50%,
    color-mix(in srgb, var(--ink) 68%, transparent),
    color-mix(in srgb, var(--ink) 28%, transparent) 56%,
    transparent 74%
  );
}

/* A name mounted on a disc has to fit inside it, so this one wraps and sits smaller.
   The Red House's plate is a circle; Chin Mee Chin's is a whole wall. */
.signage[data-tone="light"] .signage-name {
  white-space: normal;
  width: 66px;
  text-align: center;
  font-size: 11.5px;
  line-height: 1.08;
  letter-spacing: .02em;
}

.signage-sub {
  font-family: var(--font-display);
  font-style: italic;
  font-size: 11px;
  line-height: 1;
  opacity: .95;
}

/* The painted affordance: warm light sitting on the object, not a badge above it. */
.halo {
  position: absolute;
  inset: -14%;
  border-radius: 42%;
  background: radial-gradient(ellipse at 50% 50%,
    color-mix(in srgb, var(--tier) 55%, transparent) 0%,
    color-mix(in srgb, var(--tier) 22%, transparent) 42%,
    transparent 72%);
  mix-blend-mode: screen;
  pointer-events: none;
}

.region[data-lit="true"] .halo { animation: breathe 4.2s ease-in-out infinite alternate; }

@keyframes breathe {
  from { opacity: .62; transform: scale(.94); }
  to   { opacity: 1;   transform: scale(1.06); }
}

/* Commercial: cooler, weaker, never animated (design-01 §6.1). */
.region[data-tier="commercial"] .halo { opacity: .4; animation: none; }

/* Unconfirmed content reads differently in the scene too (spine §7.2). */
.region[data-state="proposed"] .halo {
  background: radial-gradient(ellipse at 50% 50%,
    color-mix(in srgb, var(--clay) 42%, transparent) 0%, transparent 68%);
}

/* An empty slot is a faint outline of where something could go — an invitation,
   not filler (design-01 §10). */
.region[data-empty="true"] .halo {
  background: none;
  border: 1.5px dashed color-mix(in srgb, var(--canvas) 55%, transparent);
  border-radius: 10px;
  inset: 0;
  opacity: .5;
  animation: none;
}

.region:focus-visible { outline: 3px solid var(--gold); outline-offset: 3px; }

@media (prefers-reduced-motion: reduce) {
  .region[data-lit="true"] .halo { animation: none; opacity: .9; }
}

/* ---------- camera ---------- */

.is-zooming .layer,
.is-zooming .marker-layer { transition: transform .42s cubic-bezier(.22,.61,.36,1); }

/* ---------- content, anchored to its object ---------- */

.anchored {
  position: absolute;
  transform: translate(-50%, 14px) translateX(var(--pan, 0px)) scale(var(--unzoom, 1));
  transform-origin: 50% 0;
  width: 236px;
  pointer-events: auto;
  background: color-mix(in srgb, var(--canvas-deep) 96%, transparent);
  border-radius: var(--radius);
  box-shadow: 0 10px 30px rgba(31,44,38,.32);
  padding: 12px 13px 13px;
  z-index: 30;
  /* CYCLE 28, REVIEW ROUND 1 — the card can be taller than the room under it, and until now
     that meant the bottom of it was simply unreachable: the stage is a fixed viewport and this
     is absolutely positioned inside it, so there was no scroll to find. A form the person could
     type into and not send.

     THE HEIGHT IS SET IN JS, NOT HERE, and `fitToFrame` in scene.js has the argument: the
     panel's top depends on where its slot is, so every fixed fraction of the viewport is wrong
     for some slot. This is only the part that makes the measurement usable.

     `overflow-x: hidden` DELIBERATELY, not `overflow: auto`. A vertical scroll container gets a
     horizontal one for free, and two of this panel's children bleed on purpose — `.detail-rail`
     and `.eatery-photos` both carry `-13px` margins out through the panel's own padding — which
     would have drawn a sideways scrollbar under them.

     AND IT COSTS THE BLEED NOTHING, WHICH WAS MEASURED RATHER THAN ASSUMED. Overflow clips at
     the PADDING box, and `.anchored` has no border, so that edge is the element's own outer
     edge — which is exactly how far those two reach. Measured after the change: the rail spans
     77.0→313.0 and the panel spans 77.0→313.0. */
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
}

.panel-tag {
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.panel-title { font-family: var(--font-display); font-size: 16px; margin: 3px 0 5px; }
.panel-body { font-size: 12.5px; line-height: 1.45; color: var(--ink-soft); }


/* ===== putting something in an empty slot — cycle 27 ===== */

/* The fold line, and it is the whole of what an empty card offers until it is asked. Cycle
   21's review round 2 on the surface next door: the form is an option a resident may take, not
   the first thing on a screen somebody opened to look at a street. Underlined text rather than
   a button-shaped button, because `panel-back` is the card's one button and a second one at the
   same weight would make the card read as a choice between two actions. */
.content-add-open {
  margin-top: 9px;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  font-size: 12.5px;
  color: var(--ink);
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}

/* WHAT THIS WILL BE FILED AS. A caption, not a heading — it is a statement about the form
   rather than a section of it, and at heading weight it competed with the invitation above.
   Same mono register as `panel-tag`, which is the card's other "this is what you are looking
   at" line. */
/* WHO SAID IT. Quiet, under the words, in the same mono register the card uses for everything
   that is ABOUT the content rather than part of it — spine §5.2 makes this the claim for a
   comment, and a claim's attribution should read as attribution rather than as a byline. */
.panel-by, .list-by {
  margin-top: 6px;
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.content-add-filed {
  margin: 0 0 9px;
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-soft);
  line-height: 1.5;
}

.content-add-lede {
  margin: 9px 0 8px;
  font-size: 12px;
  line-height: 1.4;
  color: var(--ink-soft);
}

.content-add-form .field { display: block; margin-bottom: 8px; }
.content-add-form .field > span {
  display: block;
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-soft);
  margin-bottom: 3px;
}
.content-add-form input,
.content-add-form select {
  width: 100%;
  min-height: 32px;
  padding: 4px 7px;
  border: 1px solid color-mix(in srgb, var(--ink) 26%, transparent);
  border-radius: 6px;
  background: color-mix(in srgb, var(--canvas) 70%, transparent);
  color: var(--ink);
}
/* FILLED, AND `panel-back` DIRECTLY UNDER IT IS WHY. The card already ends in an outlined pill;
   a second outlined pill above it makes sending and leaving look like a choice between two
   equal things. `rate-send` is the app's existing treatment for a primary act inside a panel
   (cycle 15) and this borrows it rather than inventing a second one. */
.content-add-form button[type="submit"] {
  width: 100%;
  min-height: 34px;
  border: 1px solid var(--ink);
  border-radius: 999px;
  background: var(--ink);
  color: var(--canvas);
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: .09em;
  text-transform: uppercase;
  cursor: pointer;
}

.content-add-error {
  margin: 7px 0 0;
  font-size: 11.5px;
  color: var(--ink);
}

/* WAITING, AND IT REPLACES THE INVITATION RATHER THAN SITTING UNDER IT — the card must not go
   on saying "nothing here yet" over the thing somebody just wrote (app.js `emptyInvitation`). */
.content-add-waiting {
  margin: 3px 0 6px;
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ink-soft);
}
.content-mine-title { font-family: var(--font-display); font-size: 15px; margin-bottom: 3px; }
.content-mine-body { font-size: 12.5px; line-height: 1.45; color: var(--ink-soft); }


/* ===== putting yourself in the scene ===== */

/* The one control the feature needs. Bottom right, above the exit bar: reachable with a
   thumb, and out of the chrome, which is already carrying four things. */
.add-memory {
  position: absolute;
  right: 14px;
  bottom: calc(var(--foot-bar-h, 58px) + 14px);
  width: var(--tap-min);
  height: var(--tap-min);
  border-radius: 50%;
  border: 1px solid color-mix(in srgb, var(--ink) 30%, transparent);
  background: color-mix(in srgb, var(--canvas) 92%, transparent);
  box-shadow: 0 6px 18px rgba(31,44,38,.22);
  cursor: pointer;
  display: grid;
  place-items: center;
  z-index: 40;
}

/* A lens, drawn rather than lettered — the chrome carries no words anywhere else. */
.add-memory-glyph {
  width: 15px;
  height: 15px;
  border-radius: 50%;
  border: 2px solid var(--ink);
}
.add-memory-glyph::after {
  content: "";
  display: block;
  width: 3px;
  height: 3px;
  margin: 4px 0 0 4px;
  border-radius: 50%;
  background: var(--ink);
}

/* The ground you can stand on. Invisible until it is being chosen from — the rest of
   the time it is structure, not interface.

   Showing the AREA rather than a handful of dots is the point: the model is "anywhere on
   this pavement", and a scatter of marks taught the opposite, which is what produced
   "why can I only drag sideways?" at review. */
.standing-area {
  position: absolute;
  transform: translateX(var(--pan, 0px));
  border-radius: 40% / 60%;
  background: linear-gradient(
    to top,
    color-mix(in srgb, var(--gold) 30%, transparent),
    color-mix(in srgb, var(--gold) 12%, transparent)
  );
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--canvas) 55%, transparent);
  opacity: 0;
  transition: opacity .2s ease;
  pointer-events: none;
  z-index: 21;
}

body[data-placing="true"] .standing-area { opacity: 1; }

/* Placing: the photo is chosen and the app is waiting to be told where you stood.

   THIS BUTTON CANCELS, AND IT USED TO LOOK LIKE THE CONFIRM. Reported at review, and the
   measurement is damning: while placing, this and "PUT ME HERE" were both
   rgb(200,154,72) — the same gold, on the same screen, one saving and one discarding the
   photograph you had just chosen. The reviewer tapped it expecting to save, and cycle 8's
   own rule about failures being spoken did not help, because from the app's point of view
   nothing had failed. It had done what it was asked.

   So it now reads as what it does: a cross, in the clay this world already uses for
   destructive things (.panel-delete), on the ordinary chrome ground rather than the
   gold. The gold is the confirm and only the confirm. */
body[data-placing="true"] .add-memory,
body[data-reading="true"] .add-memory { border-color: var(--clay); z-index: 44; }

body[data-placing="true"] .add-memory-glyph,
body[data-reading="true"] .add-memory-glyph {
  position: relative;
  width: 14px;
  height: 14px;
  border: 0;
  border-radius: 0;
}

body[data-placing="true"] .add-memory-glyph::before,
body[data-placing="true"] .add-memory-glyph::after,
body[data-reading="true"] .add-memory-glyph::before,
body[data-reading="true"] .add-memory-glyph::after {
  content: "";
  position: absolute;
  top: 6px;
  left: 0;
  width: 14px;
  height: 2px;
  margin: 0;
  border-radius: 1px;
  background: var(--clay);
}

body[data-placing="true"] .add-memory-glyph::before,
body[data-reading="true"] .add-memory-glyph::before { transform: rotate(45deg); }
body[data-placing="true"] .add-memory-glyph::after,
body[data-reading="true"] .add-memory-glyph::after { transform: rotate(-45deg); }

/* The tick, sitting directly above the cross. Two discs in one corner, under the thumb:
   the confirm and the cancel are the same gesture's two answers and belong together, not
   at opposite ends of the screen. Gold is the confirm, and only the confirm. */
.place-confirm {
  position: absolute;
  right: 14px;
  bottom: calc(var(--foot-bar-h, 58px) + 14px + var(--tap-min) + 10px);
  width: var(--tap-min);
  height: var(--tap-min);
  border-radius: 50%;
  border: 1px solid color-mix(in srgb, var(--ink) 22%, transparent);
  background: var(--gold);
  box-shadow: 0 6px 18px rgba(31,44,38,.22);
  cursor: pointer;
  display: grid;
  place-items: center;
  /* Above the pin (z 42), which can be dragged to the same corner of the pavement. The
     tap that ends the gesture has to be reachable even when the pin is standing on it;
     the pin can always be moved out again by tapping the ground somewhere else. */
  z-index: 44;
}

/* Drawn rather than lettered, like the lens it sits above. Two strokes of one box:
   the short arm and the long arm of a tick, rotated together. */
.place-confirm-glyph {
  width: 16px;
  height: 9px;
  margin-top: -3px;
  border-left: 2.5px solid var(--ink);
  border-bottom: 2.5px solid var(--ink);
  border-radius: 1px;
  transform: rotate(-45deg);
}

/* [hidden] is display:none from the UA sheet, and ANY author `display` beats it. This
   rule is why the confirm bar would not go away after placing a memory: .place-hint
   became a flex row to hold the button, and the attribute silently stopped working. */
[hidden] { display: none !important; }

/* WHERE THE PIN IS STANDING — DRAWN AGAIN AT CYCLE 9, and the history matters because
   this is the third position the same sentence has held.

   Cycle 8 review took out the labelled bar across the top: the pin already says what to
   do, and design-01's chrome carries no words anywhere else. The sentence survived as a
   live region, announced and never drawn, because it is the only feedback a person
   nudging with arrow keys gets (design-01 §9: never gesture-only).

   Cycle 9 then made the pin OPEN somewhere the photograph argues for, and the human's
   report was "the pin seems placed at the default location, similar to earlier version".
   It had in fact moved — about 30px, which on a 390px screen is nothing — and NOTHING ON
   SCREEN SAID ANYTHING HAD BEEN INFERRED. A guess nobody can see is not a feature, and
   the fix is not a louder guess but a legible one.

   So it is drawn, and the constraints cycle 8 was right about still hold: it exists only
   while placing, it carries no button, and it is the only text on this screen. It is a
   caption on a thing you are already looking at, not a bar of instructions. */
.place-hint {
  position: absolute;
  left: 12px;
  /* Clear of the confirm and cancel buttons, which sit in the bottom-right corner, and
     clear of the exit bar under them — the same measurement .store-warning uses, because
     they are avoiding the same furniture. */
  right: 76px;
  bottom: calc(var(--foot-bar-h, 58px) + 14px);
  margin: 0;
  padding: 7px 11px;
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--ink) 92%, transparent);
  color: var(--canvas);
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: .05em;
  line-height: 1.45;
  z-index: 39;
  pointer-events: none;
}

/* While placing, the authored spots show themselves. This is the moment the model is
   legible — you are choosing among places the scene has, not dropping a pin anywhere —
   and it is the only time they are drawn, because the rest of the time they are
   structure rather than interface. */
/* Everything tappable stands down while a memory is being placed.
   Koon Seng has six live regions, and asking someone to aim between them was asking
   them to fight the interaction the rest of the app is built on. */
body[data-placing="true"] .region,
body[data-placing="true"] .memory-figure,
body[data-placing="true"] .foot-side,
body[data-placing="true"] .exit-hint-btn,
body[data-placing="true"] .signage { pointer-events: none; }

body[data-placing="true"] .memory-figure { opacity: .45; }

/* The pin you drag. Big enough to hold with a thumb without covering the spot it is
   standing on, which is why the point is at the bottom and the mass is above it. */
.place-pin {
  position: absolute;
  width: 34px;
  height: 42px;
  margin: -42px 0 0 -17px;
  cursor: grab;
  touch-action: none;
  transform: scale(var(--pin-scale, 1));
  transform-origin: 50% 100%;
  z-index: 42;
  filter: drop-shadow(0 3px 6px rgba(31,44,38,.4));
}

.place-pin::before {
  content: "";
  position: absolute;
  inset: 0 0 8px 0;
  border-radius: 50% 50% 50% 50% / 58% 58% 42% 42%;
  background: var(--gold);
  border: 2px solid var(--canvas);
}

/* The point, so it is unambiguous which pixel the pin means. */
.place-pin::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 0;
  height: 0;
  margin-left: -6px;
  border: 6px solid transparent;
  border-top: 12px solid var(--canvas);
}

.place-pin[data-dragging="true"] {
  cursor: grabbing;
  transform: scale(calc(var(--pin-scale, 1) * 1.12));
}
/* Focusable because arrow keys move it between spots (design-01 §9 — never
   gesture-only), and the pin is focused on open so a keyboard reaches it without
   hunting. The ring is the world's own gold rather than the interface red, because it
   is on screen every time a photo is placed rather than only when someone tabs. */
.place-pin:focus-visible {
  outline: 2px solid var(--canvas);
  outline-offset: 2px;
  border-radius: 50%;
}


body[data-placing="true"] .scene-frame::after {
  content: "";
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--ink) 16%, transparent);
  pointer-events: none;
  z-index: 20;
}

/* Cycle 8: the same strip carries a placement that failed, and it has to be louder than
   a description of which copy this is — somebody just lost a photograph. */
.store-warning[data-failure="true"] {
  background: var(--clay);
  font-size: 10.5px;
}

/* Wherever the add button is on screen, the strip stops short of it. Full width ran the
   last word of the message underneath the button. */
body:not([data-store-mode="unreachable"]) .store-warning { right: 76px; }

/* And it gets out of the way while something is open.
   The strip is standing chrome from cycle 8 — the export shows it the whole time, where
   cycle 7's version appeared only in the rare non-durable case — so at zoom it sat across
   the memory panel and covered the date. It describes the copy you are holding, which is
   not the thing you are reading right now. Same reasoning as the exit bar above. */
.scene:has(> .scene-frame[data-zoom]:not([data-zoom="1.000"])) ~ .store-warning { opacity: 0; }

/* The honest warning when the browser will not keep anything (design-01 §10). */
.store-warning {
  position: absolute;
  left: 12px;
  right: 12px;
  bottom: calc(var(--foot-bar-h, 58px) + 14px);
  margin: 0;
  padding: 7px 11px;
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--clay) 92%, transparent);
  color: var(--canvas);
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: .05em;
  z-index: 39;
  transition: opacity .25s ease;
}

/* The photograph, in the panel — which is the ONLY place it is ever allowed to be.
   design-02 §2.3: the illustration is the stage, never a frame in the record. Putting it
   in the artwork would make the illustrated world a collage, and the illustrated world is
   the product. tests/test_memory.py scans the stage layers rather than trusting this. */
.memory-photo {
  display: block;
  width: 100%;
  border-radius: var(--radius-sm);
  margin-bottom: 9px;
  /* Landscape or portrait, a phone photo has to sit in a fixed panel without
     stretching. Cropped to a consistent shape, never squashed. */
  aspect-ratio: 4 / 3;
  object-fit: cover;
  background: var(--canvas-deep);
}

/* THE PHOTOGRAPH ON A CONTENT ITEM'S CARD — cycle 28, design-08 §5.3, and every line of this
   rule is a spine requirement rather than a layout preference:

     Spine §7.2 — an unconfirmed state's caveat is never dismissible and never lost in transit
     — means a portrait phone photograph at natural size pushing the state marker below the
     fold is a TRUST FAILURE, not a cosmetic one. Fixed aspect, contained, tap to open full,
     and the marker above the image rather than under it.

   MEASURED, NOT ASSERTED: with no rule at all, the fixture photograph (1024×1280, at
   images/module.js's 1280 cap) rendered 771px tall on an 812px screen. That number is in
   evidence/naive-image-first-failure.txt, because the test that produced it was written
   before this rule existed.

   IT FOLLOWS .memory-photo ABOVE AND NOT .eatery-photos BELOW. A food photograph bleeds to
   the panel's edge because design-04 §5.3 says the photograph IS the surface. A content
   photograph is an illustration of a sentence — a personal artefact held in a panel, which is
   what .memory-photo already is and why it has a radius and a margin. */
.panel-photo {
  display: block;
  width: 100%;
  margin: 5px 0 8px;
  border-radius: var(--radius-sm);
  aspect-ratio: 4 / 3;
  object-fit: cover;
  background: var(--canvas-deep);
  cursor: pointer;
}

/* TAP TO OPEN FULL — §5.3's third requirement, at the cheapest honest price. The crop is
   released in place and the WHOLE FRAME becomes visible; `contain` is what makes that true of
   a portrait photograph rather than only of a landscape one.

   THE CAP STAYS. "Open full" means the whole picture, not the whole screen: without it a
   1280px frame would push the body and the author's name off the card, which is the failure
   this rule exists to prevent arriving one tap later. */
.panel-photo[data-full="true"] {
  aspect-ratio: auto;
  max-height: 46vh;
  object-fit: contain;
}

/* THE SAME PHOTOGRAPH ON THE LIST ROW — design-01 §9, the list is a projection of the same
   spec and not a second implementation. It is the first field the list would have dropped.

   A FIXED HEIGHT AND `cover`, which is .detail-photo's reasoning one rail over: a list of
   rows at different heights reads as a mistake. It does NOT open full — a thumbnail that
   expanded inside a scrolling list would push every row below it down, and the row is a way
   in to the card rather than a place to look at a photograph. */
.list-photo {
  display: block;
  width: 100%;
  height: 84px;
  margin: 6px 0 0;
  object-fit: cover;
  border-radius: var(--radius-sm);
  background: var(--canvas-deep);
}

.panel-date-ask {
  display: flex;
  align-items: center;
  gap: 7px;
  margin-top: 7px;
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* 16px for the same reason as the record's form above, and this one has been wrong since
   cycle 7 — it is the same iOS zoom trap on the memory date, found while fixing the other.
   Fixed here rather than left for the next person to report from a phone. */
.panel-date-input {
  flex: 1;
  min-height: 36px;
  border: 1px solid color-mix(in srgb, var(--ink) 35%, transparent);
  border-radius: var(--radius-sm);
  background: var(--canvas);
  font-family: var(--font-mono);
  font-size: 16px;
  color: var(--ink);
  padding: 0 6px;
}

.panel-actions {
  display: flex;
  gap: 6px;
  margin-top: 10px;
}

.panel-move,
.panel-delete {
  flex: 1;
  min-height: 34px;
  border: 1px solid var(--ink);
  border-radius: 999px;
  background: none;
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: .09em;
  text-transform: uppercase;
  cursor: pointer;
}

/* Delete is destructive and says so, quietly. design-03 §7.5 makes it a real right
   rather than a buried setting, so it sits next to the memory it removes. */
.panel-delete { border-color: var(--clay); color: var(--clay); }

/* Sharing is the second act (design-03 §3.2), so it gets its own row above the filing
   buttons rather than a third slot beside them. It is outlined and not filled: a solid
   button here would make giving your photograph to the street the obvious next thing to
   do, which is the opposite of what private-by-default means. Once shared, the gold says
   it is out there, and the same button is the way back. */
.panel-share {
  width: 100%;
  min-height: 34px;
  margin-top: 10px;
  border: 1px solid var(--ink);
  border-radius: 999px;
  background: none;
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: .09em;
  text-transform: uppercase;
  cursor: pointer;
}

.panel-share[data-shared="true"] { border-color: var(--gold); color: var(--gold); }

/* §7.3's line, at the moment it means something. Small, plain, and not styled as a
   warning — it is a description of what will happen, not a discouragement. */
.panel-note {
  margin-top: 8px;
  font-family: var(--font-body);
  font-size: 11px;
  line-height: 1.35;
  color: var(--ink-soft);
}

/* Sign-in sits where the add button sits — same corner, same intention, one step
   earlier. design-03 §9: the lock is on contribution, never on looking. */
.sign-in {
  position: absolute;
  left: 12px;
  right: 12px;
  bottom: calc(var(--foot-bar-h, 58px) + 14px);
  z-index: 39;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}

/* Pale type on a pale pavement is not type. The first version borrowed the place name's
   treatment from the top of the frame, where it sits over sky; down here it sits over the
   five-foot way and vanished into it. A quiet ink pill reads on both. */
.sign-in-line {
  padding: 6px 12px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--ink) 82%, transparent);
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: .06em;
  color: var(--canvas);
}

.panel-back {
  margin-top: 10px;
  width: 100%;
  min-height: 34px;
  border: 1px solid var(--ink);
  border-radius: 999px;
  background: none;
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: .09em;
  text-transform: uppercase;
  cursor: pointer;
}

/* Demo marking. Non-dismissible, same wording everywhere (spine §7.3). */
.demo-tag {
  display: inline-block;
  margin: 4px 0 2px;
  font-family: var(--font-mono);
  font-size: 8px;
  letter-spacing: .09em;
  text-transform: uppercase;
  color: var(--canvas);
  background: var(--ink-soft);
  border-radius: 999px;
  padding: 2px 7px;
}

/* ---------- detail annotations ----------
   Dots on the artwork, words in the panel. DOM over the illustration, never text
   inside the SVG (spine §1.1). */

.detail {
  position: absolute;
  transform: translate(-50%, -50%) translateX(var(--pan, 0px)) scale(var(--unzoom, 1));
  z-index: 25;
  pointer-events: none;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: color-mix(in srgb, var(--ink) 88%, transparent);
  color: var(--canvas);
  font-family: var(--font-mono);
  font-size: 13px;
  font-weight: 600;
  border: 2px solid color-mix(in srgb, var(--canvas) 78%, transparent);
  opacity: .62;
  animation: detail-in .34s ease both;
  animation-delay: calc(var(--i) * 70ms + 100ms);
  transition: opacity .2s ease, box-shadow .2s ease;
}

/* The dot the active card is talking about. This is the whole point of splitting
   them: the words are readable, and the reference stays on the artwork. */
.detail[data-active="true"] {
  opacity: 1;
  background: var(--gold);
  color: var(--ink);
  border-color: var(--canvas);
  box-shadow: 0 0 0 7px color-mix(in srgb, var(--gold) 26%, transparent);
}

@keyframes detail-in { from { opacity: 0; } }

.detail-heading {
  margin: 12px 0 6px;
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ink-soft);
  opacity: .8;
}

/* A rail of readable cards. Cycle 3's first pass floated the words beside the dots;
   three notes on one small object collide, and shrinking them to fit made them
   unreadable. */
.detail-rail {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  margin: 0 -13px;
  padding: 2px 13px 4px;
  scrollbar-width: none;
}

.detail-rail::-webkit-scrollbar { display: none; }

.detail-card {
  scroll-snap-align: center;
  flex: 0 0 158px;
  text-align: left;
  border: 1.5px solid color-mix(in srgb, var(--ink) 14%, transparent);
  border-radius: var(--radius-sm);
  background: var(--canvas);
  box-shadow: 0 2px 6px rgba(31,44,38,.12);
  padding: 9px 10px;
  font: inherit;
  color: inherit;
  cursor: pointer;
  transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease;
}

/* The active card, its dot and its pager tick light together. */
.detail-card[data-active="true"] {
  border-color: var(--gold);
  box-shadow: 0 3px 10px rgba(31,44,38,.18);
}

.detail-card[data-active="false"] { transform: scale(.97); opacity: .8; }

/* A pager, so "more of these, sideways" is visible before anyone thinks to swipe. */
.detail-pager {
  display: flex;
  justify-content: center;
  gap: 5px;
  margin-top: 8px;
}

.detail-pager span {
  width: 6px; height: 6px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--ink) 22%, transparent);
  transition: background .2s ease, width .2s ease;
}

.detail-pager span[data-active="true"] { background: var(--gold); width: 16px; border-radius: 3px; }

.detail-num {
  display: inline-grid;
  place-items: center;
  width: 22px; height: 22px;
  border-radius: 50%;
  background: var(--gold);
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  margin-bottom: 5px;
}

.detail-card b { display: block; font-size: 12px; }
/* Was `.detail-card span`, which at 0,1,1 outranked `.detail-num` at 0,1,0 and
   silently forced the number to display:block — killing its centring. Named
   class, so an element selector cannot reach across to a sibling again. */
.detail-text { display: block; font-size: 11.5px; line-height: 1.4; color: var(--ink-soft); margin-top: 3px; }
.detail-card em {
  display: block;
  font-style: normal;
  font-family: var(--font-mono);
  font-size: 7.5px;
  letter-spacing: .09em;
  text-transform: uppercase;
  color: var(--ink-soft);
  opacity: .7;
  margin-top: 5px;
}

@media (prefers-reduced-motion: reduce) { .detail { animation: none; } }

/* ---------- exits: a hint at the edge, not a bar ---------- */

/* ── the two corners — cycle 15 at review ──────────────────────────────────────────
 *
 * The human's note: "the entry point is not well designed to be blended into the scene, it
 * felt old school." What was here was two exit chips; what was in the chrome was a counted
 * icon that opened a sheet. Both are gone. Each corner is now a trigger whose list rises
 * out of it INTO the scene — no panel, no backdrop, no sheet sliding over the illustration.
 *
 * `align-items: flex-end` is what makes the stack grow upward from the button rather than
 * pushing it up: the column is anchored at the bottom of the bar and the items pile above
 * the trigger they came from. */
.foot-bar {
  position: absolute;
  inset: auto 0 0 0;
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  padding: 0 6px calc(10px + env(safe-area-inset-bottom));
  pointer-events: none;
  /* ABOVE THE TRAY (z-index 45), and it is the same rule cycle 14 wrote for the chrome
     rather than a new one: "the tray never covers the chrome, on either axis… without the
     way back in the chrome, three turns in, people are lost" (design-01 §4.3).

     The corners ARE chrome now — the right one is the tray's own way out, §5.2's first
     dismissal — and at full the tray covered it, so the control that let you in was buried
     under what it opened. Found by the dismissal test, which is what it is for. */
  z-index: 46;
}

/* THE ONLY PLACE POINTER EVENTS ARE TURNED BACK ON. The bar is `pointer-events: none` so
   taps fall through to the illustration between the corners; this switches them on for one
   corner's controls, and NOTHING INSIDE IT MAY SWITCH THEM ON AGAIN.

   The first build set `auto` here, on `.foot-open` AND on `.foot-item` — three switches for
   one thing. So `body[data-timelapse="true"] .foot-side { pointer-events: none }` turned the
   side off and the button turned itself back on, and the corners went on swallowing taps
   meant for the record. Five tests caught it; the fix is one switch rather than a longer
   selector, because a longer selector would have to grow again with the next child. */
.foot-side {
  pointer-events: auto;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 4px;
  /* No cap here — the TILE carries it. A percentage on both compounds: 46% of a side that
     was itself 60% of the bar came out at 27% of the screen, which is exactly the width
     that truncated "Micro Red House" to "Micr o…" and sent me looking in the wrong place. */
  min-width: 0;
}
.foot-side[data-side="right"] { align-items: flex-end; }

/* The trigger. Same weight as the exit chips it replaces — set on the illustration, in
   Canvas with a shadow under it, because it is chrome standing on a drawing rather than a
   control on a panel. */
.foot-open {
  align-self: flex-start;
  min-height: var(--tap-min);
  padding: 6px 10px;
  border: 0;
  background: none;
  color: var(--canvas);
  /* Bolder and bigger at the human's instruction — it was 9px at weight 500 and read as a
     caption rather than as the way out of standing still. */
  font: 700 12px/1 var(--font-mono);
  letter-spacing: .07em;
  text-transform: uppercase;
  opacity: .85;
  cursor: pointer;
  text-shadow: 0 1px 5px rgba(31,44,38,.75);
}
.foot-side[data-side="right"] .foot-open { align-self: flex-end; }
.foot-open[aria-expanded="true"] { opacity: 1; }

/* ON THE TRAY, THE GROUND CHANGES AND SO MUST THE INK. The triggers are Canvas with a
   shadow under them because they stand on an ILLUSTRATION — and the tray raised them above
   its own Canvas sheet, where cream on cream is very nearly nothing. The right-hand one is
   the tray's way out (§5.2's first dismissal), so an invisible label there is a dismissal
   that exists and cannot be found. Seen by opening the app; no assertion in the suite reads
   a contrast ratio. */
body[data-tray="true"] .foot-open {
  color: var(--ink-soft);
  text-shadow: none;
  opacity: .9;
}

.foot-list {
  display: flex;
  /* PLAIN `column`, AND THE FIRST BUILD USED `column-reverse`.
     
     The reasoning for the reverse was that a stack rising out of a control should read
     outward from the thumb — first item nearest the finger, the way an action sheet does.
     It is a defensible instinct and it is wrong here for two reasons, one of which is not
     a matter of taste:

     1. `column-reverse` makes the VISUAL order disagree with the DOM order. A sighted
        person saw the top-ranked eatery at the bottom of the stack; a screen reader
        announced it first. design-04 §7.2 rule 1 refuses a two-tier honesty policy over one
        digit — the same objection covers an ORDER that differs by how you are reading it.
     2. This list is RANKED (§5.4 — by mean stars, ties to count then recency), and a
        ranking is read top-down. Nearest-the-thumb is the right rule for a menu of equals
        and the wrong one for a list that means something by its order. */
  flex-direction: column;
  gap: 4px;
  align-items: stretch;
  /* CAPPED, BECAUSE A STACK IS NOT A SHEET. The evidence screenshot for §5.4's ordering
     caught this: six eateries in one scene filled most of the illustration, which is the
     panel-over-the-drawing the corners exist to avoid, arriving as a pile of chips. Three
     scenes with two eateries each never hit it; the human said they intend to author more.

     Scrolls rather than truncating, because §5.4's order means the ones you cannot see are
     the ones rated lowest — hiding them outright would be a suppression, and design-04 §5.4
     is explicit that the floor "is not hidden, not suppressed". */
  max-height: 46vh;
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* An item is a chip on the illustration, not a row in a panel: Canvas ground so the words
   are legible over any part of a scene, and nothing else. No icons, no chevrons, no
   dividers — design-01 §1's whole argument is that the scene is the interface. */
/* A TILE, NOT A ROW — redrawn at cycle 15's review. It was a name on one line and a compass
   word on a second, and the human's note was that the second row "is not nice": a picture
   answers "which way" faster than a word does, and the word was the widest thing on the
   chip. So: the mark for the place, an arrow rotated to the bearing, and the name.

   NARROW ON PURPOSE. The width is capped so a stack of them reads as marks scattered over
   the illustration rather than as a panel laid on top of it, which is the whole reason the
   corners replaced the sheet. `--foot-tile-w` is one edit if the cap is wrong. */
:root {
  /* FIXED, NOT CAPPED, and 30% of the screen — the human's instruction, and the reasoning
     they gave is the one that decides it: "potentially there will be longer eateries names
     to be added." A tile that sizes to its content gives you a ragged stack whose widths
     change every time somebody authors a place with a long name.

     `min(30vw, …)` rather than a bare percentage for a bug this had for one round: a
     percentage width resolves against its containing block, so 46% inside a side that was
     itself 60% of the bar came out at 27% of the screen — the exact width the cap had been
     raised to escape. `vw` is the screen, which is what was asked for; the second term caps
     it at 30% of the 420px frame so a desktop does not get a 400px tile. */
  --foot-tile-w: min(30vw, 126px);
}

/* REVEAL, AS A ROW IN THE PLACES LIST — cycle 40, review round 3. It is the same element the
   chrome held, moved rather than rebuilt, so this restyles it into a list row instead of a
   round control over the sky. Full width and labelled, because a bare dot read fine beside
   three other glyphs and reads as nothing in a column of named places. */
.reveal-toggle[data-in-list="true"] {
  width: 100%;
  min-height: var(--tap-min);
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 12px;
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--ink) 82%, transparent);
  color: var(--canvas);
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: .09em;
  text-transform: uppercase;
  text-align: left;
}

.reveal-toggle[data-in-list="true"]::after { content: attr(data-label); }

.reveal-toggle[data-in-list="true"][aria-pressed="true"] {
  background: color-mix(in srgb, var(--gold) 88%, transparent);
  color: var(--ink);
}

.foot-item {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
  /* DO NOT SQUASH — a bug in this corner since cycle 15, surfaced by cycle 17's "new to
     you" line making the tiles one row taller.

     `.foot-list` is a flex column with `max-height: 46vh`, and a flex child shrinks by
     default. So once the stack was taller than the cap the tiles COMPRESSED instead of the
     list scrolling: `min-height: var(--tap-min)` held the box at 44px and the eatery's NAME
     was clipped out of it. Six eateries in one scene showed six tiles with no names on them.

     The list already says what it means to do — "scrolls rather than truncating, because
     §5.4's order means the ones you cannot see are the ones rated lowest" — and this is the
     one line that makes that true. A clipped name is a truncation that does not even admit
     to being one. */
  flex: 0 0 auto;
  width: var(--foot-tile-w);
  min-height: var(--tap-min);
  padding: 7px 9px;
  border: 0;
  border-radius: var(--radius-sm);
  /* Semi-transparent with a shadow under it, at the human's instruction. The blur is what
     makes that affordable: at 78% over a busy façade the name would be fighting shutters
     for contrast, and a tile you cannot read is not a lighter tile, it is a broken one. */
  background: color-mix(in srgb, var(--canvas) 78%, transparent);
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  color: var(--ink);
  text-align: left;
  cursor: pointer;
  box-shadow: 0 3px 14px rgba(31,44,38,.28);
}
.foot-side[data-side="right"] .foot-item { align-self: flex-end; }

.foot-item-marks {
  display: flex;
  align-items: center;
  gap: 5px;
  min-height: 20px;
}

/* How long the walk is, from the hint the placement already carries — a shortening of the
   sentence the eatery's page shows in full, never a more precise claim than it makes. */
.foot-mins {
  font: 500 10px/1 var(--font-mono);
  color: var(--ink-soft);
  white-space: nowrap;
}

/* An eatery on this street. No direction to point and no walk to time, because you are
   standing in it — design-04 §3's `in_scene`. */
.foot-here {
  font: 700 9px/1 var(--font-mono);
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--clay);
}
.scene-mark path,
.eatery-mark path { fill: none; stroke: var(--ink); stroke-width: 1.6; stroke-linejoin: round; }
.scene-mark .map-glyph { fill: var(--ink); stroke: none; }
/* The miniature is authored with its own fills; let it keep them. */
.scene-mark g * { vector-effect: non-scaling-stroke; }

/* The direction, as a picture. Filled where somebody has checked the bearing, hollow where
   nobody has — and the `?` beside it is the same mark the exits, the list and the tray have
   used for an unchecked bearing since cycle 6 review round 6. */
.foot-arrow { display: inline-flex; align-items: center; gap: 1px; }
.foot-arrow path { fill: var(--ink); }
.foot-arrow[data-unverified="true"] path {
  fill: none;
  stroke: var(--ink);
  stroke-width: 1.6;
  stroke-linejoin: round;
}
.foot-arrow .caveat-mark { font: 600 10px/1 var(--font-mono); color: var(--ink-soft); }

/* Two lines of a long name, then an ellipsis. A name is the one thing on this tile that
   cannot be abbreviated away, and three lines of it is a tile that has become a panel. */
.foot-item-name {
  /* THREE LINES AT THIS WIDTH, NOT TWO. The tile is fixed at ~112px on a phone now, and two
     lines of 11px hold about 30 characters — "Chin Mee Chin Confectionery" is 27 and
     "Soon Soon Huat Curry Puff" is 25, so two lines truncates the ordinary case rather than
     the long one. Three holds both and still stops a name from becoming a paragraph. */
  font: 600 11.5px/1.25 var(--font-display);
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
  /* WORD BOUNDARIES, NOT CHARACTER ONES. `anywhere` broke "Micro Red House" into "Micr / o…"
     — a truncation that reads as a rendering fault rather than as a shortened name. */
  overflow-wrap: break-word;
  word-break: normal;
  hyphens: none;
}
.foot-item .eatery-star { margin-top: 2px; gap: 4px; }
.foot-item .eatery-star-count { font-size: 11px; color: var(--ink); }
.foot-item .star-glyph { width: 13px; height: 13px; }

/* Cycle 16 — nobody has checked this place exists. The same dotted-underline-and-`?`
   vocabulary the exits, the list and the tray use for an unchecked BEARING, on a different
   fact: `.foot-arrow` above marks a direction nobody walked, this marks a place nobody
   looked for, and an eatery added from the app is both at once.

   spine §7.1 gives it no opposite: a checked eatery gets no badge and no colour, and the
   absence of this line is the whole signal. */
.foot-unchecked {
  display: inline-flex;
  align-items: baseline;
  font: 600 9px/1 var(--font-mono);
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-soft);
  border-bottom: 1px dotted currentColor;
  padding-bottom: 2px;
}

/* THE INVITATION AT THE FOOT OF THE STACK. Deliberately quieter than the tiles above it —
   dashed rather than filled, no backdrop blur — because the corner's first job is still
   "what can I eat from here" and this is the answer to a different question. */
.foot-add {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--foot-tile-w);
  min-height: var(--tap-min);
  padding: 7px 9px;
  border: 1px dashed color-mix(in srgb, var(--ink) 35%, transparent);
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--canvas) 55%, transparent);
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  color: var(--ink);
  cursor: pointer;
}
.foot-side[data-side="right"] .foot-add { align-self: flex-end; }
.foot-add-label {
  font: 700 10px/1.2 var(--font-mono);
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-soft);
  text-align: center;
}

/* While a stack is open, the round add-photo control gets out of the way. It sits in the
   bottom-right by design (design-03 §2.4) and the right-hand stack rises straight through
   it — the same collision `data-placing` and `data-timelapse` already solve for the other
   surfaces, so this uses their shape rather than a new one. */
body[data-foot="true"] .add-memory { opacity: 0; pointer-events: none; }

/* Zoomed in, the corners get out of the way — the rule the exit bar already had. */
.scene:has(> .scene-frame[data-zoom]:not([data-zoom="1.000"])) ~ .foot-bar { opacity: 0; }

/* `.exit-hint-btn` KEEPS ITS RULES BECAUSE THE LIST VIEW STILL DRAWS ONE. Cycle 15's review
   took the chips off the SCENE; design-01 §4.4's third projection is unchanged, and
   list.js still renders an exit as a row you can walk through. The rules below are the ones
   that surface still needs. What went with the chips is the over-pan brightening, which had
   no target left. */
.exit-hint-btn {
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: 5px;
  min-height: var(--tap-min);
  padding: 6px 10px;
  border: 0;
  background: none;
  color: var(--ink);
  cursor: pointer;
}

.exit-hint-btn[data-edge="right"] { flex-direction: row-reverse; }
.exit-arrow { font-size: 17px; line-height: 1; }

/* An unverified bearing is drawn as one: dotted, with a question mark. Nobody has
   walked this graph, and design-01 §3.4 allows eliding distance, never direction. */
.exit-bearing { opacity: .8; }
.exit-hint-btn[data-bearing-verified="false"] .exit-bearing {
  border-bottom: 1px dotted currentColor;
  padding-bottom: 1px;
}
.caveat-mark { margin-left: 1px; font-weight: 600; }
.exit-label {
  font-family: var(--font-mono);
  font-size: 8px;
  letter-spacing: .07em;
  text-transform: uppercase;
  max-width: 92px;
}

/* WHERE THE OVER-PAN AFFORDANCE WENT, and it is a real loss rather than a tidy-up.
   Cycle 4 brightened the exit chip you were pushing toward, and scaled it past the commit
   threshold — the edge stopped being a hint and became a promise. The chips are gone, so
   the rules had nothing to brighten.

   THE GESTURE ITSELF IS UNTOUCHED: over-panning still walks, still has its threshold, and
   still gives the rubber band back if you let go early (scene.js). What it no longer has is
   a visible target growing under your thumb, and until something replaces that, the walk's
   fast path is discoverable only by trying it. Recorded here rather than deleted quietly. */

/* ---------- list projection ---------- */

.list {
  position: absolute;
  inset: 0;
  background: var(--canvas);
  overflow-y: auto;
  padding: 84px 16px 24px;
  z-index: 30;
}

.list h2 {
  font-family: var(--font-display);
  font-size: 20px;
  margin: 0 0 2px;
}

.list-sub {
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: .09em;
  text-transform: uppercase;
  color: var(--ink-soft);
  margin: 0 0 18px;
}

.tile-strip {
  height: 8px;
  margin: 0 0 18px;
  background-image:
    linear-gradient(45deg, var(--tile-teal) 25%, transparent 25%, transparent 75%, var(--tile-teal) 75%),
    linear-gradient(45deg, var(--tile-teal) 25%, var(--gold) 25%, var(--gold) 75%, var(--tile-teal) 75%);
  background-size: 16px 16px;
  background-position: 0 0, 8px 8px;
  opacity: .85;
}

.list-row {
  display: flex;
  gap: 11px;
  width: 100%;
  text-align: left;
  padding: 12px 0;
  min-height: var(--tap-min);
  border: 0;
  border-bottom: 1px solid var(--canvas-deep);
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
}

.list-icon { font-size: 17px; flex: none; width: 22px; }
.list-body { flex: 1; min-width: 0; }

.list-affordance {
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: .09em;
  text-transform: uppercase;
  color: var(--ink-soft);
  opacity: .75;
}

.list-title { font-weight: 600; font-size: 14px; margin-top: 1px; }
.list-text { font-size: 12.5px; color: var(--ink-soft); margin-top: 2px; }

/* Details in the list. Same words the scene shows at zoom, so they read like the
   subordinate notes they are — they arrived unstyled and inherited the browser
   default, which made the least important line in the row the largest text on it. */
.list-details {
  margin-top: 6px;
  padding-left: 9px;
  border-left: 1.5px solid color-mix(in srgb, var(--ink) 12%, transparent);
  display: grid;
  gap: 3px;
}

.list-detail { font-size: 11.5px; line-height: 1.4; color: var(--ink-soft); }

.list-row[data-empty="true"] { opacity: .72; }
.list-row[data-empty="true"] .list-title { font-weight: 400; font-style: italic; }

/* `.caveat` MOVED TO css/claim.css AT CYCLE 21's REVIEW ROUND, and this note is here because
   it is the app's oldest shared mark (cycle 6) and somebody will look for it in this file.
   The operator's review page draws claims without loading app.css, and a claim's state marker
   has to travel with the claim — spine §7.2: the marker is part of the claim, not chrome
   around it. index.html loads both stylesheets, so nothing about the street changed. */

.list-exits { margin-top: 22px; }

.list-exits .list-affordance { margin-bottom: 6px; }

/* ---------- card ---------- */

.card-backdrop {
  position: absolute;
  inset: 0;
  z-index: 50;
  background: rgba(31,44,38,.45);
  display: grid;
  align-items: end;
}

.card {
  background: var(--canvas);
  border-radius: var(--radius) var(--radius) 0 0;
  padding: 18px 18px 26px;
  max-height: 74%;
  overflow-y: auto;
}

.card-tag {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.card h3 {
  font-family: var(--font-display);
  font-size: 20px;
  margin: 5px 0 8px;
}

.card p { font-size: 14px; line-height: 1.5; margin: 0 0 12px; color: var(--ink-soft); }

.card-source {
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: .04em;
  color: var(--ink-soft);
  opacity: .85;
  border-top: 1px solid var(--canvas-deep);
  padding-top: 10px;
}

.card-close {
  margin-top: 16px;
  width: 100%;
  min-height: var(--tap-min);
  border: 1px solid var(--ink);
  border-radius: 999px;
  background: none;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: .1em;
  text-transform: uppercase;
  cursor: pointer;
}

.card-stub {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: .05em;
  color: var(--clay);
  border: 1px dashed var(--clay);
  border-radius: var(--radius-sm);
  padding: 9px 11px;
  margin-bottom: 12px;
}

/* ============================================================ the map
   design-01 §4.4 — the third projection, and after cycle 6's review a HEAD-UP DISPLAY
   rather than a screen. The scene stays exactly where it was and keeps running; the map
   hangs in front of it on tinted glass. You look at where you are without leaving it,
   which is the difference between consulting a map and glancing at one.

   Nothing here is a basemap: no tiles, no imagery, no external anything. The ground is
   the street network from data/map.json, drawn in the world's own palette. */

/* The opener: a live map of the map, small enough to sit in the chrome. */
.minimap {
  flex: none;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 1px solid rgba(246,239,225,.4);
  border-radius: 50%;
  background: color-mix(in srgb, var(--ink) 34%, transparent);
  overflow: hidden;
  cursor: pointer;
  display: grid;
  place-items: center;
}
.minimap-art { width: 100%; height: 100%; display: block; }
.minimap-field { fill: rgba(246,239,225,.16); }
.minimap-road { fill: none; stroke: var(--canvas); stroke-width: 1.1; opacity: .7; stroke-linecap: round; }
.minimap-dot { fill: var(--canvas); opacity: .8; }
.minimap-dot[data-here="true"] { fill: var(--tile-pink); opacity: 1; }

/* The record's opener — cycle 12, review round 2: "a button like the map on top". Same
   size, same ring, same place, because it does the same KIND of thing: a surface you
   glance into and come back from rather than a view you switch to.

   The glyph is a clock face drawn in CSS rather than an emoji. 🕰️ is what the content
   file uses for a heritage slot and it renders as a different picture on every platform;
   this is the chrome, where the app should look like itself. */
.record-open {
  flex: none;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 1px solid rgba(246,239,225,.4);
  border-radius: 50%;
  background: color-mix(in srgb, var(--ink) 34%, transparent);
  cursor: pointer;
  position: relative;
}

.record-open[hidden] { display: none; }

.record-open::before,
.record-open::after {
  content: "";
  position: absolute;
  background: var(--canvas);
  border-radius: 1px;
  left: 50%;
  top: 50%;
  transform-origin: 0 0;
}

/* The two hands, at ten past ten — the position a clock is drawn in when it is standing
   for time rather than telling it. */
.record-open::before { width: 2px; height: 9px; transform: translate(-1px, -8px); }
.record-open::after  { width: 2px; height: 7px; transform: translate(-1px, 0) rotate(-115deg); }

body[data-timelapse="true"] .record-open {
  background: var(--gold);
  border-color: var(--gold);
}

body[data-timelapse="true"] .record-open::before,
body[data-timelapse="true"] .record-open::after { background: var(--ink); }


/* THE MAP'S DRAWING IS IN css/map.css — cycle 39.
   The OVERLAY stays here (`.map` below: the glass, the z-index, the raise) because it is app
   shell — a card that hangs over the street. The DRAWING moved, unchanged, because a second
   page now renders the same map: welcome.html's survey section, where somebody who has never
   been here picks a place to stand.

   admin-facts.html's header has the argument and the scar: app.css was borrowed for a second
   page once, and that page "came out dark green with the claim bodies invisible". The fix then
   was claim.css, a shared component's own stylesheet, and this is the same move for the same
   reason. Nothing was recoloured in the process — which matters, because these rules are drawn
   for the DARK GLASS: the strokes are pale ink at low alpha, and they are invisible on canvas.
   So the welcome page gives the map the same ground rather than the map a second palette. */

.map-body { position: absolute; inset: 0; }

.map {
  position: absolute;
  inset: 0;
  padding: 78px 10px 44px;
  z-index: 30;
  overflow: hidden;
  /* The glass. Dark enough that pale ink reads over a bright noon facade, light enough
     that the scene behind is unmistakably still there — the point of the overlay. */
  background:
    radial-gradient(120% 80% at 50% 34%, rgba(31,44,38,.52), rgba(31,44,38,.78));
  -webkit-backdrop-filter: saturate(.72);
  backdrop-filter: saturate(.72);
  animation: map-raise .22s ease-out;
}

.map[hidden] { display: none; }



@keyframes map-raise {
  from { opacity: 0; transform: scale(1.015); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .map { animation: none; }
}

/* You cannot walk out of a scene while you are looking at the map, so the edge hints go.
   Leaving them visible under the glass offered a gesture the surface does not answer.
   Keyed off the modal now rather than a view mode — round 7 made the map a card, not a
   third tab, so there is no "map view" to hang this on any more. */
body[data-map-open="true"] .foot-bar { display: none; }

/* THE WAIT, DRAWN — cycle 10.
   The place question is a language model and the spike measured 2.6-3.8s for it, against
   Cloud Vision's sub-second in cycle 9. Cycle 9 could disable the button and say nothing;
   four seconds of a dead button cannot. Asked for at this cycle's planning: "show the
   blocking with a cancel button, inform the user 'processing your image...'".

   So while it reads: the line says what is happening, the add button wears the same cross
   it wears while placing because it is now the cancel, and the scene is inert. Nothing
   spins — this world has no spinners, and a pulsing line is enough to say it is alive
   without pretending to measure progress it cannot see. */
body[data-reading="true"] .region,
body[data-reading="true"] .memory-figure,
body[data-reading="true"] .exit-hint-btn,
body[data-reading="true"] .foot-side { pointer-events: none; }

body[data-reading="true"] .place-hint { animation: reading-breathe 1.6s ease-in-out infinite; }

@keyframes reading-breathe {
  0%, 100% { opacity: 1; }
  50% { opacity: .55; }
}

@media (prefers-reduced-motion: reduce) {
  body[data-reading="true"] .place-hint { animation: none; }
}

/* A PIN THAT MATCHED NOTHING, cycle 11 at review — asked for in these words: "the icon
   colour should be different, maybe red to get attention".

   Clay rather than a raw red, because it is the palette's own alarm colour and the same
   one the cancel button uses. And it is NOT an error state: nothing went wrong, the app
   simply has no answer, and the pin is sitting in the middle of the pavement because that
   is a neutral guess rather than a proposal. What the colour says is "this one is yours to
   place" — which is why it clears the moment the pin is dragged, in upload.js, since from
   then on the position IS a person's own claim. */
.place-pin[data-unmatched="true"]::before {
  background: var(--clay);
}

.place-pin[data-unmatched="true"] {
  animation: pin-unplaced 1.6s ease-in-out 3;
}

@keyframes pin-unplaced {
  0%, 100% { transform: scale(var(--pin-scale, 1)); }
  50%      { transform: scale(calc(var(--pin-scale, 1) * 1.08)); }
}

/* design-01 §6.2's restraint applies to the world, not to a control that is waiting for
   somebody. Three pulses, then it stops — a marker that never settles is the "markers
   crowd" failure the scene-craft rubric scores at 2. */
@media (prefers-reduced-motion: reduce) {
  .place-pin[data-unmatched="true"] { animation: none; }
}

/* The local sign-in, which only ever appears when the service says its dev identity
   issuer is installed — so on a laptop and nowhere else. Deliberately plain: it is a
   development affordance sitting inside a world that has no words in it, and dressing it
   up would make it look like part of the street. */
.sign-in-dev {
  font: inherit;
  font-size: 0.85rem;
  margin-top: 0.5rem;
  padding: 0.4rem 1rem;
  border-radius: 999px;
  border: 1px dashed var(--ink-soft);
  background: transparent;
  color: var(--ink-soft);
  cursor: pointer;
}

/* ---------- the record — design-02 §3 (cycle 12, rebuilt at review round 2) ---------- */

/* A GALLERY OVER A SOFT DRAWING, which is the shape of the reference the human sent: the
   print you are on, centred and framed; its neighbours peeking either side; the years on a
   track underneath; the scene behind, blurred and dimmed.

   The drawing is still there, and that is §3.2 — the stage is "always present" — and §3.5,
   which needs it visible whenever there is no photograph to look at. */
.timelapse {
  position: absolute;
  inset: 0;
  z-index: 32;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  /* Both remaining gestures are drags, and a drag across text selects it. */
  user-select: none;
  -webkit-user-select: none;
}

.timelapse[hidden] { display: none; }

/* THE WHOLE SURFACE TAKES THE POINTER, including the transparent part over the artwork.
   The swipe that moves the year has to be available where the picture is, and while the
   record is open a tap must not reach a slot in the street behind it. The chrome sits above
   at z-index 40, so the way out is never covered. */

/* The scrim, under everything and over the drawing. */
.tl-scrim {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(120% 80% at 50% 38%, rgba(31,44,38,.18), rgba(31,44,38,.62));
  pointer-events: none;
}

/* THE DRAWING GOES SOFT WHILE A PHOTOGRAPH IS SHOWING — asked for at review, and it does
   the boundary work of §2.3 as well: the moment there is a record to look at, the stage
   stops competing with it. The blur is on the SCENE and never on the print. */
.frame:has(#timelapse:not([hidden])[data-cell="photo"]) .stage {
  filter: blur(7px) saturate(.72) brightness(1.04);
}

.frame:has(#timelapse:not([hidden])) .stage { transition: filter .34s ease; }

/* ---- the coverflow */

.tl-gallery {
  position: absolute;
  top: 76px;
  bottom: 78px;
  left: 0;
  right: 0;
  overflow: hidden;
  display: flex;
  align-items: center;
}

.tl-strip { width: 100%; height: 100%; align-items: center; }

.tl-strip {
  display: flex;
  align-items: center;
  transition: transform .34s cubic-bezier(.22,.61,.36,1);
}

@media (prefers-reduced-motion: reduce) {
  .tl-strip { transition: none; }
}

/* A plate is one POSITION ON THE TRACK — a year, with or without a photograph in it — so
   what peeks either side is the year either side. 78% wide with the rest as gutter, which
   is the peek in the reference image; timelapse.js translates the strip in the same units
   and defines both numbers once. */
/* A FIXED FRAME RATHER THAN A PLATE THAT HUGS ITS PHOTOGRAPH — review round 4. The stack
   inside slides by exactly one shot, which is only true if every shot is the same height,
   and the frame is what makes them so. It also settles a wobble the coverflow had: plates
   of different heights made the neighbours either side jump as you moved. */
.tl-plate {
  position: relative;
  flex: 0 0 84%;
  /* Not the full height of the gallery: a landscape print in a frame that tall is a
     photograph adrift in a field of paper, and review round 5 asked for it shorter again.
     68% leaves a mount that reads as a mount, gives every shot the same height — which is
     what lets the scroller snap by one — and leaves a band of scrim above and below that is
     unmistakably OUTSIDE the card, where a tap closes. */
  height: 68%;
  margin: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  background: var(--print-paper);
  padding: 9px 9px 7px;
  border-radius: 2px;
  box-shadow: 0 16px 40px rgba(31,44,38,.5), 0 0 0 1px rgba(31,44,38,.1) inset;
  transform: scale(.86);
  opacity: .5;
  transition: transform .34s cubic-bezier(.22,.61,.36,1), opacity .34s ease;
}

.tl-plate[data-current="true"] { transform: scale(1); opacity: 1; }

/* A neighbour cannot be scrolled — only the decade you are on has a scroller in play, so a
   thumb landing on the strip at the edge of the screen does not move a stack nobody can
   see. Tapping one steps to it. */
.tl-plate[data-current="false"] .tl-stack { overflow: hidden; touch-action: auto; }
.tl-plate[data-current="false"] { cursor: pointer; }

@media (prefers-reduced-motion: reduce) {
  .tl-plate { transition: none; }
}

/* ---- the stack: a decade's photographs, one under another — review round 4. A decade
   used to resolve to ONE photograph and the rest were unreachable; now the plate is a
   window and this is what moves behind it. Horizontal is between decades, vertical is
   within one: two scales of the same axis. */
/* A REAL SCROLLER since review round 5, tested on a phone rather than in a viewport. A
   synthesised vertical swipe on a web page fights the browser and loses — what it lost on
   the human's phone was the whole page scrolling underneath. This brings the platform's own
   momentum, rubber band and accessibility with it.

   `overscroll-behavior: contain` is the load-bearing line: without it, reaching the end of
   the decade chains the scroll to the page behind, which is the reported symptom exactly.
   `touch-action: pan-y` gives the vertical to the scroller and leaves the horizontal to the
   step between decades. */
.tl-stack {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  touch-action: pan-y;
  scroll-snap-type: y mandatory;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}

.tl-stack::-webkit-scrollbar { display: none; }

.tl-shot {
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

/* A CUT, NEVER A CROSSFADE, and the print itself carries no transition of any kind.
   `contain` rather than `cover`: a photograph of a building cropped to fill a frame is a
   photograph the app has edited, and §7.4 is that the record is not altered to fit. */
.tl-plate img {
  display: block;
  width: 100%;
  /* A drag that starts on an <img> is a native image drag in Chromium, and it swallows the
     pointerup — so the vertical gesture died on the one element it is most natural to start
     it on. The print is not interactive; the surface around it is. */
  pointer-events: none;
  -webkit-user-drag: none;
  /* Natural height up to a limit, rather than filling the frame: the print and its caption
     sit together in the middle of the mount, the way a photograph in an album does. Filling
     stranded the caption at the bottom of a foot of blank paper. */
  flex: 0 1 auto;
  max-height: 62%;
  object-fit: contain;
  transition: none;
  border-radius: 1px;
}

/* Only the photograph you are on is at full weight. The ones under it in the same decade
   are visible at the edges of the movement, which is what makes the stack read as a stack
   rather than as the plate changing its mind. */
.tl-plate[data-shots] .tl-shot[data-current="false"] { opacity: .55; }

/* How many, and where in them. Dots rather than a number, and each is a tap target with
   the year on it for a screen reader (design-01 §9). */
/* Top-right rather than centred: the chevrons sit at the vertical middle of the gallery
   and a dot under one is a dot nobody can press. Found by a test that could see the dot
   and could not click it. */
/* A GROUND UNDER THE DOTS, asked for at review round 5: "please make a semi-transparent
   background so it's easier for user to notice there are more photos to be swiped up/down".
   Two dots on paper are easy to miss; two dots on a pill are a control. */
.tl-depth {
  position: absolute;
  top: 6px;
  right: 6px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 4px 1px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--ink) 22%, transparent);
  backdrop-filter: blur(2px);
  z-index: 3;
}

.tl-depth .tl-dot::after { background: color-mix(in srgb, var(--canvas) 78%, transparent); }
.tl-depth .tl-dot[aria-pressed="true"]::after { background: var(--gold); }

.tl-dot {
  width: 22px;
  height: 22px;
  border: none;
  background: none;
  padding: 0;
  cursor: pointer;
  position: relative;
  font-size: 0;
}

.tl-dot::after {
  content: "";
  position: absolute;
  inset: 0;
  margin: auto;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--ink) 34%, transparent);
}

.tl-dot[aria-pressed="true"]::after { background: var(--gold); width: 8px; height: 8px; }
.tl-dot:focus-visible { outline: 2px solid var(--gold); outline-offset: -2px; }

/* A position with no photograph in it. Not a failure and not a blank: §3.5's empty cell,
   in the gallery's own vocabulary, with the drawing visible behind it because at this
   position the drawing IS what you are looking at. */
.tl-plate[data-empty="true"] {
  background: color-mix(in srgb, var(--print-paper) 82%, transparent);
  border: 1px dashed color-mix(in srgb, var(--ink) 34%, transparent);
  padding: 22px 16px;
  text-align: center;
  justify-content: center;
}

.tl-note-lead { font-family: var(--font-display); font-size: 15px; margin: 0; }

.tl-note-sub {
  font-family: var(--font-mono);
  font-size: 9px;
  line-height: 1.5;
  letter-spacing: .03em;
  margin: 7px 0 0;
  color: var(--ink-soft);
}

.tl-caption {
  flex: none;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: baseline;
  gap: 2px 8px;
  margin-top: 9px;
  color: var(--ink);
  text-align: center;
}

.tl-caption .tl-year { flex-basis: 100%; }

.tl-year { font-family: var(--font-display); font-size: 17px; letter-spacing: .02em; }

.tl-origin, .tl-by, .tl-source {
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: .05em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* Nothing in this record is confirmed, so the caveat is on all of it — spine §7.1 inverted
   is still spine §7.1: the marked state is the one that is not settled. */
.tl-caption .caveat {
  font-family: var(--font-mono);
  font-size: 8.5px;
  color: var(--clay);
  margin-top: 3px;
}

.tl-withdraw {
  display: block;
  margin: 5px auto 0;
  padding: 2px 4px;
  border: none;
  background: none;
  color: var(--clay);
  font: inherit;
  font-size: 10px;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}

/* The chevrons from the reference, sitting over the peeking neighbours. They are the tap
   half of the swipe — design-01 §9 forbids a gesture-only action. */
.tl-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: var(--tap-min);
  height: var(--tap-min);
  border: none;
  background: none;
  color: var(--canvas);
  font-size: 0;
  cursor: pointer;
  z-index: 2;
}

.tl-arrow[data-step="prev"] { left: 0; }
.tl-arrow[data-step="next"] { right: 0; }
/* A disabled chevron is out of the way as well as out of use — it sits over the plate,
   and a dead control that still swallows a press is worse than one that is not there. */
.tl-arrow:disabled { opacity: .2; cursor: default; pointer-events: none; }

.tl-arrow::after {
  content: "";
  position: absolute;
  inset: 0;
  margin: auto;
  width: 12px;
  height: 12px;
  border-top: 2px solid currentColor;
  border-right: 2px solid currentColor;
}

.tl-arrow[data-step="prev"]::after { transform: rotate(-135deg) translate(-3px, -3px); }
.tl-arrow[data-step="next"]::after { transform: rotate(45deg) translate(-3px, -3px); }
.tl-arrow:focus-visible { outline: 3px solid var(--gold); outline-offset: -6px; }

/* ---- the panel underneath, which is ONE ROW since review round 3: "I want to maximize
   the screen asset". The title, the tier line and the tenancy band are gone; the two
   full-width buttons are two icons sharing the row with the years. What is left is about
   sixty pixels of chrome instead of about two hundred and sixty. */

.tl-panel {
  position: relative;
  background: color-mix(in srgb, var(--canvas) 96%, transparent);
  border-radius: var(--radius) var(--radius) 0 0;
  box-shadow: 0 -8px 28px rgba(31,44,38,.32);
  padding: 8px 12px calc(10px + env(safe-area-inset-bottom));
}

.tl-row { display: flex; align-items: center; gap: 8px; }

.tl-unreachable {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--clay);
  margin: 0 0 6px;
}

/* ---- the sliding years (§3.4, §4.3): decade ticks where the record is sparse, years
   where it is dense, and every empty one visibly present rather than dimmed away. */
.tl-scrub {
  display: flex;
  flex: 1;
  min-width: 0;
  gap: 3px;
  overflow-x: auto;
  padding-bottom: 4px;
  /* The drag along this track IS the year gesture, so it must not also be a scroll. The
     selected tick is scrolled into view on every repaint instead. */
  touch-action: none;
  scrollbar-width: none;
}

.tl-scrub::-webkit-scrollbar { display: none; }

.tl-tick {
  flex: 1 0 auto;
  min-height: 34px;
  padding: 4px 6px;
  border: none;
  border-top: 2px solid color-mix(in srgb, var(--ink-soft) 28%, transparent);
  background: transparent;
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: .04em;
  color: var(--ink-soft);
  cursor: pointer;
}

/* An empty tick is VISIBLY PRESENT AND VISIBLY UNFILLED (§9.2.2) — it is not dimmed out
   of the way, because it is the thing that asks. A filled one carries a mark. */
.tl-tick[data-filled="true"] { border-top-color: var(--gold); color: var(--ink); }
.tl-tick[aria-pressed="true"] {
  background: color-mix(in srgb, var(--canvas-deep) 90%, transparent);
  border-top-width: 3px;
  color: var(--ink);
}

.tl-tick:focus-visible { outline: 3px solid var(--gold); outline-offset: -2px; }

/* A run of empty decades — §9.2's surviving half, and the shape of the compromise review
   round 4 asked for. Seen, named to a screen reader, and not a place you can go: sliding
   skips it, looking finds it, and it costs a few pixels instead of a whole position. */
.tl-gap {
  flex: none;
  align-self: stretch;
  margin: 0 2px;
  border-top: 2px dashed color-mix(in srgb, var(--ink-soft) 34%, transparent);
}

/* While the record is open the street's own controls are not what you are using. */
/* `.foot-side` IS NAMED HERE AND `.foot-bar` IS NOT ENOUGH, which cost five tests. The bar
   is `pointer-events: none` and each side turns it back on for its own controls — so
   switching the BAR off leaves the sides live, and the corners went on swallowing taps
   meant for the record underneath them. A property a child re-enables is not a property the
   parent controls. */
body[data-timelapse="true"] .add-memory,
body[data-timelapse="true"] .foot-side,
body[data-timelapse="true"] .sign-in { opacity: 0; pointer-events: none; }

/* ---- the two icons. Round, quiet, and the size design-01 §9 asks for — the label is on
   them for a screen reader and in a tooltip for a pointer; only the eye gets a glyph. The
   street's add button is a gold glyph floating over the scene; these sit in the panel. */
.tl-contribute { flex: none; }

.tl-add, .tl-back {
  flex: none;
  width: var(--tap-min);
  height: var(--tap-min);
  padding: 0;
  border-radius: 50%;
  cursor: pointer;
  position: relative;
  font-size: 0;
}

.tl-add {
  border: 1px solid var(--gold);
  background: color-mix(in srgb, var(--gold) 16%, transparent);
}

.tl-back {
  border: 1px solid color-mix(in srgb, var(--ink) 30%, transparent);
  background: none;
}

/* A plus, and a cross: add one, and close this. */
.tl-add::before, .tl-add::after,
.tl-back::before, .tl-back::after {
  content: "";
  position: absolute;
  inset: 0;
  margin: auto;
  background: var(--ink);
  border-radius: 1px;
}

.tl-add::before  { width: 15px; height: 2px; }
.tl-add::after   { width: 2px; height: 15px; }
.tl-back::before { width: 15px; height: 2px; transform: rotate(45deg); }
.tl-back::after  { width: 15px; height: 2px; transform: rotate(-45deg); }

.tl-add:focus-visible,
.tl-back:focus-visible { outline: 3px solid var(--gold); outline-offset: 2px; }

/* While the form is open it takes the whole row: a year and two questions do not fit
   beside a scrubber, and this is the one moment in the surface where the chrome should be
   bigger than the picture. */
.tl-panel:has(.tl-form) .tl-row { display: block; }
.tl-panel:has(.tl-form) .tl-scrub,
.tl-panel:has(.tl-form) .tl-back { display: none; }

/* The file input is opened by the button and never seen — a bare "Choose File" inside a
   drawn world is the one control that always looks like the browser rather than the app. */
.tl-file { display: none; }

.tl-form { display: grid; gap: 8px; }
.tl-form-lead { margin: 0; font-size: 11.5px; line-height: 1.4; color: var(--ink-soft); }

/* SIXTEEN PIXELS, AND IT IS A BUG FIX RATHER THAN A TYPE CHOICE — review round 6, from a
   phone: "when I upload the photo in time-lapsed, the page got zoom in when it prompts to
   enter the text field of the year being taken. After I entered the text and submitted, the
   zoom stays even back to the scene."

   iOS Safari zooms the page to make any focused input with type under 16px readable, and it
   does not zoom back out. Every surface behind it is then at the wrong scale — which for
   this app means the drawn scene, the camera and the pin positions, all composed for a
   viewport that is no longer the one on screen.

   THE OTHER FIX IS THE WRONG ONE. `maximum-scale=1, user-scalable=no` in the viewport meta
   also stops it, by taking pinch-zoom away from everybody — design-01 §9's accessibility
   floor, and WCAG 1.4.4, for a problem that costs three points of type to solve properly.

   Round 7 then removed the last text field in the app, so this floor now guards a select
   and whatever comes next rather than the field it was written for. */
.tl-origin-input {
  width: 100%;
  min-height: var(--tap-min);
  padding: 8px 10px;
  border: 1px solid color-mix(in srgb, var(--ink-soft) 40%, transparent);
  border-radius: var(--radius-sm);
  background: var(--canvas);
  color: var(--ink);
  font: inherit;
  font-size: 16px;
}

/* ---- the year, chosen twice rather than typed once (review round 7). Two rows of small
   buttons: the decades the record covers, then the ten years inside one. */
.tl-when { display: grid; gap: 4px; }

.tl-choices-label {
  margin: 4px 0 0;
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: .09em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.tl-choices {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}

.tl-choices[hidden] { display: none; }

.tl-choice {
  min-height: 34px;
  padding: 6px 9px;
  border: 1px solid color-mix(in srgb, var(--ink-soft) 34%, transparent);
  border-radius: var(--radius-sm);
  background: var(--canvas);
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: 12px;
  cursor: pointer;
}

.tl-choice[aria-pressed="true"] {
  border-color: var(--gold);
  background: color-mix(in srgb, var(--gold) 22%, transparent);
  color: var(--ink);
}

.tl-choice:focus-visible { outline: 3px solid var(--gold); outline-offset: 2px; }

.tl-save {
  min-height: var(--tap-min);
  border: none;
  border-radius: var(--radius-sm);
  background: var(--gold);
  color: var(--ink);
  font: inherit;
  font-size: 12.5px;
  cursor: pointer;
}

/* Off until a year is chosen. A button that is there and does nothing is the shape cycle
   8's review found at the confirm; one that says it is not ready yet is not that. */
.tl-save:disabled { opacity: .45; cursor: default; }

/* Cancel is clay and says which job it is doing — cycle 8's review, where a cancel drawn
   in the same gold as the confirm lost somebody's photograph. */
.tl-cancel {
  min-height: var(--tap-min);
  border: 1px dashed var(--clay);
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--clay);
  font: inherit;
  font-size: 12px;
  cursor: pointer;
}

.tl-said {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 10px;
  line-height: 1.4;
  color: var(--clay);
}

.tl-said:empty { display: none; }


/* ===== eateries — cycle 13, design-04 ===== */

/* THE PHOTOGRAPH AREA, AND IN THIS CYCLE IT IS ALWAYS EMPTY.
   design-04 §5.3: photographs BLEED — to the container's edges and to each other. No
   cards, no rounded corners, no shadows, no captions burned onto the image. The taste
   argument is that a frame around a photograph of food turns it into an ad for food; the
   rule is that a framed photograph reads as a card, and a card reads as a listing, which
   is the register §1 of that document exists to escape.

   It sits at the TOP of the panel because §5.4 puts it there, and §5.4 puts it there
   because the photograph answers "should I eat here" and the words answer "what is it".
   Note what this is NOT: .memory-photo above has a radius and a margin, because a memory
   is a personal artefact held in a panel. A food photograph is the surface. */
.eatery-photos {
  display: flex;
  gap: 0;
  margin: -12px -13px 9px;   /* out through the panel's own padding, to the edge */
  border-radius: var(--radius) var(--radius) 0 0;
  overflow: hidden;
  background: var(--canvas-deep);
}

.eatery-photo {
  display: block;
  flex: 1 1 0;
  min-width: 0;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}

/* The honest empty (spine §9 — a thin surface reads as thin), and it sits at the BOTTOM
   of the panel rather than the top. The first build followed design-04 §5.4's order
   literally and put the photo area first, so with no photographs the panel opened with
   "NO PHOTOS OF THE FOOD HERE YET" above the name — an absence answering a question the
   person had not asked yet. §5.4's order is a rule about photographs; an absence is not
   one. See the note in prototype/js/eateries.js.

   Deliberately not an image frame with a dashed border: an empty picture-shaped box is a
   promise of a picture, and design-01 §6.3's rule against fabricating a locked slot is
   the same rule. A quiet line of type, and not a button — uploading is cycle 15's. */
.eatery-absence {
  margin: 9px 0 0;
  padding-top: 7px;
  border-top: 1px solid color-mix(in srgb, var(--ink) 10%, transparent);
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--ink-soft) 72%, transparent);
}

.eatery-menu {
  margin-top: 9px;
  padding-top: 8px;
  border-top: 1px solid color-mix(in srgb, var(--ink) 12%, transparent);
}

.eatery-dish {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 5px;
  padding: 3px 0;
}

.eatery-dish b { font-size: 12.5px; font-weight: 600; }
.eatery-dish em { font-style: normal; font-size: 11px; color: var(--ink-soft); }

/* design-04 §8: a fresh price renders PLAINLY — spine §7.1, confirmed content carries no
   badge, and the absence of a caveat is the signal. */
.eatery-price {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--ink);
}

/* And a stale one carries its basis, non-dismissibly (spine §7.2, §7.3). Muted, with the
   month it was last checked — NOT hidden, because hiding loses real information, and not
   served as current, because that is the lie. 180 days, answered by the human at §13 q3. */
.eatery-price[data-stale="true"] { color: var(--ink-soft); }
.eatery-price[data-stale="true"] .caveat { display: block; text-align: right; }

/* ---------- the menu on the counter (cycle 25, design-06 §5.1) ---------- */

/* THE PRICE IS THE SAME ELEMENT IN ALL THREE PLACES — the card on the street, the card on
   the counter, the row in the list — because it is the same claim and spine §7.2 says a
   caveat that can be lost in transit is not a caveat. What changes below is only how a
   block lays out inside a card versus inside a flex row; the colour, the mono face and the
   stale treatment are `.eatery-price` above, once. */
.detail-card[data-dish="true"] .eatery-price {
  display: block;
  margin: 5px 0 0;
  text-align: left;
}

/* The food, above its name — cycle 25's review round: "I want the photo to be shown to the
   (1), (2)... cards to show the food photo and the name + desc + price."

   BLEEDING TO THE CARD'S EDGES, which is `design-04` §5.3's rule for the eatery panel's
   photographs arriving in the one other place a food photograph is drawn: "borderless, and
   why that is a trust decision as well as a taste one". A frame around it would make it an
   illustration of a photograph rather than the photograph.

   A FIXED HEIGHT AND `cover`, because the cards are a rail and a rail of different heights
   reads as a mistake. The crop is centred, which is where a plate photographed from above
   actually is. */
.detail-photo {
  display: block;
  width: calc(100% + 20px);
  margin: -9px -10px 8px;
  height: 96px;
  object-fit: cover;
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
  background: var(--canvas-deep);
}

.detail-card[data-dish="true"] .eatery-price[data-stale="true"] .caveat { text-align: left; }

.list-detail[data-dish="true"] .eatery-price { margin-left: 6px; }
.list-detail[data-dish="true"] .eatery-price[data-stale="true"] .caveat {
  display: inline;
  margin-left: 6px;
  text-align: left;
}

/* design-04 §2 — an ending is a fact about a place, not the absence of one. Clay rather
   than gold, and stated rather than struck through: a closed eatery is still the answer
   to "what was here", which is the same call design-02 §4.4 makes about demolition. */
.eatery-status {
  display: inline-block;
  margin: 2px 0 4px;
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: .09em;
  text-transform: uppercase;
  color: var(--clay);
}

/* ---------- the tray (cycle 14, design-04 §5) ---------- */

/* The opener. Same 34px disc, same ring, same row as the map and the record — §5.1: "the
   chrome now has three surfaces that behave identically instead of two that do and one
   that does not". What differs is that this one carries a number, because "a peek band
   says THERE IS FOOD HERE. A counted icon says THERE ARE FOUR." */
/* WHERE `.tray-open` AND `.tray-count` WENT — the counted chrome icon, cycle 14's third
   surface beside the map and the record. §5.1 argued for it: "the chrome now has three
   surfaces that behave identically instead of two that do and one that does not."

   The human's review on 2026-08-24 said that shape was the problem rather than the
   consistency — "the entry point is not well designed to be blended into the scene, it felt
   old school" — so the count moved to the bottom-right corner (`.foot-open` above). The
   chrome now has two surfaces that behave identically and one that does not, which is where
   §5.1 came in; design-04 §5.5 records the trade with its date. */

/* THE SURFACE. §5.2: "the divider is where the drawn world stops and the photographed one
   begins, and the user holds it." --split is the fraction of the frame the tray occupies,
   set by tray.js from the drag and snapped to a detent on release.

   Not a modal, and the absence of a backdrop is the design rather than an omission: the
   scene above stays visible AND usable at half, which is the only state where this feature
   does something an existing food app does not already do better. */
.tray {
  position: absolute;
  z-index: 45;
  background: var(--canvas);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  box-shadow: 0 -10px 30px rgba(31,44,38,.22);
}

/* IT SLIDES UP OUT OF THE CORNER IT WAS OPENED FROM, at the human's instruction. It used to
   simply be there, which reads as a screen change rather than as a sheet being pulled over
   a street — and design-04 §5.2's whole claim is that the scene is still underneath.

   ONLY ON OPEN. Dragging is a direct manipulation and must track the finger with no
   transition on it at all; an eased `height` would make the divider feel like it was
   catching up with you. So the animation is on `transform`, which the drag never touches.

   `prefers-reduced-motion` turns it off, the same way every other movement in this app
   does — design-01 §11, and the record and the map already honour it. */
@keyframes tray-rise {
  from { transform: translateY(14%); opacity: 0; }
  to   { transform: translateY(0); opacity: 1; }
}
.tray[data-axis="y"]:not([hidden]) { animation: tray-rise .22s cubic-bezier(.22,.61,.36,1); }

@keyframes tray-slide-in {
  from { transform: translateX(14%); opacity: 0; }
  to   { transform: translateX(0); opacity: 1; }
}
.tray[data-axis="x"]:not([hidden]) { animation: tray-slide-in .22s cubic-bezier(.22,.61,.36,1); }

@media (prefers-reduced-motion: reduce) {
  .tray:not([hidden]) { animation: none; }
}

/* The way out, top-right — §5.2's fourth dismissal. Over the header rather than in it, so a
   photograph that bleeds to the top edge does not have to make room for a button. */
.tray-close {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 2;
  width: var(--tap-min);
  height: var(--tap-min);
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: color-mix(in srgb, var(--canvas) 82%, transparent);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  box-shadow: 0 2px 8px rgba(31,44,38,.2);
  color: var(--ink);
  cursor: pointer;
}
.tray-close-glyph { font: 400 22px/1 var(--font-body); }
.tray[data-axis="x"] .tray-close { top: 8px; right: 8px; }

/* An eatery that is also a place you can stand in. The name carries the link because the
   name IS the thing you would tap — design-01 §1's argument that the content is the
   interface, at the smallest scale it appears. */
.eatery-name.is-scene {
  appearance: none;
  border: 0;
  background: none;
  padding: 0;
  display: flex;
  align-items: baseline;
  gap: 8px;
  flex-wrap: wrap;
  text-align: left;
  cursor: pointer;
  color: var(--ink);
}
.eatery-name.is-scene .eatery-name-text {
  font: 600 19px/1.2 var(--font-display);
  text-decoration: underline;
  text-decoration-thickness: 1.5px;
  text-underline-offset: 4px;
  text-decoration-color: var(--gold);
}
.eatery-name-go {
  font: 500 10px/1 var(--font-mono);
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--ink-soft);
  white-space: nowrap;
}
.eatery-name-go::after { content: " ›"; }

.tray[hidden] { display: none; }

/* Portrait — a sheet along the bottom edge. */
.tray[data-axis="y"] {
  left: 0;
  right: 0;
  bottom: 0;
  /* Capped so the chrome survives at full — design-01 §4.3, and see tray.js's paint(). */
  height: min(calc(var(--split, .5) * 100%), calc(100% - var(--chrome-h, 0px)));
  border-radius: 18px 18px 0 0;
  /* Room at the bottom for the corners, which sit over this. Padding rather than a shorter
     sheet so the page still bleeds to the edge of the screen — §5.3's photographs reach the
     sides, and a tray that stopped short of the bottom would put a band of ground under
     them. The last row scrolls clear of the corners instead. */
  padding-bottom: calc(var(--foot-bar-h, 58px) + env(safe-area-inset-bottom));
}

/* Landscape and wide — a rail on the trailing edge, dragged horizontally. §5.2: "it is the
   same tray and the edge follows the aspect ratio rather than being a separate design."
   The axis is set by tray.js from the FRAME's own box, not from a viewport media query,
   because .frame is capped at 420px and the two would disagree. */
.tray[data-axis="x"] {
  /* Below the chrome rather than over it. The rail is on the trailing edge, which is
     exactly where the map, the record and the tray's own button sit — covering them left
     one of §5.2's three dismissals unreachable on a phone that has no Escape key. */
  top: var(--chrome-h, 0px);
  bottom: 0;
  right: 0;
  width: calc(var(--split, .5) * 100%);
  border-radius: 18px 0 0 18px;
  box-shadow: -10px 0 30px rgba(31,44,38,.22);
}

/* The thing the person is actually holding. Big enough to grab with a thumb: the visible
   bar is 4px and the touch target is the whole strip around it. */
.tray-grip {
  flex: none;
  display: grid;
  place-items: center;
  cursor: grab;
  touch-action: none;
  padding: 9px 0;
}
.tray-grip::after {
  content: "";
  display: block;
  width: 42px;
  height: 4px;
  border-radius: 2px;
  background: color-mix(in srgb, var(--ink) 26%, transparent);
}
.tray[data-axis="x"] .tray-grip { padding: 0; width: 22px; position: absolute; inset: 0 auto 0 0; }
.tray[data-axis="x"] .tray-grip::after { width: 4px; height: 42px; }
.tray[data-axis="x"] .tray-body { margin-left: 22px; }
.tray-grip:focus-visible { outline: 2px solid var(--gold); outline-offset: -2px; }

/* WHERE THE TWO TABS WENT — design-04 §6.1, and this is the most consequential thing cycle
   15's review changed rather than a stylesheet tidy.

   `.tray-tabs` and `.tray-tab` styled Visitor Must Try beside Residents' Pick, which §6.1
   calls "the most honest advertisement this product can run for itself": an empty residents'
   list sitting next to a populated visitors' one, with an invitation in it. The tray is one
   eatery's page now, so there is no list for the pairing to be a pairing of.

   THE ASYMMETRY MOVED RATHER THAN GOING: `.eatery-ratings` below puts both lanes on every
   eatery, counted separately and never blended, so a reader still sees what visitors said
   and, beside it, that no resident has said anything. It is the same argument made
   per-place. Whether the weaker version still does the work needs a person — §12 already
   files it as a question this prototype cannot answer. */

/* ── the eatery page — cycle 15 at review ──────────────────────────────────────────── */

/* §5.3, at the top of the page: borderless, bleeding to both edges. "No cards, no rounded
   corners, no captions burned onto the image." The default image is the most recent
   photograph somebody took, and where there is none there is NO IMAGE — spine §9, and
   design-01 §6.3's refusal to fabricate something to fill a space. */
/* WHERE `.eatery-hero` WENT. It showed the most recent photograph at the top of the page,
   and that photograph was also the first row's — so the page opened with the same picture
   twice. The cover-plus-feed pattern earns that when the cover is a photograph OF THE PLACE
   and the feed is people's plates; here both were the same kind of thing from the same
   person. Removed at the human's instruction on 2026-08-24, with nothing put in its place:
   spine §9, never fabricate something to fill the space where content is not. */

/* Top padding clears the close button, which floats over the grip at the top-right. The
   `+` then sits on the title's own row without the two of them fighting for that corner. */
.eatery-head-block { padding: 46px 14px 14px; }
/* Bigger, at the human's instruction — and it can afford to be, now that it is the first
   thing on the page rather than a caption under a photograph. */
.eatery-head-block .eatery-name { font-size: 24px; margin: 0; }
.eatery-name.is-scene .eatery-name-text { font-size: 24px; }
/* NO WRAP AT THIS LEVEL. The title block wraps INSIDE itself; the `+` stays on the first
   row beside it. With wrap on, a long name took the whole width and pushed the `+` onto a
   line of its own, floating right with nothing to belong to. */
.eatery-head-block .eatery-head {
  flex-wrap: nowrap;
  align-items: flex-start;
  gap: 10px;
}
.eatery-head-block .eatery-head > .eatery-name { flex: 1 1 auto; min-width: 0; }

/* The upload, as a `+` beside the title. The words are in its accessible name, because §11
   leans its consent design on what the upload PROMPTS and a glyph prompts nothing. */
.food-add-open {
  flex: none;
  width: 34px;
  height: 34px;
  min-height: 34px;
  margin: 0;
  padding: 0;
  display: grid;
  place-items: center;
  border: 1px solid var(--ink);
  border-radius: 50%;
  background: var(--canvas);
  color: var(--ink);
  text-decoration: none;
  cursor: pointer;
}
.food-add-glyph { font: 400 21px/1 var(--font-body); }
.eatery-head-block .food-add { flex: none; margin-left: auto; }

/* Both lanes, side by side, never summed (§6.2). */
.eatery-ratings {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 18px;
  margin-top: 10px;
}

/* ── one person's contribution ─────────────────────────────────────────────────────── */

.vouch-rows { border-top: 1px solid var(--canvas-deep); }
.vouch-rows-empty { padding: 20px 16px; }

.vouch-row {
  padding: 0 0 14px;
  border-bottom: 1px solid var(--canvas-deep);
}
.vouch-row:last-child { border-bottom: 0; }

/* One person's photographs, as one swipeable thing. A real horizontal scroll with snap
   points rather than a transform we drive ourselves: it works with a thumb, a trackpad, a
   keyboard and a screen reader's own navigation, and intercepts no gesture the browser
   already handles. */
.vouch-gallery { position: relative; }
.vouch-strip {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  /* THE SWIPE STOPS HERE. Without this, reaching the end of the photographs hands the rest
     of the gesture to whatever is scrollable outside — which on iOS is how a swipe through
     three plates became the page sliding off its own left edge. */
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
}
.vouch-strip::-webkit-scrollbar { display: none; }
.vouch-slide {
  position: relative;
  flex: 0 0 100%;
  scroll-snap-align: start;
}
.vouch-slide img {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}

/* Top-right, over the photograph, saying how many there are before you swipe. */
.vouch-counter {
  position: absolute;
  top: 8px;
  right: 8px;
  padding: 3px 8px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--ink) 62%, transparent);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
  font: 500 11px/1.3 var(--font-mono);
  color: var(--canvas);
  pointer-events: none;
}

/* UNDER THE PHOTOGRAPHS, OUTSIDE THE FRAME — the human's instruction, and it removes the
   problem that the backing pill existed to solve. Over the image the dots were cream on
   whatever the photograph happened to be, so they needed a translucent slab behind them to
   be dots at all; on the page's own ground they need nothing, and the slab goes with them.

   §5.3 gains rather than loses by this. "Borderless, bleeding to the edges, no captions
   burned onto the image" — chrome sitting ON a photograph is the thing that section is
   uneasy about, and the count is the only mark left up there. */
.vouch-dots {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 2px;
  /* THE ROW IS THE SIZE OF THE DOTS; THE TAP TARGET IS NOT. Each dot's button is the full
     44px design-01 §9 asks for, pulled back with a negative margin so it overflows this
     row rather than inflating it — the first build let the buttons set the height and the
     dots sat in the middle of a slab the size of a thumb. */
  height: 14px;
  margin: 8px 0 0;
}
.vouch-dot {
  width: 24px;
  height: var(--tap-min);
  margin: calc((14px - var(--tap-min)) / 2) 0;
  padding: 0;
  border: 0;
  background: none;
  display: grid;
  place-items: center;
  cursor: pointer;
}
/* The dot is small and the TARGET is not — design-01 §9's minimum tap size, met by the
   button around the mark rather than by growing the mark. */
.vouch-dot::after {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--ink) 26%, transparent);
}
.vouch-dot[data-current="true"]::after { background: var(--ink); }
/* design-02 §7.5's withdraw, on your own photograph. Quiet, and over the image rather than
   under it, so it does not push the next row down for everybody else's photographs. */
.vouch-drop {
  position: absolute;
  right: 8px;
  bottom: 8px;
  padding: 6px 10px;
  min-height: 32px;
  border: 0;
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--canvas) 90%, transparent);
  font: 500 10px/1 var(--font-mono);
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink);
  cursor: pointer;
}

.vouch-said { padding: 10px 14px 0; }

/* AN INTEGER GETS A COUNT WHERE A MEAN GETS A SHAPE. Five marks, n filled — one person's
   own answer at exactly its own resolution. §7.1's single fractional star is for the
   AGGREGATE, which is the soft thing that must not be rendered hard; this is the hard thing
   and must not be rendered soft. */
.vouch-stars { display: flex; gap: 2px; }
.vouch-stars .star-glyph { width: 13px; height: 13px; }

.vouch-words {
  margin: 6px 0 0;
  font: 400 14px/1.45 var(--font-body);
  color: var(--ink);
}
.vouch-by {
  margin: 4px 0 0;
  font: 400 11px/1.3 var(--font-mono);
  color: color-mix(in srgb, var(--ink-soft) 84%, transparent);
}

.tray-body {
  flex: 1 1 auto;
  overflow-y: auto;
  /* EXPLICITLY HIDDEN, AND NOT REDUNDANT. `overflow-y: auto` alone makes the other axis
     compute to `auto` as well — a non-`visible` value on one axis forces the other off
     `visible` — so this box was horizontally scrollable and nobody had asked for it to be.

     Reported from an iPhone: swiping a row's photographs panned THE WHOLE PAGE sideways,
     title and all, off the left edge. It worked on a laptop because a trackpad swipe ends
     at the end of the strip, and on iOS it does not: the gesture chains to the nearest
     scrollable ancestor, which was this. Two fixes, because the two causes are separable —
     this one says the page has no sideways to go, and `.vouch-strip` below says the swipe
     does not travel outward looking for one. */
  overflow-x: hidden;
  -webkit-overflow-scrolling: touch;
}

/* A row is not a card. §5.3's trust argument: "a framed photograph reads as a card, and a
   card reads as a listing — which is precisely the register §1 is trying to escape." So
   rows are separated by a hairline and nothing else: no border, no radius, no shadow. */
.eatery-row {
  padding: 12px 14px 14px;
  border-bottom: 1px solid var(--canvas-deep);
}
.eatery-row:last-child { border-bottom: 0; }

.eatery-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.eatery-name { font: 600 15px/1.25 var(--font-display); color: var(--ink); }
.eatery-kind {
  font: 500 10px/1 var(--font-mono);
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--clay);
}

/* NO .eatery-status RULE HERE, deliberately. Cycle 13 already defines one for the panel,
   about 150 lines up, and a second definition in the same stylesheet is two sources of
   truth for one mark — the tray was rendering CLOSED in a different size from the panel's
   because my rule overrode the font and inherited the uppercase. A closure looks the same
   wherever it is shown, which is design-04 §2's point about an ending being a fact. */

/* Cycle 16 — "added by X, nobody has checked this yet". Same weight and colour as
   `.eatery-where` below, because they are the same kind of line: a fact about the row that
   is not the row itself. The dotted underline goes on the WHOLE sentence rather than on one
   word, which is the one place this departs from the bearing's treatment — the doubt is
   about the entire claim, not about one term inside it. */
.eatery-unconfirmed {
  margin-top: 4px;
  font: 400 12px/1.4 var(--font-mono);
  color: var(--ink-soft);
}
.eatery-unconfirmed-text {
  border-bottom: 1px dotted currentColor;
  padding-bottom: 1px;
}

/* WAITING TO BE CHECKED — review round 1, note 4: "highlight it's pending approval".
   A band rather than a line, and that is the whole difference between this and the caveat
   above it. The caveat is one fact among several about the place and reads as one; THIS is
   the state of the thing, and the person who has just added it is looking for it.

   The first build put the words and the button on one line with no treatment at all, and
   the screenshot showed them colliding with each other and with the bearing below — it read
   as broken rather than as pending.

   CLAY, because that is this project's accent for a caveat already (`.coord-basis` under
   `map_pin` uses it). NOT red: nothing is wrong here. A contribution waiting to be checked
   is the system working exactly as design-04 §9.3 says it should.

   AND THERE IS NO OPPOSITE BAND. spine §7.1 and rubric trust-integrity #6: a checked eatery
   gains nothing when this goes away. */
.eatery-pending {
  margin-top: 8px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 6px 10px;
  padding: 8px 10px;
  border-radius: var(--radius-sm);
  border-left: 2px solid var(--clay);
  background: color-mix(in srgb, var(--clay) 11%, transparent);
}
.eatery-pending-text {
  font: 700 10px/1.3 var(--font-mono);
  letter-spacing: .09em;
  text-transform: uppercase;
  color: var(--clay);
}
/* Quieter than the band it sits in: withdrawing is available, and it is not the thing this
   page is inviting anybody to do. */
.eatery-withdraw {
  min-height: 32px;
  padding: 6px 10px;
  border: 1px solid color-mix(in srgb, var(--ink) 25%, transparent);
  border-radius: var(--radius-sm);
  background: none;
  color: var(--ink-soft);
  font: 500 11px/1 var(--font-mono);
  cursor: pointer;
}

.eatery-where {
  margin-top: 4px;
  font: 400 12px/1.4 var(--font-mono);
  color: var(--ink-soft);
}
.eatery-where[data-relation="around"] .eatery-where-text::first-letter { text-transform: uppercase; }
/* The dotted underline the exits already use for an unchecked bearing, keyed off the same
   attribute name so the two cannot drift apart. */
.eatery-where[data-bearing-verified="false"] .exit-bearing {
  border-bottom: 1px dotted currentColor;
  padding-bottom: 1px;
}

/* The absence, said once, after the name. Cycle 13's panel learned the ordering the hard
   way: "you cannot say 'no photos of the food here' before saying what HERE is."
   Not a button and not a placeholder image — uploading is cycle 15's, and an app offering
   a flow it does not have is a worse fault than one admitting the flow is absent. */
.eatery-shots-empty {
  margin-top: 8px;
  font: 400 12px/1.4 var(--font-mono);
  color: color-mix(in srgb, var(--ink-soft) 72%, transparent);
}

/* WHERE `.eatery-shots` WENT. It styled a horizontal strip of a row's photographs, bleeding
   to the tray's edges — §5.3's "no cards, no rounded corners, no captions burned onto the
   image", which cycle 15 reached for the first time after two cycles of it being dead code.

   The human's third pass replaced the strip with a per-person carousel (`.vouch-gallery`
   above), so a row shows one photograph at a time with a count on it rather than all of
   them side by side. §5.3's rule is unchanged and is kept there: the slides still bleed to
   both edges, still have no frame, and still carry no text burned onto the picture — the
   counter and the dots are chrome over it, which is what §5.3 asks for ("attribution in
   chrome"). What went is one layout, not the constraint. */

/* §7.3's mark. One path, Ink stroke, and the fill is a hard-stop gradient set in the SVG
   rather than here — the fraction is data and belongs with the renderer that has it. What
   is here is the geometry and the two colours, and both are decisions §7.3 makes:

   OUTLINE IS INK, NOT BLACK, and FILL IS GOLD, NOT A NEW ORANGE. product-design.md §3.2
   puts foodie in the "gold, pulsing" tier along with social, heritage and civic, so gold is
   the tier's own colour and using it keeps the star inside the palette. Clay is listed as
   the foodie TAG colour — that is the chip, not the marker, and mixing the two would make
   the star read as a different tier from the slot that opened it.

   The unfilled portion is left transparent rather than white so the ground shows through,
   because a white star is correct on Canvas and wrong the moment it sits on a photograph —
   which cycle 15 made possible for the first time. */
.eatery-star { margin-top: 6px; display: flex; align-items: center; gap: 6px; }
.star-glyph { width: 15px; height: 15px; display: block; }
.star-glyph path { stroke: var(--ink); stroke-width: 1.4; stroke-linejoin: round; }
.eatery-star-count {
  font: 400 12px/1 var(--font-mono);
  color: var(--ink-soft);
}

/* The star as a control — design-04 §10's door. A button that holds a display, so the row
   stays a row: the form it opens is hidden until somebody asks for it. The first build had
   the form open on every row and the tray became a stack of forms with places written above
   them; one row filled the whole sheet at the half detent, which is the state §5.2 says the
   feature exists for. */
.eatery-rate {
  appearance: none;
  border: 0;
  background: none;
  padding: 0;
  margin: 0;
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  /* A 44px row, because the star inside it is 15px and the thing being tapped is the row. */
  min-height: 40px;
  text-align: left;
  cursor: pointer;
}
.eatery-rate .eatery-star { margin-top: 0; }
.eatery-rate-cue {
  font: 500 10px/1 var(--font-mono);
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-soft);
  text-decoration: underline;
  text-underline-offset: 3px;
}
/* Open, the cue is the way back — same control, same place, no second button. */
.eatery-rate[aria-expanded="true"] .eatery-rate-cue { text-decoration: none; opacity: .55; }

/* §4.2's aside — one line, attributed, and quiet. Not a quote block, not a card, no avatar:
   "a neighbour's aside, not a Yelp entry", and every one of those would be the surface
   growing towards a review platform. */
.eatery-said {
  margin-top: 6px;
  font: 400 12.5px/1.45 var(--font-body);
  color: var(--ink-soft);
}
/* NO text-transform HERE, AND IT IS NOT A STYLE PREFERENCE. This is a person's NAME. The
   chips and labels around it are uppercase mono because they are labels; a name is not, and
   uppercasing one is the app deciding how somebody writes their own name. identity/module.js
   already refuses the smaller version of this — it refreshes a display name on every
   sign-in, because "an attribution showing the old one would be the app disagreeing with
   them about who they are." Found by a test reading `— BAN WEI` off the row. */
.eatery-said-by {
  margin-left: 6px;
  font: 500 11px/1 var(--font-body);
  color: color-mix(in srgb, var(--ink-soft) 82%, transparent);
  white-space: nowrap;
}

/* ── the rating control — design-04 §4, §6.2, §10 ──────────────────────────────────── */

.rate-form { margin-top: 10px; }

/* §6.2's prompt, and it is the lane's whole presence on this screen. Set in the display
   face rather than the mono utility face because it is a QUESTION being asked of a person,
   not a label on a field. */
.rate-prompt {
  margin: 0 0 6px;
  font: 500 13px/1.35 var(--font-display);
  color: var(--ink);
}

.rate-stars { display: flex; gap: 2px; }
.rate-star {
  appearance: none;
  border: 0;
  background: none;
  padding: 4px 3px;
  /* 34px of tap target on a phone. A five-across control at the glyph's own 15px would be
     four misses out of five, and design-01 §4.1's minimum tap target is not smaller here
     because the thing being tapped is small. */
  min-width: 34px;
  min-height: 34px;
  display: grid;
  place-items: center;
  cursor: pointer;
}
.rate-star .star-glyph { width: 22px; height: 22px; }

.rate-words {
  display: block;
  width: 100%;
  margin-top: 8px;
  padding: 7px 9px;
  border: 1px solid var(--canvas-deep);
  border-radius: var(--radius-sm);
  background: var(--canvas);
  font: 400 13px/1.4 var(--font-body);
  color: var(--ink);
}
.rate-words::placeholder { color: color-mix(in srgb, var(--ink-soft) 55%, transparent); }

/* THE CHIPS: short action words, in the mono utility face this design uses for labels. */
.rate-send,
.rate-remove,
.food-consent-cancel {
  appearance: none;
  margin-top: 8px;
  padding: 8px 12px;
  min-height: 34px;
  border: 1px solid var(--ink);
  border-radius: var(--radius-sm);
  background: var(--canvas);
  font: 500 11px/1 var(--font-mono);
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink);
  cursor: pointer;
}
/* `.food-add-open` IS STYLED ONCE, UP WITH THE TITLE IT NOW SITS BESIDE. A second rule was
   here — the quiet underlined sentence it used to be — and being LATER in the file it won,
   so the `+` rendered as a text link with a border round it. Two rules for one control is
   how a control ends up looking like neither of the two things it was designed as. */

/* THE ANSWERS, which are a choice and therefore are buttons. */
.food-consent-answer {
  appearance: none;
  margin-top: 8px;
  padding: 8px 12px;
  min-height: 34px;
  border: 1px solid var(--ink);
  border-radius: var(--radius-sm);
  background: var(--canvas);
  font: 500 13px/1.2 var(--font-display);
  color: var(--ink);
  text-align: left;
  cursor: pointer;
}
.food-consent-answer { width: 100%; }
.food-add { position: relative; margin-top: 0; }

.rate-send { background: var(--ink); color: var(--canvas); }
.rate-send[disabled] { opacity: .4; cursor: default; }
/* Removing your own rating is a quiet act, not a destructive one — §4 says a vouch can
   never be wrong, so taking it back is not an undo of a mistake. */
.rate-remove,
.food-consent-cancel {
  border-color: transparent;
  text-decoration: underline;
  text-underline-offset: 3px;
  color: var(--ink-soft);
  padding-left: 0;
}
.rate-said { margin: 6px 0 0; font: 400 12px/1.4 var(--font-mono); color: var(--clay); }

/* §10's conversion moment. It names the act and the place; nothing here is a lock over
   content that does not exist. */
.rate-signin {
  margin-top: 4px;
  padding: 10px;
  border: 1px dashed var(--canvas-deep);
  border-radius: var(--radius-sm);
}
.rate-signin-line { margin: 0 0 8px; font: 400 13px/1.4 var(--font-body); color: var(--ink); }

/* ── adding a place you know — cycle 16, design-04 §9.2 ───────────────────────────── */

/* The tray's second contents. Same sheet, same detents, same close button — this is not a
   third surface, and it should not look like one. */
.add-eatery { padding: 4px 2px 24px; }
.add-eatery-title {
  margin: 0 0 4px;
  font: 600 19px/1.2 var(--font-display);
  color: var(--ink);
}
.add-eatery-lede,
.add-eatery-hint {
  margin: 0 0 12px;
  font: 400 13px/1.45 var(--font-body);
  color: var(--ink-soft);
}
.add-eatery-hint { margin: -6px 0 12px; }

.add-eatery-signin { margin-top: 4px; padding: 10px; border: 1px dashed var(--canvas-deep); border-radius: var(--radius-sm); }
.add-eatery-signin-line { margin: 0 0 8px; font: 400 13px/1.4 var(--font-body); color: var(--ink); }

.add-eatery-form .field {
  display: block;
  margin-bottom: 12px;
  font: 500 12px/1.4 var(--font-mono);
  color: var(--ink-soft);
}
.add-eatery-form .field > span { display: block; margin-bottom: 4px; }
.add-eatery-form input,
.add-eatery-form select {
  width: 100%;
  padding: 9px 10px;
  border: 1px solid var(--canvas-deep);
  border-radius: var(--radius-sm);
  background: var(--canvas);
  color: var(--ink);
  /* The 16px inline style the shared `input()` sets is the iOS zoom floor and is left
     alone here on purpose — cycle 6 fixed that in the app and cycle 12 found the same trap
     on the memory date. A stylesheet overriding it back down would be the third time. */
}

/* THE COMPUTED DIRECTION, SHOWN BEFORE SAVING — design-04 §2's table. Set apart from the
   fields because it is the form ANSWERING rather than asking, and it carries the same
   unchecked caveat every bearing in this repo carries. */
.add-eatery-bearing {
  margin: 0 0 12px;
  font: 400 12px/1.4 var(--font-mono);
  color: var(--ink-soft);
}
.add-eatery-bearing[data-bearing]:not([data-bearing=""]) { color: var(--ink); }

.add-eatery-problem {
  margin: 0 0 10px;
  font: 400 12.5px/1.4 var(--font-body);
  color: var(--clay);
}

.add-eatery-save,
.add-eatery-withdraw {
  min-height: var(--tap-min);
  padding: 10px 16px;
  border: 1px solid var(--ink);
  border-radius: var(--radius-sm);
  background: var(--ink);
  color: var(--canvas);
  font: 600 13px/1 var(--font-display);
  cursor: pointer;
}
.add-eatery-save[disabled] { opacity: .45; cursor: default; }
/* Withdrawing is not the primary act on this panel, so it does not look like one. */
.add-eatery-withdraw { background: none; color: var(--ink); }
.add-eatery-saved { margin: 0 0 12px; font: 400 13px/1.45 var(--font-body); color: var(--ink); }

/* ── the upload — design-04 §11 ────────────────────────────────────────────────────── */

/* The file input is driven by the button beside it, and is off-screen rather than
   `display: none`: a hidden input is not focusable and cannot be reached by a keyboard, and
   Playwright's set_input_files needs it in the tree either way. */
.food-file {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}
.food-add { position: relative; }

/* A BLOCK ON THE PAGE, full width, under the title. It used to hang off the `+` — fine
   while that was a full-width sentence, and broken the moment it became a 34px circle in a
   `flex: none` slot: the sheet spilled across the title and off the right edge. */
.food-consent {
  margin: 12px 0 0;
  padding: 12px;
  border-radius: var(--radius-sm);
  background: var(--canvas-deep);
}
.food-add-said:empty { display: none; }
.food-consent-question {
  margin: 0 0 4px;
  font: 500 13px/1.35 var(--font-display);
  color: var(--ink);
}
.food-consent-choice + .food-consent-choice { margin-top: 4px; }
/* THE DISCLOSURE SITS UNDER THE ANSWER IT APPLIES TO so it is read BEFORE the tap rather
   than acknowledged after it. Not smaller than the answer, and not grey enough to skip:
   design-03 §7.5's line "costs the product some contributions. It should." */
.food-consent-disclosure {
  margin: 2px 0 0;
  font: 400 12px/1.45 var(--font-body);
  color: var(--ink-soft);
}
.food-add-said { margin: 6px 0 0; font: 400 12px/1.4 var(--font-mono); color: var(--clay); }

/* The way out, and it looks like one — §3.3 rule 1: "positioned as a departure, never the
   primary action on the card." Last in the row, quiet, and a link rather than a button
   because it leaves. */
.eatery-maps {
  display: inline-block;
  margin-top: 8px;
  font: 500 11px/1 var(--font-mono);
  letter-spacing: .05em;
  text-transform: uppercase;
  color: var(--ink-soft);
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* The honest empty, MOVED FROM `.tray-empty` TO THE EATERY PAGE at cycle 15's review. It
   styled the empty Residents' Pick tab beside a populated Visitor Must Try one — §6.1's
   "most honest advertisement this product can run for itself" — and there is no list for
   that pairing to be a pairing of now. Nothing here is a locked slot, a blurred row or a
   count of things you cannot see (design-01 §6.3, spine §9), and the invitation survived
   the tab: see `tray_no_contributions_invite`. */
.vouch-rows-empty-line {
  margin: 0;
  font: 400 14px/1.45 var(--font-display);
  color: var(--ink);
}
.vouch-rows-empty-invite {
  margin: 6px 0 0;
  font: 400 12px/1.5 var(--font-mono);
  color: var(--ink-soft);
}

/* ── mkv's bubble — cycle 17, design-05 §5 ─────────────────────────────────────────────

   IT SITS OVER THE ILLUSTRATION AND IS NEVER PART OF IT. Spine §1.1 is the rule and the
   markup is where it is enforced (index.html: a sibling of .scene, never a node in the
   SVG); this file only has to keep it looking like an overlay rather than like a painted
   sign. Hence the ink card with a shadow — the same treatment .place-hint uses, because
   they are the same kind of thing: the app talking, over a drawing.

   TOP-LEFT, UNDER THE CHROME. Everything else the app says is at the bottom — the two
   corners, the hint, the add button, the tray — and putting a fifth thing down there would
   be a crowd. The upper band of every scene is sky and roofline, which is the emptiest part
   of all three and the part a slot never occupies (design-01 §5).

   NO BADGE, NO DOT, NO COUNT (design-05 §4.1). There is nothing in this block that could
   grow into one, and that is the point of saying so here. */
/* THE HOST IS THE WHOLE FRAME SINCE REVIEW ROUND 2, because the bubble now goes wherever
   the thing it is talking about is — under the record button, over the eateries corner, or
   beside a slot in the middle of the street. app.js measures the anchor; this is the layer
   it measures against. `--chrome-h` is the fallback position for a line with no anchor. */
.companion-host {
  position: absolute;
  inset: 0;
  z-index: 38;
  pointer-events: none;
}

.companion {
  pointer-events: auto;
  position: absolute;
  top: calc(var(--chrome-h, 62px) + 8px);
  left: 12px;
  max-width: min(300px, calc(100% - 24px));
  padding: 9px 34px 10px 12px;
  border-radius: var(--radius);
  background: var(--companion-bg);
  color: var(--canvas);
  box-shadow: 0 6px 18px rgba(31, 44, 38, .28);
  animation: companion-in .3s ease both;
}

/* THE TAIL, and it is the whole of review round 2's first half. The human drew two bubbles
   on a screenshot with their tails on the record button and the eateries corner — "so user
   can tell easily the items added". It slides along the bubble's width (`--tail-x`, set by
   companion.js) so an anchor near the screen edge is still pointed at rather than
   approximated.

   A BORDER TRIANGLE RATHER THAN A ROTATED SQUARE. The rotated square is the usual trick and
   it needs the bubble's shadow clipped off it; this needs nothing, and the bubble's own
   shadow reads correctly without the tail casting a second one. */
.companion::after {
  content: "";
  position: absolute;
  left: var(--tail-x, 24px);
  width: 0;
  height: 0;
  border: 9px solid transparent;
}

.companion[data-tail="up"]::after {
  top: -18px;
  border-bottom-color: var(--companion-bg);
}

.companion[data-tail="down"]::after {
  bottom: -18px;
  border-top-color: var(--companion-bg);
}

@media (prefers-reduced-motion: reduce) {
  .companion { animation: none; }
}

@keyframes companion-in {
  from { opacity: 0; transform: translateY(-5px); }
  to   { opacity: 1; transform: none; }
}

/* The name, small and quiet. design-05 §3.1 makes it configuration, so this styles a slot
   rather than a word — and §4.2's register is "a professional ambassador", which is not a
   thing that introduces itself loudly. */
.companion-name {
  display: block;
  font: 500 8.5px/1 var(--font-mono);
  letter-spacing: .12em;
  text-transform: lowercase;
  color: var(--gold);
  margin-bottom: 5px;
}

/* THE LINE IS A BUTTON, and it looks like a sentence. It opens what it named — the record,
   the tray, or the card the note lives in — because design-05 §4.2 says a question with no
   route to an answer is clutter, and "you have not opened it" is exactly that sentence. */
.companion-said {
  display: block;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  text-align: left;
  font: 400 13px/1.45 var(--font-display);
  color: var(--canvas);
  cursor: pointer;
}

.companion .demo-tag {
  margin: 7px 0 0;
  background: var(--canvas-deep);
  color: var(--ink);
}

/* Two controls, both small, both in the corner the text is padded away from. The dismiss
   ends it for this visit (§5, constraint 2); the info opens the disclosure below. */
.companion-about,
.companion-dismiss {
  position: absolute;
  right: 6px;
  width: 22px;
  height: 22px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: color-mix(in srgb, var(--canvas) 16%, transparent);
  color: var(--canvas);
  font: 500 11px/1 var(--font-mono);
  cursor: pointer;
}

.companion-dismiss { top: 6px; font-size: 14px; }
.companion-about { top: 32px; font-style: italic; }
.companion[data-about="true"] .companion-about {
  background: var(--gold);
  color: var(--ink);
}

/* design-05 §11.5's disclosure. Closed by default and opened from the icon: it is about
   this sentence rather than about the product, so it belongs in the bubble rather than in
   a policy dialog. */
.companion-note {
  display: none;
  margin: 9px 0 0;
  padding-top: 8px;
  border-top: 1px solid color-mix(in srgb, var(--canvas) 22%, transparent);
  font: 400 10.5px/1.55 var(--font-mono);
  color: color-mix(in srgb, var(--canvas) 82%, var(--ink));
}

.companion[data-about="true"] .companion-note { display: block; }

/* IT IS A SCENE OVERLAY, so it goes when the scene does. The map, the record, the tray and
   the list each put something else in front of the street, and a line about the street
   floating over them would be the companion talking where the user is NOT looking —
   design-05 §4.1's bound, inverted. Same `:has` shape the record's blur uses above. */
.frame:has(#map:not([hidden])) .companion-host,
.frame:has(#timelapse:not([hidden])) .companion-host,
.frame:has(#tray:not([hidden])) .companion-host,
.frame:has(#list:not([hidden])) .companion-host { display: none; }

/* And the room's bubble goes with it — cycle 35, review round 1. It is the same rule and the same
   reason: a bubble anchored to the street has nothing to point at while the street is covered.
   `#room` is in the list because a bubble offering to open the room you are standing in is the
   list door's mistake arriving on a second surface. */
.frame:has(#map:not([hidden])) .room-bubble-host,
.frame:has(#timelapse:not([hidden])) .room-bubble-host,
.frame:has(#tray:not([hidden])) .room-bubble-host,
.frame:has(#room:not([hidden])) .room-bubble-host,
.frame:has(#list:not([hidden])) .room-bubble-host { display: none; }

/* And it fades with the rest of the chrome when a card is open, exactly as .foot-bar and
   .store-warning do — one behaviour for everything that sits on top of the street.

   THE SECOND SELECTOR IS A BUG FIX AND THIS FILE ALREADY WARNED ABOUT IT. .foot-bar's
   comment above says the rule: a parent set to `pointer-events: none` is undone by any
   child that sets `auto`, and `.companion` must set `auto` because the line is a button.
   So the faded-out bubble went on swallowing taps at the top-left of the artwork while
   being invisible — found by test_camera.py's "tap the picture itself" assertion, which
   has clicked that exact corner since cycle 3 and started timing out.

   The tap dies at once and the fade still runs, which is why this is two properties on two
   selectors rather than `visibility: hidden` on one: visibility cannot be animated, so
   hiding that way would take the fade with it. */
.scene:has(> .scene-frame[data-zoom]:not([data-zoom="1.000"])) ~ .companion-host,
.scene:has(> .scene-frame[data-zoom]:not([data-zoom="1.000"])) ~ .companion-host .companion {
  pointer-events: none;
}

.scene:has(> .scene-frame[data-zoom]:not([data-zoom="1.000"])) ~ .companion-host {
  opacity: 0;
  transition: opacity .3s ease;
}

/* "new to you" — cycle 17, review round 2 ────────────────────────────────────────────

   The human: "when user tap and open, show a 'new' or some indicator." Two surfaces, one
   word, because spine §7.3 is one vocabulary everywhere.

   QUIETER THAN THE CAVEAT BESIDE IT, deliberately. In the record this sits under a "not
   confirmed" line, and the two are different kinds of claim: the caveat is about the
   photograph and this is about the person looking at it. If they ever compete for the eye,
   the one about the world has to win. So the caveat keeps the clay and the dashes, and this
   is a small teal mark that reads as a wayfinding aid rather than as a state. */
.tl-new,
.foot-new {
  font: 500 8px/1 var(--font-mono);
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--tile-teal);
}

.tl-new {
  display: block;
  margin-top: 5px;
}

.foot-new {
  display: block;
  margin-top: 3px;
}

/* And the row itself carries a hairline, so the mark is findable in a list that is scanned
   rather than read. Not a fill and not a glow: design-01 §6.2's pulse budget is for content
   in the scene, and a corner row competing with a pulsing slot would spend it. */
.foot-item[data-new="true"] {
  box-shadow: inset 2px 0 0 var(--tile-teal);
}

/* THE SAME HAIRLINE AS THE CORNER ROW, on the leading edge, and it is deliberately not an
   outline on the photograph. `.tl-plate img` is `width: 100%` with `object-fit: contain`,
   so the element box is the full width of the mount and the picture is letterboxed inside
   it — an outline traced the BOX and rendered as two stray rules above and below a picture
   it was nowhere near. Marking the mount says the same thing and cannot come apart from
   what it is marking. */
.tl-shot[data-new="true"] {
  box-shadow: inset 2px 0 0 var(--tile-teal);
}

/* ── mkv, asked — cycle 19 ────────────────────────────────────────────────────────────────

   THE DOOR. A button in the empty middle of the foot bar, between "where you can go" and
   "what you can eat". What you can ASK is the third of those and sits on the same line. It
   is a flex child, so it cannot overlap either corner — a guarantee rather than a
   measurement, which is what the first version had and got wrong. */
.ask-host {
  flex: 0 1 auto;
  min-width: 0;
  display: flex;
  justify-content: center;
  align-items: flex-end;
  pointer-events: none;
}

.ask-open {
  pointer-events: auto;
  max-width: 100%;
  padding: 7px 16px;
  border: 0;
  border-radius: 999px;
  background: var(--companion-bg);
  color: var(--canvas);
  font: inherit;
  font-size: 14px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  box-shadow: 0 4px 14px rgba(31, 44, 38, .24);
  cursor: pointer;
}

/* THE DOOR GETS OUT OF THE WAY OF EVERY OTHER SURFACE, and this was learned the expensive
   way twice. A persistent affordance in the foot bar sits on top of whatever a temporary one
   puts underneath it — the record's form, the map, the tray's lists — and eats their taps.
   Three timelapse tests failed with "the ask-open button intercepts pointer events" the
   moment this rule was dropped.

   design-01 §4.3's rule pointed the other way: the chrome is never covered, and a thing that
   covers a temporary surface is covering the way out of it. mkv is what you ask about the
   STREET, so the door belongs to the scene and hides whenever something else is standing
   over it. */
body[data-timelapse="true"] .ask-host,
body[data-map-open="true"] .ask-host,
body[data-placing="true"] .ask-host,
body[data-foot="true"] .ask-host,
body[data-view="list"] .ask-host { display: none; }

/* THE VIEW. Same shape as the record's: absolute, inset 0, inside the frame.

   IT OWNS THE SCREEN, AND THAT IS THE FIX RATHER THAN THE STYLE. The field was pinned to the
   bottom of the street first; on a phone the keyboard covers the bottom, `dvh` does not
   shrink for it, and two attempts to compensate each made it worse than doing nothing. A
   view whose field is at the TOP has nothing to be pushed off it — what a keyboard covers
   here is old answers. No listener, no measurement, no number to get wrong. */
.ask-view {
  position: absolute;
  /* BELOW THE CHROME, not under it. The view is a dark scrim, and the chrome's subtitle is
     coloured for a pale sky — over the scrim it went dark-on-dark and unreadable, which the
     screenshot caught and no assertion would have. Starting the view under the chrome's own
     height keeps the way back exactly as legible as it is everywhere else (design-01 §4.3),
     and costs the view nothing it was using. `--chrome-h` is measured by app.js because the
     subtitle wraps to three lines on Koon Seng Rd. */
  inset: var(--chrome-h, 62px) 0 0 0;
  z-index: 34;
  display: flex;
  flex-direction: column;
  background: var(--companion-bg);
  color: var(--canvas);
  animation: companion-in .22s ease both;
}

.ask-view[hidden] { display: none; }

/* The street is inert behind it, the way it is behind the record. */
body[data-ask="true"] .foot-bar,
body[data-ask="true"] .add-memory,
body[data-ask="true"] .sign-in,
body[data-ask="true"] .companion-host { display: none; }

/* The view's own header. It starts where the view starts, which is already below the chrome
   — so the close button is never under the SCENE/LIST toggle, which is what happened when the
   view ran to the top of the frame: the button was there, and could not be clicked. */
.ask-head {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 14px 6px;
}

.ask-who {
  font-size: 13px;
  letter-spacing: .16em;
  color: var(--companion-name, rgba(255, 255, 255, .7));
}

.ask-back {
  width: 34px;
  height: 34px;
  border: 0;
  border-radius: 50%;
  background: rgba(255, 255, 255, .12);
  color: var(--canvas);
  font-size: 19px;
  line-height: 1;
  cursor: pointer;
}

/* THE FIELD, AT THE TOP. Everything about this cycle's phone trouble comes down to which end
   of the screen this sits at. */
.ask {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 4px 12px 12px;
  padding: 5px 5px 5px 14px;
  border-radius: 999px;
  background: rgba(255, 255, 255, .1);
}

.ask-field {
  flex: 1 1 auto;
  min-width: 0;
  border: 0;
  background: none;
  color: var(--canvas);
  font: inherit;
  font-size: 15px;
  outline: none;
}

.ask-field::placeholder { color: rgba(255, 255, 255, .5); }

.ask-send {
  flex: 0 0 auto;
  padding: 8px 15px;
  border: 0;
  border-radius: 999px;
  background: rgba(255, 255, 255, .18);
  color: var(--canvas);
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}

.ask-send:disabled { opacity: .6; cursor: default; }

/* THE HISTORY, newest directly under the field. It scrolls; the field does not move.

   CYCLE 21 MOVED THE SCROLLING ONE LEVEL OUT. The view now holds two things under the field
   — your own cards, and what is written down about this place — and they are one column that
   scrolls together. Two independently scrolling panes on a phone give each a third of the
   height and neither a reason to have it. */
.ask-scroll {
  flex: 1 1 auto;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding: 0 12px calc(16px + env(safe-area-inset-bottom));
}

.ask-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

/* AN ANSWER AND A REFUSAL ARE THE SAME OBJECT. design-05 §8.5: "a declined answer is a
   correct answer." A refusal drawn as an error — red, an icon, an apology — teaches that the
   assistant is broken, when what it is doing is the thing the whole design is for. Same box,
   same weight, same colour. Only the words differ, and on this corpus the refusal is the
   common case. */
.answer {
  padding: 12px 14px 13px;
  border-radius: var(--radius);
  background: rgba(255, 255, 255, .08);
}

/* Which place a card was asked in, on cards from somewhere else only — the deck outlives the
   scene it was made in, and design-05 §8.6's failure is a true thing about the wrong
   subject. */
.answer-where {
  margin: 0 0 5px;
  font-size: 10px;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, .5);
}

/* What was asked, above what came back. It is what makes clearing the field safe. */
.answer-question {
  margin: 0 0 7px;
  font-size: 12px;
  line-height: 1.35;
  color: rgba(255, 255, 255, .6);
}

.answer-said {
  margin: 0;
  font-size: 15px;
  line-height: 1.45;
}

/* The basis, quieter than the sentence it supports without being hidden. spine §7.1:
   confirmed content is the silent default and carries no badge, so this names the source in
   words rather than stamping the answer with a tick. */
.answer-basis {
  margin: 8px 0 0;
  font-size: 12px;
  line-height: 1.35;
  color: rgba(255, 255, 255, .72);
}

/* Where it was read from — cycle 20, spine §3.2 condition 4's visible half.

   QUIETER THAN THE BASIS, WHICH IS QUIETER THAN THE SENTENCE, and the order is the design.
   The basis says what KIND of thing stands behind the answer; this says WHICH one. A reader
   scanning stops at the sentence; a reader checking follows the link. Making the citation as
   loud as the claim would be a badge, which spine §7.1 refuses for confirmed content.

   The link is underlined rather than coloured: this card is the only place in the app with a
   link out, and a coloured one at this size reads as a button on a dark card. */
.answer-source {
  margin: 4px 0 0;
  font-size: 12px;
  line-height: 1.35;
  color: rgba(255, 255, 255, .56);
}

.answer-source a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* A place this world has a scene of, and the walk to it — cycle 20, review round 2.

   THE WHOLE LINE IS THE BUTTON, name and cue together, which is the shape `.eatery-name.is-scene`
   settled on in the tray after its first build wrapped only the name and left the cue stranded
   mid-sentence. The two surfaces make the same offer, so they use the same mono cue and the same
   `›`, borrowed rather than redrawn — design-04 §3.3 is one rule and two places should not look
   like two rules.

   Under the basis and the source line, quieter than the sentence and louder than the citation:
   it is the only thing on this card a person can act on, and a citation they will not follow
   should not outrank a walk they might take. */
.answer-elsewhere {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin: 9px 0 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  font-size: 13px;
  line-height: 1.35;
  text-align: left;
  cursor: pointer;
  color: rgba(255, 255, 255, .82);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  text-decoration-color: rgba(255, 255, 255, .35);
}

.answer-elsewhere-go {
  font: 500 10px/1 var(--font-mono);
  letter-spacing: .07em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, .55);
  white-space: nowrap;
  text-decoration: none;
}
.answer-elsewhere-go::after { content: " ›"; }

/* Both accounts of a disputed claim (§8.3), indented under the refusal because they are what
   it is refusing ABOUT — "two accounts differ" without the accounts is a caveat about
   something the reader cannot see. */
.answer-account {
  margin: 7px 0 0 8px;
  padding-left: 9px;
  border-left: 2px solid rgba(255, 255, 255, .3);
  font-size: 14px;
  line-height: 1.4;
}

/* §8.5 pairs every refusal with the route to confirm. "Nobody has confirmed that yet" is a
   dead end without it, and the whole point of the product is that a resident is the person
   who can fix that. */
.answer-route {
  margin: 9px 0 0;
  font-size: 12px;
  color: rgba(255, 255, 255, .64);
}

/* A CARD BEING WRITTEN. The caret is the only thing that says "still arriving" — no spinner
   inside the answer, because a placeholder sitting where the sentence goes is a sentence the
   assistant did not write. The send button already says "Looking…". */
.answer[data-pending="true"] .answer-said::after {
  content: "";
  display: inline-block;
  width: 2px;
  height: 1em;
  margin-left: 2px;
  vertical-align: -2px;
  background: currentColor;
  animation: ask-caret 1s steps(2) infinite;
}

@keyframes ask-caret { 50% { opacity: 0; } }

@media (prefers-reduced-motion: reduce) {
  .answer[data-pending="true"] .answer-said::after { animation: none; opacity: .6; }
}


/* The route line is a button since cycle 21, and it still has to read as a sentence rather
   than as a control: it is the last line of a refusal, not a call to action. So it keeps the
   paragraph's size and colour and takes only an underline — the same treatment the citation
   link takes one card over, for the same reason. */
button.answer-route {
  display: block;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  text-align: left;
  font: inherit;
  font-size: 12px;
  color: rgba(255, 255, 255, .64);
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}

/* The filter sentence, above the field rather than inside it — see written.js. It is
   form.js's `field()`, which wraps the input in its own <label>, so there is no id to collide
   with on a view redrawn after every act. The admin pages style `.field` for a light page;
   this is the same structure on the dark one. */
.written-form .field { display: block; }

.written-form .field > span {
  display: block;
  margin: 0 0 4px;
  font-size: 12px;
  line-height: 1.35;
  color: rgba(255, 255, 255, .66);
}

/* THE CLAIM CARD ON THE DARK SCRIM — cycle 21, review round 1.
   The card's own rules live in css/claim.css, because the operator's review page draws the
   same card on a pale tool page (spine §7.3, one vocabulary). This is the six values that
   differ, and nothing else about the card is restated here. */
#ask-view .written {
  --claim-ink: var(--canvas);
  --claim-ink-soft: rgba(246, 239, 225, .7);
  --claim-quiet: rgba(246, 239, 225, .5);
  --claim-rule: rgba(246, 239, 225, .14);
  --claim-edge: rgba(246, 239, 225, .3);
  --claim-fill: rgba(246, 239, 225, .08);
  --claim-field: rgba(246, 239, 225, .06);
}

/* The caveat's dashed clay, lifted where it sits on ink. */
#ask-view .written .caveat { border-color: rgba(198, 124, 78, .75); }


/* ── THE RAIN ─────────────────────────────────────────────────────────────────────────
   Cycle 23, design-05 §10.6. OVER the street and never in it: this is a sibling of
   .scene-frame, so it is outside the camera's transformed subtree and outside every stage
   layer's <svg>. Spine §1.1 is why — a skin-layer element may not make a fact-layer claim,
   and the cheapest way to keep that true is for the rain to have no way of reaching the
   illustration. tests/test_weather.py asserts the ancestry.

   IT DOES NOT PAN WITH THE SCENE, on purpose. You turn your head and the buildings shift
   against each other (design-01 §4.3); the rain is between you and all of it, and rain that
   parallaxed with the forecourt would read as painted onto the forecourt.

   NO CANVAS, NO TIMER, NO ANIMATION LOOP. Three repeating-gradient sheets on linear
   transforms — the compositor's cheapest possible job — because a scene that costs a frame
   budget to be wet pays for it out of the panning, which is the thing the app is actually
   about. */
.weather-overlay {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
  /* Above the street (.scene-frame) and below every piece of chrome, the companion's
     bubble included: mkv's line about the rain must never be read through the rain. */
  z-index: 12;
}

/* The veil first — rain darkens a street before you see a single drop, and without it the
   streaks read as scratches on the screen rather than as weather in the air.

   MULTIPLY, AND THE FIRST VERSION DID NOT. A flat translucent grey laid OVER the artwork
   raises every dark value towards it, so the shophouses came out milky — a wet street was
   rendered as a paler one, which is the wrong direction and reads as a photo filter rather
   than as weather. That is the first line of this cycle's own risk list arriving in the first
   screenshot. Multiplying darkens and keeps the saturation, which is what water does. */
.weather-wet::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--rain-veil);
  mix-blend-mode: multiply;
}

.rain-sheet {
  position: absolute;
  /* Taller and wider than the frame because the sheet is translated diagonally; at these
     amplitudes a 100% box would show its own edge crossing the street. */
  inset: -30% -20%;
  /* ONE STREAK PER `--rain-period`, AND THE PERIOD IS THE MOOD'S TO SET. Round 4: the human,
     looking at the two wet weathers side by side — *"the rain is still very fast and it is
     similar as storm except storm is darker with lighting."*

     They were right and the numbers were worse than they look: rain's sheets ran at .3/.17/.1
     and the storm's at .2/.12/.07, so RAIN WAS THE DENSER OF THE TWO. The storm read as
     different only because of its veil and its flash — the water itself was identical, and
     slightly heavier on the calmer weather. That is what happens when the storm gets tuned
     down to fix a wash and nobody re-checks the thing it was supposed to be louder than. */
  background-image: repeating-linear-gradient(
    102deg,
    transparent 0 7px,
    var(--rain-streak) 7px 8px,
    transparent 8px var(--rain-period, 15px)
  );
  will-change: transform;
  animation: rain-fall linear infinite;
}

/* Three planes, three speeds, three weights — the same depth idea the stage uses, applied
   to something that has no depth of its own. One sheet reads as a texture; three read as
   falling. */
/* Depth is the sheet's own — three planes at three scales, the same idea the stage uses
   applied to something with no depth of its own. Speed and weight belong to the MOOD, below. */
.rain-sheet[data-sheet="0"] { background-size: 100% 100%; }
.rain-sheet[data-sheet="1"] { transform: scale(1.6); }
.rain-sheet[data-sheet="2"] { transform: scale(2.3); }
.rain-sheet[data-sheet="3"] { transform: scale(.75); }

/* ── RAIN: SLOW, SPARSE, PALE ─────────────────────────────────────────────────────────
   Round 1 slowed it 2.5x and round 4 slowed it again — *"the rain is still very fast"* — so
   the honest read is that my instinct for this is simply wrong and theirs is not. Twice now
   the answer has been "slower than you think", and rain in a flat illustration is apparently
   nothing like rain outside a window.

   SPARSE IS THE OTHER HALF AND IT IS THE ONE THAT SEPARATES THE TWO WEATHERS. A 26px period
   against the storm's 14px is barely half the streaks, so a shower reads as individual lines
   falling and a storm reads as water. That distinction is in the DRAWING rather than in the
   veil, which means it survives someone turning the veils down again. */
.weather-rain { --rain-period: 26px; }
.weather-rain .rain-sheet[data-sheet="0"] { opacity: .18; animation-duration: 2.9s; }
.weather-rain .rain-sheet[data-sheet="1"] { opacity: .11; animation-duration: 4.1s; }
.weather-rain .rain-sheet[data-sheet="2"] { opacity: .06; animation-duration: 5.6s; }

/* ── STORM: FAST, DENSE, AND THE VEIL DOES THE DARKENING ──────────────────────────────
   Every sheet stays THIN — that is round 2's correction and it holds. The first storm made
   itself heavy by stacking four planes of near-white at full strength, and the streaks
   accumulated into a pale wash: the same milkiness the rain's veil had already been talked
   out of, arriving by a different route. Streak opacity is additive and darkness is not.

   So the storm is louder than the rain on four axes and OPACITY IS NOT ONE OF THEM: twice the
   streak density, roughly twice the speed, a fourth plane close to the viewer, and a veil
   that does the actual darkening. */
.weather-storm { --rain-period: 14px; }
.weather-storm .rain-sheet[data-sheet="0"] { opacity: .2; animation-duration: 1.3s; }
.weather-storm .rain-sheet[data-sheet="1"] { opacity: .12; animation-duration: 1.8s; }
.weather-storm .rain-sheet[data-sheet="2"] { opacity: .07; animation-duration: 2.5s; }
.weather-storm .rain-sheet[data-sheet="3"] { opacity: .15; animation-duration: 1s; }

@keyframes rain-fall {
  from { background-position: 0 0; }
  to { background-position: 40px 200px; }
}

/* Wet ground. The stage's bottom third is pavement in all three scenes, so a sheen band
   there is the cheapest thing that says the water landed rather than just fell. */
.weather-wet::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  /* SHORTER AND BRIGHTER THAN THE FIRST PASS, which ran a third of the way up the frame and
     washed the five-foot way along with the road. Water pools on the road; the covered
     walkway behind the columns stays dry, and a sheen that reached it was saying otherwise. */
  height: 18%;
  background: linear-gradient(to bottom, transparent, var(--rain-sheen));
  mix-blend-mode: screen;
}

/* ── THE OTHER THREE WEATHERS ─────────────────────────────────────────────────────────
   Review round 1. The human asked for sunny, cloudy, raining and thundering.

   SUNNY IS A REAL LIFT AND NOT AN ABSENCE, and that is their observation rather than my
   choice: *"the current scene looks a little cloudy to me, sunny needs to be more bright."*
   They are right — the authored palette is a mild, slightly hazy daylight, so leaving `clear`
   as the untreated street would have made three of the four weathers overcast and given the
   sunny one nothing to be. A `filter` on the frame brightens the artwork itself rather than
   laying a warm film over it; a wash was the first instinct and it is the same milkiness the
   rain's veil already had to be talked out of.

   THE FILTER IS ON .scene-frame, WHICH THE CAMERA TRANSFORMS. Cheap enough at this size, and
   the alternative — filtering each of the four layers — would multiply the work by four for a
   result the eye cannot tell apart. */
.scene[data-mood="clear"] .scene-frame {
  filter: saturate(1.14) brightness(1.07) contrast(1.03);
}

/* CLOUDY HAS NO TREATMENT AT ALL — round 2, and it is a deletion rather than a tuning.
   The human: *"I can't accept the cloudy version, it looks gloomy."* They are right, and the
   reason is the same one that made `clear` need a lift in round 1: the authored palette is
   ALREADY a mild, slightly hazy daylight. Greying it was painting an overcast afternoon on
   top of an overcast afternoon, and two of those is gloom.

   So `cloudy` is the street exactly as drawn, and the app has no way to show it apart from
   having no reading at all. That is a real cost, stated in the cycle evaluation rather than
   hidden here — and it is the correct trade: an honest neutral beats a mood nobody wants to
   look at. */
/* Lightened at round 4 with the streaks — a shower dims a street a little and a storm dims it
   a lot, and the gap between those two numbers is now doing work it was not doing before. */
.scene[data-mood="rain"] .scene-frame { filter: saturate(.94) brightness(.97); }
.scene[data-mood="storm"] .scene-frame { filter: saturate(.76) brightness(.84); }

/* THE SUN — round 2. DOM over the sky, never a node inside it: spine §1.1 keeps painted
   marks out of the illustration layers, and a sun drawn into the sky artwork would be weather
   baked into the spec, carried by every scene rendered from it forever after.

   IT DOES NOT PAN, and for once that is physics rather than a shortcut. The overlay sits
   outside the camera's transformed subtree because rain should not parallax with the
   forecourt — and the sun is the one element on this screen for which "infinitely far away,
   therefore motionless" is simply true.

   POSITIONED IN THE SKY BAND ALL THREE SCENES SHARE. Below the chrome, right of where the
   companion's bubble opens, above every roofline. A percentage rather than a pixel offset
   because the frame is a phone in one hand and a pane in another. */
.weather-clear .sun {
  position: absolute;
  /* MEASURED AGAINST THE BUBBLE, NOT CHOSEN. The first placement put it at 25% wide and 8%
     from the right, and mkv's bubble covered half of it — the bubble opens top-left, runs to
     about 80% of the width, and occupies the sky from roughly 11% to 23% down. The clear band
     is the right edge below the chrome and above the rooflines, and this is it. */
  top: 8%;
  right: 3%;
  width: 19%;
  aspect-ratio: 1;
  border-radius: 50%;
  background:
    radial-gradient(circle at 50% 50%,
      var(--sun-core) 0 30%,
      var(--sun-rim) 30% 37%,
      var(--sun-halo) 38% 62%,
      transparent 63%);
}

/* The storm is the rain's veil, colder and heavier. */
.weather-storm::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--storm-veil);
  mix-blend-mode: multiply;
}

.weather-storm::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  height: 18%;
  background: linear-gradient(to bottom, transparent, var(--rain-sheen));
  mix-blend-mode: screen;
}

/* THE FLASH, AND IT IS DELIBERATELY ALMOST NOTHING. Nine seconds apart, two frames long, and
   it lifts the whole frame rather than drawing a bolt — a drawn bolt would be an illustration
   of lightning painted over an illustration, which is the one thing spine §1.1 keeps the skin
   layer away from. What a person sees is the street going pale for an instant, which is what
   lightning actually looks like from inside a room.

   `clear` IS NOT THE SAME AS THE UNTREATED STREET here either: the flash rides the overlay, so
   it stops with the overlay when the mood does. */
.weather-storm {
  animation: storm-flash 9s steps(1, end) infinite;
}

@keyframes storm-flash {
  0%, 1.4%, 2.2%, 100% { background: transparent; }
  0.7%, 1.8% { background: var(--storm-flash); }
}

/* REDUCED MOTION STOPS THE FALL AND KEEPS THE WEATHER. Someone who has asked for less
   motion has not asked to be told a dry street is wet — the veil and the sheen carry the
   fact, and only the falling stops. */
@media (prefers-reduced-motion: reduce) {
  .rain-sheet { animation: none; }
  /* AND THE LIGHTNING STOPS COMPLETELY. A flashing screen is the single worst thing on this
     page for somebody who asked for less motion — it is the one effect here that could be a
     genuine problem rather than a preference, so it goes rather than slowing down. The storm
     is still darker and heavier than the rain, which is what says it is a storm. */
  .weather-storm { animation: none; }
}

/* Cycle 30 — a resident's submission on the numbered rail. The caveat sits directly under the
   label (spine §7.2, and cycle 28's order: the marker above the words), and the author under
   the note. Both inherit the rail's own type; what they need from this rule is room. */
.detail-card .caveat { display: block; margin: 3px 0 1px; text-align: left; }

.detail-by {
  display: block;
  margin-top: 3px;
  font-family: var(--font-mono);
  font-size: 8px;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* A resident's photograph on a numbered card — cycle 30, review round 1.

   NOT `.detail-photo`, which is the dish's and bleeds to the card's top edge with a negative
   margin that only works because it is first in the card. Here the label and the caveat are
   above it (cycle 28's order: the marker above the image), so the picture sits inside the
   card's padding instead.

   THE SAME 96px FIXED HEIGHT, for `.detail-photo`'s stated reason: the cards are a rail and a
   rail of different heights reads as a mistake. */
.detail-content-photo {
  display: block;
  width: 100%;
  height: 96px;
  margin: 5px 0 6px;
  object-fit: cover;
  border-radius: var(--radius-sm);
  background: var(--canvas-deep);
}

/* ------------------------------------------------------------- who else is looking

   A LIVE SESSION, DRAWN — cycle 33, design-07 §4.

   THE TREATMENT WAS TRANSLUCENT AND THE HUMAN SAID NO — review round 1:

       "The semi-transparent figure is a bit creepy, please change to full solid colours."

   **That is ⏳ open question 8 answered, by the only instrument that could answer it.** §4.2 said
   this rule was *enforceable only by a person's eye*, and it was right twice over: the eye was
   needed, and the eye said the wash failed. It required a figure that reads as *visibly not
   present*; what it produced read as creepy — a way of being visible nobody wanted, and not a
   value that could have been tuned into the right one, because "a ghost of a person" is what
   translucency MEANS.

   SO A VIEWER IS NOW A SOLID FIGURE IN THE WORLD'S OWN LINE, and what tells it apart from a
   memory figure is the NAME UNDER IT. That trade has a cost and product-design §10.3.3 named it
   before anybody made it:

       Two figures that mean different things and look the same is worse than having neither.

   **They no longer look different; they are labelled differently.** A memory figure says *I stood
   here, then* and opens a photograph; a viewer says *Visitor 12* and cannot be tapped at all.
   Whether a label does the work a treatment was doing is the same question §4.2 asked, asked
   again about a different answer — and it is the human's, on the next look, not this file's. */
.viewer-figure {
  position: absolute;
  aspect-ratio: 1 / var(--fig-aspect);

  /* THE MOTION IS f(pan, id) AND THERE IS NO OTHER INPUT — §5.1. `--pan-n` is the pan as a
     plain number (camera.js); `--sway-phase` is a third salted draw off the presence id
     (figures.js). No timer anywhere, which is what makes §5.2's "carries no information"
     provable rather than asserted: the only time-varying term is one the CAMERA computed,
     so the answer to "why did that one move?" is "because you panned". */
  --sway-amp: 3px;
  --sway: calc(sin((var(--pan-n, 0) + var(--sway-phase, 0)) * 1turn) * var(--sway-amp));
  transform: translateX(calc(var(--pan, 0px) + var(--sway)))
             translate(-50%, calc(-100% * var(--fig-foot)));

  /* WAS 10, IS 11, AND CYCLE 35 RAISED IT — which is a change to the DRAWING and not only to
     the tap, so it is recorded rather than tucked into the line below.

     At 10 it sat under the memory figures' 11, and that quietly defeated the thing figures.js's
     `sortByDepth` says it does: *one depth order over both kinds of figure*. A z-index band that
     differs between the two kinds beats any DOM order inside it, so EVERY memory figure painted
     over EVERY viewer whatever their depth — a person at the front of the room behind a
     photograph pinned at the back. Levelling them is what makes the depth sort govern, and
     `sortByDepth`'s tie rule (the memory figure draws on top) is what it was always for.

     IT ALSO PUTS A VIEWER ABOVE THE SLOT MARKERS AT 10, which is deliberate and is the sentence
     `.memory-figure` already carries one band up: *a person on the pavement is nearer than the
     wall behind them, and tapping a person should open the person.* A viewer is a person. */
  z-index: 11;

  /* IT IS A DOOR NOW — cycle 35, design-11 §2, and this rule is one of the two edits design-07
     §4.3 built itself to make expensive to miss:

         "Adding a tap later requires changing the element type and deleting `pointer-events:
          none` — two visible edits in a diff, rather than one plausible `addEventListener`."

     **The construction did its job and it is being spent on the record.** What it bought was not
     that the change be hard — it was that the change be ARGUED, and `design-11` is the argument.

     THE RULE IS NARROWED RATHER THAN DELETED, which is stricter than §4.3 asked for and is worth
     keeping: a viewer figure is inert unless `figures.js` was handed a room to open, so the
     default is still *not a lever* and the exception has to be passed in by a caller. A figure
     drawn by a test, or by any future surface that has no room behind it, still cannot take a
     tap from the slot behind it.

     THE ELEMENT TYPE DID NOT CHANGE, and that is `design-11` §2 consequence 2 rather than a
     corner cut: *"the figure stays non-focusable and non-announced; the DOOR is what becomes
     reachable"*. A `<button>` is focusable and announced, and five of them is five controls a
     screen-reader user must walk past to reach a street. The keyboard's door is the list view's
     row (§6.3), which is also why the figures do not need to be buttons. */
  pointer-events: none;
  cursor: default;
}

.viewer-figure[data-door="true"] {
  pointer-events: auto;
  cursor: pointer;
}

/* The only affordance it gets, and it is on the LABEL rather than on the person. A halo or an
   outline around a drawn figure reads as a state that figure is in — selected, highlighted,
   active — and a viewer has no states; they are somebody with a tab open. Lifting the name plate
   says *this is a control* about the label, which is the part that is already interface. */
.viewer-figure[data-door="true"]:hover .viewer-name,
.viewer-figure[data-door="true"]:active .viewer-name {
  color: var(--ink);
  background: var(--gold);
}

.viewer-figure svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  /* SOLID. No opacity, no filter, no wash — the fills in the pose are the colours, and they are
     the six street palette pairs drawn off the id (figures.json), which is the human's second
     note: the same colours as the people already drawn on the street rather than a wash that
     turned every pair into the same pale green. */
}

/* WHO IT IS, AND IT IS NEVER WHO THEY ARE — review round 1.

   Rendered as DOM over the illustration rather than inside it, which is spine §1.1's own
   instruction rather than a way around it: *"If a scene needs to state something, the statement
   comes from the fact layer and renders as text over the illustration."* Cycle 17's mkv bubble is
   the same move.

   The words are `presence_visitor` in scenes.json and the number is a salted draw off the
   session's 128 random bits — so this names WHICH anonymous session, never WHOSE. */
.viewer-name {
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  margin-top: 2px;
  font-family: var(--font-mono);
  font-size: 7.5px;
  letter-spacing: .07em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--ink-soft);
  /* AN OPAQUE PLATE, AND IT IS ABOUT OCCLUSION RATHER THAN CONTRAST. A glow (three stacked
     text-shadows) was the first idea and it read fine over a busy floor and failed the case that
     actually happens: TWO VIEWERS AT NEARLY THE SAME DEPTH PRINT THEIR NAMES THROUGH EACH OTHER,
     and "VISITOR 70" over "VISITOR 65" is neither name.

     Spreading the figures apart would have fixed it and is forbidden — design-07 §4.1 requires a
     spot to be a function of the ID ALONE, because a position that depended on who else was here
     would make a figure MOVE when somebody arrived, which reads as a person walking around and is
     motion carrying information through the back door.

     So the labels are allowed to overlap and the nearer one WINS, which is what the rest of the
     scene already does: figures.js sorts both kinds of figure by depth, so the nearer label is
     later in the DOM and paints over. An opaque plate is what turns painting-over into
     covering. */
  background: var(--canvas);
  padding: 1px 3px;
  border-radius: 2px;
  /* It rides the figure, and the figure is already not hit-testable. Said again here because a
     text node is the one part somebody would expect to be selectable. */
  pointer-events: none;
}

/* SOMEBODY SPOKE — cycle 36, design-11 §5, and it says ONE thing.

   *This person has spoken in this room.* Not what they said, not how much, not how recently, not
   whether they are typing. §5's table refuses each of those by name and the drawing refuses them
   by having nowhere to put them: it is a shape with no text node and no state.

   NO ANIMATION, and for a sharper reason than the room bubble's quiet mark. A bubble that pulsed
   would be saying something about a person's activity RIGHT NOW, and the only thing this element
   is allowed to claim is that a message exists in a table. `design-07` §5.2's *no idle animation*
   already governs everything on a figure; this is the one element where breaking it would also
   break a content rule.

   ABOVE THE HEAD AND OFFSET, so that two figures standing close print their bubbles apart even
   when their name plates overlap. It rides the figure's own transform, so it pans and sways with
   the person it belongs to and needs no position of its own. */
.viewer-bubble {
  position: absolute;
  left: 62%;
  bottom: 96%;
  width: 9px;
  height: 7px;
  border-radius: 4px 4px 4px 1px;
  background: var(--canvas);
  border: 1px solid var(--ink-soft);
  /* The figure is the tap target (cycle 35's door); this is a mark riding on it and must not
     take a tap of its own. */
  pointer-events: none;
}

/* §5.3 — REDUCED MOTION COSTS NOTHING HERE, and §5.1's design is why it is one line. The
   figures are still there, still the same number, at the same opacity: design-01 §6.2's rule
   that reduced motion is never reduced INFORMATION. A feature built on timers would have to
   decide what to do with each of them. */
@media (prefers-reduced-motion: reduce) {
  .viewer-figure { --sway-amp: 0px; }
}

/* The same thing, in words — design-07 §8, and design-01 §9's parity is why it exists at all.
   A screen reader user cannot see five translucent figures and figures.js makes them
   `aria-hidden` on purpose, so this is not a caption for the drawing: it IS the fact.

   IT IS QUIET, AND THAT IS THE CORRECTION rather than the first idea. Unstyled it inherited the
   list's body type and sat above every row at the same weight as a content heading — the loudest
   thing on a page whose subject is the neighbourhood, for a number that is usually zero. A count
   of open tabs is not more important than what is in the street. */
.list-presence { margin: -6px 0 16px; }

.list-presence-line {
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: .09em;
  text-transform: uppercase;
  color: var(--ink-soft);
  margin: 0;
}

.list-presence-more {
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: .09em;
  text-transform: uppercase;
  color: var(--ink-soft);
  opacity: .75;
  margin: 2px 0 0;
}

/* THE DOOR, IN THE LIST — cycle 35, design-11 §6.3.

   It is styled as a control and not as another line of that quiet mono caption above it, because
   it is the one thing in this block a person can act on. The two lines above state a number; this
   opens a room.

   AND IT IS ABSENT AT ZERO, unlike the choice below it, which is the same distinction that block
   already draws in the other direction: the count is a fact, the opt-out is a choice, and a DOOR
   is neither — it is an affordance, and an affordance onto a room nobody is in is the thing
   design-05 §14.2.1 refused. list.js keeps it inside the `total > 0` branch. */
.list-presence-door {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
  margin-top: 8px;
  padding: 0 14px;
  border: 1px solid var(--tile-teal);
  border-radius: 999px;
  background: none;
  color: var(--tile-teal-deep);
  font-family: inherit;
  font-size: 12.5px;
  letter-spacing: .02em;
  cursor: pointer;
}

.list-presence-door:hover {
  background: var(--tile-teal);
  color: var(--canvas);
}

/* The choice, and it is SHOWN AT ZERO while the line above is not. The count is a fact and is
   absent when there is none (§3.3); the control is a choice, and a choice you can only find when
   somebody else happens to be looking is not one you can make. */
.list-presence-choice {
  display: flex;
  align-items: flex-start;
  gap: 7px;
  margin-top: 7px;
  font-size: 10.5px;
  line-height: 1.4;
  color: var(--ink-soft);
  opacity: .8;
  cursor: pointer;
}

.list-presence-choice input {
  flex: none;
  width: 13px;
  height: 13px;
  margin: 1px 0 0;
  accent-color: var(--ink-soft);
}
