/* ---------------------------------------------------------- 0. the window
   Every figure on this site is a screen, and every screen gets a window.

   The stage machines draw their own chrome on a plate that moves inside the card.
   The older machines below did not: they filled the card edge to edge, so whether
   a screenshot looked like a screen depended on which machine happened to be
   holding it. That is a distinction the reader has no way to interpret, because
   it does not mean anything -- it is just which idea I had first.

   So the chrome is drawn once, here, and the machine's own content goes into
   .win. Nothing inside had to change: inset:0 resolves against the nearest
   positioned ancestor, and .win is now that ancestor. The content box is 799x365
   rather than 799x391, which is why gen/plates.py computes strip travel against
   365 -- a travel percentage measured against a frame the image no longer sits in
   is a number that looks right and scrolls the picture past its own end. */
.cs-media.framed {
   background: var(--dove-ivory, #F5F2E5)
}

.cs-media.framed > .bar,
.ann-view.framed > .bar {
   position: absolute;
   left: 0;
   right: 0;
   top: 0;
   height: 26px;
   z-index: 5;
   background: var(--dove-ivory, #F5F2E5);
   border-bottom: 1px solid var(--outline-stroke, #E3E1CC)
}

.cs-media.framed > .win,
.ann-view.framed > .win {
   position: absolute;
   left: 0;
   right: 0;
   top: 26px;
   bottom: 0;
   overflow: hidden
}

/* The same three lights and address strip the home tiles and the stage windows
   use, at the same proportions, so the whole site reads as one idiom. */
.cs-media.framed > .bar i,
.ann-view.framed > .bar i {
   position: absolute;
   top: 9px;
   width: 8px;
   height: 8px;
   border-radius: 50%;
   background: #E6685C
}

.cs-media.framed > .bar i:nth-of-type(1),
.ann-view.framed > .bar i:nth-of-type(1) {
   left: 11px
}

.cs-media.framed > .bar i:nth-of-type(2),
.ann-view.framed > .bar i:nth-of-type(2) {
   left: 25px;
   background: #E8B84B
}

.cs-media.framed > .bar i:nth-of-type(3),
.ann-view.framed > .bar i:nth-of-type(3) {
   left: 39px;
   background: #7DC26B
}

.cs-media.framed > .bar b,
.ann-view.framed > .bar b {
   position: absolute;
   left: 58px;
   right: 14px;
   top: 6px;
   height: 14px;
   border-radius: 999px;
   background: var(--parchment, #FDFBEF);
   border: 1px solid var(--outline-stroke, #E3E1CC)
}

/* camera.css -- the reusable camera machines.
   =========================================================================
   Planned in CAPTURE.md Part 4 and built here. Every one of them takes a flat,
   static asset and supplies all of the movement itself, which is the whole approach
   the reference build uses and the reason its cards feel alive while holding
   screenshots that never move.

   Three rules hold for all of them.

   1. The asset is flat. Nothing here interpolates, re-crops or re-encodes anything;
      the file on disk is a still or a plain clip, and the machine is the camera.
   2. A slot configures a machine with custom properties, never by editing keyframes.
      --z, --fx, --fy, --travel, --dur, --i, --n. If a slot needs a new keyframe
      block, it is a new machine, not a variant.
   3. Every machine has a defined resting state that is a good still, because
      prefers-reduced-motion disables the animation and that frame is what is left.
      A machine whose resting frame is meaningless is a broken machine.

   The two that already existed, tile-scroll and tile-pan, stay in index.html: they
   are wired to the drawn browser frame around them and are not general.  */

/* ---------------------------------------------------------------- A. push
   Frame the whole thing, hold, move in on one part, hold, come back.

   The direct answer to wanting the recorded zoom-ins the reference appears to have.
   It never recorded one. It pushed a transform into a still.

   The long holds at both ends are the machine. Without them this is a slow
   continuous drift that never lets anyone read either framing; with them it is a
   wide shot, a decision to look closer, and a detail. --fx/--fy name the point worth
   looking at, as percentages of the image. */
/* No height here, on purpose. Every one of these is applied TOGETHER with .cs-media,
   which is 391px tall, and height:100% resolves against .cs-figure, which has no
   height of its own -- so setting it collapsed every camera plate on the site to two
   pixels. The slot owns the box; the machine owns what moves inside it. */
.cam {
   position: relative;
   width: 100%;
   overflow: hidden
}

/* Both spellings on purpose, and the second one is not decoration.
   When the window went in, the machines' content moved from being a direct child
   of .cam to being a child of .win -- and this rule, which is what gives every
   plate its box, stopped matching. The strip machine sets its own width and
   survived; push, deal, wipe and clip did not, so their images and videos fell
   back to intrinsic size and every one of them was drawn at whatever the file
   happened to be, cropped to the top-left corner of the window. It looked like a
   framing decision, which is why it went unnoticed. Keep both selectors. */
.cam > img,
.cam > video,
.cam > .win > img,
.cam > .win > video {
   position: absolute;
   inset: 0;
   width: 100%;
   height: 100%;
   object-fit: cover;
   display: block
}

.cam-push .win > img {
   transform-origin: var(--fx, 50%) var(--fy, 50%);
   animation: push var(--dur, 12s) cubic-bezier(.4, 0, .2, 1) infinite;
   will-change: transform
}

@keyframes push {
   0%, 14% {
      transform: scale(1)
   }

   34%, 66% {
      transform: scale(var(--z, 1.45))
   }

   86%, 100% {
      transform: scale(1)
   }
}

/* ---------------------------------------------------------------- B. strip
   A tall still scrolling behind a fixed frame.

   --travel is A PERCENTAGE OF THE IMAGE'S OWN HEIGHT, never a pixel count. That
   distinction is the whole of a bug this repeats from the home tiles: the values
   were once measured in the capture's own 776px space and applied against a 322px
   render, so every one was about 2.8x too large and the picture ran clean past its
   own bottom edge and held there, which reads as the thumbnail cutting off.

   gen/tiles.py computes it as (1 - windowHeight / renderedImageHeight) * 100 rather
   than anyone measuring it by hand, and build.py checks the page against what it
   computed. */
.cam-strip .win > img {
   position: static;
   width: 100%;
   height: auto;
   object-fit: unset;
   animation: strip var(--dur, 15s) cubic-bezier(.4, 0, .2, 1) infinite;
   will-change: transform
}

@keyframes strip {
   0%, 14% {
      transform: translateY(0)
   }

   44%, 60% {
      transform: translateY(calc(var(--travel, 0%) * -1))
   }

   90%, 100% {
      transform: translateY(0)
   }
}

/* ---------------------------------------------------------------- C. deal
   N stills at identical framing, cross-fading with holds.

   The base layer never fades. Everything stacks over it, so the loop point dissolves
   against a screen rather than against the card, which is the detail naive
   cross-fades get wrong: fade every layer out and there is one moment per cycle when
   the ground shows through and the whole figure blinks.

   Each layer sets --i, the container sets --n. The resting state is the first frame,
   so a sequence doubles as its own still. */
.cam-deal .win > img {
   opacity: 0;
   animation: deal var(--dur, 12s) linear infinite;
   animation-delay: calc(var(--i) * var(--dur, 12s) / var(--n))
}

.cam-deal .win > img:first-child {
   opacity: 1;
   animation: none
}

@keyframes deal {
   0% {
      opacity: 0
   }

   4%, 26% {
      opacity: 1
   }

   30%, 100% {
      opacity: 0
   }
}

/* ---------------------------------------------------------------- D. wipe
   Before and after under a travelling seam.

   Better than a cross-fade for a comparison, and the reason is specific: two
   near-identical tables dissolving into each other reads as a rendering glitch,
   while a seam moving across reads as one picture being pulled back to show another.
   The rule at the seam is what the eye follows; without it the boundary is invisible
   wherever the two images happen to agree.

   Resting state parks the seam at the middle with both halves visible, which is a
   perfectly good still and the one reduced motion gets. */
.cam-wipe .win > img:last-child {
   clip-path: inset(0 0 0 var(--x, 50%));
   animation: wipe var(--dur, 11s) cubic-bezier(.45, 0, .25, 1) infinite
}

.cam-wipe .seam {
   position: absolute;
   top: 0;
   bottom: 0;
   left: var(--x, 50%);
   width: 2px;
   background: var(--parchment, #FDFBEF);
   box-shadow: 0 0 0 1px rgba(45, 40, 38, .22);
   animation: seam var(--dur, 11s) cubic-bezier(.45, 0, .25, 1) infinite;
   z-index: 2;
   pointer-events: none
}

@keyframes wipe {
   0%, 12% {
      clip-path: inset(0 0 0 88%)
   }

   46%, 62% {
      clip-path: inset(0 0 0 12%)
   }

   96%, 100% {
      clip-path: inset(0 0 0 88%)
   }
}

@keyframes seam {
   0%, 12% {
      left: 88%
   }

   46%, 62% {
      left: 12%
   }

   96%, 100% {
      left: 88%
   }
}

/* The two labels a wipe needs, because BEFORE and AFTER is the whole argument and a
   caption underneath makes the reader hold both halves in their head to use it. */
.cam-wipe .tag {
   position: absolute;
   top: 12px;
   z-index: 3;
   font-family: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   font-size: 11px;
   letter-spacing: .04em;
   text-transform: uppercase;
   padding: 4px 9px;
   border-radius: 5px;
   background: rgba(253, 251, 239, .93);
   color: var(--dusty-granite, #5C5651);
   box-shadow: 0 1px 3px rgba(45, 40, 38, .16)
}

.cam-wipe .tag.b {
   left: 12px
}

.cam-wipe .tag.a {
   right: 12px
}

/* ---------------------------------------------------------------- E. stagger
   Not a camera over one still but a reveal across many.

   site-motion.js already adds .is-in on scroll and site.css already staggers type
   with char-in and rise-in; this is the same idea pointed at a grid. The count is
   the point. Thirty-four portraits arriving one after another says "all 34" more
   convincingly than the sentence does, and a contact sheet of component variants
   arriving one at a time makes the reader count them without being asked.

   An animation rather than a transition, deliberately: a transition would sit on
   these elements forever and compete with anything they do on hover. That exact
   collision cost the project cards their hover lift. */
.cam-stagger > * {
   opacity: 0
}

.is-in.cam-stagger > *,
.cam-stagger.is-in > * {
   animation: cam-rise .5s cubic-bezier(.22, .7, .3, 1) backwards;
   animation-delay: calc(var(--i, 0) * var(--step, 60ms));
   opacity: 1
}

@keyframes cam-rise {
   from {
      opacity: 0;
      transform: translateY(8px)
   }
}

/* ---------------------------------------------------------------- video
   A clip in a media slot. Muted, looping, inline, and it plays itself.

   preload="metadata" and a poster on every one: the poster is the clip's own first
   frame, which is also the frame it dissolves back to, so a paused clip and a
   playing one agree with each other rather than looking like two different assets.

   The clips are walkthroughs assembled from state captures, so there is no audio to
   carry and nothing is lost by muting. Muted is also the only way a browser will
   autoplay at all. */
/* Contain rather than cover, which is the one place this site does not crop to
   the slot. A still can be: the plate was cut for this box. A recording cannot --
   the crop would be hiding part of what happened, and on the villager card, whose
   heading and its Likes row are 890px apart in a 1080px capture, it would hide
   the two things the clip is for. So it fits whole and the ivory shows at the
   edges, which is why this rule has always painted a ground. */
.cam-clip .win > video {
   object-fit: contain
}

@media (prefers-reduced-motion: reduce) {

   .cam-push .win > img,
   .cam-strip .win > img,
   .cam-deal .win > img,
   .cam-wipe .win > img:last-child,
   .cam-wipe .seam,
   .is-in.cam-stagger > *,
   .cam-stagger.is-in > * {
      animation: none
   }

   /* Resting states, stated rather than inherited, because "no animation" leaves
      each machine wherever its unanimated declarations happen to put it. */
   .cam-deal .win > img {
      opacity: 0
   }

   .cam-deal .win > img:first-child {
      opacity: 1
   }

   .cam-wipe .win > img:last-child {
      clip-path: inset(0 0 0 50%)
   }

   .cam-wipe .seam {
      left: 50%
   }

   .cam-stagger > * {
      opacity: 1
   }
}

/* ---------------------------------------------------------------- G. stage
   A capture in a browser window, on a surface, with a camera over it and a label
   that arrives when the camera does.

   This replaces the arrangement the other still machines use, and the reason is a
   class of bug rather than a preference.

   Those machines put the capture into the slot and let object-fit:cover size it.
   Cover crops whatever does not match the slot's 2.04, and interface captures are
   wide -- so the strip it discards is the left edge, where the first column and
   the row labels live. Timestamps came off an event table, building names off a
   set of floor plans, both end labels off a contact sheet. Worse, --fx/--fy
   address the SLOT, so a value can name a point cover has already thrown away,
   and no number in that space can reach it. Several did.

   Here the capture is never cropped by the frame:

     - it sits in a browser window, the same one the home page thumbnails use, so
       a screenshot on this site reads as a screen everywhere it appears;
     - the resting camera fits the whole window with air around it;
     - a move is a translate and a scale over a plate that never changes, which is
       what a camera does. Nothing is resampled and nothing leaves the file;
     - the detail stop is scale 1, the plate's authored size. The assets are 2x
       that, so the closest framing is also the sharpest -- the opposite of a
       crop, which is sharpest when it is widest;
     - the target is a RECTANGLE IN PLATE PIXELS, computed in gen/media.py from
       the capture's own coordinates. "The region from 620,610 to 1620,930" cannot
       silently miss the way a percentage of the wrong box can;
     - the camera, the state change and the label all happen TOGETHER, because
       three things arriving on three schedules reads as three glitches. */
.cam-stage {
   position: relative;
   width: 100%;
   overflow: hidden;
   background: var(--dove-ivory, #F5F2E5)
}

/* The window the plate moves inside. The whole card most of the time, and
   narrower when the figure carries a label -- because positioning the plate away
   from the label is not enough on its own: a plate wider than the card is clipped
   by the CARD, so it still runs under the label and the callout ends up written
   across the screenshot it describes. Clipping it here leaves the label a column
   of bare surface, which is the only arrangement where a leader can point AT
   something rather than across it. */
.cam-stage .view {
   position: absolute;
   left: var(--vx, 0px);
   top: 0;
   width: var(--vw, 100%);
   height: 100%;
   overflow: hidden;
   border-radius: 12px
}

/* The browser window. Origin 0 0 rather than the default centre, because the
   transforms are computed from the plate's own pixel coordinates and a centre
   origin makes that arithmetic depend on the plate's size in a way that is easy
   to get wrong and impossible to read back. */
.cam-stage .plate {
   position: absolute;
   left: 0;
   top: 0;
   width: var(--pw, 1440px);
   height: var(--ph, 810px);
   transform-origin: 0 0;
   transform: translate(var(--x0), var(--y0)) scale(var(--k0));
   border-radius: var(--plate-r, 22px);
   background: var(--dove-ivory, #F5F2E5);
   box-shadow: 0 16px 40px rgba(28, 24, 22, .13), 0 2px 6px rgba(28, 24, 22, .07);
   will-change: transform
}

/* The chrome, drawn in the plate's own pixels so it scales with the camera the
   way a real window would: a hairline at rest, a proper title bar close in. The
   same three lights and address strip the home tiles use, at the same
   proportions, so the two read as one idiom rather than two. */
.cam-stage .bar {
   position: absolute;
   left: 0;
   top: 0;
   right: 0;
   height: var(--bar, 56px);
   background: var(--dove-ivory, #F5F2E5);
   border-bottom: 1px solid var(--outline-stroke, #E3E1CC);
   border-radius: var(--plate-r, 22px) var(--plate-r, 22px) 0 0
}

.cam-stage .bar i {
   position: absolute;
   top: 34%;
   width: 29%;
   aspect-ratio: 1;
   height: auto;
   border-radius: 50%;
   background: #E6685C
}

/* Sized off the bar's own height so the lights stay round and correctly spaced
   whatever the plate's width is. */
.cam-stage .bar i {
   width: calc(var(--bar, 56px) * .29);
   height: calc(var(--bar, 56px) * .29)
}

.cam-stage .bar i:nth-of-type(1) {
   left: calc(var(--bar, 56px) * .41)
}

.cam-stage .bar i:nth-of-type(2) {
   left: calc(var(--bar, 56px) * .94);
   background: #E8B84B
}

.cam-stage .bar i:nth-of-type(3) {
   left: calc(var(--bar, 56px) * 1.47);
   background: #7DC26B
}

.cam-stage .bar b {
   position: absolute;
   left: calc(var(--bar, 56px) * 2.23);
   right: calc(var(--bar, 56px) * .59);
   top: 24%;
   height: 53%;
   border-radius: 999px;
   background: var(--parchment, #FDFBEF);
   border: 1px solid var(--outline-stroke, #E3E1CC)
}

/* The screen: everything below the chrome. */
.cam-stage .shots {
   position: absolute;
   left: 0;
   right: 0;
   top: var(--bar, 56px);
   bottom: 0;
   overflow: hidden;
   border-radius: 0 0 var(--plate-r, 22px) var(--plate-r, 22px)
}

.cam-stage .shots img {
   position: absolute;
   inset: 0;
   width: 100%;
   height: 100%;
   display: block;
   object-fit: cover
}

/* The first shot is the ground and never fades. Everything else stacks over it,
   so the loop dissolves against a screen rather than against the surface: fade
   every layer and there is one moment per cycle when the window shows through
   empty and the whole figure blinks. It also means the resting frame is a real
   frame rather than whatever the animation happens to leave behind -- which is
   the test every machine in this file has to pass, and the one this machine
   failed until now. */
.cam-stage .shots img + img {
   opacity: 0
}

/* Gated on .is-in, like everything else here: a camera running in a figure three
   screens below the fold is work nobody asked for and nobody sees. */
.is-in .cam-stage.moves .plate {
   animation: stage-look var(--dur, 13s) cubic-bezier(.4, 0, .2, 1) infinite
}

/* Wide, in, and back. The long holds at both ends are the machine: without them
   this is a slow drift that never lets anyone read either framing. */
@keyframes stage-look {
   0%, 14% {
      transform: translate(var(--x0), var(--y0)) scale(var(--k0))
   }

   34%, 66% {
      transform: translate(var(--x1), var(--y1)) scale(var(--k1))
   }

   88%, 100% {
      transform: translate(var(--x0), var(--y0)) scale(var(--k0))
   }
}

/* Two detail stops: hold on the first, pan to the second at the SAME scale, come
   back. A pan at a fixed scale reads as following something through a screen; a
   second zoom reads as a cut, and the reader loses where they are. */
.is-in .cam-stage.moves.pans .plate {
   animation-name: stage-pan
}

@keyframes stage-pan {
   0%, 12% {
      transform: translate(var(--x0), var(--y0)) scale(var(--k0))
   }

   30%, 46% {
      transform: translate(var(--x1), var(--y1)) scale(var(--k1))
   }

   62%, 74% {
      transform: translate(var(--x2), var(--y2)) scale(var(--k1))
   }

   90%, 100% {
      transform: translate(var(--x0), var(--y0)) scale(var(--k0))
   }
}

/* ---- everything that arrives, arrives together ----------------------------
   The state change and the label share one window, and that window sits inside
   the camera's hold. Three things on three schedules reads as three glitches;
   one thing happening reads as an argument being made. */
.is-in .cam-stage.seq .shots img + img {
   animation: stage-arrive var(--dur, 13s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage .call {
   animation: stage-arrive var(--dur, 13s) cubic-bezier(.4, 0, .2, 1) infinite
}

@keyframes stage-arrive {

   0%, 30% {
      opacity: 0
   }

   38%, 62% {
      opacity: 1
   }

   70%, 100% {
      opacity: 0
   }
}

/* ---- the label ------------------------------------------------------------
   Positioned in SLOT coordinates, computed alongside the camera so it lands
   beside the region rather than over it. Not a caption: a caption explains the
   figure, this names one thing inside it at the moment that thing is the frame. */
.cam-stage .call {
   position: absolute;
   left: var(--cx);
   top: var(--cy);
   z-index: 3;
   width: var(--cw, 166px);
   opacity: 0;
   pointer-events: none
}

.cam-stage .call-row {
   display: flex;
   align-items: center;
   gap: 7px
}

/* The leader points back toward the thing, so the eye travels label to feature
   rather than the other way round. Mirrored when the label sits to its left. */
.cam-stage .call-line {
   flex: none;
   width: 24px;
   height: 1px;
   background: var(--outline-stroke, #D8D2C2);
   position: relative
}

.cam-stage .call-line::before {
   content: "";
   position: absolute;
   left: -3px;
   top: -2.5px;
   width: 6px;
   height: 6px;
   border-radius: 50%;
   background: var(--accent, #649F25)
}

.cam-stage .call.right .call-row {
   flex-direction: row-reverse
}

.cam-stage .call.right .call-line::before {
   left: auto;
   right: -3px
}

.cam-stage .call.right {
   text-align: right
}

.cam-stage .call-note {
   background: var(--parchment, #FDFBEF);
   border: 1px solid var(--outline-stroke, #D8D2C2);
   border-radius: 11px;
   padding: 5px 10px;
   font-family: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   font-weight: 500;
   font-size: 12px;
   line-height: 1.2;
   color: var(--dusty-granite, #5C5651);
   box-shadow: 0 2px 6px rgba(45, 40, 38, .10)
}

.cam-stage .call-desc {
   margin: 6px 0 0;
   padding-left: 31px;
   font-family: 'Diatype', 'Inter', system-ui, sans-serif;
   font-size: 12px;
   line-height: 1.35;
   color: var(--dusty-granite, #5C5651)
}

.cam-stage .call.right .call-desc {
   padding-left: 0;
   padding-right: 31px
}

@media (prefers-reduced-motion: reduce) {

   /* Resting state stated rather than inherited: the whole window, framed, the
      first shot showing, no label. A label naming a detail nobody can see would
      not be a good still, and a good still is the requirement. */
   .is-in .cam-stage.moves .plate,
   .is-in .cam-stage.seq .shots img + img,
   .is-in .cam-stage .call {
      animation: none
   }

   .cam-stage .plate {
      transform: translate(var(--x0), var(--y0)) scale(var(--k0))
   }

   .cam-stage .shots img + img {
      opacity: 0
   }

   .cam-stage .call {
      opacity: 0
   }
}

/* ---------------------------------------------------------------- H. ann
   An annotated still: leader lines to labels, and the picture pushes in on whatever
   you point at.

   This is the reference build's own walkthrough idea, moved off a phone mockup and
   onto a desktop capture. It is the best thing on those project pages, and the
   reason is that it inverts the usual arrangement: instead of a caption listing four
   features under a screenshot, the screenshot answers the question you are already
   asking, because pointing at a label IS the question.

   Driven by data-hover on the container rather than by :hover on the label, and that
   is not a preference. The zoom has to be read from the hovered label's own
   coordinates, and a custom property set on one element cannot be read by a sibling:
   properties inherit down, never sideways. case-study.js copies them up to the
   container on pointerenter, which also makes the whole thing keyboard-operable for
   free, since focus fires the same handler.

   Resting state is the full frame with every label showing, which is a perfectly good
   still and the one reduced motion keeps. */
.ann {
   position: relative;
   width: 100%;
   overflow: hidden;
   background: var(--dove-ivory, #F6F2E6)
}

/* The picture stops short of the right edge and the labels get the strip that is
   left. CAPTURE.md asks for about 120px clear on the right of an annotated capture
   for this reason, but a full-width screenshot has none, so the plate makes the
   margin itself rather than laying chips over the interface. Same arrangement the
   reference build uses: subject left, annotations right, nothing overlapping. */
.ann-view {
   position: absolute;
   left: 0;
   top: 0;
   bottom: 0;
   right: 196px;
   overflow: hidden;
   border-right: 1px solid var(--outline-stroke, #D8D2C2)
}

/* CONTAIN, not cover. The labels name controls at the far edges of the interface --
   the view toggle top right, the sidebar left, export bottom right -- and cover on a
   box narrowed to make room for the labels crops exactly those away. The whole
   screen has to be visible at rest or the annotations point at nothing. */
.ann-view img {
   position: absolute;
   inset: 0;
   width: 100%;
   height: 100%;
   object-fit: contain;
   transform-origin: var(--fx, 50%) var(--fy, 50%);
   transition: transform .55s cubic-bezier(.4, 0, .2, 1);
   will-change: transform
}

.ann[data-hover] .ann-view img {
   transform: scale(var(--z, 1.6))
}

/* A group is a leader line, a label, and a description that only exists on hover.
   Right-aligned into the clear margin the capture brief asks to be left for them. */
.ann-group {
   position: absolute;
   right: 14px;
   top: var(--t);
   z-index: 2;
   display: flex;
   flex-direction: column;
   gap: 6px;
   width: var(--w, 168px);
   transition: opacity .28s ease
}

/* Everything except the one being pointed at gets out of the way. Fading rather than
   hiding, so the group's own box keeps its place and nothing reflows underneath. */
.ann[data-hover] .ann-group:not(.on) {
   opacity: .12
}

.ann-row {
   display: flex;
   align-items: center;
   gap: 6px
}

/* The leader points LEFT, out of the label and back toward the thing it names, so
   the eye travels label to feature rather than the other way round. */
.ann-line {
   flex: none;
   width: 26px;
   height: 1px;
   background: var(--outline-stroke, #D8D2C2);
   position: relative
}

.ann-line::before {
   content: "";
   position: absolute;
   left: -3px;
   top: -2px;
   width: 5px;
   height: 5px;
   border-radius: 50%;
   background: var(--accent, #649F25)
}

.ann-note {
   flex: 1 1 auto;
   background: var(--parchment, #FDFBEF);
   border: 1px solid var(--outline-stroke, #D8D2C2);
   border-radius: 11px;
   padding: 6px 10px;
   font-family: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   font-weight: 500;
   font-size: 12px;
   line-height: 1.2;
   color: var(--dusty-granite, #5C5651);
   cursor: default;
   box-shadow: 0 2px 6px rgba(45, 40, 38, .10);
   transition: border-color .25s ease, color .25s ease
}

.ann-group.on .ann-note {
   border-color: var(--accent, #649F25);
   color: var(--accent, #649F25)
}

.ann-desc {
   margin: 0;
   padding-left: 32px;
   font-family: 'Diatype', 'Inter', system-ui, sans-serif;
   font-weight: 400;
   font-size: 12px;
   line-height: 1.35;
   color: var(--dusty-granite, #5C5651);
   opacity: 0;
   transition: opacity .28s ease .04s
}

.ann-group.on .ann-desc {
   opacity: 1
}

/* Until someone has pointed at one, the first label breathes. The reference build
   does the same to its toggle, for the same reason: an interaction nobody knows is
   there is an interaction nobody has. It stops for good on the first hover. */
.ann:not(.used) .ann-group:first-of-type .ann-note {
   animation: ann-nudge 2.6s ease-in-out infinite
}

@keyframes ann-nudge {
   0%, 72%, 100% {
      transform: translateX(0)
   }

   82% {
      transform: translateX(-4px)
   }

   92% {
      transform: translateX(0)
   }
}

@media (prefers-reduced-motion: reduce) {

   .ann-view img,
   .ann-group,
   .ann-desc {
      transition: none
   }

   .ann[data-hover] .ann-view img {
      transform: none
   }

   .ann:not(.used) .ann-group:first-of-type .ann-note {
      animation: none
   }
}

/* ---------------------------------------------------------------- H. page
   A whole page behind a window, scrolled, filtered, and finally looked at closely.

   The stage machine above holds a SCREEN: a capture the size of the window, in
   register with the other states of itself, with a camera over it. That is the
   right model for a screen you can see all of at once, and the wrong one for a
   list. A list's argument is its length, and its length is exactly the part that
   does not fit -- so a machine that only ever shows what fits has thrown the claim
   away before it starts.

   Here the window is fixed and the PAGE moves inside it, which is what a browser
   is. That buys three things the stage cannot do:

     - length reads as length. Thirty residence halls is a scroll, not a number in
       a caption;
     - the states are full pages of different heights rather than crops of one
       frame, so the grid can genuinely collapse -- seventeen, four, one -- instead
       of dissolving between three pictures of the same size;
     - the closing look can be at a part of the page the opening framing never
       showed, because the reel can carry it into view first. The camera and the
       scroll are two controls, and the payoff needs both: the switches that did
       the narrowing sit below the fold of the framing that shows the narrowing.

   One loop, in order: hold, scroll the whole list down, come back, then three
   filters land one after another and the grid falls away under them, then the
   camera goes in on the rail that did it and holds there long enough to read.
   The reset undoes the filters BEFORE the camera pulls out, so the wide framing at
   the end of a cycle is the same wide framing it started with rather than a jump
   cut on the way back. */
.cam-stage.page .shots .reel {
   position: absolute;
   left: 0;
   top: 0;
   width: 100%;
   will-change: transform
}

/* The pages hang from the top of the reel at the window's width and whatever
   height they are -- overriding the stage's inset:0/cover, which would squash a
   page four screens tall into one. Nothing is cropped and nothing is scaled to
   fit: the reel is the scroll position, and .shots is the fold. */
.cam-stage.page .shots img {
   position: absolute;
   inset: auto;
   left: 0;
   top: 0;
   width: 100%;
   height: auto;
   object-fit: fill
}

.is-in .cam-stage.page .shots .reel {
   animation: page-scroll var(--dur, 20s) cubic-bezier(.4, 0, .2, 1) infinite
}

/* --sy is the whole page, to the footer: the stop matters because a list that
   scrolls and then stops somewhere arbitrary reads as a long list, while one that
   scrolls and reaches the end reads as a list of a particular length. --sr is the
   small carry that brings the filter rail's lower half into the closing frame. */
@keyframes page-scroll {
   0%, 5% {
      transform: translateY(0)
   }

   27%, 34% {
      transform: translateY(var(--sy, 0px))
   }

   44%, 70% {
      transform: translateY(0)
   }

   77%, 90% {
      transform: translateY(var(--sr, 0px))
   }

   97%, 100% {
      transform: translateY(0)
   }
}

.is-in .cam-stage.page.moves .plate {
   animation: page-look var(--dur, 20s) cubic-bezier(.4, 0, .2, 1) infinite
}

/* Wide for the first two thirds -- the scroll and the whole narrowing happen at
   the framing where the grid is visible, because the grid collapsing IS the
   evidence and a close framing would crop it. Only then does the camera move, and
   only onto the cause. */
@keyframes page-look {
   0%, 70% {
      transform: translate(var(--x0), var(--y0)) scale(var(--k0))
   }

   77%, 90% {
      transform: translate(var(--x1), var(--y1)) scale(var(--k1))
   }

   97%, 100% {
      transform: translate(var(--x0), var(--y0)) scale(var(--k0))
   }
}

/* ---- one page, nothing landing on it --------------------------------------
   A page with no states to show has nothing to fill the long hold the sequence
   above needs between coming back up and going in, and four seconds of a still
   frame in the middle of a loop is not a pause, it is the figure appearing to
   have stopped working. So the solo timeline spends that budget on the two things
   it does have: a slower scroll, which is the one that has to be readable, and a
   longer hold on the close. Same shape, same order, no dead air. */
.is-in .cam-stage.page.solo .shots .reel {
   animation-name: page-scroll-solo
}

.is-in .cam-stage.page.solo.moves .plate {
   animation-name: page-look-solo
}

@keyframes page-scroll-solo {
   0%, 6% {
      transform: translateY(0)
   }

   36%, 43% {
      transform: translateY(var(--sy, 0px))
   }

   60%, 65% {
      transform: translateY(0)
   }

   72%, 88% {
      transform: translateY(var(--sr, 0px))
   }

   97%, 100% {
      transform: translateY(0)
   }
}

@keyframes page-look-solo {
   0%, 65% {
      transform: translate(var(--x0), var(--y0)) scale(var(--k0))
   }

   72%, 88% {
      transform: translate(var(--x1), var(--y1)) scale(var(--k1))
   }

   97%, 100% {
      transform: translate(var(--x0), var(--y0)) scale(var(--k0))
   }
}

/* ---- the filters, landing in order ---------------------------------------
   Each state rises over the one before and STAYS, so the figure accumulates
   rather than cycling: at 70% all three are on and the page below them is the
   single result. A shared window with a delay cannot do this -- a delay moves the
   end of the hold along with the start, so the earlier states would drop out from
   under the later ones and the count would climb back up mid-argument. Four fixed
   tracks, one per position, is less clever and actually correct.

   They all leave together at 90%, which is the reset: the filters come off, the
   list fills back in, and only then does the camera pull out. */
.is-in .cam-stage.page .reel img:nth-of-type(2) {
   animation: page-step-2 var(--dur, 20s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.page .reel img:nth-of-type(3) {
   animation: page-step-3 var(--dur, 20s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.page .reel img:nth-of-type(4) {
   animation: page-step-4 var(--dur, 20s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.page .reel img:nth-of-type(5) {
   animation: page-step-5 var(--dur, 20s) cubic-bezier(.4, 0, .2, 1) infinite
}

@keyframes page-step-2 {
   0%, 46% {
      opacity: 0
   }

   50%, 90% {
      opacity: 1
   }

   96%, 100% {
      opacity: 0
   }
}

@keyframes page-step-3 {
   0%, 54% {
      opacity: 0
   }

   58%, 90% {
      opacity: 1
   }

   96%, 100% {
      opacity: 0
   }
}

@keyframes page-step-4 {
   0%, 62% {
      opacity: 0
   }

   66%, 90% {
      opacity: 1
   }

   96%, 100% {
      opacity: 0
   }
}

@keyframes page-step-5 {
   0%, 70% {
      opacity: 0
   }

   74%, 90% {
      opacity: 1
   }

   96%, 100% {
      opacity: 0
   }
}

@media (prefers-reduced-motion: reduce) {

   /* The resting frame: the whole window, the unfiltered list, scrolled to the
      top. That is the state the page is actually in when you arrive at it, which
      is the only still here that is not a moment out of an argument. */
   .is-in .cam-stage.page .shots .reel,
   .is-in .cam-stage.page.moves .plate,
   .is-in .cam-stage.page .reel img {
      animation: none
   }

   .cam-stage.page .shots .reel {
      transform: translateY(0)
   }

   .cam-stage.page .reel img + img {
      opacity: 0
   }
}

/* ------------------------------------------------------------- H2. flow
   Two pages and the click between them: fill a form, press the button, land on
   the answer.

   The page machine above scrolls one page and swaps states of it in place, which
   is right for a list that narrows. A form is not that. A form has a before and
   an after, they are different pages, and the thing worth showing is the moment
   the first becomes the second -- so this machine is built around that moment
   rather than around a dissolve.

   Three things follow from taking a navigation seriously:

     - the swap is a CUT, not a fade. Two pages cross-fading reads as one page
       changing its mind; a page load is instantaneous and should look it. The
       reel jumps back to the top and the camera drops back to the wide framing in
       the same fifth of a second, because that is what a browser does;
     - the two pages are different HEIGHTS, so each has its own scroll stop. The
       second one's stop is defined as its own bottom, which is why it never shows
       the first page underneath it: at the end of its scroll the two edges are
       the same edge;
     - the press is real. A button that darkens is a hover; a button that moves is
       a press. So .press holds a crop of the button taken from the page itself,
       on a patch of the page's own background, and it is that crop which shrinks.
       Nothing is drawn twice and nothing has to be kept in sync by hand -- the
       crop is the same file at the same width, offset to the button. */
.cam-stage.page.flow .press {
   position: absolute;
   z-index: 2;
   overflow: hidden;
   background: var(--press-bg, #FBF8F4)
}

.cam-stage.page.flow .press i {
   position: absolute;
   display: block;
   background-repeat: no-repeat;
   transform-origin: 50% 50%;
   will-change: transform
}

/* An answer that has not been given yet: an unselected pill, carrying the
   selected one's own glyphs, laid exactly over it. It starts opaque and goes, so
   the form fills itself in and the state it ends in is the capture unmodified.
   Built in gen/quiz_picks.py -- see there for why the labels are lifted out of
   the crimson rather than typeset again. */
.cam-stage.page.flow .pick {
   position: absolute;
   z-index: 2;
   background-repeat: no-repeat
}

/* Linear, and not for the usual reason. The answers land on a schedule, and the
   schedule is only right if each pill is in the same place on the screen when its
   answer arrives. An eased scroll would put the middle four somewhere else, and
   the figure would look like it was clicking at nothing in particular. */
.is-in .cam-stage.page.flow .shots .reel {
   animation-name: flow-scroll;
   animation-timing-function: linear
}

.is-in .cam-stage.page.flow.moves .plate {
   animation-name: flow-look
}

.is-in .cam-stage.page.flow .reel img:nth-of-type(2) {
   animation-name: flow-load
}

.is-in .cam-stage.page.flow .press i {
   animation: flow-press var(--dur, 20s) cubic-bezier(.4, 0, .2, 1) infinite
}

/* The press overlay is a crop of the FIRST page's button, so it has to be gone
   the instant the second page arrives -- otherwise it sits on top of the new page
   painting the old state of the control over the new one. On the quiz that never
   showed, because the button there is far below the fold once the results load.
   On a view toggle in a header it showed immediately: you clicked Table, the page
   became the table, and the toggle still said Graph. */
.is-in .cam-stage.page.flow .press {
   animation: flow-hit var(--dur, 20s) linear infinite
}

@keyframes flow-hit {
   0%, 63% { opacity: 1 }
   64%, 100% { opacity: 0 }
}

.is-in .cam-stage.page.flow.still .press {
   animation-name: flow-hit-still
}

@keyframes flow-hit-still {
   0%, 36% { opacity: 1 }
   37%, 100% { opacity: 0 }
}

/* --sy is the form's own bottom, so the scroll ends with the button in frame
   rather than stopping somewhere that happens to be far enough. --sy2 is the
   answer's bottom. The 40-to-41 step is the navigation: one frame of the form at
   its foot, the next of the result at its head. */
@keyframes flow-scroll {
   0%, 7% {
      transform: translateY(0)
   }

   42%, 63% {
      transform: translateY(var(--sy, 0px))
   }

   64%, 69% {
      transform: translateY(0)
   }

   80%, 86% {
      transform: translateY(var(--sy2, 0px))
   }

   92%, 100% {
      transform: translateY(0)
   }
}

/* In on the button before the press, because a four-pixel movement at the resting
   framing is not a press, it is a rendering artefact. Out again on the load: a new
   page arrives at the framing a new page arrives at. */
@keyframes flow-look {
   0%, 46% {
      transform: translate(var(--x0), var(--y0)) scale(var(--k0))
   }

   51%, 63% {
      transform: translate(var(--x1), var(--y1)) scale(var(--k1))
   }

   64%, 100% {
      transform: translate(var(--x0), var(--y0)) scale(var(--k0))
   }
}

@keyframes flow-load {
   0%, 63% {
      opacity: 0
   }

   64%, 92% {
      opacity: 1
   }

   93%, 100% {
      opacity: 0
   }
}

/* Down, held, up. The hold is what separates a press from a flinch, and the
   brightness comes off with it because a pressed button on this site is darker as
   well as smaller. */
@keyframes flow-press {
   0%, 55% {
      transform: scale(1);
      filter: none
   }

   57%, 58% {
      transform: scale(.955);
      filter: brightness(.86)
   }

   60%, 100% {
      transform: scale(1);
      filter: none
   }
}

/* Six tracks rather than one with a delay, for the same reason the page machine
   needs six: a delay moves the END of a hold along with its start, so the answers
   would come off again in the order they went on. Each one goes and stays gone
   until the reset.

   The percentages are a schedule, not a layout, and they assume what a form
   almost always is: questions at a regular pitch, read at a steady scroll. These
   six land at the same height on screen to within a pixel. A form with uneven
   spacing would want its own numbers. */
.is-in .cam-stage.page.flow .pick:nth-of-type(1) {
   animation: flow-pick-1 var(--dur, 22s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.page.flow .pick:nth-of-type(2) {
   animation: flow-pick-2 var(--dur, 22s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.page.flow .pick:nth-of-type(3) {
   animation: flow-pick-3 var(--dur, 22s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.page.flow .pick:nth-of-type(4) {
   animation: flow-pick-4 var(--dur, 22s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.page.flow .pick:nth-of-type(5) {
   animation: flow-pick-5 var(--dur, 22s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.page.flow .pick:nth-of-type(6) {
   animation: flow-pick-6 var(--dur, 22s) cubic-bezier(.4, 0, .2, 1) infinite
}

@keyframes flow-pick-1 {
   0%, 4% { opacity: 1 }
   6%, 92% { opacity: 0 }
   93%, 100% { opacity: 1 }
}

@keyframes flow-pick-2 {
   0%, 11.5% { opacity: 1 }
   13.5%, 92% { opacity: 0 }
   93%, 100% { opacity: 1 }
}

@keyframes flow-pick-3 {
   0%, 17.4% { opacity: 1 }
   19.4%, 92% { opacity: 0 }
   93%, 100% { opacity: 1 }
}

@keyframes flow-pick-4 {
   0%, 23.3% { opacity: 1 }
   25.3%, 92% { opacity: 0 }
   93%, 100% { opacity: 1 }
}

@keyframes flow-pick-5 {
   0%, 29.1% { opacity: 1 }
   31.1%, 92% { opacity: 0 }
   93%, 100% { opacity: 1 }
}

@keyframes flow-pick-6 {
   0%, 35% { opacity: 1 }
   37%, 92% { opacity: 0 }
   93%, 100% { opacity: 1 }
}

@media (prefers-reduced-motion: reduce) {

   /* The form, at the top, unpressed. The answer is a state this figure argues
      its way to; showing it as the still would give away the only thing the
      figure has to say. */
   .is-in .cam-stage.page.flow .press i,
   .is-in .cam-stage.page.flow .pick {
      animation: none
   }

   .cam-stage.page.flow .pick {
      opacity: 1
   }

   .cam-stage.page.flow .press i {
      transform: scale(1)
   }
}

/* A clip in the window, on the same terms as a still: the plate's own size, the
   plate's own corners, and the camera over it if there is one. The site used to
   frame its screenshots and leave its recordings bare, which quietly said they
   were two different kinds of evidence. They are the same kind. */
.cam-stage .shots video {
   position: absolute;
   inset: 0;
   width: 100%;
   height: 100%;
   display: block;
   object-fit: cover
}

/* ---- more than two states -------------------------------------------------
   Two states of a screen cross-fade on one window: before and after, and the
   reader holds both in mind at once. Three or more is a different thing -- a
   progression -- and it needs to arrive in order and accumulate, the same way the
   page machine's filters do, for the same reason: a shared window with a delay
   moves the end of each hold along with its start, so the earlier states drop out
   from under the later ones and a sequence plays backwards at its own edges.

   Four fixed tracks. They all clear together at the end, so the loop restarts
   from the first state rather than dissolving through the middle of the run. */
.is-in .cam-stage.steps .shots img:nth-of-type(2) {
   animation: stage-step-2 var(--dur, 15s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.steps .shots img:nth-of-type(3) {
   animation: stage-step-3 var(--dur, 15s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.steps .shots img:nth-of-type(4) {
   animation: stage-step-4 var(--dur, 15s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.steps .shots img:nth-of-type(5) {
   animation: stage-step-5 var(--dur, 15s) cubic-bezier(.4, 0, .2, 1) infinite
}

@keyframes stage-step-2 {
   0%, 20% { opacity: 0 }
   25%, 84% { opacity: 1 }
   92%, 100% { opacity: 0 }
}

@keyframes stage-step-3 {
   0%, 40% { opacity: 0 }
   45%, 84% { opacity: 1 }
   92%, 100% { opacity: 0 }
}

@keyframes stage-step-4 {
   0%, 58% { opacity: 0 }
   63%, 84% { opacity: 1 }
   92%, 100% { opacity: 0 }
}

@keyframes stage-step-5 {
   0%, 72% { opacity: 0 }
   77%, 84% { opacity: 1 }
   92%, 100% { opacity: 0 }
}

@media (prefers-reduced-motion: reduce) {

   /* The first state, which for a progression is the one before anything has
      happened -- an empty grid rather than a finished island. The figure's whole
      claim is that the second was got from the first by rule, and a still of the
      second gives that away for nothing. */
   .is-in .cam-stage.steps .shots img {
      animation: none
   }
}

/* ------------------------------------------------------------- I. stepper
   The solver, one decision at a time, under the reader's hand.

   Every other machine in this file plays at the reader. This one waits. The
   difference matters here more than it would anywhere else on the site: the
   argument of the page is that the island is not drawn, it is DECIDED, one cell
   after another, each one following from what was already settled. A loop makes
   that a thing you watch. A click makes it a thing you do -- and the second is
   the only one that puts the reader in the position the algorithm is in.

   The frames were captured beforehand, out of a single run, chosen at equal
   increments of filled area so that every click is worth the same amount. That
   last part is what stops it feeling arbitrary: a stepper whose steps are uneven
   reads as a slideshow with a button, and the reader stops after two.

   It borrows the whole window from .cam-stage -- same frame, same chrome, same
   corners -- and adds only the control column and the state. */
.cam-step .shots img {
   opacity: 0;
   transition: opacity .22s cubic-bezier(.4, 0, .2, 1)
}

/* Without script this is a picture of a finished island, not of an empty grid.
   The last frame is the only one that stands on its own; the first one is the
   setup for a joke the reader would never get to hear. */
.cam-step .shots img:last-of-type {
   opacity: 1
}

.cam-step[data-live] .shots img:last-of-type {
   opacity: 0
}

.cam-step[data-live] .shots img.on {
   opacity: 1
}

.cam-step .view {
   cursor: pointer
}

/* The column the view gave up. Same arrangement as a callout's, because it is the
   same idea: the surface beside the window is not a gap, it is where the thing
   that talks about the window goes. */
.cam-step .step-ui {
   position: absolute;
   left: var(--cx);
   top: 50%;
   width: var(--cw, 178px);
   transform: translateY(-50%);
   display: flex;
   flex-direction: column;
   align-items: flex-start;
   gap: 10px
}

.cam-step .step-go {
   /* One line or two, the box is the same height. "Back to the empty board"
      wraps where "Place the next tiles" does not, and without this the counter
      and the track below it jump 16px on the last step -- which reads as the
      figure breaking at exactly the moment the reader finishes it. */
   display: flex;
   align-items: center;
   min-height: 45px;
   box-sizing: border-box;
   font-family: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   font-weight: 500;
   font-size: 12px;
   line-height: 1.2;
   text-align: left;
   color: var(--dusty-granite, #5C5651);
   background: var(--parchment, #FDFBEF);
   border: 1px solid var(--outline-stroke, #D8D2C2);
   border-radius: 11px;
   padding: 8px 12px;
   cursor: pointer;
   box-shadow: 0 2px 6px rgba(45, 40, 38, .10);
   transition: transform .12s ease, box-shadow .12s ease, background .12s ease
}

.cam-step .step-go:hover {
   background: #fff;
   box-shadow: 0 4px 10px rgba(45, 40, 38, .14)
}

.cam-step .step-go:active {
   transform: translateY(1px);
   box-shadow: 0 1px 3px rgba(45, 40, 38, .12)
}

.cam-step .step-note {
   margin: 0;
   font-family: 'Diatype', 'Inter', system-ui, sans-serif;
   font-size: 12px;
   line-height: 1.35;
   color: var(--dusty-granite, #5C5651)
}

/* The invitation, and it is spent the moment it is taken. Same rule the
   dashboard's hover ring follows: a prompt that keeps prompting after you have
   done the thing is not a prompt, it is a tic. */
.is-in .cam-step:not(.used) .step-go {
   animation: step-nudge 2.6s ease-in-out infinite
}

@keyframes step-nudge {

   0%, 62%, 100% {
      transform: translateY(0);
      box-shadow: 0 2px 6px rgba(45, 40, 38, .10)
   }

   72% {
      transform: translateY(-3px);
      box-shadow: 0 6px 14px rgba(45, 40, 38, .16)
   }

   82% {
      transform: translateY(0);
      box-shadow: 0 2px 6px rgba(45, 40, 38, .10)
   }
}

@media (prefers-reduced-motion: reduce) {

   .is-in .cam-step:not(.used) .step-go {
      animation: none
   }

   .cam-step .shots img {
      transition: none
   }
}

/* -------------------------------------------------------------- J. slider
   Two frames of one run, in exact register, with a line the reader drags.

   This only works because the capture it comes from does not move: same camera,
   same light, same board, and the only difference across the seam is how much has
   been decided. A wipe between two frames that do not line up is not a
   comparison, it is a glitch -- the eye reads the jump in the silhouette before it
   reads anything either half is saying.

   The handle and its two labels live in the CARD's coordinates rather than the
   plate's, because the plate is scaled down to fit and everything drawn inside it
   scales with it: a one-pixel rule would land at less than half a pixel and a
   label would go under six point. So the window's drawn rectangle is computed in
   gen/media.py and handed over as --wx/--wy/--ww/--wh, and the furniture is
   positioned against that. The clip itself stays in the plate's space, where a
   percentage means the same thing at either end. */
.cam-slide .shots img {
   opacity: 1
}

.cam-slide .shots img + img {
   opacity: 1;
   clip-path: inset(0 0 0 calc(var(--s, 50) * 1%))
}

/* The seam. Drawn over the screen only, not over the title bar, because a browser
   window whose chrome is cut in half is a broken window rather than a comparison. */
.cam-slide .slide-line {
   position: absolute;
   left: calc(var(--wx) + var(--ww) * var(--s, 50) / 100);
   top: var(--wy);
   height: var(--wh);
   width: 2px;
   margin-left: -1px;
   background: rgba(253, 251, 239, .92);
   box-shadow: 0 0 0 1px rgba(28, 24, 22, .16);
   pointer-events: none;
   z-index: 3
}

.cam-slide .slide-line i {
   position: absolute;
   left: 50%;
   top: 50%;
   width: 26px;
   height: 26px;
   margin: -13px 0 0 -13px;
   border-radius: 50%;
   background: var(--parchment, #FDFBEF);
   border: 1px solid var(--outline-stroke, #D8D2C2);
   box-shadow: 0 2px 8px rgba(45, 40, 38, .18)
}

/* Two arrows, drawn rather than typed, so the grip reads as draggable at the size
   it actually is. */
.cam-slide .slide-line i::before,
.cam-slide .slide-line i::after {
   content: "";
   position: absolute;
   top: 50%;
   width: 5px;
   height: 5px;
   margin-top: -3px;
   border: 1px solid var(--dusty-granite, #5C5651);
   border-width: 1px 1px 0 0
}

.cam-slide .slide-line i::before {
   left: 6px;
   transform: rotate(-135deg)
}

.cam-slide .slide-line i::after {
   right: 6px;
   transform: rotate(45deg)
}

/* The annotation: what each half IS, said at the size the reader is reading at. */
.cam-slide .slide-tag {
   position: absolute;
   /* Along the top, not the bottom. The app draws its own key legend into the
      bottom-left corner of the canvas, and a label sitting on top of another
      label is two things nobody reads. Sixteen pixels down clears the collapsed
      Controls tab on the other side. */
   top: calc(var(--wy) + 16px);
   /* Over the seam rather than under it: the line is furniture and the labels are
      the annotation, and at three quarters across the line was drawing straight
      through the middle of a word. */
   z-index: 5;
   padding: 4px 9px;
   border-radius: 999px;
   background: var(--parchment, #FDFBEF);
   border: 1px solid var(--outline-stroke, #D8D2C2);
   font-family: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   font-weight: 500;
   font-size: 11px;
   line-height: 1.2;
   color: var(--dusty-granite, #5C5651);
   box-shadow: 0 2px 6px rgba(45, 40, 38, .12);
   pointer-events: none
}

.cam-slide .slide-tag.left {
   left: calc(var(--wx) + 12px)
}

/* Anchored by its RIGHT edge, not by its left with a transform pulling it back.
   The transform moves the box after layout has already sized it, and layout sized
   it against the space between its left anchor and the card's edge -- fifty-eight
   pixels -- so a three-word label wrapped onto three lines inside a window barely
   a hundred tall. Anchoring the side that is actually fixed lets it size to its
   own text. */
.cam-slide .slide-tag.right {
   left: auto;
   right: calc(100% - var(--wx) - var(--ww) + 12px)
}

/* A range input is the control, laid over the window at zero opacity. It is not a
   shortcut: it is a slider, so it drags with a mouse, drags with a finger, moves
   with the arrow keys, takes focus in tab order and announces itself -- all of
   which a div with a pointermove handler would have had to be taught one at a
   time and would still have got wrong for somebody. */
.cam-slide .slide-range {
   position: absolute;
   left: var(--wx);
   top: var(--wy);
   width: var(--ww);
   height: var(--wh);
   margin: 0;
   padding: 0;
   opacity: 0;
   z-index: 4;
   cursor: ew-resize;
   -webkit-appearance: none;
   appearance: none;
   background: transparent
}

.cam-slide .slide-range::-webkit-slider-thumb {
   -webkit-appearance: none;
   width: 34px;
   height: 100%
}

.cam-slide .slide-range::-moz-range-thumb {
   width: 34px;
   height: 100%;
   border: 0
}

.cam-slide .slide-range:focus-visible ~ .slide-line i {
   outline: 2px solid var(--accent, #649F25);
   outline-offset: 2px
}

/* The invitation, spent on first use, exactly like the stepper's. */
.is-in .cam-slide:not(.used) .slide-line i {
   animation: slide-nudge 3s ease-in-out infinite
}

@keyframes slide-nudge {

   0%, 58%, 100% {
      transform: translateX(0)
   }

   70% {
      transform: translateX(-7px)
   }

   84% {
      transform: translateX(7px)
   }
}

@media (prefers-reduced-motion: reduce) {

   .is-in .cam-slide:not(.used) .slide-line i {
      animation: none
   }
}

/* ---- a first page that does not scroll ------------------------------------
   The ordinary flow spends a third of its loop scrolling the form to reach its
   button. Some controls are already on screen -- a view toggle in a header, say --
   and for those the scroll phase is a third of a loop in which nothing happens.

   Same beats, redistributed: hold, go in on the control, press it, cut, and then
   spend what was the form's scroll on the page that came back instead. */
.is-in .cam-stage.page.flow.still .shots .reel {
   animation-name: flow-scroll-still
}

.is-in .cam-stage.page.flow.still.moves .plate {
   animation-name: flow-look-still
}

.is-in .cam-stage.page.flow.still .reel img:nth-of-type(2) {
   animation-name: flow-load-still
}

.is-in .cam-stage.page.flow.still .press i {
   animation-name: flow-press-still
}

@keyframes flow-scroll-still {
   0%, 36% {
      transform: translateY(0)
   }

   37%, 44% {
      transform: translateY(0)
   }

   70%, 78% {
      transform: translateY(var(--sy2, 0px))
   }

   88%, 100% {
      transform: translateY(0)
   }
}

@keyframes flow-look-still {
   0%, 12% {
      transform: translate(var(--x0), var(--y0)) scale(var(--k0))
   }

   20%, 36% {
      transform: translate(var(--x1), var(--y1)) scale(var(--k1))
   }

   37%, 100% {
      transform: translate(var(--x0), var(--y0)) scale(var(--k0))
   }
}

@keyframes flow-load-still {
   0%, 36% {
      opacity: 0
   }

   37%, 88% {
      opacity: 1
   }

   89%, 100% {
      opacity: 0
   }
}

@keyframes flow-press-still {
   0%, 26% {
      transform: scale(1);
      filter: none
   }

   29%, 31% {
      transform: scale(.955);
      filter: brightness(.86)
   }

   34%, 100% {
      transform: scale(1);
      filter: none
   }
}

/* -------------------------------------------------------------- K. walk
   A path through an application, four screens deep, clicked out step by step.

   flow() above is this with two screens and one click. A drill-down is neither
   two screens nor one click: it is a route, and what a route has to show is that
   each step FOLLOWED from the last. Four stills side by side cannot say that --
   the reader has to work out what was pressed to get from one to the next, which
   is the one piece of information the figure exists to supply.

   So: click the card, cut, repeat. The camera does not move, and that is a choice
   rather than an omission -- the route IS the figure, and four zooms in and out
   of it turn a path into a series of destinations. The third screen scrolls,
   because what gets clicked on it is below the fold. The cuts are cuts for the
   same reason they are in flow: a page arriving is instantaneous and a cross-fade
   would say otherwise.

   Every press box is a rectangle in that screen's own pixels, read off the
   capture in gen/cases.py. There is no percentage anywhere in this machine, which
   is the whole reason it can be trusted to land on the card it says it is
   landing on. */
.cam-stage.page.cam-walk .press {
   opacity: 0
}

.is-in .cam-stage.page.cam-walk .shots .reel {
   animation: walk-scroll var(--dur, 28s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.page.cam-walk .reel img:nth-of-type(2) {
   animation: walk-page-2 var(--dur, 28s) linear infinite
}

.is-in .cam-stage.page.cam-walk .reel img:nth-of-type(3) {
   animation: walk-page-3 var(--dur, 28s) linear infinite
}

.is-in .cam-stage.page.cam-walk .reel img:nth-of-type(4) {
   animation: walk-page-4 var(--dur, 28s) linear infinite
}

.is-in .cam-stage.page.cam-walk .press.s1 { animation: walk-hit-1 var(--dur, 28s) linear infinite }
.is-in .cam-stage.page.cam-walk .press.s2 { animation: walk-hit-2 var(--dur, 28s) linear infinite }
.is-in .cam-stage.page.cam-walk .press.s3 { animation: walk-hit-3 var(--dur, 28s) linear infinite }

.is-in .cam-stage.page.cam-walk .press.s1 i {
   animation: walk-press-1 var(--dur, 28s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.page.cam-walk .press.s2 i {
   animation: walk-press-2 var(--dur, 28s) cubic-bezier(.4, 0, .2, 1) infinite
}

.is-in .cam-stage.page.cam-walk .press.s3 i {
   animation: walk-press-3 var(--dur, 28s) cubic-bezier(.4, 0, .2, 1) infinite
}

/* Only the third screen runs past its window, so only the third scrolls. The
   reel is back at the top by the time the fourth arrives, because a new page
   arrives at the top. */
@keyframes walk-scroll {
   0%, 47% { transform: translateY(0) }
   56%, 69% { transform: translateY(var(--sy3, 0px)) }
   70%, 100% { transform: translateY(0) }
}

/* Each screen arrives at its cut and stays. They come off together at the end,
   which puts the loop back on the first screen rather than dissolving through
   the middle of the route. */
@keyframes walk-page-2 {
   0%, 20% { opacity: 0 }
   21%, 96% { opacity: 1 }
   99%, 100% { opacity: 0 }
}

@keyframes walk-page-3 {
   0%, 41% { opacity: 0 }
   42%, 96% { opacity: 1 }
   99%, 100% { opacity: 0 }
}

@keyframes walk-page-4 {
   0%, 69% { opacity: 0 }
   70%, 96% { opacity: 1 }
   99%, 100% { opacity: 0 }
}

/* A press overlay is a patch of page with a crop of the target on it, so it can
   only be on screen while its own screen is. */
@keyframes walk-hit-1 {
   0%, 3% { opacity: 0 }
   4%, 20% { opacity: 1 }
   21%, 100% { opacity: 0 }
}

@keyframes walk-hit-2 {
   0%, 24% { opacity: 0 }
   25%, 41% { opacity: 1 }
   42%, 100% { opacity: 0 }
}

@keyframes walk-hit-3 {
   0%, 54% { opacity: 0 }
   55%, 69% { opacity: 1 }
   70%, 100% { opacity: 0 }
}

/* The click has to carry itself here. With no camera going in, a card is drawn at
   about forty-five pixels and the four-percent shrink that reads as a press up
   close is under two pixels -- which is not a press, it is a rendering artefact.
   So: a firmer squash, and a ring that leaves the card the way a tap ripple does.
   Two signals, because at this size either one alone is missable. */
.cam-stage.page.cam-walk .press i {
   border-radius: 12px
}

.cam-stage.page.cam-walk .press i::after {
   content: "";
   position: absolute;
   inset: -1px;
   border-radius: 13px;
   border: 5px solid var(--accent-deep, #4C7A1C);
   opacity: 0;
   pointer-events: none
}

.is-in .cam-stage.page.cam-walk .press.s1 i::after {
   animation: walk-ring-1 var(--dur, 28s) cubic-bezier(.2, .6, .3, 1) infinite
}

.is-in .cam-stage.page.cam-walk .press.s2 i::after {
   animation: walk-ring-2 var(--dur, 28s) cubic-bezier(.2, .6, .3, 1) infinite
}

.is-in .cam-stage.page.cam-walk .press.s3 i::after {
   animation: walk-ring-3 var(--dur, 28s) cubic-bezier(.2, .6, .3, 1) infinite
}

@keyframes walk-press-1 {
   0%, 15% { transform: scale(1); filter: none }
   16.5%, 17.5% { transform: scale(.93); filter: brightness(.9) }
   19%, 100% { transform: scale(1); filter: none }
}

@keyframes walk-press-2 {
   0%, 36% { transform: scale(1); filter: none }
   37.5%, 38.5% { transform: scale(.93); filter: brightness(.9) }
   40%, 100% { transform: scale(1); filter: none }
}

@keyframes walk-press-3 {
   0%, 65% { transform: scale(1); filter: none }
   66.5%, 67.5% { transform: scale(.93); filter: brightness(.9) }
   69%, 100% { transform: scale(1); filter: none }
}

@keyframes walk-ring-1 {
   0%, 16% { opacity: 0; transform: scale(1) }
   17.5% { opacity: 1; transform: scale(1) }
   20.5%, 100% { opacity: 0; transform: scale(1.22) }
}

@keyframes walk-ring-2 {
   0%, 37% { opacity: 0; transform: scale(1) }
   38.5% { opacity: 1; transform: scale(1) }
   41.5%, 100% { opacity: 0; transform: scale(1.22) }
}

@keyframes walk-ring-3 {
   0%, 66% { opacity: 0; transform: scale(1) }
   67.5% { opacity: 1; transform: scale(1) }
   70.5%, 100% { opacity: 0; transform: scale(1.22) }
}

@media (prefers-reduced-motion: reduce) {

   /* The first screen, at the top, nothing pressed. A route's still is where the
      route starts; any other frame is the middle of a sentence. */
   .is-in .cam-stage.page.cam-walk .shots .reel,
   .is-in .cam-stage.page.cam-walk.moves .plate,
   .is-in .cam-stage.page.cam-walk .reel img,
   .is-in .cam-stage.page.cam-walk .press,
   .is-in .cam-stage.page.cam-walk .press i,
   .is-in .cam-stage.page.cam-walk .press i::after {
      animation: none
   }
}


/* ---------------------------------------------------------- L. the cabinets
   Four alternatives in one window, switched by tabs in the title bar.

   The tab strip IS the chrome. Every other figure on this site draws three
   lights and an address bar because that is what says "screen"; this one draws
   three lights and four tabs, because that is what says "four screens, pick
   one", and the reader already knows how to read it.

   Only the selected clip is displayed and only the selected clip plays. The rest
   sit at opacity 0 with no pointer events, which is what lets the video elements
   stay in the DOM -- switching back is instant, and a browser that has already
   decoded the first frame does not have to fetch it again. */
.cam-clip .win > video,
.cab-win > video {
   background: var(--dove-ivory, #F6F2E6)
}

.cs-media.cab {
   background: var(--dove-ivory, #F5F2E5)
}

.cab-bar {
   position: absolute;
   left: 0;
   right: 0;
   top: 0;
   height: 34px;
   z-index: 5;
   display: flex;
   align-items: stretch;
   gap: 2px;
   padding-left: 58px;
   background: var(--dove-ivory, #F5F2E5);
   border-bottom: 1px solid var(--outline-stroke, #E3E1CC)
}

.cab-lights {
   position: absolute;
   left: 11px;
   top: 13px;
   display: flex;
   gap: 6px
}

.cab-lights i {
   width: 8px;
   height: 8px;
   border-radius: 50%;
   background: #E6685C
}

.cab-lights i:nth-child(2) {
   background: #E8B84B
}

.cab-lights i:nth-child(3) {
   background: #7DC26B
}

/* A tab, not a button: square-ish top corners, no shadow, and the selected one
   joins the panel below it by covering the border. */
.cab-tab {
   appearance: none;
   border: 0;
   background: transparent;
   margin: 4px 0 0;
   padding: 0 13px;
   border-radius: 7px 7px 0 0;
   font: 500 11.5px/1 'Thistle', ui-monospace, monospace;
   letter-spacing: .02em;
   color: var(--muted, #878676);
   cursor: pointer;
   white-space: nowrap;
   transition: background .18s ease, color .18s ease
}

.cab-tab:hover {
   background: rgba(0, 0, 0, .04);
   color: var(--ink, #423E3D)
}

.cab-tab.is-on {
   background: var(--parchment, #FDFBEF);
   color: var(--ink, #423E3D);
   box-shadow: 0 1px 0 0 var(--parchment, #FDFBEF)
}

.cab-tab:focus-visible {
   outline: 2px solid var(--accent-deep, #4C7A1C);
   outline-offset: -2px
}

.cab-win {
   position: absolute;
   left: 0;
   right: 0;
   top: 34px;
   bottom: 34px;
   overflow: hidden;
   background: var(--dove-ivory, #F6F2E6)
}

.cab-win > video {
   position: absolute;
   inset: 0;
   width: 100%;
   height: 100%;
   object-fit: contain;
   display: block;
   opacity: 0;
   pointer-events: none;
   transition: opacity .22s ease
}

.cs-media.cab[data-i="0"] > .cab-win > video[data-i="0"],
.cs-media.cab[data-i="1"] > .cab-win > video[data-i="1"],
.cs-media.cab[data-i="2"] > .cab-win > video[data-i="2"],
.cs-media.cab[data-i="3"] > .cab-win > video[data-i="3"] {
   opacity: 1
}

/* The line under the window is the tab's own caption. It changes with the tab,
   so the figure's caption underneath can stay about the four of them together. */
.cab-note {
   position: absolute;
   left: 14px;
   right: 14px;
   bottom: 0;
   height: 34px;
   margin: 0;
   display: flex;
   align-items: center;
   font: 400 11.5px/1.35 var(--sans, system-ui), sans-serif;
   color: var(--muted, #878676);
   border-top: 1px solid var(--outline-stroke, #E3E1CC)
}

/* ---------------------------------------------------- M. annotated specimen
   One drawing, several parts named at once.

   stage()'s callout names one thing and leaves the rest to prose. That is the
   wrong shape for an object whose argument is that it HAS parts: naming one of
   five decisions implies the other four were not decisions. So the specimen sits
   on the left and its parts are listed down the right, with a ring on the
   specimen for whichever line you are reading.

   The rings are positioned from rectangles measured in the plate's own pixels
   and converted in gen/media.py against the drawn size, which is the same rule
   the camera machines follow and for the same reason. */
.cam-ann {
   background: var(--dove-ivory, #F5F2E5)
}

.ann-shot {
   position: absolute;
   left: 14px;
   top: var(--anny, 14px);
   width: var(--annw, 452px);
   height: var(--annh, 363px);
   border-radius: 10px;
   overflow: hidden;
   background: var(--dove-ivory, #F5F2E5);
   border: 1px solid var(--outline-stroke, #E3E1CC)
}

.ann-shot > .bar {
   height: var(--bar, 22px)
}

/* The window needs its own box, not just a top offset.
   The base rule that gives .win its inset is .cs-media.framed > .win, and this
   .win is a child of .ann-shot -- which carries `framed` but is not .cs-media,
   so nothing matched. .win collapsed to zero height, the image's inset:0
   resolved against .ann-shot instead, and the plate was drawn over the title bar
   as well as under it. Every ring then sat a bar's height too low, which looked
   like a measuring error in the rectangles and was not. */
.ann-shot > .win {
   position: absolute;
   left: 0;
   right: 0;
   top: var(--bar, 22px);
   bottom: 0;
   overflow: hidden
}

.ann-shot .win > img {
   position: absolute;
   inset: 0;
   width: 100%;
   height: 100%;
   object-fit: contain
}

.ann-ring {
   position: absolute;
   left: var(--rx);
   top: var(--ry);
   width: var(--rw);
   height: var(--rh);
   z-index: 4;
   border: 2px solid var(--accent-deep, #4C7A1C);
   border-radius: 6px;
   background: rgba(76, 122, 28, .09);
   opacity: 0;
   transform: scale(1.06);
   transition: opacity .2s ease, transform .2s cubic-bezier(.3, 1.3, .4, 1);
   pointer-events: none
}

.cam-ann[data-i="0"] .ann-ring[data-i="0"],
.cam-ann[data-i="1"] .ann-ring[data-i="1"],
.cam-ann[data-i="2"] .ann-ring[data-i="2"],
.cam-ann[data-i="3"] .ann-ring[data-i="3"],
.cam-ann[data-i="4"] .ann-ring[data-i="4"],
.cam-ann[data-i="5"] .ann-ring[data-i="5"] {
   opacity: 1;
   transform: scale(1)
}

.ann-list {
   position: absolute;
   left: calc(14px + var(--annw, 452px) + 20px);
   right: 14px;
   top: 14px;
   bottom: 14px;
   margin: 0;
   padding: 0;
   list-style: none;
   display: flex;
   flex-direction: column;
   justify-content: center;
   gap: 2px
}

.ann-item {
   position: relative;
   padding: 4px 6px 4px 18px;
   border-radius: 7px;
   cursor: default;
   opacity: .5;
   transition: opacity .2s ease, background .2s ease
}

.ann-item.is-on {
   opacity: 1;
   background: rgba(0, 0, 0, .035)
}

.ann-item:focus-visible {
   outline: 2px solid var(--accent-deep, #4C7A1C);
   outline-offset: 0
}

.ann-dot {
   position: absolute;
   left: 6px;
   top: 11px;
   width: 6px;
   height: 6px;
   border-radius: 50%;
   background: var(--outline-stroke, #C9C6AE);
   transition: background .2s ease, transform .2s ease
}

.ann-item.is-on .ann-dot {
   background: var(--accent-deep, #4C7A1C);
   transform: scale(1.25)
}

.ann-item b {
   display: block;
   font: 500 11.5px/1.3 'Thistle', ui-monospace, monospace;
   color: var(--ink, #423E3D)
}

.ann-item p {
   margin: 1px 0 0;
   font: 400 10.5px/1.35 var(--sans, system-ui), sans-serif;
   color: var(--muted, #878676)
}

/* ------------------------------------------------------------- N. the ticks
   Two clocks racing.

   The lesson is a number -- physics stepped once per frame at 60Hz runs 20 per
   cent fast against a loop written for a 50Hz tick -- and a number in a sentence
   is a thing the reader either believes or does not. Two bars leaving together
   and arriving a fifth apart is the same claim as an event.

   --ratio is the only number here, it comes from the two frequencies, and both
   lanes are driven by it: the fast lane finishes in 1/ratio of the time. Nothing
   is keyframed to a figure typed by hand, so the picture cannot disagree with
   the caption. */
.cam-tick {
   background: var(--dove-ivory, #F5F2E5)
}

.tick-lane {
   position: absolute;
   left: 26px;
   right: 26px;
   height: 74px;
   display: flex;
   align-items: center;
   gap: 16px
}

.tick-lane.fast {
   top: 74px
}

.tick-lane.slow {
   top: 176px
}

.tick-cap {
   width: 178px;
   flex: none;
   font: 500 11.5px/1.35 'Thistle', ui-monospace, monospace;
   color: var(--ink, #423E3D)
}

.tick-cap i {
   display: block;
   font-style: normal;
   font-size: 10.5px;
   color: var(--muted, #878676)
}

.tick-track {
   position: relative;
   flex: 1;
   height: 26px;
   border-radius: 13px;
   background: rgba(0, 0, 0, .05);
   overflow: visible
}

.tick-fill {
   position: absolute;
   left: 0;
   top: 0;
   bottom: 0;
   width: 0;
   border-radius: 13px;
   background: var(--accent-deep, #4C7A1C);
   opacity: .18
}

.tick-fish {
   position: absolute;
   top: 3px;
   left: 0;
   width: 20px;
   height: 20px;
   margin-left: -10px;
   border-radius: 50%;
   background: var(--accent-deep, #4C7A1C)
}

/* The run only plays when the figure is on screen, and then once per press.
   A race that restarts every time it scrolls past is wallpaper. */
.cam-tick.is-run .fast .tick-fill {
   animation: tick-fill calc(6s / var(--ratio)) cubic-bezier(.35, 0, .25, 1) forwards
}

.cam-tick.is-run .fast .tick-fish {
   animation: tick-fish calc(6s / var(--ratio)) cubic-bezier(.35, 0, .25, 1) forwards
}

.cam-tick.is-run .slow .tick-fill {
   animation: tick-fill 6s cubic-bezier(.35, 0, .25, 1) forwards
}

.cam-tick.is-run .slow .tick-fish {
   animation: tick-fish 6s cubic-bezier(.35, 0, .25, 1) forwards
}

@keyframes tick-fill {
   from {
      width: 0
   }

   to {
      width: 100%
   }
}

@keyframes tick-fish {
   from {
      left: 0
   }

   to {
      left: 100%
   }
}

/* The finish line, and the distance between the two arrivals. */
.tick-line {
   position: absolute;
   right: 26px;
   top: 74px;
   height: 176px;
   border-left: 2px dashed var(--outline-stroke, #C9C6AE)
}

.tick-gap {
   position: absolute;
   right: 26px;
   top: 258px;
   font: 500 12px/1 'Thistle', ui-monospace, monospace;
   color: var(--accent-deep, #4C7A1C)
}

.tick-go {
   position: absolute;
   left: 26px;
   bottom: 26px;
   appearance: none;
   border: 1px solid var(--outline-stroke, #E3E1CC);
   background: var(--parchment, #FDFBEF);
   border-radius: 999px;
   padding: 7px 16px;
   font: 500 11.5px/1 'Thistle', ui-monospace, monospace;
   color: var(--ink, #423E3D);
   cursor: pointer
}

.tick-go:hover {
   border-color: var(--accent-deep, #4C7A1C);
   color: var(--accent-deep, #4C7A1C)
}

.tick-note {
   position: absolute;
   left: 160px;
   right: 26px;
   bottom: 28px;
   margin: 0;
   text-align: right;
   font: 400 11px/1.4 var(--sans, system-ui), sans-serif;
   color: var(--muted, #878676)
}

@media (prefers-reduced-motion: reduce) {

   .cam-tick.is-run .tick-fill,
   .cam-tick.is-run .tick-fish {
      animation-duration: .01s !important
   }

   .ann-ring {
      transition: none
   }
}


/* ------------------------------------------------ O. the floated window
   A clip is an object on a surface, like every plate on this site, rather than a
   thing that fills the card to its edges.

   This was the whole stylistic gap between the game pages and the app pages: the
   plate machines float their capture with air and a shadow, and clip() made the
   card itself the window. Two ideas about what a figure is, on one site, with
   nothing to tell the reader why.

   --wx/--wy/--ww/--wh are computed in gen/media.py from the recording's delivered
   dimensions. Nothing here is a percentage, so a re-cut at a different crop moves
   the frame instead of letterboxing inside a box sized for the old one. */
.cs-media.framed.cam-clip,
.cs-media.cab {
   background: var(--dove-ivory, #F5F2E5)
}

/* The shadow is its own layer behind both halves of the window. Put on either
   half it draws a line across the seam where the two meet. */
.cs-media.framed.cam-clip::before {
   content: "";
   position: absolute;
   left: var(--wx);
   top: var(--wy);
   width: var(--ww);
   height: var(--wh);
   border-radius: 11px;
   box-shadow: 0 14px 30px rgba(45, 40, 38, .15);
   z-index: 0
}

.cs-media.framed.cam-clip > .bar {
   left: var(--wx);
   right: auto;
   top: var(--wy);
   width: var(--ww);
   height: var(--bar, 24px);
   border-radius: 11px 11px 0 0;
   border: 1px solid var(--outline-stroke, #E3E1CC);
   border-bottom-color: var(--outline-stroke, #E3E1CC);
   z-index: 2
}

.cs-media.framed.cam-clip > .bar i {
   top: 8px;
   width: 7px;
   height: 7px
}

.cs-media.framed.cam-clip > .bar i:nth-of-type(1) { left: 10px }
.cs-media.framed.cam-clip > .bar i:nth-of-type(2) { left: 22px }
.cs-media.framed.cam-clip > .bar i:nth-of-type(3) { left: 34px }

.cs-media.framed.cam-clip > .bar b {
   left: 50px;
   right: 12px;
   top: 5px;
   height: 13px
}

.cs-media.framed.cam-clip > .win {
   left: var(--wx);
   right: auto;
   top: calc(var(--wy) + var(--bar, 24px));
   bottom: auto;
   width: var(--ww);
   height: calc(var(--wh) - var(--bar, 24px));
   border-radius: 0 0 11px 11px;
   border: 1px solid var(--outline-stroke, #E3E1CC);
   border-top: 0;
   z-index: 2
}

/* The cabinets float on the same rule. Their bar is taller because it carries
   four tabs, and the caption line sits under the window on the ivory rather than
   inside the frame -- it describes the tab, not the screen. */
.cs-media.cab::before {
   content: "";
   position: absolute;
   left: var(--wx);
   top: var(--wy);
   width: var(--ww);
   height: var(--wh);
   border-radius: 11px;
   box-shadow: 0 14px 30px rgba(45, 40, 38, .15);
   z-index: 0
}

.cs-media.cab > .cab-bar {
   left: var(--wx);
   right: auto;
   top: var(--wy);
   width: var(--ww);
   height: var(--bar, 32px);
   border-radius: 11px 11px 0 0;
   border: 1px solid var(--outline-stroke, #E3E1CC);
   border-bottom: 1px solid var(--outline-stroke, #E3E1CC);
   z-index: 2
}

.cs-media.cab > .cab-win {
   left: var(--wx);
   right: auto;
   top: calc(var(--wy) + var(--bar, 32px));
   bottom: auto;
   width: var(--ww);
   height: calc(var(--wh) - var(--bar, 32px));
   border-radius: 0 0 11px 11px;
   border: 1px solid var(--outline-stroke, #E3E1CC);
   border-top: 0;
   z-index: 2
}

.cs-media.cab > .cab-note {
   left: var(--wx);
   right: auto;
   width: var(--ww);
   bottom: 10px;
   height: auto;
   border-top: 0;
   justify-content: center;
   text-align: center
}
