/* The Hoverlet web client.
 *
 * Every colour, size, radius and duration here comes from a custom property in
 * tokens.css. A literal colour in this file is a bug: it is a value the panel
 * (hoverlet/internal/ui/designtokens.go) cannot see.
 */

/* ---------------------------------------------------------------- base */

/* A registered custom property, so --cs is a NUMBER the engine can interpolate
   rather than an opaque token it can only swap. Without the registration the
   transition on `.stage .card.settling` is a silent no-op and the opened card's
   type jumps to its new size while the frame is still moving. */
@property --cs {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  background: var(--c-canvas);
  color: var(--c-ink);
  font-family: var(--font-sans);
  font-size: var(--t-body);
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
}

body {
  /* Installed, the app runs under the status bar — that is what
     viewport-fit=cover and apple-mobile-web-app-status-bar-style buy. */
  padding: env(safe-area-inset-top) env(safe-area-inset-right)
           env(safe-area-inset-bottom) env(safe-area-inset-left);
  overscroll-behavior-y: none;
}

button, input, textarea, select { font: inherit; color: inherit; }
button { cursor: pointer; }
:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }

/* Figures, not a face. Rubik carries `tnum`, so a column of numbers lines up
   in the one family — which is the whole reason monospace stopped being a UI
   face here. `code.cmd` below is the exception, and it is the rule: a shell
   command a person pastes exactly. */
.mono { font-variant-numeric: tabular-nums; font-weight: var(--w-medium); }
.num { font-variant-numeric: tabular-nums; }

/* ---------------------------------------------------------------- shell */

#root {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

/* The shell does not move, ever. It used to translate by a `--ui-shift` while a
   widget was open, so that the board and the conversation were centred as a
   pair; what that reads as is the whole page jumping sideways every time a card
   is opened. The card is the only thing that travels now. */
.shell {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
  /* How wide the BOARD came out, written here by app.js from what the board
     measured. The app's own line and the "+" stand on it — see `.topbar`. The
     fallback is a phone's, which is also what a board that has not drawn yet
     will be. */
  --measure: var(--board-max);
}

.topbar {
  position: sticky;
  top: 0;
  z-index: 4;
  display: flex;
  align-items: center;
  gap: var(--s-lg);
  padding: var(--s-lg) var(--s-xl);
  /* …and on a board narrower than the window, the BOARD's own measure: its
     first column on the left, its last on the right, wherever the board happens
     to be centred. It is the rule the opened widget's controls already follow —
     held to the thing they belong to rather than to the window's far edges.
     A phone changes nothing, because there the board fills the screen. A 1100px
     window is where it shows: the board is a 480px column with three hundred
     pixels of canvas either side, and the app's name sat out in that canvas
     with nothing underneath it. */
  padding-inline: max(var(--s-xl), calc((100% - var(--measure)) / 2 + var(--board-pad)));
  background: color-mix(in srgb, var(--c-canvas) 88%, transparent);
  backdrop-filter: blur(var(--blur-board));
  -webkit-backdrop-filter: blur(var(--blur-board));
  border-bottom: 1px solid transparent;
}
.topbar.scrolled { border-bottom-color: var(--c-line); }

.brand {
  display: flex;
  align-items: center;
  gap: var(--s-md);
  font-size: var(--t-title);
  font-weight: var(--w-semibold);
  letter-spacing: var(--track-title);
  margin-right: auto;
}
.brand img { width: 24px; height: 24px; border-radius: var(--s-md); display: block; }

/* The two controls up here are real targets, the size the "+" is: a 34px disc
   is what a control looks like when it floats ON something (the × and the grip
   on a card), and these float on nothing. */
.topbar .icon-btn { width: var(--tap); height: var(--tap); }
.topbar .icon-btn svg { width: 20px; height: 20px; }

/* The account, as one letter. Same disc as the gear beside it rather than an
   accent one — there is exactly one accent circle on this screen and it is the
   "+", which is the only thing here that MAKES something. */
.icon-btn.avatar {
  /* A WASH of the accent, not a fill: the "+" stays the one filled disc, and
     the account still reads as yours rather than as one more grey control. */
  background: color-mix(in srgb, var(--c-accent) 12%, var(--c-surface));
  border-color: color-mix(in srgb, var(--c-accent) 28%, var(--c-line));
  color: var(--c-accent);
  font-size: var(--t-callout);
  font-weight: var(--w-semibold);
  line-height: 1;
}

/* ---------------------------------------------------------------- board */

.board-scroll {
  flex: 1;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}

.board {
  position: relative;          /* cards are absolutely placed inside it */
  /* Its WIDTH is written on the element by board.js: a phone board is two
     columns and a desktop one is as many as fit, so how wide it is is an
     answer the layout gives rather than a cap the stylesheet sets. */
  max-width: 100%;
  margin: 0 auto;
  padding-bottom: calc(var(--s-5xl) * 2);
  transition: filter var(--m-base) var(--ease), opacity var(--m-base) var(--ease);
}

/* While a widget is open the board is a backdrop and nothing more.
   NOTE: filter creates a containing block for position:fixed descendants, which
   is exactly why .stage is a SIBLING of .board-scroll and never a child. */
.board.dimmed {
  filter: blur(var(--blur-board));
  pointer-events: none;
}

.scrim {
  position: fixed;
  inset: 0;
  z-index: 5;
  background: var(--c-scrim);
  opacity: 0;
  transition: opacity var(--m-base) var(--ease);
  pointer-events: none;
}
.scrim.on { opacity: 1; pointer-events: auto; }

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

/* A card is a FACE with a NAME under it. The face is the painted, clipped box
   the widget is drawn in; the label lies on the canvas below it, the way a home
   screen labels the things on it. The card itself paints nothing — it is the
   slot the two of them share, and it is what carries the position, the size
   and the jiggle. */
.card {
  /* The card's CONTENT SCALE, written on the element by card.js from
     `contentScale()`. Everything inside a card is a multiple of it — type,
     block heights, the row budget — so a widget FITS the frame it was given
     rather than being drawn once and cropped. 1.0 is a 2x1; a 1x1 is 0.85 and
     the opened widget 1.1. The declaration here is the fallback for anything
     that renders a card without measuring one. */
  --cs: 1;
  /* The name's band — `LABEL` in views/display.js, written on the element by
     card.js. EVERY card has one, the opened widget included. */
  --label: 32px;
  /* …and the scale it is DRAWN at, which is the BOARD's and not the card's: the
     name is the app writing on its own canvas, so one board has one size of it.
     See `labelScale` in layout/geometry.js. */
  --label-cs: 1;
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  flex-direction: column;
  border-radius: var(--r-card);
  color: var(--c-ink);
  contain: layout style;
  /* pan-y and NEVER none: the browser must be free to scroll the board
     vertically without asking JavaScript first. Arrange mode sets none. */
  touch-action: pan-y;
  -webkit-touch-callout: none;
  user-select: none;
  -webkit-user-select: none;
  transition: translate var(--m-base) var(--ease),
              width var(--m-base) var(--ease),
              height var(--m-base) var(--ease),
              opacity var(--m-fast) var(--ease);
}

.card-face {
  /* The INSET the card holds its contents in, and it is the same on all four
     sides. The body was flush with the top edge and 12 in from the left, which
     the head's own padding hid on an opened card and nothing hid on a board
     one: a drawing that has slipped up out of its frame. Its room is counted in
     `CHROME` (views/display.js) like everything else, so the table above is
     drawn to fit what is left. */
  --pad: calc(var(--s-lg) * var(--cs));
  position: relative;
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  border-radius: inherit;
  background: var(--c-surface);
  /* FLAT: the edge IS the elevation, in both appearances. This used to be a
     soft shadow in light and a hairline in dark, which meant a white card on a
     near-white canvas existed only because of the shadow — a 1.11:1 edge. */
  border: 1px solid var(--c-line);
  transition: transform var(--m-fast) var(--ease),
              box-shadow var(--m-fast) var(--ease),
              border-color var(--m-fast) var(--ease);
}

/* The press, and it is on the FACE. On the card it was `transform: var(--t)
   scale(...)` against an inline transform card.js wrote every render — a
   declaration the element's own style attribute beat every time, so pressing a
   card did nothing at all. The face has no inline transform to lose. */
.card.pressed .card-face { transform: scale(0.985); }
.card.hidden { visibility: hidden; }

/* The widget's NAME, on the canvas under its face — on the board and on the
   OPENED widget alike. `--label` tall exactly, so the face gets the rest of the
   slot and the row budget in views/display.js still describes the card it is
   drawn on. */
.card-label {
  flex: none;
  /* The board's scale, not the card's. Two cards in one row are the same height
     by construction, so a band whose height moved with the card ended their
     faces on two different lines — 12px apart on a desktop board, which is the
     raggedest thing a grid of cards can do. */
  height: calc(var(--label) * var(--label-cs));
  display: flex;
  align-items: center;
  gap: calc(var(--s-md) * var(--label-cs));
  padding: 0 calc(var(--s-sm) * var(--label-cs));
  overflow: hidden;
}

.card-name {
  font-size: calc(var(--t-body) * var(--label-cs));
  line-height: 1.25;
  font-weight: var(--w-semibold);
  letter-spacing: var(--track-title);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  flex: 1;
}
.card-body {
  flex: 1; min-height: 0; position: relative;
  /* The inset on three sides; the bottom is the GAP above the stamp, which
     carries the fourth side itself. */
  padding: var(--pad) var(--pad) calc(var(--s-md) * var(--cs));
}

/* State is carried by badges and by NOTHING else, so a board reads at a glance
   without decoding four card treatments. */
/* In the band, so at the band's scale. */
.dot { width: calc(9px * var(--label-cs, 1)); height: calc(9px * var(--label-cs, 1)); border-radius: var(--r-pill); flex: none; }
.dot.changed { background: var(--c-accent); }
.dot.attention { background: var(--c-danger); }
.dot.drift { background: var(--c-warn); }
.dot.asleep { background: var(--c-ink3); }

.card.is-asleep { opacity: 0.62; }

/* The card being talked about, on a DESKTOP board where it never leaves its
   slot. A ring and nothing else: the face is painted by the agent, so a wash
   over it would be a colour chosen against a ground we do not know, and the
   board around it must not move — which rules out a border, whose width would
   push the card's own contents in by a pixel. An outline is drawn outside the
   box and costs the layout nothing. */
.card.ringed .card-face {
  outline: 2px solid var(--c-accent);
  outline-offset: 1px;
}

/* A widget that is switched OFF. Two marks, and both are needed: the whole card
   dims, which is what reads at a glance across a board, and a power glyph in
   the NAME BAND, which is what says WHY it is dim. Dimming alone is
   indistinguishable from a sleeping computer.

   In the band and not on the face. The face is whatever the agent drew — a
   display whose first block is a number starts flush in the top-left corner,
   which is where this mark was first put and where it was completely invisible
   underneath one. There is no reliably free corner on a card's face; the band
   is the place state already lives.

   It is a glyph rather than a dot because it is not one of the badges: every
   badge describes something that happened to a widget that is RUNNING, and this
   says nothing is happening. */
.card.is-off .card-face { opacity: 0.55; }
.card-off {
  flex: none;
  display: grid;
  place-items: center;
  color: var(--c-ink2);
}
.card-off svg {
  width: calc(15px * var(--label-cs, 1));
  height: calc(15px * var(--label-cs, 1));
}

/* The switch on an opened widget's control line. A track and a knob, in the
   accent when it is on and in the line colour when it is off — the one control
   in the app whose whole job is to show a state rather than to invite a press,
   which is why it is not an icon button. It keeps the 44pt target the header's
   other controls have, around a track that is deliberately smaller. */
.switch {
  flex: none;
  width: var(--tap);
  height: var(--tap);
  display: grid;
  place-items: center;
  border: 0;
  background: none;
  padding: 0;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.switch::before {
  content: "";
  grid-area: 1 / 1;
  width: 34px;
  height: 20px;
  border-radius: var(--r-pill);
  background: var(--c-line);
  transition: background var(--m-fast) var(--ease);
}
.switch.on::before { background: var(--c-accent); }
.switch .knob {
  grid-area: 1 / 1;
  width: 16px;
  height: 16px;
  border-radius: var(--r-pill);
  background: var(--c-surface);
  translate: -7px 0;
  transition: translate var(--m-fast) var(--ease);
}
.switch.on .knob { translate: 7px 0; }

/* How old the table on the card is: a clock and "2h ago". It sits in the one
   corner nothing else claims — the × is top-right, the grip bottom-right, and
   the opened widget's own controls are on the header's line rather than on the
   card at all — so it never has to be moved out of a control's way. Its ROOM is
   counted in CHROME (views/display.js), which is why the table above stops short
   of it rather than running underneath.

   28 tall INCLUDING the face's own inset at the bottom, leaving a 16 line for
   13px type; the body's bottom padding above it is the gap. iOS spends the same
   28 on it, as 12 of stack spacing plus a 16 line. */
.card-stamp {
  flex: none;
  display: flex;
  align-items: center;
  gap: calc(var(--s-sm) * var(--cs));
  height: calc(28px * var(--cs));
  padding: 0 var(--pad) var(--pad);
  font-size: calc(var(--t-micro) * var(--cs));
  line-height: 1;
  color: var(--c-ink3);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
/* The icon is drawn at the type's own size rather than the 18px the shared
   `svg()` helper hands every other glyph: a clock beside 12px type is part of
   the word, not a control. */
.card-stamp svg {
  flex: none;
  width: calc(14px * var(--cs));
  height: calc(14px * var(--cs));
}

/* ---------------------------------------------------------------- a table */

.table-wrap {
  height: 100%;
  overflow-x: auto;          /* the TABLE scrolls sideways, never the page */
  overflow-y: hidden;
  scrollbar-width: thin;
}
.table-wrap.no-scroll { overflow-x: hidden; }

/* The table sets its OWN leading. It inherited the body's 1.45, which is a
   reading measure for prose and pure waste in a grid of short cells — and it
   was what made the row budget's divisor wrong. 13px at 1.3 is a 21px row. */
table.csv { border-collapse: collapse; width: max-content; min-width: 100%; line-height: 1.3; }
table.csv th {
  text-align: left;
  font-size: calc(var(--t-micro) * var(--cs));
  font-weight: var(--w-semibold);
  letter-spacing: 0.02em;
  padding: 0 calc(var(--s-lg) * var(--cs)) calc(var(--s-sm) * var(--cs)) 0;
  white-space: nowrap;
  color: var(--c-ink2);
}
table.csv td {
  font-size: calc(var(--t-small) * var(--cs));
  font-variant-numeric: tabular-nums;
  padding: 0 calc(var(--s-lg) * var(--cs)) calc(var(--s-sm) * var(--cs)) 0;
  white-space: nowrap;
  color: var(--c-ink);
}
table.csv th:last-child, table.csv td:last-child { padding-right: 0; }

/* A wide table on a card cannot be scrolled — the card takes no gestures — so
   it must LOOK cut. A three-column card of a six-column table otherwise reads
   as the whole answer. */
.table-wrap.no-scroll { mask-image: linear-gradient(to right, #000 0, #000 88%, transparent 100%); }

/* Exactly `BLOCK.more` tall (views/display.js) — the room the row budget takes
   off the table for it, and the room iOS gives the same line. It was a line at
   the body's own leading plus 2px of padding, which is 19.4 at cs 1: nearly two
   pixels more than the budget it is drawn inside, so the count that says a card
   cut something was itself cut. */
.more-rows {
  font-size: calc(var(--t-micro) * var(--cs));
  color: var(--c-ink2);
  height: calc(19px * var(--cs));
  line-height: calc(19px * var(--cs));
}

.empty-note {
  font-size: calc(var(--t-small) * var(--cs));
  color: var(--c-ink3);
  display: flex;
  align-items: center;
  height: 100%;
}

/* ---------------------------------------------------------------- draft */

.card.draft .card-face {
  background: color-mix(in srgb, var(--c-surface) 60%, transparent);
  border: 1px dashed var(--c-line);
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: calc(var(--s-xl) * var(--cs));
}
/* On the BOARD that 60% composites over the canvas, which is what makes a draft
   read as a soft placeholder. The opened card is over the BLURRED BOARD, where
   the same value let the cards behind it bleed up through its own face — so the
   stage mixes towards the canvas explicitly: the same colour, opaque. */
.stage .card.draft .card-face { background: color-mix(in srgb, var(--c-surface) 60%, var(--c-canvas)); }

/* A draft's face carries only what to DO about it. What it is — "New widget",
   "The agent has a question", "Couldn't set this up" — is under the card, in
   the same band every other card wears its name in. */
.draft-hint { font-size: calc(var(--t-small) * var(--cs)); color: var(--c-ink2); }

/* ---------------------------------------------------------------- arrange */

.board.arranging .card { touch-action: none; cursor: grab; }
/* A card IN THE HAND. It is drawn where the cursor has it rather than in a
   slot, so it must not lag: no transition, and no `pointer-events: none`
   either — this element holds the pointer capture for the whole gesture, and
   taking it out of hit-testing is a way to lose it. */
.card.dragging {
  z-index: 3;
  transition: none;
  cursor: grabbing;
  /* The `scale` PROPERTY. It composes AFTER `rotate` and BEFORE the card's
     position (which is `translate`), so the card grows about its own centre and
     stays exactly under the cursor — and it grows with its NAME, so the two are
     one thing in the hand. */
  scale: 1.03;
}
.card.dragging .card-face {
  border-color: var(--c-accent);
  box-shadow: var(--lift);
}

/* Arrange mode's jiggle. Each card carries its OWN phase and its own period
   (`--wob-delay`, `--wob-dur`, written by card.js from the widget's id), and
   that is the whole of the effect: forty cards rocking in lockstep read as one
   sheet of paper being shaken, which is exactly what it looked like. The delay
   is negative, so a card starts mid-swing rather than waiting its turn.

   EASE-IN-OUT, and not the app's own `--ease`. Every other motion here starts
   fast and settles, which is right for something arriving somewhere; a rock
   that alternates has no arrival, and an ease-out curve played backwards is an
   ease-in — so the card snapped to one side and crawled back. A jiggle is
   symmetric or it reads as a twitch. The PERIOD is the phone's (`Jiggle` in
   WidgetGrid.swift) and so is the half-degree; the rock is round the card's
   rest position rather than off it, because a card that spends half its time
   tilted one way is a board hung crooked. */
.card.wobble {
  animation: wobble var(--wob-dur, 300ms) ease-in-out infinite alternate;
  animation-delay: var(--wob-delay, 0ms);
}
@keyframes wobble {
  from { rotate: -0.55deg; }
  to { rotate: 0.55deg; }
}

/* A control that floats ON a card. Pale and translucent rather than a black
   disc: it sits on a surface the AGENT coloured, so it has to read as glass
   over whatever is underneath instead of as a hole punched in it. And it is a
   real 34px target — these were 24px ink circles, which is under every
   guideline there is and looked like it. */
.card-x, .card-grip {
  position: absolute;
  z-index: 2;
  display: grid;
  place-items: center;
  background: color-mix(in srgb, var(--c-surface) 82%, transparent);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  color: var(--c-ink);
  border-radius: var(--r-pill);
  width: 34px;
  height: 34px;
  font-size: var(--t-callout);
  line-height: 1;
  /* A BORDER, not an outline and not a shadow. A border because `outline` is
     what :focus-visible uses — same specificity, and this rule comes later in
     the file, so an outline here would silently take the keyboard focus ring
     off the only two controls on a card. Box-sizing is border-box, so the 34px
     stays 34px. */
  border: 1px solid color-mix(in srgb, var(--c-ink) 8%, transparent);
  cursor: pointer;
}
.card-x { top: var(--s-md); right: var(--s-md); }
.card-grip {
  bottom: var(--s-md);
  right: var(--s-md);
  cursor: nwse-resize;
  touch-action: none;
}

/* The opened widget's own controls stand on the HEADER's line, above the card
   and not on it. On the card they covered the first thing the widget drew —
   which is the whole top of it now that the name has moved underneath — and a
   control that hides the content it belongs to is a control in the way. So the
   header line, which a widget you have opened owns for as long as it is open,
   carries them instead: the way BACK on the left, the wand and the "…" on the
   right, the app's own name and account standing down while they are there.

   The way back wears an ARROW. It was an × in the corner every board card uses
   for delete, so the one control that returns you to the board wore the mark of
   the one that destroys a widget. */
.stage-bar {
  /* Its top and its height are written by stage.js off the header's own rect,
     so it lands exactly on that line at any safe-area inset rather than on a
     copy of the header's padding that would drift the moment either changed. */
  position: fixed;
  left: 0;
  right: 0;
  z-index: 3;
  /* The CARD's own measure, centred — the same one the conversation keeps. The
     controls belong to the widget under them, and at the window's edges on a
     wide display they read as the app's rather than as its. */
  max-width: var(--board-max);
  margin-inline: auto;
  display: flex;
  align-items: center;
  gap: var(--s-md);
  padding: 0 var(--s-xl);
  pointer-events: none;
}
.stage-bar > * { pointer-events: auto; }
.stage-bar .spacer { flex: 1; pointer-events: none; }
.stage-bar button {
  width: var(--tap);
  height: var(--tap);
  flex: none;
  display: grid;
  place-items: center;
  border-radius: var(--r-pill);
  border: 1px solid var(--c-line);
  background: var(--c-surface);
  color: var(--c-ink);
}
.stage-bar button svg { width: 20px; height: 20px; }
/* The switch is not a disc: a toggle drawn inside a circle is two controls'
   shapes on one. It stands on the line bare, on its own 44pt target. */
.stage-bar button.switch { border: 0; background: none; }
/* Done is the one that KEEPS something, so it is the one accent circle here —
   the same rule the board follows, where the "+" is the only filled disc. */
.stage-bar button.primary { background: var(--c-accent); color: var(--c-on-accent); border-color: transparent; }
.stage-bar button:disabled { opacity: 0.4; cursor: default; }

/* And the app's own name and account stand down while it is: two things on one
   line, one of them the widget's back arrow sitting exactly on the app's mark,
   is not a header either of them can be read in. */
.topbar > * { transition: opacity var(--m-fast) var(--ease); }
.topbar.veiled > * { opacity: 0; pointer-events: none; }

.arrange-bar {
  position: fixed;
  left: 50%;
  bottom: calc(var(--s-3xl) + env(safe-area-inset-bottom));
  transform: translateX(-50%);
  z-index: 6;
  display: flex;
  gap: var(--s-md);
  align-items: center;
  background: var(--c-ink);
  color: var(--c-canvas);
  padding: var(--s-md) var(--s-lg);
  border-radius: var(--r-pill);
  font-size: var(--t-small);
}
.arrange-bar button {
  background: var(--c-canvas);
  color: var(--c-ink);
  border: 0;
  border-radius: var(--r-pill);
  padding: var(--s-sm) var(--s-lg);
  font-weight: var(--w-semibold);
}

/* ---------------------------------------------------------------- stage */

/* The opened widget. A sibling of the board scroller, positioned against the
   VIEWPORT — so the board's scroll offset never enters the arithmetic, and the
   board's blur cannot capture it. */
.stage {
  position: fixed;
  inset: 0;
  z-index: 6;
  display: flex;
  flex-direction: column;
  pointer-events: none;
}
.stage > * { pointer-events: auto; }

.stage .card {
  position: fixed;
  z-index: 2;
  /* The ONE shadow in this design language, on the one thing that is genuinely
     off the surface: the widget you opened, hovering over the board it came out
     of. Everywhere else a hairline is still the whole of how a surface ends —
     and this card keeps its hairline too, underneath the lift. */
  box-shadow: var(--lift);
}
.stage .card.settling {
  transition: translate var(--m-base) var(--ease),
              width var(--m-base) var(--ease),
              height var(--m-base) var(--ease),
              /* The contents grow WITH the frame — one gesture, not a resize
                 followed by a re-type. */
              --cs var(--m-base) var(--ease);
}

/* ---------------------------------------------------------------- bits */

.fab {
  position: fixed;
  /* On the board's own measure, like the app's line above: a "+" pinned to the
     far corner of a monitor, with the board it adds to three hundred pixels
     away, belongs to the window rather than to the board. */
  right: max(var(--s-xl), calc((100% - var(--measure)) / 2 + var(--board-pad)));
  bottom: calc(var(--s-xl) + env(safe-area-inset-bottom));
  z-index: 4;
  width: 56px; height: 56px;   /* the phone's, exactly */
  border: 0; border-radius: var(--r-pill);
  background: var(--c-accent); color: var(--c-on-accent);
  font-size: var(--t-large);
  display: grid; place-items: center;
}

.btn {
  border: 1px solid var(--c-line);
  background: var(--c-surface);
  color: var(--c-ink);
  /* Round, like every other control here — the discs up top, the "+", the
     chat's own buttons and composer. */
  border-radius: var(--r-pill);
  padding: var(--s-md) var(--s-xl);
  font-weight: var(--w-medium);
}
.btn.primary { background: var(--c-accent); color: var(--c-on-accent); border-color: transparent; font-weight: var(--w-semibold); }
.btn.quiet { background: none; border-color: transparent; color: var(--c-accent); }
.btn:disabled { opacity: 0.5; }

.icon-btn {
  width: 34px; height: 34px;
  display: grid; place-items: center;
  border: 1px solid var(--c-line);
  background: var(--c-surface);
  border-radius: var(--r-pill);
}

.sheet-backdrop {
  position: fixed; inset: 0; z-index: 8;
  background: var(--c-scrim);
  display: flex; align-items: flex-end; justify-content: center;
}
.sheet {
  width: 100%;
  max-width: calc(var(--board-max) + var(--s-5xl));
  max-height: 86dvh;
  overflow-y: auto;
  background: var(--c-surface);
  border-radius: var(--r-card) var(--r-card) 0 0;
  padding: var(--s-xl) var(--s-xl) calc(var(--s-3xl) + env(safe-area-inset-bottom));
}
/* Past a phone it is a dialog in the middle of the window rather than a panel
   rising from the thumb — four rounded corners, and the HAIRLINE every other
   floating surface here ends with (`.palette` below carries the same one). A
   sheet resting on the bottom edge needs no edge of its own; one floating with
   canvas all round it had nothing to say where it ended. */
@media (min-width: 720px) {
  .sheet-backdrop { align-items: center; }
  .sheet { border-radius: var(--r-card); border: 1px solid var(--c-line); box-shadow: var(--lift); padding: var(--s-3xl); }
}
/* A sheet is one short page, not the app: its title is a title, its sections
   are headings under it rather than rivals of it, and what it says is read —
   so it is the small size, not the fine print. */
.sheet h2 { font-size: var(--t-title); }
.sheet h3 { font-size: var(--t-body); margin: 0 0 var(--s-sm); }
.sheet .tiny { font-size: var(--t-small); }
.sheet p { margin: 0 0 var(--s-sm); }
.sheet img[data-testid="share-qr"] { border: 1px solid var(--c-line); padding: var(--s-md); }

.menu {
  position: fixed; z-index: 9;
  background: var(--c-surface);
  border: 1px solid var(--c-line);
  border-radius: var(--r-control);
  padding: var(--s-sm);
  min-width: 200px;
}
/* A line of FACT above the actions — which account this menu belongs to. Not a
   row: it has no hover, no target and nothing to select. */
.menu-head {
  padding: var(--s-md) var(--s-lg) var(--s-md);
  margin-bottom: var(--s-sm);
  border-bottom: 1px solid var(--c-line);
  font-size: var(--t-micro);
  color: var(--c-ink3);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.menu button {
  display: block; width: 100%; text-align: left;
  background: none; border: 0;
  padding: var(--s-md) var(--s-lg);
  border-radius: var(--r-inset);
  font-size: var(--t-callout);
}
.menu button:hover { background: var(--c-raised); }
.menu button.danger { color: var(--c-danger); }

/* One shape for every allowance, because they are one question about different
   things and a reader should not have to learn a second layout. */
.meter { display: flex; flex-direction: column; gap: var(--s-sm); padding: var(--s-lg) 0; }
.meter-head { display: flex; justify-content: space-between; font-size: var(--t-small); }
.meter-bar { height: 6px; border-radius: var(--r-pill); background: var(--c-line); overflow: hidden; }
.meter-fill { height: 100%; background: var(--c-accent); }
.meter-foot { font-size: var(--t-micro); color: var(--c-ink2); }

.cover {
  position: fixed; inset: 0; z-index: 20;
  background: var(--c-canvas);
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: var(--s-xl); padding: var(--s-3xl); text-align: center;
}

.centered {
  flex: 1; display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: var(--s-xl); padding: var(--s-3xl); text-align: center;
}

.muted { color: var(--c-ink2); }
.tiny { font-size: var(--t-micro); }
h1 { font-size: var(--t-display); font-weight: var(--w-semibold); letter-spacing: var(--track-title); margin: 0; }
h2 { font-size: var(--t-large); font-weight: var(--w-semibold); letter-spacing: var(--track-title); margin: 0; }
h3 { font-size: var(--t-title); font-weight: var(--w-semibold); margin: 0 0 var(--s-md); }

/* A computer with a newer build waiting. Its own block, because the line to run
   belongs to THAT machine and not to the list it sits in. */
.update-offer {
  display: flex;
  flex-direction: column;
  gap: var(--s-md);
  margin-top: var(--s-md);
  padding: var(--s-lg);
  border: 1px solid var(--c-line);
  border-radius: var(--r-inset);
  background: var(--c-raised);
}
.update-offer p { margin: 0; }

code.cmd {
  display: block;
  font-family: var(--font-mono);
  font-size: var(--t-small);
  /* A well, not a second input box. */
  background: var(--c-raised);
  border: 1px solid var(--c-line);
  border-radius: var(--r-inset);
  padding: var(--s-lg);
  overflow-x: auto;
  white-space: pre;
  text-align: left;
}

.spinner {
  width: 16px; height: 16px;
  border: 2px solid var(--c-line);
  border-top-color: var(--c-accent);
  border-radius: var(--r-pill);
  animation: spin 0.8s linear infinite;
}
@keyframes spin { to { rotate: 360deg; } }

@media (prefers-reduced-motion: reduce) {
  .card, .card-face, .chat, .shell, .board, .scrim { transition: none !important; }
  .card.wobble { animation: none; }
}

/* ---------------------------------------------------------------- display */
/* The blocks a display evaluates to, one under the other, in function order.
   Every height here is the SAME number as ios/Hoverlet/Rendering/DisplayView.swift
   and views/display.js, so a card on either surface shows the same rows. */

.display { display: flex; flex-direction: column; gap: calc(var(--s-md) * var(--cs)); height: 100%; min-height: 0; }

/* HEIGHTS COME FROM THE ALLOCATOR, not from here. `views/display.js` measures
   the tree and writes a pixel height onto every box; CSS flexes only
   HORIZONTALLY, inside a `columns`. A row budget computed against a height CSS
   then disagreed with is five rows drawn into four rows of box, which is the
   classic way this goes wrong. So every block below is `flex: none` and takes
   the height it was given. */
.display > *, .display-group > *, .display-columns > * { flex: none; }
.display-slot { min-height: 0; overflow: hidden; }
.display-slot > .table-wrap { height: 100%; }
/* An OPENED card scrolls and every block draws whole, so nothing is written on
   it and the table goes back to taking what it is given. */
.display.expanded .display-slot { height: auto !important; overflow: visible; }

/* Side by side. The WIDTH share is CSS's — `flex: <weight> 1 0` written by the
   allocator — because a width has no row budget hanging off it. Each column is
   as tall as the container, which is what makes a row of columns read as one
   band rather than as three things that happen to be near each other. */
.display-columns {
  display: flex;
  flex-direction: row;
  gap: calc(var(--s-md) * var(--cs));
  align-items: stretch;
  min-height: 0;
}

/* Several things drawn as one. `box` is a hairline frame, which is the one
   primitive this flat language has for "these belong together" — deliberately
   not a tint, because a widget's face is a colour the agent chose and a wash
   over it would be a colour picked against a ground we do not know. */
.display-group {
  display: flex;
  flex-direction: column;
  gap: calc(var(--s-md) * var(--cs));
  min-height: 0;
}
.display-group.boxed {
  border: 1px solid var(--c-line);
  border-radius: var(--r-inset);
  padding: calc(var(--s-md) * var(--cs));
}
.display-group > .group-title {
  flex: none;
  height: calc(19px * var(--cs));
  font-size: calc(var(--t-micro) * var(--cs));
  font-weight: var(--w-medium);
  color: var(--c-ink2);
  letter-spacing: .04em;
  text-transform: uppercase;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* ONE row read the other way round: a label and a value per line, where a
   table would spend a header line and a row on the same two facts. */
.display-fields { overflow: hidden; }
.display-fields .field-row {
  display: flex;
  align-items: baseline;
  gap: calc(var(--s-md) * var(--cs));
  height: calc(22px * var(--cs));
  font-size: calc(var(--t-small) * var(--cs));
}
.display-fields .field-key {
  flex: none;
  min-width: calc(72px * var(--cs));
  color: var(--c-ink2);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.display-fields .field-value {
  flex: 1; min-width: 0;
  font-variant-numeric: tabular-nums;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* A value against a CEILING. The percentage is beside the value rather than
   inside the track: a number written on a bar is a number whose contrast
   depends on where the fill happens to end. */
.display-gauge { display: flex; flex-direction: column; gap: calc(var(--s-sm) * var(--cs)); overflow: hidden; }
.display-gauge .gauge-head { display: flex; align-items: baseline; gap: calc(var(--s-md) * var(--cs)); }
.display-gauge .gauge-value {
  flex: 1; min-width: 0;
  font-size: calc(var(--t-callout) * var(--cs));
  font-weight: var(--w-medium);
  font-variant-numeric: tabular-nums;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.display-gauge .gauge-pct {
  flex: none;
  font-size: calc(var(--t-micro) * var(--cs));
  font-variant-numeric: tabular-nums;
  opacity: 0.7;
}
.display-gauge .gauge-track {
  display: block;
  height: calc(6px * var(--cs));
  border-radius: var(--r-pill);
  background: var(--c-line);
  overflow: hidden;
}
.display-gauge .gauge-fill { display: block; height: 100%; border-radius: var(--r-pill); }
/* Where a card CUTS, its bottom edge fades — the same treatment the table's
   right edge gets, and for the same reason. The row budget only shrinks the
   TABLE; every other block has a fixed height, so a card holding more blocks
   than it has room for cuts one in half, and a glyph sliced through the middle
   reads as a card that failed to draw rather than as a card with more in it. */
.display:not(.expanded) {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 12px * var(--cs)), transparent 100%);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 12px * var(--cs)), transparent 100%);
}

/* The opened widget scrolls where it stands: every block at its full height,
   the table whole, and the card's body scrolling past its frame. */
.display.expanded { overflow-y: auto; }
.display.expanded > .table-wrap { flex: none; height: auto; overflow-y: visible; }
/* ONE height and ONE size per block, times the scale. There used to be a
   second, "expanded" set (a number block was 48 on a card and 58 when opened)
   and the two multiplied: at the opened card's own 1.3 that came to 75, a step
   nobody asked for on top of a step already taken. */
.display-number-block { overflow: hidden; }
.display-number {
  font-variant-numeric: tabular-nums;
  font-weight: var(--w-medium);
  font-size: calc(var(--t-large) * var(--cs));
  line-height: 1.1;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.display-caption { font-size: calc(var(--t-micro) * var(--cs)); color: var(--c-ink2); opacity: 0.85; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.display-label {
  line-height: calc(24px * var(--cs));
  font-size: calc(var(--t-body) * var(--cs));
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.display-bars { padding: calc(2px * var(--cs)) 0; overflow: hidden; }
.display-bars .bar-row {
  display: grid; grid-template-columns: calc(72px * var(--cs)) 1fr auto; gap: calc(var(--s-md) * var(--cs));
  align-items: center; height: calc(21px * var(--cs)); font-size: calc(var(--t-micro) * var(--cs));
}
.display-bars .bar-label { color: var(--c-ink2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.display-bars .bar-track { display: block; height: calc(10px * var(--cs)); }
/* Thin, a 4px rounded data-end, square at the baseline. */
.display-bars .bar { display: block; height: calc(10px * var(--cs)); border-radius: 0 calc(4px * var(--cs)) calc(4px * var(--cs)) 0; }
.display-bars .bar-value { font-variant-numeric: tabular-nums; white-space: nowrap; }
.display-line { display: flex; flex-direction: column; gap: calc(2px * var(--cs)); overflow: hidden; }
.display-line .line-plot { position: relative; flex: 1; min-height: 0; }
.display-line svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: visible; }
.display-line polyline { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.display-line .line-dot {
  position: absolute;
  width: calc(8px * var(--cs)); height: calc(8px * var(--cs));
  margin: calc(-4px * var(--cs)) 0 0 calc(-4px * var(--cs));
  /* The one ring left in the stylesheet, and it is not elevation: a 2px
     surface gap between a marker and the line it sits on is a data spacer. */
  border-radius: var(--r-pill); box-shadow: 0 0 0 2px var(--c-surface);
}
.display-line .line-labels {
  display: flex; justify-content: space-between;
  height: calc(19px * var(--cs)); font-size: calc(var(--t-micro) * var(--cs)); color: var(--c-ink2);
}
.display-error {
  display: flex; gap: calc(var(--s-sm) * var(--cs)); align-items: center;
  font-size: calc(var(--t-micro) * var(--cs)); color: var(--c-ink2); white-space: nowrap; overflow: hidden;
}
.display-error i {
  flex: none; width: calc(6px * var(--cs)); height: calc(6px * var(--cs));
  border-radius: var(--r-pill); background: var(--c-warn);
}

/* ---------------------------------------------------------------- hover */
/* A MOUSE gets an answer from everything it can press.
 *
 * The phone has press feedback and nothing else, because a finger is on a
 * control only while it is using it; a pointer hovers, and a screen where
 * nothing lights up under it reads as a picture of an app. This is the whole of
 * it — one tint for something resting on a surface, one for a filled disc, and
 * a cursor — so there is no second set of colours to keep true.
 *
 * The tint is the INK mixed into whatever the control already is, which means
 * it darkens in the light appearance and lightens in the dark one without a
 * token of its own. And it is behind `hover: hover` so that a touch screen,
 * which fires `:hover` on tap and then keeps it until the next tap elsewhere,
 * never leaves a control looking pressed.
 */
@media (hover: hover) and (pointer: fine) {
  .btn:hover:not(:disabled),
  .icon-btn:hover:not(:disabled),
  .chip:hover,
  .cmp-chip:hover,
  .cmp-add:hover,
  .cmp-out:hover,
  .palette-item:hover,
  .insp-actions button:hover,
  .insp-weights button:hover,
  .insp-flag:hover:not(.on),
  .designer-tabs button:hover:not(.on),
  .stage-tabs button:hover:not(.on),
  .version-marker:hover,
  .stage-bar button:hover:not(:disabled):not(.switch),
  .card-x:hover,
  .card-grip:hover {
    background: color-mix(in srgb, var(--c-ink) 8%, var(--c-surface));
  }
  .cmp-row:hover {
    background: color-mix(in srgb, var(--c-ink) 8%, var(--c-raised));
  }
  .icon-btn.avatar:hover { background: color-mix(in srgb, var(--c-accent) 20%, var(--c-surface)); }
  .btn.quiet:hover:not(:disabled),
  .icon-btn.tiny:hover:not(:disabled),
  .cmp-grip:hover,
  .cmp-fold:hover,
  .cmp-remove:hover {
    background: color-mix(in srgb, var(--c-ink) 8%, transparent);
  }

  /* A filled disc lightens or darkens in place: mixing the ink in keeps it the
     accent rather than turning it into a second colour. */
  .btn.primary:hover:not(:disabled),
  .fab:hover,
  .composer-bar > button.send:hover:not(:disabled),
  .agent-form-foot .send:hover:not(:disabled),
  .stage-bar button.primary:hover:not(:disabled) {
    background: color-mix(in srgb, var(--c-ink) 14%, var(--c-accent));
  }
  .arrange-bar button:hover { background: color-mix(in srgb, var(--c-ink) 10%, var(--c-canvas)); }

  /* A CARD says it can be opened by its edge, which is the only thing a card
     has: it is painted by the agent and a tint over it would be a colour chosen
     against a ground we do not know. */
  .board:not(.arranging) .card:hover .card-face { border-color: var(--c-ink3); }
  .board:not(.arranging) .card { cursor: pointer; }

  /* The same rule for the two other things that are surfaces rather than
     controls: the composer's bar, and a cell on the designer's picking table —
     which already wears a mark colour when it is picked, so a tint here would
     be a second meaning in the same place. */
  .composer-bar:hover:not(:focus-within) { border-color: var(--c-ink3); }
  .pick th:hover, .pick td:hover { border-color: var(--c-ink3); }
}

/* The home-screen offer, over the board and shown once (views/addtohome.js).
   It sits INSIDE the scroller rather than pinned, so it scrolls away like any
   other row: a bar that follows the board down would be a nag, and the whole
   point of a one-time offer is that it can be left behind. */
.install-banner {
  display: flex;
  align-items: center;
  gap: var(--s-md);
  margin: 0 var(--s-lg) var(--s-md);
  padding: var(--s-md) var(--s-lg);
  border: 1px solid var(--c-line);
  border-radius: var(--r-card);
  background: var(--c-surface);
}
.install-banner-words { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.install-banner-words strong { font-weight: var(--w-semibold); }

/* ------------------------------------------------ focus mode (views/stage.js)
   An opened widget: the board blurs behind the scrim, the widget comes forward
   and the conversation with it — forji's chat widget — stands beside it. Every
   rect is written by stage.js (`focusLayout`); this only says how they look and
   that they travel rather than jump. */
.scrim.on { -webkit-backdrop-filter: blur(7px) saturate(1.05); backdrop-filter: blur(7px) saturate(1.05); }
.stage.focus { position: fixed; inset: 0; z-index: 6; pointer-events: none; }
.stage.focus > * { pointer-events: auto; }
.stage.focus .stage-bar {
  position: absolute; left: 0; right: auto; top: 0; max-width: none; margin: 0; height: 44px;
  padding: 0 var(--s-md); transition: translate var(--m-base) var(--ease), width var(--m-base) var(--ease);
}
.stage.focus.phone .stage-bar { padding-top: env(safe-area-inset-top); height: calc(52px + env(safe-area-inset-top)); }
.stage.focus .card {
  z-index: 2;
  transition: translate var(--m-slow, 340ms) var(--ease), width var(--m-slow, 340ms) var(--ease),
              height var(--m-slow, 340ms) var(--ease);
}
/* The lift is the FACE's. On the slot it fell on the name band too, which
   then sat in a grey shadowed slab under the card. */
.stage.focus .card { box-shadow: none; }
.stage.focus .card .card-face { box-shadow: var(--lift); }
.focus-chat {
  position: absolute; left: 0; top: 0; z-index: 1; opacity: 0;
  transition: opacity var(--m-base) var(--ease) 80ms, translate var(--m-base) var(--ease);
  border-radius: var(--r-card); overflow: hidden; border: 1px solid var(--c-line);
  background: var(--c-surface);
}
.stage.focus.in .focus-chat { opacity: 1; }
.stage.focus.phone .focus-chat { border-radius: var(--r-card) var(--r-card) 0 0; border-bottom: 0; }
.focus-chat forji-chat {
  display: block; width: 100%; height: 100%;
  /* forji's chat in hoverlet's own colours — ALL of them: what is not mapped
     here is forji's own brown, and `--tint` is what its bubbles, chips and
     focus rings are washed with. */
  --bg: var(--c-canvas); --panel: var(--c-surface); --ink: var(--c-ink); --muted: var(--c-ink2); --line: var(--c-line);
  --accent: var(--c-accent); --accent-ink: var(--c-on-accent); --danger: var(--c-danger); --soft: var(--c-raised);
  --tint: var(--c-accent-rgb);
  --glass: color-mix(in srgb, var(--c-surface) 66%, transparent);
  --glass2: color-mix(in srgb, var(--c-surface) 80%, transparent);
  --glass-line: color-mix(in srgb, var(--c-ink) 12%, transparent);
  --shadow: 0 1px 2px color-mix(in srgb, var(--c-ink) 8%, transparent);
  font-family: var(--font-sans);
}
@media (prefers-reduced-motion: reduce) { .stage.focus .card, .focus-chat, .stage.focus .stage-bar { transition: none; } }
.focus-readings { overflow-y: auto; padding: var(--s-xl); }
.focus-readings h3 { margin: 0 0 var(--s-md); }

/* What a widget read, newest first; a tap opens that table */
.readings { list-style: none; margin: 0; padding: 0; }
.readings li { border-bottom: 1px solid var(--c-line); }
.readings li > button { display: flex; gap: var(--s-md); width: 100%; padding: var(--s-md) 0; background: none; border: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.readings li > button:disabled { cursor: default; }
.readings .when { flex: none; width: 5.5em; color: var(--c-ink3); font-size: .85em; font-variant-numeric: tabular-nums; }
.readings .what { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.readings li.failed .what { color: var(--c-danger); white-space: normal; }
.readings .table-wrap { margin-bottom: var(--s-md); }
