/* gd-viewer (iteration 07 rounds 3-4): the photo is the flat content
   layer; controls float above it as glass. Actions live in ONE inset
   glass cluster (12px inset, radius-card, blur material); prev/next are
   circular glass buttons; the back pill keeps a 44px hit area; side
   padding is the unified 24px; every target is 44-52px. */
gd-viewer { position: fixed; inset: 0; z-index: 30; background: var(--surface-viewer);
  display: flex; flex-direction: column; }
/* Every element, because touch-action does NOT inherit: setting it on the
   viewer alone left the arrows and the cluster to the browser, and a
   double-tap near them hit iOS's own double-tap-to-zoom and blew up the whole
   page -- photo, buttons, copy and all. `manipulation` keeps taps instant and
   refuses that zoom. The viewer offers its own zoom, so it owes the page none.
   The stage takes `none` below (class beats element, so it wins) because it
   handles the pinch and the drag itself. */
gd-viewer, gd-viewer * { touch-action: manipulation; }
/* The stage owns every touch inside it. `touch-action: none` is deliberate and
   is what stops a pinch zooming the whole PAGE instead of the photo; the
   viewer hands back what it does not use (the OS back gesture at the left
   edge, in viewer.js). */
gd-viewer .lb-stage, gd-viewer .lb-stage * { touch-action: none; }
gd-viewer .lb-stage { flex: 1; min-height: 0; overflow: hidden; }
/* prev | current | next, side by side. The track follows the finger, so a drag
   shows the next photo arriving rather than teleporting to it.
   The track's BOX stays one stage wide and its slides overflow it: a transform
   percentage resolves against the element's own width, so a 300%-wide track
   would read -100% as three stages and fling every slide off screen. One
   stage wide means -100% is exactly one photo, which is what the rest of the
   gesture maths assumes. */
gd-viewer .lb-track { display: flex; height: 100%; width: 100%;
  transform: translate3d(-100%, 0, 0); will-change: transform; }
gd-viewer .lb-slide { flex: 0 0 100%; height: 100%; display: flex;
  align-items: center; justify-content: center; overflow: hidden; }
gd-viewer .lb-img { max-width: 100%; max-height: 100%; width: 100%; height: 100%;
  object-fit: contain; will-change: transform; }
/* The designed stand-in when a photo will not load, replacing the browser's
   broken-image glyph (handset 2026-07-25). It sits over the slide, says what
   happened, and offers the only action that helps. The slide keeps its size,
   so nothing jumps when the retry succeeds. */
gd-viewer .lb-slide { position: relative; }
/* OPAQUE, and that is the entire point of it. This state exists so a guest
   never sees the browser's broken-image glyph -- "the one thing this app is
   not allowed to do", per viewer.js. It shipped transparent, so the glyph
   rendered straight through it and the operator photographed our copy with
   Safari's blue `?` sitting between the two lines (2026-07-25). A designed
   state that does not cover what it replaces is not a designed state. */
gd-viewer .lb-failed { position: absolute; inset: 0; display: flex;
  flex-direction: column; align-items: center; justify-content: center;
  gap: var(--space-sm); text-align: center; padding: var(--space-lg);
  background: var(--surface-viewer);
  color: var(--ink-2); font: var(--type-body) var(--font-ui); }
gd-viewer .lb-failed p { margin: 0; }
gd-viewer .lb-reload { min-height: 44px; padding: 0 var(--space-lg);
  border-radius: var(--radius-pill); border: 1px solid var(--border-ctl);
  background: transparent; color: var(--ink); font: var(--type-body) var(--font-ui); }
/* A zoomed photo owns the gesture: nothing else may slide under the guest
   while they are looking closely at one photo. */
gd-viewer.zoomed .lb-track { transition: none !important; }
/* A save that has been acknowledged: states its own progress and stops
   inviting the tap it already took. */
gd-viewer .lb-save.is-busy { opacity: 0.62; cursor: default; }
gd-viewer .lb-top { display: flex; justify-content: space-between; align-items: center;
  padding: var(--space-sm) var(--inset-floating); }
gd-viewer .lb-counter { font: var(--type-note)/1 var(--font-ui); color: var(--ink-2); padding-right: var(--space-sm); }
gd-viewer .lb-bottom { padding: var(--gap-grid) 0 calc(var(--inset-floating) + env(safe-area-inset-bottom));
  max-width: 480px; width: 100%; margin: 0 auto; }
gd-viewer .lb-navrow { display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-sm); margin: 0 var(--pad-side) var(--space-sm); }
gd-viewer .lb-note { font: var(--type-note)/1.4 var(--font-ui); color: var(--ink-3); text-align: center; flex: 1; }
/* The single floating cluster: the only glass panel in the chrome. */
gd-viewer .lb-cluster { margin: 0 var(--inset-floating); padding: var(--space-sm) 14px;
  background: var(--glass); border: 1px solid var(--accent-soft-border); border-radius: var(--radius-card);
  backdrop-filter: blur(var(--blur-glass)) saturate(var(--glass-saturate));
  box-shadow: inset 0 1px 0 var(--glass-edge); }
gd-viewer .lb-ctas { display: grid; gap: var(--space-xs); }
gd-viewer .lb-ctas .btn { text-align: center; text-decoration: none; }
/* The picks-review linklike sits outside .lb-ctas, so the shared
   .btn.full + .linklike.center stack-gap rule can't reach it; give it the
   same gap explicitly so it reads like the identical PurchaseBar control. */
gd-viewer .lb-cluster .lb-review { margin-top: var(--space-xs); }
gd-viewer .lb-foot { font: var(--type-fine)/1.5 var(--font-ui); color: var(--ink-3); text-align: center;
  margin: var(--space-xs) 0 0; min-height: 15px; }
