/* presser.css — the anonymous surface, on top of knocklet.css.
   The palette, the faces, the pill and the column come from there; everything here names an
   element or a state that exists only on this page.

   The shape, top to bottom: the room's name as a label rather than a headline, the state as the
   headline, the disc, and then whatever the state allows — a note field, the way to take a press
   back, the presses this browser made, the way out. One decision on the screen at a time, and the
   same page on a laptop, only wider.

   Two mechanisms decide what is visible and both of them only ever hide: the state rules below
   say what the state allows, and the `hidden` attribute says what the view actually carried.
   Nothing here un-hides, so a page whose script never ran shows chrome instead of empty labels —
   and a reviewer can set data-state by hand and read all eleven states with no server behind
   them. app.js owns `hidden` on .room, #cancel, #note-field and #history and never touches the
   rest. */

.page--presser {
  --edge: var(--gutter);                            /* the page's own inline padding, said once */
  --disc: 208px;                                    /* THE BUTTON, at the phone size */
  /* The ripple field. The arcs are drawn at 0.3 of it, so disc / 0.6 puts the first front on the
     disc's rim, which is the point a knock has to come out of. 88vw is the cap that keeps a
     narrow phone from cropping the fronts that expand past it. */
  --stage: min(88vw, calc(var(--disc) / .6));
}

/* ── The room ───────────────────────────────────────────────────────────── */

/* A label, not a headline: the state is the headline, and the room is what the state is about.
   The hue is the owner's identity and this is the one place on the page it appears. */
.room { display: flex; flex-wrap: wrap; align-items: center; justify-content: center;
        gap: var(--sp-2); margin-block-start: var(--sp-4); }
.room-swatch { inline-size: .5rem; block-size: .5rem; border-radius: 50%; }
.room-label {
  margin: 0;
  font: 500 var(--t-caption)/1.3 var(--face);
  letter-spacing: .12em; text-transform: uppercase;
  color: var(--ink2); text-align: center;
}
.room-window { flex-basis: 100%; margin: 0; text-align: center;
               font: var(--t-footnote)/1.4 var(--face); color: var(--ink3); }

/* ── The state, the disc, and what hangs off it ─────────────────────────── */

.knock-block { display: flex; flex-direction: column; align-items: center; }

/* Headline and sentence, above the disc where they read before it is pressed. The floor is two
   lines of sentence under one line of headline, so an answer arriving does not move the disc. */
.knock-status { margin: var(--sp-5) 0 var(--sp-5); min-block-size: 5.5rem; text-align: center; }
.state-word { display: block; font: 400 var(--t-title1)/1.2 var(--face);
              letter-spacing: -.015em; color: var(--ink); }
.state-sub {
  display: block; max-inline-size: 42ch; margin: var(--sp-2) auto 0;
  font: 400 var(--t-callout)/1.5 var(--face); color: var(--ink2); text-wrap: pretty;
}
[data-state="waiting"]      .state-word { color: var(--accent); }
[data-state="acknowledged"] .state-word { color: var(--ack); }

.stage { position: relative; width: var(--stage); height: var(--stage);
         margin-inline: auto; display: grid; place-items: center; flex: none; }

.knock {
  position: relative; z-index: 1;
  /* Said out loud because a button is content-box in Chrome and Safari and border-box in
     Firefox: unstated, the padding lands outside the disc on two engines out of three and the
     measurement the stage is sized against holds on neither. */
  box-sizing: border-box;
  inline-size: var(--disc); block-size: var(--disc);
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--sp-1);
  border-radius: 50%;
  border: var(--hair) solid var(--btn-line);
  padding: var(--sp-5);
  background: var(--btn-bg);
  color: var(--btn-fg);
  font: 500 clamp(var(--t-callout), calc(var(--disc) * .105), var(--t-title2))/1.2 var(--face);
  letter-spacing: -.01em;
  text-align: center;
  overflow-wrap: anywhere;
  cursor: pointer;
  touch-action: manipulation;            /* kills the 300ms double-tap wait, keeps pinch-zoom */
  -webkit-tap-highlight-color: transparent;
  appearance: none;
  transition: background-color 250ms ease, color 250ms ease, border-color 450ms ease,
              box-shadow 450ms cubic-bezier(.25,.46,.45,.94), opacity 90ms linear;
}

/* Press: opacity only. No scale — that is a micro-animation and the motion budget is the
   ripple and the settle. KnockButtonStyle uses 0.82. */
.knock:active { opacity: .82; }
.knock:focus-visible { outline: 2px solid var(--switch-on); outline-offset: 3px; }

/* The figure inside the disc: how long this has been going on, in the mono face, and never in
   seconds. The button carries its own aria-label, so this is a figure to look at — the label
   already names the time it started. */
.knock__figure {
  font: 400 var(--t-footnote)/1.3 var(--face-mono);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0;
}

/* WAITING — the warm ground, a brass rim and a brass word, arcs live behind it. */
[data-state="waiting"] .knock {
  background: var(--wash); color: var(--accent);
  border-color: var(--accent); cursor: default;
}

/* ACKNOWLEDGED — the settle lands here; the rim cools brass to slate blue over 450ms. */
[data-state="acknowledged"] .knock {
  background: var(--surface); color: var(--ack);
  border-color: var(--ack); cursor: default;
}
[data-state="waiting"] .knock__figure, [data-state="acknowledged"] .knock__figure { color: var(--ink2); }

/* CLOSED — the same disc, spent. Outlined rather than filled, because the press it is offering
   is a second one and the sentence above it has already said the first is over. */
[data-state="closed"] .knock {
  background: none; color: var(--ink2); border-color: var(--line);
}

/* Asleep: outside the window, switched off, or held after a 429. A dashed rim, no fill, and the
   tertiary mark — below the text floor and allowed, because an inactive control is exempt and the
   sentence that carries the state is not in here. */
[data-state="notYet"] .knock,
[data-state="over"]   .knock,
[data-state="off"]    .knock,
[data-held="true"]    .knock {
  background: none; color: var(--ink3);
  border-style: dashed; border-color: var(--line); cursor: default;
}

/* Taking the press back, and the note this browser sent with it. Both belong to the disc rather
   than to the page, so they sit inside its block and move with it. */
#cancel { margin-block-start: var(--sp-5); }
.said {
  margin: var(--sp-4) 0 0; max-inline-size: 42ch;
  font: var(--t-callout)/1.5 var(--face); color: var(--ink2); text-align: center;
  text-wrap: pretty;
}

/* The owner's word back, drawn only on `acknowledged`. It carries the ack's own hue rather than
   the note's grey: this is the one line on the page that came from the other side of the door. */
.reply {
  margin: var(--sp-4) 0 0; max-inline-size: 42ch;
  font: var(--t-callout)/1.5 var(--face); color: var(--ack); text-align: center;
  text-wrap: pretty;
}

/* Held, offline, back online, a press that did not go through: the sentence that composes onto
   a render rather than replacing it. Its own polite region, so a repaint of the state does not
   read it out again. */
#notice {
  margin: var(--sp-3) 0 0; max-inline-size: 42ch;
  font: var(--t-subheadline)/1.45 var(--face);
  color: var(--ink2);
  text-align: center;
}

/* ── What each state puts on screen ─────────────────────────────────────── */

:is([data-state="boot"], [data-state="code"], [data-state="ended"]) .stage { display: none; }

/* off arrives bare — no label, no colour, no window — because the owner shut the room off and
   re-serving what it was called defeats that. The page discards what it was holding. */
:is([data-state="boot"], [data-state="code"], [data-state="ended"], [data-state="off"]) .room { display: none; }

/* Two headings is one too many. The room is the heading once there is a room; the wordmark is
   the heading when there is not, which includes `off`, where the label is withheld and there is
   nothing to put in its place. */
.page:not(:is([data-state="boot"], [data-state="code"], [data-state="ended"], [data-state="off"]))
  .room:not([hidden]) ~ .wordmark { display: none; }

:is([data-state="boot"], [data-state="code"], [data-state="ended"],
    [data-state="off"], [data-state="notYet"], [data-state="over"]) #history { display: none; }

/* The code field is the entry screen and the exit screen, and nowhere in between. */
.page:not(:is([data-state="code"], [data-state="ended"])) #code-form { display: none; }

/* A press can only be taken back while nobody has answered it. Everywhere else the server would
   refuse, and a pill that 409s is a promise the page cannot keep. */
.page:not([data-state="waiting"]) #cancel { display: none; }

/* Leaving does not close a ring, so the way out is hidden while one is open: the owner would
   keep being pushed about someone who has gone. A note cannot be attached to an open ring
   either — a re-press is deduped and the second note is discarded — so the field goes with it. */
.page:not(:is([data-state="ready"], [data-state="closed"], [data-state="sent"]))
  :is(#leave, #note-field) { display: none; }

.room-window:empty, #elapsed:empty, #said:empty, #reply:empty, #notice:empty, #note-count:empty { display: none; }

/* ── The knock ripple ───────────────────────────────────────────────────── */

.ripple { position: absolute; inset: 0; width: 100%; height: 100%;
          pointer-events: none; overflow: visible; }
.wave {
  fill: none;
  stroke: var(--accent);
  stroke-linecap: round;
  /* fill-box, not view-box: Chrome resolves a percentage origin against the viewBox's size with
     its min-x and min-y dropped, so `50% 50%` on a -50 -50 100 100 box lands on user (50,50) —
     the bottom-right corner — and every front expands up and to the left off the screen. The two
     arcs are symmetric about the user-space origin, so their own box centres on the disc, which
     is the point a knock has to come out of. */
  transform-box: fill-box;
  transform-origin: 50% 50%;
  transform: scale(1);
  opacity: 0;
  stroke-width: 4.4;
  transition: opacity 450ms cubic-bezier(.25,.46,.45,.94),
              transform 450ms cubic-bezier(.25,.46,.45,.94);
}

/* Someone is waiting. Three fronts, one 2.5s cycle, a third of a cycle apart. */
[data-state="waiting"] .wave { animation: knock-wave 2.5s linear infinite; }
[data-state="waiting"] .wave:nth-child(2) { animation-delay: -.8333s; }
[data-state="waiting"] .wave:nth-child(3) { animation-delay: -1.6667s; }

@keyframes knock-wave {
  0%   { transform: scale(1);     opacity: 0;    stroke-width: 4.40; }
  18%  { transform: scale(1.236); opacity: .417; stroke-width: 2.78; }
  40%  { transform: scale(1.450); opacity: .269; stroke-width: 1.77; }
  60%  { transform: scale(1.578); opacity: .153; stroke-width: 1.29; }
  80%  { transform: scale(1.648); opacity: .058; stroke-width: 1.07; }
  100% { transform: scale(1.667); opacity: 0;    stroke-width: 1.01; }
}

/* The held frame, phases 0.30 / 0.60 / 0.90. Not a degradation: it is literally the app icon,
   which is why it is a first-class state. `sent` wears it permanently — a press landed in a
   room that does not report back, so the knock is out there and the page claims nothing about
   what happened next. `waiting` wears it only under reduce motion. */
[data-state="sent"] .wave:nth-child(1) { transform: scale(1.363); opacity: .334; stroke-width: 2.14; }
[data-state="sent"] .wave:nth-child(2) { transform: scale(1.578); opacity: .153; stroke-width: 1.29; }
[data-state="sent"] .wave:nth-child(3) { transform: scale(1.663); opacity: .022; stroke-width: 1.01; }

@media (prefers-reduced-motion: reduce) {
  .knock, .wave { transition-duration: 250ms; }   /* Apple's substitution: fade, don't remove */
  [data-state="waiting"] .wave { animation: none; }
  [data-state="waiting"] .wave:nth-child(1) { transform: scale(1.363); opacity: .334; stroke-width: 2.14; }
  [data-state="waiting"] .wave:nth-child(2) { transform: scale(1.578); opacity: .153; stroke-width: 1.29; }
  [data-state="waiting"] .wave:nth-child(3) { transform: scale(1.663); opacity: .022; stroke-width: 1.01; }
}

/* ── The code field, the presses behind you, the way out ────────────────── */

#code-form { display: flex; flex-direction: column; }
#code-form .btn { align-self: center; margin-block-start: var(--sp-4); }

/* Own-ring history. A time and a word, no badge, no dot, no count. */
.history { inline-size: 100%; max-inline-size: 24rem; margin-inline: auto; }
.history ul { list-style: none; margin: 0; padding: 0; }
.history li {
  display: flex; justify-content: space-between; gap: var(--sp-3);
  padding: var(--sp-2) 0; border-block-start: var(--hair) solid var(--hairline);
  font-size: var(--t-subheadline);
}
.history .when { font-family: var(--face-mono); font-variant-numeric: tabular-nums; color: var(--ink2); }
.history .note { flex: 1 1 auto; color: var(--ink2); overflow-wrap: anywhere; }
.history .verb { font-weight: 500; color: var(--ink2); }
.history .verb--waiting      { color: var(--accent); }
.history .verb--acknowledged { color: var(--ack); }

#leave { align-self: center; }

/* The scheme picker, in the small print above the guard line: present in every state, and quiet
   enough that the disc stays the only thing this page asks about. */
.theme--floor { align-self: center; margin-block-start: var(--sp-2); }
/* The credit, dressed like the foot's company line: legible to whoever wonders, invisible to
   whoever doesn't. */
.powered { align-self: center; margin: 0; font: var(--t-footnote)/1.5 var(--face); }
.powered a { color: var(--ink3); }
.powered a:hover { color: var(--ink2); }
#note-field { inline-size: 100%; max-inline-size: 24rem; margin-inline: auto; }

/* ── The other two sizes ────────────────────────────────────────────────── */

/* Wide enough for the column to become a card. knocklet.css does the card; the presser opens up
   to the width the mock draws it at and the disc grows with it. */
@media (min-width: 48rem) {
  .page--presser {
    --edge: var(--sp-8);
    --disc: 248px;
    max-width: 42.5rem;
    padding-inline: var(--edge);
    background: var(--bg);
  }
}

/* 390px at 200% zoom lands here; 320px at 100% does not. The disc label moves out of the disc
   and the disc becomes a pure target. */
@media (max-width: 18rem) {
  .knock__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
  .state-word { font-size: var(--t-title3); }
}

/* A 568pt phone is 276pt shorter than the modal one and has the same blocks to fit, so the disc
   is sized off the height it has as well as its own figure. The rhythm tightens with it, and the
   guard line pays for the difference: on the states carrying the most blocks it falls a scroll
   below the fold. It still renders in every state, which the render, no-hide and DOM-order tests
   hold it to, and bin/shoot.mjs photographs the foot of the page on every short viewport so the
   trade is looked at rather than assumed. */
@media (max-height: 40rem) {
  .page--presser { --disc: min(208px, 30svh); --flow: var(--sp-2); }
  .page--presser { padding-block: var(--sp-3) calc(var(--sp-3) + env(safe-area-inset-bottom)); }
  .knock-status { margin-block: var(--sp-4); min-block-size: 4.5rem; }
}

@media (forced-colors: active) {
  .knock { forced-color-adjust: none; background: Canvas; color: CanvasText;
           border-color: CanvasText; }
  [data-state="ready"] .knock, [data-state="closed"] .knock, [data-state="sent"] .knock {
    background: ButtonFace; color: ButtonText; }
  .wave { stroke: CanvasText; }
}
