/* The viewer is its own visual world: dark, edge-to-edge, chrome kept minimal so
   the video is the page. Deliberately does not inherit the marketing pages' theme. */
:root {
  --g-bg: #0d0d10;
  --g-panel: #17171c;
  --g-border: #2a2a33;
  --g-text: #f2f2f5;
  --g-muted: #8e8e9a;
  --g-accent: #4a9eff;
  --g-live: #35c46a;
  --g-warn: #e0a33a;
}
html, body.viewer {
  height: 100%;
  margin: 0;
  /* style.css is shared with the marketing pages and puts `padding: 3rem 1.5rem`
     on body. Resetting margin alone left that padding as a frame around the
     video, so reset both. */
  padding: 0;
  background: var(--g-bg);
  color: var(--g-text);
  font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  overflow: hidden;
}
.shell { display: flex; flex-direction: column; height: 100%; }

.topbar {
  display: flex; align-items: center; gap: 1rem;
  /* The one place a margin is wanted, so the controls clear the window edge. */
  padding: 0.55rem 0.9rem;
  border-bottom: 1px solid var(--g-border);
  flex: 0 0 auto;
}
.brand { font-size: 0.72rem; font-weight: 650; letter-spacing: 0.14em; text-transform: uppercase; color: var(--g-muted); }
.spacer { flex: 1; }
.gbtn {
  font: inherit; font-size: 0.875rem;
  color: var(--g-text); background: transparent;
  border: 1px solid var(--g-border); border-radius: 7px;
  padding: 0.4rem 0.8rem; cursor: pointer;
}
.gbtn:hover { background: #ffffff10; }

/* Two-way controls. A button naming where it will take you reads as a claim
   about where you are — "Live" while showing stills. Showing both words and
   lighting the one in force removes the guess. Built like the iOS segmented
   control the phone already teaches: a dim track, a lit half. */
.seg {
  display: inline-flex; gap: 2px; padding: 2px;
  background: #ffffff0a;
  border: 1px solid var(--g-border); border-radius: 8px;
}
/* `hidden` is a bare attribute selector, which the display above outranks. */
.seg[hidden] { display: none; }
.segbtn {
  font: inherit; font-size: 0.82rem;
  color: var(--g-muted); background: transparent;
  border: 0; border-radius: 6px;
  padding: 0.3rem 0.6rem; cursor: pointer;
  white-space: nowrap;
}
.segbtn.on { background: #ffffff24; color: var(--g-text); font-weight: 600; }
@media (hover: hover) and (pointer: fine) {
  .segbtn:hover { color: var(--g-text); }
  .segbtn.on:hover { background: #ffffff2e; }
}
.gbtn.primary { background: var(--g-accent); border-color: var(--g-accent); color: #fff; }
.gbtn:disabled { opacity: 0.45; cursor: default; }

.stage { flex: 1; min-height: 0; display: flex; }
.grid {
  flex: 1; min-width: 0;
  /* gap separates tiles from each other; no outer padding, so video runs to the
     window edge on the left, right and bottom. */
  display: grid; gap: 3px; padding: 0;
  grid-template-columns: repeat(var(--cols, 2), 1fr);
  grid-auto-rows: 1fr;
}
.tile {
  position: relative; overflow: hidden;
  /* Square corners: a radius leaves dark notches once tiles meet the window edge. */
  background: #000; border-radius: 0;
  display: flex; align-items: center; justify-content: center;
}
/* The video sits inside a box whose aspect ratio equals the VISIBLE (cropped)
   region. The video element is then blown up by 1/cropWidth and shifted, so the
   chosen region exactly fills that box. Sizing it this way means the video
   element's own box always ends up at the camera's true aspect ratio, so
   `object-fit: fill` stretches nothing — cropping and letterboxing stop
   fighting each other. */
.cropbox {
  position: relative;
  /* Its own stacking context, so the z-index the two still buffers use to swap
     stays inside the picture. Without this they competed with the tile's own
     overlays and painted over the name and age label, which sit at auto. */
  z-index: 0;
  isolation: isolate;
  overflow: hidden;
  margin: auto;
  /* Dimensions are set in px by the tile's fit(). Relying on `aspect-ratio`
     plus max-width/max-height alone collapses the box to 0x0 here: inside a
     flex container with no definite size basis there is nothing for the ratio
     to resolve against, and the video silently has nowhere to render. */
  width: 0; height: 0;
}
.grid.fill .cropbox {
  /* Fill mode: let the box take the whole tile and crop whatever overflows. */
  width: 100%; height: 100%; max-width: none; max-height: none; aspect-ratio: auto;
}
.tile video {
  position: absolute;
  width: var(--vid-w, 100%);
  height: var(--vid-h, 100%);
  left: var(--vid-x, 0);
  top: var(--vid-y, 0);
  object-fit: fill;
  opacity: 0; transition: opacity 220ms ease;
}
.grid.fill .tile video {
  object-fit: cover;
  /* Which part of an over-filled frame is visible. Dragged by the user. */
  object-position: var(--focus-x, 50%) var(--focus-y, 50%);
}
/* Only offer the grab cursor where there is actually something to pan to. */
.grid.fill .tile.pannable { cursor: grab; touch-action: none; }
.grid.fill .tile.pannable.panning { cursor: grabbing; }
/* Stops a long press on a tile raising Safari's save-image sheet mid-drag. */
.tile { -webkit-touch-callout: none; }
.tile video, .tile .still { -webkit-user-select: none; user-select: none; pointer-events: none; }

/* Crop editor */
.tilebtns {
  /* Bounded on both sides, not just the right: anchored only to the right the
     row ran off the left edge of a half-width phone tile and ate the first
     button. Wrapping to a second row is better than losing one. */
  position: absolute; top: 6px; right: 6px; left: 6px; z-index: 3;
  display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.3rem;
  opacity: 0; transition: opacity 150ms ease;
  /* The cluster now spans the tile, so it must not take taps itself — that
     strip is still tile surface for tap-to-toggle, panning and crop drags. */
  pointer-events: none;
}
/* Only tappable while actually on screen; an invisible button should not be
   sitting in the way of the tap that reveals it. */
.tile.touched .tilebtn,
.tile.cropping .tilebtn,
.grid.focused .tile.focused .tilebtn { pointer-events: auto; }
.tilebtn {
  font: inherit; font-size: 0.75rem;
  background: #000000b0; color: #fff;
  border: 1px solid #ffffff40; border-radius: 5px;
  padding: 0.25rem 0.55rem; cursor: pointer;
  /* Comfortably tappable on a phone without dominating a small tile. */
  min-height: 30px;
  /* "Go Live" is two words in a cell sized for one; let it overhang rather
     than break onto a second line and make its row taller than the others. */
  white-space: nowrap;
}
.tilebtn:hover { background: #000000d8; border-color: #ffffff70; }
/* Touch and mouse are kept strictly apart.
   iOS applies :hover to the last-tapped element and leaves it there, so an
   unguarded hover rule pins the controls open on a phone no matter what the tap
   handler does. Hover therefore applies only where a real pointer exists, and
   .touched is only ever set on touch devices. */
.tile.touched .tilebtns,
.tile.cropping .tilebtns { opacity: 1; }

@media (hover: hover) and (pointer: fine) {
  .tile:hover .tilebtns { opacity: 1; }
  .tile:hover .tilebtn { pointer-events: auto; }
}

/* A live camera is marked even when the controls are hidden, so one left
   streaming is noticeable at a glance. The remaining time sits here rather than
   in the bottom-left label, which is reserved for how current the picture is. */
.tile.mode-live .label .dot { background: var(--g-live); }
.livebadge { display: none; }
.tile.mode-live .livebadge {
  display: block;
  position: absolute; top: 8px; left: 8px; z-index: 2;
  font-size: 0.6rem; font-weight: 700; letter-spacing: 0.09em;
  color: var(--g-live);
  background: #000000a0; border-radius: 3px; padding: 0.2rem 0.4rem;
  pointer-events: none;
}
/* Same specificity as the rule above, so it has to come after it to win. */
.tile.mode-live .livebadge.hidden { display: none; }

.cropveil {
  position: absolute; inset: 0; z-index: 4;
  background: #000000a8;
  cursor: crosshair;
  /* Required for pointer drags on touch. Left at the default, Safari claims the
     gesture as a possible scroll or zoom and delivers pointercancel instead of
     pointermove, so the drag silently does nothing. A mouse is never intercepted
     this way, which is why this only showed up on the phone. */
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}
.cropveil .sel {
  position: absolute;
  border: 2px solid var(--g-accent);
  background: transparent;
  box-shadow: 0 0 0 9999px #000000a8;
  pointer-events: none;
}
.cropbar {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 5;
  display: flex; gap: 0.5rem; align-items: center; justify-content: center;
  padding: 0.6rem; background: #000000d0;
  font-size: 0.8rem; color: var(--g-muted);
}
.tile video.active { opacity: 1; }
.tile .label {
  position: absolute; left: 0; bottom: 0; right: 0;
  padding: 0.5rem 0.7rem;
  background: linear-gradient(transparent, #000000cc);
  font-size: 0.85rem; font-weight: 550;
  display: flex; align-items: center; gap: 0.45rem;
  pointer-events: none;
}
/* Live is a state, not part of the camera's name, so it reads as a marker
   rather than a word: caps, tracked out, in the green the dot already uses. */
.tile .label .livetag { display: none; }
.tile.mode-live .label .livetag {
  display: inline;
  color: var(--g-live);
  font-size: 0.72em; font-weight: 700; letter-spacing: 0.1em;
}
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--g-muted); flex: 0 0 auto; }
.dot.live { background: var(--g-live); }
.dot.connecting { background: var(--g-warn); animation: pulse 1.2s ease-in-out infinite; }
.dot.error { background: #e05a5a; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.3 } }
/* Overlaid, not laid out. As a flex sibling of the picture this took horizontal
   space of its own, so every "Waking the camera…" or error shoved the image
   sideways and resized it — the frame should stay exactly where it is and the
   message should sit on top of it. */
.tile .state {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 3;
  max-width: 84%;
  padding: 0.45rem 0.85rem;
  border-radius: 999px;
  background: #000000b8;
  color: var(--g-text);
  font-size: 0.8rem;
  text-align: center;
  pointer-events: none;
}

.picker {
  /* Widened when rows gained a drag handle: at 260px the handle's 32px pushed
     the longest camera name into an ellipsis, and the name is the point. */
  flex: 0 0 292px;
  border-left: 1px solid var(--g-border);
  background: var(--g-panel);
  padding: 1rem; overflow-y: auto;
}
.picker h2 { margin: 0 0 0.25rem; font-size: 1rem; }
.picker p.hint { margin: 0 0 1rem; color: var(--g-muted); font-size: 0.85rem; }
.camrow {
  display: flex; align-items: center; gap: 0.65rem;
  padding: 0.45rem; margin-bottom: 0.35rem;
  border: 1px solid transparent; border-radius: 8px;
  cursor: pointer; font-size: 0.9rem;
}
.camrow:hover { background: #ffffff08; }
.camrow.on { border-color: var(--g-accent); background: #4a9eff1a; }
.camrow.offline { opacity: 0.45; }

/* A recent still reads faster than the name, but similar rooms look alike, so
   both are shown rather than the thumbnail alone. */
.camthumb {
  flex: 0 0 76px; height: 44px;
  border-radius: 5px; overflow: hidden;
  background: #000; border: 1px solid var(--g-border);
  display: flex; align-items: center; justify-content: center;
}
.camthumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.camthumb.blank { font-size: 0.6rem; color: var(--g-muted); text-align: center; line-height: 1.1; }

.cammeta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.1rem; }
.cammeta .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cammeta .sub { font-size: 0.7rem; color: var(--g-muted); }

/* The only draggable part of a row, so tapping still selects and a drag
   anywhere else still scrolls the drawer. Drawn rather than typed, so it does
   not depend on a glyph being present in the font. */
.camgrip {
  flex: 0 0 auto;
  width: 32px; height: 32px;
  border-radius: 6px;
  cursor: grab;
  touch-action: none;
  background: repeating-linear-gradient(
      to bottom, var(--g-muted) 0 2px, transparent 2px 6px)
    center / 14px 14px no-repeat;
  opacity: 0.75;
}
.camgrip:active { cursor: grabbing; }
@media (hover: hover) and (pointer: fine) {
  .camgrip:hover { opacity: 1; background-color: #ffffff12; }
}
.camrow.dragging { opacity: 0.5; }
.orderreset { display: block; width: 100%; margin-top: 0.6rem; font-size: 0.8rem; }
.orderreset[hidden] { display: none; }
.empty { margin: auto; text-align: center; color: var(--g-muted); max-width: 26rem; padding: 2rem; }
.empty h2 { color: var(--g-text); }
.empty strong { color: var(--g-text); }
/* Steps read left-aligned, but the block itself still centres in the panel. A
   block sized to its content does both; inline-block also centred, but let the
   dismiss button flow up alongside the last step. The icons are the markers,
   so the list's own numbering goes. */
.empty .steps {
  /* max-content, not fit-content: fit-content let a row wrap while the panel
     still had room, splitting "Open the browser menu" across two lines. */
  display: block; width: max-content; max-width: 100%; text-align: left;
  list-style: none; margin: 0.8rem auto 1.3rem; padding-left: 0;
}
/* Exactly two children per row — icon and label. The label is wrapped rather
   than left as loose text because gap falls between every flex item, which put
   a second space inside phrases like "Tap Share". */
.empty .steps li {
  display: flex; align-items: center; gap: 0.65rem;
  margin: 0.45rem 0;
}
/* Fixed width so the wording starts on one line down the list, whatever the
   glyph's own width turns out to be. */
.empty .steps .ic {
  flex: 0 0 1.7rem; text-align: center;
  font-size: 1rem; line-height: 1; color: var(--g-text);
}

/* Last known frame behind a tile that has not started streaming. Dimmed so a
   stale image is never mistaken for a live one. */
.poster {
  position: absolute; inset: 0;
  background-size: cover; background-position: center;
  opacity: 0.35;
  filter: grayscale(0.3);
  transition: opacity 220ms ease;
}
.tile.playing .poster { opacity: 0; }

/* Stills view: a full-resolution frame in place of the video, sharing the crop,
   fill and pan behaviour so both modes look and move identically. */
.tile .still {
  position: absolute;
  width: var(--vid-w, 100%);
  height: var(--vid-h, 100%);
  left: var(--vid-x, 0);
  top: var(--vid-y, 0);
  object-fit: contain;
  /* No transition. Fading one frame out while the other fades in leaves both
     part-transparent in the middle, and what shows through is the black behind
     them — the dip reads as exactly the flicker this is meant to remove. The
     incoming image is already decoded, so it can simply appear. */
  opacity: 0;
  z-index: 1;
}
.tile .still.active { opacity: 1; z-index: 2; }
.grid.fill .tile .still {
  object-fit: cover;
  object-position: var(--focus-x, 50%) var(--focus-y, 50%);
}
/* Video and stills are mutually exclusive, and the choice is now per camera
   rather than per grid, so the classes live on the tile. */
.tile.mode-stills video { display: none; }
.tile.mode-live .still { display: none; }

/* Ring reported motion on this camera. Brief and edge-only: a wall of tiles
   should draw the eye to the one that moved without becoming a distraction. */
.tile.motion { box-shadow: inset 0 0 0 3px var(--g-live); }
.tile.motion .label { color: var(--g-live); }


/* ── Narrow screens ─────────────────────────────────────────────────────
   A fixed 260px sidebar swallows most of a phone, so below this width the
   picker slides over the grid instead of taking a column from it. */
@media (max-width: 700px) {
  .picker {
    position: fixed; top: 0; right: 0; bottom: 0; z-index: 20;
    flex: 0 0 auto; width: min(84vw, 320px);
    transform: translateX(100%);
    transition: transform 220ms ease;
    box-shadow: -12px 0 30px #00000070;
  }
  .picker.open { transform: translateX(0); }
  /* One row, always. Wrapping cost a whole extra band of chrome across the top
     of a phone screen — on a page whose entire point is the pictures. */
  .topbar {
    flex-wrap: nowrap;
    gap: 0.3rem;
    padding: calc(0.4rem + env(safe-area-inset-top, 0px)) 0.5rem 0.4rem;
    overflow: hidden;
  }
  .brand { display: none; }              /* the app's own name is the least useful pixel here */
  /* Signing out is rare; it does not deserve space next to the controls used
     constantly. It moves into the camera drawer on small screens. */
  #signout { display: none; }
  #summary {
    font-size: 0.7rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    min-width: 0;
  }
  .topbar .gbtn {
    /* Sized for a finger, not just to fit. Squeezing these to make one row was
       overcautious — the controls need about 300px of the ~390px available, so
       there was room to keep them tappable. Apple's guidance is a 44pt target;
       this lands close to that without a second row. */
    font-size: 0.85rem;
    padding: 0.5rem 0.7rem;
    min-height: 40px;
    white-space: nowrap;
    flex: 0 0 auto;
  }
  /* Two labels have to fit where one used to, so the halves take less width
     each while keeping a finger-sized height. */
  .topbar .seg { flex: 0 0 auto; }
  .topbar .segbtn {
    font-size: 0.8rem;
    padding: 0.45rem 0.45rem;
    min-height: 36px;
  }
  /* Bigger targets for fingers. */
  .camrow { padding: 0.6rem 0.5rem; }
  .camthumb { flex-basis: 84px; height: 48px; }
  /* A phone has no width to spare around a panel of instructions. */
  .empty { padding: 1.25rem; }
  .tilebtn { font-size: 0.8rem; min-height: 34px; padding: 0.3rem 0.7rem; }
  /* Four word-labelled buttons cannot fit one row on a half-width phone tile
     (~227px of buttons into ~182px), and free wrapping leaves a lopsided 3 + 1.
     A fixed two-column block wraps evenly instead, and equal-width cells stop
     the pairs from jittering as the mode button flips Live/Still. */
  .tilebtns {
    left: auto; width: 150px;
    display: grid; grid-template-columns: 1fr 1fr;
  }
  .tilebtn { padding: 0.3rem 0.4rem; }
  /* Expanded, the tile is the full width of the phone — put them back in a row
     rather than stacking a 2x2 block into a screen with room to spare. */
  .grid.focused .tile.focused .tilebtns,
  .grid.solo .tilebtns,
  /* Three buttons fit a phone tile in one row; only four need stacking. */
  .tile.mode-live .tilebtns {
    left: 6px; width: auto;
    display: flex; flex-wrap: wrap; justify-content: flex-end;
  }
  .grid.focused .tile.focused .tilebtn,
  .grid.solo .tilebtn { padding: 0.3rem 0.7rem; }
}

/* Only needed when the picker is an overlay; beside the grid it never covers
   the button that opens it. */
.pickerclose { display: none; }
@media (max-width: 700px) {
  .pickerclose {
    display: block;
    position: absolute; top: 8px; right: 10px;
    font: inherit; font-size: 1.5rem; line-height: 1;
    background: transparent; border: none; color: var(--g-muted);
    padding: 0.2rem 0.5rem; cursor: pointer;
  }
  .pickerclose:hover { color: var(--g-text); }
}

/* Installed to the Home Screen the page owns the whole screen, including the
   area behind the notch and home indicator — inset the chrome, not the video. */
@supports (padding: env(safe-area-inset-top)) {
  .topbar {
    padding-top: calc(0.55rem + env(safe-area-inset-top));
    padding-left: calc(0.9rem + env(safe-area-inset-left));
    padding-right: calc(0.9rem + env(safe-area-inset-right));
  }
  .picker { padding-bottom: calc(1rem + env(safe-area-inset-bottom)); }
  /* Beside the grid the picker starts below the top bar, which has already
     stepped around the notch. As a drawer it is fixed from y=0 instead, so it
     meets the unsafe area itself and has to inset its own contents. */
  @media (max-width: 700px) {
    .picker { padding-top: calc(1rem + env(safe-area-inset-top)); }
    .pickerclose { top: calc(8px + env(safe-area-inset-top)); }
  }
  /* Either shape of picker sits against the right edge, which is where the
     inset lands when a phone is rotated. */
  .picker { padding-right: calc(1rem + env(safe-area-inset-right)); }
}


/* Sign out lives in the drawer on phones, where the top bar has no room for it. */
.drawer-extra { display: none; }
@media (max-width: 700px) {
  .drawer-extra { display: block; width: 100%; margin-top: 0.6rem; }
  #fullscreen-drawer { margin-top: 1.25rem; }
  #fullscreen-drawer[hidden] { display: none; }
  /* Its place in the bar went to the second label on each control. */
  .topbar #fullscreen { display: none; }
  /* And so did the summary's. Three two-way controls plus Cameras leave it
     about twenty pixels, which is an ellipsis, not a sentence — and the
     controls now show the mode it was mostly reporting. The camera count is
     still in the drawer, which is where you go to change it. */
  .topbar #summary { display: none; }
}

/* Expanded: one camera takes the whole grid. The others are hidden rather than
   unmounted, so their sessions, crops and scroll position all survive the trip. */
.grid.focused { grid-template-columns: 1fr !important; }
.grid.focused .tile { display: none; }
.grid.focused .tile.focused { display: flex; }
/* Controls stay up while expanded — Close has to be reachable without hunting
   for it, and there is room to spare at this size. */
.grid.focused .tile.focused .tilebtns { opacity: 1; }
/* Refreshing means waking the camera for a fresh frame. A live camera is
   already sending them, so the button has nothing to do there. */
.tile.mode-live .tilebtn.refresh { display: none; }

/* Nothing to expand out of when one camera is selected. The guard matters:
   while expanded this same button is the Close. */
.grid.solo:not(.focused) .tilebtn.expand { display: none; }

/* The camera currently taking over the screen, marked apart from the cameras
   merely selected into the grid behind it. */
.camrow.expanded { border-color: var(--g-live); background: #3ddc8420; }

/* ── List view ─────────────────────────────────────────────────────────────
   One camera per row on a tall screen, per column on a wide one, each drawn at
   its own cropped shape — so rows genuinely differ in height rather than being
   forced to a common one. Because a tile is exactly the shape of its picture,
   nothing overflows and nothing is pannable, which is what leaves a vertical
   swipe free to mean "scroll the list" and nothing else. */
.grid.list-v,
.grid.list-h { display: flex; overscroll-behavior: contain; }

.grid.list-v {
  flex-direction: column;
  overflow-y: auto; overflow-x: hidden;
  -webkit-overflow-scrolling: touch;
  /* Without a reserved gutter the scrollbar's arrival narrows the grid, which
     reshapes every row, which can toggle the scrollbar again. */
  scrollbar-gutter: stable;
}
.grid.list-h {
  flex-direction: row;
  overflow-x: auto; overflow-y: hidden;
}

/* flex-basis auto with an aspect ratio: one axis is given, the other follows
   from the camera's own shape. */
.grid.list-v .tile { flex: 0 0 auto; width: 100%; height: auto; }
.grid.list-h .tile { flex: 0 0 auto; height: 100%; width: auto; }
.grid.list-v .tile,
.grid.list-h .tile { aspect-ratio: var(--ar, 1.7778); }

/* Fit and Fill are the same picture once the tile is the shape of the picture.
   Expanded it stops being true — the camera has a whole screen of a different
   shape to fill — and it is the control that makes the picture draggable, so it
   comes back. */
body.listmode:not(.expanded) #fit-mode { display: none; }

/* `hidden` has to actually hide. The UA stylesheet's [hidden] { display: none }
   is a bare attribute selector, which every .grid display rule above outranks —
   so a grid told to hide stayed in the layout and was squeezed to a sliver
   beside whatever message had replaced it. */
.grid[hidden] { display: none !important; }

/* Expanded, the picture is positioned in pixels and its element box is already
   exactly the shape of the source frame, so it must not be re-fitted inside
   itself — `fill` leaves the box alone. This has to outrank fill mode's cover. */
.grid.focused .tile.focused video,
.grid.focused .tile.focused .still {
  object-fit: fill;
  object-position: 50% 50%;
}
/* The tile owns pinch and drag while expanded; the browser gets neither. */
.grid.focused .tile.focused { touch-action: none; }
/* Belt and braces against the stretch above: a crop box is a flex item, and a
   flex item wider than its container is shrunk on that axis alone. */
.grid.focused .tile.focused .cropbox { flex: 0 0 auto; }
