/* Fabulous — the whole look of the product.
 *
 * TWO RULES DECIDE EVERYTHING HERE.
 *
 * 1. THE VIEWER IS THE HERO. The type sits directly on the geometry, at the
 *    edges, and gets out of the way the moment there is a model to look at.
 *
 * 2. THIS IS THE FABER SYSTEM, NOT A THIRD ONE. The palette, the type stack
 *    and the motion curves below are `crazyshit/src/crazyshit/ui/static/app.css`
 *    and `fab/apps/desktop/ui/static/styles.css`, token for token. Fabulous
 *    shipped its own — warm graphite, a brass accent, Instrument Serif at
 *    13vw — and it read as a different company's product beside the other two.
 *
 *    Both of the things it invented had already been decided against upstream.
 *    Fab's own `fonts.css` says it in one line: "Bundled DM Sans / Instrument
 *    Serif / JetBrains faces are no longer loaded." And its `styles.css` says
 *    the accent is "ink-with-alpha by default (CrazyShit)". The one warm tone
 *    in the whole system is CREAM (`248 240 222`), and it is spent on the
 *    thing you press and on nothing else.
 *
 *    That is also why the loader is the kit's `void` variant unrecoloured —
 *    its own notes define `void` as "l'app depuis la PR #33 : fond #0b0c0e,
 *    encre #e8eaee, etincelle creme #f8f0de".
 */

:root {
  /* SPACE-separated, and that is not cosmetic: `rgb(var(--ink-rgb) / 0.22)` is
     the modern syntax and it requires space-separated channels. With commas
     the whole declaration is REJECTED — measured in crazyshit, where a border
     asked for at 22% had silently been rendering fully opaque. */
  --ink-rgb:     232 234 238;
  --cream-rgb:   248 240 222;   /* the only warm tone in the system */

  --void-0:      #0b0c0e;
  --void-1:      #15171b;

  --ink:         #e8eaee;
  --ink-soft:    #b2b7bd;
  --ink-mute:    #737981;

  --line:        rgb(var(--ink-rgb) / 0.10);

  /* The accent is ink-with-alpha; cream is what a DECISION looks like. */
  --accent-deep: rgb(var(--cream-rgb) / 0.92);
  --accent-soft: rgb(var(--cream-rgb) / 0.38);

  --radius:      6px;

  /* System faces only. The serif is the product's own VOICE — the sentences it
     says about itself — and never a label; mono is every label; sans is
     everything else. Nothing is vendored, so nothing is downloaded. */
  --sans:  ui-sans-serif, -apple-system, "Segoe UI Variable Text", "Segoe UI",
           system-ui, sans-serif;
  --serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
  --mono:  ui-monospace, "Cascadia Mono", "Cascadia Code", Consolas,
           "DejaVu Sans Mono", monospace;

  --wake: 180ms cubic-bezier(0.2, 0.7, 0.3, 1);
  --dim:  280ms cubic-bezier(0.2, 0.7, 0.3, 1);
  --enter: 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  height: 100%;
  overflow: hidden;
  background: var(--void-0);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 14px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* The viewer draws its own colours; nothing here may be re-tinted by the
     browser's own dark-mode heuristics. */
  color-scheme: dark;
}

::selection { background: rgb(var(--cream-rgb) / 0.28); color: var(--ink); }

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

#stage {
  position: fixed;
  inset: 0;
  /* ATMOSPHERE, NOT DECORATION — and specifically ONE LIGHT, from above and
     slightly forward, which is the whole reason this reads as a room rather
     than a swatch. The cool pool is fab's own field, verbatim; the second,
     warmer and tighter, is the lamp over the drawing board. A flat black field
     makes a dark grey part look like a hole. Under the canvas the viewer
     paints, so it only shows where there is no model. */
  background:
    radial-gradient(52% 44% at 50% 34%, rgb(var(--cream-rgb) / 0.026) 0%,
                    transparent 70%),
    radial-gradient(120% 90% at 52% 26%, var(--void-1) 0%, transparent 62%),
    var(--void-0);
}

/* A DRAWING OFFICE RATHER THAN A VOID. The empty stage is where somebody
   decides whether this is a serious tool, and a flat black field with three
   lines of type on it is a splash screen. This is the paper the drawings this
   thing eats are drawn on — a 44 px minor grid with a major line every fifth,
   faded out radially so it is a ground and never a texture.

   It goes at the first build. The geometry is the interface and a grid behind
   a solid is a rectangle taken away from it; it is also under the canvas, so
   it could only ever show where there is nothing anyway. */
#stage::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image:
    linear-gradient(rgb(var(--ink-rgb) / 0.05) 1px, transparent 1px),
    linear-gradient(90deg, rgb(var(--ink-rgb) / 0.05) 1px, transparent 1px),
    linear-gradient(rgb(var(--ink-rgb) / 0.03) 1px, transparent 1px),
    linear-gradient(90deg, rgb(var(--ink-rgb) / 0.03) 1px, transparent 1px);
  background-size: 220px 220px, 220px 220px, 44px 44px, 44px 44px;
  -webkit-mask-image: radial-gradient(58% 52% at 50% 34%, #000 0%, transparent 72%);
  mask-image: radial-gradient(58% 52% at 50% 34%, #000 0%, transparent 72%);
  opacity: 0;
  transition: opacity 900ms ease;
  pointer-events: none;
}

body.empty #stage::before { opacity: 1; }

/* The viewer's own canvas fills the stage; its wrapper insists on a width. */
#stage .tcv_cad_view,
#stage canvas { max-width: none !important; }

/* THE VIEWER PAINTS ITS OWN BACKGROUND, and under `theme: "dark"` that is a
   flat #444 — which is not this product's black, and which covers the field
   above with a grey rectangle the exact size of the canvas. Its renderer is
   built with `alpha: true`, so handing it a transparent colour is enough: the
   WebGL clear goes through to the stage underneath. Declared after the vendor
   sheet, on its own selector, so a version bump keeps overriding cleanly. */
[data-theme="dark"] { --tcv-bg-color: transparent; }
.tcv_cad_viewer, .tcv_cad_view { background: transparent !important; }

/* --------------------------------------------------------------- the veil
 *
 * The first viewport when there is nothing built, and the drag frame at every
 * other moment.
 *
 * THIS USED TO BE THREE LINES OF TYPE AND NOTHING ELSE, and the whole window
 * was the only drop target. On mesh2cad that reads fine — the mesh appears the
 * instant it is opened, so the drop confirms itself. On draw2cad it does not:
 * a drawing goes to `/api/file`, the stage has nothing to put on it, and the
 * screen for the minutes before the first build is bit-for-bit the screen from
 * before the drop. There was no zone, no upload confirmation, and no moment
 * the user decided anything.
 *
 * WHAT MAKES IT A COMPOSITION RATHER THAN A COLUMN, since two attempts at this
 * were "a logo, a title, a subtitle and a dashed box" and both looked like a
 * template:
 *
 *   - DEPTH. The mark is not an item in the stack, it is a watermark BEHIND
 *     the stack at 4% — so the screen has a foreground and a background.
 *   - ONE LIGHT. A single warm pool from above-centre, on the stage, on the
 *     grid's mask, on the top edge of the card and along the top of the
 *     letterforms. Everything is lit by the same lamp, which is what makes a
 *     dark interface look built instead of unfinished.
 *   - GRAIN. 2.8% fractal noise over the veil. It is the single cheapest thing
 *     that separates an expensive-looking dark surface from a flat one, and it
 *     is on the PAPER only — the geometry stays clean, which is also the
 *     honest split.
 *   - A REAL CARD instead of a dashed rectangle. A dashed border is the
 *     universal mark of an unstyled file input. This one has a hairline, a
 *     specular top edge, an elevation shadow and a footer caption below it.
 *   - TYPE THAT COMMITS. 68 px, weight 600, -0.045em, in a top-lit silver —
 *     against a 16 px humanist italic. Two faces doing two jobs, with a real
 *     jump between them, rather than one face at four sizes.
 */

#veil {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  pointer-events: none;
  z-index: 5;
  transition: opacity 700ms ease;
}

/* GRAIN. Generated in the URL — no asset, no request, and it tiles. Kept low
   enough to be felt and not seen: at 0.05 it is visibly dirty, at 0.015 it
   does nothing. It rides the veil, so it is over the paper and never over
   somebody's part. */
#veil::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: 0.028;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
}

#veil-inner {
  position: relative;
  z-index: 2;
  text-align: center;
  padding: 0 6vw;
  animation: fade-up 600ms cubic-bezier(.2, .8, .2, 1);
  transition: opacity 500ms ease, transform 700ms cubic-bezier(.2, .8, .2, 1);
}

@keyframes fade-up { from { opacity: 0; transform: translateY(6px); } }

/* With a model on screen the composition is spent; while the agent is working
   the compass takes its place. `pointer-events` goes with the opacity or the
   zone stays clickable through an invisible layer — a click that reopens a
   file picker over a model somebody is orbiting. */
body:not(.empty) #veil-inner,
body.working #veil-inner {
  opacity: 0;
  transform: translateY(-14px);
  pointer-events: none;
}

/* AND THE CHILD THAT OPTED BACK IN HAS TO OPT BACK OUT. `pointer-events: none`
   on a parent is not inherited the way opacity is: a descendant declaring
   `auto` — which `#drop` does, because it has to be clickable while the stage
   IS empty — re-enables hit-testing on itself and goes on being clicked
   through the invisible layer above. The rule above says `pointer-events` goes
   with the opacity, and it did, on the wrong element.
   Reported from production 2026-08-19: with a mesh on screen, double-clicking
   anywhere near the middle of the viewer opened a file browser. The card is
   `min(452px, 86vw)` centred, so "near the middle" is most of where somebody
   orbits. */
body:not(.empty) #drop,
body.working #drop {
  pointer-events: none;
}

/* THE NAME, LIT FROM ABOVE. The gradient is not a colour — it is the same lamp
   that lights the stage and the card, landing on the cap line and falling off
   to the baseline. The previous two versions were a brass gradient and then a
   40 px book serif; this is the sans the whole system is set in, pushed to a
   weight and a tracking that make it a mark rather than a heading. */
#mark {
  margin: 0;
  font-family: var(--sans);
  font-size: clamp(38px, 4.2vw, 52px);
  font-weight: 500;
  line-height: 1;
  letter-spacing: -0.032em;
  color: rgb(var(--ink-rgb) / 0.94);
}

#line {
  margin: 13px auto 0;
  max-width: 40ch;
  font-family: var(--serif);
  font-style: italic;
  font-size: 15px;
  line-height: 1.45;
  color: rgb(var(--ink-rgb) / 0.44);
}

/* The one warm word on the screen, and it is the word the product is about. */
#line .accent { color: rgb(var(--ink-rgb) / 0.72); font-style: normal; }

/* ------------------------------------------------------------------ the card
 *
 * One rectangle with two faces — the invitation, and then what landed in it —
 * so confirming an upload does not move the page under the cursor that is
 * about to press the button.
 *
 * NOT DASHED. A dashed border is what an unstyled `<input type=file>` looks
 * like, and this is the object the whole screen is built around. It is a
 * surface: a hairline, a 1 px specular highlight along the top edge (the lamp
 * again), a long soft shadow underneath, and a gradient that is fractionally
 * brighter at the top than at the bottom.
 */
#drop {
  position: relative;
  width: min(452px, 86vw);
  margin: 38px auto 0;
  padding: 23px 24px;
  border: 1px solid rgb(var(--ink-rgb) / 0.09);
  border-radius: 10px;
  background: rgb(var(--ink-rgb) / 0.035);
  box-shadow:
    inset 0 1px 0 rgb(255 255 255 / 0.045),
    0 14px 34px -24px rgb(0 0 0 / 0.85);
  backdrop-filter: blur(8px);
  /* The uploading hairline below sweeps +/-42% of this width; without a clip
     it runs out past both edges of the rectangle it belongs to. */
  overflow: hidden;
  pointer-events: auto;
  cursor: pointer;
  transition: border-color var(--wake), background var(--wake),
              box-shadow var(--wake), transform var(--dim);
}

#drop:hover,
#drop:focus-visible {
  border-color: rgb(var(--ink-rgb) / 0.16);
  background: rgb(var(--ink-rgb) / 0.055);
  outline: none;
}

/* Under a dragged file the card is the target, and says so loudly enough to be
   read out of the corner of an eye that is on the file, not on the page: the
   hairline goes cream and the lamp above it brightens. */
body.dragging #drop {
  border-color: rgb(var(--cream-rgb) / 0.62);
  background: rgb(var(--cream-rgb) / 0.055);
  box-shadow:
    inset 0 1px 0 rgb(var(--cream-rgb) / 0.22),
    0 14px 34px -24px rgb(0 0 0 / 0.85);
}

body.dragging #drop-verb { color: rgb(var(--cream-rgb) / 0.9); }

/* Holding a file: no longer an invitation, so it stops behaving like one. */
body.staged #drop { cursor: default; }

/* Nobody is signed in, so a drop would be refused before it left the browser.
   Saying that with the card costs no words. */
body.need-auth #drop { opacity: 0.3; pointer-events: none; }

#drop-face {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 11px;
}

#drop-glyph {
  color: rgb(var(--ink-rgb) / 0.42);
  transition: color var(--wake), transform var(--dim);
}

#drop:hover #drop-glyph { color: rgb(var(--ink-rgb) / 0.62); }
body.dragging #drop-glyph {
  color: rgb(var(--cream-rgb) / 0.9);
}

#drop-verb {
  margin: 0;
  font-size: 15px;
  font-weight: 500;
  letter-spacing: -0.005em;
  color: var(--ink-soft);
}

#drop-verb .or {
  display: block;
  margin-top: 7px;
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 400;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-mute);
}

/* The caption under the card, not a fourth block inside it. */
#hint {
  margin: 22px 0 0;
  font-family: var(--mono);
  font-size: 9.5px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: rgb(var(--ink-rgb) / 0.26);
  line-height: 1.8;
}

/* The bytes are in flight. A 40 MB drawing over a slow line is several seconds
   of a card that looks exactly like a card nothing was dropped on, and the
   receipt below cannot be drawn until a 200 comes back — so the wait gets its
   own hairline, running along the bottom edge of the rectangle the file was
   let go of on. No words: the file's name is about to be the words. */
#drop::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: linear-gradient(90deg, rgb(var(--cream-rgb) / 0) 0%,
              var(--accent-deep) 50%, rgb(var(--cream-rgb) / 0) 100%);
  opacity: 0;
  transition: opacity 160ms ease;
}

body.uploading #drop::after { opacity: 1; animation: uploading 1.1s ease-in-out infinite; }

@keyframes uploading {
  0%   { transform: translateX(-42%) scaleX(0.4); }
  50%  { transform: translateX(42%) scaleX(0.4); }
  100% { transform: translateX(-42%) scaleX(0.4); }
}

/* A SECOND FILE DROPPED ONTO A FINISHED MODEL HAS TO BE CONFIRMABLE TOO, and
   the receipt lives inside the composition that went away when the model
   arrived — so without this the drop would upload, stage, and show nothing at
   all, which is the exact failure this whole change exists to remove. The name
   and the line stay gone: those are a title page, not a control. */
body.staged #veil-inner {
  opacity: 1;
  transform: none;
  pointer-events: auto;
}

body.staged:not(.empty) #mark,
body.staged:not(.empty) #line,
body.staged:not(.empty) #hint { display: none; }

body.staged:not(.empty) #drop { margin-top: 0; }

/* ...and it needs a ground of its own, because a hairline card over a lit
   solid is a card nobody can read. */
body.staged:not(.empty) #veil {
  background: rgb(9 10 12 / 0.66);
  backdrop-filter: blur(4px);
}

/* ------------------------------------------------------- what actually landed
 *
 * THE ANSWER TO "DID MY UPLOAD WORK", and it is a real answer rather than a
 * hopeful one: the name is the one the SERVER wrote to disk (so a collision
 * shows up as `plate-2.pdf` here, where it is readable, instead of surprising
 * the agent), the byte count is what the server counted, and the thumbnail is
 * the file's own pixels. Nothing here is drawn until a 200 comes back.
 */
#staged[hidden] { display: none !important; }
#drop-face[hidden] { display: none !important; }

#staged { animation: staged-in var(--enter); }

/* FROM the invisible state, never TO it. A visible state that lives only in a
   keyframe is `opacity: 0` until the animation completes, and an animation
   outranks a plain declaration — which is how `#refused` spent a session
   invisible. This way a keyframe that never runs costs a fade, not the card. */
@keyframes staged-in {
  from { opacity: 0; transform: translateY(7px); }
}

#staged-row {
  display: flex;
  align-items: center;
  gap: 14px;
  text-align: left;
}

#staged-thumb {
  flex: none;
  width: 52px;
  height: 52px;
  border: 1px solid rgb(var(--ink-rgb) / 0.12);
  border-radius: 6px;
  background-color: rgb(0 0 0 / 0.35);
  background-size: cover;
  background-position: center;
  display: grid;
  place-items: center;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.06em;
  color: var(--ink-mute);
  overflow: hidden;
}

#staged-text { min-width: 0; flex: 1; }

#staged-name {
  display: block;
  font-size: 15.5px;
  font-weight: 500;
  letter-spacing: -0.01em;
  line-height: 1.3;
  color: var(--ink);
  /* A long CAD filename must not push the tick off the card. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#staged-meta {
  display: block;
  margin-top: 5px;
  font-family: var(--mono);
  font-size: 9.5px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-mute);
}

#staged-tick {
  flex: none;
  position: relative;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1px rgb(var(--cream-rgb) / 0.42);
}

/* Drawn rather than typed: a check mark character is whatever the user's
   fallback font decides, and on a machine without one it is a box. */
#staged-tick::after {
  content: "";
  position: absolute;
  left: 7.5px;
  top: 5px;
  width: 5px;
  height: 10px;
  border: solid var(--accent-deep);
  border-width: 0 1.6px 1.6px 0;
  transform: rotate(45deg);
}

/* ----------------------------------------------------------- the confirmation
 *
 * The only filled control in the product, and the only thing on the page that
 * starts work. CREAM because cream is the one warm tone in this system and it
 * is spent on decisions — the same paint fab puts on its send button. The
 * gloss along its top edge and the light it throws underneath are the same
 * lamp as everything else on this screen.
 */
#go {
  display: block;
  margin: 22px auto 0;
  padding: 13px 30px;
  border: none;
  border-radius: 999px;
  background: rgb(var(--cream-rgb) / 0.94);
  color: #14161a;
  font-family: inherit;
  font-size: 13.5px;
  font-weight: 500;
  letter-spacing: -0.005em;
  cursor: pointer;
  pointer-events: auto;
  transition: background var(--wake), transform 120ms ease;
}

#go[hidden] { display: none !important; }

#go:hover { background: rgb(var(--ink-rgb) / 0.96); }
#go:active { transform: translateY(1px); }
#go:focus-visible { outline: none; box-shadow: 0 0 0 3px rgb(var(--cream-rgb) / 0.3); }

#auth {
  margin: 28px auto 0;
  max-width: 320px;
  pointer-events: auto;
  text-align: left;
}

#auth[hidden], #quota[hidden], #auth-err[hidden] { display: none !important; }

#google-btn { display: flex; justify-content: center; min-height: 40px; }

/* UNDER the button, not over it, and set as a sentence rather than as a
   control: pressing the button is the acceptance, so a second thing to press
   first would be a second decision where there is one. */
#auth-consent {
  margin: 14px 0 0;
  text-align: center;   /* `#auth` is left-aligned for the error line */
  font-family: var(--sans);
  font-size: 11.5px;
  line-height: 1.5;
  color: var(--ink-mute);
}

#auth-consent a {
  color: rgb(var(--ink-rgb) / 0.62);
  text-decoration: underline;
  text-underline-offset: 2px;
  transition: color var(--wake);
}

#auth-consent a:hover { color: rgb(var(--cream-rgb) / 0.92); }

#auth-err {
  margin: 10px 0 0;
  color: var(--accent-deep);
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.06em;
}

/* The window at large is still a drop target, and under a dragged file it says
   so with one inset hairline rather than a modal. Quieter than the card: the
   card is the loud one, and two things shouting is a flicker. */
#veil::after {
  content: "";
  position: absolute;
  inset: 16px;
  border: 1px solid rgb(var(--cream-rgb) / 0);
  border-radius: 12px;
  transition: border-color var(--wake), background var(--wake);
}

body.dragging #veil::after {
  border-color: rgb(var(--cream-rgb) / 0.13);
}

/* --------------------------------------------------------------- the compass
 *
 * WHAT THE AGENT WORKING LOOKS LIKE. The two-pixel hairline at the very top of
 * a full-bleed viewer was the whole answer before this, and on a draw2cad job
 * — where the stage stays empty until the first build lands, which is minutes
 * — the honest reading of that screen is that nothing is happening.
 *
 * It is the watermark from the veil, drawn by a compass and withdrawn again:
 * axis line, envelope arc, inner arcs, cote. The backdrop coming alive. Still
 * no agent prose — a shape and a clock, nothing said.
 */
#thinking {
  position: fixed;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 11;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 420ms ease,
              top 620ms cubic-bezier(.2, .8, .2, 1),
              transform 620ms cubic-bezier(.2, .8, .2, 1);
}

body.working #thinking { opacity: 1; }

#thinking-mark {
  width: 168px;
  height: 168px;
  transition: width 620ms cubic-bezier(.2, .8, .2, 1),
              height 620ms cubic-bezier(.2, .8, .2, 1);
}

#elapsed {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.26em;
  color: rgb(var(--ink-rgb) / 0.34);
  font-variant-numeric: tabular-nums;
}

/* The one line under the compass. Sized and coloured to be READ WITHOUT BEING
   LOOKED AT: a line that competes with the geometry is a chat panel by another
   name.

   IT WAS `white-space: nowrap` IN A 30ch BOX, and that was the bug. The server
   trims a thought to `loop._THINKING_MAX`; this box then trimmed THAT to about
   thirty characters with an ellipsis of its own, so a reader got two clauses'
   worth of set-up and none of the verb — every line on screen ended mid
   sentence. It now wraps to two lines, which is what the server's cap is
   sized for, and `line-clamp` is the backstop rather than the rule.

   The height is RESERVED, both lines of it, whether or not there is anything
   on them. The cluster is a centred flex column and the compass sits on top
   of this element: a line that grew from one row to two would shove a 168 px
   mark up and down the screen every few seconds. */
#thought {
  max-width: min(64ch, 78vw);
  height: calc(2 * 1.55em);
  font-size: 12px;
  line-height: 1.55;
  letter-spacing: 0.01em;
  color: rgb(var(--ink-rgb) / 0.34);
  text-align: center;
  text-wrap: balance;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  opacity: 0;
  transition: opacity 260ms ease;
}

#thought.in { opacity: 1; }

/* ...and the words arrive one at a time. THIS IS WHERE THE STREAMING IS
   VISIBLE. The frames come off the wire in bursts on the model's schedule —
   three sentences can close in the same millisecond — so the rhythm a person
   reads is not the wire's, it is this: `app.js::paintThought` gives every word
   its own delay and the cascade is short enough (520 ms, capped) that the line
   is never still arriving when it is being read. A swap with no reveal at all
   was the other half of the flicker. */
#thought .w {
  animation: thought-word 300ms cubic-bezier(.2, .8, .2, 1) both;
}

@keyframes thought-word {
  from { opacity: 0; filter: blur(1.5px); }
  to   { opacity: 1; filter: blur(0); }
}

/* Somebody who asked not to be moved gets the line and not the cascade. */
@media (prefers-reduced-motion: reduce) {
  #thought .w { animation: none; }
}

/* THE WAY OUT — one corner, in both layouts, and the corner is the whole
   reason it is not inside `#thinking`. That cluster is centred with a
   `transform`, and a transformed ancestor becomes the containing block for a
   `position: fixed` child: `right: 26px` in there is 26 px from the CLUSTER's
   right edge, which put the button in the middle of the screen while the
   computed style read exactly what was asked for. Measured in a browser,
   x = 612 on a 1280 px page.

   Held still on purpose, too. The cluster's width is the sum of its children
   and one of them is a sentence replaced every few seconds, so a Stop inside
   it slides ~70 px sideways while somebody is reaching for it — the last
   thing the one control that ends a turn may do. It sits at the project
   pastille's height on the opposite side, under `#quota` (`top: 22px`).

   Quiet enough to be ignored for the whole of a turn that is going fine, and
   findable in the one second somebody wants it: no fill, no border until the
   pointer is on it, and the cream reserved for that moment. It is not styled
   as a danger — nothing is lost by pressing it, the model on screen is kept. */
#stop {
  position: fixed;
  top: 44px;
  right: 26px;
  z-index: 11;
  /* `visibility` and not `hidden`, so the fade still runs BOTH ways — and it
     is what takes the button out of the tab order and the accessibility tree
     between turns. `opacity: 0` alone leaves a focusable control on a page
     that has nothing to stop. The delay is on the way OUT only, so the
     element survives its own fade. */
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 420ms ease, visibility 0s linear 420ms,
              color var(--wake), border-color var(--wake),
              background var(--wake);
  padding: 5px 13px;
  border: 1px solid transparent;
  border-radius: 999px;
  background: none;
  color: rgb(var(--ink-rgb) / 0.30);
  font-family: inherit;
  font-size: 10.5px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  cursor: pointer;
}

body.working #stop {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transition: opacity 420ms ease, visibility 0s, color var(--wake),
              border-color var(--wake), background var(--wake);
}

#stop:not(:disabled):hover {
  border-color: rgb(var(--cream-rgb) / 0.34);
  background: rgb(var(--ink-rgb) / 0.07);
  color: var(--accent-deep);
}

/* Pressed, and the turn is unwinding: the request is in, the thread stops at
   its next frame. Dead rather than gone — a control that vanishes on click
   reads as a misfire. */
#stop:disabled { opacity: 0.4; cursor: default; }

/* A stop can be refused: the turn ended in the moment between the button being
   there and the click landing. Same idiom as the layer switches — the border
   says no and the reason is in the console — but its own keyframes, because
   this button has no resting border to take AWAY. The visible state is still
   the plain declaration above; only the flash lives in here. */
#stop.refused { animation: refuse-stop 700ms ease; }

@keyframes refuse-stop {
  0%, 100% { border-color: transparent; }
  30%, 70% { border-color: rgb(var(--cream-rgb) / 0.34); }
}

/* ...and once there is geometry, it gets out of the way of it. Top centre is
   the only edge of this page not already spoken for — wordmark, quota, layers
   and the STEP hold the four corners. */
body.working:not(.empty) #thinking {
  top: 14px;
  transform: translate(-50%, 0);
  flex-direction: row;
  gap: 12px;
}

body.working:not(.empty) #thinking-mark { width: 46px; height: 46px; }
/* With geometry on screen the cluster is a ROW along the top edge, so the
   line has the compass and the clock beside it rather than above: narrower,
   and quieter still. Two lines, same as the centred layout — one line here
   would put the cut back, just at 52 characters instead of thirty. */
body.working:not(.empty) #thought {
  max-width: min(52ch, 44vw);
  font-size: 11px;
  line-height: 1.5;
  text-align: left;
  /* NO reserved height here, and that is not an oversight: this is the ROW,
     where the 46 px mark sets the cluster's height and `align-items: center`
     puts whatever the line is against the middle of it. Reserving two lines
     instead makes a one-line thought sit at the TOP of an empty two-line box,
     a few pixels above the clock beside it — visible, and wrong the moment a
     tool verb replaces a sentence. The column layout below the compass is the
     one that needs the reservation, because there the line is what the mark
     is standing on. */
  height: auto;
}
body.working:not(.empty) #elapsed { font-size: 10px; letter-spacing: 0.18em; }

/* ----------------------------------------------------------- coming back
 *
 * THE ONE WAIT IN THIS PRODUCT THAT IS NOT A TURN, and it had no screen at
 * all. Opening a saved project pulls its files out of the store, puts the
 * process on them and RELOADS this page — so what a person saw between
 * clicking a project and seeing it was the project they were leaving, then a
 * flash of empty page, then the drop zone, for as long as the whole thing
 * took. Every one of those screens says the same wrong thing: nothing is
 * happening.
 *
 * OPAQUE, and that is the point rather than a style: it is the only element
 * here that has to survive a navigation, so it is armed before the POST, held
 * across the reload out of `sessionStorage`, and taken down when the geometry
 * is actually on screen. The vocabulary is the turn's — the same compass, the
 * same clock, the same quiet mono — because a person waiting on this product
 * should not have to learn a second thing that means "wait".
 */
#opening {
  position: fixed;
  inset: 0;
  z-index: 20;              /* over the veil (5), the compass (11) and the
                               projects overlay (14) — and UNDER the refusal,
                               which went to 22 for exactly that reason */
  display: grid;
  place-items: center;
  background:
    radial-gradient(56% 46% at 50% 42%, rgb(var(--cream-rgb) / 0.030) 0%,
                    transparent 70%),
    var(--void-0);
  opacity: 1;
  transition: opacity 340ms ease;
}

/* Faded rather than cut, because the geometry arriving underneath is the whole
   reward and a hard swap steals it. `hidden` follows on the transition's end
   (`app.js::endOpening`), so nothing is left taking the pointer. */
#opening.out { opacity: 0; }
#opening[hidden] { display: none; }

#opening-inner {
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
  padding: 0 6vw;
  animation: fade-up 500ms cubic-bezier(.2, .8, .2, 1);
}

#opening-mark { width: 132px; height: 132px; }

/* THE NAME IS THE MESSAGE. It is the one thing a person wants confirmed —
   that the thing coming back is the thing they clicked — so it is the biggest
   type on the screen, in the serif the veil's own line uses. */
#opening-name {
  font-family: var(--serif);
  font-size: 21px;
  letter-spacing: 0.01em;
  color: rgb(var(--ink-rgb) / 0.82);
  max-width: min(38ch, 80vw);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ...and under it, what is being done and for how long. Mono, at the chrome's
   own weight: a verb and a clock, never a progress bar — there is no honest
   percentage to draw, and a bar that guesses is worse than a number that is
   true. */
#opening-note {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: rgb(var(--ink-rgb) / 0.30);
  font-variant-numeric: tabular-nums;
  min-height: 1.4em;
}

#opening-clock:not(:empty)::before {
  content: " · ";
  color: rgb(var(--ink-rgb) / 0.22);
}

@media (prefers-reduced-motion: reduce) {
  #opening, #opening-inner { transition: none; animation: none; }
}

/* ------------------------------------------------------------- the live mark */

#mark-live {
  position: fixed;
  top: 20px;
  left: 26px;
  z-index: 8;
  font-family: var(--serif);
  font-size: 15px;
  letter-spacing: 0.01em;
  color: rgb(var(--ink-rgb) / 0.5);
  opacity: 0;
  transition: opacity 500ms ease;
  pointer-events: none;
}

body:not(.empty) #mark-live { opacity: 1; }

/* The quota and the way out share one row, and the row holds the corner. Two
   separately-positioned elements here would have had to agree about the
   quota's width — which is `3 / 3 RUNS` today and a different length the
   moment a limit is not one digit. */
#account {
  position: fixed;
  top: 22px;
  right: 26px;
  z-index: 8;
  display: flex;
  align-items: center;
  gap: 14px;
  /* The quota is a mark and takes no pointer; the button turns it back on
     for itself alone. */
  pointer-events: none;
}

#quota {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-mute);
}

/* Set as the quota is, one shade quieter, and with no border and no fill: it
   is not a danger — nothing is lost by pressing it — and it must be ignorable
   for the whole of a session and findable in the one second somebody wants it.
   Same argument as `#stop`, which sits directly under it. */
#signout {
  pointer-events: auto;
  padding: 0;
  border: 0;
  background: none;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: rgb(var(--ink-rgb) / 0.34);
  cursor: pointer;
  transition: color var(--wake);
}

#signout:hover { color: var(--ink-soft); }
#signout[hidden] { display: none !important; }

/* ---------------------------------------------------------------- the refusal
 *
 * One sentence, low and centred, in the serif this system reserves for what
 * the product says about ITSELF — never the mono the quota and the format list
 * are set in. That is deliberate: mono here is the voice of the chrome
 * (`3 / 3 RUNS`, `PDF · PNG · JPG`), and this is the product speaking to a
 * person about the file they just dropped. Same rule crazyshit applies to its
 * own refusal line.
 *
 * It sits ABOVE the veil's z-index so it survives both states — a drop refused
 * on an empty page (veil up) and a drop refused over a model (veil gone) — and
 * above the PICKER's, which is the one screen that can produce a refusal
 * without closing first: `switchTo` closes the overlay before it says anything
 * and a rename does not, because the card being renamed is the context for
 * what went wrong. At z-index 12 that sentence was painted underneath it. And
 * it is `pointer-events: none` because there is nothing to click: the way out
 * of a refusal is to drop a different file, and the whole window is already
 * the drop zone. */
#refused {
  position: fixed;
  left: 50%;
  bottom: 13%;
  transform: translateX(-50%) translateY(6px);
  transition: opacity 420ms ease, transform 420ms ease;
  /* ABOVE THE OPENING SCREEN (20), and that is the same argument that put it
     above the veil. A file dropped while a project is coming back IS refused
     — it would otherwise land in whichever workspace the process reached
     first — and at 15 the sentence saying so was painted UNDERNEATH the
     cover: present, correct, and invisible. Found by looking at
     `dev/_scratch/opening/6-refused.png`, on a check that read `textContent`
     and was perfectly happy. */
  z-index: 22;
  max-width: min(560px, 78vw);
  text-align: center;
  font-family: var(--serif);
  font-style: italic;
  font-size: 17px;
  line-height: 1.45;
  color: rgb(var(--ink-rgb) / 0.86);
  opacity: 0;
  pointer-events: none;
}

#refused::before {
  content: "";
  display: block;
  width: 34px;
  height: 1px;
  margin: 0 auto 14px;
  background: var(--accent-deep);
  opacity: 0.6;
}

/* A TRANSITION AND NOT AN ANIMATION, and the difference is the difference
 * between a refusal being seen and not.
 *
 * This was `animation: refused-in 420ms ease forwards`, with the visible state
 * living only in the keyframes' `to`. That makes `opacity: 0` the element's
 * real value until an animation COMPLETES — so any reason it does not
 * complete leaves the product's one and only refusal channel invisible while
 * every other part of the product reports success. Worse, an animation
 * outranks a plain declaration in the cascade, so a second rule saying
 * `opacity: 0.92` does not rescue it: the frozen `from` keyframe still wins.
 *
 * Caught 2026-08-19 driving a real server. The element measured 560 x 70, in
 * the right place, with the right text and the right colour — and `opacity`
 * read 0 seven hundred milliseconds later. That particular instance was an
 * instrument artifact (a browser tab that is not composited freezes its
 * animation clock: `currentTime` stuck at 0 with `playState: "running"`), but
 * a reduced-motion setting, a user stylesheet or an extension reaches the same
 * place for real, and the failure is silent from every other angle.
 *
 * A transition's end state is an ORDINARY declaration on `.in`. If it runs,
 * it fades; if it never runs, the sentence is simply there. The replay trick
 * is unchanged — `app.js` removes `in`, forces a reflow and adds it back — so
 * a second refusal in a row still fades on text that barely changed. */
#refused.in { opacity: 1; transform: translateX(-50%) translateY(0); }

/* Somebody who asked for less motion still gets the sentence, at once. */
@media (prefers-reduced-motion: reduce) {
  #refused { transition: none; transform: translateX(-50%); }
  #veil-inner, #staged { animation: none; }
}

/* --------------------------------------------------------------- the one control */

#step {
  position: fixed;
  right: 26px;
  bottom: 26px;
  z-index: 9;
  padding: 10px 20px;
  border: 1px solid var(--accent-soft);
  border-radius: 999px;
  background: rgb(11 12 14 / 0.6);
  backdrop-filter: blur(6px);
  color: var(--accent-deep);
  font-family: inherit;
  font-size: 12.5px;
  font-weight: 500;
  letter-spacing: 0.01em;
  text-decoration: none;
  transition: background var(--wake), color var(--wake), border-color var(--wake);
}

#step:hover {
  background: var(--accent-deep);
  border-color: var(--accent-deep);
  color: var(--void-0);
}

/* ------------------------------------------------- the two solids, and the gap
 *
 * The second and last control cluster on this page, and it earns its rectangle
 * the way `#step` does: in mesh2cad there are TWO solids in the frame, in the
 * same millimetres, and taking either away is the only way to read the other.
 * Same idiom as Download STEP and the opposite corner — no panel, no labels
 * above it, and nothing at all on a drawing job.
 */

#layers {
  position: fixed;
  left: 26px;
  bottom: 26px;
  z-index: 9;
  display: flex;
  gap: 6px;
}

#layers[hidden] { display: none !important; }

#layers button {
  padding: 8px 15px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: rgb(11 12 14 / 0.6);
  backdrop-filter: blur(6px);
  color: var(--ink-mute);
  font-family: inherit;
  font-size: 12.5px;
  letter-spacing: 0.01em;
  cursor: pointer;
  transition: background var(--wake), color var(--wake), border-color var(--wake);
}

/* ON is the layer being ON SCREEN, which is the state a user reads off the
   geometry anyway — so the switch agrees with what they are looking at. */
#layers button[aria-pressed="true"] {
  border-color: var(--accent-soft);
  color: var(--accent-deep);
}

#layers button:disabled { opacity: 0.28; cursor: default; }

/* HOVER MUST NOT LOOK LIKE ON. Borrowing the accent here — which is what this
   rule did first — makes a switch that is OFF read as ON for as long as the
   pointer rests on it, and the pointer rests on the one just clicked. Caught
   on `dev/try_layers.py`'s own screenshots. So hover is a warmth behind it, and
   the cream stays the property of the state. */
#layers button:not(:disabled):hover { background: rgb(var(--ink-rgb) / 0.10); }

/* A comparison can be refused — mid-turn, or before anything has been built.
   Nothing on this page paints words, so the refusal is the border going out
   and coming back; the reason is in the console. */
#layers button.refused { animation: refuse 700ms ease; }

@keyframes refuse {
  0%, 100% { border-color: var(--line); }
  30%, 70% { border-color: rgb(var(--ink-rgb) / 0); }
}

/* ---------------------------------------------------------------- the heartbeat */

#pulse {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 2px;
  z-index: 10;
  opacity: 0;
  background: linear-gradient(90deg,
              rgb(var(--cream-rgb) / 0) 0%, var(--accent-deep) 50%,
              rgb(var(--cream-rgb) / 0) 100%);
  transition: opacity 300ms ease;
}

#pulse.on { opacity: 1; animation: sweep 2.4s ease-in-out infinite; }

@keyframes sweep {
  0%   { transform: translateX(-55%); }
  50%  { transform: translateX(55%); }
  100% { transform: translateX(-55%); }
}

/* The vendored viewer ships a tree pane, a toolbar and a set of light chrome
   defaults. This product shows none of them — `treeWidth: 0` and `tools: false`
   ask for that, and these hold it when a version bump forgets. */
.tcv_cad_tree, .tcv_cad_tools, .tcv_cad_toolbar, .tcv_cad_inset { display: none !important; }
.tcv_cad_body { border: none !important; }

/* -------------------------------------------------------------- the gap key
 *
 * The only numbers this product puts on screen, and they earn it: a colour map
 * whose colours mean nothing is a decoration, and two lobes with no cubic
 * millimetres beside them are a disagreement nobody can size. Sits above the
 * layer cluster, same corner, same type — it belongs to those switches, and
 * there is one box because only one of the two views is ever up.
 *
 * GREEN AND RED WERE NOT SWAPPED FOR THE METROLOGY CONVENTION, and that
 * decision now applies to one of the two views rather than to both. It was
 * written here when Diff was the only gap: in this product green has meant
 * "material to add" and red "material to remove" since the first comparison,
 * and a user who sees both pictures in one turn is better served by one
 * vocabulary than by two correct ones. The Volume switch still keeps exactly
 * that vocabulary — its lobes are `mesh_view.ADD` and `mesh_view.REMOVE`, the
 * same two hexes the agent's own contact sheet is painted in. The DIFF map is
 * the metrology rainbow now (`DEVIATION_RAMP` in `app.js`), where green is not
 * a direction at all but the nominal band at zero, so there is nothing left
 * for it to clash with. The stops are NOT repeated in this file: the bar's
 * gradient is set from that one array, because the version of this rule that
 * kept its own named `#9aa3ad` at the middle while the painter used `#5c6269`.
 */
#scale {
  position: fixed;
  left: 26px;
  bottom: 72px;
  z-index: 9;
  width: 224px;
  padding: 10px 12px 8px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: rgb(11 12 14 / 0.6);
  backdrop-filter: blur(6px);
  pointer-events: none;
}

#scale[hidden] { display: none !important; }
#scale-map[hidden], #scale-gap[hidden] { display: none !important; }

/* No `background` here on purpose — `paintScale` sets it from the same seven
   stops the pixels are interpolated from. See the note above. */
#scale-bar {
  height: 6px;
  border-radius: 1px;
}

/* NOWRAP, AND THE WIDTH ABOVE IS SIZED FOR THE WIDEST THIS EVER READS. The
   scale is auto-fitted, so the graduations are not a fixed length: on a model
   that is far off the limit reached ±11.0 mm and the three of them wrapped —
   `-11.0` over `mm`, `2.68 mm` over `mean` — a legend in four lines under a
   picture whose whole claim is that it is readable at a glance. Seen on
   `dev/_scratch/layers/12-diff-far.png` and nowhere else, because every
   earlier shot of this box was of a model close enough to read ±0.4 mm. */
#scale-marks {
  display: flex;
  justify-content: space-between;
  gap: 6px;
  margin-top: 6px;
  white-space: nowrap;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.04em;
  color: var(--ink-mute);
  font-variant-numeric: tabular-nums;
}

#scale-mid { color: var(--accent-deep); }

/* THE VOLUME VIEW'S KEY: one row per lobe the server actually wrote, and — when
   it wrote none — the one sentence saying which precondition refused it. Rows
   are built as nodes by `paintGapKey`; the swatch colour is the server's own,
   so nothing here may name a colour. */
.gap-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0 0 5px;
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.05em;
  color: var(--ink-mute);
  font-variant-numeric: tabular-nums;
}

.gap-row:last-child { margin-bottom: 0; }

.gap-dot {
  flex: 0 0 auto;
  width: 9px;
  height: 9px;
  border-radius: 2px;
}

/* The sentence, and it is the one thing in this cluster that is prose. It
   earns it: a view that cannot be computed has to say so where the view would
   have been, or the switch reads as broken. Set in the page's serif, small and
   dim, so it is a caption and never a status line — and it is only ever the
   SERVER's own words about a precondition, never anything an agent wrote. */
.gap-why {
  margin: 8px 0 0;
  padding-top: 8px;
  border-top: 1px solid var(--line);
  font-family: var(--serif);
  font-size: 11px;
  line-height: 1.42;
  color: rgb(var(--ink-rgb) / 0.52);
}

/* ------------------------------------------------------------- the projects
 *
 * The third and last control cluster on this page, and it earns its corner the
 * way `#step` and `#layers` earn theirs: it is the only thing here that says
 * this process holds MORE than the model in front of you. Same idiom as both —
 * a pill, a corner, no panel, no labels — and it says the one word it has to,
 * which is the name of the project you are in.
 *
 * It sits under the wordmark rather than opposite it because the top right is
 * the quota's, and two pills in one corner read as a toolbar starting.
 */

#place {
  position: fixed;
  top: 44px;
  left: 26px;
  z-index: 9;
  display: flex;
  align-items: center;
  gap: 7px;
  max-width: 240px;
  padding: 5px 12px 5px 10px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: rgb(11 12 14 / 0.5);
  backdrop-filter: blur(6px);
  color: var(--ink-mute);
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  cursor: pointer;
  transition: color var(--wake), border-color var(--wake), background var(--wake);
}

#place[hidden] { display: none !important; }
#place:hover { color: var(--ink-soft); border-color: rgb(var(--ink-rgb) / 0.22); }
#place[aria-expanded="true"] { color: var(--accent-deep); border-color: var(--accent-soft); }

#place-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#place-dot {
  width: 5px;
  height: 5px;
  flex: none;
  border-radius: 50%;
  background: var(--accent-soft);
}

/* The overlay. Full bleed and opaque enough to read type over geometry — the
   model is still there under it, and a picker you cannot read is worse than a
   list you have to leave the model for. */
#places {
  position: fixed;
  inset: 0;
  z-index: 14;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgb(11 12 14 / 0.82);
  backdrop-filter: blur(10px);
  animation: places-in var(--enter) both;
}

#places[hidden] { display: none !important; }

@keyframes places-in { from { opacity: 0; } to { opacity: 1; } }

#places-inner {
  width: min(720px, 86vw);
  max-height: 76vh;
  overflow-y: auto;
}

#places-title {
  margin: 0 0 16px;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-mute);
}

#places-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(196px, 1fr));
  gap: 10px;
}

/* A CELL, NOT A CARD, is what the grid lays out now. The card is still the
   button that opens the project and fills the whole of it; the pencil is a
   sibling in the corner, because a button inside a button is invalid HTML and
   the browser hoists the inner one clean out of the outer. */
.place-cell {
  position: relative;
  display: flex;
}

.place-card {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 14px 15px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: rgb(var(--ink-rgb) / 0.03);
  color: var(--ink-soft);
  font-family: inherit;
  font-size: 13px;
  text-align: left;
  cursor: pointer;
  transition: background var(--wake), border-color var(--wake), color var(--wake);
}

.place-card:hover { background: rgb(var(--ink-rgb) / 0.09); color: var(--ink); }

/* The one DECISION on this screen, so it is the one thing wearing the cream —
   the same rule that makes Build the CAD and Download STEP the only warm
   things on their own screens. */
.place-card.new { border-color: var(--accent-soft); color: var(--accent-deep); }
.place-card.new:hover { background: var(--accent-deep); color: var(--void-0); }

/* The project you are already in is not a destination. It stays in the list
   because taking it out would make the grid re-flow under the pointer every
   time you open it, and because "you are here" is half of what a picker is
   for. */
.place-card.here { cursor: default; border-color: var(--accent-soft); }
.place-card.here:hover { background: rgb(var(--ink-rgb) / 0.03); color: var(--ink-soft); }

.place-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  /* ROOM FOR THE PENCIL, which is drawn over this line's right end. Without
     it a long folder name runs underneath the control and the ellipsis never
     fires — `IMG_4127-bracket-2` reads as complete with a glyph sitting on
     its last character. The meta line under it keeps the full width. */
  padding-right: 16px;
}

.place-meta {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-mute);
}

/* A turn outlives the tab that started it and can be running in another
   project. Switching into one of those is switching into a model that is
   moving, and this is the only place that fact is visible. */
.place-card.busy .place-meta { color: var(--accent-deep); }

/* The rename, and it is quiet without being hidden. A picker is a list of
   names to click, not a list of names to edit — but a control nobody can see
   is a control nobody uses, and nothing else on this screen says a project can
   be renamed at all. So it is drawn at rest, faintly, and comes up to full
   under the pointer. `:focus` is on the same rule: a keyboard reaching a
   control at `opacity: 0` would tab to nothing. */
.place-rename {
  position: absolute;
  top: 6px;
  right: 6px;
  width: 22px;
  height: 22px;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--ink-mute);
  font-size: 12px;
  line-height: 1;
  cursor: pointer;
  opacity: 0.4;
  transition: opacity var(--enter);
}

.place-cell:hover .place-rename,
.place-rename:focus { opacity: 1; }
.place-rename:hover { color: var(--ink); background: rgb(var(--ink-rgb) / 0.10); }

/* THE FIELD IS THE CARD, laid over it rather than beside it: renaming a
   project is editing the name that is already on screen, and a dialog for one
   short string would be a second overlay over the overlay. The card stays
   underneath and stays legible — the meta line still says when you were last
   there, which is half of what tells two projects apart. */
.place-field {
  position: absolute;
  left: 14px;
  right: 14px;
  top: 12px;
  padding: 2px 6px;
  border: 1px solid var(--accent-soft);
  border-radius: 6px;
  background: var(--void-0);
  color: var(--ink);
  font-family: inherit;
  font-size: 13px;
}

.place-field:focus { outline: none; border-color: var(--accent-deep); }
.place-cell.renaming .place-name { visibility: hidden; }
.place-cell.renaming .place-rename { display: none; }

/* ------------------------------------------------------- what the user SAYS
 *
 * The third and last control cluster, and the only one that is not about
 * looking at the model — it is about answering it. Same idiom as the other
 * two: pills, no panel, no labels above them, nothing at all until there is
 * geometry to write on.
 *
 * Bottom CENTRE on purpose. `#layers` (left) and `#step` (right) are already
 * spoken for, and this belongs between them in more than position: it is what
 * you do when the part is NEARLY right, which is the state between reading it
 * and downloading it. It is also the only control on this page a user has to
 * FIND rather than recognise, and the middle is where a person looks.
 */

#marks {
  position: fixed;
  left: 50%;
  bottom: 26px;
  transform: translateX(-50%);
  z-index: 9;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  /* THE FIELD DECIDES THE WIDTH AND THE BUTTONS SIT UNDER IT. Sized by
     content, a 92 px row would put a 92 px field over it — and a field that
     narrow reads as a search box, which is the one thing this is not.
     THE SECOND TERM IS NOT A `vw` FRACTION, AND MEASURING IS WHY. It was
     `min(520px, 84vw)`, and 84vw is 756 px on a 900 px window — so the clause
     that was there to keep this clear of the other two clusters never bound
     until ~620 px, and the bar sat ON TOP of `#layers` (measured, five widths,
     boxes read off the real page, twice — the second time because `main`
     landed a fifth layer switch under this branch and took `#layers` from
     225 px to 349 px, which is exactly why a number like this is measured on
     the MERGED tree and not on the one it was written against).

     So the bound is the SPACE LEFT between the two side clusters, not a
     fraction of the window: `#layers` runs to x=349 on the left, and the 730
     is that, MIRRORED, plus a margin — this box is centred, so the left
     cluster costs it twice. Under ~1010 px there is no such space and the
     floor wins, which is a viewport this page's bottom clusters do not fit in
     with or without a field.

     THE RIGHT SIDE IS NO LONGER `#step`. `#feedback` landed there in the same
     week and is 288 px wide with its note box open, against Download STEP's
     141 — so the widest thing on the right is 314 px from the edge, and the
     left cluster still binds first at every width this clears. Measured with
     that box OPEN, which is the worst case and the one nobody would think to
     check: clear at 1600, 1440, 1280 and 1024. */
  width: max(280px, min(520px, calc(100vw - 730px)));
}

#marks[hidden] { display: none !important; }
#marks button[hidden] { display: none !important; }

#mark-row {
  display: flex;
  gap: 6px;
  justify-content: center;
  flex-wrap: wrap;
}

/* WHAT THE PART IS WRONG ABOUT WHEN NO FACE IS WRONG. A rounded rectangle and
   not a pill: a pill is a control you press, and this is a place you write.

   ONE LINE, AND IT STAYS ONE LINE. It used to grow to four as the sentence
   did, and Jules's call on 2026-08-24 is that a box which changes height is a
   panel arriving in instalments — this page has no panel, and the one place a
   person writes should not be the largest thing on screen. The scrollbar goes
   with it: with the height fixed, a long sentence scrolls under the caret, and
   a scroll TRACK drawn down the inside of a 42 px field is a piece of chrome
   wider than the ink it is measuring. Hidden in both engines, never
   `overflow: hidden` — that would put the end of the sentence somewhere the
   caret can reach and the eye cannot. */
#mark-said {
  display: block;
  box-sizing: border-box;
  width: 100%;
  height: 42px;
  min-height: 42px;
  max-height: 42px;
  padding: 11px 16px;
  border: 1px solid var(--line);
  border-radius: 18px;
  background: rgb(11 12 14 / 0.6);
  backdrop-filter: blur(6px);
  color: var(--ink);
  font-family: inherit;
  font-size: 13px;
  line-height: 1.45;
  resize: none;
  /* ONE LINE, ENFORCED BY THE WRAPPING AND NOT BY THE HEIGHT. Bounding the
     height alone was not enough and the screenshot is what said so: the box
     measured 42 px and rendered two lines of type inside it. `nowrap` makes a
     second line impossible rather than clipped, and the sentence scrolls
     sideways under the caret. */
  white-space: nowrap;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;              /* firefox */
  transition: border-color var(--wake);
}

#mark-said::-webkit-scrollbar { width: 0; height: 0; }

#mark-said[hidden] { display: none !important; }
#mark-said:focus { outline: none; border-color: var(--accent-soft); }

/* THE ONLY INSTRUCTION ON THIS PAGE, and it has to be readable to be one —
   the muted ink the rest of the chrome uses is for labels beside a control,
   not for a sentence somebody is meant to act on. */
#mark-said::placeholder {
  color: rgb(var(--ink-rgb) / 0.42);
  opacity: 1;
}

#marks button {
  padding: 8px 15px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: rgb(11 12 14 / 0.6);
  backdrop-filter: blur(6px);
  color: var(--ink-mute);
  font-family: inherit;
  font-size: 12.5px;
  letter-spacing: 0.01em;
  cursor: pointer;
  transition: background var(--wake), color var(--wake), border-color var(--wake);
}

/* ARMED is a MODE, and a mode nobody can see is a mode that eats clicks: while
   this is on, a click on the part plants a note instead of doing nothing. Same
   rule as `#layers` — the cream belongs to the state, never to hover. */
#marks button[aria-pressed="true"] {
  border-color: var(--accent-soft);
  color: var(--accent-deep);
}

#marks button:not(:disabled):hover { background: rgb(var(--ink-rgb) / 0.10); }
#marks button:disabled { opacity: 0.28; cursor: default; }

/* The decision, in cream, exactly like Download STEP. The two never compete:
   this one is "the part is not right yet" and that one is "it is". */
#mark-send {
  border-color: var(--accent-soft) !important;
  color: var(--accent-deep) !important;
}

#mark-send:not(:disabled):hover {
  background: var(--accent-deep) !important;
  border-color: var(--accent-deep) !important;
  color: var(--void-0) !important;
}

/* A send can be refused — mid-turn, or with three runs spent. Nothing here
   paints words, so it is the border going out and coming back, borrowing
   `#layers`'s own `refuse` keyframes. */
#marks button.refused { animation: refuse 700ms ease; }

/* HOW CAREFULLY TO BUILD IT — three words beside the receipt.
   It shares the receipt's lifetime exactly: shown with the staged file, gone
   the moment the turn starts, so nothing of it stands on screen while the
   agent works. `aria-checked` carries the state, so the selected look and what
   a screen reader announces cannot drift apart. */
#mode {
  /* THREE EQUAL COLUMNS, on the drop zone's own width. Sized by content the
     three read as three unrelated buttons — "Auto" is 92 px against "Fast"'s
     160 — and the eye has to measure them before it can compare them. A grid
     of `1fr` on `#drop`'s `min(452px, 86vw)` makes the row a base under the
     card rather than a toolbar beside it, and the choice becomes one control
     with three faces. */
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  width: min(452px, 86vw);
  gap: 6px;
  margin: 14px auto 0;
  /* IN FLOW EVEN WHEN IT IS NOT OFFERED. `display: none` here moved the drop
     zone ~50 px under the cursor between letting go of the file and pressing
     the button — `try_home.py` calls that "one footprint, two faces" and goes
     red on it. Reserving the row costs an empty strip on the idle screen and
     buys a composition that does not jump. */
  visibility: hidden;
}
body.staged #mode { visibility: visible; }
#mode[hidden] { display: flex; }
#mode button {
  appearance: none;
  /* The label and its gloss are centred on a column the content does not get
     to size, so a longer sentence wraps inside its own box instead of pushing
     its neighbours out of alignment. */
  min-width: 0;
  background: rgba(255, 255, 255, .03);
  border: 1px solid rgba(255, 255, 255, .10);
  border-radius: 6px;
  color: rgba(255, 255, 255, .52);
  cursor: pointer;
  font: inherit;
  font-size: 12.5px;
  line-height: 1.25;
  padding: 7px 13px 8px;
  text-align: center;
  transition: color .14s, border-color .14s, background .14s;
}
#mode button span {
  display: block;
  font-size: 10.5px;
  letter-spacing: .01em;
  margin-top: 2px;
  opacity: .62;
}
#mode button:hover { color: rgba(255, 255, 255, .78); }
#mode button[aria-checked="true"] {
  background: rgba(255, 255, 255, .07);
  border-color: rgba(255, 255, 255, .30);
  color: rgba(255, 255, 255, .94);
}
#mode button:focus-visible {
  outline: 2px solid rgba(255, 255, 255, .55);
  outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) { #mode button { transition: none } }

/* ------------------------------------------------------- was that any good
 *
 * The fourth and last cluster, and the only one that is not about the model or
 * about the agent: it is about the RUN. A trace in obs says what the agent did
 * and never what the person made of it, so two thumbs and — once one is
 * pressed — a box for the sentence they may want to add.
 *
 * Same corner as Download STEP and directly above it, because they are the two
 * halves of one moment: "it is right, I will take it", and "was that any good".
 * Directly above and not beside, so the download never moves under a cursor
 * that is reaching for it — this cluster appears when a turn ends, which is
 * exactly when somebody is going for the STEP.
 *
 * NOTHING HERE IS ADDRESSED TO THE AGENT. `#marks` is the channel that talks
 * to the model and every word in it is anchored to geometry; this one talks to
 * us, reaches no model, and starts no turn (`server.record_feedback`). That is
 * why it is allowed a textarea at all, and why the textarea may never move
 * into `#marks` — `dev/try_marks.py` asserts that bar carries no free-text
 * field, and it is asserting a product decision, not a layout.
 */

#feedback {
  position: fixed;
  right: 26px;
  bottom: 74px;
  z-index: 9;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 8px;
}

#feedback[hidden] { display: none !important; }
#fb-note[hidden], #fb-thanks[hidden] { display: none !important; }

#fb-thumbs { display: flex; gap: 6px; }

#fb-thumbs button {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: rgb(11 12 14 / 0.6);
  backdrop-filter: blur(6px);
  color: var(--ink-mute);
  cursor: pointer;
  transition: background var(--wake), color var(--wake), border-color var(--wake);
}

#fb-thumbs button:hover { background: rgb(var(--ink-rgb) / 0.10); }

/* PRESSED IS A STATE AND NOT A HOVER, the same rule the layer switches live
   under: the cream belongs to what is true, never to where the pointer is. */
#fb-thumbs button[aria-pressed="true"] {
  border-color: var(--accent-soft);
  color: var(--accent-deep);
}

/* The one thing on this page a person types into, and it exists only after a
   thumb has been pressed — i.e. after the feedback itself is already filed. */
#fb-note {
  display: flex;
  flex-direction: column;
  gap: 6px;
  width: min(288px, 74vw);
  padding: 10px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: rgb(11 12 14 / 0.72);
  backdrop-filter: blur(6px);
}

#fb-text {
  width: 100%;
  box-sizing: border-box;
  resize: none;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: rgb(var(--ink-rgb) / 0.04);
  color: var(--ink);
  font: inherit;
  font-size: 12.5px;
  line-height: 1.35;
  padding: 7px 8px;
}

#fb-text::placeholder { color: var(--ink-mute); }
#fb-text:focus-visible { outline: 1px solid var(--accent-soft); outline-offset: 1px; }

#fb-row { display: flex; justify-content: flex-end; gap: 6px; }

#fb-row button {
  padding: 6px 13px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: transparent;
  color: var(--ink-mute);
  font-family: inherit;
  font-size: 12px;
  cursor: pointer;
  transition: background var(--wake), color var(--wake), border-color var(--wake);
}

#fb-row button:hover { background: rgb(var(--ink-rgb) / 0.10); }

/* The decision, in cream, exactly like Download STEP and Send. */
#fb-send { border-color: var(--accent-soft); color: var(--accent-deep); }
#fb-send:hover {
  background: var(--accent-deep);
  border-color: var(--accent-deep);
  color: var(--void-0);
}

/* Said once, and then the whole cluster goes: an acknowledgement that stays is
   a caption on the geometry, which is the rule the thinking line lives under. */
#fb-thanks {
  margin: 0;
  color: var(--ink-mute);
  font-size: 12px;
  letter-spacing: 0.01em;
}

/* The label a screen reader needs and the corner does not have room for. */
#feedback .vh {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  #fb-thumbs button, #fb-row button { transition: none; }
}
