/*
 * One stylesheet, no build step, no framework.
 *
 * The app is a single owner's private surface on a phone and a laptop. A build
 * pipeline would add a toolchain to maintain and a source of drift between what
 * is written and what is served, in exchange for conveniences a five-view app
 * does not need.
 */

:root {
  --bg: #0b0d12;
  /*
   * The card surface, and the one token a theme was already sending that
   * nothing read.
   *
   * `themeVariables()` emits `--surface` from the theme's palette. This
   * stylesheet used `--panel` and `--panel-2`, which no theme can reach, so
   * every themed palette was painted onto two hard-coded surfaces. In dark
   * mode that is merely inconsistent. In light mode it is the defect the owner
   * photographed: the theme supplies a pale `--text` for its own dark
   * background, the light block supplies a near-white `--panel-2`, and the
   * assistant's reply is `#e8eef7` on `#eef2f8` — 1.04:1, which is one colour
   * twice.
   *
   * A capability that exists and is never called is the defect this repository
   * keeps finding. This was the same shape in a stylesheet.
   */
  --surface: #151922;
  --panel: var(--surface);
  --panel-2: #1d2330;
  --line: #2a3140;
  --text: #e7ecf3;
  --muted: #97a1b3;
  --accent: #5fa8ff;
  --danger: #ff6b6b;
  --warn: #ffb454;
  --ok: #56d19a;
  --radius: 14px;
  /*
   * The height of the phone's bottom navigation, and the only place it is
   * written down.
   *
   * It is a number two rules have to agree on -- the bar's own box, and the
   * space the shell holds back for it -- and they did not agree: the bar was
   * 45px tall while three separate rules reserved 56px, 64px and 64px for it,
   * on top of a safe-area inset the body had already applied. That is where
   * the empty strip above the navigation came from.
   */
  --nav-h: 52px;
  /*
   * The iPhone's safe-area insets, behind names.
   *
   * `env()` is not settable, which means "is the bottom inset applied exactly
   * once?" -- the question this layout got wrong -- cannot be measured in a
   * browser that reports zero for it, and every phone in a test harness
   * reports zero. Read through a custom property it becomes measurable: a test
   * sets `--sab` to a real 34px and checks that the shell grows by 34 and not
   * by 68.
   *
   * The fallback is the env value itself, so on the owner's phone nothing is
   * different from writing `env()` inline.
   */
  --sat: env(safe-area-inset-top, 0px);
  --sar: env(safe-area-inset-right, 0px);
  --sab: env(safe-area-inset-bottom, 0px);
  --sal: env(safe-area-inset-left, 0px);
  /*
   * The height the owner can actually see, in pixels, and the composer's own.
   *
   * `--vvh` is the visual viewport -- what is left once the software keyboard
   * has taken its half of the screen -- published by `followKeyboard()`. It
   * exists because `vh` and `dvh` both measure the screen and not the space:
   * the composer's `max-height: 30vh` was 253px on an 844pt iPhone whether or
   * not a keyboard was covering 340 of those points, so a long draft grew the
   * input to 274px inside a 506px shell and the conversation above it
   * collapsed to a single 28px line. Measured, not guessed at.
   *
   * `--composer-h` is the input's real measured height, published by a
   * ResizeObserver. Anything that has to sit clear of the composer reads this
   * rather than assuming a number that was true when it was written.
   *
   * Both fall back to something sane for a browser with no `visualViewport`
   * and for the moment before the first measurement, which is every desktop.
   */
  --vvh: 100vh;
  --composer-h: 0px;
  color-scheme: dark;
}

/*
 * The raised surface, one step from the card in the direction of the text.
 *
 * Derived rather than fixed, so it follows a theme the way `--panel` now does:
 * mixing the surface toward `--text` lightens it on a dark palette and darkens
 * it on a light one, without either polarity being written down.
 *
 * Behind `@supports` because a custom property that fails to compute is
 * *guaranteed-invalid*, not merely ignored — `background: var(--panel-2)`
 * would resolve to nothing and the card would lose its background entirely.
 * A browser without `color-mix` keeps the fixed value above, which is what it
 * has always had.
 */
@supports (color: color-mix(in srgb, red 50%, blue)) {
  :root {
    --panel-2: color-mix(in srgb, var(--surface) 90%, var(--text));

    /*
     * Secondary text, one step firmer, for the raised surface it sits on.
     *
     * `--muted` is a theme's own choice and it is tuned against that theme's
     * *background*. On `--panel-2` -- a composer field, a disabled control --
     * it is a step closer to what is behind it, and four of the six builtin
     * palettes landed between 3.76:1 and 4.20:1 there: MINIMAL and DARK pick a
     * muted grey that is nearly the surface again.
     *
     * The alternative was to flatten `--panel-2` toward `--surface` until the
     * numbers cleared, which buys contrast by removing the visible step
     * between a card and the field inside it. Strengthening the text is the
     * cheaper thing to give up: it stays plainly secondary against `--text`
     * and stops being invisible against the box it is in.
     */
    --muted-raised: color-mix(in srgb, var(--muted) 62%, var(--text));
  }
}

/*
 * Text named for the surface it sits on, not inherited from the page.
 *
 * `.msg.jarvis` carried a background and no colour at all, so an assistant's
 * reply took `--text` from `body` -- a colour chosen for the *page*, on a card
 * that is a different colour. That is how it became `#e8eef7` on `#eef2f8`,
 * 1.04:1, when the card's surface stopped following the palette: the two were
 * never tied together, so nothing had to change for them to disagree.
 *
 * They are tied now. Both tokens resolve to the same palette the surface comes
 * from, so a card and its text move together or not at all, and every rule
 * that paints text on a panel says which surface it means.
 */
:root {
  --on-panel: var(--text);
  --on-panel-muted: var(--muted-raised, var(--muted));
}


* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  /*
   * `--app-height` is set by `followKeyboard()` from `visualViewport`, which is
   * the only thing that reports the software keyboard. `100%` is the fallback
   * for a browser without it, and it is what every desktop uses.
   *
   * Deliberately not `100dvh`: the dynamic viewport unit tracks browser
   * chrome, not the keyboard, so it is behind the keyboard exactly as `%` is.
   */
  height: var(--app-height, 100%);
  background: var(--bg);
  color: var(--text);
  font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  /* The installed app sits under the notch and over the home indicator. */
  padding: var(--sat) var(--sar) var(--sab) var(--sal);
}

body {
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/*
 * The document is not a scroller, and on iOS that has to be said twice.
 *
 * `overflow: hidden` stops a scrollbar; it does not stop WebKit scrolling the
 * document itself. iOS scrolls the page to reveal a focused field, and it
 * rubber-bands past the end of a page under a finger -- and either can leave a
 * page displaced with nothing to put it back, because there is no scrollbar to
 * show it happened and no scroll the app performed to undo. That is the owner's
 * first symptom: "the ENTIRE JARVIS app shell is pushed far down, leaving a huge
 * blank black area above the JARVIS header." A page that cannot scroll cannot
 * be left scrolled.
 *
 * `position: fixed` with all four insets takes the document out of the scrolling
 * model altogether: there is no scrollable overflow to move, so `scrollY` is
 * structurally zero rather than incidentally zero. `overscroll-behavior: none`
 * stops the rubber band that would otherwise still animate.
 *
 * It is in a media query rather than everywhere on purpose. A desktop window is
 * resizable and a fixed body cannot grow with it; this is the phone's problem
 * and the phone's fix. Below, the mobile block relies on it.
 */
@media (max-width: 640px), (max-height: 620px) {
  html,
  body {
    position: fixed;
    inset: 0;
    width: 100%;
    overscroll-behavior: none;
    /*
     * `touch-action` on the body rather than on the scroller: the conversation
     * declares its own, and everything else on a phone is a tap.
     */
    -webkit-overflow-scrolling: auto;
  }
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

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

/*
 * The `hidden` attribute has to actually hide.
 *
 * The browser's own stylesheet says `[hidden] { display: none }`, but that is a
 * user-agent rule and ANY author rule beats it. `.view { display: flex }` did,
 * so every `hidden` view stayed in the layout: all five rendered at once,
 * stacked, each taking flex space. On a phone that put the Devices section
 * hundreds of pixels down the page behind a screenful of nothing, which is
 * exactly the "excessive blank space" and "content is missing" the owner saw.
 *
 * `!important` on purpose, and it is the right tool for once: this must beat
 * every author rule, present and future, because the alternative is that
 * adding a `display` to some component silently un-hides it somewhere else.
 */
[hidden] {
  display: none !important;
}

#app {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

.bar {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 14px;
  border-bottom: 1px solid var(--line);
  background: var(--panel);
}

/*
 * On a desktop the group is not a box, it is just markup.
 *
 * `display: contents` drops it out of the layout entirely, so the header stays
 * the single flex row it has always been. `order` puts its two children back
 * either side of the tab list, which is where they were before the grouping.
 */
.bar-top {
  display: contents;
}

.bar-top h1 {
  order: 0;
}

.bar > .tabs {
  order: 1;
}

.bar-top #sign-out {
  order: 3;
}

.bar h1 {
  font-size: 15px;
  letter-spacing: 0.14em;
  margin: 0;
  color: var(--muted);
  font-weight: 600;
}

.tabs {
  display: flex;
  gap: 4px;
  flex: 1;
}

.tab {
  background: transparent;
  border: 1px solid transparent;
  color: var(--muted);
  border-radius: 999px;
  padding: 6px 12px;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}

.tab[aria-current="true"] {
  background: var(--panel-2);
  border-color: var(--line);
  color: var(--text);
}

.badge {
  display: inline-block;
  margin-left: 6px;
  min-width: 18px;
  padding: 0 5px;
  border-radius: 999px;
  background: var(--warn);
  color: var(--bg);
  font-size: 12px;
  font-weight: 700;
  text-align: center;
}

.link {
  background: none;
  border: 0;
  color: var(--muted);
  font: inherit;
  font-size: 14px;
  cursor: pointer;
  text-decoration: underline;
}

.view {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}

.note {
  color: var(--muted);
  font-size: 14px;
  margin: 12px 14px 0;
}

/* ------------------------------------------------------------------- chat */

.messages {
  list-style: none;
  margin: 0;
  /*
   * Real room under the last message, inside the scroller.
   *
   * The list ends exactly where the composer begins, so the newest bubble used
   * to sit flush against the input's top border -- readable, but reading as
   * "there is more underneath this". The padding is part of the scrollable
   * content, so scrolling to the end actually reveals it rather than stopping
   * short of it.
   */
  padding: 14px 14px 18px;
  overflow-y: auto;
  /*
   * The end of this list is the end of it. Without this a flick that runs past
   * the bottom hands the scroll to the page, which on iOS drags the whole
   * shell and takes the composer with it.
   */
  overscroll-behavior: contain;
  /*
   * Where `scrollIntoView` considers the bottom edge to be. The composer is
   * measured rather than assumed, so a two-line draft and a ten-line one both
   * leave the last message clear.
   */
  scroll-padding-bottom: calc(var(--composer-h, 0px) + 12px);
  flex: 1;
  /*
   * A floor, so the conversation cannot be squeezed out of existence.
   *
   * Flexbox will happily resolve an over-tall column by taking every pixel
   * from the one item that is allowed to shrink, and that item is the
   * conversation. With the keyboard up and a long draft it was taken down to
   * 28px -- one line of a reply the owner had asked for. `min-height: 0` is
   * still what lets it scroll; this is the smallest it may become while doing
   * so.
   */
  min-height: 4.5rem;
  display: flex;
  flex-direction: column;
  gap: 10px;
  -webkit-overflow-scrolling: touch;
}

.msg {
  max-width: 46rem;
  padding: 10px 13px;
  border-radius: var(--radius);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/*
 * Text on a signal fill is the palette's own background, everywhere.
 *
 * These fills -- accent, danger, warn, ok -- are theme data, and their labels
 * used to be four hard-coded near-blacks: right for a bright fill on a dark
 * page, wrong the moment the fill is dark. The light fallback tried to correct
 * for that with a list of selectors forced to white, which then leaked over
 * every themed fill and measured as low as 1.57:1.
 *
 * `var(--bg)` needs no list and no correction. A palette's background is by
 * construction the thing its accent is meant to stand out against, so it
 * inverts with the theme: near-black under a bright accent, near-white under a
 * dark one. The synthetic light theme in `mobile-contrast.test.ts` exists to
 * hold that, because every builtin is dark and none of them would notice.
 */
.msg.owner {
  align-self: flex-end;
  background: var(--accent);
  color: var(--bg);
}

.msg.jarvis {
  align-self: flex-start;
  background: var(--panel-2);
  /* Stated, because this card is not the page. */
  color: var(--on-panel);
}

/*
 * Tool output is not speech. It is rendered as a labelled, visually distinct
 * note so that content JARVIS *read* can never be mistaken for something
 * JARVIS *said* — the same distinction the system makes internally.
 */
.msg.tool {
  align-self: flex-start;
  background: transparent;
  border: 1px dashed var(--line);
  color: var(--muted);
  font-size: 14px;
}

.msg.system {
  align-self: center;
  background: transparent;
  color: var(--warn);
  font-size: 14px;
  text-align: center;
}

.composer {
  display: flex;
  gap: 8px;
  padding: 10px 14px;
  border-top: 1px solid var(--line);
  background: var(--panel);
  align-items: flex-end;
  /*
   * It is in normal flow and cannot be overlapped today; this says so, so that
   * it stays true. A message with a transform, a negative margin or a sticky
   * child would otherwise paint over the field the owner types into, and that
   * is a bug nobody would think to look for in a stylesheet.
   *
   * It never shrinks either: flexbox resolves an over-tall column by taking
   * space from whatever may give, and the composer giving space means a Send
   * button clipped off the bottom of the screen.
   */
  position: relative;
  z-index: 1;
  flex: none;
}

/*
 * The placeholder, said in a colour this app chose.
 *
 * Left to the browser it is a fixed grey -- `rgb(117, 117, 117)` in Chromium
 * and WebKit alike -- which knows nothing about the surface under it: 4.10:1
 * on the light composer and 3.41:1 on the dark one, under the floor in both.
 * `--muted` is the colour this stylesheet already uses for text that is
 * present but secondary, and it is defined per scheme.
 *
 * Every field, not just this one: the stop-reason box and the bootstrap form
 * ask for text the owner has to get right.
 */
input::placeholder,
textarea::placeholder {
  color: var(--on-panel-muted);
  /* Firefox dims a placeholder to 0.54 of its own accord. */
  opacity: 1;
}

.composer textarea {
  flex: 1;
  resize: none;
  /*
   * Thirty per cent of what is visible, not of the screen.
   *
   * `30vh` measures the large viewport and ignores the keyboard entirely, so
   * on an 844pt iPhone this was 253px whether or not 340 of those points were
   * behind a keyboard: a long draft grew the composer to 274px inside a 506px
   * shell, and the reply the owner was trying to read was reduced to one line.
   * `--vvh` is the visual viewport, published by `followKeyboard()`.
   */
  max-height: calc(var(--vvh, 100vh) * 0.3);
  /* Past the cap the draft scrolls inside the field rather than growing it. */
  overflow-y: auto;
  padding: 10px 12px;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background: var(--panel-2);
  color: var(--on-panel);
  font: inherit;
}

/*
 * A focused field, said out loud.
 *
 * There was no author focus style on any input in this app -- only the
 * browser's own ring, which on iOS is a hairline the composer's own border
 * hides, and which no palette here has any say over. The owner types into
 * these; which one has the caret should not be something they infer from the
 * caret.
 *
 * `:focus-visible` rather than `:focus` to match the capability legend above,
 * and it costs nothing here: the spec has a text field match `:focus-visible`
 * whenever it is focused, however it was reached.
 */
.composer textarea:focus-visible,
.gate input:focus-visible,
.safety form input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.composer button {
  padding: 10px 16px;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background: var(--panel-2);
  color: var(--text);
  font: inherit;
  cursor: pointer;
}

.composer button[type="submit"] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--bg);
  font-weight: 600;
}

/*
 * The icon buttons are narrower than Send on purpose. There are three of them
 * now -- attach, talk, speak -- and at 320px the composer is one flex row with
 * a textarea that still has to be usable.
 *
 * The numbers are load-bearing rather than taste. Adding the third button
 * squeezed the text field to 63px on a 320px screen, which is a composer that
 * technically fits and that nobody can type into. The row was measured, not
 * eyeballed: `mobile-layout.test.ts` asserts the field keeps more than 80px
 * and that Send stays on screen, and it failed before these rules existed.
 *
 * `flex: none` on the buttons and a `min-width` on the textarea are what make
 * the squeeze impossible rather than merely unlikely -- without them, flexbox
 * resolves an overflowing row by taking the space from whichever item is
 * allowed to shrink, and the only such item is the one the owner types into.
 */
.composer .talk {
  padding: 10px 10px;
  flex: none;
  /* A touch target, and the smallest one that is still one. */
  min-width: 2.25rem;
}

.composer button[type="submit"] {
  flex: none;
}

.composer textarea {
  min-width: 5.5rem;
}

@media (max-width: 360px) {
  /* The tightest screens buy the room back from the padding rather than from
     the text field. */
  .composer {
    gap: 6px;
    padding: 10px 8px;
  }

  .composer button[type="submit"] {
    padding: 10px 10px;
  }
}

/*
 * The primary control, marked as one while it is off.
 *
 * Lit, this button is unmistakable -- a red microphone is a red microphone.
 * Off, it was one of three identical grey buttons in a row, which is the state
 * the owner was actually looking at when they could not tell which one starts
 * a conversation. The accent outline is the smallest thing that says "this is
 * the one" without adding a control or taking room from the text field, which
 * `mobile-layout.test.ts` measures as exactly full at 320px.
 */
.composer .primary-voice[aria-pressed="false"] {
  border-color: var(--accent);
  color: var(--accent);
}

.talk[aria-pressed="true"] {
  background: var(--danger);
  border-color: var(--danger);
  color: var(--bg);
}

/*
 * Lit, the two toggles must not look the same, because they do not mean the
 * same thing. Red is a live microphone. "Read replies aloud" is a setting the
 * owner left on, so it takes the accent instead.
 */
#speak[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--bg);
}

/*
 * Disabled, and still legible.
 *
 * `opacity: 0.55` over an accent fill measured 1.67:1 in light mode and
 * 3.10:1 in dark -- a Send button whose label the owner cannot read, which is
 * worse than one that merely looks unavailable. A disabled control says so by
 * losing its colour, not by fading through it: the fill drops to the panel,
 * the label to muted, and both are real values with a contrast that can be
 * measured.
 */
button[disabled] {
  cursor: default;
}

/* ------------------------------------------------------- approvals, tasks */

.cards {
  list-style: none;
  margin: 0;
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  overflow-y: auto;
  flex: 1;
}

.card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 14px;
}

.card h2 {
  margin: 0 0 6px;
  font-size: 16px;
  overflow-wrap: anywhere;
}

.card dl {
  margin: 10px 0 0;
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 4px 12px;
  font-size: 14px;
}

.card dt {
  color: var(--muted);
}

.card dd {
  margin: 0;
  overflow-wrap: anywhere;
}

.card .actions {
  display: flex;
  gap: 8px;
  margin-top: 14px;
}

.card .actions button {
  flex: 1;
  padding: 10px;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background: var(--panel-2);
  color: var(--text);
  font: inherit;
  cursor: pointer;
}

.card .actions .approve {
  background: var(--ok);
  border-color: var(--ok);
  color: var(--bg);
  font-weight: 600;
}

.card .actions .deny {
  color: var(--danger);
}

.pill {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.04em;
  border: 1px solid var(--line);
  color: var(--muted);
}

.pill.LOW { color: var(--ok); }
.pill.MEDIUM { color: var(--accent); }
.pill.HIGH { color: var(--warn); }
.pill.CRITICAL { color: var(--danger); border-color: var(--danger); }
.pill.COMPLETED { color: var(--ok); }
.pill.FAILED,
.pill.CANCELLED { color: var(--danger); }
.pill.WAITING_FOR_OWNER,
.pill.WAITING_FOR_PC,
.pill.WAITING_FOR_EXTERNAL_SERVICE { color: var(--warn); }

/*
 * Health, which is a different question from presence.
 *
 * A device card can carry both pills at once and they can disagree: ONLINE
 * beside "Not working" is exactly the state the owner spent an afternoon in,
 * and it is the pair of words that explains it.
 */
.pill.health-PASS { color: var(--ok); }
.pill.health-WARN { color: var(--warn); }
.pill.health-FAIL,
.pill.health-UNKNOWN { color: var(--danger); border-color: var(--danger); }

.why {
  list-style: none;
  padding: 0;
  margin: 6px 0 0;
  display: grid;
  gap: 4px;
}

.why li {
  font-size: 13px;
  color: var(--muted);
  /* Wraps rather than scrolls: this is read on a 320px phone. */
  overflow-wrap: anywhere;
}

.why .why-FAIL { color: var(--danger); }
.why .why-WARN { color: var(--warn); }

/* ------------------------------------------------------------------- gate */

.gate {
  margin: auto;
  padding: 24px;
  max-width: 26rem;
  text-align: center;
}

.gate h1 {
  letter-spacing: 0.2em;
  font-size: 22px;
}

.gate button,
.gate input {
  width: 100%;
  padding: 12px;
  margin-top: 10px;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background: var(--panel-2);
  color: var(--on-panel);
  font: inherit;
}

.gate button {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--bg);
  font-weight: 600;
  cursor: pointer;
}

.status {
  position: fixed;
  /*
   * Above the bottom navigation, which declares `z-index: 20`. Without a
   * stacking order of its own the toast is painted under an opaque bar
   * whenever the two overlap, and `say(error.message, "error")` is this app's
   * only error surface.
   */
  z-index: 30;
  left: 50%;
  bottom: calc(16px + var(--sab));
  transform: translateX(-50%);
  max-width: 90vw;
  padding: 10px 16px;
  border-radius: 999px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  font-size: 14px;
  margin: 0;
}

.status.error {
  border-color: var(--danger);
  color: var(--danger);
}

.panel {
  margin: auto;
  padding: 24px;
  color: var(--muted);
}

/*
 * Light mode, and the reason it was unreadable on the owner's phone.
 *
 * This block used to redefine the five neutrals and stop. The four signal
 * colours -- accent, danger, warn, ok -- stayed exactly as they are on a black
 * background, where they are meant to glow. Measured against the light page
 * they collapse: warn 1.66:1, ok 1.80:1, accent 2.31:1, danger 2.61:1, where
 * 4.5 is the readable floor for body text. Every system notice, every status
 * pill, every lit capability was painted in one of them.
 *
 * The owner's own message bubble was the worst of it. It is `background:
 * var(--accent)` with white text forced on here, which is white on #5fa8ff:
 * 2.46:1. Not a subtle failure -- a message the owner had just typed, that
 * they could not read back.
 *
 * Each of the four is used two ways, and both have to work: as text on the
 * page, and as a fill with text on top of it. The values below clear 4.5:1 as
 * text on all three light surfaces (page, panel and card), and white on top of
 * them clears 5.6:1 -- so the same token serves both roles rather than needing
 * a second set of names that some future rule would forget to use.
 */
@media (prefers-color-scheme: light) {
  /*
   * `:not([data-themed])`, because this is the fallback palette and not a
   * second one to blend with.
   *
   * `applyAppearance()` writes the worn theme's colours as inline properties
   * on `:root`, which beat every stylesheet rule including this block — but
   * only for the names it sets. So a themed app in light mode took its
   * `--accent`, `--text` and `--muted` from the theme and the rest of this
   * block from here, and the two halves were built for different backgrounds.
   * Every hard-coded `color` below leaked the same way: white labels on fills
   * a theme had already repainted bright, measured as low as 1.57:1.
   *
   * A theme is a complete palette. When one is worn it decides, and this block
   * stands down; the marker is set by the same function that applies it.
   */
  :root:not([data-themed]) {
    --bg: #f6f8fb;
    --surface: #ffffff;
    --panel-2: #eef2f8;
    --line: #d5dde8;
    --text: #10141b;
    --muted: #5a6678;
    /* Text on the light page: 5.40, 5.28, 6.00, 5.70 against #f6f8fb. */
    --accent: #1565c0;
    --danger: #c62828;
    --warn: #8a5200;
    --ok: #14713f;
    color-scheme: light;
  }

  /*
   * The other half of the swap. These four fills are dark now, so the text on
   * them is white -- the near-black each of them carries was chosen for a
   * pale fill and would be a dark-on-dark card, which is the same defect
   * pointing the other way.
   */
}

/* -------------------------------------------------------------- health */

/*
 * Lists inside a card, without the browser's bullets and indentation.
 *
 * The health card is mostly sentences: what is wrong, and what could not be
 * confirmed. A bulleted list of full sentences on a 320px screen wraps into
 * something nobody reads.
 */
.card ul.plain {
  list-style: none;
  margin: 4px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-size: 14px;
  overflow-wrap: anywhere;
}

.card ul.plain .note {
  display: block;
  font-size: 13px;
  color: var(--muted);
}

.card h3 {
  margin: 12px 0 0;
  font-size: 12px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
  font-weight: 600;
}

/* The overall verdict, in the same shape as every other pill on a card. */
.pill.HEALTHY { border-color: var(--ok); color: var(--ok); }
.pill.DEGRADED,
.pill.RECOVERING,
.pill.UNKNOWN { border-color: var(--warn); color: var(--warn); }
.pill.BLOCKED_EXTERNAL,
.pill.QUARANTINED { border-color: var(--accent); color: var(--accent); }
.pill.FAILED { border-color: var(--danger); color: var(--danger); }

/*
 * How achievable a client's week is.
 *
 * NOT_ENOUGH_EVIDENCE is deliberately the same warn colour as UNKNOWN and not
 * a neutral grey: "I cannot tell you yet" is a thing to act on, and styling it
 * as absence is how it becomes a line the owner reads past.
 */
.pill.WITHIN_CAPACITY { border-color: var(--ok); color: var(--ok); }
.pill.NEEDS_MORE_THAN_STATED,
.pill.WRONG_FORMAT { border-color: var(--accent); color: var(--accent); }
.pill.NOT_ENOUGH_EVIDENCE { border-color: var(--warn); color: var(--warn); }

/* The week's plan, inside a client card. */
.card .plan {
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--line);
}

/* -------------------------------------------------------------- safety */

/*
 * The stop strip, above every view.
 *
 * Deliberately quiet while nothing is wrong: a permanently red banner is one
 * the eye stops seeing, and this has to be legible on the day it matters. It
 * only takes colour when JARVIS is actually stopped or restricted.
 */
.safety {
  padding: 8px 14px 0;
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.safety > .link {
  align-self: flex-start;
  font-size: 12px;
  color: var(--muted);
}

.safety-state {
  margin: 0;
  padding: 8px 10px;
  border-radius: var(--radius);
  font-size: 13px;
  line-height: 1.35;
  overflow-wrap: anywhere;
}

.safety-state.stopped {
  background: color-mix(in srgb, var(--danger) 18%, var(--panel));
  border: 1px solid var(--danger);
  color: var(--text);
}

.safety-state.restricted {
  background: color-mix(in srgb, var(--warn) 16%, var(--panel));
  border: 1px solid var(--warn);
  color: var(--text);
}

.safety form {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.safety form .row {
  display: flex;
  gap: 8px;
  align-items: center;
}

.safety label {
  font-size: 12px;
  color: var(--muted);
}

/* ------------------------------------------------------- recent, devices */

.recent {
  padding: 12px 14px 0;
  flex: 0 0 auto;
}

/*
 * The heading is a button now, and on a desktop it must be indistinguishable
 * from the text it replaced: no chrome, no pointer, no arrow, and the same
 * typography. Only the phone collapses, so only the phone shows a control.
 */
.recent h2 button {
  all: unset;
  display: flex;
  align-items: center;
  gap: 8px;
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
}

.recent .chev,
.recent #recent-count {
  display: none;
}

.recent h2 {
  margin: 0 0 6px;
  font-size: 12px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
  font-weight: 600;
}

.recent ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

/*
 * Four rows on a wide screen, which is what this strip has always shown.
 *
 * The cap lives here rather than in `refreshRecent` because how many rows fit
 * is a question about the screen: the phone's panel is a scroll container and
 * shows all of them, and the media query below says so. Every row is in the
 * document either way, so a screen reader on a desktop still reaches the rest.
 */
.recent li:nth-child(n + 5) {
  display: none;
}

.recent li {
  font-size: 14px;
  color: var(--muted);
  overflow-wrap: anywhere;
}

.recent li .dot {
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 999px;
  background: var(--muted);
  margin-right: 8px;
  vertical-align: middle;
}

.recent li.HIGH .dot { background: var(--warn); }
/* A row opens what it is about, like the lock-screen notification. */
.recent li[role="button"] { cursor: pointer; }
.recent li.HIGH { color: var(--text); }

/*
 * "x3" on a collapsed row.
 *
 * Repeats are folded into one line so four identical Done rows do not fill the
 * whole strip, and the count is kept because "five times" and "once" are
 * different facts. Dimmer than the text: it is a footnote to the row, not a
 * second thing to read.
 */
.recent li .count {
  margin-left: 6px;
  font-variant-numeric: tabular-nums;
  /*
   * No `opacity`, and no colour of its own: it inherits `--muted` from the row.
   * At 0.75 opacity this measured 3.26:1 in light mode, and "x3" is the part
   * of the row that says how many times a thing happened.
   */
}

button.wide {
  margin: 14px 14px 0;
  padding: 12px;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background: var(--panel-2);
  color: var(--text);
  font: inherit;
  cursor: pointer;
}

.pairing {
  margin: 12px 14px 0;
}

.command {
  margin: 8px 0 0;
  padding: 12px;
  border-radius: var(--radius);
  background: var(--panel);
  border: 1px solid var(--line);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 13px;
  overflow-x: auto;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  user-select: all;
}

.pill.ONLINE { color: var(--ok); }
.pill.OFFLINE { color: var(--muted); }
.pill.REVOKED { color: var(--danger); }
.pill.PENDING { color: var(--warn); }

/* ------------------------------------------------------------------ mobile */
/*
 * Until this existed the stylesheet had no width media query at all, and the
 * header was one non-wrapping flex row: title, five tabs, sign out. Measured in
 * a real browser, its intrinsic width was 540px at EVERY viewport — so on a
 * 390px iPhone, Devices and Empire were not cramped, they were off the right
 * edge of the screen entirely, and the page scrolled sideways. That is the
 * whole "content looks missing" report.
 *
 * The fix is not to shrink the row. Five destinations do not fit across 320px
 * at a legible size, and a horizontally scrolling strip hides them behind a
 * gesture with nothing to suggest it. So on a phone the navigation moves to
 * the bottom, which is where an iOS app puts it: every destination visible at
 * once, in the half of the screen a thumb reaches.
 *
 * Same markup, only CSS. Nothing above this block is touched, so the desktop
 * layout is exactly what it was.
 */
@media (max-width: 640px) {
  /*
   * The bottom inset is applied exactly once, and this is where it stops
   * being applied twice.
   *
   * `body` pads itself by the safe-area insets so the installed app can sit
   * under the notch. `.tabs` is `position: fixed`, which is positioned against
   * the viewport and ignores that padding entirely -- so the bar sat at the
   * true bottom edge and added the inset again itself, while the body's own
   * bottom inset went on shrinking the box everything else was laid out in.
   * On a phone with a home indicator that is the inset counted twice and a
   * strip of background nobody asked for.
   *
   * On a phone the bar is the bottom-most thing on the screen, so the bar owns
   * the inset. Desktop is untouched: there is no fixed bar there and the body
   * keeps its own padding.
   */
  body {
    padding-bottom: 0;
  }

  /*
   * The navigation is a row of the shell, not a layer over it.
   *
   * It was `position: fixed` with the shell holding an equal amount of padding
   * open for it, and those are two numbers that have to agree about the height
   * of a bar only one of them can see. They did not, twice: the strip of
   * background above the bar, and the bottom inset applied twice, are both in
   * this file's history. And a fixed bar is painted over whatever is beneath
   * it, so "JARVIS replies are still hidden behind the composer/bottom
   * navigation" is a thing that *can* happen -- the reserved padding is a
   * promise, and a promise is not a layout.
   *
   * As a flex row it consumes real height. The conversation is `flex: 1` and
   * receives exactly what is left, so a reply rendering underneath the bar is
   * not merely unlikely, it has nowhere to render. There is one number now and
   * flexbox owns it.
   */
  #app {
    padding-bottom: 0;
  }

  /*
   * While the keyboard is up there is no bottom bar, and nothing held back for
   * one. `followKeyboard()` sets the attribute from `visualViewport`; this is
   * the whole of what the stylesheet does with it.
   */
  :root[data-keyboard] .tabs {
    display: none;
  }

  :root[data-keyboard] #app {
    padding-bottom: 0;
  }

  /*
   * Nothing above the conversation moves when the keyboard opens. Nothing.
   *
   * The previous version hid the orb, the chips, the microphone line and Recent
   * while the keyboard was up, and put them back when it closed. Each of those
   * is above Recent in the column, so every one of them moved Recent -- up by
   * 227px on the way in and back down on the way out. The owner read that as
   * exactly what it is: "parece subir por un instante, pero inmediatamente
   * vuelve a bajar y termina casi en la misma posición."
   *
   * It was not drift and it was not a scroll fighting a layout. It was a second
   * layout, and having two layouts for one screen is what makes a screen jump.
   * There is one now: the block above the conversation has the same height with
   * the keyboard up as with it down, so Recent has one Y and keeps it.
   *
   * The conversation absorbs the whole of the keyboard instead, which is what
   * `flex: 1` on a scroller is for -- it is the one region that can lose height
   * without anything moving, because losing height is what a scroller does. It
   * gets ~137px while typing on a 390x844 rather than the ~209px the hiding
   * bought, and that is the trade this file makes deliberately: a smaller
   * conversation that stays where it is beats a larger one that arrives by
   * moving everything the owner was looking at.
   *
   * The two rules that remain keyed on the keyboard are both *below* the
   * conversation -- the bottom navigation goes, and the shell stops reserving
   * its height -- so neither can move Recent.
   */

  /*
   * The toast clears the bar rather than sitting behind it.
   *
   * `.status` is `bottom: 16px` and the bar is `52px + inset` of opaque panel
   * at a higher stacking level, so 36px of every message the app has -- and
   * `say(error.message, "error")` is its only error surface -- was painted
   * over. It predates this change; the change made the bar 7px taller and is
   * the reason it was measured.
   */
  .status {
    bottom: calc(var(--nav-h) + var(--sab) + 12px);
  }

  /*
   * With the keyboard up the toast has to be measured against the space, not
   * the screen.
   *
   * `position: fixed` on iOS is positioned against the *layout* viewport,
   * which the keyboard does not shrink -- so `bottom: 12px` is 12px above the
   * bottom of a screen whose lower half is a keyboard, and the message is
   * behind it. `100vh - var(--vvh)` is exactly how much of the screen the
   * keyboard has taken, so this sits the toast just above it.
   */
  :root[data-keyboard] .status {
    bottom: calc(100vh - var(--vvh, 100vh) + 12px);
  }

  /* The title row keeps the title and sign-out; the tabs leave it. */
  .bar-top {
    padding: 10px 14px;
    gap: 8px;
  }

  /*
   * The header dissolves so its two groups can go to opposite ends of the
   * shell, and each becomes a row of `#app` in its own right.
   */
  .bar {
    display: contents;
  }

  .bar-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 10px 14px;
    border-bottom: 1px solid var(--line);
    background: var(--panel);
    order: 0;
    flex: none;
  }

  /*
   * `.bar > .tabs` rather than `.tabs`, to match the specificity of the desktop
   * rule this has to beat.
   *
   * The desktop header orders its three items with `.bar > .tabs { order: 1 }`,
   * which is (0,2,0). A media query adds no specificity at all, so a plain
   * `.tabs { order: 2 }` in here is (0,1,0) and loses -- and the "bottom"
   * navigation renders directly under the title, which is exactly what it did
   * when this was first written. Measured, not reasoned about.
   */
  .bar > .tabs {
    /*
     * A row, in flow, at the end of the shell. Real height that the
     * conversation's `flex: 1` then cannot be given, which is what makes a
     * reply underneath it impossible rather than merely unlikely.
     */
    order: 2;
    flex: none;
    gap: 0;
    background: var(--panel);
    border-top: 1px solid var(--line);
    /*
     * Declared rather than left to the content, so the row is the same height
     * whatever the labels do. Above the home indicator, not under it -- and the
     * inset is applied here and nowhere else, because on a phone this is the
     * bottom-most thing on the screen.
     */
    height: calc(var(--nav-h) + var(--sab));
    padding-bottom: var(--sab);
  }

  /*
   * The views sit between them. Stated explicitly because `order` defaults to 0
   * and one item opting out of the default is a rule that only works while
   * nobody else opts in.
   */
  .view {
    order: 1;
  }

  .tab {
    flex: 1 1 0;
    min-width: 0;
    border-radius: 0;
    border: 0;
    padding: 10px 2px calc(10px + env(safe-area-inset-bottom, 0px) * 0);
    font-size: 11px;
    letter-spacing: 0.01em;
    /* Five labels across 320px: let them shrink, never spill. */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    /* Comfortably past the 44px Apple touch target once padding is counted. */
    min-height: 44px;
  }

  .tab[aria-selected="true"],
  .tab.active {
    border-top: 2px solid var(--accent, currentColor);
    padding-top: 8px;
  }

  /*
   * Nothing here reserves room for the bar.
   *
   * There used to be three rules that did -- 64px on the scrollers, 56px of
   * margin under the composer, 64px again on the devices view -- each of them
   * a guess at the height of a bar none of them could see, and each of them
   * adding the safe-area inset the body had already added. The composer's
   * margin was the worst of them: a margin on the last item of a flex column
   * makes the column taller than the box it has to fit in, so on a 375x667
   * screen the composer was laid out 80px below the bottom of the viewport and
   * clipped, and on a 390x844 one it left a 71px band of background between
   * itself and the bar. `#app`'s padding is the single reservation now.
   */

  /*
   * The draft field is capped so that the column always fits, on a phone.
   *
   * 30% of the visible height is right on a desktop and slightly too generous
   * here. With the keyboard up, 30% of 506 is a 173px composer, and the bar,
   * the core, Recent, the conversation's own floor and that composer come to
   * 547px inside 506 -- so flexbox took the difference from the only item that
   * may yield, which is the core, and Recent moved 16px. Stability above the
   * conversation is the whole point of this screen, and a number that makes the
   * column fit is how it is kept rather than hoped for.
   *
   * 22% leaves the column 40px of slack at the tightest real combination. The
   * core and Recent stay `flex: 0 1 auto` behind it, so a case nobody predicted
   * still yields a few pixels of orb rather than laying the Send button off the
   * bottom of the screen -- which is what rigidity here cost the last time it
   * was tried, and is written down four hundred lines above.
   */
  .composer textarea {
    max-height: calc(var(--vvh, 100vh) * 0.22);
  }

  /*
   * The devices view had no scroll container of its own: the button, the
   * pairing panel and the device list were siblings under `overflow: hidden`,
   * so anything past the fold was unreachable rather than merely below.
   */
  #view-devices {
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
  }

  #view-devices .cards {
    overflow: visible;
    flex: 0 0 auto;
    padding-bottom: 0;
  }
}

/* ------------------------------------------------------- the pairing panel */
/*
 * A card rather than a loose paragraph and a <pre>. It carries a single-use
 * credential the owner has to act on within ten minutes, so it gets a title, a
 * way out, and a copy button — and it stays compact enough that the command is
 * on screen with the button that produced it.
 */
.pairing {
  margin: 12px 14px 0;
  padding: 14px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--panel-2, var(--panel));
}

.pairing-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
}

.pairing-head h2 {
  margin: 0;
  font-size: 15px;
  font-weight: 600;
}

.pairing .note {
  /* The card already provides the gutter. */
  margin: 8px 0 0;
}

.pairing .command {
  /* Long enough to wrap on a phone; wrapping beats a sideways scroll the
     owner has to discover inside a panel they are already reading. */
  max-height: 40vh;
  overflow-y: auto;
}

.pairing button.wide {
  margin: 10px 0 0;
  width: 100%;
  box-sizing: border-box;
}

button.icon {
  background: none;
  border: 0;
  color: var(--muted);
  font: inherit;
  font-size: 22px;
  line-height: 1;
  padding: 4px 8px;
  cursor: pointer;
  /* A dismiss control small enough to miss is a control that traps people. */
  min-width: 44px;
  min-height: 44px;
}

/*
 * Two labels per destination, one shown at a time.
 *
 * Pure CSS rather than a resize listener: the switch has to survive rotation
 * and Safari's toolbar collapsing, and a media query already does that
 * correctly for free.
 */
.lbl-short {
  display: none;
}

@media (max-width: 640px) {
  .lbl-full {
    display: none;
  }
  .lbl-short {
    display: inline;
  }
}

/* ------------------------------------------------- connected services */
/*
 * Integrations sit under the device list in the same view. A paired computer
 * and a connected shop are the same question asked about different things, and
 * a sixth tab would not fit: the five labels already only just clear 320px.
 */
.integrations {
  margin-top: 20px;
}

.integrations h2 {
  font-size: 15px;
  margin: 0 0 4px;
}

.integrations ul {
  list-style: none;
  margin: 8px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.integration {
  display: flex;
  align-items: baseline;
  gap: 8px;
  font-size: 14px;
  /* Provider ids are short, but `missing` is a sentence and a long one must
     wrap rather than push the page sideways. */
  flex-wrap: wrap;
  overflow-wrap: anywhere;
}

.integration .dot {
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 999px;
  background: var(--muted);
  flex: 0 0 auto;
}

.integration .name {
  font-weight: 600;
}

.integration .state,
.integration .why {
  color: var(--muted);
}

/* The one line that is a request rather than a report. Brighter than the
   state text beside it, because it is the only part the owner must act on. */
.integration .todo {
  color: var(--warn);
}

/* Colour carries no information on its own: the state is always spelled out
   in words beside it, because a dot alone is unreadable to anyone who cannot
   separate these hues. */
.integration.CONNECTED .dot { background: var(--ok); }
.integration.AUTH_FAILED .dot,
.integration.ERROR .dot { background: var(--danger); }
.integration.STALE .dot,
.integration.BLOCKED_OWNER .dot,
.integration.DEFINED .dot { background: var(--warn); }

/* ------------------------------------------------------------------ */
/* Activity: what JARVIS is doing                                      */
/* ------------------------------------------------------------------ */

/* One card per piece of work. Everything on it is a fact from a row: the
   state, the machine, the project, how many planned steps are done. There is
   no percentage anywhere, because nothing measures one. */
.work .work-line,
.work .work-project,
.work .work-event {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 6px 10px;
  margin: 6px 0 0;
  font-size: 14px;
}

.work .work-line .state {
  font-weight: 600;
}

.work .where,
.work .when,
.work .cap {
  color: var(--muted);
}

.work .work-event .detail,
.work .work-project .name {
  overflow-wrap: anywhere;
}

/* Why it stopped, when it did. Red on its own is not the message -- the label
   says "It stopped here:" in words, for the same reason the state dots do. */
.work .work-failed {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 8px;
  margin: 8px 0 0;
  padding: 8px 10px;
  font-size: 14px;
  border-radius: 8px;
  background: color-mix(in srgb, var(--danger) 10%, transparent);
}

.work .work-failed .label {
  font-weight: 600;
}

.work .work-failed .reason {
  overflow-wrap: anywhere;
}

.work .work-failed .code {
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* The "include finished work" switch. A row rather than a button, because it
   is a state the owner leaves set rather than an action they take. */
.view .toggle {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0 0 12px;
  font-size: 14px;
  color: var(--muted);
}

.view .toggle input {
  width: 18px;
  height: 18px;
  flex: 0 0 auto;
}

/* The file a piece of work is moving. Size and destination, never a bar --
   nothing measures transfer progress, and a bar implies something does. */
.work .work-artifact {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 10px;
  margin: 6px 0 0;
  font-size: 14px;
}

.work .work-artifact .name {
  overflow-wrap: anywhere;
}

.work .work-artifact .size,
.work .work-artifact .where {
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.work .work-progress {
  margin: 8px 0 0;
  font-size: 14px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* Colour never carries the meaning on its own: every dot has its state
   spelled out beside it, for the same reason the integrations list does. */
.work .dot {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--muted);
  flex: 0 0 auto;
  align-self: center;
}

.work.RUNNING .dot,
.work.PLANNING .dot {
  background: var(--ok);
}

.work.WAITING_FOR_PC .dot,
.work.WAITING_FOR_EXTERNAL_SERVICE .dot {
  background: var(--warn);
}

.work.WAITING_FOR_OWNER .dot,
.work.needs-you .dot {
  background: var(--warn);
}

.work.FAILED .dot {
  background: var(--danger);
}

/*
 * The director's own cards.
 *
 * A left edge rather than a different card: it is the same question as the
 * tasks below it -- what is JARVIS doing -- at a different grain, and making
 * it look like a separate feature would hide that.
 */
.work.run {
  border-left: 3px solid var(--accent);
}

.work.run .owner-action {
  margin: 8px 0 0;
  font-size: 14px;
  color: var(--warn);
}

.work .todo {
  margin: 8px 0 0;
  font-size: 14px;
  color: var(--warn);
}

.work > .link {
  margin-top: 10px;
  padding: 0;
}

/* The timeline behind one card. Scrolls inside itself so a long turn cannot
   make the page scroll sideways on a 320px phone. */
.timeline {
  list-style: none;
  margin: 0;
  padding: 0;
  max-height: 50vh;
  overflow-y: auto;
}

.timeline .event {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 10px;
  padding: 8px 0;
  border-bottom: 1px solid var(--line);
  font-size: 13px;
}

/* The task's outcome, first, when the panel was opened for one task. */
.timeline .event.outcome .detail { white-space: pre-wrap; overflow-wrap: anywhere; }
.timeline .event:last-child {
  border-bottom: 0;
}

.timeline .stage {
  font-weight: 600;
  overflow-wrap: anywhere;
}

/* ------------------------------------------------------------ el vídeo */

/*
 * Three answers on one card, on a 320px phone.
 *
 * `.card .actions` is a non-wrapping flex row, which is right for the two
 * buttons every other card has and is not right for three: "Pedir cambios" is
 * fourteen characters and the row measured wider than the screen. Wrapping
 * here rather than shortening the words -- a button that says less to fit is a
 * button somebody presses meaning something else.
 */
.card.video .actions,
.card.autopublish .actions {
  flex-wrap: wrap;
}

.card.video .actions button,
.card.autopublish .actions button {
  flex: 1 1 44%;
  min-width: 0;
}

/* The link to the pipeline's own screen sits where a player would be. */
.card.video a.approve {
  display: block;
  margin-top: 12px;
  padding: 10px;
  border-radius: var(--radius);
  background: var(--ok);
  border: 1px solid var(--ok);
  color: var(--bg);
  font-weight: 600;
  text-align: center;
  text-decoration: none;
}

.card.video ul.quality {
  list-style: none;
  margin: 10px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 13px;
  color: var(--warn);
}

/* Wraps rather than widens: a finding is a sentence, and the page must not
   scroll sideways at 320px because one of them was long. */
.card.video ul.quality li {
  overflow-wrap: anywhere;
}

/* The measured value and the line it crossed, under the sentence rather than
   inside it: the sentence is what the owner reads, the numbers are what they
   argue with, and mixing them makes both harder to scan. */
/* `--on-panel-muted`, not `opacity`. Dimming text with opacity fades it
   against whatever is behind it and the contrast ratio stops being knowable —
   which is why the contrast test refuses it, and it caught this. */
.card.video ul.quality .evidence,
.card.video ul.quality .scene {
  display: block;
  font-size: 0.82em;
  color: var(--on-panel-muted);
  font-variant-numeric: tabular-nums;
}

/* ------------------------------------------------------------- auditoría */

/*
 * The audit list, inside Devices.
 *
 * `.cards` is written for a view's whole scrolling column -- `flex: 1` and its
 * own padding -- and this list is a section inside one. Left as it stands the
 * padding would inset the rows from the heading above them and the flex grow
 * would fight the view's own scroll, so both are undone here rather than a
 * second card class being invented.
 */
#audit-list {
  padding: 0;
  flex: 0 0 auto;
  overflow: visible;
  gap: 8px;
  margin: 8px 0 0;
}

#audit-list .card {
  padding: 10px 12px;
}

.audit-head,
.audit-facts {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 8px;
  margin: 0;
  font-size: 12px;
  color: var(--muted);
}

.audit-head .action {
  font-weight: 600;
  color: var(--text);
  /* An action name is one long token. It wraps rather than widening the page:
     `mobile-layout.test.ts` measures horizontal overflow at 320px. */
  overflow-wrap: anywhere;
}

.audit-summary {
  margin: 4px 0;
  font-size: 14px;
  overflow-wrap: anywhere;
}

.audit-facts .cap,
.audit-facts .device,
.audit-facts .code {
  overflow-wrap: anywhere;
}

/*
 * Failure is legible without being alarming.
 *
 * The outcome word carries it rather than the whole card: a red panel for
 * every refused action would make an audit trail that is doing its job look
 * like an incident.
 */
.audit-event.FAILURE .outcome,
.audit-event.BLOCKED .outcome {
  color: var(--danger);
  font-weight: 600;
}

#audit-detail .timeline .outcome,
#audit-detail .timeline .device,
#audit-detail .timeline .code {
  color: var(--muted);
}

/* ------------------------------------------------------------ the orb */

/*
 * The operations core.
 *
 * Every colour here is a custom property the server set from a validated
 * theme, so this stylesheet describes *shape* and the theme describes
 * appearance. That split is what lets a theme be data: there is nothing in a
 * theme document that could reach a selector, and nothing in this file that a
 * theme could add to.
 */
.core {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 10px 0 4px;
  flex: 0 0 auto;
}

.orb {
  display: block;
  width: min(52vw, calc(var(--orb-scale, 0.42) * 900px));
  aspect-ratio: 1;
  max-height: 34vh;
  /* The canvas is the whole of the orb; nothing behind it needs painting. */
  background: transparent;
}

.orb-caption {
  margin: 0;
  font-size: 15px;
  color: var(--text);
  text-align: center;
  min-height: 1.3em;
  letter-spacing: var(--tracking, 0);
  transition: color var(--transition, 300ms) ease;
}

/*
 * The states that are not ordinary work take the colour that says so.
 *
 * Read from the caption's own data attribute rather than from a class the
 * script has to remember to remove: the attribute is set in one place, from
 * the state machine, so the caption cannot be left saying one thing in the
 * colour of another.
 */
.orb-caption[data-state="WAITING_APPROVAL"],
.orb-caption[data-state="DEGRADED"],
.orb-caption[data-state="RECOVERING"] {
  color: var(--warn);
}
.orb-caption[data-state="ERROR"],
.orb-caption[data-state="LOCKDOWN"] {
  color: var(--danger);
  font-weight: 600;
}
.orb-caption[data-state="COMPLETED"] {
  color: var(--ok);
}

/*
 * The microphone's real state, and it is never hidden.
 *
 * A dot plus a word: the dot for the glance, the word for the screen reader
 * and for anybody who does not know what the dot means. There is no state in
 * which this element is `display: none` while the microphone is open.
 */
.mic-state {
  margin: 0;
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: var(--muted);
}

.mic-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--muted);
  flex: 0 0 auto;
}

.mic-state[data-mic="LISTENING"] {
  color: var(--ok);
}
.mic-state[data-mic="LISTENING"] .mic-dot {
  background: var(--ok);
  animation: mic-live 1.4s ease-in-out infinite;
}
.mic-state[data-mic="PROCESSING"] {
  color: var(--accent);
}
.mic-state[data-mic="PROCESSING"] .mic-dot {
  background: var(--accent);
}

@keyframes mic-live {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.35; }
}

/*
 * Where the conversation is, when there is one.
 *
 * A second line rather than more states on the first, because they answer
 * different questions: the microphone line is about the hardware, and this is
 * about the cycle -- listening, understanding, working, speaking. Collapsing
 * them would mean showing "microphone off" while JARVIS is talking to you,
 * which is true and reads as broken.
 *
 * Shares the dot-plus-word shape so the two read as a pair, and shares the
 * `mic-live` pulse for LISTENING so the one state that means "say something"
 * looks the same in both.
 */
.voice-state {
  margin: 0;
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: var(--muted);
}

.voice-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--muted);
  flex: 0 0 auto;
}

.voice-state[data-voice="LISTENING"] {
  color: var(--ok);
}
.voice-state[data-voice="LISTENING"] .voice-dot {
  background: var(--ok);
  animation: mic-live 1.4s ease-in-out infinite;
}
.voice-state[data-voice="UNDERSTANDING"] .voice-dot,
.voice-state[data-voice="WORKING"] .voice-dot {
  background: var(--accent);
}
.voice-state[data-voice="UNDERSTANDING"],
.voice-state[data-voice="WORKING"] {
  color: var(--accent);
}
.voice-state[data-voice="SPEAKING"] {
  color: var(--text);
}
.voice-state[data-voice="SPEAKING"] .voice-dot {
  background: var(--text);
  animation: mic-live 1s ease-in-out infinite;
}
/*
 * Suspended is a warning, not an error.
 *
 * The platform stopped listening and JARVIS did not; the owner has done
 * nothing wrong and nothing is broken. `--warn` says "this is not what you
 * asked for" without saying "this failed".
 */
.voice-state[data-voice="SUSPENDED"] {
  color: var(--warn);
}
.voice-state[data-voice="SUSPENDED"] .voice-dot {
  background: var(--warn);
}

/*
 * The areas that ring the core.
 *
 * A wrapping row rather than an actual ring, and the reason is 320px: twelve
 * labels arranged in a circle at that width are either overlapping or
 * unreadable. The canvas draws the ring; this is the legend, and it stays a
 * list a screen reader can walk.
 */
.areas {
  list-style: none;
  margin: 0;
  padding: 0 8px;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 4px 6px;
  max-width: 520px;
}

/* Focusable, so give the focus somewhere to land. */
.areas:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 6px;
}

.areas .area {
  font-size: 11px;
  /*
   * Dimmed by colour, not by `opacity`.
   *
   * At 0.55 this measured 2.26:1 in light mode and 3.00:1 in dark -- an
   * eleven-pixel label below the readable floor in both, which is the whole
   * list of what JARVIS can do. The lit and unlit states still read as
   * different: one is muted and one takes the accent, with a border to match.
   */
  color: var(--muted);
  padding: 2px 7px;
  border: 1px solid var(--line);
  border-radius: 999px;
  /* Colour, since the dimming stopped being an opacity. */
  transition:
    color var(--transition, 300ms) ease,
    border-color var(--transition, 300ms) ease;
}

/* Lit only while a real capability named this area. */
.areas .area.lit {
  color: var(--orb-link, var(--accent));
  border-color: var(--orb-link, var(--accent));
}

/*
 * A stacked theme puts the panels under the orb rather than round it.
 *
 * The canvas already refuses to draw connection lines when the arrangement is
 * STACK -- lines to a legend that is not in a ring point at nothing.
 */
:root[data-arrangement="STACK"] .areas {
  max-width: 100%;
}

/* Density, from the theme. Shape only: the theme supplies the token. */
:root[data-density="COMPACT"] .card { padding: 8px 10px; }
:root[data-density="COZY"] .card { padding: 10px 12px; }

/* The font stacks a theme may name. A closed list, because a family is a fetch. */
:root[data-stack="MONO"] {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
:root[data-stack="GEOMETRIC"] {
  font-family: "Avenir Next", Avenir, Futura, Corbel, system-ui, sans-serif;
}
:root[data-stack="SERIF"] {
  font-family: Georgia, "Times New Roman", serif;
}

/*
 * Reduced motion stops the pulse too.
 *
 * The canvas honours it by drawing one frame and stopping the loop; this is
 * the CSS half, and it has to be here rather than left to the theme because a
 * theme cannot be trusted to remember and the operating system already asked.
 */
@media (prefers-reduced-motion: reduce) {
  .mic-state[data-mic="LISTENING"] .mic-dot {
    animation: none;
  }
  .orb-caption,
  .areas .area {
    transition: none;
  }
}

/*
 * The phone's operations core.
 *
 * Down here, and not in the mobile block six hundred lines above, for a reason
 * that cost a measurement to find: `.core`, `.orb` and `.areas` are defined
 * *after* that block, and a media query adds no specificity. Written up there
 * the mobile `width` and `flex-wrap` lost to the desktop rules further down and
 * silently did nothing -- the orb measured 202px on a 390px screen with the
 * override in place, exactly as it had without it. A stylesheet whose
 * responsive layer is overridden by the components it adjusts is a trap, and
 * the fix is source order rather than `!important`.
 *
 * Keyed on *either* dimension, and that is the second lesson from the same
 * measurement. Written `max-width: 640px` alone it described a phone held
 * upright and nothing else -- so the moment the owner rotated, the screen went
 * back to a 76px legend, a `flex: 0 0 auto` orb block and a composer 155px
 * below the bottom of the screen. Worse than before the fix, because the rule
 * that had been protecting short screens was hiding the legend outright and
 * this change had removed it.
 *
 * A phone in landscape is 844x390: a wide viewport and a short one. Height is
 * what this block is actually about.
 */
@media (max-width: 640px), (max-height: 620px) {
  /*
   * The operations core, sized for a phone rather than shrunk from a desktop.
   *
   * Measured at 375x667 -- an iPhone SE or 8, which is a phone people still
   * carry -- this block was 423px tall inside a 610px shell. With Recent's
   * 107px under it that left nothing for the conversation and pushed the
   * composer 30px past the bottom of the screen, where `overflow: hidden`
   * clipped it. The orb was only 195px of that: the other 200 were the
   * thirteen capability labels wrapping into five rows.
   *
   * So the legend becomes one row that scrolls sideways. No capability is
   * removed -- all thirteen are still there, in the same order, still a list a
   * screen reader walks in one pass -- and the block costs one row instead of
   * five.
   */
  .areas {
    flex-wrap: nowrap;
    overflow-x: auto;
    justify-content: flex-start;
    max-width: 100%;
    padding: 0 12px 2px;
    -webkit-overflow-scrolling: touch;
    /* The row is a scroller, not a scrollbar: the chips themselves are the
       affordance, and a 2px bar over an 11px label is only noise. */
    scrollbar-width: none;
  }

  .areas::-webkit-scrollbar {
    display: none;
  }

  .areas .area {
    flex: 0 0 auto;
  }

  /*
   * Something on screen that says the row continues. A scroller with no
   * scrollbar and no cut-off edge reads as a complete list of four
   * capabilities rather than a window onto thirteen.
   */
  .areas {
    mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
    -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
  }

  /*
   * The orb stays the first thing on the screen and stops being half of it.
   * `clamp` rather than a media query per phone: 30vw is the shape of the
   * thing on every width, and the two bounds are what keep it recognisable at
   * 320px and from dominating at 430px.
   */
  .orb {
    /*
     * Bounded by both dimensions. `width` with a `max-height` cannot preserve
     * `aspect-ratio: 1` -- one of them has to give, and it was the shape: at
     * 844x390 the desktop `min(52vw, ...)` under a `max-height: 22vh` drew a
     * 378x86 ellipse. Taking the smaller of the two bounds as the width keeps
     * it a circle at every size.
     *
     * Smaller than it was, because the screen it sits on is a chat screen.
     * Measured at 390x844: the orb was 117px and the whole block above the
     * conversation 352px -- 42% of the phone gone before a single message, and
     * the owner asking why the conversation keeps ending up further down. The
     * orb is the largest single item in that block and the one carrying the
     * least text; at 84px it is still unmistakably the orb, still animated,
     * still the first thing on the screen.
     */
    width: min(clamp(64px, 21vw, 96px), 16vh);
    max-height: none;
  }

  /*
   * The tightest screen the app supports, with the longest caption it shows.
   *
   * At 320px an approval request -- "Esperando tu aprobación para publicar el
   * anuncio" -- wraps to three lines, and with a 96px orb above it the
   * operations core took 203px of a 568px screen and left the conversation
   * 95px. The orb and the caption each give up a little rather than the
   * conversation giving up all of it; 14px is still a caption you read at
   * arm's length, and this applies to one phone size.
   */
  @media (max-width: 360px) {
    .orb {
      width: min(clamp(56px, 19vw, 96px), 16vh);
    }

    .orb-caption {
      font-size: 14px;
      line-height: 1.35;
    }
  }

  /*
   * A screen this short is a phone lying on its side, and there the operations
   * core stops being a column.
   *
   * Stacked -- orb, caption, microphone, legend -- it is about 180px, which on
   * a 390px-tall screen leaves the conversation a single line. Laid out as a
   * row it is about 105px for the same information: the orb shrinks, the
   * caption and the microphone state sit beside it instead of under it, and
   * the legend takes the line below. Nothing is removed.
   */
  @media (max-height: 480px) {
    .core {
      flex-direction: row;
      flex-wrap: wrap;
      align-items: center;
      justify-content: center;
      gap: 4px 10px;
    }

    .orb {
      width: min(clamp(56px, 16vh, 96px), 18vh);
    }

    .orb-caption {
      text-align: left;
    }

    /* The legend keeps a line of its own, under the row. */
    .areas {
      flex-basis: 100%;
    }
  }

  .core {
    /*
     * The gaps between the orb, the caption, the microphone line and the
     * capability chips. Four items means four gaps plus the block's own
     * padding, so a couple of pixels each is a dozen pixels of conversation --
     * and on a phone this block sits between the owner and the thing they came
     * for. Two pixels still reads as four separate lines.
     */
    padding: 2px 0 0;
    gap: 2px;
    /*
     * Able to give ground, which it was not. `flex: 0 0 auto` on this and on
     * Recent is what made the composer the only item flexbox could take the
     * space from -- and it took it by laying the composer out below the
     * bottom of the screen. The conversation and the input come first; the orb
     * yields before either of them does.
     */
    flex: 0 1 auto;
    min-height: 0;
  }

  /*
   * Two lines of history and a count of the rest.
   *
   * Recent is a glance; on a phone it competes with the conversation for the
   * same screen and the conversation is what the owner came for. The first
   * version of this rule hid rows three and four and said in a comment that
   * they were a tap away under Activity -- which was not true, and a comment
   * claiming a thing the code does not do is the defect this repository keeps
   * finding. They appear nowhere else, so the strip says how many there are.
   */
  /*
   * Recent gives ground too, and for the same reason the orb does.
   *
   * 78px at 390x844 for a heading and two lines. The heading was 15px on its
   * own line with 8px above the block and 12px under the title; on a phone that
   * is a third of the strip spent on the word "Reciente". The lines themselves
   * are what the owner reads.
   */
  .recent {
    flex: 0 1 auto;
    min-height: 0;
    padding-top: 4px;
  }

  .recent h2 {
    /* The size is already 12px uppercase; it is the space around it that goes. */
    margin-bottom: 3px;
  }

  .recent ul {
    gap: 2px;
  }

  /*
   * Closed by default on the chat screen, and one line when it is.
   *
   * "RECENT/task history consumes most of the Chat screen and pushes the actual
   * conversation below the visible area" -- and the owner is right that a
   * glance at what changed should not outrank the answer they are reading. The
   * rows are still here, still the same rows, one tap away; the full history
   * was always under Activity and still is. Nothing is deleted and no request
   * changed.
   */
  .recent:not([data-open]) ul {
    display: none;
  }

  .recent h2 {
    margin: 0;
  }

  .recent h2 button {
    /*
     * A real touch target: the whole strip opens it, not a 12px word.
     *
     * 44px is Apple's minimum and the number the rest of this stylesheet uses
     * -- `.tab` and `button.icon` both hold that line. A control the owner
     * reaches for while reading a reply is one they should not have to aim at.
     */
    width: 100%;
    min-height: 44px;
    cursor: pointer;
  }

  /* How many there are, so a closed strip still says whether it is worth opening. */
  .recent #recent-count {
    display: inline;
    color: var(--on-panel-muted);
    letter-spacing: 0;
  }

  .recent .chev {
    display: inline-block;
    margin-inline-start: auto;
    /* A triangle from borders: no glyph to go missing, no image to load. */
    border-inline: 4px solid transparent;
    border-top: 5px solid currentColor;
  }

  .recent[data-open] .chev {
    border-top: 0;
    border-bottom: 5px solid currentColor;
  }

  /*
   * Open, it is still bounded: it may take a third of the shell and scroll
   * inside itself rather than pushing the conversation off the screen, which is
   * the behaviour that started this.
   */
  .recent[data-open] ul {
    max-height: calc(var(--vvh, 100vh) * 0.28);
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  /*
   * All of them, on the screen with the least room -- which is only a
   * contradiction until you look at what "open" now costs.
   *
   * Four rows and a "+2" was the compromise of a strip that was always on
   * screen and therefore had to be short. The panel is closed by default here,
   * so its open height is something the owner asked for; capping it would be
   * the old compromise surviving inside the thing that replaced it, and the
   * closed bar's count would be promising rows that opening does not produce.
   *
   * `max-height` and `overflow-y: auto` above are what make that safe: a long
   * history scrolls inside the panel instead of pushing the conversation down,
   * which is the failure this whole change exists to fix.
   */
  .recent li:nth-child(n + 5) {
    display: list-item;
  }

}

/*
 * The legend used to be hidden outright on a short screen, and that rule lived
 * here. It is gone rather than moved: one scrolling row is about twenty pixels,
 * and the block above -- which now covers a short screen at any width -- makes
 * the orb small enough that there is nothing left to buy. Hiding the thirteen
 * capabilities to save twenty pixels removed the one part of this screen that
 * says what JARVIS can actually do.
 */

/* ------------------------------------------------------------ appearance */

.appearance {
  margin-top: 14px;
}

.appearance h2 {
  font-size: 14px;
  color: var(--muted);
  margin: 0 0 8px;
  font-weight: 600;
}

.themes {
  list-style: none;
  margin: 0 0 10px;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.themes .theme {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border: 1px solid var(--line);
  border-radius: var(--radius, 12px);
  background: var(--surface);
  cursor: pointer;
}

/* The one being worn. Marked, not merely first. */
.themes .theme.worn {
  border-color: var(--accent);
}
.themes .theme.worn .name {
  color: var(--accent);
  font-weight: 600;
}

.themes .name {
  flex: 1 1 auto;
  font-size: 14px;
  color: var(--text);
  /* A long theme name wraps rather than pushing the row sideways. */
  min-width: 0;
  overflow-wrap: anywhere;
}

.themes .by {
  font-size: 11px;
  color: var(--muted);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 1px 6px;
  flex: 0 0 auto;
}

/*
 * The swatch: the theme's own colours, which is the only honest preview.
 *
 * "A bit darker and more minimal" is not something to decide from a
 * paragraph, and a name tells the owner nothing about what they would be
 * looking at.
 */
.swatch {
  display: inline-flex;
  gap: 2px;
  flex: 0 0 auto;
}

.swatch .chip {
  width: 12px;
  height: 20px;
  border-radius: 3px;
  border: 1px solid var(--line);
}

.swatch.wide .chip {
  width: 22px;
  height: 26px;
}

/* ------------------------------------------------------------------ files */

/*
 * Files the owner has shown JARVIS.
 *
 * Sized in the same tokens as everything else so a theme can change the feel
 * of it and nothing else. The one rule with teeth here is the viewer's
 * `max-height`: an image the owner sends is whatever size their camera makes
 * it, and a photograph that pushes the composer off a phone screen is a
 * feature that has eaten the app.
 */
.media {
  margin: var(--stack) 0;
}

.media h2 {
  font-size: 0.95rem;
  margin: 0 0 calc(var(--stack) / 2);
}

.media-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: calc(var(--stack) / 3);
}

.media-item {
  display: flex;
  align-items: baseline;
  gap: calc(var(--stack) / 2);
  padding: calc(var(--density) / 2) var(--density);
  background: var(--surface);
  border-radius: var(--radius);
  cursor: pointer;
}

.media-item .kind {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
  flex: none;
}

.media-item .name {
  flex: 1 1 auto;
  /* A filename is text somebody else chose. It gets one line and it does not
     get to decide the width of the list. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.media-item .size,
.media-item .by {
  font-size: 0.75rem;
  color: var(--muted);
  flex: none;
}

.viewer {
  margin-top: var(--stack);
  padding: var(--density);
  background: var(--surface);
  border-radius: var(--radius);
}

.viewer-name {
  margin: 0 0 calc(var(--stack) / 2);
  font-weight: 600;
  overflow-wrap: anywhere;
}

.viewer audio {
  display: block;
  width: 100%;
  margin-top: calc(var(--stack) / 2);
}

.viewer img,
.viewer video {
  display: block;
  max-width: 100%;
  /* See the block comment above: the owner's photograph does not get to be
     taller than half the screen. */
  max-height: 50vh;
  border-radius: var(--radius);
  background: var(--bg);
}

.viewer-doc {
  margin: 0;
  color: var(--muted);
  font-size: 0.85rem;
}

.readings {
  list-style: none;
  margin: var(--stack) 0 0;
  padding: 0;
  display: grid;
  gap: calc(var(--stack) / 2);
}

/*
 * What was read out of the file.
 *
 * Deliberately styled as a quotation rather than as JARVIS speaking: the rule
 * on the screen has to match the rule in the database. The note above it is
 * the server's own sentence saying this is external content, and the border is
 * there so the owner can see at a glance where JARVIS stops and the file
 * starts.
 */
.reading .quoted {
  margin: 0;
  padding-left: var(--density);
  border-left: 2px solid var(--warn);
  color: var(--fg);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.reading .note {
  margin: 0 0 calc(var(--stack) / 4);
}

/*
 * One page of a document, and the controls to move between them.
 *
 * Styled as the same quotation as a reading, and for the same reason: the rule
 * on the screen has to match the rule in the database, and a page of a PDF is
 * external content whichever way it is paged through. The border is where
 * JARVIS stops and the file starts.
 */
.pages {
  margin: 0 0 var(--stack);
}

.pager {
  display: flex;
  align-items: center;
  gap: var(--density);
  margin-bottom: calc(var(--stack) / 4);
}

.pager button {
  /* A touch target, because this is a phone-first app and a chevron is small. */
  min-width: 2.75rem;
  min-height: 2.75rem;
}

.pages .note {
  margin: 0 0 calc(var(--stack) / 4);
}

.pages .quoted {
  margin: 0;
  padding-left: var(--density);
  border-left: 2px solid var(--warn);
  color: var(--fg);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.quiet {
  margin: calc(var(--stack) / 4) 0 0;
  padding-left: 1.1rem;
  color: var(--muted);
  font-size: 0.85rem;
}

/*
 * Disabled, said in colour rather than in transparency, and last on purpose.
 *
 * `opacity: 0.55` over an accent fill measured 1.67:1 in light mode: a Send
 * button whose label could not be read. Colour alone was not enough either --
 * `.composer button[type="submit"]` is the more specific selector, so the
 * disabled button kept its accent fill and its white label and became
 * indistinguishable from the enabled one. Readable and lying is not better
 * than unreadable.
 *
 * `:root` rather than `!important`: it ties those component rules on
 * specificity and wins from here on source order. The first version reached
 * for `!important` and did not need to -- and reached for the `background`
 * shorthand with it, which would have pinned every disabled control's
 * `background-image` and `background-size` to their initial values for good.
 *
 * The border takes `--muted` rather than `--line`, because `decide()` disables
 * every button on an approval card at once: with a hairline border they all
 * collapsed to the same grey box at 1.35:1 against the card, and the owner
 * could not see which one they had just pressed.
 */
:root button[disabled] {
  background-color: var(--panel-2);
  border-color: var(--muted);
  color: var(--muted-raised, var(--muted));
}

/*
 * The video the owner is deciding about, on the phone they are deciding on.
 *
 * Full width of the card and its own aspect ratio, so the approve buttons stay
 * where the thumb expects them rather than being pushed off by a player that
 * sized itself from the file. `playsinline` is set on the element for the
 * same reason: iOS takes a video full-screen the moment it plays otherwise,
 * and the owner comes back from that to a card they have lost their place in.
 */
.video-preview {
  display: block;
  width: 100%;
  max-height: 50vh;
  margin: 0.75rem 0;
  border-radius: 10px;
  background: #000;
}

/* ------------------------------------------------------- control center */
/*
 * The top of the fifth tab. Its lists do not scroll on their own: the whole
 * view scrolls, the way the devices view does, because a stack of self-
 * scrolling lists inside an `overflow: hidden` view left everything past the
 * fold unreachable.
 */
#view-empire {
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}

#view-empire .cards {
  overflow: visible;
  flex: 0 0 auto;
  padding-bottom: 0;
}

.control > h2 {
  margin: 14px 14px 0;
  font-size: 20px;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.ask {
  display: flex;
  gap: 8px;
  margin: 12px 14px 0;
}

.ask input {
  flex: 1;
  min-width: 0;
  /* 16px: iOS zooms the page into any field smaller than that. */
  font: inherit;
  font-size: 16px;
  padding: 10px 12px;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background: var(--panel);
  color: var(--on-panel);
}

.ask button {
  flex: 0 0 auto;
}

.brief-line {
  margin: 6px 0 0;
  color: var(--on-panel);
  overflow-wrap: anywhere;
}

.decision {
  padding: 10px 0;
  border-top: 1px solid var(--line);
}

.decision p {
  margin: 0 0 6px;
  overflow-wrap: anywhere;
}

.decision .actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 8px;
}

.project-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px 8px;
  width: 100%;
  margin-top: 8px;
  padding: 10px 12px;
  text-align: left;
  font: inherit;
  color: var(--on-panel);
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  cursor: pointer;
}

.project-name {
  font-weight: 700;
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
}

.project-now,
.project-blocker {
  flex: 1 0 100%;
  font-size: 14px;
  color: var(--on-panel-muted);
  overflow-wrap: anywhere;
}

.project-blocker {
  color: var(--warn);
}

.pill.ACTIVE { color: var(--ok); }
.pill.PAUSED,
.pill.NOT_STARTED,
.pill.ARCHIVED { color: var(--muted); }
.pill.BLOCKED { color: var(--danger); }
.pill.WAITING_APPROVAL { color: var(--warn); }

#control-project {
  padding: 14px;
  border-radius: var(--radius);
  background: var(--panel);
  border: 1px solid var(--line);
}

#control-project dl {
  overflow-wrap: anywhere;
}
