/* ---------------------------------------------------------------------------
   Canvas
   Every mark carries a variable. No mark is decoration. That single rule is
   what makes this both minimal and dense — there is nothing to strip out
   because nothing was added for looks.
   --------------------------------------------------------------------------- */

:root {
  /* How far the floating chrome sits from the edges, and how far the top row
     sits from the top. Named because a menu's position is written against the
     control it hangs off, in a different rule from that control — so a build
     that moves the control has to remember to move the menu, and the Mac app
     moved the control and did not. Both are now one edit. */
  --chrome-inset: 14px;
  --chrome-top: 14px;

  /* ---- the greys, and there are four -------------------------------------
     Every surface, hairline, quiet mark and second-read word in this app is
     one of these four. Nothing else in it is grey.

     There were a dozen before, in two families: a warm set for the canvas and
     a cold set for the chrome, on the argument that what you write on should
     read like paper and what you handle it with should not. It is a nice line
     and it did not survive contact with the app. Two families meant every new
     surface had to answer a question — is this a thing you work ON or a thing
     you work WITH — that nobody could see the answer to once it was drawn, and
     a wrong answer did not look wrong, it looked slightly dirty beside its
     neighbour. Around the two families a dozen one-off hexes had collected,
     each written next to the rule that wanted it, which is how a palette stops
     being a palette.

     One family now, and it leans cold: r < g < b at every step, by the same
     margin the whole way up, so any two of these sit together with no
     temperature seam between them.

     The steps are roles, not shades. Almost nothing below says --grey-N; each
     rule says the role it means, and the roles are what hold the count at four.

       1  fills   every surface that sits on the page
       2  lines   every hairline, edge and key cap
       3  faint   a mark that is present and is not being read
       4  dim     a word that is read second

     The page and the ink are not on this scale. They are the two ends it runs
     between. */
  --grey-1: #eef1f5;
  --grey-2: #dde2e9;
  --grey-3: #aab2bd;
  --grey-4: #6e7683;

  --bg: #fff;
  /* Cold as well, and it has to be. An ink that leans warm is the last warm
     thing on the page, and it does not stay on the page: --chrome-edge, the
     hover wash and the panel shadow below are all this colour at a low alpha,
     so a lean here is a lean carried into every hairline in the chrome. */
  --ink: #1a1d21;

  --faint: var(--grey-3);
  --dim: var(--grey-4);
  /* The fill under a run of code, the chip a typed command wears, a table's
     heading — and every chip that floats over the canvas, which used to be a
     second grey named --cold-panel. There is one fill now because there is one
     family, and a chip on the plane and a chip in the chrome are the same
     object seen in two places. */
  --panel: var(--grey-1);
  --line: var(--grey-2);
  --sel: #0d99ff;
  --accent: #d97757;

  /* A relationship, drawn as a stream between two blocks. See canvas/ties.js.

     Grey, because the graph is structure rather than a second annotation
     colour. It should clarify the canvas, not compete with selected blocks,
     agent identity, or the ink somebody drew by hand.

     --tie-on is the blue everything selected on this canvas wears, because a
     ribbon under the pointer is selected in precisely the same sense a block
     is: Delete removes it. */
  --tie: rgb(110 118 131 / .2);
  --tie-on: rgb(13 153 255 / .28);
  --tie-edge: rgb(110 118 131 / .36);
  /* Overlaps deepen rather than average. Multiply on a light ground is two
     washes of ink laid over each other, which is what these are meant to look
     like; the dark theme screens instead, for the same reason and the opposite
     arithmetic. */
  --tie-blend: multiply;

  --tok-c: var(--grey-4);
  --tok-s: #3f8f5f;
  --tok-tag: #3b6ea5;
  --tok-kw: #8452ad;

  /* floating chrome — the zoom pill, its menu, the presence stack. One surface
     token set, so every panel is the same object seen in different places.
     No shadow ever leaves the panel's own edge: --chrome-shadow is pure inset,
     cast DOWN from the top edge — the shading sits just inside the border at
     the top, like a surface recessed into the canvas, not a card lifted off
     it. */
  --chrome: #fff;
  --chrome-edge: rgb(26 29 33 / .09);
  --chrome-hover: rgb(26 29 33 / .055);
  --chrome-sunk: rgb(26 29 33 / .05);
  --chrome-shadow: inset 0 2px 4px rgb(26 29 33 / .13), inset 0 0 0 1px rgb(26 29 33 / .07);
  --chrome-ink: var(--ink);
  /* What a provider's mark is drawn in. Its own token rather than --chrome-ink
     because it is not text: a logo at 15px reads heavier than a letterform at
     the same ink, so it sits at the dim step in light mode and goes all the way
     to white in dark, where a mark has to hold its own against the field.
     White is not on the scale above and does not need to be: it is the ground
     the light theme is drawn on, borrowed. */
  --logo-ink: var(--grey-4);
  --chrome-dim: var(--dim);

  /* Whether a thing worked, for the two places in this app that have to say so
     without a sentence: a step in an agent's receipt, and the mark beside it.
     Cold rather than warm, like the rest of the chrome they are drawn in — a
     green that leans the way the ink does reads as a highlighter on the canvas
     rather than as a tick on a control. Both are borrowed off the code tokens
     above at the same lightness, so the panel and a run of code agree about
     what green is. */
  --ok: #3f8f5f;
  --bad: #c4553d;

  /* the arrow itself is the agent's hue; the halo stays white in both themes so
     a saturated cursor keeps its edge over ink, text, and a live browser frame */
  --ptr-edge: #fff;

  /* one typeface everywhere — block text, code, chrome. --mono and --ui stay
     two names (not one) so a future seam — code wanting an actual monospace
     again — is a one-line change, not a find-and-replace. */
  --mono: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  --ui: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;

  /* and the seam, opened. An actual monospace, used only where somebody is
     reading a program rather than reading the canvas: the source view under a
     built app. Indentation has to line up there and a proportional font makes a
     nonsense of it, which is not true of the odd `word` inside a sentence. */
  --code: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  /* one curve for every piece of chrome that opens, moves, or slides */
  --ease: cubic-bezier(.16, 1, .3, 1);

}

:root[data-theme="dark"] {
  /* The same four steps, turned over. 1 is still the surface that sits on the
     page and 2 is still the line; they are simply the lighter of each pair here
     rather than the darker. Same lean, same spacing, so a panel that stood one
     step off the page in light stands one step off it here. */
  --grey-1: #1c2025;
  --grey-2: #2d3239;
  --grey-3: #545c66;
  --grey-4: #969ea9;

  --bg: #121417;
  --ink: #dde1e7;

  --faint: var(--grey-3);
  --dim: var(--grey-4);
  --panel: var(--grey-1);
  --line: var(--grey-2);

  --tok-c: var(--grey-4);
  --tok-s: #5fbf82;
  --tok-tag: #6ea8e0;
  --tok-kw: #b48ce0;

  --chrome: var(--grey-1);
  --chrome-edge: rgb(255 255 255 / .09);
  --chrome-hover: rgb(255 255 255 / .07);
  --chrome-sunk: rgb(255 255 255 / .06);
  /* same top-cast inset, dark surface: the shading goes to true black
     rather than grey so it still reads against a dark fill */
  --chrome-shadow: inset 0 2px 5px rgb(0 0 0 / .55), inset 0 0 0 1px rgb(0 0 0 / .4);
  --chrome-ink: var(--ink);
  --logo-ink: #fff;
  --chrome-dim: var(--dim);

  /* Lifted the way every other token here is on a dark ground: same hue, more
     light, so a tick keeps its weight against the panel instead of sinking into
     it. --ok tracks --tok-s exactly, as it does in the light theme. */
  --ok: #5fbf82;
  --bad: #e08b76;

  /* Brighter and screened, so a stream still reads as light laid over the page
     rather than as a stain on it. See the note in the light theme above. */
  --tie: rgb(170 178 189 / .22);
  --tie-on: rgb(90 180 255 / .3);
  --tie-edge: rgb(170 178 189 / .42);
  --tie-blend: screen;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

/* The app has one typeface, and this is where it is actually said. Every rule
   below still sets its own size and weight — but without a base here, any
   element whose rule forgets the `font` shorthand falls all the way back to the
   browser default, which is a 16px serif and reads as another application
   pasted into the panel.
   Deliberately NOT extended to `button, input, select` as a bare element
   selector: Clerk mounts its own sign-in form and account menu into this page,
   and a rule that broad reaches inside them. Our own controls each state their
   font, which is where a control's font belongs anyway — the OS default only
   ever leaks through the ones that forget. */
html, body {
  background: var(--bg);
  font: 400 13px/1.4 var(--ui);
  color: var(--ink);
  /* There was an `overscroll-behavior: none` here, to stop the page bouncing
     when a two-finger scroll pushed past what the document could scroll. It is
     gone, and the reason is worth leaving behind so it is not tried again the
     next time somebody notices the bounce.

     On macOS the elastic behaviour of the DOCUMENT scroller is not a separate
     decoration on top of scrolling — it is the same mechanism that carries
     momentum after your fingers leave the trackpad. Turning it off here took
     the rubber band and the flick with it, so the canvas moved exactly as far
     as a hand pushed it and then stopped dead. On an infinite plane that is a
     far worse trade than the bounce: panning is how you get anywhere.

     The bounce is fixed, and not here. It is fixed by moving the edge instead
     of the behaviour: the document below is 2.4 million pixels square, the
     plane sits a million of them in from every side, and the scroller
     therefore never reaches an end anybody can scroll to — not with a bounce,
     and not with an afternoon of panning either, which is the other way people
     found the edge. See #plane, `body` below, and GUTTER in core/viewport.js —
     which is the one place that knows the offset, so nothing else in the app
     had to learn about it.

     Why it mattered enough to do: what macOS drags when it rubber-bands is the
     whole page, `position: fixed` and all. In the Mac app the fixed things are
     the window's own card and the glass ring around it, so two fingers at the
     top of the canvas pulled the entire frame down off the window and showed
     the desktop through the gap. */
}

/* The document, which is bigger than the plane on purpose.

   2400000 square holds the plane (100000, and up to 400000 of it once scaled to
   the 400% ceiling) with a million-pixel gutter in front of it and the same
   left over behind it at every zoom level. It is empty in every direction
   the plane does not reach and costs a scroll range and nothing else.

   Two things live on that emptiness. It is what keeps macOS from rubber-banding
   the frame off the window, which needs only that the edge not be at the plane;
   and it is what keeps a hand from ever panning into a wall, which needs that
   the edge be a very long way from the plane. The second sets the number: see
   GUTTER in core/viewport.js. ui/styles.test.mjs checks this is big enough for
   both the gutters and the plane at full zoom. */
body { min-width: 500000px; min-height: 500000px; }

/* The plane is one fixed-size surface that is scaled, never re-laid out.
   core/viewport.js writes `transform: scale(z)` here and nothing else ever
   touches this element's transform.

   The origin has to be the corner, because the corner is canvas 0,0 and every
   coordinate in the app is measured from it: screen = canvas * zoom - scroll,
   both ways, everywhere. Scaling from the middle of a 100000px square would
   put the origin 50000px from where all of that arithmetic believes it is.
   core/viewport.js sets this again in JS for the same reason.

   One rule follows from the plane being scaled, and it binds everything drawn
   on it: nothing inside here may carry `will-change: transform`.

   A browser rasterises a composited layer at one scale and hands the bitmap to
   the compositor. Normally it notices when the scale it is being drawn at has
   changed and rasterises again — which is why text, borders and SVG on this
   plane stay sharp as you zoom, at any level, on any screen. The one thing that
   turns that off is a layer promising its transform is about to change:
   re-rastering something mid-animation every frame would cost more than the
   animation, so the browser keeps the bitmap it has and stretches it. The
   plane's zoom is an ancestor transform, so a hint meant to cover a 200ms slide
   ends up covering every zoom level the canvas will ever be at, and whatever
   carries it is a photograph of itself taken at one scale — pixelated at the
   others, and worst on exactly the small crisp things (a cursor, a hairline, a
   name) that the eye reads as resolution.

   Screen-space chrome is free to use the hint: the panels, the following bar,
   the task list. They are never scaled. Anything on the plane is, always.
   client/ui/styles.test.mjs holds the list and fails on a new one. */
#plane {
  position: relative;
  /* Canvas 0,0, held off the document's own corner. This is GUTTER in
     core/viewport.js and has to stay equal to it — that file turns a scroll
     offset into a canvas coordinate by subtracting exactly this, and
     ui/styles.test.mjs fails if the two numbers drift apart.

     A relative offset rather than a margin: a margin here collapses through
     body and html and moves the document instead of the plane, which leaves no
     blank in front of the origin at all — which is the entire point. It is
     laid out before the transform below, so the corner stays at 1000000,1000000
     in document space at every zoom level, and the arithmetic above stays one
     multiplication and one subtraction. */
  top: 0;
  left: 0;
  width: 100000px;
  height: 100000px;
  transform-origin: 0 0;
}

/* Ink is on top of what it is about.
 *
 * This layer used to sit where it is written — first child of #plane, z-index
 * auto — which put every stroke UNDER every block, because positioned siblings
 * with no z-index stack in document order and blocks are appended after it. On
 * most of the canvas nothing showed: a note has no background, so a line drawn
 * across one lands between the glyphs and reads as being on top.
 *
 * A running app is not transparent. `.block.render .frame` is a white iframe,
 * and an iframe is not a background colour that ink can sit behind — it is
 * another document painted over this one. So a ring drawn around a component
 * was captured, saved, sent to everyone else on the canvas, and drawn in a
 * place nobody could see it. The gesture the preview invites in its own words
 * ("anybody here can circle part of it and say what should change") was the one
 * gesture on this canvas with no mark to show for it.
 *
 * So the layer is lifted, generally rather than for apps. Ink over a sheet of
 * paper is ink on the paper, and ink over a component is a note about the
 * component: there is no case on a whiteboard where the pen goes under. It
 * stops below #marquee and #selbox at 5 and below #agents at 10, which is the
 * right order for the same reason — a selection box and a cursor are the canvas
 * talking about the ink, not more ink. */
#strokes {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 4;

  /* One period for the boil below, so the phase canvas/strokes.js writes per
     stroke is a fraction of this and nothing has to agree about a number. */
  --boil: .56s;
}
#strokes path {
  fill: none;
  stroke: var(--ink);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
#strokes path.ink { fill: var(--ink); stroke: none; }
#strokes path.sel { stroke: var(--sel); }

/* Ink that has been put down keeps moving, very slightly.
 *
 * A drawing is a drawn thing, and a drawn thing holding perfectly still reads
 * as a photograph of one. Hand-drawn animation has the opposite problem and
 * solved it by accident: every frame is re-inked, so no line ever lands twice
 * in the same place, and the picture appears to breathe. This is that, on
 * purpose. Each stroke sits in one of four positions less than half a pixel
 * apart and moves between them about seven times a second, which is slow
 * enough to read as a redraw rather than as a vibration.
 *
 * Per stroke rather than per layer, and that is the entire effect. One
 * transform on #strokes is the whole drawing sliding around together, which
 * looks like something is wrong with the page. Marks that each wander on their
 * own look like marks.
 *
 * Three things keep it honest:
 *
 *   - the phase is a negative delay written by canvas/strokes.js out of the
 *     stroke's id, so no two jump in step and every browser with this canvas
 *     open jumps the same stroke at the same moment
 *   - .wet is the stroke under the pen right now, and it is held still. Ink
 *     comes alive once it has been let go of; a line that squirms while you
 *     are still drawing it feels broken rather than alive
 *   - no will-change, ever. These paths are on the scaled plane, and a pinned
 *     raster there is ink that is sharp at one zoom level and mush at the
 *     rest. See the note above #plane, and ui/styles.test.mjs.
 */
@keyframes ink-boil {
  0%,
  100% { transform: translate(.38px, -.26px) rotate(.06deg); }
  25%  { transform: translate(-.34px, .2px) rotate(-.09deg); }
  50%  { transform: translate(.14px, .38px) rotate(.04deg); }
  75%  { transform: translate(-.2px, -.36px) rotate(-.03deg); }
}
#strokes path {
  transform-box: fill-box;      /* turn about the stroke, not about the plane */
  transform-origin: center;
  animation: ink-boil var(--boil) step-end infinite;
}
#strokes path.wet { animation: none; }

@media (prefers-reduced-motion: reduce) {
  #strokes path { animation: none; }
}

/* ---- relationships: the graph drawn between blocks ------------------------
 *
 * One layer, under the ink and over the blocks, and it is empty until somebody
 * turns the switch in the contexts tray on. Above the blocks for the reason
 * stated above #strokes: an app is an opaque frame, and a connection drawn
 * behind one is a connection nobody can see. Below the ink, because ink is what
 * somebody drew and this is what the canvas drew for them.
 *
 * Nothing here is a line. Each tie is a filled shape whose width varies along
 * it, with flat caps where it meets a block, because a stroke cannot change
 * width and because an arrow would be a second notation arguing with the arrows
 * people already draw in ink. See canvas/ties.js, which builds both banks of
 * every one of these.
 */
#ties {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 3;
  /* Not painted at all until the layer is on. `visibility` rather than
     `display`, so nothing about this 100000px layer is laid out twice when it
     comes back. */
  visibility: hidden;
}
body.linking #ties { visibility: visible; }

.tie {
  fill: var(--tie);
  stroke: none;
  mix-blend-mode: var(--tie-blend);
  pointer-events: none;
  transition: fill .12s ease;
}
/* Only ever grabbable while the layer is up, and only the ribbon itself: the
   layer above it stays transparent to the pointer, so turning relationships on
   takes nothing away from the canvas underneath. */
body.linking .tie { pointer-events: fill; cursor: grab; }
body.linking .tie:hover { fill: var(--tie-edge); }
body.linking .tie.on { fill: var(--tie-on); }
/* The one in the air is not a thing yet: it must not swallow the pointer that
   is looking for the block underneath it. */
.tie-ghost, body.linking .tie-ghost { pointer-events: none; fill: var(--tie-edge); }

/* What the person called it, written along the middle where the ribbon is
   widest. Haloed in the page colour rather than sat on a chip: a chip is
   another rectangle on a canvas of rectangles, and paint-order lets the letters
   carry their own clearance through whatever they cross. */
.tie-name {
  fill: var(--dim);
  font: 500 12px var(--ui);
  text-anchor: middle;
  dominant-baseline: middle;
  paint-order: stroke;
  stroke: var(--bg);
  stroke-width: 4px;
  stroke-linejoin: round;
  pointer-events: none;
  user-select: none;
}
body.linking .tie-name { pointer-events: auto; cursor: text; }

/* Naming one. The only field on this canvas that is not a block, because what
   is being typed is a property of the relationship rather than another thing on
   the plane. It lives on the plane all the same, so it is in canvas units and
   scales with the zoom like everything else drawn there. */
.tie-field {
  position: absolute;
  transform: translate(-50%, -50%);
  width: 190px;
  padding: 4px 8px;
  border: 1px solid var(--tie-edge);
  border-radius: 7px;
  background: var(--bg);
  color: var(--ink);
  font: 500 12px var(--ui);
  text-align: center;
  outline: none;
  z-index: 6;
}
.tie-field::placeholder { color: var(--faint); font-weight: 400; }

/* While the layer is up, every block is a node. The frame is constant so the
   flat stream caps have a visible boundary to attach to, and hover only turns
   up the blue rather than inventing a second state. */
body.linking .block {
  cursor: crosshair;
  outline: 2px solid color-mix(in srgb, var(--sel) 58%, transparent);
  outline-offset: 0;
  border-radius: 8px;
}
body.linking .block:hover,
body.linking .block.tie-lit {
  outline-color: var(--sel);
}
/* An app is a frame that eats every pointer event, so while relationships are
   being drawn it stops being a running app and becomes a node like the rest.
   It goes back to being an app the moment the switch is off. */
body.linking .block .frame { pointer-events: none; }

#agents {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 10;
}

/* ---- text blocks ---- */

.block {
  position: absolute;
  width: max-content;
  max-width: 38rem;
  min-width: 8rem;
  /* the only thing that ever fades a block is a section search stepping it
     back; going quiet is the whole signal, so it should be seen happening */
  transition: opacity .15s ease;
}
.block pre, .block textarea {
  font: 400 16px/1.75 var(--mono);
  white-space: pre-wrap;
  overflow-wrap: break-word;
  word-break: break-word;
  tab-size: 2;
}
.block pre { color: var(--ink); min-height: 1.75em; }
/* Under a picture the empty caption line is dead space, but it cannot go to
   zero: the transparent textarea is inset:0 on the pre, so collapsing it would
   leave nowhere to click to write a caption. It is shortened instead — and the
   textarea's line-height moves with it, because the two agreeing is the whole
   reason the caret lands on the text. Typing removes .img-only, so a real
   caption gets the full line back.

   17px, and not a ratio, because this strip is now doing two jobs: it is the
   place a caption is typed, and it is the air under the picture. With the 3px
   the reaction bar stands off by, it comes to the 20px the header leaves above
   the picture, so an image with nothing written under it is spaced the same on
   both sides. See .block.has-image.img-only below. */
.block.img-only pre,
.block.img-only textarea { line-height: 17px; min-height: 0; }
/* An agent writes in ink, the same as everybody else.
   The tint was how you knew who had written a block, back when a block carried
   no name and no face. A message carries both now, and the colour was doing a
   job that had already been done — at the cost of putting the words themselves
   in periwinkle, which reads as a link, or as writing that has been marked up,
   rather than as somebody talking. The identity stays where it belongs: the
   cursor, the avatar, the tag on a mention, the halo on a selection. */
.block.agent pre { color: var(--ink); }
.block textarea {
  position: absolute;
  inset: 0;
  background: transparent;
  color: transparent;
  /* The ink it is about to lay down, not a colour of its own. A caret is the
     next character before you have typed it, so it belongs to the text rather
     than to the app, and a terracotta bar in front of black writing was the one
     thing on this canvas announcing itself for no reason. --ink rather than a
     literal black because the same rule has to hold on a dark canvas, where
     black is invisible and the text is pale. It also matches the caret in a
     table cell, which is contenteditable and has always used the text colour. */
  caret-color: var(--ink);
  border: none;
  outline: none;
  resize: none;
  overflow: hidden;
}
/* Selecting text in a block is the browser's own act, so it wears the browser's
   own colour. The terracotta wash this used to paint was the accent doing a job
   the platform already does, and it read as a highlight somebody had applied to
   the words rather than as a selection the reader was making. */
/* What somebody else said, and anything an agent wrote, is readable, selectable
   and quotable, and is not yours to rewrite. A blinking caret in one would be
   the same lie a greyed-out field tells, only more convincing — so the caret
   goes and the pointer stops promising a place to type. Selecting still works,
   because copying what was said is exactly the thing you do with a record. */
.block textarea[readonly] { caret-color: transparent; cursor: default; }
/* #selbox already draws one box around everything selected — outlining each
   block as well meant a single selected block wore two rectangles. The class
   still marks what is selected; it just no longer paints. */

/* ---- paper: the sheets, and the pages that came before them ---------------
   Nothing on the canvas makes a PAGE any more: the mode that did has come off
   the "/" menu. Those rules stay for the pages written while it was there,
   which are still in documents and still have to come back looking like what
   they were.

   A sheet of paper is the same stuff, put down on purpose — "/paper", or ⌥P —
   and it is the one anybody makes today. The look is shared rather than copied
   because it is genuinely the same object; what makes paper its own kind is
   what can be DONE to it, not how it is drawn. See canvas/paper.js.

   A page rather than a note. Every other one here is words lying directly on
   the canvas; this is the one that carries a ground of its own, because it is a
   document being kept rather than a line being said. So it is the least paper
   that can still be told apart from the canvas: an opaque fill, a hairline, and
   a corner barely off square. No shadow, and what is written inside it is
   ordinary markdown, rendered the way it is everywhere else here.

   4px of radius, which is under a third of what a card in this app wears. At 0
   a sheet read as a crop mark — a rectangle cut out of the canvas rather than
   an object placed on it — and at 10 it read as a card, which is a thing with
   controls in it. This is the amount that says "sheet": you notice it only by
   how much less sharp the corner is than the section frames around it.

   The padding goes on the pre and the textarea rather than on the block: the
   textarea is inset:0 over the block, and the caret only lands on the glyph it
   is in front of while both boxes are identical.

   Every rule is held off .cmd for the reason the chat chrome below is: typing
   "/" in a page is still typing a command, and a mode picker wearing a page
   would be a costume on the wrong thing. While the command is live the block is
   exactly the plain one it would have been, and the paper comes back with the
   words. */
.block.page:not(.cmd),
.block.paper:not(.cmd) {
  width: 24rem;
  max-width: 24rem;
  background: var(--bg);
  border: 1px solid var(--line);
}
/* The column the body stands in. A sheet given a height by its edges has to
   fill it: laid out as ordinary blocks the words
   would end where they end and the paper under them would be empty space with
   nothing to click on, which is a page you cannot type at the bottom of. */
.block.paper:not(.cmd) {
  border-radius: 4px;
  display: flex;
  flex-direction: column;
}
.block.page:not(.cmd) pre,
.block.page:not(.cmd) textarea,
.block.paper:not(.cmd) pre,
.block.paper:not(.cmd) textarea { padding: 14px 16px; }
/* an empty page is still a page, and this is how much of one */
.block.page:not(.cmd) pre,
.block.paper:not(.cmd) pre { min-height: 9rem; }
/* A sheet dragged taller is taller everywhere: the block carries the height, so
   the body has to be willing to fill it or the paper would end where the words
   do and the corner would be pulling on nothing. */
.block.paper:not(.cmd) .block-body { flex: 1; display: flex; }
.block.paper:not(.cmd) .block-body pre { flex: 1; }

/* ---- the edges you pull --------------------------------------------------
   A sheet is the only block here whose size is a decision. A message is as big
   as what was said; a table is as big as its columns; a sheet is as big as you
   want the sheet, because it is paper and the whole point of paper is that you
   choose how much of it there is.

   So it is resized the way a region is: eight invisible strips over its own
   edges, and the cursor is the entire affordance. No handle drawn in the corner,
   which could only ever pull down and to the right — a sheet grown upward has to
   keep the corner you are not touching where it was, and that is what an edge is
   for. Same offsets and same widths as .section-grip above, because it is the
   same gesture and a second set of numbers would drift from the first.

   z-index 3 puts them over the transparent textarea, which is inset:0 on the
   body and would otherwise take every press that landed within a few pixels of
   the edge. That costs the outermost 5px of the writing area as a place to put
   the caret; it is inside the padding, where there is no text to aim at. */
.paper-grip {
  position: absolute;
  z-index: 3;
}
.paper-grip-n, .paper-grip-s { left: 0; right: 0; height: 11px; cursor: ns-resize; }
.paper-grip-w, .paper-grip-e { top: 0; bottom: 0; width: 11px; cursor: ew-resize; }
.paper-grip-n { top: -6px; }
.paper-grip-s { bottom: -6px; }
.paper-grip-w { left: -6px; }
.paper-grip-e { right: -6px; }
/* corners sit over the edges they meet, so aiming at one gets both axes */
.paper-grip-nw, .paper-grip-ne, .paper-grip-sw, .paper-grip-se {
  width: 15px; height: 15px;
  z-index: 4;
}
.paper-grip-nw { top: -7px; left: -7px; cursor: nwse-resize; }
.paper-grip-ne { top: -7px; right: -7px; cursor: nesw-resize; }
.paper-grip-sw { bottom: -7px; left: -7px; cursor: nesw-resize; }
.paper-grip-se { bottom: -7px; right: -7px; cursor: nwse-resize; }

/* A pen has to be able to draw across a sheet, and an eraser to rub something
   off one, so the edges stop answering the pointer in the two modes where the
   pointer is not for pointing. Drag mode keeps them, unlike a region's: a region
   is ground you pan from, and a sheet is an object you are holding. */
body.draw .paper-grip,
body.erase .paper-grip { pointer-events: none; }
/* and nobody who may not change the document may change its shape */
body[data-role="viewer"] .paper-grip,
body[data-role="commenter"] .paper-grip { display: none; }

/* ---- chat blocks ---------------------------------------------------------
   Slack's shape, for exactly the conversation that already happens here: your
   own write-mode notes, and an idle agent's reply to one. .block-body is what
   .block itself used to be — pre and its transparent textarea, absolutely
   overlaid — just demoted one level so the header can sit above it without
   the textarea's inset:0 landing on top of the avatar instead of the text. */

/* One gutter down the left of a message, and everything said in it lives to the
   right of that gutter: the name, the words, the picture, the reactions. Only
   the face is in it. That is the whole of Slack's shape, and it is the reason a
   thread of messages reads as a column of what was said rather than as a stack
   of headers — the eye follows one left edge down the text, and the faces sit
   outside it.
   The face is out of flow rather than the first cell of a grid, so its height is
   its own business: it stands across the name and the first line under it
   without either of those rows growing to hold it, and a message whose first
   line is a picture or a table is spaced by the picture, not by the avatar.

   The gutter is chrome, not measure: the words in a message wrap at the same
   width as the words in a plain note, so a thread and a note beside it read at
   one text width instead of two. Both bounds are widened by exactly what the
   face takes, and box-sizing is border-box, so the text inside lands back on
   the 38rem/8rem every other block obeys. */
.block.chat:not(.cmd) {
  --chat-gutter: 52px;
  padding-left: var(--chat-gutter);
  max-width: calc(38rem + var(--chat-gutter));
  min-width: calc(8rem + var(--chat-gutter));
}
/* the menus a message opens belong to the words, so they hang off the text's
   left edge and not off the face's */
.block.chat:not(.cmd) .mention-box { left: var(--chat-gutter); }

/* Chrome is not writing, and must not select like it.
   A drag across the canvas caught the task card's labels, a message's author
   and time, the reaction pills and the word "reply", and left them all in
   selection blue — none of which is text anybody meant to take a copy of. What
   IS text stays selectable: the message itself, the notes, and anything you can
   type into. */
.tasklist, .block-head, .block-reacts { user-select: none; }
.tasklist input, .tasklist textarea, .tasklist [contenteditable],
.tl-row, .tl-text { user-select: text; }

.block-head {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-bottom: 3px;
}
/* Big enough to stand beside the name AND the first line of the message, which
   is what makes the gutter read as one column rather than as a chip parked over
   a paragraph. 40px is that span here — 17px of name and the 28px line under it
   — the same proportion Slack's 36px keeps against its own smaller text. */
.block-avatar {
  position: absolute;
  left: 0; top: 0;
  width: 40px; height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  /* A squircle, the shape an app icon on a phone has, rather than a rounded
     square. A border-radius is a circular arc spliced into two straight sides,
     and the two joins are what make a rounded corner look drawn instead of
     shaped: the curvature jumps from nothing to everything at a point the eye
     can find. This is the superellipse |x|^5 + |y|^5 = 1 instead, where the
     corner is never entered and never left. It is written out as a path because
     the one-line way to say it — corner-shape: squircle — is in the newest
     Chromium and in no WebKit, and Safari is a browser this canvas is used in.
     Eight cubics, two per corner, fitted to the curve at 45° and 67.5°.
     The numbers are the 40px box above, so the two move together. There is no
     border-radius under it on purpose: the picture inside takes its corner from
     `border-radius: inherit`, and any radius here would intersect the clip and
     hand the corners back to the arc this is here to avoid. */
  clip-path: path("M40 20C40 34.89 39.2 35.62 37.41 37.41C35.62 39.2 34.89 40 20 40C5.11 40 4.38 39.2 2.59 37.41C0.8 35.62 0 34.89 0 20C0 5.11 0.8 4.38 2.59 2.59C4.38 0.8 5.11 0 20 0C34.89 0 35.62 0.8 37.41 2.59C39.2 4.38 40 5.11 40 20Z");
  background: var(--agent, hsl(var(--hue, 233) 58% 46%));
  color: #fff;
  font: 600 16px/1 var(--ui);
}
.block-avatar[data-you] { background: var(--accent); }
/* a real profile picture fills the chip entirely, so the letter-avatar's
   background never shows as a ring around it */
.avatar-img {
  width: 100%; height: 100%;
  /* the picture takes the chip's corner: a circle in the people list, and on a
     message nothing at all, because the chip clips it to a squircle instead */
  border-radius: inherit;
  object-fit: cover;
  display: block;
}
.block-avatar:has(.avatar-img) { background: none; }
/* Barely bold. At 600 the name outweighed the message under it in a column of
   messages that are mostly one line each; at 400 it sat level with the body and
   the header stopped reading as a header. 500 is the half step that names the
   speaker without competing with what they said. The case is whatever Clerk has
   on the account, because a person's name is theirs to capitalise. */
.block-name {
  font: 500 14px/1 var(--ui);
  color: var(--ink);
}
.block-time {
  font: 400 11px/1 var(--ui);
  color: var(--faint);
}
/* Quieter than the timestamp beside it: it is a footnote about the message, not
   a fact about the conversation. Emptied rather than hidden when the message
   has not been edited, and :empty is what keeps an empty one from spending the
   header's 8px gap on nothing. */
.block-edited {
  font: 400 11px/1 var(--ui);
  color: var(--faint);
  opacity: .75;
  cursor: default;
}
.block-edited:empty { display: none; }

/* the message being replied to: a rule down the side and one line of it, the
   way a quoted reply reads in chat. Clicking jumps back to the original. */
.block-quote {
  display: flex;
  align-items: baseline;
  gap: 6px;
  max-width: 100%;
  margin-bottom: 4px;
  padding-left: 8px;
  border-left: 2px solid var(--line);
  font: 400 12px/1.5 var(--ui);
  color: var(--dim);
  cursor: pointer;
}
.block-quote:hover { border-left-color: var(--dim); }
/* The quoted name goes with it. This one keeps a little weight rather than none:
   it is the only thing separating who from what on a single line that runs
   "Alex @soffo yo", where the header above has a gap and a timestamp to do it. */
.quote-name { font-weight: 500; color: var(--dim); flex: none; }
.quote-img { flex: none; color: var(--faint); }
.quote-text {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.quote-gone { font-style: italic; color: var(--faint); }

/* jumped-to messages flash once, so you can find what you landed on */
@keyframes blockflash {
  0%, 100% { box-shadow: 0 0 0 0 transparent; }
  20%      { box-shadow: 0 0 0 4px color-mix(in srgb, var(--sel) 34%, transparent); }
}
.block.flash { animation: blockflash 1.1s ease-out; border-radius: 6px; }
/* A page keeps its corners while it flashes. The radius above is there so the
   halo hugs a note; on a page it would round the one edge that says this is a
   page, for the second and a bit somebody is looking straight at it. Paper is
   not in this any more: it has 4px of its own, and the halo should follow the
   corner the sheet actually has. */
.block.page.flash { border-radius: 0; }

.block-body { position: relative; }

/* an image sits above whatever is written about it, capped so a big photo
   cannot stretch the block past the width text already obeys */
.block-image {
  display: block;
  max-width: 100%;
  max-height: 420px;
  width: auto;
  border-radius: 8px;
  border: 1px solid var(--line);
  margin-bottom: 2px;
  user-select: none;
  -webkit-user-drag: none;
}

/* A picture is an object in a block exactly as a grid is, and it takes the same
   air: the name opens up above it by the same amount, and the same gap is left
   under it before the reactions. It used to take 3px on both sides, so two
   messages side by side — one holding a table, one holding a photo — sat at
   different heights under the same name.

   The caption is the one thing that belongs TO the picture rather than after
   it, so it goes on hugging the frame at 2px and the gap falls below the words
   instead. With nothing written under it there is no caption to hug, and the
   picture gives its 2px up to the same gap. Both land on the 20px a table's
   grid stands off by, and both then wear the 3px every block leaves above its
   reactions. */
.block.has-image .block-head { margin-bottom: 20px; }
.block.has-image .block-body { padding-bottom: 20px; }

/* With nothing written under it there is no caption to hug, and the picture
   gives its 2px up to the gap. The gap itself is then the empty caption line
   and nothing else: the body's own 20px would have stacked on top of that line
   rather than replaced it, which is how a picture on its own came to sit 20px
   under its name and 41px above its reactions. 17px of caret line plus the 3px
   every block leaves above its reactions is the same 20px, measured this time
   from the bottom of the picture instead of from the bottom of an empty line
   nobody can see. */
.block.has-image.img-only .block-image { margin-bottom: 0; }
.block.has-image.img-only .block-body { padding-bottom: 0; }

/* More than one, and they are a strip you push sideways rather than a column
   that grows down the canvas. A message is a thing somebody said; six
   photographs stacked vertically would be six times the height of the sentence
   they arrived with, and the block would stop reading as one message at all.

   Held to a common height so the row has a straight top and bottom whatever
   mixture of portraits and landscapes is in it, and snapped, so a flick lands
   on a picture rather than between two. No scrollbar, the same argument the
   integrations list makes: the picture cut off by the right edge is what says
   there is another one, and it says it without drawing a bar. */
.block-shots { display: block; }
.block.has-shots .block-shots {
  display: flex;
  gap: 6px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  padding-bottom: 2px;
  margin-bottom: 2px;
}
.block.has-shots .block-shots::-webkit-scrollbar { width: 0; height: 0; }
.block.has-shots .block-image {
  flex: none;
  height: 220px;
  max-height: 220px;
  max-width: none;
  width: auto;
  margin-bottom: 0;
  scroll-snap-align: start;
}
.block.has-shots.img-only .block-shots { margin-bottom: 0; }

/* the command palette ("/write", "/ask", ...) is not a message — while a
   block is showing it, the chat chrome underneath is suppressed rather than
   dressing up a mode picker in an avatar and a reaction bar. It reappears on
   its own once the text stops looking like a command. */
.block.cmd .block-head, .block.cmd .block-reacts { display: none; }

/* ---- what the agent is doing ---------------------------------------------
   The run's own steps, in the block the run is writing, above the words it will
   write. A header naming where the work has been so far, and under it one line
   per call in the order they happened.

   This was a card first — a bordered lid over every tool call, each row
   expandable to the raw result — and that was a run being audited when nobody
   was auditing. Then it was the /build mark: the line writing and unwriting
   itself with seven words rotating beside it, on the argument that a person
   waiting on an answer wants to know something is alive and nothing more. That
   argument was right about the raw output and wrong about the words.
   "Actualizing" is true of every run there has ever been, so it says nothing
   about this one, and a person watching a two-minute errand could not tell a
   mailbox being read from a model going in circles.

   So the steps are back, and the lid with them, shut. What is drawn is the
   account the run already keeps — the verb, the app it reached, the one
   argument worth reading — as one sentence per row in two weights. A row opens
   onto what came back only where what came back could not have been said on the
   line, which is where the card went wrong: it was open by default and it put
   eight rows of somebody else's JSON between a question and its answer.

   Nothing here rotates. A spinner is a thing somebody is made to watch while
   nothing is being said, and every row in this panel says what it is; the row
   in flight has a band sweeping through its sentence and the mark at its head
   writing itself. Both are phase-matched to --age so a repaint does not restart
   them — see age() in ui/activity.js.

   The mark is the /build line, and it comes back here as the shape and nothing
   else: no words rotating beside it, no ground, no frame. Those belong to the
   place it is still drawn whole, which is a 640px rectangle waiting on a
   document, where there is genuinely nothing to report until one lands. See
   .ac-mark below, and .loading-mark and .framewip further down.

   Quiet, not loud. This is chrome laid over the work rather than a piece of the
   writing — the same call the zoom pill and the source card make — so it takes
   --dim and its line, and the message under it is the only thing in the block
   set in ink.

   It disappears on the answer's first character, not on the run's last one.
   See waitingIn() in ui/activity.js for why those are seconds apart. */
.ac {
  /* 6 and 7 rather than one number, because the panel is not what the air above
     and below it is measured from. The quote over it carries 4px of its own and
     the reactions under it carry 3, so these are the two halves that make both
     gaps 10px. Change either neighbour and change these with it. */
  padding: 6px 0 7px;
  user-select: none;
  /* The rows are a report and not a control, so a press on one lands on the
     block underneath exactly as it did when this was a single line — a message
     stays as clickable while it is being written as it is after. The header
     takes its own presses back below, because folding the list away IS a
     control and is the only one here. */
  pointer-events: none;
  font: 500 12.5px/1.45 var(--ui, system-ui, -apple-system, "Segoe UI", Inter, sans-serif);
  color: var(--dim);
  max-width: 100%;
}

/* The one control. See the note on pointer-events above. */
.ac-head {
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}
.ac-head.bare { pointer-events: none; cursor: default; }

/* The apps the header names, as their own marks, ahead of the words naming
   them. Up to three: past that the header is a strip of thumbnails and the
   sentence beside it already says "and 2 more". A source with no company behind
   it — the web, this canvas, this Mac — brings no mark and needs none. */
.ac-marks {
  flex: none;
  display: flex;
  align-items: center;
  gap: 3px;
}
.ac-marks:empty { display: none; }

.ac-title {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The band that sweeps through whatever is still being worked on.
   Borrowed from the mark's words and slowed: there the band is the only thing
   on a 640px frame and wants to be seen, here it rides through 12px type on a
   line somebody is reading, and at the mark's 3s it read as a flicker. Four and
   a half seconds, and the band widened from a 10% notch to a 26% swell so what
   passes over a word is a change of weight rather than a stripe crossing it.

   Cosine, not linear. A linear sweep on an infinite loop arrives back at its
   start at full speed, and what the eye catches is the seam. Eased at both ends
   with a pause between passes, the band comes in, crosses, and settles before
   it comes round again — which is the difference between a surface that is
   alive and one that is being animated at you. */
.ac .live {
  background: linear-gradient(100deg,
    var(--dim) 0%, var(--dim) 37%,
    var(--shine-ac) 50%,
    var(--dim) 63%, var(--dim) 100%);
  background-size: 280% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  /* Started from where the panel it replaced had got to. See age() in
     ui/activity.js: a rebuilt element restarts its animations at zero, and a
     negative delay on an infinite loop is the same loop already that far in. */
  animation: ac-sweep 4500ms cubic-bezier(.5, 0, .5, 1) infinite;
  animation-delay: calc(var(--age, 0) * -1ms);
}

/* The band is a contrast rather than a colour, so on white the word sits light
   and what rides through it is darker, and in the dark theme the two turn over.
   The same call --shine makes for the mark's words, made again here because the
   ground either side of it is --dim and not --ink-mark. Which makes the band
   the ink itself: it is already the one value that flips with the theme, so
   there is nothing to name here that the palette does not name. */
:root, :root[data-theme="dark"] { --shine-ac: var(--ink); }

@keyframes ac-sweep {
  0%   { background-position: 118% 0; }
  62%  { background-position: -18% 0; }
  100% { background-position: -18% 0; }
}

.ac-caret {
  flex: none;
  width: 12px; height: 12px;
  margin-left: 1px;
  opacity: 0;
  color: var(--dim);
  transition: transform .2s cubic-bezier(.3, .8, .4, 1), opacity .2s ease;
}
.ac-caret svg { width: 100%; height: 100%; display: block; }

/* On the header always, on a row only when there is a pointer on it. A caret
   standing on every row of a list of nine is nine invitations where one thing
   is being read; on hover it is an offer made at the moment somebody looks. */
.ac-head .ac-caret { opacity: .5; }
.ac-head:hover .ac-caret { opacity: .85; }
.ac-row:hover .ac-caret { opacity: .6; }
.ac.open > .ac-head .ac-caret,
.ac-step.on .ac-caret { transform: rotate(180deg); }
.ac-step.on .ac-caret { opacity: .75; }

/* Folded away rather than removed, so the header does not jump as the run adds
   a step to a list nobody is looking at. */
.ac-steps { display: none; padding-top: 2px; }
.ac.open .ac-steps { display: block; }

.ac-step { min-width: 0; }

/* The row itself: a button where there is something under it to open, a plain
   div where there is not. Both are drawn the same, because whether a result was
   kept is a fact about the verb and not something the eye should have to sort
   rows by; the caret on hover is the whole difference. */
.ac-row {
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: 7px;
  width: 100%;
  padding: 2.5px 0;
  border: 0;
  border-radius: 5px;
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
  min-width: 0;
}
button.ac-row { cursor: pointer; }
div.ac-row { pointer-events: none; }

/* The fill runs a little wider than the text so the row reads as an object
   under the pointer rather than as a highlight on a sentence. Negative margin
   rather than padding, so nothing in the column above it moves when it appears
   and the glyphs stay in the one line they were in. */
button.ac-row:hover {
  background: var(--panel);
  margin: 0 -7px;
  padding-left: 7px;
  padding-right: 7px;
  width: calc(100% + 14px);
}

/* Every row starts in the same column whatever is standing in it — an app's
   mark, the canvas's own, or the cross. The slot is what lines the sentences
   up; the thing inside it is not. Sixteen rather than fifteen because the mark
   is a wide shape and the logos are square, and the column is measured by the
   wider of the two. */
.ac-glyph {
  flex: none;
  width: 16px; height: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--dim);
}
.ac-glyph svg { width: 100%; height: 100%; display: block; overflow: visible; }

/* The mark, at the head of a row.

   The same line ui/loading-mark.js draws while an app is being built, at 16px
   instead of 640, and the shape only: no words rotating beside it, no ground,
   no frame. Those belong to a rectangle waiting on a document. Here the shape
   alone says the thing it has always said, which is that this canvas is making
   something, and it says it of a call that is over as readily as of one still
   running — still in the first case, writing itself in the second.

   The stroke is the one number not carried over. --stroke-w is 26 in a 550-unit
   viewBox, which is a hairline in a 640px frame and 0.75 of a pixel here, and a
   0.75px line beside a 16px logo is a smudge. 38 puts it back on 1.1, which is
   the weight the logos read at. */
.ac-mark {
  width: 16px;
  height: auto;
  fill: none;
  stroke: currentColor;
  stroke-width: 38;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Drawn, and staying drawn. --len is written onto the document element by
   measure() in ui/loading-mark.js off the real path length; until it has been,
   an unset dasharray is a solid line, which is the same picture. */
.ac-mark path {
  stroke-dasharray: var(--len);
  stroke-dashoffset: 0;
}

/* And writing itself, on the row in flight. The animation is the build's, whole
   — same keyframes, same cycle, built once at runtime because the easing needs
   the path length in pixels — started from where the panel it replaced had got
   to. Without that a panel rebuilt on every tool call restarts a 4-second write
   every 3 seconds and never finishes drawing a single letter, which is the
   reason this was not used here the first time. See age() in ui/activity.js. */
.ac-step.running .ac-mark path,
.ac-head.bare .ac-mark path {
  animation: write var(--cycle, 4140ms) infinite;
  animation-delay: calc(var(--age, 0) * -1ms);
}

/* The bare mark, drawn alone — the "@" menu's rule, and its fallback: an app
   with no transparent artwork wears its tile instead, scaled into the slot with
   the corner the tile has. See client/logos/README.md. */
.ac-logo { width: 15px; height: 15px; display: block; object-fit: contain; }
.ac-logo.full { border-radius: 3.5px; object-fit: cover; }

/* One sentence, in two weights. The verb is what happened; what follows is what
   it happened to, and it gives way first when the block is narrow — a row that
   has to lose something should lose the object of the sentence rather than the
   verb of it. */
.ac-line {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ac-detail { color: color-mix(in srgb, var(--dim) 66%, var(--bg)); }

/* What came back, as it came back. The fill and the hairline the canvas already
   uses for a control laid over the work, and the type a diff is set in: this is
   somebody else's API response, and dressing it as prose would be claiming to
   have read it. Capped by runs.js at 900 characters, which is also as much of
   it as the model was shown.

   Three classes, and it is not tidiness. This is a <pre> inside a block, and
   two rules at the top of this file are aimed at exactly that: `.block pre`
   sets the font for every one of them at 16px in the canvas's own face, and
   `.block.agent pre` sets the colour — one and two classes plus an element,
   against the one class this rule started with. Both won. The pane came out set
   in body copy: sixteen-pixel sans, line height 1.75, an API response dressed
   as somebody's writing. Anything given to a <pre> in here has to out-specify
   those two or it will quietly not apply.

   Small on purpose. It is a reference rather than a reading: somebody opens it
   to find the one field they came for and shuts it again, and at 16px four
   lines of JSON are taller than the message they belong to. */
.ac .ac-step .ac-out {
  display: none;
  margin: 3px 0 5px 22px;
  padding: 7px 9px;
  max-height: 168px;
  overflow: auto;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel);
  color: var(--dim);
  /* --code and not --mono. This canvas sets code in Inter like everything else
     and opens the seam to a real monospace exactly where somebody is reading a
     program rather than reading the canvas — see the note by --code at the top.
     A JSON body is that: it is indented, and the indentation is how you find
     the field you came for. */
  font: 400 10px/1.5 var(--code);
  /* min-height comes off `.block pre` too, where it holds an empty line open
     for a caret. Nothing is typed in here. */
  min-height: 0;
  white-space: pre-wrap;
  word-break: break-word;
  tab-size: 2;
  user-select: text;
  pointer-events: auto;
}
.ac .ac-step.on .ac-out { display: block; }

/* --- the change a call made --------------------------------------------------

   Under the row that reports it, in the block the agent is working in. It was
   a card on the Code tab for a release, at the far side of the window from the
   sentence saying the file had been written, and being in two places is the
   whole of what was wrong with it: watching an agent work meant reading its
   account here and its output there, in two rhythms, neither of them complete.

   So the row opens onto its own diff exactly as a call opens onto its own
   result — the same control, the same column, the same fold — and the only
   difference is what is inside. A result is somebody else's JSON and stays shut
   until asked for; a change is the work, and is open until somebody shuts it.

   Set in the panel's cold palette rather than a diff viewer's, because this is
   chrome laid over somebody's message and not an application window. What the
   green and the red are for is which lines, and they are the only two colours
   in it. */
.ac-change {
  display: none;
  margin: 3px 0 6px 22px;
  border: 1px solid var(--line);
  border-radius: 8px;
  overflow: hidden;
  pointer-events: auto;
  user-select: text;
}
.ac .ac-step.on .ac-change { display: block; }
/* The count on the row, so a folded change still says how big it is. */
.ac-n {
  flex: none;
  display: flex;
  gap: 5px;
  font: 500 11px/1 var(--ui);
  font-variant-numeric: tabular-nums;
}
.ac-n b { color: var(--ed-add); font-weight: 500; }
.ac-n s { color: var(--ed-del); text-decoration: none; }

/* The diff itself. One renderer draws this wherever a change is shown — see
   ui/diffview.js — so the rules are about the shape and not about the panel it
   happens to be in.

   Its own scroller in both directions: a wrapped diff line is two lines that
   look like two changes, and a long one must not be allowed to widen whatever
   it is sitting in. The height is a screenful of a message and no more, because
   the thing under it is the answer somebody is waiting for. */
.ac, .ed-diff {
  --ed-add: #1a7f37;
  --ed-del: #cf222e;
  --ed-wash: 12%;
}
:root[data-theme="dark"] .ac,
:root[data-theme="dark"] .ed-diff {
  --ed-add: #4ac26b;
  --ed-del: #f0706d;
  /* The same tint reads heavier on a dark ground: it is a bright colour lifting
     off black rather than a pale one settling onto white. */
  --ed-wash: 9%;
}
.ed-diff {
  max-height: 240px;
  overflow: auto;
  padding-bottom: 4px;
  background: var(--panel);
  color: var(--dim);
  font: 400 10px/1.6 var(--code);
  tab-size: 2;
  overscroll-behavior: contain;
}
/* Where the hunk is, in the notation every diff already uses, and stuck to the
   top of the scroller: a file being written scrolls under it, and the numbers
   are what say where in the file you are looking. */
.ed-at {
  position: sticky;
  top: 0;
  padding: 4px 9px 3px;
  background: var(--panel);
  border-bottom: 1px solid var(--line);
  color: color-mix(in srgb, var(--dim) 66%, var(--bg));
  font: 400 9.5px/1.4 var(--code);
  white-space: pre;
}
.ed-at:not(:first-child) { border-top: 1px solid var(--line); margin-top: 5px; }

.ed-line {
  display: flex;
  white-space: pre;
}
/* Both numbers, each in its own column: the before's and the after's, which is
   the pair that lets somebody say what a deleted line used to be — one column
   of after-numbers cannot. Tabular so they form an edge, dim enough to read
   past, and never selected, so dragging across a diff puts the code on the
   clipboard and not a column of numbers through it. */
.ed-line > i {
  flex: none;
  width: 30px;
  padding-right: 7px;
  color: color-mix(in srgb, var(--dim) 60%, var(--bg));
  font-style: normal;
  font-variant-numeric: tabular-nums;
  text-align: right;
  user-select: none;
}
/* The verdict on the line, in the column a diff keeps it in. A middle dot for
   the lines that only came along to say where this is. */
.ed-line > em {
  flex: none;
  width: 14px;
  color: color-mix(in srgb, var(--dim) 55%, var(--bg));
  font-style: normal;
  text-align: center;
  user-select: none;
}
.ed-line > span { min-width: 0; padding: 0 10px 0 2px; }

.ed-line.add { background: color-mix(in srgb, var(--ed-add) var(--ed-wash), transparent); }
.ed-line.del { background: color-mix(in srgb, var(--ed-del) var(--ed-wash), transparent); }
/* The number and the sign of a changed line carry the colour at full strength,
   on a heavier tint, which is what makes the left edge of a diff scannable
   without reading a word of it. */
.ed-line.add > i, .ed-line.add > em {
  color: var(--ed-add);
  background: color-mix(in srgb, var(--ed-add) calc(var(--ed-wash) * 1.6), transparent);
}
.ed-line.del > i, .ed-line.del > em {
  color: var(--ed-del);
  background: color-mix(in srgb, var(--ed-del) calc(var(--ed-wash) * 1.6), transparent);
}
/* A file that is entirely new has nothing to contrast with, so it is left
   alone: every line green is a green panel, and the count on the row already
   said the file is new. See diffHtml in ui/diffview.js. */
.ed-diff.whole .ed-line.add { background: none; }
.ed-diff.whole .ed-line.add > i, .ed-diff.whole .ed-line.add > em {
  background: none;
  color: color-mix(in srgb, var(--dim) 60%, var(--bg));
}

.ed-none, .ed-cut {
  padding: 6px 9px;
  color: color-mix(in srgb, var(--dim) 66%, var(--bg));
  font: 400 10.5px/1.5 var(--ui);
}

/* A step that did not work is not drawn differently, and the absence of a rule
   here is the design rather than an omission.

   It had two, and they went one at a time. First the colour: --accent for the
   glyph and seven parts of it mixed into the grey for the sentence, which is
   the canvas's warm orange standing in a panel that is cold everywhere else and
   reads as a severity this panel has no scale for. Then the weight that
   replaced it, and then the cross in ui/activity.js, because both were the same
   move — a second and a third way of saying "this failed" stacked on top of the
   sentence that says it in words.

   What is left is one design for every row: the house mark, --dim, 12.5px,
   writing itself while a call is in flight and still when it is over. A run of
   fourteen rows now reads as one list, and what happened in any of them is read
   rather than spotted. */

/* The calls that have scrolled off the top of the list, as a count. Sits in the
   label column rather than the glyph one, so it reads as a note about the list
   and not as another step in it. */
.ac-more {
  padding: 2.5px 0 2.5px 22px;
  color: color-mix(in srgb, var(--dim) 66%, var(--bg));
}

@media (prefers-reduced-motion: reduce) {
  .ac .live { animation: none; background: none; color: var(--dim); }
  .ac-step.running .ac-mark path, .ac-head.bare .ac-mark path { animation: none; }
  .ac-caret { transition: none; }
}

/* ---- an agent asking you something ---------------------------------------
   In the block it is working in, under whatever it has done so far. See the
   note at the top of ui/ask.js for what this replaced and why: a question used
   to be a second block on the plane, placed before anybody knew how tall it
   would be, answered by finding it, pressing reply, typing, and pressing Enter.

   It is the one thing in this panel that is not cold. Everything else here is
   chrome laid over the work and steps back from the canvas's own colour; a
   question is the agent talking, in words somebody wrote, and it is read rather
   than operated. So the question takes --ink like any other writing in the
   block, and only the line above it — who is asking, and what kind of asking —
   stays in the grey the steps are in.

   Nothing is modal and nothing is a dialog, which is the rule this whole
   mechanism is built on: three agents working means three of these, in three
   blocks, all answerable in any order while everything else keeps running. */
.ak {
  pointer-events: auto;
  padding: 8px 0 4px;
  max-width: 100%;
}

/* A question you press gets a box; a question you type does not.
 *
 * The open question already carries one — .ak-write below, a hairline round the
 * field that says where the typing goes. The pressed one had nothing: lead,
 * question and the buttons loose on the block, which is fine while it is the
 * only thing there and stops being fine the moment there is a run of tool steps
 * above it, because then the question is just more grey text in a column of
 * grey text and the buttons are the only sign that this one is waiting on a
 * person.
 *
 * So it takes the same box the field takes — the same hairline, the same 9px,
 * the same width to stop at — and the two forms of being asked something look
 * like two forms of one thing. Boxing both would be a box inside a box, which
 * is why this is on the picks and not on .ak. */
.ak:has(.ak-picks) {
  /* The 8px and 4px the unboxed version padded with are now the box's own air,
     so the gap to the steps above and the reactions below is put back outside
     it. Both neighbours carry some of their own; these are the halves that make
     the two gaps even. See the note on .ac's padding. */
  margin: 3px 0 4px;
  padding: 9px 11px 10px;
  max-width: 460px;
  border: 1px solid var(--line);
  border-radius: 9px;
  background: var(--bg);
}

.ak-lead {
  font: 500 12.5px/1.45 var(--ui);
  color: var(--dim);
  margin-bottom: 3px;
}

.ak-q {
  font: 400 14px/1.5 var(--ui);
  color: var(--ink);
  margin-bottom: 8px;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/* The answers, where they are a known short list. A row of them rather than a
   column: two or three short labels read as a set of alternatives side by side
   and as a menu stacked, and this is a choice, not a menu.

   Two of them, for a permission. There was a third — "Always allow", which
   wrote a standing rule into somebody's own settings — and it is gone; see
   optionsFor in ../core/native.js for why. What it leaves behind is worth more
   than the button was: a pair, which can be drawn as a pair. */
.ak-picks {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 2px;
}
/* The permission pair, which is not a list of alternatives and is not drawn as
   one. It is an action row: the two answers sit at the end of the line, refusal
   then affirmative, where a hand that has finished reading the question already
   is. Genuine alternatives keep the left margin, because those ARE a list and a
   list is read down the same edge as the words above it. */
.ak-picks.pair {
  justify-content: flex-end;
  margin-top: 10px;
}

/* Sized off the reply pill under the same message, not off the confirm dialog.
   The dialog's buttons are 34px because they are the only thing in a card of
   their own; these sit two rows above a 24px reply, and at 32px they stood over
   it like a different class of control had been dropped into the thread. Same
   height, same corner, same 12px type as its neighbours: it is still a button
   rather than a pill — solid fill, a real target either side of centre — but it
   belongs to the row of chrome it lives in.

   The label stays in --ink at the weight of a control. That was the fix for an
   earlier pass where it was grey 12.5px type behind a hairline, which made the
   refusal in particular read as a caption somebody had bordered by accident,
   and shrinking the box does not bring that back. */
.ak-pick {
  height: 24px;
  padding: 0 10px;
  min-width: 64px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid transparent;
  border-radius: 6px;
  background: var(--panel);
  font: 500 12px/1 var(--ui);
  color: var(--ink);
  white-space: nowrap;
  cursor: pointer;
  transition: background .12s ease, border-color .12s ease, color .12s ease,
    transform .08s ease;
}
.ak-pick:hover {
  background: color-mix(in srgb, var(--ink) 9%, var(--panel));
}
/* A press that moves. The whole of the feedback: it is a button on a plane that
   itself pans and zooms, so anything larger reads as the canvas moving. */
.ak-pick:active { transform: translateY(1px); }

/* The affirmative — the one that lets it carry on — is the only solid thing
   here. Solid rather than coloured: a green Allow once and a red Deny is a pair
   of traffic lights telling somebody which answer is correct, and which answer
   is correct is the whole of what they are being asked. Weight says "this is
   the affirmative"; it does not say "press this one".

   Its refusal is a filled chip rather than an outline, which is the change that
   made the pair read as a pair. Two hairline boxes on a block that already has
   an edge is three rectangles inside each other; a soft fill beside a solid one
   is one control and its alternative, told apart by weight alone.

   The corner stays square-ish in both, against the reference this was drawn
   from, which rounds them to capsules. A capsule is a shape this canvas does not
   use anywhere else, and a permission is the last place to introduce one: it
   takes the 6px of the pills beside it. See the note above .ak-pick. */
.ak-pick.first {
  border-color: var(--ink);
  background: var(--ink);
  color: var(--bg);
}
.ak-pick.first:hover {
  background: color-mix(in srgb, var(--ink) 88%, var(--bg));
  border-color: color-mix(in srgb, var(--ink) 88%, var(--bg));
  color: var(--bg);
}
/* Drawn on the outside in both cases, so the filled one keeps its own edge
   between the ring and the fill rather than wearing the ring as a border. */
.ak-pick:focus-visible {
  outline: 2px solid var(--sel);
  outline-offset: 2px;
}

/* And the open question: one line, taking Enter. The field is the control and
   the button beside it is a second way to press the same key, for somebody who
   reached for the mouse. */
.ak-write {
  display: flex;
  align-items: center;
  gap: 6px;
  max-width: 460px;
  padding: 3px 3px 3px 10px;
  border: 1px solid var(--line);
  border-radius: 9px;
  background: var(--bg);
  transition: border-color .14s ease, box-shadow .14s ease;
}
.ak-write:focus-within {
  border-color: color-mix(in srgb, var(--sel) 55%, var(--line));
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--sel) 14%, transparent);
}

.ak-in {
  flex: 1 1 auto;
  min-width: 0;
  padding: 4px 0;
  border: 0;
  background: none;
  font: 400 13.5px/1.5 var(--ui);
  color: var(--ink);
  outline: none;
}
.ak-in::placeholder { color: color-mix(in srgb, var(--dim) 66%, var(--bg)); }

.ak-send {
  flex: none;
  width: 26px; height: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 6px;
  background: var(--panel);
  color: var(--dim);
  cursor: pointer;
  transition: background .12s ease, color .12s ease;
}
.ak-send svg { width: 14px; height: 14px; display: block; }
.ak-send:hover { background: var(--ink); color: var(--bg); }

@media (prefers-reduced-motion: reduce) {
  .ak-pick, .ak-write, .ak-send { transition: none; }
  .ak-pick:active { transform: none; }
}

/* The panel stands in FOR the answer, so it stands in its PLACE. Underneath it
   is an empty <pre> that is a line tall whether or not there is anything in it,
   and while nothing has been written that line is not the answer's space, it is
   a hole between the waiting and the reactions — which is what put the reply
   button a clear inch below and made the row look bottom-heavy.
   It comes back with the first character, because the panel goes at the same
   moment: both are `:has(> .ac.instead)`, and wrote() takes the panel away.

   `.instead` and not `.ac`, and the difference is the whole rule. A panel is
   drawn in two kinds of block: the empty reply an errand opened for its answer,
   which is this, and the person's own message in a build — where the panel sits
   under a brief somebody wrote and there is nothing to stand in for. Keyed on
   `.ac` alone, the second one hid the message it was working on. See paint() in
   ui/activity.js, which is where the two are told apart. */
.block:has(> .ac.instead) > .block-body,
.block:has(> .ac.instead) > pre { display: none; }

/* ---- tables --------------------------------------------------------------
   A grid, drawn as one object rather than as characters. Everything else in a
   block is a <pre> under a transparent <textarea>; this is real elements, for
   the reason set out at the top of canvas/table.js — a column lines up because
   its width was measured, not because the source was padded.

   The design is the least table that is still a table: one hairline, a header
   that is only a weight and a fill, and nothing else. No zebra striping, no
   outer radius, no shadow. The canvas already has an idea of what a line is
   (--line) and what a raised surface is (--panel), and a table that invented
   its own would read as something pasted in from another application.

   The controls are the whole interaction and none of them are on screen until
   the pointer is: a "+" strip past the right edge and one under the bottom edge
   to add, a switch in the left margin for whether the first row is the heading,
   and a line on each column's right edge to drag its width. Every one of them
   stands 4px off the table, so the frame reads as one thing rather than as four
   controls that each found their own distance. Nothing is on screen at rest, so
   a table sitting on the canvas is a table and not a table with a toolbar.

   Taking a row or a column out is not a control at all. You pick its cells and
   press Backspace, the way you would in a spreadsheet — see the note above
   eraseKind in canvas/table.js. What was here before was an "×" at the end of
   every heading and another in every row's margin, which meant a table wore a
   remove button per row and per column, each of them one stray click from
   taking a column of somebody's work away. */

/* A wide table is wider than the 38rem a paragraph is capped at, and capping it
   would either scroll the columns or squeeze them. Neither is what somebody
   with eight columns wants, so the block gives way instead.

   The chat form is spelled out because a message carries its own max-width —
   38rem plus the avatar's gutter — and that rule is the more specific of the
   two. A table in a message gives way exactly as a table in a note does. */
.block.has-table,
.block.chat.has-table:not(.cmd) { max-width: none; }

.tablewrap {
  position: relative;
  width: max-content;
  max-width: 56rem;
  /* Room under the grid for the "+" that adds a row: the 4px every control
     stands off the table by, and then the 16px strip itself. Padding rather
     than a margin, so the strip is positioned inside this box and the
     arithmetic below is the box's own. Without the room that strip lands on the
     reaction bar, which sits 3px under everything in a message.
     It is also half of a pair: the header above the grid opens up by the same
     amount, so the table sits in the block with equal air on both sides rather
     than pinned to the name and floating over the reactions. */
  padding-bottom: 20px;
}
/* The other half. A line of text needs almost nothing between it and the name
   above it; a grid is an object, and an object wants the same margin underneath
   it that it has on top. */
.block.has-table .block-head { margin-bottom: 20px; }

.tbl {
  border-collapse: collapse;
  table-layout: auto;
  font: 400 13.5px/1.5 var(--ui);
  color: var(--ink);
  background: var(--bg);
}
/* Once a column has been dragged, every column is a number and the browser has
   nothing left to work out: fixed layout, and the widths on the <col> elements
   are the widths. The cap on the wrap goes with it, because 56rem is a guess
   about what is readable and a width somebody dragged is not a guess. */
.tbl.sized { table-layout: fixed; }
.tablewrap.sized { max-width: none; }
/* The bounds that were keeping an auto table sane are the same bounds that
   would refuse the number just dragged. */
.tbl.sized .tbl-cell { min-width: 0; max-width: none; }
.tbl-td {
  position: relative;
  border: 1px solid var(--line);
  padding: 0;
  vertical-align: top;
  text-align: left;
  font-weight: inherit;
}
/* The header is a weight and a fill and no more. A darker rule under it, or a
   colour, would make the first row look like a different object. And with the
   heading switched off there is no <th>, so the first row is drawn as what it
   now is: a row.

   The fill is the cold grey, which revises the rule stated at the top of this
   file. That rule says what the canvas SAYS is warm and what you use to handle
   it is cold, and a column heading was filed under what it says. It is not: a
   heading is not data, it is the label on the data, and it does the same job for
   a grid that a section's name does for a region — which has been cold since it
   was drawn. Warm, it read as a first row that happened to be shaded; cold, it
   reads as the thing the rows are underneath. */
.tbl th.tbl-td {
  background: var(--panel);
  font-weight: 600;
  color: var(--ink);
}
/* The cell itself, and every cell is the same. The first row used to keep 26px
   of its right side clear for the "×" that removed the column, which meant a
   heading sat differently in its box than the words under it; with the "×" gone
   the reservation goes too, and one padding holds for the whole grid.

   Sized here rather than on the <td> because this is the editable box: the
   caret has to sit inside the padding, not outside it. */
.tbl-cell {
  min-width: 5.5rem;
  max-width: 16rem;
  padding: 6px 10px;
  outline: none;
  overflow-wrap: break-word;
  word-break: break-word;
  /* an empty cell still has to be a cell you can aim at */
  min-height: 1.5em;
}
.tbl-cell:focus {
  /* Inside the cell, so the row does not shift by a pixel when the caret lands.
     The canvas's selection blue, because this is the same act as selecting. */
  box-shadow: inset 0 0 0 2px var(--sel);
}
.tablewrap.locked .tbl-cell { cursor: default; }

/* ---- the controls ----
   Everything below appears on hover and occupies no space when it does: the
   grid must not move a pixel because a pointer crossed it. */
.tbl-add, .tbl-head {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 5px;
  background: var(--chrome);
  color: var(--dim);
  opacity: 0;
  cursor: pointer;
  transition: opacity .12s ease, color .12s ease, border-color .12s ease;
}
.tbl-add svg, .tbl-head svg { width: 12px; height: 12px; }
.tbl-add:hover, .tbl-head:hover { color: var(--ink); border-color: var(--dim); }

/* Each one is the size of the edge it adds to, so the target is the whole side
   rather than a dot somebody has to find, and the two are the same strip turned
   ninety degrees: 16px thick, 4px off the table, running the length of the side
   it belongs to. They used to differ — the row strip was shorter and sat
   flush — and two controls that do the same thing on two sides of one object
   have to look like it. Each stops at the table's corner rather than running
   the full length of this box, or they would meet there and neither would be
   pressable. */
.tbl-add-col { top: 0; bottom: 20px; right: -20px; width: 16px; }
.tbl-add-row { bottom: 0; left: 0; width: 100%; height: 16px; }
.tablewrap:hover .tbl-add,
.tablewrap:hover .tbl-head { opacity: .55; }
.tablewrap .tbl-add:hover,
.tablewrap .tbl-head:hover { opacity: 1; }

/* The heading switch, in the left margin beside the first row: level with the
   row it is about, and 4px off the table like everything else on this frame.
   The margin is open plane on a canvas and it is empty now that rows are not
   removed by a button, so this is the whole of what lives out there. It is
   drawn inside the first cell so that "beside the first row" stays true at any
   row height, without anything having to measure one. -22px rather than the
   -21px that would put a 17px button 4px out: it hangs off the cell, and a
   collapsed border leaves the cell's edge a pixel inside the table's. */
.tbl-head {
  left: -22px;
  top: 50%;
  transform: translateY(-50%);
  width: 17px; height: 17px;
  border-color: transparent;
  background: none;
}
.tbl-head:hover { background: var(--chrome); }

/* A table nobody here may change offers nothing to change it with. */
.tablewrap.locked .tbl-add,
.tablewrap.locked .tbl-head,
.tablewrap.locked .tbl-edge { display: none; }

/* ---- picked cells ----
   A wash rather than a ring: an outline around each cell of a block would draw
   a second grid inside the grid, and what is picked here is one region, not
   nine boxes. The same blue a focused cell wears, because it is the same act.
   The grid itself takes the focus while a block is picked, and a focus ring
   around the whole table on top of the wash would be saying it twice. */
.tbl-cell.picked { background: color-mix(in srgb, var(--sel) 16%, transparent); }
.tablewrap:focus { outline: none; }
/* Dragging across cells is picking cells, not selecting the words in them. */
.tablewrap.picking .tbl-cell { user-select: none; cursor: default; }

/* ---- the column edges ----
   One per cell, lying on its right-hand border and a little wider than it, so
   the line is easy to catch without the cell losing the pixels: this sits over
   the border rather than beside it.

   Per cell rather than per column, because the thing being grabbed is the
   boundary between two columns and that boundary runs the height of the table.
   A handle on the heading alone would mean a column you can only resize by
   going up to the top of it first, which is the sort of rule you have to learn
   rather than see. So each row carries its own piece of the line, the pieces
   tile because the rows do, and hovering any of them lights all of them — see
   hot() in canvas/table.js.

   Nothing is drawn until the pointer is on it, and then it is the selection
   blue, the same colour a focused cell wears. `.on` is that colour held for the
   length of a drag. */
.tbl-edge {
  position: absolute;
  /* A pixel past the cell at each end, so the pieces overlap the rule between
     two rows instead of stopping short of it. Without this the line is drawn
     with a gap at every row boundary and again at the top and bottom of the
     table: a dashed line, where what is being shown is one edge. */
  top: -1px;
  bottom: -1px;
  right: -3px;
  width: 7px;
  cursor: col-resize;
  /* above the cell, so the pointer meets the edge and not the text under it */
  z-index: 1;
}
.tbl-edge::after {
  content: "";
  position: absolute;
  inset: 0 3px;
  background: transparent;
  transition: background .12s ease;
}
.tbl-edge.hot::after, .tbl-edge.on::after { background: var(--sel); }
/* The whole page keeps the resize cursor for the length of the drag, or the
   pointer flickers back to a caret every time it leaves the 7px it started in. */
body.sizing, body.sizing * { cursor: col-resize; }
/* And nothing is selected by a drag that is about a column, not about words. */
body.sizing .tbl-cell { user-select: none; }

/* Every icon's stroke width used to be restated here, as five rules that said
 * the same numbers the markup already said. They were there because WebKit
 * multiplies an SVG's stroke-width PRESENTATION ATTRIBUTE by the CSS `zoom` on
 * an ancestor, and the plane was zoomed that way: at 150% the 1.7 in
 * `stroke-width="1.7"` was used as about 6.8 inside a viewBox 24 units across,
 * which is thick enough to swallow the drawing. Every icon under a message went
 * to a solid blob with pinholes where the gaps had been.
 *
 * The plane is scaled by a transform now (see core/viewport.js) and a transform
 * scales the finished drawing rather than the numbers that produced it, so
 * there is nothing left to restate. Kept as a note because the bug is
 * invisible until somebody zooms in: if the plane ever goes back to CSS `zoom`,
 * the icons go back to blobs, and this is where that was written down. */

.block-reacts {
  position: relative;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px;
  margin-top: 3px;
}

/* The three buttons and reply are one thing and wrap as one.
 *
 * The row wraps because a message can collect more reactions than fit across
 * it, and that is right for the pills. It is wrong here: reply sits beside the
 * box because it is the fourth answer to the same question, and a reply button
 * on its own line underneath reads as a separate control that happens to be
 * nearby. At high zoom the row was wide enough for the break to actually
 * happen. Grouping them means the pair moves down together or not at all. */
.react-pair {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  flex: none;
}
.react-pill, .react-tools, .react-reply {
  height: 24px;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--bg);
  color: var(--dim);
  font: 400 12px/1 var(--ui);
  cursor: pointer;
}
.react-pill { padding: 0 8px; color: var(--ink); }
.react-pill.mine { border-color: color-mix(in srgb, var(--sel) 55%, var(--line)); background: color-mix(in srgb, var(--sel) 10%, var(--bg)); }
.react-pill:hover { border-color: var(--dim); }
.react-reply { padding: 0 9px; }
.react-reply:hover { color: var(--ink); border-color: var(--dim); }

/* The smile, the "@" and the paperclip in one box: three answers to a single
   question — how do you want to answer this — rather than three unrelated
   buttons that happen to sit in a row. Which one is live is said by moving one
   panel between them, not by lighting a border in place: one thing that moves
   reads as one control, three borders taking turns read as three. */
.react-tools {
  position: relative;
  display: inline-flex;
  align-items: center;
  padding: 2px;
  border-radius: 7px;
}
.rt-glide {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 26px;
  height: calc(100% - 4px);
  border-radius: 5px;
  /* the cold grey, borrowed from the source card: a panel on a control is not
     the canvas's own warm ground, and stepping off it is what makes the panel
     an object rather than a stain */
  background: var(--panel);
  opacity: 0;
  pointer-events: none;
  transition: transform .24s var(--ease), opacity .16s ease;
}
.react-icon {
  position: relative; /* over the panel, never under it */
  width: 26px;
  /* the box is border-box at 24px, so this is the 18px left between the border
     and the padding — exactly what the panel behind it is sized to */
  height: 100%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 5px;
  background: none;
  color: var(--dim);
  cursor: pointer;
}
.react-icon svg { width: 14px; height: 14px; }
.react-icon:hover, .react-icon:focus-visible { color: var(--ink); }
/* A message that is not yours to write in keeps the smile and loses the other
   two — reacting to what somebody said is not rewriting it. Said out loud
   because the display above would otherwise beat the browser's own [hidden]
   rule and leave a button that is gone still standing there taking clicks. */
.react-icon[hidden] { display: none; }

/* Where the panel is, said by the group rather than by a class JS keeps in
   step: the pointer is already the state, so nothing has to mirror it. Written
   in source order so hovering while the picker is open wins over the picker. */
.react-tools.open .rt-glide,
.react-tools:has(.react-add:hover) .rt-glide,
.react-tools:has(.react-add:focus-visible) .rt-glide { opacity: 1; transform: translateX(0); }
.react-tools:has(.react-mention:hover) .rt-glide,
.react-tools:has(.react-mention:focus-visible) .rt-glide { opacity: 1; transform: translateX(26px); }
.react-tools:has(.react-attach:hover) .rt-glide,
.react-tools:has(.react-attach:focus-visible) .rt-glide { opacity: 1; transform: translateX(52px); }
@media (prefers-reduced-motion: reduce) { .rt-glide { transition: opacity .16s ease; } }

.react-picker {
  position: absolute;
  left: 0;
  top: calc(100% + 5px);
  z-index: 20;
  width: 272px;
  padding: 6px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 8px;
  box-shadow: var(--chrome-shadow);
}
/* opening animates, so the panel reads as coming from the button rather than
   appearing on top of it. display cannot be transitioned, so the panel is laid
   out from the moment it is inserted and only its opacity and transform move —
   the class is added on the next frame so there is a state to animate from. */
.react-picker {
  opacity: 0;
  transform: translateY(-6px) scale(.97);
  transform-origin: top left;
  pointer-events: none;
  transition: opacity .13s ease, transform .19s var(--ease);
}
.react-picker.on {
  opacity: 1;
  transform: none;
  pointer-events: auto;
}

.rp-search {
  width: 100%;
  height: 28px;
  padding: 0 8px;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--bg);
  color: var(--chrome-ink);
  font: 400 13px/1 var(--ui);
  outline: none;
}
.rp-search:focus { border-color: var(--dim); }

/* the scroll lives on the grid, so the search field stays put while the
   emoji move under it */
.rp-grid {
  display: grid;
  grid-template-columns: repeat(8, 1fr);
  gap: 1px;
  max-height: 196px;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.rp-empty {
  display: none;
  padding: 10px 2px 12px;
  color: var(--chrome-dim);
  font: 400 12px/1 var(--ui);
  text-align: center;
}

.react-emoji {
  width: 30px; height: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  border-radius: 5px;
  font-size: 17px;
  line-height: 1;
  cursor: pointer;
}
.react-emoji:hover { background: var(--chrome-hover); }
/* the arrow-key cursor: a ring rather than a fill, so it stays legible over an
   emoji's own colours the way a hover tint does not */
.react-emoji.on {
  background: var(--chrome-hover);
  box-shadow: inset 0 0 0 1.5px var(--sel);
}

/* markdown: the render is the render — marks are hidden once the text is just
   being read, so **bold** shows as bold and nothing else.
   They come back while a block is actually being written in: the textarea
   sitting on top is transparent except for its caret, and the caret can only
   land in the right place if the painted text still measures the same as the
   source.

   .writing, and not only :focus-within, is what turns them back on. The class
   is set on pointerdown, one beat before the browser places the caret from
   that same press; :focus-within arrives one beat after it, which is too late
   and is what used to leave the caret sitting short of the letter that was
   clicked. See the pointerdown listener in canvas/blocks.js. */
.m  { color: var(--faint); font-weight: 400; font-style: normal; display: none; }
.block:focus-within .m,
.block.writing .m { display: inline; }

/* A list marker is content rather than syntax, so it is neither dimmed nor
   hidden: "1." and "-" are the list itself, in the ink of the line they
   belong to. They also never move in or out of the flow, so a click into a
   list lands where it was aimed no matter what else is on the line. */
.li { color: var(--ink); }
/* the source character (-, *, or +) stays in the flow, invisible, so it
   still measures exactly what the textarea underneath measures it at; only
   the painted bullet is new, drawn on top with zero width of its own.
   Centred on the character it stands for rather than hung off its left edge:
   '•' is wider than '-' in Inter, and a left-aligned one leaned into the
   space after it instead of sitting over the mark it replaces. */
.li.bullet { position: relative; visibility: hidden; }
.li.bullet::before {
  content: '•';
  visibility: visible;
  position: absolute;
  left: 50%; top: 0;
  transform: translateX(-50%);
  color: var(--ink);
}
.h, .b { font-weight: 700; }
.i  { font-style: italic; }
.s  { text-decoration: line-through; color: var(--dim); }
.q  { color: var(--dim); font-style: italic; }
.c  { background: var(--panel); border-radius: 3px; }
.cb { color: var(--dim); }
.u  { color: var(--dim); text-decoration: underline; text-underline-offset: 3px; }
.hr { color: var(--faint); }
/* a typed command reads as a chip, Notion-style. Background and colour only —
   this <pre> is measured against the transparent textarea on top of it, so
   padding or weight here would walk the caret off the text. */
.cmd-chip {
  background: var(--panel);
  border-radius: 4px;
  color: var(--dim);
}
.cmd-ghost { color: var(--faint); }
/* the line an empty sheet shows in place of writing.

   Colour and nothing else, which is the rule this <pre> lives under: it is
   measured against a transparent textarea holding the same characters, so
   anything here with a width of its own moves every glyph after it and walks
   the native caret off the text. The "#" wore a box for a while and was the one
   exemption; it wears quotes now, which weigh nothing and are what somebody
   would type anyway. See paperHint in canvas/blocks.js.

   Then the whole line stepped back, in one number rather than in new colours.
   --faint is already the quietest text this canvas has, so what is wanted is
   not a different grey but less of this one — and opacity is the one
   declaration that means the same thing in both themes, where lighter is toward
   white on one and toward black on the other. */
.paper-hint {
  color: var(--faint);
  opacity: .5;
}

/* @mentions: tinted by the mentioned agent's own hue so it reads as the same
   identity as their cursor and tag. "@you" is the one exception: it carries
   no hue of its own, so it takes the app's accent instead, the way Slack
   lifts a self-mention out of the crowd.
   No padding, no margin, no weight change — same trap .c (inline code)
   already avoids: this pre sits under a REAL textarea whose invisible native
   caret follows the plain, unstyled text. Anything here that changes a
   character's advance width makes the visible pill wider than the caret
   thinks it is, and everything after it on the line drifts out from under
   its own text. A background color never moves a glyph; padding and bold
   both do. */
.mention {
  border-radius: 4px;
  background: color-mix(in srgb, var(--agent, hsl(var(--hue, 233) 70% 50%)) 22%, transparent);
  /* The pill's own colour, pulled toward the ink so the text stays readable
     on the tint behind it. Two of the eight palette colours are pale enough
     that using them raw here would be light blue on light blue. */
  color: color-mix(in srgb, var(--agent, hsl(var(--hue, 233) 70% 50%)) 58%, #000);
}
:root[data-theme="dark"] .mention {
  background: color-mix(in srgb, var(--agent, hsl(var(--hue, 233) 70% 55%)) 24%, transparent);
  color: color-mix(in srgb, var(--agent, hsl(var(--hue, 233) 70% 55%)) 76%, #fff);
}
.mention.you {
  background: color-mix(in srgb, var(--accent) 18%, transparent);
  color: color-mix(in srgb, var(--accent) 75%, var(--ink));
}

/* An "@app" is a reach, not a body. It carries the selection blue rather than a
   hue of its own: every other coloured pill on this canvas is somebody, and a
   Gmail tag tinted like a person would be one more colleague in the room. */
.mention.app {
  background: color-mix(in srgb, var(--sel) 18%, transparent);
  color: color-mix(in srgb, var(--sel) 80%, var(--ink));
}

/* a #reference points at a place, not a body — so it stays neutral grey,
   with no hue to mistake for someone's colour */
.section-pill {
  border-radius: 4px;
  background: color-mix(in srgb, var(--dim) 20%, transparent);
  color: var(--dim);
}

/* ---- mention autocomplete ------------------------------------------------
   Lives inside the block itself — a child of the thing it is picking for, so
   it inherits the plane's zoom and never needs its own coordinate math. */

.mention-box {
  position: absolute;
  left: 0;
  top: calc(100% + 6px);
  /* above the reaction row it now sits below, and above neighbouring blocks */
  z-index: 30;
  min-width: 150px;
  max-width: 220px;
  padding: 4px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 7px;
  box-shadow: var(--chrome-shadow);
  display: none;
  font: 400 16px/1 var(--mono);
}
.mention-box.on { display: block; }

.mention-opt {
  display: flex;
  align-items: center;
  height: 30px;
  padding: 0 8px;
  border-radius: 5px;
  color: var(--chrome-ink);
  font-size: 13px;
  cursor: pointer;
}
.mention-opt.on { background: var(--chrome-hover); }

/* An emoji in the same list, found by name and written as itself. The character
   leads because it is the thing being picked; the name behind it is only how
   you got there, so it is set in the quieter ink until the row is the one. */
.mention-opt.is-emoji { gap: 7px; }
/* fixed, like .mo-slot below and for the same reason: a flag is wider than a
   dot, and every name in the menu still starts at the same x */
.mo-char {
  flex: none;
  width: 20px;
  font-size: 16px;
  line-height: 1;
  text-align: center;
}
/* names run long — ":blond_haired_person:" — and the menu has a width it keeps.
   min-width:0 because a flex item will not shrink below its text without it,
   which is the difference between an ellipsis and a menu that grows a scrollbar. */
.mo-code {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--chrome-dim);
}
.mention-opt.is-emoji.on .mo-code { color: var(--chrome-ink); }

/* An app in the same list, wearing its own artwork instead of a colour. The
   logo is removed rather than hidden when it has not landed, so nothing holds a
   request that already failed. See client/logos/README.md. */
.mention-opt.is-app { gap: 7px; }
/* The mark itself and nothing around it. No tile, no rounding, no ground: the
   panel gives artwork a tile because it is a settings row where the icon is the
   subject, and this is a menu row where the name is. A frame here would be a
   second edge inside a list that is already a box.

   The slot is a span rather than the image, so an app with no mark file keeps
   its 16px and every name in the menu still starts at the same x. */
.mo-slot {
  width: 16px; height: 16px;
  flex: none;
  display: block;
}
.mo-logo {
  width: 100%; height: 100%;
  display: block;
  /* the marks are not all square — a document is taller than it is wide — so
     they are fitted rather than filled, and never cropped to make a tile */
  object-fit: contain;
}
/* An app somebody is reaching for and has not connected. It is an offer, not a
   tag, and it says so twice: the mark is held back so the connected rows above
   read as the live ones, and the word on the right says what picking it does.
   The row is not dimmed as a whole — the name is what is being searched for,
   and a search result you can barely read is a strange thing to return. */
.mention-opt.is-app.off .mo-logo { opacity: 0.45; }
.mention-opt.is-app.off.on .mo-logo { opacity: 1; }

/* The panel's own artwork, standing in for an app with no bare mark. This one
   is a full-bleed colour block by nature, so it fills its square and takes the
   corner the panel gives it, scaled down. Nothing is added around it: the
   ground here belongs to the logo rather than to the row. */
.mo-logo.full {
  border-radius: 4px;
  object-fit: cover;
}

/* The "/" menu is the same list wearing more: a glyph, the mode, its keystroke.
   Small, and smaller than it was. A row here is one word and one chord, so
   everything in it is sized to be recognised rather than read: the whole list
   is a thing you glance at while your hands are already on the keys. Nothing
   was using the other hundred pixels of width once the sentences went. */
.mention-box.cmd-menu { min-width: 174px; max-width: 210px; }
.cmd-opt { gap: 8px; height: 28px; font-size: 12px; }
.cmd-icon {
  width: 13px; height: 13px;
  flex: none;
  color: var(--chrome-dim);
}
.cmd-opt.on .cmd-icon { color: var(--chrome-ink); }
.cmd-name { color: var(--chrome-ink); }
.cmd-empty { color: var(--chrome-dim); cursor: default; }
/* The keycap is the zoom menu's, defined once down beside it. Only two things
   are said here: where in the row it sits, and that this menu wears a smaller
   pair than the zoom menu does — a cap the size of that one would be the
   tallest thing in a 28px row and would set the row's height itself. */
.cmd-keys { margin-left: auto; padding-left: 10px; }
.mention-box.cmd-menu kbd {
  min-width: 17px; height: 17px;
  padding: 0 3px;
  border-radius: 2.5px;
  font-size: 10px;
}
/* what an app row says on the right instead: the one word "connect", for an app
   that is being offered rather than tagged */
.cmd-hint {
  margin-left: auto;
  padding-left: 12px;
  color: var(--chrome-dim);
  font-size: 12px;
  white-space: nowrap;
}

body.draw #plane, body.erase #plane { cursor: crosshair; touch-action: none; }
/* drag mode: the canvas itself is the thing you are holding */
body.drag #plane { cursor: grab; touch-action: none; }
body.drag #plane:active { cursor: grabbing; }
body.draw .block, body.erase .block { pointer-events: none; }
/* drag mode is explicitly "no writing": the block still takes the click, so it
   can be selected and moved, but the textarea under the pointer does not, so
   no caret ever lands in it */
body.drag .block textarea { pointer-events: none; }
/* A running app answers the pointer itself, which is the whole point of it and
   also a hole in every mode where the pointer is not for pointing: the frame
   would swallow a pan that started over it, and eat a pen stroke drawn across
   it. In those modes it goes inert like everything else on the plane. */
body.drag .block .frame { pointer-events: none; }
/* The same rule, for the same reason, for a table: in drag mode a press on a
   cell is a press on the block, so the whole table moves rather than taking a
   caret. The grip and the "+" go inert with it — adding a column is writing. */
body.drag .tablewrap { pointer-events: none; }

/* ---- mode label --------------------------------------------------------
   The command line's one piece of feedback: which mode you are in, small and
   faint in the corner you are not looking at. Top-left now that the profile
   control has taken the bottom-left corner it used to sit in. */

/* The command line's one piece of feedback, now sitting inside #docbar beside
   the canvas's name — see ui/docbar.js, which moves the node there. No position
   of its own any more: it is laid out by the bar that holds it. */
/* The mode readout, as a chip with its key on it.

   Two tokens rather than literal greys, because this pair is a shape that will
   be wanted again: a soft field with a key sitting in it. Keeping the
   relationship in tokens is what stops the second place it appears inventing
   its own pair of nearly-the-same greys.

   Both are grey, and the cap is grey by being DARKER than the field rather than
   lighter. A cap lifted towards white is the usual way to draw a key and it was
   the first version of this; it read as a white pill stuck on a grey one,
   because the strip behind all of it is already pale. Going the other way makes
   the cap sit in the field instead of on it, and means the chip is one material
   throughout rather than two.

   Which makes them the fill and the line, exactly as the rest of the app uses
   them: a key is a small surface with an edge, and it had no business carrying
   two greys of its own. The names stay because "cap" and "field" is what this
   readout is made of, and a rule about a key should say so. */
:root, :root[data-theme="dark"] {
  --key-fill: var(--panel);
  --key-cap: var(--line);
}

#mode {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 3px 3px 8px;
  border-radius: 7px;
  background: var(--key-fill);
  color: var(--dim);
  font: 500 11px/1 var(--ui);
  pointer-events: none;
  user-select: none;
}

/* An empty chip is a grey blob with nothing in it, which is what this was for
   the one frame before a mode is set. It was invisible while this element was
   bare text and is not any more. */
#mode:empty { display: none; }

/* ---- and quieter in the light ----
   The two chips in the corner are the same grey in both themes, and the same
   grey does not carry the same weight in both. On the dark strip they are a
   step up out of it, which is how anything is drawn there. On the light one
   they are a step DOWN into a strip that is already nearly white, so the pair
   read as the most solid objects in the corner — heavier than the tab beside
   them, competing with the canvas for the same glance, while saying two things
   nobody needs to be told twice.

   So they are held back, in light only. The whole chip rather than its field:
   the cap inside the readout is part of the same object, and fading the ground
   out from under a cap that stayed put would leave the cap floating as the one
   solid thing left. Everything about them keeps its relationship; there is
   simply less of all of it.

   Written against :not([data-theme="dark"]) rather than ="light" so that the
   frame before ui/theme.js has stamped anything is a light frame, which is what
   the rest of this stylesheet already assumes bare :root means.

   The task chip comes back under the pointer, because it is a button and a
   button that stayed faint while you were on it would read as one that had
   stopped working. The readout does not: it is not a control, it takes no
   pointer at all, and there is nothing for it to come back for. */
:root:not([data-theme="dark"]) #mode,
:root:not([data-theme="dark"]) #taskBtn { opacity: .72; }
:root:not([data-theme="dark"]) #taskBtn:hover,
:root:not([data-theme="dark"]) #taskBtn.on { opacity: 1; }

/* The cap. No hairline: it was there to hold a near-white key off a grey field,
   and a darker key needs nothing to separate it. A border at these two values
   would be a third grey inside a 16px box.

   min-width matched to the height so "/" comes out square, the way a single key
   is, while anything longer keeps the shape by growing sideways. */
.mode-key {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: 4px;
  background: var(--key-cap);
  font: 500 10px/1 var(--ui);
  color: var(--dim);
}

/* ---- zoom control -------------------------------------------------------
   Bottom-right, the corner where a canvas keeps its scale. The readout is a
   button; the menu it opens is the only inventory of view commands, and each
   row wears its keystroke so the menu argues for its own obsolescence. */

#zoomctl {
  position: fixed;
  right: var(--chrome-right);
  bottom: var(--chrome-inset);
  z-index: 21;
  display: flex;
  align-items: center;
  padding: 3px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 7px;
  box-shadow: var(--chrome-shadow);
}

.zc-value {
  display: flex;
  align-items: center;
  gap: 2px;
  height: 28px;
  padding: 0 5px 0 9px;
  background: none;
  border: none;
  border-radius: 5px;
  color: var(--chrome-ink);
  font: 400 13px/1 var(--ui);
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: background .13s ease;
}
.zc-value:hover { background: var(--chrome-hover); }
.zc-chev { width: 16px; height: 16px; color: var(--chrome-dim); }

/* ---- top bar: presence + share --------------------------------------------
   Presence first, share on its right — the multiplayer readout, then the
   thing you'd do about it. #topbar is just the shell that keeps a fixed gap
   between them regardless of how wide the presence stack grows, or whether
   it's showing at all. */

#topbar {
  position: fixed;
  top: var(--chrome-top); right: var(--chrome-right);
  z-index: 21;
  display: flex;
  align-items: center;
  gap: 8px;
}

#shareBtn {
  height: 34px;
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 0 8px 0 12px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 7px;
  box-shadow: var(--chrome-shadow);
  color: var(--chrome-ink);
  font: 500 13px/1 var(--ui);
  cursor: pointer;
}
#shareBtn:hover { background: var(--chrome-hover); }

/* The three chips at the head of the bar, which are one object seen three
   times. Presence is who is here, share is who may be; these say what any of
   them can touch (#intBtn), what you have already made (#libBtn), and what your
   agents have been given to read (#cxBtn). All three are things the canvas can
   draw on that are not on the canvas right now, so they are drawn as a set: the
   same 34px, the same border, the same recessed inset the rest of the bar has.

   One rule rather than three, and that is the point of writing it this way. It
   was two identical blocks before the third arrived, which is the shape a
   fourth copy comes from — a chip that is subtly not one of the set because
   somebody changed one of the three.

   A mark and no word on each: "Integrations" is a long label for a bar that
   already carries "agent" and "Share", and every one of these panels names
   itself the moment it opens. */
#intBtn, #libBtn, #cxBtn {
  width: 34px; height: 34px;
  display: grid;
  place-items: center;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 7px;
  box-shadow: var(--chrome-shadow);
  color: var(--chrome-dim);
  cursor: pointer;
}
#intBtn svg, #libBtn svg, #cxBtn svg { width: 16px; height: 16px; }
#intBtn:hover, #libBtn:hover, #cxBtn:hover,
#intBtn[aria-expanded="true"],
#libBtn[aria-expanded="true"],
#cxBtn[aria-expanded="true"] { background: var(--chrome-hover); color: var(--chrome-ink); }
#intBtn:focus, #intBtn:focus-visible,
#libBtn:focus, #libBtn:focus-visible,
#cxBtn:focus, #cxBtn:focus-visible { outline: none; }

.share-chev { width: 15px; height: 15px; color: var(--chrome-dim); transition: transform .15s var(--ease); }
#shareBtn[aria-expanded="true"] .share-chev { transform: rotate(180deg); }

/* ---- share panel -----------------------------------------------------
   Notion/Docs-shaped: an invite row, who you are, then the one action that
   is actually real here. There is no account system yet, so the invite row
   is disabled rather than pretending an email would go anywhere — only
   "Copy link" does something, and what it does is real. */

#shareMenu {
  position: fixed;
  /* 42 is #shareBtn's 34 and the 8 of air under it. */
  top: calc(var(--chrome-top) + 42px); right: var(--chrome-right);
  z-index: 22;
  width: 320px;
  padding: 12px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 10px;
  box-shadow: var(--chrome-shadow);
  opacity: 0;
  transform: translateY(-6px) scale(.97);
  transform-origin: 100% 0;
  pointer-events: none;
  /* `right` is here rather than in the group down at "everything on the right
     edge steps aside" — see the note there. */
  transition: opacity .14s ease, transform .18s var(--ease), right .18s var(--ease);
}
#shareMenu.on { opacity: 1; transform: none; pointer-events: auto; }

.sm-row { display: flex; gap: 8px; margin-bottom: 14px; }
/* No border, no fill, no ring of its own — it is not a box any more, it is the
   left-hand side of one. .sm-field above owns all of that. */
.sm-input {
  flex: 1;
  min-width: 0;
  height: 100%;
  padding: 0 0 0 11px;
  background: none;
  border: none;
  outline: none;
  color: var(--chrome-ink);
  font: 400 13px/1 var(--ui);
}
.sm-input::placeholder { color: var(--chrome-dim); }
.sm-invite {
  flex: none;
  height: 36px;
  padding: 0 14px;
  background: var(--sel);
  border: none;
  border-radius: 8px;
  color: #fff;
  font: 500 13px/1 var(--ui);
  cursor: pointer;
}
.sm-invite:hover:not(:disabled) { filter: brightness(1.06); }
/* An empty field has nobody to invite, so the verb goes grey rather than
   staying a live blue button that can only tell you off for pressing it. */
.sm-invite:disabled {
  background: var(--chrome-sunk);
  color: var(--chrome-dim);
  cursor: default;
}
/* the button's own disabled look is further down: it goes grey rather than
   fading, and fading it as well would leave it barely visible */
.sm-input:disabled { opacity: .5; cursor: not-allowed; }

.sm-avatar {
  width: 30px; height: 30px;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  /* the person's own colour, the same one their cursor wears — so the member
     list and the canvas agree about who is who */
  background: var(--agent, hsl(var(--hue, 20) 72% 48%));
  color: #fff;
  font: 600 13px/1 var(--ui);
  overflow: hidden;
}
.sm-avatar:has(.avatar-img) { background: none; }

/* sits in the link row now, so it matches the control it stands beside */
.sm-copy {
  flex: none;
  display: flex;
  align-items: center;
  gap: 6px;
  height: 38px;
  padding: 0 12px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 8px;
  color: var(--chrome-ink);
  font: 500 13px/1 var(--ui);
  cursor: pointer;
  white-space: nowrap;
}
.sm-copy svg { width: 15px; height: 15px; color: var(--chrome-dim); }
.sm-copy:hover { background: var(--chrome-hover); }

/* The agents ARE the other players. Each already has a name, a hue, and a body
   on the plane, so this is not a new concept — it is those same identities
   collected where a multiplayer app keeps them, answering "who is here and is
   anyone working?" without making you go find their cursors. */

#presence {
  height: 34px; /* matches #shareBtn, so the two chips read as one row */
  display: none;
  align-items: center;
  gap: 7px;
  padding: 0 9px 0 5px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 7px;
  box-shadow: var(--chrome-shadow);
  user-select: none;
}
#presence.on { display: flex; }

/* The way in, said out loud. The count still opens the same panel, but the count
   is a number first and only a control if you happen to try it — so the one
   thing a new canvas needs you to find was findable only by accident. This sits
   inside the pill's own recess, to the left of your face.

   It used to be literally #fff, on the reasoning that the pill is a surface
   sunk into the canvas and the one lit thing on it should be the thing to
   press. That reasoning was right and the colour was the wrong way to say it:
   in dark mode a white chip on a --chrome pill is not lit, it is the one piece
   of the chrome that did not get the memo.

   What actually does the lifting is the pill's own inset shadow, which darkens
   its edges around whatever sits on it — so the button reads as raised at the
   surface colour in either theme, and every value here is the token it was
   already equal to in light. */
.pr-agent {
  flex: none;
  height: 24px;
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 0 8px 0 6px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 6px;
  color: var(--chrome-ink);
  font: 500 11px/1 var(--ui);
  cursor: pointer;
}
.pr-agent svg { width: 13px; height: 13px; color: var(--chrome-dim); }

/* Translucent, so it darkens over the pill in light and lightens in dark
   rather than naming a colour that is only right in one of them. */
.pr-agent:hover,
.pr-agent[aria-expanded="true"] { background: var(--chrome-hover); }
.pr-agent:focus, .pr-agent:focus-visible { outline: none; }

.pr-stack { display: flex; }
.pr-face {
  width: 21px; height: 21px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 4px;
  /* the ring is the panel's own colour, so overlapping faces read as separate
     chips rather than one smeared block */
  box-shadow: 0 0 0 2px var(--chrome);
  /* people, not agents — so this is the app's own accent, never an agent hue */
  background: var(--accent);
  color: #fff;
  font: 600 10px/1 var(--ui);
  overflow: hidden;
}
/* a real profile picture replaces the chip entirely */
.pr-face:has(.avatar-img) { background: none; }
.pr-face { position: relative; } /* later chips paint over earlier ones */
.pr-face + .pr-face { margin-left: -7px; }

/* working pulses a halo in the agent's own colour, never the face itself —
   dimming an identity to say "busy" makes the identity harder to read */
.pr-face.busy { animation: prwork 1.5s ease-in-out infinite; }
@keyframes prwork {
  0%, 100% { box-shadow: 0 0 0 2px var(--chrome), 0 0 0 2px transparent; }
  /* the halo stays inside the pill's own padding — a glow that spills past the
     panel edge reads as a rendering slip, not as attention */
  50%      { box-shadow: 0 0 0 2px var(--chrome), 0 0 0 3.5px color-mix(in srgb, var(--agent, hsl(var(--hue) 58% 46%)) 42%, transparent); }
}

/* Just how many. It used to open the agent panel as well, and a number that is
   secretly a button is a number you cannot trust — you learn it by pressing
   something that looked like a label. .pr-agent carries that job now, so this
   ends the run of faces the way it reads: as their tally. */
.pr-count {
  color: var(--chrome-dim);
  font: 500 11px/1 var(--ui);
  font-variant-numeric: tabular-nums;
}

#zoomMenu {
  position: fixed;
  right: var(--chrome-right);
  /* 40 is the pill's own height and the air above it. */
  bottom: calc(var(--chrome-inset) + 40px);
  z-index: 22;
  width: 238px;
  padding: 6px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 9px;
  box-shadow: var(--chrome-shadow);
  opacity: 0;
  transform: translateY(6px) scale(.97);
  transform-origin: 100% 100%;
  pointer-events: none;
  /* `right` is here rather than in the group down at "everything on the right
     edge steps aside" — see the note there. */
  transition: opacity .14s ease, transform .18s var(--ease), right .18s var(--ease);
}
#zoomMenu.on { opacity: 1; transform: none; pointer-events: auto; }

.zm-item {
  width: 100%;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 0 10px;
  background: none;
  border: none;
  border-radius: 5px;
  color: var(--chrome-ink);
  font: 400 13px/1 var(--ui);
  text-align: left;
  cursor: pointer;
}
.zm-item span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zm-item:hover, .zm-item:focus-visible { background: var(--chrome-hover); outline: none; }

/* Keycaps, here and in the "/" menu — one definition, because they are one
   thing: the keystroke a row is teaching, sunk into the panel it sits on. Both
   menus are the same chrome surface, so the same tokens land the same way in
   both, and a cap learned in one is recognised in the other. */
.zm-keys, .cmd-keys { display: flex; gap: 4px; flex: none; }
.zm-keys kbd, .cmd-keys kbd {
  min-width: 20px; height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 4px;
  background: var(--chrome-sunk);
  color: var(--chrome-dim);
  border-radius: 3px;
  font: 500 11px/1 var(--ui);
  box-shadow: var(--chrome-shadow);
}

.zm-themes {
  display: flex;
  gap: 2px;
  margin-top: 5px;
  padding-top: 5px;
  border-top: 1px solid var(--chrome-edge);
}
.zm-theme {
  flex: 1;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  border-radius: 5px;
  color: var(--chrome-dim);
  cursor: pointer;
  transition: background .13s ease, color .13s ease;
}
.zm-theme svg { width: 17px; height: 17px; }
.zm-theme:hover { background: var(--chrome-hover); color: var(--chrome-ink); }
.zm-theme.on {
  background: var(--chrome-sunk);
  color: var(--chrome-ink);
}

/* ---- profile -------------------------------------------------------------
   Bottom-left, the corner the mode label used to sit in. Just a mount point —
   Clerk's own UserButton renders into it, real avatar and all. */

#profile {
  position: fixed;
  bottom: var(--chrome-inset); left: var(--chrome-inset);
  z-index: 21;
}

/* ---- auth gate -----------------------------------------------------------
   Covers the whole canvas until a session exists. Opaque and above every
   other piece of chrome (they all sit at z-index 21-22) so nothing about the
   canvas is visible, let alone reachable, before sign-in.

   Shown by default and only taken down once a session is confirmed, so it
   fails closed: the seconds Clerk spends booting, a module that throws, a
   blocked script — every one of those leaves the wall up rather than
   flashing the app shell. auth.js removes .off only after clerk().user. */

#authGate {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  background: var(--bg);
}
#authGate.off { display: none; }
/* still covering, but blank — the wall is up before we know whether anyone
   needs to see a sign-in form, and a reload with a live session should never
   flash one. Hidden rather than unmounted so the panes keep their layout. */
#authGate.checking > * { visibility: hidden; }

#authGateLeft {
  flex: 1 1 50%;
  display: flex;
  align-items: center;
  justify-content: center;
}

#authGateRight {
  position: relative;
  flex: 1 1 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  border-left: 1px solid var(--line);
  overflow: hidden;
}

#authGateTileHost {
  position: relative;
  width: 100%;
}

/* pinned rather than laid out in flow, so it can't shift the tile-field's
   vertical centering as the pane resizes */
#authGateCopyright {
  position: absolute;
  bottom: 18px;
  left: 0;
  right: 0;
  text-align: center;
  color: var(--dim);
  font: 400 12px/1 var(--ui);
}

@media (max-width: 860px) {
  #authGateRight { display: none; }
}

.tile-field-canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

.auth-fatal {
  max-width: 360px;
  padding: 14px 16px;
  border: 1px solid var(--line);
  border-radius: 8px;
  color: var(--dim);
  font: 400 13px/1.5 var(--ui);
  text-align: center;
}

/* ---- the agent's body -------------------------------------------------
   State is legible from motion, never from a label. These rules only add
   what motion alone cannot carry: identity, and the ring that shows reading. */

/* No `will-change: transform` on anything that lives on the plane. See the note
   above #plane: a layer carrying that hint is rasterised once at the scale it
   was promoted at and then stretched by the compositor at every other scale —
   the browser deliberately skips the re-raster, on the theory that a layer
   declaring its transform will change is mid-animation and about to change
   again. The plane's zoom IS an ancestor transform, so the hint turns every
   cursor, tag and label on it into a bitmap of itself: crisp at whatever zoom
   the tab happened to be at, visibly pixelated at the rest. That is the blur
   you see on an arrow and its name at 200%.
   Nothing is lost by dropping it. ui/cursors.js and agents/agent.js write
   translate3d() every frame, which promotes these to their own layer anyway,
   and a layer moved by translation alone is composited without repainting —
   the whole benefit the hint was here for, minus the pinned raster. */
.agent-cursor {
  position: absolute;
  left: 0; top: 0;
  z-index: 12;
  pointer-events: none;
  opacity: 0;
  transition: opacity .6s ease;
}
.agent-cursor.on { opacity: 1; }

.agent-cursor .body {
  display: flex;
  align-items: flex-start;
}

.arrow {
  display: block;
  overflow: visible;
  filter: drop-shadow(0 1px 2px rgb(0 0 0 / .25));
}
/* the halo is drawn first and slightly fatter, so the coloured arrow keeps a
   clean white edge against ink, text, or a live browser frame underneath */
.arrow-halo {
  fill: var(--ptr-edge);
  stroke: var(--ptr-edge);
  stroke-width: 1.8;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.arrow-fill {
  fill: var(--agent, hsl(var(--hue) 72% 52%));
  stroke: var(--agent, hsl(var(--hue) 72% 52%));
  stroke-width: .6;
  stroke-linejoin: round;
  stroke-linecap: round;
}

/* A press. The arrow dips toward its own tip and comes back.

   The transform origin is the tip rather than the centre, because that is the
   point the arrow is actually pointing at and the one thing about a cursor that
   must not appear to move while it clicks. Scaled, not translated: a pointer
   that slides on click reads as a pointer that missed. */
.agent-cursor .body.pressing .arrow {
  animation: cursorpress .17s ease-out;
  transform-origin: 2px 1px;
}
@keyframes cursorpress {
  40% { transform: scale(.82); }
  to  { transform: scale(1); }
}

/* One bubble, always. At rest it is the name; talking swaps the same pill's
   text to the message and back — there is no second box to spawn or hide. */
.agent-cursor .tag {
  margin: 9px 0 0 -1px;
  background: var(--agent, hsl(var(--hue) 72% 52%));
  color: #fff;
  font: 500 11px/1 var(--mono);
  padding: 4px 8px 5px;
  border-radius: 5px;
  white-space: nowrap;
  transition: background .12s ease;
}

/* Stop this one.

   Beside the name, the same height and the same colour, so the pill and the
   button read as one object belonging to one agent rather than as a control
   that has landed next to it. The gap is 3px: enough that they are two things
   you can hit separately, little enough that they are still one.

   The glyph is a white rounded square, which is what stop has looked like on
   every machine anyone has used. Drawn with a pseudo-element rather than an
   SVG because it is a square: a 24-byte rule against a tag, an attribute and a
   path, for the same eight pixels.

   Only while there is something to stop. `.holding` is set by the busy accessor
   in agents/agent.js, so this cannot be up over an idle cursor and cannot
   survive a body being stood down.

   pointer-events are given back here and only here. The cursor layer is inert
   by construction — a pointer that swallowed clicks would make the canvas
   underneath unusable wherever an agent happened to be standing — so this is
   the one element on a body that can be pressed. */
.agent-cursor .halt {
  display: none;
  place-items: center;
  flex: none;
  margin: 9px 0 0 3px;
  width: 20px;
  height: 20px;
  padding: 0;
  border: 0;
  border-radius: 5px;
  background: var(--agent, hsl(var(--hue) 72% 52%));
  cursor: pointer;
  pointer-events: auto;
  -webkit-app-region: no-drag;
}
.agent-cursor.holding .halt { display: grid; }
.agent-cursor .halt::before {
  content: "";
  width: 8px;
  height: 8px;
  border-radius: 1.5px;
  background: #fff;
}
/* Pressed, in the same language the rest of the app uses for a press. */
.agent-cursor .halt:active { transform: translateY(.5px); }
.agent-cursor .halt:hover::before { transform: scale(1.08); }
.agent-cursor .halt::before { transition: transform .12s ease; }

/* thinking pulses the tag — the one place a state gets a visual assist,
   because "occupied but not moving" is genuinely hard to read from motion */
.agent-cursor.is-thinking .tag { animation: pulse 1.15s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: .45 } }

.agent-cursor.is-reading .tag { background: var(--agent, hsl(var(--hue) 40% 52%)); }

/* a message swapping in gets one brief pop — the only cue that the pill's
   content just changed, since the text itself changes with no other motion */
.agent-cursor.talking .tag { animation: pop .16s ease-out; }
@keyframes pop { from { transform: scale(.92); } to { transform: scale(1); } }

/* reading: a ring closes over exactly what is being read. You see attention
   land, and on what — the thing chat can never show you. */
.scan-ring {
  position: absolute;
  border: 1.5px solid var(--agent, hsl(var(--hue) 72% 52%));
  border-radius: 3px;
  pointer-events: none;
  opacity: 0;
  transform: scale(1.06);
  transition: opacity .25s ease, transform .45s cubic-bezier(.2, .8, .2, 1);
}
.scan-ring.on { opacity: .75; transform: scale(1); }

/* ---- selection ---- */

#marquee, #selbox {
  position: absolute;
  pointer-events: none;
  z-index: 5;
  display: none;
}
#marquee { border: 1.5px solid var(--sel); background: color-mix(in srgb, var(--sel) 8%, transparent); }
#selbox { border: 1.5px solid var(--sel); }

/* the one action a selection offers, sitting on the selection box's top edge
   the way Figma's does — translated up by its own height so it rides above the
   box rather than covering the content inside it. Which action depends on what
   is selected; see paintAction in canvas/selection.js */
#selAction {
  position: absolute;
  z-index: 6;
  display: none;
  align-items: stretch;
  transform: translateY(-100%);
  margin-top: -7px;
  height: 28px;
  border-radius: 6px;
  background: var(--sel);
  overflow: hidden;
  box-shadow: 0 1px 6px rgb(0 0 0 / .18);
}
/* One pill holding one or two verbs. The divider is a border on the second
   button rather than a element of its own, so a bar with one verb in it is
   exactly the control this used to be and is not carrying a hairline at its
   left edge for a neighbour that is not there. */
#selAction button {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 0 10px;
  border: none;
  background: none;
  color: #fff;
  font: 500 12px/1 var(--ui);
  white-space: nowrap;
  cursor: pointer;
}
#selAction button + button { border-left: 1px solid rgb(255 255 255 / .22); }
#selAction button:hover { background: rgb(255 255 255 / .14); }
#selAction button:focus, #selAction button:focus-visible { outline: none; }
#selAction kbd {
  font: 500 11px/1 var(--ui);
  opacity: .72;
}
/* What a verb says for a moment after it is pressed, when what it did is not
   visible on the canvas. Steady rather than animated: it is a receipt. */
#selAction button.flash { background: rgb(255 255 255 / .2); }

/* ---- sections ------------------------------------------------------------
   A named region, drawn as ground rather than as content: it sits under
   everything, takes no pointer events, and owns nothing inside it. The name
   chip and the edges are the only parts you can touch — one says what the
   region is called, the other says how far it reaches. */

.section {
  position: absolute;
  z-index: 0;
  pointer-events: none;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: color-mix(in srgb, var(--dim) 7%, transparent);
}
/* The frame is resizable from any edge or corner. The grips are invisible
   strips lying along the border rather than the usual square handles: a section
   is ground, and eight dots hovering over the canvas would make it furniture.
   The cursor is the entire affordance, which is what a border already promises
   everywhere else. */
.section-grip {
  position: absolute;
  pointer-events: auto;
}
.section-grip-n, .section-grip-s { left: 0; right: 0; height: 11px; cursor: ns-resize; }
.section-grip-w, .section-grip-e { top: 0; bottom: 0; width: 11px; cursor: ew-resize; }
.section-grip-n { top: -6px; }
.section-grip-s { bottom: -6px; }
.section-grip-w { left: -6px; }
.section-grip-e { right: -6px; }
/* corners sit over the edges they meet, so aiming at one gets both axes */
.section-grip-nw, .section-grip-ne, .section-grip-sw, .section-grip-se {
  width: 15px; height: 15px;
  z-index: 1;
}
.section-grip-nw { top: -7px; left: -7px; cursor: nwse-resize; }
.section-grip-ne { top: -7px; right: -7px; cursor: nesw-resize; }
.section-grip-sw { bottom: -7px; left: -7px; cursor: nesw-resize; }
.section-grip-se { bottom: -7px; right: -7px; cursor: nwse-resize; }

/* A pen has to be able to draw across a section, and drag mode has to be able
   to pan from anywhere. The edges only answer the pointer in the modes where
   the pointer is for pointing. */
body.draw .section-grip,
body.erase .section-grip,
body.drag .section-grip { pointer-events: none; }

/* the chip is the section's other handle: drag it to move the region and
   everything in it, click the name to rename, click the × to remove */
.section-label {
  position: absolute;
  z-index: 1;
  display: flex;
  align-items: center;
  transform: translateY(-100%);
  /* Even on all four sides. It used to be 8 left and 5 right, which was not a
     lopsided chip on purpose — it was 5px of padding plus an × that is
     invisible until you point at the name and takes its 18px whether or not
     you can see it, so the name sat off-centre in its own label at all times.
     The × gives its width up instead: see .section-del below. */
  padding: 4px 8px;
  border-radius: 6px;
  background: var(--panel);
  font: 500 12px/1.2 var(--ui);
  white-space: nowrap;
  cursor: grab;
}
.section-label:active { cursor: grabbing; }
/* A long name yields to the search field rather than running under it — the
   width comes from section.js, which is the only place that knows how much
   room the region has. Editing scrolls the caret into view as usual. */
.section-name {
  color: var(--dim);
  outline: none;
  cursor: text;
  overflow: hidden;
  text-overflow: ellipsis;
}
.section-name:focus { color: var(--ink); }
.section-label:focus-within { box-shadow: 0 0 0 1.5px var(--sel); }

.section-del {
  /* No width at rest, so a control nobody can see takes no room from the name
     it sits beside. It opens out of the right edge when the label is pointed
     at, which the chip can afford: a label is anchored by its left edge, so it
     grows away from the region rather than shifting over it. */
  width: 0;
  height: 16px;
  margin-left: 0;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 4px;
  background: none;
  color: var(--dim);
  font: 400 14px/1 var(--ui);
  cursor: pointer;
  opacity: 0;
  transition: opacity .12s ease, width .14s var(--ease), margin-left .14s var(--ease);
}
.section-label:hover .section-del,
.section-label:focus-within .section-del { width: 16px; margin-left: 4px; opacity: 1; }
@media (prefers-reduced-motion: reduce) { .section-del { transition: none; } }
.section-del:hover { background: var(--chrome-hover); color: var(--ink); }

/* ---- the other end of a section's line ----------------------------------
   Name it on the left; look through it and settle it on the right. One element
   holding both so they stay one line, right-aligned to the region's far edge.
   The field drops out when the region is too narrow to hold it — FIND_W and
   FIND_MIN in canvas/section.js — which is what yields the name the room it
   does not need. */
.section-rail {
  position: absolute;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 6px;
  transform: translate(-100%, -100%);
}


.section-find {
  display: flex;
  align-items: center;
  gap: 6px;
  width: 168px; /* FIND_W in canvas/section.js, which yields the name this much */
  padding: 3px 8px 4px;
  border-radius: 6px;
  background: var(--panel);
  color: var(--dim);
  font: 400 12px/1.2 var(--ui);
}
/* `display: flex` above would otherwise beat the browser's rule for [hidden],
   and a section too narrow for the field would wear it anyway, over its name */
.section-find[hidden] { display: none; }
.section-find:focus-within { box-shadow: 0 0 0 1.5px var(--sel); }

.sf-icon {
  flex: none;
  width: 13px; height: 13px;
  opacity: .8;
}
.section-find.is-on .sf-icon { color: var(--ink); opacity: 1; }

/* 16px to match the delete button across the line, so both chips are the
   same height whether or not either has anything in it */
.sf-q {
  flex: 1;
  min-width: 0;
  height: 16px;
  padding: 0;
  border: none;
  background: none;
  color: var(--ink);
  font: 400 12px/16px var(--ui);
  outline: none;
}
.sf-q::placeholder { color: var(--dim); }

.sf-hits {
  flex: none;
  color: var(--dim);
  font-variant-numeric: tabular-nums;
}
.sf-hits[hidden] { display: none; }
.section-find.is-empty .sf-hits { color: var(--accent); }

/* The region answering the field. Nothing moves — a message you were reading a
   second ago is still exactly where you were reading it, only quieter. A match
   has no mark of its own: it is simply the one that did not step back, which is
   the whole of what the search is saying. */
.block.find-miss { opacity: .18; }
/* the one Enter is standing on, held after the flash has finished */
.block.find-at {
  border-radius: 6px;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--sel) 30%, transparent);
}

/* A pen has to be able to draw across a section, and drag mode has to pan from
   anywhere — the same reason the grips go inert. Typing into a field you cannot
   see the point of is not worth a stroke that lands somewhere else. */
body.draw .section-rail,
body.erase .section-rail,
body.drag .section-rail { pointer-events: none; }

/* ---- built artifacts ---- */

/* The source under a running app, opened in the app's own rectangle.
   --rh is the height the frame was claimed at and the height it still has: the
   app is hidden behind this, not removed, so swapping between the two must not
   move a single pixel of the block or of anything on the canvas beside it.

   Which is also why the corners are square and the border is the frame's own
   1px of --line. This is not a card sitting where an app was, it is the same
   rectangle showing its other side — and a corner that rounded off on the way
   in would read as the block changing shape rather than changing contents. */
.codecard {
  display: none;
  flex-direction: column;
  width: 100%;
  height: var(--rh, 320px);
  /* The canvas's own ground, which is plain white in the light theme, rather
     than --panel's warm grey. --panel is the fill for something sitting ON the
     canvas: an inline code span, a chip, a menu. This is not on the canvas, it
     is the canvas showing you the inside of a block, and it stands in the exact
     rectangle a running app stands in -- which is white with one line of --line
     around it. Toggling should change what is in the rectangle and nothing
     about the rectangle.

     Following the token rather than hardcoding #fff is what keeps the dark
     theme sane: the app inside a frame is white because the app's own document
     is, but this is our chrome, and somebody reading code in the dark theme
     wants it dark. */
  background: var(--bg);
  border: 1px solid var(--line);
  overflow: hidden;
}
/* Carrying .render as well as .viewing is not redundant. `.block.render .frame`
   sets display: block further down this file, and at equal specificity the one
   written later wins — so a plain `.block.viewing .frame` would lose to it and
   leave the frame sitting on top of the code, invisible only in the sense that
   it is behind nothing, still taking every click and every scroll. The extra
   class buys a weight that does not depend on which rule was typed first. */
.block.render.viewing .frame { display: none; }
.block.render.viewing .codecard { display: flex; }

.cc-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 14px;
  border-bottom: 1px solid var(--line);
}
/* The header's three pills: what this is, and the two things you can do with
   it. One shape for all three, in the cold grey defined at the top of this
   file, so the strip reads as the tool's own chrome rather than as part of the
   document under it.

   The first is not a button and does not pretend to be one on hover. It wears
   the same outline because it belongs to the same row of objects, which is a
   different claim from being pressable -- the two that are pressable say so by
   reacting. */
.cc-pill {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  height: 22px;
  padding: 0 7px;
  background: none;
  border: 1px solid var(--line);
  border-radius: 5px;
  color: var(--dim);
  font: 400 11px/1 var(--mono);
}
/* Sized off the word beside it rather than off its own box, so the mark reads
   as part of the label instead of as a second control. */
.cc-pill svg { width: 12px; height: 12px; flex: none; }

.cc-lang {
  /* It names the panel, so it is the one of the three that is filled: a label
     reads as a label when it sits ON something, and the two beside it stay
     unfilled because a filled button in this row would look pressed. */
  background: var(--panel);
  letter-spacing: .01em;
}

.cc-acts {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

/* Still no fill on either. Hovering firms the edge and the ink, which is the
   whole of what "you can press this" needs to say once the edge is there. */
.cc-copy, .cc-get { cursor: pointer; transition: border-color .12s ease, color .12s ease; }
.cc-copy:hover, .cc-get:hover { color: var(--ink); border-color: var(--dim); }
.cc-copy:active, .cc-get:active { transform: translateY(1px); }
.cc-copy:focus, .cc-copy:focus-visible,
.cc-get:focus, .cc-get:focus-visible { outline: none; }
.cc-copy:focus-visible, .cc-get:focus-visible { color: var(--ink); border-color: var(--sel); }

/* Wide enough for the answer almost every press gets -- "copied", "saved" --
   so the button does not resize under the pointer at the moment it is pressed.
   Not wide enough for "press ⌘C", which is a clipboard the browser refused: it
   is rare, and reserving room for it on every card made the button look padded
   for a word it hardly ever says. */
.cc-copy .cc-word { min-width: 34px; }
.cc-get .cc-word { min-width: 52px; }

/* The scroller, and the only place on this canvas where the text is not the
   canvas's own typeface. --mono is Inter like everything else, and the comment
   at the top of this file marks it as the seam for exactly this — but turning
   it into a monospace now would restyle every block on every canvas, which is
   not a code viewer's business. So the seam opens here and nowhere else. */
.cc-body {
  flex: 1;
  display: flex;
  align-items: flex-start;
  overflow: auto;
  font: 400 12.5px/1.65 var(--code);
  color: var(--ink);
  white-space: pre;
  tab-size: 2;
}
/* Sticky against the horizontal scroll, opaque so a long line passes behind it
   rather than through it. Vertically it just rides the scroller, which is what
   keeps a number level with its line without a scroll handler to sync them. */
.cc-gutter {
  position: sticky;
  left: 0;
  z-index: 1;
  flex: none;
  padding: 12px 10px 12px 14px;
  text-align: right;
  background: var(--bg);
  border-right: 1px solid var(--line);
  color: var(--faint);
  user-select: none;
}
.cc-code {
  flex: 1;
  padding: 12px 16px 12px 14px;
  font: inherit;
}

.tok-c { color: var(--tok-c); font-style: italic; }
.tok-s { color: var(--tok-s); }
.tok-tag { color: var(--tok-tag); }
.tok-kw { color: var(--tok-kw); }

.block.render { width: var(--rw, 480px); max-width: none; }
/* Square, like the frame that stood here while it was being built — which is
   the same rectangle, and should not change shape at the moment it fills. */
.block.render .frame {
  display: block;
  width: 100%;
  height: var(--rh, 320px);
  border: 1px solid var(--line);
  background: #fff;
}
.framelabel {
  display: flex;
  align-items: center;
  gap: 7px;
  font: 500 11px/1 var(--mono);
  color: var(--dim);
  margin: 0 0 7px 1px;
}

/* ---- a frame claimed for an app still being written ----

   The whole state is borrowed rather than invented. The colour is the selection
   blue, but it is no longer spent on a line. A hairline around a rectangle is
   how this canvas says "selected", and a frame that is only being written is
   not that: it read as something you had picked, and it read as a hard object
   before there was an object. So the blue comes in off the inside of the four
   edges and falls away before it reaches the middle, which claims the same
   rectangle without drawing anything you could point at. Nothing is painted
   outside the box — the glow is a child clipped to it, not a shadow around it.
   The badge is the wrap button off a selection: same blue, same pill, riding
   above the top edge, because that is the one place on this canvas where a
   label sits over a rectangle and says what is about to happen to it.

   What is not borrowed is a progress bar, and deliberately. Nothing on this
   side knows how far along a build is, so the only honest animation is one that
   says the thing is alive, not one that says how nearly done it is. */
/* ---- the mark that writes itself while something is being made ----

   From github.com/syanhg/Three, values unchanged. The block below is that
   file's ":root { --- tune these --- }" carried over verbatim, and the JS in
   ui/loading-mark.js reads the four durations off the document element exactly
   as it does there, so they have to live at the root and keep their names.

   The one number that is not the original's is --size, and the gap below it.
   There the mark IS the page, so both are a share of the viewport. Here it
   stands inside a 640px frame on a canvas that zooms, where a share of the
   viewport would put a 200px mark and a word set in 113px type inside a box
   that cannot hold either. --size is the knob that file marks as tunable, and
   everything else in this block is sized off it. */
:root {
  --ink-mark: var(--faint);  /* stroke colour */
  --stroke-w: 26;          /* stroke width, in viewBox units */
  --draw: 1900ms;          /* time to write */
  --erase: 1500ms;         /* time to unwrite */
  --hold-full: 420ms;      /* pause when fully drawn */
  --hold-empty: 320ms;     /* pause when empty */
  --size: 28px;            /* width of the mark */

  /* The mark's viewBox is 550 x 295, so its drawn height is 0.536 of its
     width. Text is sized so its ascender-to-descender run (~0.95em in this
     stack) matches that height — the two line up at the same visual size. */
  --mark-h: calc(var(--size) * .5364);
  --text-size: calc(var(--mark-h) / .95);

  --shine: var(--dim);     /* the band that sweeps through the text */
  --word-hold: 2000ms;     /* time each word is on screen */
  --sweep: 3000ms;         /* time for one shimmer pass */
}

/* The two colours turn over in the dark theme, because the shimmer is a
   contrast and not a colour: on white the word sits light and the band riding
   through it is darker, and carried onto a dark ground unchanged the word
   becomes the brightest thing on the canvas while the band vanishes into it — a
   shimmer you cannot see is just a word flickering.

   Nothing is restated for that. --faint and --dim already turn over with the
   theme, and they turn over in the right order: faint is the lighter of the two
   on white and the darker of the two on black. The word is drawn faint and the
   band is drawn dim in both, and the contrast reverses itself. */

/* Centred in whatever is waiting, and painting nothing of its own.
   It stood on white on the argument that a page is white before it is
   anything, which is true of a page and not of this: what is on screen during a
   build is not a page yet, it is a rectangle on somebody's canvas. In dark mode
   that white was simply a lit panel sitting in a dark room. The ground belongs
   to whatever this stands in — .framewip carries var(--bg) — so it is left to
   it, and the mark works on either theme. */
.loading-mark {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: transparent;
  overflow: hidden;
}

.loading-mark .stage {
  width: var(--size);
  display: block;
  overflow: visible;
}

.loading-mark .mark {
  fill: none;
  stroke: var(--ink-mark);
  stroke-width: var(--stroke-w);
  stroke-linecap: round;
  stroke-linejoin: round;
  /* --len is written by JS from getTotalLength() */
  stroke-dasharray: var(--len);
  stroke-dashoffset: var(--len);
  will-change: stroke-dashoffset;
}

.loading-mark .loader {
  display: flex;
  align-items: center;
  /* Off the mark rather than off the viewport. The original sizes this gap in
     vw because there the pair is the whole page and the page is the only thing
     it has to be in proportion to. Here it is a small label inside a frame, and
     a gap that grows with the window while the mark stays 28px would drift
     apart on a wide screen and close up on a narrow one. Tied to --size, the
     two read as one object at any size either of them is set to. */
  gap: calc(var(--size) * .18);
}

/* Every word sits in the same grid cell, so the box is as wide as the
   longest one and nothing reflows as they swap. */
.loading-mark .words {
  display: grid;
  /* Line height must clear the font's ascent+descent (~1.16em), not just
     1em: background-clip only paints inside the padding box, so anything
     hanging outside it — descenders on p, g, j — gets clipped away. */
  font: 500 var(--text-size)/1.35 system-ui, -apple-system, "Segoe UI", Inter, sans-serif;
  letter-spacing: -.02em;   /* tightened, as display sizes want */
  white-space: nowrap;
  /* Ascender and descender don't sit symmetrically in the em box, so
     centring the boxes leaves the word riding low against the mark. */
  transform: translateY(-.04em);
}

.loading-mark .words b {
  grid-area: 1 / 1;
  justify-self: start;
  font-weight: inherit;
  opacity: 0;
  /* Extra paint area so no glyph edge falls outside the clipped background.
     Every word carries the same padding, so nothing shifts relative to
     anything else. */
  padding: .06em .08em;

  /* The shimmer: a darker band riding through the glyphs. The gradient is
     3x the box so the band travels fully off one edge before it re-enters
     the other — at 2x it teleported from the right edge to the left on
     every loop. */
  background: linear-gradient(90deg,
    var(--ink-mark) 0%, var(--ink-mark) 45%,
    var(--shine) 50%,
    var(--ink-mark) 55%, var(--ink-mark) 100%);
  background-size: 300% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  /* No will-change. This mark is drawn inside frames ON the plane — a build
     waiting for its app — and `transform` in that list pinned the raster of the
     one element here that is made of letters. Words clipped out of a gradient
     are already the most expensive thing in this component to rasterise and the
     most obviously wrong when they are stretched instead: at 200% they came back
     as a bitmap of themselves with fringed edges. The animations below still
     composite; they just no longer promise the compositor that a stale raster
     will do. */
}

.loading-mark.ready .words b {
  animation:
    swap calc(var(--word-hold) * 7) infinite,
    shimmer var(--sweep) linear infinite;
}

.loading-mark.ready .words b:nth-child(1) { animation-delay: calc(var(--word-hold) *  0), 0ms; }
.loading-mark.ready .words b:nth-child(2) { animation-delay: calc(var(--word-hold) * -1), 0ms; }
.loading-mark.ready .words b:nth-child(3) { animation-delay: calc(var(--word-hold) * -2), 0ms; }
.loading-mark.ready .words b:nth-child(4) { animation-delay: calc(var(--word-hold) * -3), 0ms; }
.loading-mark.ready .words b:nth-child(5) { animation-delay: calc(var(--word-hold) * -4), 0ms; }
.loading-mark.ready .words b:nth-child(6) { animation-delay: calc(var(--word-hold) * -5), 0ms; }
.loading-mark.ready .words b:nth-child(7) { animation-delay: calc(var(--word-hold) * -6), 0ms; }

/* Each word owns a 1/7 slot = 14.2857%, and only fades out *after* its slot
   ends — which is when the next word's timeline starts. The incoming word
   then waits 1.6% before it begins rising, so the outgoing one is most of
   the way gone before it appears: at this size two long words at similar
   opacity just read as mud. The handover still overlaps by ~170ms, enough
   that there's never a blank frame. Offsets are in em so the drift scales
   with the type. */
@keyframes swap {
  0%        { opacity: 0; transform: translateY(.1em)  scale(.99); }
  1.6%      { opacity: 0; transform: translateY(.1em)  scale(.99);
              animation-timing-function: cubic-bezier(.25, .6, .35, 1); }
  5.2%      { opacity: 1; transform: translateY(0)     scale(1);
              animation-timing-function: linear; }
  14.2857%  { opacity: 1; transform: translateY(0)     scale(1);
              animation-timing-function: cubic-bezier(.3, .86, .5, 1); }
  17.1%     { opacity: 0; transform: translateY(-.1em) scale(.99); }
  100%      { opacity: 0; transform: translateY(-.1em) scale(.99); }
}

@keyframes shimmer {
  from { background-position: 100% 0; }
  to   { background-position: 0% 0; }
}

/* Animation only starts once JS has measured the path and written
   the @keyframes (it needs the real path length in px). */
.loading-mark.ready .mark {
  animation: write var(--cycle) infinite;
}

@media (prefers-reduced-motion: reduce) {
  .loading-mark.ready .mark { animation: none; stroke-dashoffset: 0; }
  .loading-mark.ready .words b { animation: none; }
  /* Without the cycle every word would sit at opacity 0 — hold the first. */
  .loading-mark .words b:first-child { opacity: 1; transform: none; color: var(--ink-mark); }
}

/* Not scoped to .block.building, although a build is where it was first used.
   The class is only ever put on the one element that means this: an agent has
   claimed a rectangle on the canvas and is working in it where you can watch.
   Scoping it would have meant a second copy of these six lines under a different
   name for the next thing that does that, which is how two things that should
   look identical stop looking identical. */
.framewip {
  position: relative;
  width: 100%;
  height: var(--rh, 320px);
  background: var(--bg);
  overflow: hidden;

  /* The blue itself, and it is not the same number on both grounds. On white a
     wash at .34 is a tint; carried onto the dark ground unchanged it is the brightest
     thing on the canvas, because the ground has nowhere left to go but up. Dark
     takes less of it and reaches a little further in, so the falloff stays as
     gradual there as it looks here. */
  --wip-peak: .34;
  --wip-far: 34%;
}
:root[data-theme="dark"] .framewip {
  --wip-peak: .26;
  --wip-far: 40%;
}

/* How far in from each edge the blue still has anything left, and how much of
   it arrives at the edge itself. Registered so they can be animated: a raw
   custom property inside a gradient is substituted once and then holds, so the
   breathing below would have had nothing to interpolate and the glow would sit
   still. Where @property is not understood the initial values stand and the
   glow is simply steady, which is the same picture without the tide. */
@property --wip-reach {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 34%;
}
@property --wip-lit {
  syntax: '<number>';
  inherits: false;
  initial-value: .34;
}

/* Four one-sided fades, one per edge, each landing on transparent well before
   the middle. They add where they overlap, so the corners carry the most and
   the centre carries none, which is the shape the reference has: light coming
   off the inside of the box rather than a line drawn on it.

   Absolutely positioned and inset 0 inside an overflow-hidden parent, so every
   pixel of it is inside the rectangle. Nothing here is a box-shadow, which is
   the one way this could have leaked outward. */
/* Shared, verbatim, with a browser block being worked by an agent. An app being
   written and a page being driven are the same event on this canvas and they
   wear the same light — not a second implementation of it in another colour,
   which is what was here and which silently drew nothing. The two of them differ in
   one number each: how far in the fade reaches, below. */
.framewip::before,
.wb-tab.driving::before {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  --wip-edge: rgb(13 153 255 / var(--wip-lit));
  background:
    linear-gradient(to right,  var(--wip-edge), transparent var(--wip-reach)),
    linear-gradient(to left,   var(--wip-edge), transparent var(--wip-reach)),
    linear-gradient(to bottom, var(--wip-edge), transparent var(--wip-reach)),
    linear-gradient(to top,    var(--wip-edge), transparent var(--wip-reach));
  animation: wipglow 4.4s ease-in-out infinite;
}

/* A tide rather than a pulse. Both ends of the swell are slow and the depth
   moves further than the brightness does, so what you notice is the blue
   creeping in and letting go again, not a rectangle blinking. It is deliberately
   too slow to watch on purpose — a build takes minutes, and anything quicker
   than this becomes a thing in the room after the first thirty seconds of it.

   Repainting the gradient every frame rather than scaling one composited copy
   of it, and on purpose. A scaled layer is cheaper and would have given the
   same swell, but this frame stands on a plane that zooms: the layer is
   rasterised once and stretched, so at 200% the soft edge comes back as a
   stretched bitmap of itself. The same trap ../ui/loading-mark.js's words fell
   into. This repaints at whatever scale it is actually being seen at, and there
   is one of these on screen at a time, not one per block. */
@keyframes wipglow {
  0%, 100% { --wip-reach: calc(var(--wip-far) * .72); --wip-lit: calc(var(--wip-peak) * .62); }
  50%      { --wip-reach: var(--wip-far);             --wip-lit: var(--wip-peak); }
}

/* Held at the swell's middle, where it would have spent most of its time. */
@media (prefers-reduced-motion: reduce) {
  .framewip::before,
  .wb-tab.driving::before {
    animation: none;
    --wip-reach: calc(var(--wip-far) * .86);
    --wip-lit: calc(var(--wip-peak) * .8);
  }
  .block.web.driving .wb-page { animation: none; --wip-lit: .38; }
}

/* ---- how far the light reaches in a browser block ----

   The one number that differs from an artifact frame, and it differs twice
   because a block holds two rectangles of very different sizes.

   A page: only the outermost band of the fade is ever visible. The rest is
   under a native view that nothing in this document can draw on top of, so the
   page is inset by RING points (see canvas/browser.js) and the light lives in
   the gap. A reach of a third of the box, which is what an artifact uses, would
   put a flat wash of blue in that gap; a few per cent puts the bright end of a
   real falloff there and lets the rest of it disappear under the page edge.

   A tab: the whole pill is visible and it is a hundred points across, so the
   fade has to be most of it or there is nothing to see. */
/* A page's glow is one inset shadow rather than four fades, and it is the only
   place in this app that departs from the frame it is copying. The reason is
   the corners.

   Four one-sided fades ADD where they meet, which is right when the whole
   falloff is visible: the corners carry the most, the middle carries none, and
   what you read is light coming off the inside of a box. Over a page only a
   narrow band of that is ever visible — the rest is under a native view — so
   the corners were not a gradient any more, they were two bands crossing, with
   the overlap showing as a brighter square and a diagonal seam through it.
   Exactly the thing the reference's shape exists to avoid.

   An inset shadow has no seams to show. It is one shape blurred inward from one
   edge, so the corners round off into each other instead of crossing, and the
   blur is how far in it reaches — which is the other half of what was wrong:
   the blue stopped at the gap and wanted to come further into the page. */
.block.web.driving .wb-page {
  /* Two shadows rather than one, and the reason is the square.
   *
   * A single inset shadow is one falloff, and whatever width it is, it ENDS
   * somewhere — and where it ends against the page it is cut off by the
   * webview, which draws a hard rectangle exactly there. That edge is the
   * square: not the shape of the light, the shape of where the light stopped.
   *
   * So the light has to be gone before the page begins, and it has to get there
   * gradually. A bright, tight one holds the edge; a wide, faint one carries it
   * inward and dies out around thirty points in, which is inside the gap the
   * page is inset by. Nothing reaches the webview's edge, so there is no edge
   * to see — and two overlaid falloffs of different widths is what light off a
   * surface actually looks like, rather than one linear ramp.
   */
  box-shadow:
    inset 0 0 20px -4px rgb(13 153 255 / var(--wip-lit)),
    inset 0 0 62px 0 rgb(13 153 255 / calc(var(--wip-lit) * .6));
  animation: wb-page-tide 4.4s ease-in-out infinite;
}
/* The same swell as the frame's, at the same pace, on the same registered
   number — so it is repainted each frame rather than being a composited layer
   whose soft edge comes back stretched at 200%. */
@keyframes wb-page-tide {
  0%, 100% { --wip-lit: .30; }
  50%      { --wip-lit: .46; }
}
.wb-tab.driving {
  position: relative;
  overflow: hidden;
  --wip-peak: .34;
  --wip-far: 44%;
}
/* The favicon and the name sit above the light rather than under it. A
   positioned pseudo-element paints over in-flow children, and a tab whose text
   is behind a wash of blue is a tab you cannot read. */
.wb-tab.driving > * { position: relative; }
:root[data-theme="dark"] .block.web.driving .wb-page { animation-name: wb-page-tide-dark; }
@keyframes wb-page-tide-dark {
  0%, 100% { --wip-lit: .24; }
  50%      { --wip-lit: .36; }
}
:root[data-theme="dark"] .wb-tab.driving { --wip-peak: .28; --wip-far: 50%; }

/* A rebuild keeps the last version running under the badge. People are using
   that app; the announcement is not worth taking it away from them. */
.block.rebuilding .frame { border-color: var(--sel); }

.wip-badge {
  position: relative;
  overflow: hidden;
  flex: none;
  padding: 0 8px;
  height: 18px;
  display: inline-flex;
  align-items: center;
  border-radius: 5px;
  background: var(--sel);
  color: #fff;
  font: 500 10.5px/1 var(--ui);
  white-space: nowrap;
}
.wip-badge::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, transparent 22%, rgb(255 255 255 / .5) 50%, transparent 78%);
  transform: translateX(-100%);
  animation: wipshine 1.9s ease-in-out infinite;
}
@keyframes wipshine { to { transform: translateX(100%); } }

/* A build that stopped answering. The frame stays — somebody asked for it and
   should be able to see that it was tried — but it stops pretending. */
.wip-badge.stalled { background: var(--dim); }
.wip-badge.stalled::after { display: none; }

@media (prefers-reduced-motion: reduce) {
  .wip-badge::after { display: none; }
}
/* What the app last sent back through answer(). It is the block's text, drawn
   here because a running app has no <pre> to draw it in — and it is the one
   part of an app the agent that built it can read, so the person who pressed
   the button should be able to see exactly what went. */
.framesaid {
  font: 400 11px/1.4 var(--mono);
  color: var(--dim);
  margin: 7px 1px 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.framesaid b { font-weight: 500; color: var(--ink); }

/* ===========================================================================
   Multiplayer
   Everything below is about other people being here. It divides cleanly in
   two: the document, which is shared and saved, and presence, which is shared
   and thrown away. Only the second half needs pixels of its own — the first
   half just makes the canvas change under you, which it already knew how to do.
   =========================================================================== */

/* ---- other people's cursors ----------------------------------------------
   Drawn on the plane, in canvas coordinates, exactly where and how the agents
   are drawn — which is the whole point: on this canvas a person and an agent
   are both a cursor, and anything that renders them differently is claiming a
   difference that isn't there. See ui/cursors.js. */

/* On the plane, beside #agents — so a person's cursor and an agent's cursor
   are the same size at every zoom level, because they are scaled by the same
   thing. z-index sits just above the agents: when both are in the same spot,
   the person is the one you need to see. */
#peers {
  position: absolute;
  inset: 0;
  z-index: 13;
  pointer-events: none;
}

.peer {
  position: absolute;
  top: 0; left: 0;
  /* same box the agent's .body is: no gap, because the tag positions itself
     off the arrow with its own margin, exactly as the agent's does */
  display: flex;
  align-items: flex-start;
  /* No `will-change: transform`, for the reason spelled out at .agent-cursor:
     on the plane it pins the layer's raster to one zoom level and the
     compositor stretches the bitmap at every other one, so the arrow and the
     name beside it go soft the moment the canvas is scaled. */
  /* No transition. A fixed duration restarted by each arriving message is what
     made cursors stutter: the stream is 15Hz on average and irregular in
     practice, so half the moves were cut off early and half arrived late and
     stopped dead. ui/cursors.js eases every cursor toward its target once per
     frame instead, which is the same smoothing without the restarts. */
}

/* .is-agent no longer changes how a cursor moves — every cursor is eased in
   script now — and is kept because it says what a body IS, which the label and
   anything reading the DOM still care about. */

/* Landing on a cursor you asked to be taken to used to draw a ring around it,
   twice, in that peer's colour. It is gone: the scroll stopping on somebody is
   already the answer to where they are, and a mark that appears on a body for
   no reason the body itself gave is the canvas talking over the thing it is
   pointing at. Being taken there is enough. */

/* The arrow is .arrow / .arrow-halo / .arrow-fill, shared verbatim with the
   agents — same 16px, same path, same halo drawn under the fill. Nothing to
   restate here; ui/cursors.js emits the identical markup agents/agent.js does.

   The label is the agent's tag to the pixel: same padding, same 11px, and 5px
   of radius on all four corners. It used to carry a clipped bottom-left — a
   speech-bubble tail pointing back at the arrow — but the arrow is already
   touching it, so the tail pointed at something adjacent, and it made a
   person's label a subtly different object from an agent's when the only real
   difference between the two is colour. */
.peer-name {
  margin: 9px 0 0 -1px;
  max-width: 180px;
  padding: 4px 8px 5px;
  border-radius: 5px;
  background: var(--agent, hsl(var(--hue) 72% 52%));
  color: #fff;
  font: 500 11px/1 var(--mono);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* a cursor holding a pen is doing something different from one that is just
   passing through */
.peer.inking .peer-name::after { content: ' ✎'; }

/* An attached agent speaking swaps its own label to the line, exactly as the
   resident does, and gets the same one-frame pop — the only cue that the pill's
   content changed, since the text swaps with no other motion. A line can be
   longer than a name, so this is the one moment the label may grow. */
.peer.talking .peer-name {
  max-width: 320px;
  animation: pop .16s ease-out;
}

/* What they have picked up, and what they are typing into. Both are the same
   idea — "this is spoken for" — so both are the same mark in their colour,
   and neither is ever filled enough to obscure what is underneath. */
.peer-sel, .peer-edit {
  position: absolute;
  top: 0; left: 0;
  border: 1.5px solid var(--agent, hsl(var(--hue) 72% 52%));
  border-radius: 4px;
  /* No `will-change: transform` — same reason as .peer above. A 1.5px hairline
     is the most sensitive thing on the plane to a stretched raster: pinned, it
     comes back at 200% as a 3px smear with a soft edge on both sides. */
  pointer-events: none;
}
.peer-sel { background: color-mix(in srgb, var(--agent, hsl(var(--hue) 72% 52%)) 7%, transparent); }
.peer-edit {
  border-radius: 8px;
  border-style: dashed;
  background: color-mix(in srgb, var(--agent, hsl(var(--hue) 72% 52%)) 5%, transparent);
  animation: peer-edit-breathe 2.2s ease-in-out infinite;
}
@keyframes peer-edit-breathe {
  0%, 100% { opacity: .55; }
  50%      { opacity: 1; }
}

/* ---- following ----------------------------------------------------------
   Riding along behind somebody's viewport. The border is the whole interface:
   while it is up, the view is not yours, and scrolling anywhere takes it back. */

body.following::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 23;
  border: 2px solid var(--follow-color, hsl(var(--follow-hue, 233) 72% 52%));
  pointer-events: none;
}

#followBar {
  position: fixed;
  top: var(--chrome-top); left: 50%;
  z-index: 24;
  display: flex;
  align-items: center;
  gap: 8px;
  height: 30px;
  padding: 0 6px 0 12px;
  background: var(--agent, hsl(var(--hue) 72% 48%));
  border-radius: 7px;
  color: #fff;
  font: 400 12px/1 var(--ui);
  white-space: nowrap;
  opacity: 0;
  transform: translate(-50%, -8px);
  pointer-events: none;
  transition: opacity .14s ease, transform .18s var(--ease);
}
#followBar.on { opacity: 1; transform: translate(-50%, 0); }
#followBar b { font-weight: 600; }
#followBar kbd {
  padding: 3px 5px;
  border-radius: 4px;
  background: rgb(255 255 255 / .2);
  font: 500 10px/1 var(--ui);
}

/* ---- presence stack -----------------------------------------------------
   Real people now, not a count that was always 1. Each face is a button
   because each face is a place you can go. */

.pr-face {
  border: none;
  padding: 0;
  cursor: pointer;
  /* their cursor colour, so the roster and the canvas agree about who is who */
  background: var(--agent, hsl(var(--hue) 72% 48%));
}
.pr-face.you { background: var(--accent); cursor: default; }
/* An attached agent is a face in this row like anyone else, in its own colour,
   and now lifts like one too. It was flat and unclickable while there was
   nowhere for a click to take you: an agent has no viewport to ride behind. It
   has a position, though, and a click goes to it — so the only face in the row
   that cannot be pressed is your own, which is the only one you are already at. */
.pr-face:not(.you):hover { transform: translateY(-1px); }

/* No ring, in any state. A face became a button when it became somewhere you
   could go, and buttons collect halos: the browser's own on click, and one of
   ours marking who you were following. Neither earns the space on a 21px
   avatar. Following is already said twice and said better — a banner naming
   the person, and a border around the whole viewport — so a third mark here
   only competes with the face it is drawn around. */
.pr-face,
.pr-face:focus,
.pr-face:focus-visible,
.pr-face.following { outline: none; box-shadow: 0 0 0 2px var(--chrome); }

/* the socket is down: the roster is a memory, and says so rather than
   presenting stale faces as if they were live */
#presence.offline { opacity: .45; }
#presence.offline .pr-face { filter: grayscale(1); }

/* ---- the canvas's name --------------------------------------------------
   Top-left. A shared document has to be able to say what it is called, and
   that it is one of several — which is exactly what could never be true when
   the canvas was a key in localStorage. */

/* Flat: a light grey field, no border and no inset shading. The other chrome
   reads as a surface recessed into the canvas; this one is meant to read as a
   label ON it — closer to a filename in a title bar than to a control — so it
   sits back and lets the name be the only thing with any weight.

   --panel, like every other chip that floats over the plane and like every
   fill on it. See the four greys at the top of this file. */
/* The top-left corner: the document's pill, and the button that opens what is
   yours, side by side as two objects rather than one.

   The row is what is pinned. The pill inside it is `relative` rather than fixed
   because the canvases menu hangs off it — see .db-menu, which is absolute
   against exactly this element — and because a fixed child of a fixed row would
   be measuring from the window again, which is the arrangement this replaces.

   In the Mac app the pill is not drawn at all and both of its neighbours have
   been moved into the strip, so this row is left holding nothing and paints
   nothing: it has no background, no padding and no height of its own. */
#topleft {
  position: fixed;
  top: var(--chrome-top); left: var(--chrome-inset);
  z-index: 21;
  display: flex;
  align-items: center;
  gap: 8px;
  transition: transform .18s var(--ease);
}

#docbar {
  position: relative;
  display: flex;
  align-items: center;
  height: 34px;
  padding: 0 11px 0 10px;
  max-width: min(46vw, 460px);
  background: var(--panel);
  border-radius: 7px;
}

/* the one mark between the name and the mode, so they read as two facts about
   the same thing rather than one run-on phrase */
.db-sep-v {
  width: 1px;
  height: 14px;
  flex: none;
  margin: 0 9px 0 5px;
  background: var(--line);
}
/* a divider with nothing after it is just a stray mark — #mode is empty for the
   moment between this bar being built and the mode being set */
#docbar:has(#mode:empty) .db-sep-v { display: none; }

.db-title {
  min-width: 20px;
  max-width: 340px;
  padding: 3px 4px;
  border-radius: 4px;
  color: var(--chrome-ink);
  font: 500 13px/1.2 var(--ui);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  outline: none;
}
#docbar:not(.read-only) .db-title:hover { background: var(--chrome-hover); }
.db-title:focus { background: var(--chrome-sunk); text-overflow: clip; overflow-x: auto; }
#docbar.read-only .db-title { cursor: default; }

.db-menu-btn {
  width: 24px; height: 24px;
  flex: none;
  display: grid;
  place-items: center;
  margin-left: 2px;
  background: none;
  border: none;
  border-radius: 5px;
  color: var(--dim);
  cursor: pointer;
}
.db-menu-btn:hover { background: var(--chrome-hover); color: var(--chrome-ink); }
.db-menu-btn svg { width: 15px; height: 15px; transition: transform .15s var(--ease); }
.db-menu-btn[aria-expanded="true"] svg { transform: rotate(180deg); }

/* Three cards, the way #agentMenu is cards, and for the same reason it gives:
   the split states a difference no heading inside one card can. Everything in
   the top card is about canvases — which ones you have, making another, ending
   this one. The key in the second is about you, and it is the same key
   whichever canvas you are standing on. Filed as one list, "Your AI key" read
   as a fourth thing you could do to this canvas.

   The third is the account: what it is on, what that allows, and how many
   people are under it. Yours is not the word for it — that is the point of
   its own card rather than a rule under the key, which is where it lived
   while it was one line about what you pay.

   So the menu itself carries no chrome: it is the column, and the cards in it
   are the objects. */
.db-menu {
  position: absolute;
  top: 40px; left: 0;
  width: 280px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  opacity: 0;
  transform: translateY(-6px) scale(.97);
  transform-origin: 0 0;
  pointer-events: none;
  transition: opacity .14s ease, transform .18s var(--ease);
}
.db-card {
  padding: 6px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 9px;
  box-shadow: var(--chrome-shadow);
}
/* The list is the only part that can run long, so it is the only part that
   scrolls — capping the column instead would let a stack of canvases push the
   key card off the bottom of the screen. */
.db-card.db-list {
  max-height: min(60vh, 460px);
  overflow-y: auto;
}
.db-menu.on { opacity: 1; transform: none; pointer-events: auto; }

.db-group, .db-loading, .db-guest {
  padding: 7px 8px 5px;
  color: var(--chrome-dim);
  font: 500 11px/1.4 var(--ui);
}
.db-guest { font-weight: 400; }

.db-item, .db-new, .db-delete {
  width: 100%;
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 7px 8px;
  background: none;
  border: none;
  border-radius: 6px;
  color: var(--chrome-ink);
  font: 400 13px/1.3 var(--ui);
  text-align: left;
  cursor: pointer;
}
.db-item:hover, .db-new:hover, .db-delete:hover { background: var(--chrome-hover); }
.db-item.on { background: var(--chrome-sunk); font-weight: 500; }
.db-item-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.db-item-meta { flex: none; color: var(--chrome-dim); font-size: 11px; }
.db-sep { height: 1px; margin: 6px 4px; background: var(--chrome-edge); }
.db-new { font-weight: 500; }
.db-delete { color: #c4483a; }

/* ---- the key, edited in place ----
   No labels and no sentences: the card is titled, and a provider and a field
   under that title do not need to be told apart in words. */
.ak { padding: 2px 4px 4px; }
.ak .dd { width: 100%; margin-bottom: 6px; }

.ak-row { display: flex; gap: 6px; }
.ak-input {
  flex: 1;
  min-width: 0;
  height: 28px;
  padding: 0 9px;
  background: var(--chrome);
  border: none;
  border-radius: 7px;
  outline: none;
  box-shadow: var(--chrome-shadow);
  color: var(--chrome-ink);
  font: 400 12.5px/1 var(--ui);
}
.ak-input::placeholder { color: var(--chrome-dim); }
.ak-input:focus { box-shadow: var(--chrome-shadow), 0 0 0 2px rgb(13 153 255 / .4); }
.ak-input:disabled { opacity: .55; }

.ak-save {
  flex: none;
  height: 28px;
  padding: 0 11px;
  background: var(--sel);
  border: none;
  border-radius: 7px;
  color: #fff;
  font: 500 12.5px/1 var(--ui);
  cursor: pointer;
}
.ak-save:hover:not(:disabled) { filter: brightness(1.06); }
.ak-save:disabled {
  background: var(--chrome-sunk);
  color: var(--chrome-dim);
  cursor: default;
}

/* The address, for the one provider that is an address rather than a company.
   Full width under the key it belongs to, and gone entirely for the other
   three — the attribute alone would do it, since .ak-input never sets display,
   but saying it here keeps that true if it ever does. */
.ak-base { width: 100%; margin-top: 6px; }
.ak-base[hidden] { display: none; }

/* The model, under the key that reaches it. The same dropdown as the provider
   above it, with a hairline of air between them so they read as two questions
   rather than one control that grew. */
.ak .dd.ak-model { margin: 6px 0 0; }
/* No key yet, so nothing to ask what it offers. Dimmed rather than removed:
   the choice is about to exist, and a control that appears after a save is a
   panel that moves under somebody's cursor. */
.ak .dd.ak-off { opacity: .5; }

/* ---- the account: what it is on, and who is on it ----
   Its own card now. The rule that used to sit above this is gone with it: a
   card edge already draws the line a border was standing in for. */
.db-team { padding: 0 4px 2px; }

/* Hidden until /api/me/plan says there is something being sold. The rule is
   its own rather than inherited: .db-card is a flex child of .db-menu, and an
   element that stays laid out is one that keeps taking a gap in the column. */
.db-teamcard[hidden] { display: none; }

.db-planline {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 4px;
}
.db-planname {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--chrome-ink);
  font: 400 12.5px/1.3 var(--ui);
}
/* Quiet on purpose. Nothing here needs pressing today — it is the way out to
   Stripe for the month something does. */
.db-planbtn {
  flex: none;
  height: 24px;
  padding: 0 9px;
  background: none;
  border: 1px solid var(--chrome-edge);
  border-radius: 7px;
  color: var(--chrome-dim);
  font: 500 11.5px/1 var(--ui);
  cursor: pointer;
}
.db-planbtn:hover:not(:disabled) { background: var(--chrome-hover); color: var(--chrome-ink); }
.db-planbtn:disabled { opacity: .55; cursor: default; }
.db-planbtn[hidden] { display: none; }

/* What the plan allows against what the account has spent of it. Three rows,
   label left and count right, because the question they answer is "how much
   room is left" and that is read down the right edge in one go.

   Deliberately not a bar or a meter. Two of these three numbers are capped at
   three and five, and a progress bar drawn over five agents is a graphic
   pretending a number is too big to read. */
.db-use {
  margin-top: 9px;
  padding-top: 8px;
  border-top: 1px solid var(--chrome-edge);
}
.db-useline {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  padding: 3px 0;
  color: var(--chrome-dim);
  font: 400 12px/1.3 var(--ui);
}
.db-useline span:last-child {
  color: var(--chrome-ink);
  font-variant-numeric: tabular-nums;
}

/* one line, and only when there is something to say */
.ak-note {
  margin-top: 6px;
  color: var(--chrome-dim);
  font: 400 11.5px/1.4 var(--ui);
}
.ak-note.bad { color: #c4483a; }

/* ---- share panel, the working version -----------------------------------
   Same Notion shape as the drawing that stood here before it, with the
   difference that every control now does the thing it says. */

#shareMenu { width: 400px; }

.sm-row { align-items: center; }
.sm-note {
  margin: -6px 0 12px;
  color: var(--chrome-dim);
  font: 400 12px/1.4 var(--ui);
}
.sm-note.bad { color: #c4483a; }

/* The invite row is ONE control, not three sitting next to each other. Who you
   are inviting and what you are inviting them to is a single sentence — the
   address and the access are answers to the same question — so they share a
   field, and the field takes focus as a whole. Only the Invite button stands
   apart, because it is the verb. */
.sm-field {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 6px;
  height: 36px;
  padding: 0 5px 0 0;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 8px;
  transition: border-color .12s ease, box-shadow .12s ease;
}
/* The one focus ring left in the app, and it earns it: a text field is the only
   control here you can be *inside*, and the ring says where your typing is
   going. It follows the caret, so it never outlives the action that raised it. */
.sm-field:focus-within {
  border-color: var(--sel);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--sel) 22%, transparent);
}

.sm-head {
  padding: 2px 0 8px;
  color: var(--chrome-dim);
  font: 500 11px/1 var(--ui);
}
.sm-empty { color: var(--chrome-dim); font: 400 12px/1.4 var(--ui); }

.sm-people { max-height: 260px; overflow-y: auto; margin-bottom: 12px; }

.sm-person {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 5px 0;
}
/* ---- dropdown (ui/dropdown.js) -----------------------------------------
   The closed control is a chip; the open menu is the same object as every
   other menu in the app — chrome fill, hairline edge, the same inset shadow,
   rows that light on hover. That agreement is the entire reason this exists
   instead of a <select>, whose open menu belongs to the operating system and
   cannot be reached from here at all. */

.dd { position: relative; flex: none; display: flex; }

/* A choice that IS its row, rather than one sitting at the end of one: full
   width, icon and label hard left, chevron hard right.
   Scoped to `.dd` deliberately. The variant class goes on the menu as well as
   on the control — that is how the menu picks up the matching row size — and an
   unscoped `width: 100%` there resolved against <body>, because the menu is
   parented to <body>, so the list opened as wide as the window. */
.dd.dd-wide { flex: 1; width: 100%; }
.dd.dd-wide .dd-btn {
  width: 100%;
  max-width: none;
  height: 38px;
  padding: 0 11px;
  border-radius: 8px;
  font-size: 13px;
}
.dd.dd-wide .dd-chev { margin-left: auto; }
/* rows sized to match the taller control they drop out of */
.dd-menu.dd-wide .dd-opt { height: 34px; font-size: 13px; }
/* Unless it was also asked to be small, which is not a contradiction: wide is a
   fact about the anchor a menu came from and small is a fact about the control
   it belongs to, and a menu can be both. This rule exists because the wide one
   above carries a height with it — three classes to the small rule's two, so it
   won — and a list of four words opened from a 22px clock came out with 34px
   rows, half again as tall as every other menu in the app. */
.dd-menu.dd-small.dd-wide .dd-opt { height: 28px; font-size: 12.5px; }
.dd-icon {
  flex: none;
  display: grid;
  place-items: center;
  margin-right: 8px;
  color: var(--chrome-dim);
}
/* A dropdown with nothing to put in the icon slot hides it, and `display: grid`
   above was quietly beating the browser's own rule for [hidden]: the slot went
   on holding its 8px in front of the label. Any control that never carries an
   icon, like the role beside the agents, was sitting a space to the right of
   where it looked like it should. */
.dd-icon[hidden] { display: none; }
.dd-icon svg { width: 16px; height: 16px; display: block; }
.dd-btn .dd-icon { color: var(--chrome-ink); }
/* The open globe, in the same blue as Invite — the two places this panel says
   "someone new can get in". Stated at this weight so it wins over the button
   and menu rules above rather than depending on source order. */
.dd-btn .dd-icon.dd-icon-open,
.dd-menu .dd-icon.dd-icon-open,
.sm-linkflat .dd-icon.dd-icon-open { color: var(--sel); }

/* One shape for every control in this app.
   A hairline, a 6px corner, a chrome fill, a quiet hover, and 500 at twelve
   pixels. The dropdown's closed chip is where it was first written down and the
   buttons on the private list are the same object, so they are one rule with
   two names rather than two rules that happen to agree this week. Everything
   that differs between them — how tall, how much padding, how wide it may get —
   is below, where it belongs. */
/* `display: flex` below beats the browser's own rule for [hidden], so anything
   toggled that way needs saying twice. It is the same trap .dd-icon, .react-icon
   and .section-find each fell into, and this time it left a clock chip standing
   beside the schedule pill that had replaced it — the row stating the same fact
   twice, once in a control that no longer did anything. */
.dd-btn[hidden], .tl-ctl[hidden] { display: none; }

.dd-btn, .tl-ctl {
  display: flex;
  align-items: center;
  gap: 3px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 6px;
  color: var(--chrome-ink);
  font: 500 12px/1 var(--ui);
  cursor: pointer;
}
.dd-btn { height: 32px; max-width: 150px; padding: 0 7px 0 10px; }
.dd-small .dd-btn { height: 26px; padding: 0 5px 0 9px; font-size: 11.5px; }
.dd-btn:hover, .tl-ctl:hover { background: var(--chrome-hover); }
.dd-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-chev {
  width: 14px; height: 14px;
  flex: none;
  color: var(--chrome-dim);
  transition: transform .15s var(--ease);
}
.dd-btn[aria-expanded="true"] .dd-chev { transform: rotate(180deg); }

/* On <body> rather than inside the panel that opened it: the member list
   scrolls, and a menu parented to a scrolling box is clipped by it. Placed
   against its button each time it opens — see place() in ui/dropdown.js.

   Which is exactly why it has to outrank every panel it can be opened from.
   Parented to <body>, it no longer inherits its opener's stacking context, so
   its z-index competes with theirs directly: at 40 it sat above the share
   panel (22) and below the first run (70), where it opened correctly and
   invisibly, underneath the card that asked for it. 80 cleared all of them and
   then the Mac app's canvases panel arrived at 96 — opaque, full height, and
   holding the provider picker for your AI key, whose list opened behind the
   panel it was opened from. Exactly the same failure as the share panel, one
   layer up. 97 clears that too, and still leaves #authGate (100), the drag
   band (101) and the strip (102) above it, which is right: a session that has
   ended, and the window's own chrome, outrank any menu that was open. */
.dd-menu {
  position: fixed;
  z-index: 97;
  max-height: min(56vh, 340px);
  overflow-y: auto;
  padding: 5px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 9px;
  box-shadow: var(--chrome-shadow);
  opacity: 0;
  transform: translateY(-4px) scale(.98);
  transition: opacity .12s ease, transform .16s var(--ease);
}
.dd-menu.on { opacity: 1; transform: none; }

.dd-opt {
  display: flex;
  align-items: center;
  gap: 10px;
  height: 30px;
  padding: 0 8px;
  border-radius: 6px;
  color: var(--chrome-ink);
  font: 400 13px/1 var(--ui);
  cursor: pointer;
  user-select: none;
}
.dd-small .dd-opt { height: 28px; font-size: 12.5px; }
.dd-opt-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* one highlight, driven by the keyboard cursor — the pointer moves that cursor
   rather than lighting a second row, so there is never a "which one will Enter
   take?" question on screen */
.dd-opt.cursor { background: var(--chrome-hover); }
/* The chosen one, now that there is no tick beside it. Weight rather than a
   mark: it is the row you last picked, not a row that needs pointing at, and a
   list where one line is bolder than the others says which without spending a
   column on it. */
.dd-opt.on { font-weight: 500; }
.dd-opt.danger { color: #c4483a; }
.dd-sep { height: 1px; margin: 5px 3px; background: var(--chrome-edge); }
.sm-who { flex: 1; min-width: 0; }
.sm-name {
  display: flex;
  align-items: center;
  gap: 6px;
  font: 500 13px/1.3 var(--ui);
  color: var(--chrome-ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.sm-tag { color: var(--chrome-dim); font-weight: 400; }
.sm-sub {
  font: 400 12px/1.3 var(--ui);
  color: var(--chrome-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.sm-role-flat { flex: none; color: var(--chrome-dim); font: 400 12px/1 var(--ui); }

.sm-person.pending .sm-name { color: var(--chrome-dim); font-weight: 400; }
/* the fill is shared with the other status badges, further down */
.sm-envelope { color: var(--chrome-dim); }
.sm-envelope svg { width: 15px; height: 15px; }
.sm-revoke {
  flex: none;
  height: 26px;
  padding: 0 9px;
  background: none;
  border: 1px solid var(--chrome-edge);
  border-radius: 5px;
  color: var(--chrome-dim);
  font: 400 11px/1 var(--ui);
  cursor: pointer;
}
.sm-revoke:hover { color: #c4483a; border-color: #c4483a; }

/* The last row of the panel: who the link lets in, and the link itself. They
   were stacked, with the control spanning the full width and Copy link alone
   underneath — two rows to say one thing about one link. Side by side, the
   setting and the thing it governs are read together.
   No rules above or below either: the control is already a bordered box sitting
   in a gap, and drawing lines around a gap that was doing the work says it
   twice. Space separates these sections. */
.sm-link {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 4px 0 2px;
}
/* takes what is left after Copy link, which keeps its natural width */
.sm-linkctl { flex: 1; min-width: 0; display: flex; }
/* The pending-invite badge. Lit from the same direction --chrome-shadow's light
   already comes from, so it belongs to the same room as the panel around it:
   brighter at the top-left, settling into its fill by the bottom, with a
   hairline inside the edge to give it a rim. Mixed from a token rather than
   picked, so dark mode needs no second rule.
   The lock and globe used to be badges like this too. They are icons inside the
   link control now — a status that a control already states in words does not
   also need a disc of its own. */
.sm-envelope {
  background:
    radial-gradient(120% 100% at 30% 0%,
      color-mix(in srgb, var(--chrome-ink) 3%, transparent) 0%,
      transparent 60%),
    linear-gradient(160deg,
      color-mix(in srgb, var(--chrome-ink) 4%, transparent) 0%,
      color-mix(in srgb, var(--chrome-ink) 12%, transparent) 100%);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--chrome-ink) 5%, transparent);
}

/* what a viewer sees in place of the control: the same row, without the offer */
.sm-linkflat {
  flex: 1;
  display: flex;
  align-items: center;
  height: 38px;
  padding: 0 11px;
  border: 1px solid var(--chrome-edge);
  border-radius: 8px;
  color: var(--chrome-dim);
  font: 400 13px/1 var(--ui);
}

/* someone who can only read has nothing to type into and nobody to invite */
#shareMenu:not(.managed) .sm-row { display: none; }

/* ---- bring your own agent (ui/agents.js) --------------------------------
   The same object as the share panel, deliberately: same chrome, same edge,
   same shadow, same rise on open, and the member rows are literally the share
   panel's classes. It is answering the share panel's question about a peer who
   is not a person, and drawing it as a different kind of surface would say they
   were a different kind of thing.

   It is opened from the presence stack, but it hangs where the other three
   panels hang: under the whole bar, at the whole bar's width. */

/* Two surfaces on a stack rather than one panel with a rule through it. The
   offer and the roster are answers to different questions, and a gap between
   two cards says that more plainly than any heading inside one card can. So the
   menu itself carries no chrome at all: it is the column, and the cards in it
   are the objects. */
#agentMenu {
  position: fixed;
  /* Width, left and top are all measured when it opens — see
     client/ui/underbar.js. The four panels that hang off this bar share one
     geometry, and it is the bar's own run, which changes every time somebody
     joins the canvas. Any constant here would be right on one screen. */
  z-index: 22;
  display: flex;
  flex-direction: column;
  gap: 8px;
  opacity: 0;
  transform: translateY(-6px) scale(.97);
  transform-origin: 0 0;
  pointer-events: none;
  transition: opacity .14s ease, transform .18s var(--ease);
}
#agentMenu.on { opacity: 1; transform: none; pointer-events: auto; }

/* what the share panel is, twice */
.ag-card,
#agentMenu .ag-list {
  padding: 12px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 10px;
  box-shadow: var(--chrome-shadow);
}

/* There was a sentence here saying what connecting an agent gets you. The three
   marks say it faster: you know what Claude Code is, and the row of them is
   already the offer. Everything else this panel could explain is said inside the
   prompt, to the reader who actually needs it.

   the question and the answer to what the agent may do, on one line */
.ag-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 8px;
}
.ag-row .ag-head { padding: 0; }

/* The verb of the panel, and the whole feature. Three agents, named and marked,
   because the failure this replaces was never the pasting: it was a person
   deciding which third of a paragraph was addressed to them. Picking your own
   agent off a row of marks is a thing nobody gets wrong. */
.ag-picks {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
  margin-bottom: 10px;
}

.ag-pick {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 12px 6px 10px;
  background: var(--chrome-sunk);
  border: 1px solid var(--chrome-edge);
  border-radius: 10px;
  cursor: pointer;
  transition: border-color .12s ease, transform .1s var(--ease);
}
.ag-pick:hover:not(:disabled) {
  border-color: color-mix(in srgb, var(--chrome-ink) 20%, transparent);
}
.ag-pick:active:not(:disabled) { transform: translateY(1px); }
.ag-pick:disabled { opacity: .5; cursor: default; }

/* The marks come with their own white ground, so they are treated as app icons
   rather than as glyphs: rounded like one, with a hairline so the white has an
   edge in either theme. */
.ag-pick img {
  border-radius: 7px;
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--chrome-ink) 10%, transparent);
}
.ag-pick-name {
  color: var(--chrome-ink);
  font: 500 12px/1 var(--ui);
  white-space: nowrap;
}
.ag-pick-do { color: var(--chrome-dim); font: 400 11px/1 var(--ui); }

/* what it looks like the moment after it worked, in the app's own blue */
.ag-pick.done { border-color: var(--sel); }
.ag-pick.done .ag-pick-do { color: var(--sel); }

/* Everything that is not one of those three, which is a row you open rather than
   a fourth tile: a tile saying "other" beside three that say who they are reads
   as a fourth product. Shut by default, because opening it answers a question
   most people do not have, and the ones who do have it will read a sentence
   before they press anything. */
.ag-else { margin-bottom: 0; }

.ag-else-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  height: 34px;
  padding: 0 11px;
  background: none;
  border: 1px solid var(--chrome-edge);
  border-radius: 8px;
  color: var(--chrome-ink);
  font: 500 12.5px/1 var(--ui);
  text-align: left;
  cursor: pointer;
}
.ag-else-head:hover { background: var(--chrome-hover); }
.ag-else-head svg {
  width: 15px;
  height: 15px;
  flex: none;
  color: var(--chrome-dim);
  transition: transform .15s var(--ease);
}
.ag-else-head[aria-expanded="true"] svg { transform: rotate(180deg); }

.ag-else-body {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 6px;
  padding: 10px 11px;
  border: 1px solid var(--chrome-edge);
  border-radius: 8px;
}
/* `display: flex` above would otherwise beat the browser's rule for [hidden],
   the same way .dd-icon did, and the shut disclosure would not be shut. */
.ag-else-body[hidden] { display: none; }

/* A size down from the row that opens it: this is the instruction under the
   offer, not the offer, and it also buys the button beside it some room. */
.ag-else-say {
  flex: 1;
  min-width: 0;
  color: var(--chrome-dim);
  font: 400 11.5px/1.45 var(--ui);
}

.ag-other {
  flex: none;
  display: flex;
  align-items: center;
  gap: 7px;
  height: 32px;
  padding: 0 12px;
  background: var(--sel);
  border: none;
  border-radius: 8px;
  color: #fff;
  font: 500 12.5px/1 var(--ui);
  cursor: pointer;
  white-space: nowrap;
}
.ag-other:hover:not(:disabled) { filter: brightness(1.06); }
.ag-other:disabled { background: var(--chrome-sunk); color: var(--chrome-dim); cursor: default; }
.ag-other svg { width: 15px; height: 15px; flex: none; }

/* Where the agents on this Mac are working, and how to work somewhere else.
   One line under the tiles rather than a section: it is a fact about them, not
   a setting of its own, and the only time it is worth reading is the moment you
   notice an agent is looking in the wrong place. */
.ag-where {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin: -2px 0 10px;
  font: 400 12px/1.4 var(--ui);
  color: var(--chrome-dim);
}
.ag-where-say {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ag-where-do {
  flex: none;
  border: 0;
  padding: 0;
  background: none;
  color: var(--chrome-ink);
  font: 500 12px/1.4 var(--ui);
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 2px;
}
.ag-where-do:hover { color: var(--accent); }
/* Same rule as .ag-note below, for the same reason: `display` set above beats
   the browser default for [hidden], and what is left is an invisible strip
   across the panel catching clicks meant for the tiles. */
.ag-where[hidden] { display: none; }

/* what just happened, directly under the tile it happened to */
.ag-note {
  margin: -2px 0 10px;
  color: var(--chrome-dim);
  font: 400 12px/1.4 var(--ui);
}
.ag-note.bad { color: #c4483a; }
/* Toggled with the `hidden` attribute rather than a class, so it needs a rule
   that actually removes it. A `display` set above without one wins over the
   browser default and leaves an invisible strip sitting across the panel,
   catching the clicks meant for the tiles above it. */
.ag-note[hidden] { display: none; }

/* An agent that is installed and signed in has nothing left to connect, so its
   tile stops offering an action and starts naming the thing you type. The tag
   is set in the accent colour rather than the dim one every other tile uses:
   it is the one word on this panel that does something. */
.ag-pick[data-state="ready"] .ag-pick-do {
  color: var(--sel);
  font-weight: 500;
}
.ag-pick[data-state="ready"] { border-color: color-mix(in srgb, var(--sel) 35%, transparent); }
/* Installed, nobody signed in. The one state that still asks for something. */
.ag-pick[data-state="signin"] .ag-pick-do { color: var(--chrome-fg); }

.ag-head {
  padding: 2px 0 8px;
  color: var(--chrome-dim);
  font: 500 11px/1 var(--ui);
}
.ag-empty { color: var(--chrome-dim); font: 400 12px/1.4 var(--ui); }

/* Who is on this canvas: a card of its own below the offer, carrying the chrome
   the offer carries. Not a grey well inside it, which would have said the agents
   were sunk into the panel and slightly further away, and not a hairline box
   either, which was still a section of somebody else's panel. There is nothing
   here at all until an agent has been connected. */
.ag-list {
  max-height: 240px;
  overflow-y: auto;
}

.ag-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 5px 0;
}
/* a key copied and not yet pasted, drawn exactly as a pending email invite is */
.ag-item.waiting .sm-name { color: var(--chrome-dim); font-weight: 400; }

/* A key that has been copied and not yet pasted is not a person-shaped thing
   waiting in a disc — nobody is there yet. It is one open question, so it gets a
   loader and no badge: the disc said "here is an agent" a beat before there was
   one. It keeps the avatar's 30px so the name lines up with the rows below,
   where somebody real does have a face. */
.ag-wait {
  width: 30px; height: 30px;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
}
.ag-spin {
  width: 15px; height: 15px;
  border: 1.75px solid var(--chrome-edge);
  /* one lit arc against the ring, which is what makes the rotation readable */
  border-top-color: var(--chrome-dim);
  border-radius: 50%;
  animation: agspin .7s linear infinite;
}
@keyframes agspin { to { transform: rotate(360deg); } }

/* An agent that is on the canvas right now wears the same halo a working agent
   wears in the presence stack, in its own colour, for the same reason. */
.ag-item.here .sm-avatar {
  box-shadow: 0 0 0 2.5px color-mix(in srgb, var(--agent, hsl(var(--hue) 58% 46%)) 34%, transparent);
}

/* A viewer can see which agents are here and change nothing about them, so the
   whole offer goes rather than emptying out and leaving its card standing. */
#agentMenu:not(.managed) .ag-card { display: none; }

/* ---- integrations -------------------------------------------------------
   A settings list, not a gallery. Ten apps you switch on is the same object as
   every connectors page anyone has used, and the grid of tinted tiles that
   stood here first was reading as ten things to admire rather than ten things
   to turn on. So: one row each, a hairline between, the verb on the right where
   the eye ends up, and no fill on a row until you are pointing at it.

   Same rise-and-settle as #agentMenu and #shareMenu, from the left edge because
   every panel that spans the bar rises from the corner it starts at. */

#intMenu {
  position: fixed;
  /* Width, left and top are all measured when it opens — see
     client/ui/underbar.js. The four panels that hang off this bar share one
     geometry, and it is the bar's own run, which changes every time somebody
     joins the canvas. Any constant here would be right on one screen. */
  z-index: 22;
  opacity: 0;
  transform: translateY(-6px) scale(.97);
  transform-origin: 0 0;
  pointer-events: none;
  transition: opacity .14s ease, transform .18s var(--ease);
}
#intMenu.on { opacity: 1; transform: none; pointer-events: auto; }

/* Placed from the chip's own rect when it opens, not from an offset written
   here. The chip is the first thing in a bar pinned to the right edge, so its
   left position is everything to the right of it — presence and share — and
   presence changes width every time somebody joins. Anything hardcoded here
   would be right until the second person arrived. */

.int-card {
  padding: 0;
  overflow: hidden;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 12px;
  box-shadow: var(--chrome-shadow);
  /* As tall as what is in it, and never taller than the room under the bar.
     --room is measured when the panel opens — see client/ui/underbar.js — and
     the column is what lets the note keep its line while the list gives up the
     rest. */
  display: flex;
  flex-direction: column;
  max-height: var(--room, 76vh);
}

/* Every app, all at once. It was six rows and then a scroll, which was a fair
   trade while the panel was thought of as a glance — but the question it
   answers is "what can my agents reach", and a list that hides four of its
   answers behind a wheel gesture answers it wrongly to anyone who does not
   make the gesture. There are ten apps; a row is 44px; the whole roster is
   shorter than the canvas is tall, so all of it fits and none of it needs
   finding.

   The scroll stays as a floor rather than as a design: --room is the space
   under the bar, and in a window too short to hold the list the card gives up
   the difference here instead of running off the bottom of the screen.

   No scrollbar, in either engine, in the case where it does scroll — the row
   cut off by the bottom edge is what says there is more, and it says it
   without a stripe of chrome down the side of a card that is otherwise nothing
   but apps, their names, and the one button you press. */
.int-list {
  min-height: 0;
  padding: 5px 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: none;
}
.int-list::-webkit-scrollbar { width: 0; height: 0; }

.int-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 14px;
}
/* Between rows and never above the first or below the last, which is what a
   border on the row itself would give. */
.int-row + .int-row { box-shadow: inset 0 1px 0 var(--chrome-edge); }

/* An app icon: its own ground, a hairline so artwork that arrives on white has
   an edge in either theme, and grey until the artwork lands. Deliberately a
   flat tile rather than an initial in a circle — a placeholder that admits it
   is one reads as unfinished, where a "G" reads as a decision somebody made.
   See client/logos/README.md. */
/* The tile, and the artwork ON the tile rather than inside it.

   background-color rather than the shorthand, because the row writes the image
   as an inline style and a shorthand here would be a rule about the image too —
   a fight between the two that the sheet would sometimes win. Colour here, image
   there, no overlap.

   A background is also the only shape in which a missing logo is genuinely
   invisible. An <img> that 404s, or whose request is cancelled by the page
   navigating away, paints the browser's broken-image glyph, and no error handler
   can run before that happens. This just does not draw. */
.int-logo {
  flex: none;
  width: 28px; height: 28px;
  display: block;
  overflow: hidden;
  border-radius: 8px;
  background-color: color-mix(in srgb, var(--chrome-ink) 12%, transparent);
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--chrome-ink) 10%, transparent);
}

.int-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--chrome-ink);
  font: 450 13px/1.3 var(--ui);
}

/* The verb, on the right, and the only thing in the row that does anything.
   It used to be quiet until you were anywhere on the row, which drew a hover
   fill across the whole width and promised that clicking the name would
   connect the app. It does not, and never did. So the button looks like a
   button at rest and the row does not react to being pointed at.

   Barely rounded, on the same reasoning the chips in the top bar are: a full
   pill in a list of square-shouldered rows reads as a tag rather than a
   control. */
.int-do {
  flex: none;
  height: 24px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  /* One width for all three words, so the column lines up and, more to the
     point, so a button does not resize under the pointer the moment
     "Connected" becomes "Disconnect". */
  min-width: 92px;
  padding: 0 9px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 5px;
  color: var(--chrome-ink);
  font: 500 11.5px/1 var(--ui);
  cursor: pointer;
  transition: background .12s ease, border-color .12s ease, color .12s ease;
}
/* Sized off the word beside it rather than off the 24px box the icon is drawn
   in, so the mark reads as part of the label instead of as a second control. */
.int-do svg { width: 12px; height: 12px; flex: none; color: var(--chrome-dim); }
/* Waiting for the connect link, drawn with the agents panel's loader at the
   size the plus it replaces was drawn at, so the button neither grows nor goes
   blank while the request is out. */
.int-do .ag-spin { width: 12px; height: 12px; border-width: 1.5px; }
.int-do:hover { background: var(--chrome-hover); }
.int-do:hover svg { color: var(--chrome-ink); }
.int-do:active { transform: translateY(1px); }
.int-do:disabled { opacity: .55; cursor: default; }
.int-do:focus, .int-do:focus-visible { outline: none; }

/* Connected states what is true, in the selection blue, and keeps its border in
   that blue rather than dropping it. Borderless read as a label that had
   wandered into the button column and left the row looking unfinished beside
   the nine outlines above and below it; an edge in the same colour as the word
   says the state without pretending the control is gone. Hovering turns it into
   the undo, in the same place, so a row never shows two verbs at once. */
.int-row.on .int-do {
  border-color: color-mix(in srgb, var(--sel) 45%, transparent);
  color: var(--sel);
}
.int-row.on .int-do:hover {
  background: var(--chrome);
  border-color: color-mix(in srgb, #c4483a 35%, transparent);
  color: #c4483a;
}
/* The word is swapped rather than the button rebuilt, so the change costs
   nothing and cannot land a frame late. Blanking the span rather than the
   button is what keeps this from also blanking an icon, which is why the label
   has a span of its own at all. */
.int-row.on .int-do:hover .int-word { font-size: 0; }
.int-row.on .int-do:hover .int-word::after {
  content: 'Disconnect';
  font: 500 11.5px/1 var(--ui);
}

.int-note {
  /* the line keeps its height in a short window; the list above gives up the
     difference, because a sentence squashed to two thirds of itself is unread
     and a list one row shorter is not */
  flex: none;
  padding: 9px 14px 11px;
  border-top: 1px solid var(--chrome-edge);
  color: var(--chrome-dim);
  font: 400 11.5px/1.45 var(--ui);
}
.int-note.bad { color: #c4483a; }
.int-note[hidden] { display: none; }

/* A row before there is anything to put in it. The tile is already the grey
   the logo tile is while artwork is in flight, so only the name needs drawing:
   a bar of the same grey, at the height of the line it stands in for. The row
   keeps its exact 44px because that height comes off the 28px tile and the
   padding around it, neither of which is missing here.

   No pulse. It is on screen for as long as one request takes and a shimmer
   would be the most animated thing in the panel, drawing the eye to the one
   part that has nothing to say. */
.int-row.wait .int-name {
  max-width: 104px;
  height: 9px;
  border-radius: 3px;
  background: color-mix(in srgb, var(--chrome-ink) 12%, transparent);
}

/* ---- what a role can see ------------------------------------------------ */

/* A viewer's canvas is a canvas, not a disabled form: the controls that would
   do nothing are gone rather than greyed, and the plane keeps its normal
   cursor because pointing at things still works. */
body[data-role="viewer"] .react-tools,
body[data-role="viewer"] .react-reply,
body[data-role="viewer"] .section-del { display: none; }
body[data-role="commenter"] .section-del { display: none; }
/* an edge you may not move should not offer a resize cursor either */
body[data-role="viewer"] .section-grip,
body[data-role="commenter"] .section-grip { display: none; }
body[data-role="viewer"] .react-pill { pointer-events: none; }

/* Someone else's note, on a canvas with several people on it. No permanent
   label — that would turn a plane of thoughts into a comment thread — but
   hovering says whose it is, which is the only time you ever ask. */
.block.other:hover::after {
  content: attr(data-author);
  position: absolute;
  left: 0;
  bottom: 100%;
  margin-bottom: 3px;
  padding: 2px 6px;
  border-radius: 4px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  color: var(--chrome-dim);
  font: 400 10px/1.4 var(--ui);
  white-space: nowrap;
  pointer-events: none;
}

/* A link visitor's corner: who they are on this canvas, and the one thing they
   don't have yet. */
#guestChip {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 34px;
  padding: 0 4px 0 4px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 7px;
  box-shadow: var(--chrome-shadow);
}
.gc-face {
  width: 26px; height: 26px;
  flex: none;
  display: grid;
  place-items: center;
  border-radius: 5px;
  background: var(--agent, hsl(var(--hue) 72% 48%));
  color: #fff;
  font: 600 11px/1 var(--ui);
  overflow: hidden;
}
.gc-face:has(.avatar-img) { background: none; }
.gc-signup {
  height: 26px;
  padding: 0 10px;
  background: none;
  border: none;
  border-radius: 5px;
  color: var(--chrome-ink);
  font: 500 12px/1 var(--ui);
  cursor: pointer;
}
.gc-signup:hover { background: var(--chrome-hover); }

/* No focus ring on any of the chrome — not the browser's blue one, and not a
   drawn-in replacement either. A hard outline around a control you just clicked
   is a mark that outlives the action that caused it, and on floating chrome
   over a canvas it reads as state rather than as focus.
   Hover already says what is under the pointer, and every one of these controls
   changes its own fill when it does. Text fields are the exception: a caret is
   the indicator there, and it is the browser's to draw. */
.dd-btn:focus, .sm-invite:focus, .sm-revoke:focus,
.sm-copy:focus, .db-menu-btn:focus, .db-item:focus, .db-new:focus,
.db-delete:focus, .gc-signup:focus, .db-title:focus, #shareBtn:focus,
.dd-btn:focus-visible, .sm-invite:focus-visible, .sm-revoke:focus-visible,
.sm-copy:focus-visible, .db-menu-btn:focus-visible, .db-item:focus-visible,
.db-new:focus-visible, .db-delete:focus-visible, .gc-signup:focus-visible,
#shareBtn:focus-visible { outline: none; }

/* ---- confirm dialog (ui/confirm.js) --------------------------------------
   Replaces window.confirm(), which opened bolted to the top of the window with
   "app.getsoffo.com says" over the question and an "Okay" button that named no
   action. Above the dropdown menus at 40 and below the auth gate at 100 — this
   asks about the canvas, the gate asks whether there is one to ask about. */

#confirmLayer {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: grid;
  place-items: center;
}
/* `display: grid` above beats the browser's own rule for [hidden], and a
   dismissed dialog is still a full-viewport box over the canvas: invisible at
   opacity 0, and eating every click that lands on it. */
#confirmLayer[hidden] { display: none; }

.cf-scrim {
  position: absolute;
  inset: 0;
  background: rgb(26 29 33 / .38);
  opacity: 0;
  transition: opacity .16s ease;
}
:root[data-theme="dark"] .cf-scrim { background: rgb(0 0 0 / .55); }
#confirmLayer.on .cf-scrim { opacity: 1; }

/* No drop shadow: the scrim is what lifts this off the canvas, and every other
   panel in this app is a surface recessed into the page rather than a card
   floating over it. Same inset edge as the rest, one step larger. */
.cf-card {
  position: relative;
  width: min(380px, calc(100vw - 32px));
  padding: 20px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 12px;
  box-shadow: var(--chrome-shadow);
  opacity: 0;
  transform: translateY(6px) scale(.97);
  transition: opacity .16s ease, transform .2s var(--ease);
}
#confirmLayer.on .cf-card { opacity: 1; transform: none; }

.cf-title {
  margin-bottom: 7px;
  color: var(--chrome-ink);
  font: 600 15px/1.35 var(--ui);
}
.cf-body {
  margin-bottom: 18px;
  color: var(--chrome-dim);
  font: 400 13px/1.5 var(--ui);
}

.cf-actions { display: flex; justify-content: flex-end; gap: 8px; }
.cf-actions button {
  height: 34px;
  padding: 0 14px;
  border-radius: 8px;
  border: 1px solid transparent;
  font: 500 13px/1 var(--ui);
  cursor: pointer;
}
.cf-cancel {
  background: none;
  border-color: var(--chrome-edge);
  color: var(--chrome-ink);
}
.cf-cancel:hover { background: var(--chrome-hover); }
/* the safe answer holds focus on open, so it is the one that shows a ring */
.cf-cancel:focus-visible { border-color: var(--chrome-ink); }

.cf-go { background: var(--chrome-ink); color: var(--chrome); }
/* the dangerous choice is the one that looks dangerous — a destructive action
   behind a neutral button is the thing "Okay" got wrong */
.cf-go.danger { background: #c4483a; color: #fff; }
.cf-go:hover { filter: brightness(1.08); }
.cf-go:focus-visible { box-shadow: 0 0 0 2px var(--chrome), 0 0 0 4px currentColor; }

@media (prefers-reduced-motion: reduce) {
  .cf-scrim, .cf-card { transition: none; }
}

/* ---- the first run ------------------------------------------------------
   Two questions, asked once: what this canvas is for, and whose key its
   agents spend. Built out of the same surface every other panel here is made
   of — one recessed card, one inset edge, no floating shadow — so the first
   thing anyone sees of this app is already the app rather than a signup flow
   wearing its colours.

   Above the canvas and its chrome, below #authGate (100): a session expiring
   mid-onboarding is still the more urgent wall of the two. */

#onboard {
  position: fixed;
  inset: 0;
  z-index: 70;
  display: grid;
  place-items: center;
  padding: 20px;
}
/* Same trap as #confirmLayer above, and it matters more here because this one
   opens for everybody on their first visit: `display: grid` outranks the
   browser's [hidden], so a finished first run left the whole canvas
   unclickable behind a pane nobody could see. */
#onboard[hidden] { display: none; }

/* The first run is white, in both themes.
   It is the one screen in this app that is not the app: nobody has a canvas
   yet, nothing is themed because there is nothing to theme, and the two
   drawings on it are ink on white by nature. A dark first run would be the
   product's least familiar screen wearing its least familiar surface.

   Every token the card's children read is re-pointed to its light value here
   rather than each rule being rewritten, because they were all written against
   a light panel and half of them invert in the dark theme: --chrome-ink alone
   would put near-white text on white. Scoped to this layer, so nothing outside it
   is affected and the rest of the app stays in whatever theme it was in. */
#onboard {
  --bg: #fff;
  --ink: #1a1d21;
  --dim: #6e7683;
  --line: #dde2e9;
  --panel: #eef1f5;
  --chrome: #fff;
  --chrome-edge: rgb(26 29 33 / .09);
  --chrome-hover: rgb(26 29 33 / .055);
  --chrome-sunk: rgb(26 29 33 / .05);
  --chrome-shadow: inset 0 2px 4px rgb(26 29 33 / .13), inset 0 0 0 1px rgb(26 29 33 / .07);
  --chrome-ink: #1a1d21;
  --logo-ink: #6e7683;
  --chrome-dim: #6e7683;
}
/* The two dark-theme rules that are not expressed as tokens and so cannot be
   re-pointed above. The artwork one matters most: inverting ink-on-white
   drawings and screening them onto white leaves a blank rectangle. */
:root[data-theme="dark"] #onboard .ob-art img { filter: none; mix-blend-mode: multiply; }
:root[data-theme="dark"] #onboard .ob-choice.picked { background: rgb(13 153 255 / .08); }
:root[data-theme="dark"] #onboard .ob-choice.picked:hover { background: rgb(13 153 255 / .12); }

/* The first run owns the screen.
   It was a card on a scrim, which is the right shape for a question you can
   decline and the wrong one for the only questions the canvas cannot work
   without. A dimmed plane behind a dialog reads as "your work is back there,
   just past this" — so people look past it, hunt for the way out, and answer
   the questions badly on the way to somewhere else. There is nothing behind
   this. It is the page. */
.ob-scrim {
  position: absolute;
  inset: 0;
  background: var(--bg);
  opacity: 0;
  transition: opacity .2s ease;
}
#onboard.on .ob-scrim { opacity: 1; }

/* No border, no shadow, no corner: the surface IS the page, so a frame around
   the content would be drawing a card on a background of exactly its own
   colour. The width cap stays, because a line of prose ten inches long is
   unreadable however much room there is for it, and the block stays centred so
   a wide monitor does not leave it in a corner. */
.ob-card {
  position: relative;
  width: min(560px, 100%);
  /* Of the padded area, not of the viewport: #onboard carries 20px of padding
     and centres this inside it, so 100vh would be forty pixels taller than the
     room it is being given and a long step would run off the bottom. */
  max-height: 100%;
  overflow-y: auto;
  padding: 26px 26px 22px;
  background: none;
  opacity: 0;
  /* A rise rather than a scale. Scaling up is a thing arriving on top of
     something; this arrives instead of it. */
  transform: translateY(10px);
  transition: opacity .22s ease, transform .3s var(--ease);
}
#onboard.on .ob-card { opacity: 1; transform: none; }

/* Every step, left-aligned, one size.
   These are titles rather than field labels, and they sit right on top of
   their own subtitle: the two lines are one block of address, not two rows of
   a panel, which is what the 3px gap is for. Left because everything under
   every heading here — a pair of tiles, a field, a list of invitees — starts
   at the same left edge, and a centred heading only lines up with its content
   on the one screen whose content happens to be symmetrical. */
.ob-title {
  margin-bottom: 3px;
  color: var(--chrome-ink);
  font: 600 24px/1.2 var(--ui);
  letter-spacing: -.01em;
}
.ob-sub {
  margin-bottom: 18px;
  color: var(--chrome-dim);
  font: 400 13px/1.55 var(--ui);
}
.ob-sub.bad { color: #c4483a; }

/* ---- what is this canvas for ---- */

.ob-choices { display: flex; gap: 12px; }

.ob-choice {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 3px;
  padding: 14px 14px 16px;
  background: none;
  border: 1px solid var(--chrome-edge);
  border-radius: 11px;
  text-align: left;
  cursor: pointer;
  transition: border-color .16s ease, background .16s ease, transform .16s var(--ease);
}
.ob-choice:hover { background: var(--chrome-hover); border-color: var(--chrome-dim); }
.ob-choice:active { transform: scale(.985); }
/* A standing answer, not a flash before the screen changes: it holds until
   Continue, and clicking the other tile moves it.

   The fill is the accent at low alpha rather than the neutral sunk grey, so
   "chosen" is stated by the whole tile instead of by its outline alone — a
   1px edge is a thin thing to carry the only decision on the screen. Alpha
   rather than a mixed hex so it sits correctly on the card in both themes.
   Inset ring rather than a thicker border, so selecting one does not shift the
   pair by a pixel. */
.ob-choice.picked {
  border-color: var(--sel);
  background: rgb(13 153 255 / .08);
  box-shadow: inset 0 0 0 1px var(--sel);
}
.ob-choice.picked:hover {
  border-color: var(--sel);
  background: rgb(13 153 255 / .12);
}
/* the same tint has to work harder against a dark card to read as blue at all */
:root[data-theme="dark"] .ob-choice.picked { background: rgb(13 153 255 / .16); }
:root[data-theme="dark"] .ob-choice.picked:hover { background: rgb(13 153 255 / .22); }

/* The drawings are ink on white, so neither one is a picture with a background
   — they are marks. multiply drops the white out onto the card in light mode;
   inverting first does the same job against a dark one, where the marks become
   the light part and screen drops the black. */
.ob-art {
  width: 100%;
  height: 96px;
  margin-bottom: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.ob-art img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  mix-blend-mode: multiply;
  user-select: none;
}
:root[data-theme="dark"] .ob-art img {
  filter: invert(1);
  mix-blend-mode: screen;
}

.ob-choice-title {
  color: var(--chrome-ink);
  font: 600 14px/1.3 var(--ui);
}
.ob-choice-line {
  color: var(--chrome-dim);
  font: 400 12.5px/1.5 var(--ui);
}

/* ---- fields ---- */

.ob-field { margin-bottom: 16px; }
/* Recessed, like every other surface in this app.
   --chrome-shadow is the token the zoom pill, the presence stack and the share
   panel are all made of: an inset cast down from the top edge, plus an inset
   1px ring that IS the edge — which is why there is no border here. A field is
   the one control that genuinely is a well you put something into, so it is the
   place that language fits best. Both halves are theme-aware already, so dark
   needs nothing of its own. */
.ob-input {
  width: 100%;
  height: 40px;
  padding: 0 13px;
  background: var(--chrome);
  border: none;
  border-radius: 10px;
  outline: none;
  box-shadow: var(--chrome-shadow);
  color: var(--chrome-ink);
  font: 400 13px/1 var(--ui);
}
.ob-input::placeholder { color: var(--chrome-dim); }
/* The well keeps its depth and gains the accent around it. Restating
   --chrome-shadow is required, not belt-and-braces: box-shadow replaces
   wholesale, so naming only the ring would flatten the field on focus. */
.ob-input:focus { box-shadow: var(--chrome-shadow), 0 0 0 2px rgb(13 153 255 / .4); }
.ob-input:disabled { opacity: .55; cursor: not-allowed; }

.ob-note {
  margin-bottom: 14px;
  color: var(--chrome-dim);
  font: 400 12.5px/1.5 var(--ui);
}
.ob-note.bad { color: #c4483a; }
.ob-note[hidden] { display: none; }

/* ---- the plans ----

   Two cards, side by side, in the same dialog as every other step. Widening
   the card for this one screen made the plans a different kind of thing from
   the questions around them — the panel jumped as you arrived at it, and the
   last step of a flow is the worst place to move the furniture. So the columns
   are sized to the card instead: tighter gutters, a smaller price, and the
   longest feature line still sits on one line at this width. */

.ob-plans {
  display: flex;
  gap: 12px;
  margin-bottom: 4px;
}

/* A plan is a column and the button is its floor, so the two cards keep their
   buttons on one line however differently the features wrap. */
.ob-plan {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  padding: 16px 16px 14px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  /* Square, against the rounded dialog holding them. The corners are what
     separate a plan from the panel it is being offered in: rounded, they read
     as two more of the app's own surfaces, and the eye stops seeing a pair of
     columns to compare. */
  border-radius: 0;
}

/* The one they are on, when this panel is opened by a limit rather than by the
   first run. Stated by the card and not only by its button, because the point
   of the screen at that moment is the difference between the two columns. */
.ob-plan.on {
  border-color: var(--sel);
  box-shadow: inset 0 0 0 1px var(--sel);
}

.ob-plan-name {
  margin-bottom: 6px;
  color: var(--chrome-ink);
  font: 600 16px/1.2 var(--ui);
  letter-spacing: -.01em;
}

/* The number is the thing being compared, so it is the biggest thing on the
   card and the unit rides on its baseline rather than beneath it: "$15" and
   "/user / mo" are one phrase read left to right, not two lines of a label. */
.ob-plan-price {
  display: flex;
  align-items: baseline;
  gap: 1px;
  margin-bottom: 14px;
}
/* Not bold. The size is already doing the work of saying this is the number
   that matters, and weight on top of it made the price shout at the one moment
   somebody is deciding whether to trust the thing. */
.ob-plan-amount {
  color: var(--chrome-ink);
  font: 400 29px/1 var(--ui);
  letter-spacing: -.03em;
}
.ob-plan-unit {
  color: var(--chrome-dim);
  font: 400 12px/1 var(--ui);
}

/* Ruled rather than bulleted. Every line here is one capability with a value
   attached, so the dotted rule does the job a bullet would and also says where
   one fact ends — which is what makes "Up to 3 workspaces" and "Unlimited
   workspaces" line up across the two cards at a glance. Dotted, and a single
   pixel, so the list reads as separated rather than as a table. */
.ob-feats {
  flex: 1;
  margin: 0 0 14px;
  padding: 0;
  list-style: none;
}
.ob-feat {
  padding: 9px 0;
  border-bottom: 1px dotted var(--chrome-edge);
  color: var(--chrome-dim);
  font: 400 12.5px/1.4 var(--ui);
}
.ob-feat:first-child { padding-top: 0; }
.ob-feat:last-child { border-bottom: none; }

/* The same button as Continue, because it is the same act: the way on from
   this step. It was an outline so that neither plan outranked the other, but
   two identical buttons say that just as well as two quiet ones do — and a
   flow whose every step ends in one blue button should not end in two grey
   ones. Inline-flex for the arrow, exactly as .ob-go carries it. */
.ob-plan-go {
  width: 100%;
  height: 40px;
  padding: 0 14px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  background: var(--sel);
  border: 1px solid transparent;
  border-radius: 10px;
  color: #fff;
  font: 500 13.5px/1 var(--ui);
  cursor: pointer;
  transition: background .14s ease, filter .14s ease;
}
.ob-plan-go:hover:not(:disabled) { filter: brightness(1.06); }
.ob-plan-go:hover:not(:disabled) .ob-arrow { transform: translateX(3px); }
.ob-plan-go:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px var(--chrome), 0 0 0 4px var(--sel);
}
/* The plan you are already on, and a plan with no price configured. Neither is
   a thing to press, so both go the grey a disabled Continue goes rather than a
   faded blue that still looks live. */
.ob-plan-go:disabled {
  background: var(--chrome-sunk);
  border-color: transparent;
  color: var(--chrome-dim);
  cursor: default;
}

/* Said out loud, because a test key means the card being typed is not real
   money and somebody testing the flow needs to know which mode they are in. */
.ob-testmode {
  margin-top: 14px;
  color: var(--chrome-dim);
  font: 400 12px/1.5 var(--ui);
}

@media (max-width: 620px) {
  .ob-plans { flex-direction: column; }
}

/* ---- invites ---- */

.ob-inviterow { display: flex; gap: 8px; margin-bottom: 12px; }
.ob-inviterow .ob-input { flex: 1; min-width: 0; }

.ob-invited {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 14px;
}
.ob-invited:empty { display: none; }
.ob-chip {
  padding: 4px 9px;
  background: var(--chrome-sunk);
  border-radius: 999px;
  color: var(--chrome-ink);
  font: 400 12px/1.3 var(--ui);
}

/* ---- actions ---- */

.ob-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 20px;
}
/* A step whose only action is "go on" centres it, because there is nothing on
   the row for it to be weighed against. Every other step pairs it with a way
   past, and those stay opposed at the two ends of the row. */
.ob-actions.solo { justify-content: center; }

/* .ob-go is named as well as matched through .ob-actions, because one of them
   is not in an actions row: Invite sits inside the form it submits, beside the
   field. Matching only on the row left that button with no height, padding or
   radius at all — a default-sized browser button next to a 38px input. */
.ob-actions button, .ob-go {
  /* inline-flex so a label and an arrow centre as one unit, rather than the
     glyph hanging off the text baseline */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  height: 40px;
  padding: 0 18px;
  border-radius: 10px;
  border: 1px solid transparent;
  font: 500 13.5px/1 var(--ui);
  cursor: pointer;
}
/* The arrow is the button saying there is a screen after this one, which is
   the job the step dots used to do. It leans into the direction on hover —
   small enough to feel like the button answering rather than an animation. */
.ob-arrow {
  width: 16px;
  height: 16px;
  flex: none;
  transition: transform .16s var(--ease);
}
.ob-go:hover:not(:disabled) .ob-arrow { transform: translateX(3px); }
/* Nothing counts the steps out any more, so this button is the only thing
   saying there is a next screen. Given the whole row to itself it can afford
   the width to look like a destination rather than a corner control. */
.ob-go.wide { min-width: 260px; }
/* the quiet way past sits hard left, away from the button it is not */
.ob-skip {
  margin-right: auto;
  background: none;
  color: var(--chrome-dim);
}
.ob-skip:hover { color: var(--chrome-ink); background: var(--chrome-hover); }
.ob-skip[hidden] { display: none; }

/* The way back to sign in.
   In the corner of the screen, not in the card and not in any actions row: the
   card is the question this step is asking, and leaving the account is not one
   of the answers to it. Everything on it is set to not compete with the one
   blue button in the middle of the page — an outline with nothing in it, small
   enough to read as chrome, and the arrow ahead of the words because it points
   back the way it came. */
.ob-back {
  position: absolute;
  top: 20px;
  left: 20px;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  height: 26px;
  padding: 0 8px 0 6px;
  border: 1px solid var(--chrome-edge);
  border-radius: 7px;
  background: none;
  color: var(--chrome-dim);
  font: 400 12px/1 var(--ui);
  cursor: pointer;
  /* arrives with the card rather than before it, so the first thing on the
     screen is never the way off it */
  opacity: 0;
  transition: opacity .22s ease, color .14s ease, border-color .14s ease;
}
#onboard.on .ob-back { opacity: 1; }
.ob-back .ob-arrow { width: 13px; height: 13px; }
.ob-back:hover:not(:disabled) { color: var(--chrome-ink); border-color: var(--chrome-dim); }
/* the arrow leans the way it points, same as the one on Continue does */
.ob-back:hover:not(:disabled) .ob-arrow { transform: translateX(-2px); }
.ob-back:disabled { cursor: default; }
.ob-back:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px var(--chrome), 0 0 0 4px var(--sel);
}

/* Flat, and the same blue Invite already uses — this panel's one accent, doing
   the one thing an accent is for. The selected tile now carries the same hue as
   a fill, so the screen reads as one decision in one colour: what you chose,
   and the way on. */
.ob-go {
  flex: none;
  background: var(--sel);
  border-color: transparent;
  color: #fff;
  transition: background .14s ease, filter .14s ease;
}
.ob-go:hover:not(:disabled) { filter: brightness(1.06); }
/* Nothing chosen yet, so nothing to press — the same grey a disabled Invite
   goes, rather than a faded blue that still looks live. */
.ob-go:disabled {
  background: var(--chrome-sunk);
  border-color: transparent;
  color: var(--chrome-dim);
  cursor: default;
}

/* "Open the canvas" ends the flow rather than sending anything, so it stays an
   outline beside the raised one */
.ob-done {
  flex: none;
  background: none;
  border: 1px solid var(--chrome-edge);
  color: var(--chrome-ink);
}
.ob-done:hover { background: var(--chrome-hover); }

.ob-go:focus-visible, .ob-done:focus-visible, .ob-skip:focus-visible,
.ob-choice:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px var(--chrome), 0 0 0 4px var(--sel);
}
/* A selected tile is already wearing an inset ring, and the rule above would
   replace it — leaving a focused tile looking unselected. Both, in one
   declaration, since box-shadow does not merge. */
.ob-choice.picked:focus-visible {
  box-shadow: inset 0 0 0 1px var(--sel), 0 0 0 2px var(--chrome), 0 0 0 4px var(--sel);
}

@media (max-width: 520px) {
  .ob-choices { flex-direction: column; }
  .ob-art { height: 76px; }
}

@media (prefers-reduced-motion: reduce) {
  .ob-scrim, .ob-card, .ob-choice, .ob-back { transition: none; }
}

/* ---- the tour -----------------------------------------------------------
   soffo standing next to a control, and a card where it is standing.

   No scrim and no pointer events on the layer: the whole argument for pointing
   at the real field instead of a picture of one is that you can use it while it
   is being pointed at. Only the card's own buttons take clicks.

   Above the chrome it points at (21-24) so the ring is never behind the thing
   it is drawn around, and below #onboard (70) so the first run always wins. */

#tour {
  position: fixed;
  inset: 0;
  z-index: 50;
  pointer-events: none;
}
#tour[hidden] { display: none; }

/* The stroke goes on the target itself, so it is exact by construction: an
   outline follows the element's own box and its own corner radius, and there is
   nothing to measure and therefore nothing to be measured wrong. Offset outward
   so it reads as drawn around the panel rather than as part of its edge.

   This lives outside #tour on purpose — the element wearing it is a menu card
   or a button somewhere else in the page, not a child of the tour layer. */
.tr-lit {
  outline: 2px solid var(--sel);
  outline-offset: 2px;
}

/* Flat. --chrome-shadow is an inset cast from the top edge, which is right for
   a surface recessed into the page and wrong for a card that is standing in
   front of one. A plain border is the whole edge this needs. */
.tr-pop {
  position: fixed;
  width: 272px;
  padding: 13px 14px 12px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 11px;
  pointer-events: auto;
  opacity: 0;
  transform: translateY(-4px);
  transition: opacity .2s ease, transform .24s var(--ease),
              left .28s var(--ease), top .28s var(--ease);
}
#tour.on .tr-pop { opacity: 1; transform: none; }

/* No face beside it. soffo is standing next to the control in person; a
   portrait of it on the card is the same signature twice. */
.tr-step {
  margin-bottom: 6px;
  color: var(--sel);
  font: 600 11px/1 var(--ui);
  letter-spacing: .03em;
}

.tr-title {
  margin-bottom: 4px;
  color: var(--chrome-ink);
  font: 600 13.5px/1.35 var(--ui);
}
.tr-line {
  color: var(--chrome-dim);
  font: 400 12.5px/1.5 var(--ui);
}

.tr-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 12px;
}
.tr-actions button {
  height: 28px;
  padding: 0 12px;
  border: none;
  border-radius: 7px;
  font: 500 12.5px/1 var(--ui);
  cursor: pointer;
}
.tr-skip {
  margin-right: auto;
  background: none;
  color: var(--chrome-dim);
}
.tr-skip:hover { color: var(--chrome-ink); background: var(--chrome-hover); }
.tr-skip[hidden] { display: none; }

.tr-next { background: var(--sel); color: #fff; }
.tr-next:hover { filter: brightness(1.06); }

.tr-next:focus-visible, .tr-skip:focus-visible {
  outline: 2px solid var(--sel);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .tr-pop { transition: opacity .2s ease; }
}

/* ---- notices on the gate ------------------------------------------------
   A link that leads somewhere you cannot go is not an error, and should not
   look like one. Same pane the sign-in form uses, same wordmark beside it. */

.auth-notice {
  max-width: 340px;
  color: var(--ink);
  font: 400 13px/1.6 var(--ui);
}
.auth-notice h2 { font: 600 17px/1.3 var(--ui); margin-bottom: 8px; }
.auth-notice p { color: var(--dim); margin-bottom: 10px; }
.auth-notice a { color: var(--ink); }

@media (prefers-reduced-motion: reduce) {
  .agent-cursor .tag, .scan-ring, #zoomMenu, .react-picker { transition: none; }
  .agent-cursor.is-thinking .tag, .agent-cursor.talking .tag,
  .peer.talking .peer-name,
  .pr-face.busy { animation: none; }
  /* the ring stays, so the row still reads as pending — it just stops turning */
  .ag-spin { animation: none; }
  .peer { transition: none; }
  .peer-edit { animation: none; opacity: .8; }
}

/* ---- the shelf -------------------------------------------------------
   Apps you have kept, under the chip that keeps them. Built from the
   integrations panel's own parts, because it is the same object: a card
   hanging off a 34px chip in the same bar, holding a list of rows and one
   line of feedback under them. Anything different here would be a second
   visual language for a second panel in the same corner. */

#libMenu {
  position: fixed;
  /* Width, left and top are all measured when it opens — see
     client/ui/underbar.js. The four panels that hang off this bar share one
     geometry, and it is the bar's own run, which changes every time somebody
     joins the canvas. Any constant here would be right on one screen. */
  z-index: 22;
  opacity: 0;
  transform: translateY(-6px) scale(.97);
  transform-origin: 0 0;
  pointer-events: none;
  transition: opacity .14s ease, transform .18s var(--ease);
}
#libMenu.on { opacity: 1; transform: none; pointer-events: auto; }

.lib-card {
  overflow: hidden;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 12px;
  box-shadow: var(--chrome-shadow);
}

/* One list, and tall enough for three rows with the fourth showing its top
   edge, which is how a list says it scrolls without a scrollbar to say it
   with. No scrollbar, matching the panel beside it. */
.lib-list {
  max-height: min(64vh, 432px);
  overflow-y: auto;
  padding: 6px 0;
  scrollbar-width: none;
}
.lib-list::-webkit-scrollbar { width: 0; height: 0; }

/* A row is a picture with a caption and its controls beside it.

   Three cells on a two by two grid: the picture down the left holding both
   rows, the name top right, the buttons under the name. The picture spans
   rather than sitting in one cell because it is the thing being described and
   the two lines to its right are the description -- a thumbnail level with only
   the title would leave the row bottom-heavy and the buttons floating against
   nothing. */
.lib-row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  /* The second track is the button's own height and the first takes whatever is
     left, which is what squares the two columns off against each other. The
     picture spans both and is the tallest thing here, so it sets the total; the
     free space lands in the top row; and the bottom row ends exactly where the
     picture does. That is why Share sits on the picture's baseline rather than
     near it, and why it keeps sitting there if the picture changes size. */
  grid-template-rows: 1fr auto;
  column-gap: 10px;
  row-gap: 5px;
  padding: 9px 12px;
}
.lib-row + .lib-row { box-shadow: inset 0 1px 0 var(--chrome-edge); }
.lib-row.busy { opacity: .55; }

/* Both halves place the app. The picture is the mouse target and the name is
   the one that can be reached by keyboard; see row() for why it is not both. */
.lib-open {
  display: flex;
  min-width: 0;
  padding: 0;
  border: none;
  background: none;
  color: var(--chrome-ink);
  font: 400 12.5px/1 var(--ui);
  text-align: left;
  cursor: pointer;
}
.lib-open:disabled { cursor: default; }
.lib-open:focus, .lib-open:focus-visible { outline: none; }

.lib-thumb {
  grid-column: 1;
  grid-row: 1 / span 2;
  align-self: start;
}
/* Both of these sit on the bottom of their row: the name just above the
   buttons, the buttons flush with the foot of the picture. */
.lib-title {
  grid-column: 2;
  grid-row: 1;
  align-self: end;
  align-items: baseline;
  gap: 7px;
}

/* The picture. A small fixed window onto a frame drawn at the app's own width
   and scaled down, so what shows is the app rather than whatever the app does
   at 84px. Cropped from the top: the part of an interface worth recognising is
   its first stripe, not its middle.

   Small on purpose. It is a mark that tells two rows apart at a glance, the way
   the logo tile does in the integrations list, not a preview anybody is meant
   to read.

   These two numbers are also in ui/library.js as THUMB_W and THUMB_H, which is
   what sizes the frame inside. They move together. */
.lib-shot {
  display: block;
  flex: none;
  width: 84px;
  height: 52px;
  overflow: hidden;
  border: 1px solid var(--chrome-edge);
  border-radius: 5px;
  background: #fff;
}
.lib-shot iframe {
  display: block;
  border: 0;
  transform-origin: 0 0;
  /* It cannot be clicked, scrolled or tabbed into. It is a picture, and the
     press belongs to the button it is inside. */
  pointer-events: none;
}
/* Before the document has been fetched. Grey, and not pretending otherwise --
   the same admission the integrations logo tile makes while artwork is in
   flight. No shimmer: four of these animating is the most active thing in a
   panel whose rows have nothing to say yet. */
.lib-shot.empty { background: color-mix(in srgb, var(--chrome-ink) 7%, transparent); }
:root[data-theme="dark"] .lib-shot { background: var(--bg); }
/* Off the row rather than off either button, so pointing at the name lights the
   picture too: they are one target wearing two elements. */
.lib-row:hover .lib-shot { border-color: color-mix(in srgb, var(--sel) 40%, transparent); }
.lib-title:focus-visible .lib-name { text-decoration: underline; }

.lib-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.lib-meta {
  flex: none;
  color: var(--chrome-dim);
  font: 400 11px/1 var(--ui);
}

/* The verbs, under the name they belong to, left-aligned with it so the row
   reads down its right-hand column: this is the app, this is what you can do
   about it. */
.lib-acts {
  grid-column: 2;
  grid-row: 2;
  align-self: end;
  display: flex;
  align-items: center;
  gap: 4px;
  min-width: 0;
}
.lib-by {
  color: var(--chrome-dim);
  font: 400 11px/1 var(--ui);
}

/* The same control as the integrations row's Connect, answering the same shape
   of question: handed over or not, one click either way, with the undo taking
   the state's place on hover so a row never shows two verbs at once. */
.lib-do {
  flex: none;
  height: 24px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  /* One width for all three words, so the column lines up and a button does not
     resize under the pointer the moment "Shared" becomes "Unshare". Narrower
     than the integrations row's 92px, which was sized around a plus this one
     no longer carries. */
  min-width: 72px;
  padding: 0 9px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 5px;
  color: var(--chrome-ink);
  font: 500 11.5px/1 var(--ui);
  cursor: pointer;
  transition: background .12s ease, border-color .12s ease, color .12s ease;
}
.lib-do .ag-spin { width: 12px; height: 12px; border-width: 1.5px; }
.lib-do:hover { background: var(--chrome-hover); }
.lib-do:active { transform: translateY(1px); }
.lib-do:disabled { opacity: .55; cursor: default; }
.lib-do:focus, .lib-do:focus-visible { outline: none; }

/* Shared states what is true, in the selection blue, keeping its border in that
   blue rather than dropping it -- borderless reads as a label that wandered
   into the button column. Hovering turns it into the undo, in the same place. */
.lib-row.on .lib-do {
  border-color: color-mix(in srgb, var(--sel) 45%, transparent);
  color: var(--sel);
}
.lib-row.on .lib-do:hover {
  background: var(--chrome);
  border-color: color-mix(in srgb, #c4483a 35%, transparent);
  color: #c4483a;
}
/* The word is swapped rather than the button rebuilt, so the change costs
   nothing and cannot land a frame late. */
.lib-row.on .lib-do:hover .lib-word { font-size: 0; }
.lib-row.on .lib-do:hover .lib-word::after {
  content: 'Unshare';
  font: 500 11.5px/1 var(--ui);
}

/* Removing says what it does. A bare x is a guess in a row that already has a
   real button beside it, and the guess it invites is "close", not "delete".

   Always drawn, never revealed. It used to appear on row hover, which flickered
   for two reasons that compound: the pointer crossing the gap between Share and
   Remove leaves and re-enters the row, and any repaint -- which every share and
   every delete triggers -- replaces the node under the cursor, so the browser
   has to rediscover the hover on the new element. A control that blinks while
   you reach for it reads as broken whichever of those did it.

   Quiet by weight instead: dim, and no border. Two outlined buttons side by
   side read as a pair of equal choices, and these are not. One is the row's
   verb; the other is how you take the row away. */
.lib-x {
  flex: none;
  height: 24px;
  padding: 0 7px;
  border: none;
  border-radius: 5px;
  background: none;
  color: var(--chrome-dim);
  font: 500 11.5px/1 var(--ui);
  white-space: nowrap;
  cursor: pointer;
}
.lib-x:hover { background: var(--chrome-edge); color: #c4483a; }
.lib-x:focus, .lib-x:focus-visible { outline: none; }
.lib-x:focus-visible { color: var(--chrome-ink); background: var(--chrome-edge); }

/* Nothing to list: not yet, not working, or nothing saved.

   The box that holds the line is the box that centres it, and it carries its
   own height to do that with. Centring inside a scroller that is exactly as
   tall as one line of text is centring inside nothing, which is why the height
   cannot live on the parent -- and putting all three properties on the same
   element means the result does not depend on a class landing somewhere else
   first. .is-empty below only takes the scroller's padding off. */
.lib-empty {
  box-sizing: border-box;
  width: 100%;
  min-height: 148px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 24px;
  text-align: center;
  text-wrap: balance;
  color: var(--chrome-dim);
  font: 400 12.5px/1.5 var(--ui);
}
.lib-list.is-empty { padding: 0; }

.lib-note {
  padding: 9px 14px 11px;
  border-top: 1px solid var(--chrome-edge);
  color: var(--chrome-dim);
  font: 400 11.5px/1.45 var(--ui);
}
.lib-note.bad { color: #c4483a; }
.lib-note[hidden] { display: none; }

/* ---- contexts -----------------------------------------------------------
   Pills, not rows, and that is the one place this panel departs from the two
   beside it. A row is the right shape for a thing you switch on, which is what
   an integration is. A context is not switched on: it is picked up and put into
   a sentence, and it turns up again under the message that used it. Drawing it
   as a row here and a pill everywhere else would be two pictures of one object.

   Everything else is borrowed intact from #intMenu and #libMenu — the card, the
   rise-and-settle, the note along the bottom — because it is the same object
   hanging off the same bar. */

#cxMenu {
  position: fixed;
  /* Width, left and top are all measured when it opens — see
     client/ui/underbar.js. The four panels that hang off this bar share one
     geometry, and it is the bar's own run, which changes every time somebody
     joins the canvas. Any constant here would be right on one screen. */
  z-index: 22;
  display: flex;
  flex-direction: column;
  gap: 8px;
  opacity: 0;
  transform: translateY(-6px) scale(.97);
  transform-origin: 0 0;
  pointer-events: none;
  transition: opacity .14s ease, transform .18s var(--ease);
}
#cxMenu.on { opacity: 1; transform: none; pointer-events: auto; }

.cx-card,
.cx-graph-panel {
  overflow: hidden;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 12px;
  box-shadow: var(--chrome-shadow);
}

/* The field of pills. It wraps, and it scrolls at about five rows of them,
   which is the height a whole shelf still reads at in one look. No scrollbar,
   matching both panels beside it: the row cut off by the bottom edge is what
   says there is more, and it says it without a stripe of chrome to say it
   with. */
.cx-field {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  max-height: min(52vh, 208px);
  padding: 12px;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: none;
}
.cx-field::-webkit-scrollbar { width: 0; height: 0; }
/* A shelf with nothing on it is not drawn at all: no empty box, no line saying
   it is empty, no border under it. The sources row below is then the whole
   panel, which is the right thing for somebody who has not brought anything in
   yet. The rule has to be here rather than left to the browser, because the
   field sets its own display and that beats the UA's [hidden]. */
.cx-field[hidden] { display: none; }

/* ---- the pill ----
   A mark, a name, a soft ground, and nothing else: the shape in the reference
   this was drawn from, which is a thing you can pick up rather than a control
   you operate.

   There is no wrapper any more and no buttons pinned to its corner. What a pill
   can do is done by pressing, dragging or deleting the pill itself; what it IS
   — pinned, shared, mid-fetch — it says by how it looks. Every rule below is
   about one of those two things and there is nothing else in here. */
.cx-pill {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  max-width: 100%;
  height: 27px;
  padding: 0 11px 0 8px;
  /* No fill at rest. A pill on the shelf is a fact rather than a state — this
     document is here — and the outline is enough to say it. Fill is what
     selection means, so spending it on the resting state would leave selection
     nothing left to say with. */
  background: transparent;
  /* A real grey line rather than the near-invisible one the rest of the chrome
     uses. This outline is the whole of the pill at rest — there is no fill
     behind it doing half the work — so it has to be visible on its own. */
  border: 1px solid color-mix(in srgb, var(--chrome-ink) 20%, transparent);
  border-radius: 999px;
  color: var(--chrome-ink);
  font: 450 12px/1 var(--ui);
  text-align: left;
  cursor: pointer;
  transition: background .12s ease, border-color .12s ease,
              transform .14s var(--ease), box-shadow .14s var(--ease);
}

/* A pill drawn as a span — the rail under a block, where there is nothing to
   press — must not look pressable. */
span.cx-pill { cursor: default; }

/* Lifted rather than tinted. The tray is a tray of objects, so the feedback for
   pointing at one is the feedback for being about to pick something up, and it
   is the same half-pixel every draggable thing in this app rises by. */
/* A fill arriving, not a card rising. There is no surface here to lift. */
button.cx-pill:hover { background: var(--chrome-hover); }
button.cx-pill:active { background: var(--chrome-sunk); }
.cx-pill:focus { outline: none; }
/* Keyboard only, and it is the one place a ring is right: a pill reached by Tab
   has no pointer resting on it to say which one is about to take a Delete. */
.cx-pill:focus-visible {
  outline: none;
  border-color: color-mix(in srgb, var(--sel) 55%, transparent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--sel) 18%, transparent);
}

.cx-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The mark, at the size of the word beside it rather than at the size of a
   tile, so it reads as part of the name instead of as a second object in the
   pill. A logo keeps its own colours; a drawn mark takes the pill's, one step
   back from the ink so the name leads. */
.cx-mark {
  flex: none;
  display: grid;
  place-items: center;
  width: 15px; height: 15px;
}
.cx-mark svg { width: 14px; height: 14px; color: var(--chrome-dim); }
.cx-mark img { width: 100%; height: 100%; object-fit: contain; }
/* No bare artwork for this one: the panel's copy carries its own ground, so it
   is given the corners to sit in rather than being left square. */
.cx-mark img.full { border-radius: 4px; object-fit: cover; }

/* ---- selected ----
   One press. Grey becomes the selection blue, and the cross inside it appears:
   the only way to take a document off this shelf, and it is not in the room
   until you have pointed at the thing it would remove. A second press on the
   pill hands the document to whatever you were writing. */
.cx-pill.picked {
  background: color-mix(in srgb, var(--sel) 14%, var(--chrome));
  border-color: color-mix(in srgb, var(--sel) 48%, transparent);
  color: color-mix(in srgb, var(--sel) 82%, var(--chrome-ink));
}
.cx-pill.picked .cx-mark svg { color: var(--sel); }
button.cx-pill.picked:hover { background: color-mix(in srgb, var(--sel) 20%, var(--chrome)); }

/* The cross. Width is animated rather than display swapped, so the pill opens
   for it instead of jumping, and it is clipped to nothing rather than hidden so
   there is never a frame where it can be pressed but not seen. */
.cx-pill .cx-x {
  display: grid;
  place-items: center;
  width: 0;
  overflow: hidden;
  opacity: 0;
  flex: none;
  transition: width .13s var(--ease), opacity .1s ease, margin .13s var(--ease);
}
/* The right padding was measured for a name ending in a letter. A round mark
   ends in its own whitespace, so the same gap after it reads as a hole. */
.cx-pill.picked { padding-right: 5px; }
.cx-pill.picked .cx-x { width: 15px; margin-left: 2px; opacity: .8; }
.cx-pill .cx-x svg { width: 15px; height: 15px; flex: none; }
.cx-pill.picked .cx-x:hover { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .cx-pill .cx-x { transition: none; } }

/* ---- pinned ----
   Every agent reads it on every run. Said by tinting the mark, and only where
   the mark is one we draw — an app's own logo is its own artwork and is never
   recoloured or ringed to carry a state of ours. The outline stays grey because
   grey is what "on the shelf" looks like, and fill plus edge is what "selected"
   looks like; a third meaning competing for either would make both ambiguous,
   and a pill can be pinned and selected at the same moment. */
.cx-pill.pinned .cx-mark svg { color: var(--sel); }

/* Shared with the team: a firmer outline, which is the quietest way to say
   "somebody else has this too" without spending a word or a mark on it. */
.cx-pill.shared { border-color: color-mix(in srgb, var(--chrome-ink) 38%, transparent); }

/* Something is happening to it. Dimmed and inert rather than replaced by a
   spinner: the pill is the thing being acted on, and swapping it for a loader
   would take the name off the screen at the moment somebody is watching to see
   what happens to it. */
.cx-pill.busy { opacity: .45; pointer-events: none; }

/* On its way in, and not a document yet. The travelling shimmer is the only
   animation in this tray, and it is here because this is the only state that
   lasts long enough for stillness to read as nothing happening. */
.cx-pill.cx-loading {
  cursor: default;
  color: var(--chrome-dim);
  border-style: dashed;
  background:
    linear-gradient(90deg, transparent 0%, var(--chrome-hover) 50%, transparent 100%)
    0 0 / 200% 100% no-repeat,
    var(--chrome);
  animation: cx-wait 1.5s linear infinite;
}
.cx-pill.cx-loading .cx-mark svg,
.cx-pill.cx-loading .cx-mark img { opacity: .5; }
@keyframes cx-wait { from { background-position: -100% 0; } to { background-position: 100% 0; } }
@media (prefers-reduced-motion: reduce) { .cx-pill.cx-loading { animation: none; } }

/* ---- carrying one somewhere ----
   A pill in the air, and the things that can catch it.

   An agent is drawn on #agents, which is the whole plane and inert to the
   pointer, so a cursor on it has to be woken up for as long as a document is
   being carried and put back afterwards. One class on the body does it.

   The cursor and NOT the layer, which is the whole of the rule. A layer with
   `pointer-events: none` still lets a child that sets `auto` be a target, so
   waking #agents itself was never what made a cursor catchable — and it is
   inset:0 at z-index 10, above every block on the canvas. Waking it laid an
   invisible sheet over the whole plane for the length of the drag, so the block
   under the pointer was never the drop target and a pill could not be given to
   a message at all. */
body.cx-dragging .agent-cursor { pointer-events: auto; }
body.cx-dragging .cx-pill { cursor: grabbing; }

/* What is about to take it. Deliberately the same ring in both cases even
   though they mean different things — this message, versus that agent forever —
   because what the ring says is only "released here, it lands here"; what it
   then means is said by the thing wearing it. */
.block.cx-over,
.agent-cursor.cx-over .body {
  outline: 2px solid color-mix(in srgb, var(--sel) 65%, transparent);
  outline-offset: 3px;
  border-radius: 8px;
}
.agent-cursor.cx-over .tag {
  background: var(--sel);
  color: #fff;
}

.cx-note {
  padding: 9px 12px 11px;
  border-top: 1px solid var(--chrome-edge);
  color: var(--chrome-dim);
  font: 400 11.5px/1.45 var(--ui);
}
.cx-note.bad { color: #c4483a; }
.cx-note[hidden] { display: none; }

/* ---- the four places one comes from ----
   The other half of the tray, and it looks different because it answers a
   different question. Above is what you have, as objects you pick up. This is a
   choice between four places, so it is four marks: Notion's own logo,
   Granola's, a page for a PDF, a frame for a region. A place is recognised by
   its mark long before its name is read, which is why these are not four "+"
   chips saying nothing about any of them.

   It is always here, including when the shelf above is empty. A tray you open
   in order to add something to, that has no way to add anything, is a dead end
   however quiet it looks. */
.cx-add {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px;
  border-top: 1px solid var(--chrome-edge);
}
/* With nothing above it there is nothing to divide, and the line would be a
   rule across the top of the card for its own sake. */
.cx-field[hidden] + .cx-add { border-top: 0; }

.cx-sources {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 5px;
}

/* Square-shouldered, so a source never reads as a pill. The two halves of this
   tray must not be confusable: one is a document you have, the other is a door
   to one you do not, and a rounded chip beside a rounded pill would have made
   them the same object at a glance. */
.cx-src {
  display: flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
  height: 30px;
  padding: 0 9px;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 6px;
  color: var(--chrome-ink);
  font: 450 12px/1 var(--ui);
  cursor: pointer;
  transition: background .12s ease, border-color .12s ease, color .12s ease;
}
.cx-src span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.cx-src:hover { background: var(--chrome-hover); }
.cx-src:active { transform: translateY(1px); }
.cx-src:disabled { opacity: .5; cursor: default; }
.cx-src:focus, .cx-src:focus-visible { outline: none; }
/* Lit while its field is open, so the field below is never a box floating under
   a row with nothing to say which of the four it belongs to. */
.cx-src.on {
  background: color-mix(in srgb, var(--sel) 10%, var(--chrome));
  border-color: color-mix(in srgb, var(--sel) 45%, transparent);
  color: color-mix(in srgb, var(--sel) 80%, var(--chrome-ink));
}
.cx-src .ag-spin { width: 15px; height: 15px; border-width: 1.5px; flex: none; }

/* The canvas's own graph, isolated from the source picker because it is a mode
   for reading structure on the canvas rather than another document source. A
   switch is the right shape: it is either visible and editable, or it is not. */
.cx-graph {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  min-height: 54px;
  padding: 10px 12px;
  border: 0;
  border-radius: 12px;
  background: none;
  color: var(--chrome-ink);
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.cx-graph:hover { background: var(--chrome-hover); }
.cx-graph:active { transform: translateY(1px); }
.cx-graph:focus, .cx-graph:focus-visible { outline: none; }

.cx-graph-say { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
.cx-graph-name { font-size: 13px; font-weight: 500; }
.cx-graph-note {
  font-size: 11px;
  color: var(--chrome-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The switch itself, drawn rather than borrowed: this app has no checkbox
   anywhere else, and a native one carries the operating system's own idea of
   what a control looks like into a bar that has spent a lot of effort not
   looking like one. */
.cx-graph-switch {
  flex: none;
  width: 28px;
  height: 16px;
  border-radius: 999px;
  background: var(--chrome-sunk);
  box-shadow: inset 0 0 0 1px var(--chrome-edge);
  transition: background .14s ease;
}
.cx-graph-switch i {
  display: block;
  width: 12px;
  height: 12px;
  margin: 2px;
  border-radius: 50%;
  background: var(--chrome-dim);
  transition: transform .14s ease, background .14s ease;
}
.cx-graph.on .cx-graph-switch { background: var(--sel); box-shadow: none; }
.cx-graph.on .cx-graph-switch i { transform: translateX(12px); background: #fff; }

/* ---- the one field ----
   Chosen source on the left as its own mark, the sentence in the rest of it,
   and no button. There was a Find beside this and it was a button for the key
   that was already going to be pressed. */
.cx-ask {
  display: flex;
  align-items: center;
  gap: 7px;
  height: 30px;
  padding: 0 10px;
  background: var(--chrome-sunk);
  border: 1px solid color-mix(in srgb, var(--sel) 40%, transparent);
  border-radius: 6px;
}
.cx-ask[hidden] { display: none; }
.cx-ask-mark { display: grid; place-items: center; width: 15px; height: 15px; flex: none; }
.cx-ask-mark .cx-mark { width: 15px; height: 15px; }

.cx-input {
  flex: 1;
  min-width: 0;
  border: 0;
  background: none;
  color: var(--chrome-ink);
  font: 400 12px/1 var(--ui);
}
.cx-input::placeholder { color: var(--chrome-dim); }
.cx-input:focus { outline: none; }

/* Which region did you mean. Only ever drawn when there is more than one, since
   guessing between four is the kind of guess that is wrong often enough to be
   worse than a question — and it is the tray's own pills, so it is not a new
   thing to look at. */
.cx-pick {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
}
.cx-pick[hidden] { display: none; }
/* Both pickers are a column when their rows carry a hint, because a date is the
   whole answer to "which of these" and two of them side by side is two dates
   competing for the same glance. */
.cx-pick { flex-direction: column; align-items: stretch; }
/* Square-shouldered, like the four sources directly above them, because that is
   what these rows are: still part of choosing where a document comes from,
   answering the second half of the question the lit source asked. A pill is a
   document you already have, and one of these is not that yet. */
.cx-pick .cx-pill { justify-content: flex-start; border-radius: 6px; }

/* What tells two near-identical things apart: a date, a person, a section. On
   the pill rather than in a tooltip, because an answer you have to hover for is
   not an answer. Pushed to the far end so the dates line up down the column. */
.cx-hint {
  margin-left: auto;
  padding-left: 8px;
  flex: none;
  color: var(--chrome-dim);
  font: 400 11px/1 var(--ui);
}

/* ---- a document, named in a sentence ----
   "#pillars" in the words IS the document it names, always: from the moment the
   name resolves, in every block, whether a caret is in it or not.

   It is drawn in two layers, and that is what lets it be permanent. A
   transparent textarea lies exactly on top of this <pre> and its native caret
   walks the plain characters, so anything drawn at a different width moves
   every glyph after it on the line and takes the caret with them. The pill used
   to collapse to a flat token whenever a caret arrived, for exactly that
   reason.

   So the token still holds the line: .cx-ghost is "#pillars", at the block's
   own size, invisible. The pill is drawn on top of it, out of flow, occupying
   none of the line's width. The layout the textarea measures and the layout you
   see are the same layout — the caret lands where the text says it does, just
   past the pill — and the pill never has to go away for that to be true.

   Out of flow also means the pill owes the line nothing, which is what lets it
   be whole: it is never trimmed to the token's width. See .cx-face. */
.cx-tag.rich {
  position: relative;
  display: inline-block;
  vertical-align: baseline;
}

/* The characters, doing the measuring and nothing else. `visibility` rather
   than `display` or a transparent colour: it keeps the exact space the token
   occupies, which is the whole job, and takes it out of the paint. */
.cx-ghost { visibility: hidden; }

/* And the pill, over the top. Centred on the ghost — on both axes — so it sits
   in the middle of the line's height with the line showing above and below,
   which is breathing room an inline box cannot get from margin.

   It is never clipped. The pill is the document's name, and half a name is not
   one: whatever the title is, the pill holds all of it. That means it can come
   out wider than the token it stands on, because the token pays for a "#" where
   the pill pays for a mark and two rounded ends — so a short name like
   "#pillars" leaves the pill about a character short of the room it wants.

   Which is why it is centred rather than pinned to the left. The overflow
   splits, half into the gap before the token and half into the gap after, and
   those gaps are almost always the spaces on either side of the name: a "#"
   only starts a reference at the start of a line or after a non-word character.
   Two spaces of slack absorb the overhang of nearly every title, and the ones
   they don't, the pill leans over rather than cutting itself short.

   None of this moves a glyph. The pill is out of flow and the ghost still
   measures the line, so the caret goes on landing exactly where the characters
   say it does — and it goes on landing there when you click the pill, since the
   textarea is the layer above and takes the click. */
.cx-face {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  display: inline-flex;
  align-items: center;
  gap: 3px;
  padding: 2px 6px 2px 4px;
  border-radius: 999px;
  font-size: .72em;
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  background: color-mix(in srgb, var(--sel) 14%, var(--bg));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sel) 30%, transparent);
  color: color-mix(in srgb, var(--sel) 78%, var(--ink));
  /* Nothing to press, so nothing that invites a press. The caret belongs to
     this line; clicking here puts it where you clicked, as on any other word. */
  cursor: inherit;
}
:root[data-theme="dark"] .cx-face {
  background: color-mix(in srgb, var(--sel) 22%, var(--bg));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sel) 38%, transparent);
  color: color-mix(in srgb, var(--sel) 84%, #fff);
}
.cx-face .cx-mark { width: 11px; height: 11px; flex: none; }
.cx-face .cx-mark svg { width: 10px; height: 10px; color: currentColor; }
/* No overflow rule of its own, deliberately: the title is written out whole
   here, and the only place in this file that may not ellipsise. */
.cx-face .cx-name { min-width: 0; }

/* ---- a context in the "#" menu ----
   A section is a place on this canvas and a context is a document off it. The
   row says which without a word: a section is its slug, plainly, as it always
   was, and a document leads with the mark it wears everywhere else. */
.mention-opt.is-cx {
  display: flex;
  align-items: center;
  gap: 7px;
}
.mention-opt.is-cx .cx-mark { width: 15px; height: 15px; }


/* A PDF being dragged over the canvas. The whole plane says it will take it,
   in one line at the edge, because there is no drop target to point at — it
   lands wherever it is let go of. */
body.cx-dropping::after {
  content: '';
  position: fixed;
  inset: 8px;
  z-index: 40;
  border: 2px dashed color-mix(in srgb, var(--sel) 55%, transparent);
  border-radius: 12px;
  pointer-events: none;
}


/* ---- the private list (canvas/tasks.js) ----------------------------------
   The one surface here nobody else can see, and it has to look like it.

   Everything else on this plane is either warm — the writing, the ink, a table
   — or cold, which is what this app's chrome is drawn in: the zoom pill, a
   section's label, the card over a built app. A todo list is genuinely neither.
   It is not chrome, because it holds somebody's words rather than operating on
   somebody's words; and it is not the canvas, because the canvas is shared and
   this is not.

   So it is drawn as the one thing on the plane that is a sheet of paper. Square
   corners and no shadow, which is half the difference: every other floating
   thing in this app is a rounded card lifted off the canvas, and this one is
   laid ON it, held by an edge and nothing else.

   The other half is that the edge is dashed. This canvas already uses dashes to
   mean "on the plane but not of it" — the browser mode's mark is a pointer
   inside a dashed frame, a screen that is somebody else's — and that is exactly
   what a private list is. Everything with a solid edge here is the document,
   shared, on four other screens. The dashes say this one is not, before anybody
   has read a word of it.

   Nothing inside it is pill-shaped. Every control here is the shape the rest of
   the app already draws a control in — a hairline, a 6px corner, a chrome fill
   — and that rule is written once, up in the dropdown section, with .tl-ctl as
   its second name. A fully round button among them would be the one thing on
   screen claiming to be from a different application.

   Three bands, in the order you read them: the list's name and what you can do
   to it, where a task is written, and the tasks. Nothing is ruled off from
   anything. There were hairlines between them and they were two more lines on a
   card that already has a border, a box inside it and a column of checkboxes —
   space says the same thing and says it without drawing anything.

   There was a greeting over all of it for a while, and it has gone the way the
   weekday did. "Good afternoon, Seungyong" on a card only Seungyong can open is
   a sentence that spends four lines of vertical space telling somebody their own
   name.

   One inset runs down BOTH sides of the whole card and every band answers to it:
   the word Tasks, the composer's box, every checkbox and the Completed count all
   start or stop on it. That is the only reason the bar and the box below it read
   as one object — the bar used to be padded 10 on the right against the box's 16,
   and six pixels was enough to make the count look like it was hanging off the
   edge of something.

   Inside that, two text lines: the word Tasks and the composer's own text on the
   first, the words of every task on the second, out past the checkboxes. Two
   lines and one inset, and nothing else is holding this together now that the
   rules are gone. */

.tasklist {
  /* The one inset, and the one text line past it. --tl-text is exactly the
     gutter plus a checkbox plus the gap after it, so a task's words start where
     they start because of the arithmetic rather than because a number was
     nudged until it looked right. */
  --tl-gut: 14px;
  --tl-text: 42px;
  /* Fills the pane it is given rather than carrying a width of its own. The card
     was 380px on the plane because that is a readable column standing on its
     own; in a panel the panel is the column, and a card narrower than its pane
     would be a box inside a box. The extra width goes where it is wanted: a task
     now carries a schedule on the same line as its face and its bin. */
  width: 100%;
  background: none;
  border: 0;
  border-radius: 0;
  box-shadow: none;
}

/* ---- the creator ----
   A field with two controls and a button under it, in a box of its own.

   The box is the one thing on this card that is drawn rather than spaced, and
   it earns that: everything else here is a record of something and this is the
   only place you put something in. A field with no edge, on a sheet with no
   rules, is a paragraph of grey text that happens to accept a caret. The edge
   is what makes it a thing you write into.

   Rounded, on a card that is not. The card is a sheet laid on the canvas and
   the box is an object sitting on the sheet, and the corner is how you tell
   those apart at a glance. */
.tl-new {
  margin: 0 var(--tl-gut) 12px;
  border: 1px solid var(--chrome-edge);
  border-radius: 10px;
  background: var(--chrome);
  transition: border-color .14s ease, box-shadow .14s ease;
}
/* Answering the caret, quietly. The same blue every other field in this app
   goes when it has the keyboard, at the weight a box this large can carry. */
.tl-new:focus-within {
  border-color: color-mix(in srgb, var(--sel) 45%, transparent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--sel) 12%, transparent);
}

/* The field's text and the controls under it share a left edge — 11px in from
   the box, which is where the first chip's own border lands. */
.tl-new-field {
  padding: 11px 11px 8px;
  min-height: 44px;
  max-height: 176px;
  overflow-y: auto;
  outline: none;
  color: var(--ink);
  font: 400 13.5px/1.45 var(--ui);
  letter-spacing: -.004em;
  overflow-wrap: anywhere;
  white-space: pre-wrap;
}
/* Three words, on one line, and gone the moment anybody types.
   There was a whole example sentence here — an app, a time, a name — on the
   argument that the example is the documentation. It was, and it was also two
   wrapped lines of grey text sitting in the middle of a card whose entire case
   is that it is short. A placeholder is read once and then read past forever,
   and this one was the largest thing on the card every time the list was empty.
   What it was teaching is taught better by the two controls directly under it,
   which are the same lesson and do not have to be read to be seen. */
.tl-new-field:empty::before {
  content: 'Create a Task';
  color: var(--faint);
  pointer-events: none;
}

/* The row of controls under the field.
   It was deleted along with the strip of delay presets that used to expand
   inside it — the strip is gone and the row is not, and without it three chips
   that had always sat side by side stacked into a column. */
.tl-new-foot {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 0 11px 10px;
}

/* Small, and smaller than the controls in the bar above the box. These are not
   the point of the composer — the sentence is — and a row of controls the size
   of the thing they are attached to reads as a toolbar rather than as options
   sitting under a field. */
.tl-chip {
  height: 24px;
  padding: 0 9px 0 6px;
  color: var(--chrome-dim);
  font-size: 11.5px;
}
.tl-chip svg { width: 13px; height: 13px; flex: none; }
/* Once it is carrying something it stops being an offer and starts being a
   fact, so the "+" goes and the word stands on its own. */
.tl-chip.set {
  padding: 0 9px;
  background: var(--chrome-sunk);
  color: var(--chrome-ink);
  border-color: transparent;
}
/* Said for every chip that can fill rather than for the one that filled first:
   the agents composer has four more of them, and each was drawing a "+" beside
   the fact it had already been given — "+ 3 Skills". The child combinator is
   what keeps the Mode chip's own glyph, which is an svg inside .tl-chip-mark
   rather than one sitting directly in the button. */
.tl-chip.set > svg { display: none; }

/* The mark on the Mode button: a "+" until a mode is chosen, and that mode's own
   glyph after — the same one the "/" menu draws for the same word. It swaps in
   place rather than being added beside the plus, so the control keeps its width
   and its balance when it fills. */
.tl-chip-mark {
  flex: none;
  display: grid;
  place-items: center;
  width: 15px; height: 15px;
}
.tl-chip-mark svg { width: 100%; height: 100%; }
.tl-chip-mode.set { padding-left: 7px; gap: 5px; }

/* The clock is the whole of the schedule control until there is a time on it.
   "Schedule" beside a clock is the picture and its caption, and this row has two
   other controls competing for a line 380px wide. Square, so an icon on its own
   is centred in a box rather than sitting in the padding a word left behind. */
/* The composer had a clock here too. It is gone: two decisions belong under a
   field you are still writing in — what it may reach, and what shape the answer
   takes — and when it happens is a fact about a task that exists, one press away
   on the row itself. */

/* The one filled control on the card, and the one round thing on it.
   A word became an arrow, and the shape went with the meaning: "Create" is a
   verb you read, and an arrow in a circle is a thing you press — the same
   object every composer anybody uses has at the end of its field, in the one
   accent this app spends. It is round for the reason nothing else here is: a
   circle is what a send is, everywhere, and matching the square controls beside
   it would have made the one thing that commits look like the two that do not.
   It also never carries a word, so there is no label for a corner to hold. */
.tl-send {
  flex: none;
  margin-left: auto;
  display: grid;
  place-items: center;
  width: 26px; height: 26px;
  padding: 0;
  background: var(--sel);
  border: 0;
  border-radius: 50%;
  color: #fff;
  cursor: pointer;
  transition: filter .14s ease, background .14s ease, color .14s ease;
}
/* `display: block` as well as the grid centring above. An inline SVG sits on a
   text baseline and carries the line-box's descender space with it, which is a
   fraction of a pixel of the button's height spent below the glyph — invisible
   on a rectangle and exactly the kind of thing that shows up as "not quite
   centred" inside a circle. */
.tl-send svg { display: block; width: 15px; height: 15px; }
.tl-send:hover { filter: brightness(1.06); }
/* Nothing written is nothing to send. The same grey a disabled Invite goes,
   rather than a faded blue that still looks live. */
.tl-send:disabled {
  background: var(--chrome-sunk);
  color: var(--chrome-dim);
  cursor: default;
  filter: none;
}
.tl-send:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--sel) 22%, transparent);
}

/* ---- the list's own name ----
   At the top, and the drag handle. A window is moved by its title bar, and this
   is the only band on the card that is neither a field somebody types into nor
   a row somebody edits — every other press in here has a better meaning than
   "move this". Which is also why it has room to spare on the left: a handle you
   have to aim at is not a handle. */
/* The bar is no longer a handle: the list is in a panel and there is nothing to
   drag it to. So it keeps its height and gives up its cursor. What is in it is a
   greeting on the left and two controls on the right, which is the arrangement
   it had when the left held a title. */
.tl-bar {
  display: flex;
  align-items: center;
  gap: 6px;
  height: 36px;
  padding: 0 var(--tl-gut);
}
.tl-bar button { cursor: pointer; }

/* The one word on the card, and the only thing here that is not a control.
   It was a button that rolled the card up to its header, with a chevron beside
   it saying so, and both are gone: a list you have to unroll before you can read
   it is a list you stop reading, and the card already steps back to 60% when you
   are working on something else, which is what rolling it up was actually for
   and costs no gesture at all.

   Ink rather than the dim grey a section label is drawn in — with nothing above
   it, this is the title of the thing rather than a caption on part of it — but
   only medium: the colour is already doing the work of saying it leads, and 600
   on top of that made one word shout at two small grey controls it is meant to
   sit beside. */
/* The greeting, on the left of the same row as Add and at the same height as it.
   There was a title here once — the word "Tasks" — and the panel's own tab says
   that now, so the space went to the one line in here that is not work.

   Ink and 12.5, against controls at 11 in dim grey: it is the thing being said
   and they are the things you can do, and a row where those two weigh the same
   is a row with no first word. It gives way before they do — a long name
   ellipses rather than pushing Add off the end — because a truncated name is
   still a greeting and a control you cannot reach is not a control. */
/* The letters, and the wave that runs through them.
   ---------------------------------------------------------------------------
   Each character is its own element so the movement can travel: every letter is
   a beat behind the one to its left, which is the difference between a wave and
   a fade. Twenty-six milliseconds is the beat — enough that the eye follows a
   crest along the words, little enough that the whole line has settled inside a
   second, which is roughly how long the panel takes to feel like it has arrived.

   The blue is the same selection blue everything else in this app answers the
   caret with, and it is there and gone: a letter rises into it and lands in ink.
   That is what makes it read as light passing over the words rather than as text
   that is briefly the wrong colour — the colour is a property of the movement,
   not of the letter. No halo behind it: a glow is a picture of light, and the
   movement is the light.

   `both` on the animation matters more than it looks: it holds each letter at
   its first keyframe through its own delay, so the line reveals itself letter by
   letter instead of appearing whole and then rippling. */
.tl-ch {
  display: inline-block;
  white-space: pre;
  /* No `will-change: transform`. It was here to smooth the rise and it bought
     nothing: a running CSS transform animation is composited by the browser
     without being asked, and the hint is not scoped to the second the animation
     lasts — it is on the letters for as long as the card is in the document. A
     greeting is a dozen letters, each one is its own element, and each one was
     therefore holding its own layer and its own bitmap for the whole session,
     for a movement that happens once when the panel opens. */
}
.tl-hello.go .tl-ch {
  animation: tl-hello-rise .58s var(--ease) both;
  animation-delay: calc(var(--i) * 26ms);
}

@keyframes tl-hello-rise {
  0% {
    opacity: 0;
    transform: translateY(5px);
    color: color-mix(in srgb, var(--sel) 70%, transparent);
  }
  /* The crest: up, and blue. There was a glow here — a soft halo at the top of
     the rise — and it was the one part of this that looked like an effect
     rather than like light passing over the words. The colour is already doing
     that work, and doing it without blurring the letter it is on. */
  45% {
    opacity: 1;
    transform: translateY(-2.5px);
    color: var(--sel);
  }
  /* Past the top and settling, the way anything with weight does. */
  72% { transform: translateY(.5px); }
  100% {
    opacity: 1;
    transform: none;
    color: var(--chrome-ink);
  }
}

/* Somebody who has asked for less movement gets the greeting and none of this. */
@media (prefers-reduced-motion: reduce) {
  .tl-hello.go .tl-ch { animation: none; }
}

.tl-hello {
  flex: 1 1 auto;
  min-width: 0;
  height: 22px;
  display: flex;
  align-items: center;
  color: var(--chrome-ink);
  font: 500 12.5px/1 var(--ui);
  letter-spacing: -.008em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  user-select: none;
}

/* Both controls, and they are deliberately the same object at opposite ends of
   the bar: one adds to the list and one is the part of it that is over. */
/* Small, against a title at 14 and a bar 40 tall. What is on the left of this
   bar is the name of the thing and these are what you can do to it, and a verb
   drawn at the weight of a title is a bar with two titles in it. The room around
   them is not waste either: the bar is the drag handle, and a handle you have to
   aim at is not a handle. */
.tl-add, .tl-doneof {
  height: 22px;
  padding: 0 8px;
  color: var(--chrome-dim);
  font-size: 11px;
}
/* Both hard right, together. What is on the left is the name of the thing and
   what is on the right is what you can do to it and how much of it is over —
   one control adrift in the middle of the bar was a third alignment for no
   third reason. Add first, because it is a verb and the other is a total. */
.tl-add {
  margin-left: auto;
  padding: 0 8px 0 4px;
}
.tl-add svg { width: 13px; height: 13px; flex: none; }
.tl-add:hover, .tl-doneof:hover { color: var(--chrome-ink); }

.tl-doneof {
  gap: 4px;
  font-variant-numeric: tabular-nums;
}
.tl-doneof[hidden] { display: none; }
.tl-doneof.on {
  background: var(--chrome-sunk);
  border-color: transparent;
  color: var(--chrome-ink);
}
.tl-doneof-n { font-weight: 600; }

/* ---- a row ----
   A square, a line, and whatever it turned out to be about. Everything else the
   row can carry — who is doing it, when it starts, what it may read — is drawn
   at the end of the line, because a task is one line and three names would be
   the whole of it. */
.tl-rows { padding: 1px 0 8px; }

/* A grid rather than a row of boxes, and the reason is the second line.
   The tick, the words and the marks sit across the top; the line under it — what
   you typed there, or what an agent wrote back when it finished — spans the
   marks' column as well as its own, out to the edge of the row.

   It used to be a text box beside a marks box, which meant every line of a note
   was as narrow as the line above it, cut short by a chip that is only on the
   first line. "ran out of turns before finishing this one" wrapped to three
   lines against a name that was nowhere near it. */
.tl-row {
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: start;
  column-gap: 10px;
  padding: 6px var(--tl-gut);
}
.tl-tick  { grid-area: 1 / 1; }
.tl-body  { grid-area: 1 / 2; }
.tl-marks { grid-area: 1 / 3; }
/* Both spanning to the end. -1 is the far edge of the grid, which is the marks'
   own right-hand edge, so a note runs exactly as wide as the row it is on. */
.tl-note { grid-area: 2 / 2 / 3 / -1; }
.tl-rail { grid-area: 3 / 2 / 4 / -1; }
.tl-row:hover { background: color-mix(in srgb, var(--chrome-ink) 3.5%, transparent); }
.tl-tick {
  flex: none;
  display: grid;
  place-items: center;
  width: 18px; height: 18px;
  margin-top: 1px;
  background: none;
  border: 1.5px solid color-mix(in srgb, var(--chrome-ink) 20%, transparent);
  border-radius: 5px;
  color: transparent;
  cursor: pointer;
  transition: border-color .12s ease, background .12s ease, color .12s ease;
}
.tl-tick svg { width: 12px; height: 12px; }
.tl-tick:hover { border-color: color-mix(in srgb, var(--chrome-ink) 45%, transparent); }
.tl-tick:hover svg { color: color-mix(in srgb, var(--chrome-ink) 28%, transparent); }
/* Filled in the accent once it is done, not in ink. A tick is the one moment on
   this card worth a colour, and it is the same blue the button that made the
   task was drawn in — the beginning and the end of a task in one hue. */
.tl-row.done .tl-tick {
  background: var(--sel);
  border-color: transparent;
  color: #fff;
}

/* One line each, always, scrolling sideways rather than wrapping.
   A task is a thing you scan down a list of, and a list where one row is four
   lines tall and the next is one has no rhythm to scan. So both fields keep a
   line and the rest of a long one is read by pushing it — which also means a
   note an agent wrote three paragraphs of is three paragraphs long and not
   three paragraphs tall.

   No scrollbar: an 18px line with a scrollbar under it is mostly scrollbar. The
   end fades instead, and only when there is something past it — see runsOn() in
   canvas/tasks.js, because CSS has no way to ask whether an element overflows.
   Never while it has the caret: a fade over the last twenty pixels of a field
   somebody is typing into is a fade over the thing they are typing. */
.tl-body, .tl-note {
  outline: none;
  white-space: nowrap;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
}
.tl-body::-webkit-scrollbar,
.tl-note::-webkit-scrollbar { display: none; }
.tl-body.over:not(:focus),
.tl-note.over:not(:focus) {
  -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 20px), transparent);
  mask-image: linear-gradient(90deg, #000 calc(100% - 20px), transparent);
}
.tl-body {
  padding-top: 1px;
  color: var(--ink);
  font: 500 13.5px/1.4 var(--ui);
  letter-spacing: -.008em;
}
/* What there is to know about it: what you typed under the line, and what an
   agent wrote back when it had finished. One field, because those are the same
   thing said at different times. */
.tl-note {
  margin-top: 2px;
  color: var(--dim);
  font: 400 12.5px/1.45 var(--ui);
  letter-spacing: -.003em;
}
/* Not there until there is something in it. Without the :empty rule an empty
   editable div still holds its line, so every task on the list carried a blank
   grey row underneath it. */
.tl-note:empty:not(:focus) { display: none; }

/* No clock on a finished task. There is nothing to schedule about something
   that has already happened, and the alternative is a control offering to arm a
   task that claim() would refuse to run. The bin stays, because a finished task
   is exactly the one you are most likely to want rid of. */
.tl-row.done .tl-when { display: none; }

.tl-row.done .tl-body { color: var(--dim); text-decoration: line-through; }
.tl-row.done .tl-note { color: var(--faint); }
.tl-row.done .tl-rail { opacity: .45; }

/* Somebody else's, on your list because they put it there. The line down the
   left says it is not from here without spending a word on it, and it sits in
   the gutter rather than pushing the row across — every checkbox on the card
   stays on the same vertical line, which is the whole of why it is drawn as a
   pseudo-element and not as a border. */
.tl-row.theirs::before {
  content: '';
  position: absolute;
  left: 0; top: 5px; bottom: 5px;
  width: 2px;
  border-radius: 0 2px 2px 0;
  background: color-mix(in srgb, var(--sel) 50%, transparent);
}
/* Their words, your job. Arming it is theirs too, so the clock is not there to
   be pressed — a control that opens a menu whose every option the server would
   refuse is worse than no control. The face stays, because it is the only thing
   on the row that says who is waiting on you. */
.tl-row.theirs .tl-when,
.tl-row.theirs .tl-bin { display: none; }
.tl-row.theirs .tl-who { cursor: default; }

/* ---- what it has been given ----
   Documents off the shelf, as their own marks, under the words. Only when there
   are any, and only documents: the app logos that used to sit here are gone.
   A brand mark on a row is a claim about what the task IS, and it is the wrong
   claim — an app is how a thing gets done, not what the thing is. */
.tl-rail { display: none; gap: 5px; margin-top: 5px; }
.tl-rail.on { display: flex; }
.tl-mark {
  display: grid;
  place-items: center;
  width: 14px; height: 14px;
  opacity: .8;
}
.tl-mark img { width: 100%; height: 100%; object-fit: contain; }
.tl-mark svg { width: 100%; height: 100%; display: block; }
/* The mode's glyph, one step back from a document's mark beside it: what a task
   is to be done with is a smaller fact than what it is to be done to. */
.tl-mode { color: var(--chrome-dim); opacity: 1; }
.tl-mark img.full { border-radius: 3px; object-fit: cover; }

/* ---- the two tags at the end of the line ----
   The same shape as the controls in the creator and the two in the bar, one
   size down. The assign one used to be a dashed ring, which is a picture of an
   absent face — a riddle where a word would do. */
.tl-marks {
  flex: none;
  display: flex;
  align-items: center;
  gap: 4px;
}

.tl-tag {
  max-width: 112px;
  height: 22px;
  padding: 0 7px;
  gap: 4px;
  border-color: transparent;
  background: none;
  color: var(--chrome-dim);
  font-size: 11.5px;
  font-variant-numeric: tabular-nums;
  transition: background .12s ease, color .12s ease, border-color .12s ease, opacity .12s ease;
}
.tl-tag svg { width: 12px; height: 12px; flex: none; }
.tl-tag-word {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* An offer rather than a control: a tag is not in the room until you are
   pointing at the row it would change. What is already set stays visible,
   because a timer you cannot see is a timer that goes off by surprise, and a
   name you cannot see is a task you think is still yours.

   The clock is the exception, and it is an exception this feature earned. It is
   no longer only a timer — it is where a task is told to happen every morning,
   which is the whole of what used to be a Routines section with a heading on it.
   A control that is the only way to reach a feature cannot be invisible until
   the pointer happens to cross the row it lives on: that is not restraint, it is
   a feature nobody can find. So it sits there quietly at rest and comes up to
   full under the pointer like everything else. */
.tl-tag:not(.set) { opacity: 0; }
.tl-when:not(.set) { opacity: .38; }
.tl-row:hover .tl-tag,
.tl-tag.set,
.tl-tag:focus-visible { opacity: 1; }

.tl-tag.set {
  background: var(--chrome);
  border-color: var(--chrome-edge);
  color: var(--chrome-ink);
}
/* Square while there is no word in them to give the padding a reason: the same
   box the composer's schedule button is drawn in, one size down, and the two of
   them read as a pair of marks rather than as two controls. The clock grows a
   word once a time is set, and takes ordinary padding back with it. */
.tl-when, .tl-bin {
  width: 22px;
  padding: 0;
  justify-content: center;
}
.tl-when.set {
  width: auto;
  padding: 0 8px 0 6px;
  justify-content: flex-start;
}
/* Nothing there is not an empty word taking a gap. Same trap the composer's
   clock fell into: an empty span is still a flex item, and the gap in front of
   it is still spent, so the icon centres itself in a box wider than the one it
   is drawn in. */
.tl-when:not(.set) .tl-tag-word { display: none; }
/* The one destructive thing on the card, and it says so only when you are
   pointing at it. Grey at rest, because a red mark on every row of a list is a
   list that looks like it is warning you about something; red under the pointer,
   because by then you are about to press it and it is the last moment the colour
   can still mean anything. */
.tl-bin:hover {
  background: color-mix(in srgb, #c4483a 8%, transparent);
  border-color: color-mix(in srgb, #c4483a 30%, transparent);
  color: #c4483a;
}
/* Grey, not the accent it wore. Once the row says how long in words there is
   nothing left for a colour to tell anybody, and orange on every armed task
   made a list of scheduled work look like a list of warnings. The neutral fill
   .tl-tag.set already carries is enough to say it is holding something. */
.tl-who.set { padding-left: 3px; }

.tl-row.running .tl-when {
  color: var(--sel);
  border-color: color-mix(in srgb, var(--sel) 32%, transparent);
  opacity: 1;
}
.tl-row.running .tl-when svg { animation: tlpulse 1.6s ease-in-out infinite; }
@keyframes tlpulse { 50% { opacity: .45; } }

/* A squircle, and the same one a message's avatar is clipped to — the
   superellipse |x|^5 + |y|^5 = 1, written out as eight cubics because the
   one-line way to say it is in the newest Chromium and in no WebKit. The
   numbers are .block-avatar's own, scaled by 16/40, so the two shapes are one
   shape at two sizes rather than a rounded square that nearly matches it.

   No border-radius under it, deliberately: the picture inside takes its corner
   from `border-radius: inherit`, and any radius here would intersect the clip
   and hand the corners back to the circular arc this is here to avoid. */
.tl-face {
  flex: none;
  display: grid;
  place-items: center;
  width: 16px; height: 16px;
  clip-path: path("M16 8C16 13.96 15.68 14.25 14.96 14.96C14.25 15.68 13.96 16 8 16C2.04 16 1.75 15.68 1.04 14.96C0.32 14.25 0 13.96 0 8C0 2.04 0.32 1.75 1.04 1.04C1.75 0.32 2.04 0 8 0C13.96 0 14.25 0.32 14.96 1.04C15.68 1.75 16 2.04 16 8Z");
  background: var(--chrome-sunk);
  color: var(--chrome-ink);
  font: 600 9px/1 var(--ui);
  user-select: none;
}
/* A body rather than a person: the same face it wears as a cursor, so the row
   and the agent crossing the canvas are recognisably the same colleague. */
.tl-face-agent { background: var(--accent); color: #fff; }
.tl-face-agent img { padding: 1px; background: var(--chrome); }
/* A real photograph fills the whole shape, so the letter ground behind it never
   shows as a ring — the same rule .avatar-img follows on a message. */
.tl-face:has(.avatar-img) { background: none; }

/* ---- nothing on it yet ----
   Centred, which is the one place on this card where the gutter is the wrong
   answer. Everything else here lines up on the left because it is one of a
   column of things; this is not in that column, it is standing in for the whole
   of it. A line hanging off the left margin of an empty box reads as the first
   item of a list that failed to load — in the middle, it reads as the box
   saying there is nothing in it. */
.tl-empty {
  display: none;
  padding: 22px var(--tl-gut);
  color: var(--faint);
  font: 400 12.5px/1.5 var(--ui);
  text-align: center;
  text-wrap: balance;
}
/* Equal above and below, which takes three rules rather than one because two
   other things were contributing space that only exists when there are rows.
   The composer's bottom margin is the gap before the first task and the rows
   box carries the padding under the last one; with no tasks, neither is
   separating anything, and both were stacking on top of the line's own room.
   So the empty state owns the whole of its space and sits in the middle of it. */
.tasklist.bare .tl-new { margin-bottom: 0; }
.tasklist.bare .tl-rows { padding: 0; }
.tasklist.bare .tl-empty { display: block; }

/* Keyboard only, and the same ring every other control in this app answers Tab
   with: the pointer already says which one is under it, and a permanent outline
   on a card of eight controls is eight outlines. */
.tl-ctl:focus-visible,
.tl-tick:focus-visible {
  outline: none;
  border-color: color-mix(in srgb, var(--sel) 55%, transparent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--sel) 18%, transparent);
}

/* An app already on the task being written, said in the menu that would
   otherwise offer it again. The menu is single-choice by construction and this
   one is not, so the row carries the state a tick would have carried. */
.dd-added {
  margin-left: 6px;
  padding: 1px 5px;
  border-radius: 4px;
  background: var(--chrome-sunk);
  color: var(--chrome-dim);
  font-size: 10.5px;
  font-weight: 500;
}
/* ---- the provider marks, monotone ----
   The three logos arrive as the companies that own them draw them: two black
   wordmarks and one four-colour gradient. Side by side in a menu that is
   otherwise all one ink, that reads as three different kinds of thing rather
   than three of the same thing, and the coloured one reads loudest — which is a
   claim about importance nobody meant to make.

   So they are drawn as SILHOUETTES rather than as pictures: the PNG is the mask
   and the colour is ours, which is why all three had to carry an alpha channel
   and do. A filter would have been the other way and a worse one — grayscale()
   on a gradient gives mud rather than a mark, and no filter turns a black logo
   white without also inverting whatever it is sitting on. */
.pv-logo {
  display: inline-block;
  width: 15px;
  height: 15px;
  flex: none;
  background-color: var(--logo-ink);
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-size: contain;
  mask-size: contain;
}
/* The ?v is not decoration. server/index.js serves /client/logos with a day of
   max-age and a week of stale-while-revalidate, deliberately — these are the
   same handful of files for everybody and a cold Mac app spends 200ms per mark
   without it. The cost of that bargain is that replacing a file in place changes
   nothing anybody can see for a day, which is exactly what happened the first
   time one of these was swapped.

   So the URL carries the version and the file keeps its name: BUMP THE NUMBER
   WHEN YOU REPLACE THE PICTURE. Only the one that changed needs it — bumping
   all three would throw away two caches that are still correct. */
.pv-anthropic {
  -webkit-mask-image: url('/client/logos/provider-anthropic.png?v=1');
  mask-image: url('/client/logos/provider-anthropic.png?v=1');
}
.pv-openai {
  -webkit-mask-image: url('/client/logos/provider-openai.png?v=1');
  mask-image: url('/client/logos/provider-openai.png?v=1');
}
.pv-google {
  -webkit-mask-image: url('/client/logos/provider-google.png?v=2');
  mask-image: url('/client/logos/provider-google.png?v=2');
}
/* The one provider that is an address rather than a company has no mark, and a
   gap where the others have one is what keeps the names in a column. */
.pv-none { display: inline-block; width: 15px; height: 15px; flex: none; }

/* A second line's worth of information on one line: what a skill is for, beside
   what it is called. Quiet enough that the names still read as the list. */
.dd-dim {
  margin-left: 6px;
  color: var(--chrome-dim);
  font-weight: 400;
}
/* The app marks in that menu, at the size the other icons in it are drawn. */
.dd-opt .cx-logo { width: 16px; height: 16px; object-fit: contain; }

/* There was a task mode here, and a crosshair for it: a click on the canvas put
   the card down or added a line to it. The list is a panel now — see
   ui/sidebar.js — so there is no gesture on the plane left to describe. */

/* ---- the Mac app ---------------------------------------------------------
   Everything below is behind [data-desktop], which core/desktop.js sets only
   when it finds a Tauri shell around the page. The same stylesheet is served
   to the browser, where none of it matches and the canvas stays full-bleed.

   The window draws no title bar. Instead there is a strip of chrome across the
   top holding the traffic lights and the tabs, and the canvas sits below it as
   a rounded card inset from the three remaining edges. */

/* The tint on the glass behind the card.

   It was a flat grey, and it was the whole frame: an opaque wash painted over
   everything the card did not cover. The shell now hands this page a
   transparent window with an NSVisualEffectView under it — the desktop behind
   the app, blurred by the compositor — so the frame has something real to be
   made of, and what is left for CSS is how much of it to let through.

   Barely more than a wash, because the material is the point. Enough to keep
   the strip's own writing legible over a wallpaper that could be anything, and
   not so much that it goes back to being a grey rectangle with a blur behind
   it. Cold in the light theme like every other control surface, warm-black in
   the dark one so it deepens the material rather than fogging it. */
:root { --frame: rgb(238 241 245 / .5); }
:root[data-theme="dark"] { --frame: rgb(28 32 37 / .5); }

/* One curve and one duration for the whole shell, so the panel arriving, the
   card's edge moving and the canvas travelling under it are three views of a
   single movement rather than three animations that happen to overlap.

   Not --ease. Both are ease-outs, and the difference between them is entirely
   in how much they spend at the start: --ease is cubic-bezier(.16, 1, .3, 1)
   and has covered 49% of the distance one tenth of the way through, where this
   one has covered 27%. That is right for a menu snapping open in 140ms and
   wrong for a panel the width of a sidebar, which at that speed has arrived
   before the eye has registered it leaving. This is the curve Apple's own
   sheets move on, over 380ms rather than 220 for the same reason: the further a
   thing travels, the longer it has to be seen travelling.

   core/desktop.js evaluates these same four numbers to move the canvas, because
   the card's edge and the work under it have to arrive together. */
:root {
  --shell-ease: cubic-bezier(.32, .72, 0, 1);
  --shell-ms: 380ms;
}

/* The page, no longer painting the window.

   Everywhere the card below does not reach, this page now paints nothing at
   all — which is the entire mechanism. The window is transparent and there is
   an NSVisualEffectView under the webview, so a pixel the page leaves alone is
   a pixel of the desktop behind the app, blurred by AppKit. A background on
   `html` or `body` is a sheet of paint over the whole of that, and it is what
   was there before. */
html[data-desktop], html[data-desktop] body { background: transparent; }

/* The paper. One rectangle, the only opaque thing in the window, and the thing
   every other rule in this section is arranged around.

   It sits UNDER the plane rather than over it — core/desktop.js puts it first
   in the body for exactly that reason — so the canvas is drawn on it the way
   ink is drawn on paper. Nothing about the plane changes: it still fills the
   window, still scrolls with it, and core/viewport.js still measures
   innerWidth. What used to be a hole cut in a grey sheet is now a sheet the
   size of the hole. */
html[data-desktop] #desktop-card {
  position: fixed;
  top: 38px;
  left: 8px;
  right: 8px;
  bottom: 8px;
  border-radius: 12px;
  background: var(--bg);
  pointer-events: none;
  z-index: 0;
  transition: left var(--shell-ms) var(--shell-ease);
}

/* The glass, drawn as a ring rather than a sheet.

   The shape is the border of a box 40px larger than the window on every side:
   78px along the top for the strip, 48px down the other three sides. Doing it
   with a border rather than four rectangles is what gets the inside corners
   right for free — CSS rounds the padding box by the outer radius minus the
   border, so 60/90 at the top leaves exactly the card's own 12px, and 60/60 at
   the bottom leaves the same 12 again. Four rectangles would leave a square
   nick at each corner of the card with the canvas showing through it.

   The 40px of overshoot is what keeps that trick from deciding the window's
   own corners as well. The two radii are locked together — the inside one is
   whatever the outside one is minus the border, and the border at the top is
   38 — so a 12px corner on the card forced a 20 by 50 ellipse at the top of
   the window, which is not a shape any Mac window has. It showed: the page
   painted nothing along the first 20px of either top corner, and the material
   there came through untinted while the window went on being square. Grown by
   40, the element's own corners are off-screen on all four sides and every
   pixel the window has is painted. What rounds the result is the window,
   which is the only thing that knows the system's radius and follows it when
   the next macOS changes it.

   Then the mask cuts the middle out: two layers, one clipped to the border box
   and one to the padding box, combined with XOR. Without it the tint would lie
   across the whole element — the card included — and grey out the canvas
   somebody is working on.

   The blur that goes under this tint is NOT here, and that is a performance
   fix rather than a tidiness one. See .desktop-blur below. */
html[data-desktop] #desktop-frame {
  position: fixed;
  inset: -40px;
  z-index: 95;
  pointer-events: none;
  border: solid transparent;
  border-width: 78px 48px 48px;
  border-radius: 60px / 90px 90px 60px 60px;
  background: var(--frame);
  -webkit-mask-image: linear-gradient(#000, #000), linear-gradient(#000, #000);
  mask-image: linear-gradient(#000, #000), linear-gradient(#000, #000);
  -webkit-mask-clip: border-box, padding-box;
  mask-clip: border-box, padding-box;
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  transition: border-left-width var(--shell-ms) var(--shell-ease),
              border-radius var(--shell-ms) var(--shell-ease);
}

/* Where the glass stops and the paper starts. On the frame rather than on the
   card, because the card is under the plane and a hairline drawn down there
   would be behind the canvas — and behind the blur, which would soften it into
   a glow. An absolutely positioned child is laid against the padding box, so
   `inset: 0` here is the card's own edge, and the 1px sits just outside it
   where the mask still lets paint through. */
html[data-desktop] #desktop-frame::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 12px;
  box-shadow: 0 0 0 1px var(--line);
}

/* Room made for the panel. The card's left edge moves to clear it — 8 of inset,
   248 of panel, 8 of gap — and core/desktop.js walks the canvas across by the
   same 256 on the same curve, so the work moves with the edge instead of being
   covered by it.

   The glass grows by the same amount from the other side: its left border is
   the gap plus the 40 of overshoot, so it becomes 304 and the two left radii
   grow with it to keep the inside corners at 12. The panel itself is opaque
   and sits over most of what that widening covers; what it does not cover is
   the 8px of glass between it and the card. */
/* The blur, as four bands instead of one window.

   The glass is a ring, and a ring is a shape a browser has no way to filter.
   `backdrop-filter` reads the backdrop of the element's BOX — so on #desktop-frame
   above, which is the window plus 40px on every side, what it asked for every
   frame was a 24px blur and a saturate over an area larger than the window,
   only to throw all but a thin border of the result away at the mask. The mask
   is what makes the ring; it is not what makes the ring cheap.

   That is affordable on a static page and this is not one. The plane runs edge
   to edge underneath, so the backdrop changes on every pan, every zoom, every
   agent that crosses the canvas and every keystroke that repaints a block — and
   each of those is a full-window blur, on the compositor, in a webview that is
   already compositing against an NSVisualEffectView because the window is
   transparent. It is the single most expensive thing the Mac app does that the
   browser build does not do at all, which is exactly the shape of "the web one
   is fine and the app drags".

   So the tint and the shape stay on the ring, where the mask can cut the middle
   out, and the blur moves down here onto four rectangles that cover the four
   margins and nothing else. A 1440x900 window filters about 66,000px² instead
   of about 1,500,000 — the same glass, drawn over a twentieth of the area.

   The bands are square and stop at the card's bounding box, so they never blur
   the canvas inside the card. What that costs is the four concave slivers where
   the card's 12px corners pull away from that box: about 30px² each, still
   painted by the ring's tint on top, and merely unblurred. It is the corner nick
   that ruled out drawing the ring itself as four rectangles, and it is nothing
   at all when the thing being drawn is a blur rather than the glass.

   No overshoot, because these have no corners of their own to hide: the ring
   needs its 40px so its radii fall off-screen, and a square band does not. */
html[data-desktop] .desktop-blur {
  position: fixed;
  z-index: 94;
  pointer-events: none;
  -webkit-backdrop-filter: blur(24px) saturate(180%);
  backdrop-filter: blur(24px) saturate(180%);
}

/* The four margins, meeting at the corners and never overlapping — two blurs
   over one place would blur the blur, and the window's own corners would be
   visibly softer than its edges. The top and bottom run full width; the sides
   take what is left between them. */
html[data-desktop] .desktop-blur.top { left: 0; right: 0; top: 0; height: 38px; }
html[data-desktop] .desktop-blur.bottom { left: 0; right: 0; bottom: 0; height: 8px; }
html[data-desktop] .desktop-blur.left { left: 0; width: 8px; top: 38px; bottom: 8px; }
html[data-desktop] .desktop-blur.right { right: 0; width: 8px; top: 38px; bottom: 8px; }

/* And the left one widens with the card, on the same curve as everything else
   the panel moves, so the glass beside the panel is glass the whole way out.
   The panel is opaque over most of this; the 8px between it and the card is the
   part anybody sees. */
html[data-desktop] .desktop-blur.left {
  transition: width var(--shell-ms) var(--shell-ease);
}
html[data-desktop][data-canvases] .desktop-blur.left { width: 264px; }

html[data-desktop][data-canvases] #desktop-card { left: 264px; }
html[data-desktop][data-canvases] #desktop-frame {
  border-left-width: 304px;
  border-radius: 316px 60px 60px 316px / 90px 90px 60px 60px;
}

/* No room is made for the browser, because it is not beside the canvas.

   It was, for a while: half the window, with the card's right edge left where
   it was and the panel drawn flush onto it so the two halves read as one sheet
   of paper rather than two applications. What is left of that arrangement is
   this note. A page is a block on the plane now, so it takes no width from the
   window, needs no inset out of the chrome, and moves nothing when it opens.
   See canvas/browser.js. */

/* ---- the fill under the tab that is lit (ui/glide.js) ---------------------
   Three strips share this rule: the canvases in the Mac title bar below, the
   pages inside a browser block, and Tasks / Agents / Code in the side panel.
   All three used to move their selection by painting a background onto one tab
   and taking it off another, which is not a move at all. It is one shape
   destroyed and another created a few pixels away, and no length of fade makes
   two shapes read as one. So the fill left the tabs: it is this, one per strip,
   behind the words, and it travels.

   Placed by transform rather than by `left`, because a transform is the one
   thing a browser will animate without touching layout. `width` is animated
   beside it and cannot be, but the box is out of flow, so the only thing that
   width reflows is itself. Not scaleX, which is the usual trick to avoid even
   that: scaling this box would scale the radius with it, and a pill whose ends
   go oval on the way across is a worse thing than the frame it saves.

   No will-change here, deliberately, and the sweep in ui/styles.test.mjs is why
   it stays that way. One of these strips is inside a block on the plane, and
   the plane is scaled; a layer pinned for a 300ms slide would be pinned at
   whatever zoom it was first rasterised at, and the tab under it would be sharp
   at one magnification and soft at every other. */
.glide {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 0;
  width: 0;
  pointer-events: none;
  border-radius: 7px;
  background: var(--chrome-sunk);
  opacity: 0;
  transition:
    transform .32s var(--ease),
    width .32s var(--ease),
    opacity .12s ease;
}
/* Only once it is standing on a tab. A strip whose selection has gone — a
   browser block rebuilding its pages, a panel not yet opened — has no fill and
   its tabs take their own back, so there is never a frame with nothing lit.
   ui/glide.js puts this class on the strip and takes it off. */
.glided > .glide { opacity: 1; }

/* And the words sit over it rather than under. Both are positioned, so tree
   order would otherwise decide, and the fill is appended last. */
.dt-tab, .side-tab, .wb-tab { position: relative; z-index: 1; }

@media (prefers-reduced-motion: reduce) {
  .glide { transition: opacity .12s ease; }
}

/* The strip. Also the title bar: it drags the window, and a double click on it
   zooms the window, both of them AppKit's own gestures rather than ours. Tauri
   acts only when the event target is itself a marked element, so the bar and
   .dt-rest each carry the attribute — see the note beside it in ui/tabs.js —
   and every button in between stays a button. */
#desktop-tabs {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 38px;
  z-index: 102;
  display: flex;
  align-items: center;
  gap: 2px;
  /* Room for the three lights. tauri.conf.json places the group at x: 20, and on
     Tahoe a button is 14pt across with 23pt between them, so it runs 20 → 80 —
     where this said 84, the number from when they were 12pt and 20 apart, and
     left 4px between the green one and the first control. 12px of air, the same
     as the gap the strip's own controls keep from each other's edges. */
  padding: 0 8px 0 92px;
  /* Their height is the same fact from the other side, and it is set in
     tauri.conf.json because only the shell can place them. A tab is 26px in
     this 38px strip, so its middle is 19 and a 14pt light — the Tahoe size,
     where it used to be 12 — wants its top at 12. tao sizes the titlebar
     container to `button height + y` and leaves the button 9pt up from the
     bottom of it, so the top of a light lands at y − 9: y: 21. It was 17, which
     put them 4px high, which is a light and a half above where a tab is. */
  font: 500 12.5px/1 var(--ui);
  color: var(--chrome-ink);
  user-select: none;
}

/* Fills the rest of the bar so there is always somewhere to grab, and carries
   the mode readout that ui/docbar.js would otherwise be holding. */
.dt-rest {
  flex: 1 1 auto;
  align-self: stretch;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  /* The readout and the task button were touching, which read as one control
     with a seam down it. 3px is enough to say they are two. */
  gap: 3px;
  padding-right: 4px;
  min-width: 24px;
}

.dt-strip {
  position: relative;
  display: flex;
  align-items: center;
  gap: 2px;
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
}
.dt-strip::-webkit-scrollbar { display: none; }

.dt-tab {
  flex: 0 1 auto;
  min-width: 0;
  max-width: 190px;
  height: 26px;
  /* The hover, and the word going from dim to full as the fill arrives under
     it. Both were a hard swap, which in a strip where everything else now moves
     is the one thing left blinking. */
  transition: background-color .16s var(--ease), color .22s var(--ease);
  display: flex;
  align-items: center;
  gap: 4px;
  /* Even on both sides. The close button is a 16px box around a 12px glyph, so
     its 2px of internal air is what makes the right side read as level with
     the left rather than tighter than it. */
  padding: 0 8px;
  border-radius: 7px;
  color: var(--chrome-dim);
  cursor: pointer;
  user-select: none;
}
.dt-tab:hover { background: var(--chrome-hover); }
/* The one being looked at is filled rather than outlined, so the bar reads as
   one surface with a single hole punched in it. */
.dt-tab.on { background: var(--chrome-sunk); color: var(--chrome-ink); }
/* Once the fill is a thing of its own, the tab stops carrying one. Kept as two
   rules rather than one so the strip is right either way: the fill is placed by
   script, and a script that has not run yet is a strip that still lights its
   own tab. See .glide above. */
.dt-strip.glided .dt-tab.on { background: none; }
.dt-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  /* The strip sets 12.5px/1, and a line box exactly as tall as the type has
     nowhere to put a descender: overflow: hidden then cuts the tail off every
     g, y and p. Engineering lost its g to this. The name gets room of its own
     instead, and the flex row goes on centring it. */
  line-height: 1.5;
  padding-block: 1px;
}

/* Holds its place whether or not it is showing, so a row of tabs does not
   shuffle sideways under the pointer as it crosses them. */
.dt-x {
  flex: none;
  width: 16px;
  height: 16px;
  display: grid;
  place-items: center;
  border: 0;
  padding: 0;
  border-radius: 5px;
  background: none;
  color: inherit;
  opacity: 0;
  cursor: pointer;
}
.dt-x > svg { width: 12px; height: 12px; }
.dt-tab:hover .dt-x, .dt-tab.on .dt-x { opacity: .6; }
.dt-x:hover { background: var(--chrome-sunk); opacity: 1; }
/* It appears and disappears on every crossing of the strip, so it fades rather
   than flicking. Short: this is feedback about where the pointer is, and
   feedback that outlasts the movement stops being feedback. */
.dt-x { transition: opacity .14s ease, background-color .14s ease; }

.dt-btn {
  flex: none;
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
  border: 0;
  padding: 0;
  border-radius: 7px;
  background: none;
  color: var(--chrome-dim);
  cursor: pointer;
}
.dt-btn > svg { width: 16px; height: 16px; }
.dt-btn:hover { background: var(--chrome-hover); color: var(--chrome-ink); }
.dt-btn { transition: background-color .14s ease, color .14s ease; }
.dt-all { margin-right: 4px; }

/* A canvas is on its way, and the page is about to be replaced. The strip has
   already lit the tab being opened, so the only thing left to do is stop
   accepting gestures it cannot honour — a second click during the load would
   queue a navigation the first one is already making pointless. */
#desktop-tabs.leaving .dt-btn,
#desktop-tabs.leaving .dt-tab { pointer-events: none; }

/* The chrome that sat 14px from the window's edges now sits 14px from the
   card's, which is 8px in and 38px down. Setting the two constants moves every
   control and every menu hanging off one, which is the whole point of their
   being constants: the share menu opened on top of its own button because this
   was three rules naming three controls, and a menu is not a control. */
html[data-desktop] {
  --chrome-inset: 22px;
  --chrome-top: 52px;
}

/* #docbar is not drawn at all. The name of the canvas is in a tab, the mode
   readout is in the strip, and core/desktop.js has moved the panel itself onto
   the body — so the bar has nothing left to hold and no reason to occupy a
   box. It stays in the DOM because ui/docbar.js is still what owns the list of
   canvases, and that list is what the strip's leftmost button opens: one
   implementation of the switcher rather than a second written for this window.

   Collapsing it to a zero-height anchor was the previous attempt, and it left a
   fragment of the bar showing over the first-run screen. A thing that is not
   rendered cannot show through anything. */
html[data-desktop] #docbar { display: none; }

/* The bare drag band, under the strip and covering the same 38px. It exists
   from the moment the page does, where the strip cannot: a tab is a canvas
   with a name, and neither is known until a session is. Above the auth gate
   (100) and below the strip (102), so the window is movable at every point in
   the app's life including the sign-in it starts at. */
#desktop-grip {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 38px;
  z-index: 101;
}

/* No scrollbars. The plane is 100000px square and the window is what scrolls
   it, so macOS has every reason to draw an overlay scrollbar down the right
   edge — and it lands across a rounded card, which reads as the card being
   broken rather than as the canvas being large. Figma has none of these for
   the same reason: on an infinite plane a scrollbar measures nothing. */
html[data-desktop], html[data-desktop] body { scrollbar-width: none; }
html[data-desktop]::-webkit-scrollbar,
html[data-desktop] body::-webkit-scrollbar { width: 0; height: 0; }

/* ---- the canvases panel (Mac app) ----------------------------------------
   The same menu, stood up as a sidebar.

   It is the same DOM ui/docbar.js has always built. Only the geometry changes:
   a column down the left of the card instead of a dropdown hanging off a bar
   that no longer exists in this window. The list keeps its own scroll and the
   key and team panels stay pinned under it, which is what the dropdown did by
   capping the list — and what ui/tour.js depends on, since it points at the
   key card and cannot point at something scrolled out of sight.

   Minimal on purpose: no card borders, no shadows, no nesting. One surface,
   hairlines between the three things it holds, and the same left corner radius
   as the card it sits against. */
html[data-desktop] .db-menu {
  position: fixed;
  top: 38px;
  left: 8px;
  bottom: 8px;
  width: 248px;
  z-index: 96;
  display: flex;
  flex-direction: column;
  gap: 0;
  padding: 4px 0 6px;
  /* An island, not a flap on the side of the card: its own four corners, its
     own hairline, and 8px of the window's own surface showing between it and
     the canvas. Two objects on one ground reads as a shell; one shape with a
     seam down it reads as a card that has been cut. */
  border-radius: 12px;
  background: var(--chrome);
  border: 1px solid var(--line);
  opacity: 1;
  pointer-events: auto;
  /* Slides rather than fades. A panel this size arriving at full opacity in one
     frame reads as the window changing shape. The extra 8px carries it past the
     card's own inset so no edge of it is left showing when it is away. */
  transform: translateX(calc(-100% - 8px));
  transition: transform var(--shell-ms) var(--shell-ease);
  /* Kept on its own layer. The panel is opaque and full-height, and promoting
     it means the slide is a composite rather than a repaint of everything it
     crosses. */
  will-change: transform;
}
html[data-desktop] .db-menu.on { transform: none; }

/* Flat. The cards were cards because they floated over a canvas; inside a
   panel they are sections of one surface, and a border around each would be
   three boxes in a box. */
html[data-desktop] .db-menu .db-card {
  flex: none;
  padding: 6px 8px;
  background: none;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  max-height: none;
  overflow: visible;
}
html[data-desktop] .db-menu .db-card + .db-card {
  margin-top: 8px;
  padding-top: 12px;
  border-top: 1px solid var(--chrome-edge);
}

/* The list is the only part that can run long, so it is the only part that
   gives — the key and team panels hold their height at the bottom of the
   column however many canvases there are. */
html[data-desktop] .db-menu .db-list {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
}


/* Fullscreen takes the traffic lights with it, and the 84px of room made for
   them at the start of the strip becomes a hole. core/desktop.js sets the
   attribute from the shell's own answer: the webview is full-size in both
   states and has no way to tell them apart by measuring. */
html[data-desktop][data-fullscreen] #desktop-tabs { padding-left: 8px; }

/* The way out of the first run, in a window whose top 38px is not the page.
   In a browser "Back to sign in" sits at 20,20, which is the corner of the
   screen. Here that corner is the tab strip, and the frame's mask (95) outranks
   the first run (70) — so the button was drawn under the window's chrome with
   an 8px sliver of its bottom edge showing through the card, which read as
   debris rather than as a control. It moves to the card's own top-left corner:
   clear of the strip, clear of the 12px radius, and the only door out of this
   flow for somebody signed in as the wrong person is a whole button again.

   And it loses its outline there. In a browser the box is what separates a
   small grey control from the page it is floating on; in this window the strip
   is already a hard edge two rows above it, and a second bordered box tucked
   under the traffic lights read as a fragment of the chrome rather than as a
   link. Words and an arrow, close in to the card's edge. The height stays, so
   what you press is still a control's worth of target. */
html[data-desktop] .ob-back {
  top: 46px;
  left: 18px;
  padding: 0;
  border: 0;
}

/* ---- colleagues you build ------------------------------------------------
   The agents tab IS the task card. Its host carries `tasklist` as well as `mk`
   — see mountBuiltAgents() — so the bar, the box you write in, the chips under
   it, the send, the rows, the faces and the marks down the right are the card's
   own rules rather than a second set that agrees with them this week. Change how
   a task row looks and an agent row changes with it.

   What is left here is only what an agent has and a task does not: two fields in
   the composer where a task needs one, a face where the checkbox goes, and a
   swatch. */

/* Two lines in one box: what it is called, and what it is for.
   A task is one sentence, so its composer is one field. An agent is a name AND a
   paragraph, and those are different kinds of thing — so the name is set at the
   weight of a name and the brief reads underneath it in the same box, which is
   how a note on this canvas carries a title above its text. */
.mk-name-field,
.mk-role-field {
  padding: 0 11px;
  outline: none;
  color: var(--ink);
  overflow-wrap: anywhere;
  white-space: pre-wrap;
}
/* Regular, not medium. It was set at the weight of a title on the argument that
   a name is one — and above a rule, in its own line, it is already plainly the
   title of the thing. The weight was doing nothing the position was not, and it
   made the line read as a heading printed on the box rather than as a field
   waiting for words. */
.mk-name-field {
  padding-top: 10px;
  padding-bottom: 8px;
  font: 400 13.5px/1.4 var(--ui);
  letter-spacing: -.004em;
}
/* The same box the task composer is, to the pixel: 11 above, 8 below, and 44 of
   field between them. Two cards side by side in one panel that are nearly the
   same height are worse than two that are obviously different, and this one had
   drifted about twenty pixels shorter — enough to notice when the tabs are
   switched and not enough to look deliberate. See .tl-new-field. */
.mk-role-field {
  padding: 11px 11px 8px;
  min-height: 44px;
  max-height: 160px;
  overflow-y: auto;
  font: 400 12.5px/1.5 var(--ui);
  color: var(--chrome-ink);
}
/* The name is a line you ask for, not the first thing you are asked.
   What you know when you open this is what you want done; what to call the thing
   that does it is an afterthought, often a good one and always an afterthought.
   So the brief is the field and the title line arrives above it, ruled off, when
   the Name chip is pressed. */
.mk-title {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  padding-right: 8px;
  border-bottom: 1px solid var(--chrome-edge);
}
.mk-title[hidden] { display: none; }
.mk-title .mk-name-field { flex: 1; min-width: 0; }

/* What it will be called if the line is left empty, standing in the line itself
   — a suggestion somebody can see and overrule rather than a name that turns up
   on a list afterwards. Written from the brief as it is typed; see nameFrom(). */
.mk-name-field:empty::before {
  content: attr(data-stand);
  color: var(--faint);
  pointer-events: none;
}

.mk-unname {
  flex: none;
  width: 22px; height: 22px;
  margin-top: 8px;
  display: grid;
  place-items: center;
  background: none;
  border: 0;
  border-radius: 6px;
  color: var(--chrome-dim);
  cursor: pointer;
}
.mk-unname:hover { background: var(--chrome-sunk); color: var(--chrome-ink); }
.mk-unname svg { width: 13px; height: 13px; }

.mk-role-field:empty::before {
  content: 'What it does, and how you want it to work';
  color: var(--faint);
  pointer-events: none;
}
/* With the title line open, the brief gives up its floor.
   A box that grows by forty pixels because somebody pressed Name is a box that
   jumps, and what it is growing to hold is one line of text. So the field keeps
   its padding and drops its 44px minimum to a single line: the box grows by the
   line that was added and by nothing else, which is the smallest it can honestly
   get away with. */
.mk-title:not([hidden]) + .mk-role-field {
  padding-top: 10px;
  min-height: 19px;
}

/* Nothing in this box may leave it.
   The chips ran out of the side of the panel for a while — five of them and a
   send in a row built for three, because Skills and Model each arrived on their
   own and neither added up the row. Two of the five are gone now and the
   arithmetic is comfortable again, so what is left here is the floor rather than
   the fix: a chip may shrink and clip its own word rather than push the row
   past the edge. Nothing should ever reach it at 420px. It is what makes the
   next chip somebody adds a slightly narrow row instead of a broken one. */
.mk .tl-new-foot { min-width: 0; }
.mk .tl-chip { min-width: 0; }
.mk .tl-chip-word {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Whether this colleague is working, where a task has its checkbox.
   There was a letter in a coloured square here and it was an ornament: the name
   is on the same line six pixels to its right, so the square said it again in one
   character. What the row could not say at all is the thing this says — an agent
   switched off is offered to nothing and its tasks wait, and it is still yours,
   with its brief and its tools, one press from working again. */
.mk-switch {
  flex: none;
  position: relative;
  width: 26px; height: 16px;
  margin-top: 3px;
  padding: 0;
  background: color-mix(in srgb, var(--chrome-ink) 16%, transparent);
  border: 0;
  border-radius: 999px;
  cursor: pointer;
  transition: background .16s var(--ease);
}
.mk-switch.on { background: var(--sel); }
.mk-knob {
  position: absolute;
  top: 2px; left: 2px;
  width: 12px; height: 12px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 2px rgb(0 0 0 / .18);
  transition: transform .16s var(--ease);
}
.mk-switch.on .mk-knob { transform: translateX(10px); }
/* A row that is off says so once, quietly, rather than in every element on it.
   Not hidden and not struck through: it is not finished, it is not doing
   anything. */
.mk-row.off .tl-body,
.mk-row.off .tl-note,
.mk-row.off .tl-marks,
.mk-row.off .tl-rail { opacity: .5; }

/* What it may reach, as the apps rather than as a number.
   "+1" counts a thing whose identity is the whole question — which app can this
   touch is not answered by how many — so the marks stack the way faces do on
   anything shared: overlapped, each ringed in the panel's own colour so the edges
   read, four in the width two would take side by side. The row therefore does not
   grow a horizontal list as somebody connects more apps. */
/* A caption rather than a control: it says what this colleague can touch, and
   pressing it does nothing. There is no menu a stack of logos could open that
   would not be a control disguised as a caption, and the same list is one press
   away in the composer, which is where an agent is described.
   That is also why it stands last, in the corner beside the bin: the tags either
   side of it open menus, and a menu hanging off a control pinned to the panel's
   right edge has nowhere to open. The one unpressable thing takes the edge. */
.mk-stack {
  display: flex;
  align-items: center;
  padding: 0 2px;
  cursor: default;
}
.mk-stack[hidden] { display: none; }
.mk-chit {
  display: grid;
  place-items: center;
  width: 15px; height: 15px;
  flex: none;
  border-radius: 5px;
  background: var(--chrome);
  box-shadow: 0 0 0 1.5px var(--chrome);
}
.mk-chit + .mk-chit { margin-left: -5px; }
.mk-chit .cx-logo { width: 13px; height: 13px; object-fit: contain; }

/* What it thinks with, beside what it was taught. A tag rather than a chip,
   because it sits with the marks on the right of the row and has to be the
   quietest thing there: a model name is a detail on a row whose subject is a
   name and a brief. Wider than the tags elsewhere only because model names are
   long, and truncated rather than wrapped. */
.mk-mind {
  max-width: 96px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  display: block;
  line-height: 22px;
}
/* Given one of its own. Legible rather than loud — it is still a caption, and
   the difference it is drawing is between "mine" and "whatever theirs is". */
.mk-mind.on { color: var(--chrome-ink); }
.mk-row.off .mk-mind { opacity: .5; }

/* What it was taught, beside what it thinks with. Same weight as the model tag,
   because they are the same kind of statement about the same row. */
.mk-taught { white-space: nowrap; }
.mk-taught.on { color: var(--chrome-ink); }
.mk-row.off .mk-taught { opacity: .5; }

/* ---- the file a colleague is handed ----
   There were forty lines here: a composer with two fields and a raw view, a list
   of what you had written, a row per file with a bin on it. All of it was a
   place in this app where you WRITE a procedure, and nobody does. A skill is a
   file people already have, tuned for a coding agent and sitting in
   .claude/skills, so what is left is a chip that opens the picker and says what
   it is holding. It is a .tl-chip like the two beside it and needs nothing of
   its own beyond the one line below. See ../ui/colleagues.js. */
.mk-md { max-width: 46%; }
/* And it keeps its mark once it is holding something, which is the one place
   .tl-chip.set is wrong about a chip. That rule drops the glyph when a chip
   fills because the glyph is a "+" and a "+" is an offer. This one is a file,
   and a file is what the chip is still saying it holds. */
.mk-md.set { padding: 0 9px 0 6px; }
.mk-md.set > svg { display: block; }
/* The picker itself, which is never seen: an input the chip clicks. */
.mk-take { display: none; }

/* Past the fourth, a count — which is the one place a number is the right
   answer, because it is not standing in for anything. */
.mk-more {
  padding: 0 3px;
  width: auto;
  min-width: 15px;
  background: var(--chrome-sunk);
  color: var(--chrome-dim);
  font: 500 9.5px/1 var(--ui);
}

/* There was a colour picker here — a swatch chip on the composer, another on
   every row, and a menu of seven behind each. It is gone, and the colour is not:
   an agent is still one of the seven, chosen for it, on the face it wears
   everywhere. What went is the asking. Which colour a colleague happens to be is
   not a decision anybody wants to make while describing what it does, and it was
   the one control in that card standing in for a question rather than a fact.
   See freeHue() in agents/custom.js, which picks the lowest nobody is wearing. */

/* The bin is always there on an agent row, and visible without being hovered.
   On a task it is an offer that waits until you are pointing at the row, because
   the ordinary way to be rid of a task is to do it and there are twelve of them
   on a list. An agent is never "done": letting it go is the only way it ever
   leaves, there are rarely more than a few, and a control you have to discover by
   sweeping the pointer along a row is a control that is not there.

   The rule after `.tl-tag:not(.set)` above wins on source order rather than on
   weight — same specificity, later wins — which is worth knowing before that one
   is ever reordered. */
.mk-row .tl-bin {
  display: flex;
  opacity: 1;
}

/* ---- the task button -----------------------------------------------------
   Beside the mode readout, in whichever strip the shell puts that pair in, and
   built to the same measurements: the same height, the same corner, the same
   soft field. They are the two facts the corner states about how you are
   working — what a click does, and where the things you have not done are —
   so they read as one control with two halves rather than as a chip and a
   button that happen to be adjacent.

   A word, not an icon. Everything else up there is a glyph because it names
   something you can see; a list of what you owe yourself has no picture that is
   not a metaphor, and "task" is quicker to read than any of them are to decode. */

#taskBtn {
  flex: none;
  display: inline-flex;
  align-items: center;
  height: 22px;
  padding: 0 8px;
  border: 0;
  border-radius: 7px;
  background: var(--key-fill);
  color: var(--dim);
  font: 500 11px/1 var(--ui);
  cursor: pointer;
  transition: background .12s ease, color .12s ease;
}
/* Standing on its own in the browser's corner rather than inside the pill, it
   takes the pill's height and the pill's field: at 22px in a key-cap grey it
   read as a keycap that had fallen out of the readout next to it. Inside the Mac
   app's strip it stays the small chip it was, because there it IS one of several
   small chips. */
#topleft > #taskBtn {
  height: 34px;
  padding: 0 14px;
  background: var(--panel);
  font-size: 12px;
}
#topleft > #taskBtn:hover { background: var(--chrome-hover); }
#topleft > #taskBtn.on { background: var(--key-cap); }
#taskBtn:hover { background: var(--key-cap); }
/* Open, and staying the colour it already was.
   It was inverting to ink-on-chrome, which is what a control does when being on
   is the important fact about it — a mode, a filter, a recording light. This is
   none of those: the panel it opens is four hundred pixels of itself sitting on
   the right of the window, and no chip needs to shout over that. A black pill in
   a strip of soft grey ones was the loudest thing in the corner, arguing for the
   least surprising fact on screen.
   So it settles rather than flips: the same field one step down, which is the
   shade it already wears under the pointer. The state is legible because the
   chip is darker than its neighbours, and it is legible without introducing a
   second colour to the only strip in this app that has stayed two greys. */
#taskBtn.on {
  background: var(--key-cap);
  color: var(--dim);
}



/* ---- the side panel ------------------------------------------------------
   Your tasks and the colleagues you built, in one shell on the right.

   Fixed to the window rather than placed on the plane, and that is the whole
   design decision. What it gives up is being beside the work it is about. What
   it gets is being in the same place at every zoom, on every canvas, after every
   pan — and being able to open a menu without asking where on a scaled,
   scrolled plane one of its own controls has ended up, which is the question the
   Mac app answered differently from the browser and is why the schedule menu
   used to open halfway across the window there. See the note in ui/sidebar.js.

   Insets from the same two tokens every other panel uses, so it lines up with
   the top bar in the browser and with the card's edge in the Mac app without
   knowing which of those it is in. */

/* Wider than the 380 the card carried on the plane. The extra 40 is spent on one
   line: a task can now say when it repeats — "Daily 9:00am AEST" — beside the
   face of whoever does it, and at 380 that chip pushed the words of the task
   itself into an ellipsis. A token because three other rules have to know it:
   the two controls pinned to the right edge move by exactly this much when the
   panel opens. */
:root {
  --side-w: 420px;
  /* How far in from the right the chrome sits.
     Everything pinned to that edge reads this rather than --chrome-inset
     directly, so opening the side panel moves all of it in one line — the bar,
     the zoom readout, and both of the menus they open. That last part is the
     whole reason this is a token: the menus are fixed to the window and parented
     to <body> rather than to the controls they hang off, so moving the controls
     with a transform moved the buttons and left their menus behind the panel,
     where they opened correctly and invisibly. */
  --chrome-right: var(--chrome-far);

  /* How far in from the window's right edge the canvas's own chrome starts.
     The inset, which in the Mac app is 14px inside the card, and nothing else:
     it used to move for the browser panel, which took half the window and would
     otherwise have had the top bar and the zoom pill drawn over it — which in
     this window means BEHIND it, a native webview being above this document.
     A page on the plane cannot do that to the chrome. What it does instead is
     take itself away when the chrome is over it; see look() in
     canvas/browser.js. */
  --chrome-far: var(--chrome-inset);
}
body.sided { --chrome-right: calc(var(--chrome-far) + var(--side-w) + 12px); }

#side {
  position: fixed;
  top: var(--chrome-top);
  right: var(--chrome-far);
  bottom: var(--chrome-inset);
  z-index: 23;
  display: flex;
  flex-direction: column;
  width: var(--side-w);
  max-width: calc(100vw - var(--chrome-inset) - var(--chrome-far));
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 12px;
  box-shadow: var(--chrome-shadow);
  opacity: 0;
  transform: translateX(8px) scale(.99);
  transform-origin: 100% 0;
  pointer-events: none;
  transition: opacity .14s ease, transform .18s var(--ease);
}
#side.on {
  opacity: 1;
  transform: none;
  pointer-events: auto;
}

/* The two halves, as tabs rather than as a segmented control.
   A segmented control says "two settings of one thing"; these are two different
   things you keep in one place, which is what a tab strip says. */
.side-head {
  position: relative;
  flex: none;
  display: flex;
  gap: 2px;
  padding: 8px 8px 0;
}
.side-tab {
  height: 28px;
  padding: 0 10px;
  background: none;
  border: 0;
  border-radius: 7px;
  color: var(--chrome-dim);
  font: 500 12px/1 var(--ui);
  cursor: pointer;
  /* The word going from dim to full. The pill under it is no longer redrawn
     somewhere else, so it no longer has to fade: it is one shape that slides
     along the strip, and this is only what happens to the writing as it passes
     underneath. See .glide above. */
  transition: background-color .16s var(--ease), color .22s var(--ease);
}
/* Lighter than the selection now, where it used to be exactly it. With one
   fill sliding between three words, a hover that painted the same shade put a
   second identical pill on the strip and left the question of which tab you
   are on to the writing alone. */
.side-tab:hover { background: var(--chrome-hover); color: var(--chrome-ink); }
.side-tab.on { background: var(--chrome-sunk); color: var(--chrome-ink); }
.side-head.glided .side-tab.on { background: none; }

/* One scroller for the whole panel rather than one per pane: two panes each
   with a scrollbar is two scrollbars in a 420px column, and only one of them is
   ever in use. */
.side-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 6px 0 12px;
}
.side-pane[hidden] { display: none; }

/* A tab arrives rather than appears.
   Switching used to be a cut — one column gone, another simply there — and with
   four tabs sharing one scroller that is the panel blinking rather than moving.
   So the pane that comes up rises out of the side the tab it came from is on,
   in the same short arrival the diff and the log use: long enough that the eye
   follows the column instead of re-reading it, short enough that nobody waits
   for it. The direction is set on the element by show() in ./sidebar.js, and is
   nought when the panel is merely being opened on the tab it was left on.

   An animation rather than a transition, because the thing that changes is
   `display`, and nothing transitions out of none. It restarts on its own every
   time the pane is shown, which is exactly the event worth drawing. */
.side-pane:not([hidden]) { animation: side-turn .28s var(--ease) both; }
@keyframes side-turn {
  from { opacity: 0; transform: translateX(var(--turn, 0px)); }
  to { opacity: 1; transform: none; }
}
/* Somebody who asked for less movement gets the switch and none of it. The tab
   strip still says which one is showing, which is the whole of what this was
   drawing. */
@media (prefers-reduced-motion: reduce) {
  .side-pane:not([hidden]) { animation: none; }
  .side-tab { transition: none; }
}
/* Nothing at all, on any side, which is what the tasks pane has and therefore
   what these must have: they are the same card in the same column, and every
   pixel one of them adds of its own is the whole tab moving when the tabs are
   switched.

   Both of the numbers that were here came from when the agents pane held a form
   rather than a card. The 14 down the sides was being added on top of the
   --tl-gut the `tasklist` inside already carries, so the agents card sat 28px in
   from the panel where the task card sits 14. The 6 on top was being added to
   the 6 .side-body already has above every pane, which put the title one line's
   worth of hair lower than the greeting on the other tab — too small to name and
   not too small to see. Written out rather than left to the default, because
   this is the rule the two of them were breaking. */
.side-pane[data-pane="agents"],
.side-pane[data-pane="skills"] { padding: 0; }

/* The panel is the column now, so the card inside it drops its own frame — see
   .tasklist above. What is left of the card is its rhythm, and the composer is
   the one thing in it that is still drawn as an object. */
#side .tl-bar { padding-top: 4px; }

/* Room for a schedule on the row.
   "Daily 9:00am AEST" is four times the width of "5 mins" and it is a fact
   somebody scans down the list to check, so it gets the room and the words of
   the task give it up rather than the other way round. It never wraps: a row
   that grows to two lines because of its clock is a list that jumps about as
   times are set. */
.tl-when .tl-tag-word {
  max-width: 148px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* A repeating one keeps its chip filled whether or not it is armed this minute,
   because the fact it states is about the task rather than about the next
   fourteen hours. */
.tl-when.repeats { background: var(--chrome-sunk); color: var(--chrome-ink); }


/* Everything on the right edge steps aside for the panel, and it does it by
   moving its own edge rather than by being transformed.
   A transform was the first version and it moved exactly the wrong half: the bar
   and the zoom readout are elements, but the menus they open are fixed to the
   window and parented to <body>, so the buttons slid left and their menus stayed
   pinned to the right — behind a panel with a higher z-index, where they opened
   correctly and could not be seen. Reading one token instead means the menus
   move with the controls because they are told the same thing, not because they
   happen to be inside them.

   Only the two controls are listed here. The two menus say the same thing in
   their own rules, because `transition` is one property and not a list you can
   add to: naming them here wrote over the opacity-and-transform they open with,
   further up the file and at the same specificity, and both of them appeared
   fully drawn where every other menu in the app rises and fades. */
#topbar,
#zoomctl { transition: right .18s var(--ease); }

/* ---- the schedule, as a control -------------------------------------------
   What a repeat is, and how it is changed, in the same words: the shape, the
   time, the zone, each of them pressable, and a way out on the end.

   One object rather than four chips — a hairline around the group, a line
   between each part, nothing else. Four separate chips in a row would read as
   four unrelated things you could set, which is exactly what this is not: it is
   one sentence with three editable words in it. */

.sp {
  display: inline-flex;
  align-items: center;
  height: 24px;
  border: 1px solid var(--chrome-edge);
  border-radius: 6px;
  background: var(--chrome);
  overflow: hidden;
  vertical-align: middle;
  max-width: 100%;
}

/* There was a green dot on the front of this, meaning "on". It was the only
   green in the app and it was buying very little: a schedule on a row is already
   a statement that something recurs, and the dot spent the width of a whole
   segment saying so a second time. The first word takes that space instead. */

.sp-seg {
  height: 100%;
  padding: 0 9px;
  background: none;
  border: 0;
  border-left: 1px solid var(--chrome-edge);
  color: var(--chrome-ink);
  font: 500 11.5px/1 var(--ui);
  white-space: nowrap;
  cursor: pointer;
}
/* Nothing to the left of the first word, so no line in front of it.
   It is also the widest cell, and deliberately: it holds the longest word this
   control can say, and its own column has to be wide enough to offer that word
   without cutting it. Sizing to "Daily" and then opening a list with "Monthly"
   in it is a menu narrower than its own contents. */
.sp-seg:first-child {
  border-left: 0;
  /* Wide enough for the longest word its own list offers, and no wider. The
     column takes this width, so the floor is set by "Monthly" at 11.5/500 plus
     the row padding either side — a few pixels under it and the menu is narrower
     than a word it is offering; a few over and the pill carries dead space on
     every task that says "Daily". */
  min-width: 68px;
  text-align: left;
}
.sp-seg:hover { background: var(--chrome-sunk); }
/* The one with a menu open under it stays lit, so it is obvious which column
   the list belongs to. */
.sp-seg[aria-expanded="true"] { background: var(--chrome-sunk); }

/* And the pill gives up the corners on the side its column is joined to, so the
   two are one outline rather than a rounded thing with a square thing under it.
   Which side depends on where there was room; columnMenu() sets it. */
.sp.open-down { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
.sp.open-up { border-top-left-radius: 0; border-top-right-radius: 0; }

.sp-off {
  display: grid;
  place-items: center;
  width: 26px;
  padding: 0;
  color: var(--chrome-dim);
}
.sp-off svg { width: 12px; height: 12px; }
.sp-off:hover {
  background: color-mix(in srgb, #c4483a 10%, transparent);
  color: #c4483a;
}

.sp.off .sp-seg { color: var(--chrome-dim); }

/* On a row it sits on the rail under the words, which is the one line with room
   to read left to right rather than the marks column, which holds things a
   single glyph wide. */
.tl-rail .sp { margin-top: 2px; }


/* ---- and its own dropdown --------------------------------------------------
   A menu the width of the segment that opened it, directly underneath, so it
   reads as that column continuing downward rather than as a panel that happened
   to appear nearby.

   It is not the app's shared dropdown, and that is the point. That control is
   built for a row of options with icons and a tick — 176px at its narrowest,
   which against a 54px segment is a menu four times the width of the thing it
   belongs to, hanging off one corner of it. Here the column IS the affordance:
   press "Daily", get the list of shapes in exactly that column; press "AEST",
   get zones in that one. Which is also why every label in it is short — see
   segMenu() in canvas/tasks.js, where the zones are offered by the name the
   segment shows rather than by their IANA path. */

.cm {
  position: fixed;
  z-index: 80;
  max-height: 260px;
  overflow-y: auto;
  overscroll-behavior: contain;
  /* The segment, carried on downwards, and nothing else: the same fill, the same
     hairline, no corner and no shadow anywhere. Every one of those would be this
     saying it is a separate object — a radius is where a surface ends, a shadow
     is height above the thing underneath — and it is not a separate object. The
     shared edge is overlapped by a pixel in columnMenu(), so the line between
     the segment and its list is one line rather than two.

     No padding either, so the rows run the full width. An inset row inside a
     square column draws a second, softer rectangle inside the first, which is
     the shadow this was accused of having. */
  padding: 0;
  background: var(--chrome);
  border: 1px solid var(--chrome-edge);
  border-radius: 0;
  box-shadow: none;

  /* It unrolls from the edge it is joined to rather than fading in place.
     Clipped rather than scaled or slid: a transform would move it off the
     segment it is attached to for the length of the animation, which is the one
     thing this control cannot do — it is supposed to BE that segment, carried
     on. Clipping means the surface is always exactly where it will end up and
     only how much of it exists is changing, which is what "extending" looks
     like. */
  opacity: 0;
  clip-path: inset(0 0 100% 0);
  transition: clip-path .2s var(--ease), opacity .12s ease;
}
.cm.up { clip-path: inset(100% 0 0 0); }
.cm.on { opacity: 1; clip-path: inset(0 0 0 0); }

/* Somebody who does not want the movement gets the surface without it. */
@media (prefers-reduced-motion: reduce) {
  .cm { transition: opacity .1s ease; clip-path: none; }
}
/* No scrollbar. Ninety-six times of day is a long list and a 60px-wide column
   with a scrollbar down it is mostly scrollbar. */
.cm { scrollbar-width: none; }
.cm::-webkit-scrollbar { width: 0; }

/* Edge to edge, and the horizontal padding is the segment's own, so a word in
   the list sits directly under the same word in the control. */
.cm-opt {
  display: block;
  width: 100%;
  padding: 6px 9px;
  background: none;
  border: 0;
  border-radius: 0;
  color: var(--chrome-ink);
  font: 400 11.5px/1.2 var(--ui);
  text-align: left;
  white-space: nowrap;
  cursor: pointer;
}
.cm-opt:hover { background: var(--chrome-sunk); }
/* What it is set to now, said by weight rather than by a tick: a tick needs a
   column of its own and this menu is one column wide. */
.cm-opt.on { font-weight: 600; }

/* ---------------------------------------------------------------------------
   Code: the branches waiting on you

   The third pane of the side panel, and it inherits nearly everything. The rows
   are .tl-row, the composer is .tl-new, the chips are .tl-chip, because
   ui/code.js mounts itself as `mk tasklist` exactly as the agents pane does —
   so what is written here is only what a pull request has that a task does not:
   a face on the left, a repository under the words, a log, and a head.
   --------------------------------------------------------------------------- */

/* This pane adds nothing either, exactly as the other three do not: every row
   inside carries --tl-gut for itself, so a row that wants the full width can
   take it — a hover tint that stops short of the panel edge reads as a box, and
   the diff genuinely needs the pixels.

   There was a rule here giving it 6px and 14px and it had never once applied:
   mountCode() assigned its host's className, which took `side-pane` off the very
   element the rule selects. Nobody noticed because the answer it was reaching
   for was already the right one. The mounts add their classes now rather than
   assigning them — see ./code.js — so a rule like that would take effect, which
   is exactly why this one is gone rather than kept. */

/* The bar carries a way back as well as a way to ask again, and both are on the
   ends so the title between them stays where it is when they appear. */
/* In a room the bar is a way back and a note of where you are, and those are
   opposite kinds of thing: one is the only control on the row and one is a
   label. So they go to opposite ends, with the whole width between them, rather
   than sitting together at the left where the label reads as the button's
   caption. */
.gh-in-room .tl-hello {
  justify-content: flex-end;
  padding-left: 12px;
  color: var(--chrome-dim);
  font-weight: 400;
  font-variant-numeric: tabular-nums;
}

.gh-back {
  margin-right: 0;
  height: 26px;
  padding: 0 9px 0 6px;
  color: var(--chrome-dim);
  font-size: 11.5px;
}
.gh-back:hover { color: var(--chrome-ink); }
.gh-back svg { width: 13px; height: 13px; flex: none; }
/* Both are toggled with `hidden`, which .dd-btn/.tl-ctl already answer for up
   in the dropdown section — see the rule beside their display. Nothing to add
   here; the note stays because "why is there no [hidden] rule for these" is a
   question this file invites. */

/* Every state that is not a list: a sentence, a control, and room around them.
   Centred because each one is a single next step rather than a list of them. */
/* Nothing to show, in the middle of the space where the something would have
   been. Centred rather than set to the left: when the list is empty this line is
   the entire contents of the panel, and a sentence pinned to the left edge of an
   otherwise empty column reads as a label for something rather than as the
   answer to what you asked. */
.gh-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 28px var(--tl-gut);
  color: var(--chrome-dim);
  font: 400 12px/1.5 var(--ui);
  text-align: center;
}
.gh-empty p { margin: 0; max-width: 34ch; }
.gh-empty strong { color: var(--chrome-ink); font-weight: 600; }

/* --- which project, and which of its branches ----------------------------- */

/* Up in the title row, in the half of it that was empty. "Code / canvas / any
   branch" reads as one address getting more specific, and moving it here left
   the row below to the filters alone — which is what let them go back to being
   real buttons rather than four words squeezed beside a fifth control. */
.gh-where {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 1px;
  min-width: 0;
}
.gh-where[hidden] { display: none; }
.gh-sep { color: var(--chrome-dim); opacity: .4; font: 400 11.5px/1 var(--ui); }

/* No outline, deliberately, and the only things in this panel without one. They
   are not controls somebody hunts for: each already says the name of the thing
   it would change, which is more than a border could tell them. */
.gh-pill {
  display: flex;
  align-items: center;
  gap: 1px;
  min-width: 0;
  max-width: 150px;
  padding: 3px 4px;
  background: none;
  border: 0;
  border-radius: 5px;
  color: var(--chrome-ink);
  font: 500 11.5px/1.2 var(--ui);
  cursor: pointer;
}
.gh-pill:hover { background: var(--chrome-sunk); }
.gh-pill .dd-text {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.gh-pill svg {
  width: 12px; height: 12px; flex: none;
  color: var(--chrome-dim);
  transition: transform .15s var(--ease);
}
.gh-pill[aria-expanded="true"] svg:last-child { transform: rotate(180deg); }
/* The branch is the lesser half of the pair: which project is chosen once, which
   branch is a narrowing of it. */
.gh-pick-base { color: var(--chrome-dim); font-weight: 400; gap: 3px; }
.gh-pick-base:hover { color: var(--chrome-ink); }

/* --- which of them -------------------------------------------------------- */

/* The whole row to themselves now, which is what makes four buttons fit where
   four words had to. */
/* On the gutter every other row in this panel sits on. It was not, and the
   filters started a clear 14px to the left of the word above them — which is the
   sort of thing that reads as "broken" long before anybody works out why. The
   pane adds nothing horizontally and .tl-bar and .tl-row each carry --tl-gut for
   themselves; anything that skips it lands short. */
.gh-shows {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 5px;
  padding: 2px var(--tl-gut) 10px;
  min-width: 0;
}
.gh-shows[hidden] { display: none; }
.gh-tabs {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 5px;
  min-width: 0;
}
.gh-tabs[hidden] { display: none; }

/* .tl-ctl gives them the fill, the edge, the radius and the hover every other
   button in this panel has. What is added is the height, and what "on" looks
   like: filled rather than merely darker, because a border that changes shade is
   a difference you have to compare two of them to notice. */
.gh-tab {
  height: 26px;
  padding: 0 10px;
  color: var(--chrome-dim);
  font-size: 11.5px;
  white-space: nowrap;
}
.gh-tab.on {
  background: var(--chrome-sunk);
  border-color: transparent;
  color: var(--chrome-ink);
  font-weight: 600;
}
.gh-tab.on:hover { background: var(--chrome-sunk); }

/* The one verb at the end of the title row, whichever of the three it currently
   is. One rule rather than three, because they are one control at three stages
   and a person should not be able to tell from its shape that the panel changed
   its mind about which it is showing. */
.gh-verb {
  margin-left: auto;
  flex: none;
  height: 26px;
  padding: 0 9px 0 6px;
  color: var(--chrome-dim);
  font-size: 11px;
  white-space: nowrap;
}
.gh-verb:hover { color: var(--chrome-ink); }
.gh-verb .cx-logo { width: 14px; height: 14px; flex: none; object-fit: contain; }
.gh-verb svg { width: 13px; height: 13px; flex: none; }

/* --- the room ------------------------------------------------------------- */

/* Three lines, and the title is the middle one because the title is the thing.
   Above it what this is and where; below it when it moved and which branch goes
   into which. No headings: a rail labelled Status / Author / Branches spends
   three words saying what three values already say. */
.gh-head { padding: 6px var(--tl-gut) 14px; }

.gh-top {
  display: flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
  color: var(--chrome-dim);
  font: 400 12px/1 var(--ui);
}
.gh-repo { color: var(--chrome-ink); font-weight: 500; }
.gh-num { font-variant-numeric: tabular-nums; }
.gh-dot { opacity: .45; }

/* The state, filled. It is one word that changes twice in the life of a branch
   and is the first thing anybody wants from this screen, so it gets the one
   piece of colour up here — and a tint rather than a solid, which at this size
   reads as a label rather than as a button somebody could press. */
.gh-state {
  padding: 3px 8px;
  border-radius: 4px;
  font: 600 11.5px/1 var(--ui);
}
/* One colour for all three, and the word is what tells them apart.

   Merged was already the app's own blue rather than GitHub's purple, on the
   argument that this panel REPORTS a state rather than borrowing a brand, and
   that every other settled outcome here — Connected in the integrations list,
   the focus ring on every control — is --sel. Open and Closed stayed green and
   red, which was that argument half-applied.

   Both are GitHub's palette, and both say something this panel does not mean.
   Red reads as a failure, and a closed pull request is a decision somebody made
   rather than a fault. Green reads as a result, and an open one is work in
   progress rather than a pass. They also put two more hues into a surface whose
   entire chrome is a single blue, so a list of branches came out as the most
   colourful thing on a canvas that is otherwise somebody's work.

   Nothing is lost by collapsing them. The state is one word in a row that
   already carries the repository, the number, the author and the branch, and it
   is the word people read — the colour was never doing the telling. */
.gh-state.open,
.gh-state.merged,
.gh-state.closed {
  background: color-mix(in srgb, var(--sel) 14%, transparent);
  color: var(--sel);
}

/* The way out to the real thing. Everything in this panel is a view of somebody
   else's system, and the moment somebody wants a control this does not have,
   the honest move is to hand them over rather than make them go and find it. */
.gh-out {
  margin-left: auto;
  display: grid;
  place-items: center;
  width: 22px; height: 22px;
  border-radius: 6px;
  color: var(--chrome-dim);
}
.gh-out:hover { background: var(--chrome-sunk); color: var(--chrome-ink); }
.gh-out svg { width: 15px; height: 15px; }

/* The hero. Everything else on this screen is a fact about it. */
.gh-head h3 {
  margin: 9px 0 8px;
  color: var(--chrome-ink);
  font: 600 15px/1.35 var(--ui);
  letter-spacing: -.01em;
  text-wrap: pretty;
}

.gh-when-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  color: var(--chrome-dim);
  font: 400 11.5px/1.6 var(--ui);
}
.gh-arrow { opacity: .45; flex: none; }
.gh-where-at { color: var(--chrome-ink); font-weight: 500; }
.gh-where-at .gh-num { font-weight: 400; opacity: .7; }

/* Beside the state, sharing a row with it and with the way out. Allowed to
   shrink, since a branch called agents/flight-path-flutter-port next to a badge
   is most of a 420px column on its own. */
.gh-pair {
  display: flex;
  align-items: center;
  gap: 5px;
  min-width: 0;
}

/* A branch name is read here for one reason: to go and check it out. So it is a
   button that copies itself, rather than a label somebody has to select by hand
   in a 420px column. */
.gh-branch {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  min-width: 0;
  max-width: 118px;
  padding: 3px 6px;
  background: var(--chrome-sunk);
  border: 0;
  border-radius: 6px;
  color: var(--chrome-dim);
  cursor: pointer;
}
.gh-branch code {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font: 400 10.5px/1.5 var(--mono, ui-monospace, monospace);
}
.gh-branch svg { width: 11px; height: 11px; flex: none; opacity: .55; transition: opacity .12s ease; }
.gh-branch:hover { color: var(--chrome-ink); }
.gh-branch:hover svg, .gh-branch:focus-visible svg { opacity: 1; }

/* Where the review stands. Words, not a row of coloured faces: "waiting on Dan"
   is something to act on, and a green dot beside a picture is something to
   decode. */
.gh-verdicts {
  display: flex;
  flex-direction: column;
  gap: 3px;
  margin-top: 12px;
  font: 400 11.5px/1.4 var(--ui);
  color: var(--chrome-dim);
}
.gh-v.good { color: #1a7f37; }
.gh-v.bad { color: #cf222e; }

/* --- the three views of one branch ---------------------------------------- */

/* Underlined rather than pilled, because the row above it is already pills and
   two sets of them side by side read as eight things of one kind. This one says
   "which part of this branch"; that one says "which branches". */
.gh-segs {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 0 var(--tl-gut);
  border-bottom: 1px solid var(--chrome-edge);
}
.gh-seg {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 0 0 8px;
  background: none;
  border: 0;
  border-bottom: 1.5px solid transparent;
  margin-bottom: -1px;
  color: var(--chrome-dim);
  font: 500 12px/1 var(--ui);
  cursor: pointer;
}
.gh-seg:hover { color: var(--chrome-ink); }
.gh-seg.on { color: var(--chrome-ink); border-bottom-color: var(--chrome-ink); }
/* The count is the reason a tab is worth pressing, so it is always there — and
   quiet, because it is a footnote to the word beside it. */
.gh-seg-n {
  padding: 1px 5px;
  border-radius: 5px;
  background: var(--chrome-sunk);
  font-size: 10.5px;
  font-variant-numeric: tabular-nums;
}
.gh-seg.on .gh-seg-n { color: var(--chrome-ink); }

/* One log, several authors, in time order. The rule down the left is what makes
   it read as one stream rather than as a stack of cards: these are not separate
   objects, they are things that happened to the same branch. */
.gh-log {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 14px 0 14px 12px;
  border-left: 2px solid var(--chrome-edge);
  /* The gutter every row here sits on, plus a little more: the rule down the
     left is the log's own edge, and standing it exactly on the gutter would
     line it up with text that is not part of it. */
  margin: 12px var(--tl-gut) 0 calc(var(--tl-gut) + 6px);
}
.gh-ev { font: 400 12px/1.5 var(--ui); color: var(--chrome-ink); }

/* The three quiet kinds. A commit, a check and a state change are things that
   happened rather than things somebody said, so they are one line and dim, and
   what somebody said is the only thing in the log with weight on it. */
.gh-ev-commit, .gh-ev-check, .gh-ev-mark {
  display: flex;
  align-items: center;
  gap: 7px;
  color: var(--chrome-dim);
  font-size: 11.5px;
}
.gh-ev-commit code {
  flex: none;
  padding: 1px 5px;
  border-radius: 5px;
  background: var(--chrome-sunk);
  font: 400 10.5px/1.5 var(--mono, ui-monospace, monospace);
}
.gh-ev-text {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.gh-ev-check .gh-check-mark { flex: none; display: grid; place-items: center; }
.gh-check-mark svg { width: 13px; height: 13px; }
.gh-ev-check.good .gh-check-mark { color: #1a7f37; }
.gh-ev-check.bad  .gh-check-mark { color: #cf222e; }
.gh-ev-check.going .gh-check-mark { color: #9a6700; }
/* A failing check is the one line in this log worth reading twice. */
.gh-ev-check.bad .gh-ev-text { color: var(--chrome-ink); }

.gh-ev-head {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 4px;
  font-size: 11.5px;
  color: var(--chrome-dim);
}
.gh-ev-head strong { color: var(--chrome-ink); font-weight: 600; }
.gh-ev-head .gh-face { width: 20px; height: 20px; }
.gh-ev-head .gh-when { margin-left: auto; }
.gh-verdict { opacity: 1; height: 18px; font-size: 10.5px; }
.gh-verdict.good { background: #1a7f371a; color: #1a7f37; }
.gh-verdict.bad  { background: #cf222e1a; color: #cf222e; }

/* What somebody wrote, as the markdown it is. Indented to the name above it so
   the log reads as a column of people rather than a column of paragraphs. */
.gh-ev-body { padding-left: 26px; }
.gh-ev-body > :first-child { margin-top: 0; }
.gh-ev-body > :last-child { margin-bottom: 0; }
.gh-ev-body p { margin: 0 0 6px; }
.gh-ev-body pre {
  overflow-x: auto;
  padding: 8px 10px;
  border-radius: 8px;
  background: var(--chrome-sunk);
  font: 400 11px/1.5 var(--mono, ui-monospace, monospace);
}
.gh-ev-body code {
  padding: 1px 4px;
  border-radius: 4px;
  background: var(--chrome-sunk);
  font: 400 11px/1.5 var(--mono, ui-monospace, monospace);
}
.gh-ev-body pre code { padding: 0; background: none; }

/* --- what changed --------------------------------------------------------- */

.gh-files { padding: 8px var(--tl-gut) 4px; }

/* A row per file, and the row is the control: the whole line opens the diff,
   because a chevron in a 420px column is a 12px target beside a 380px one that
   does nothing. */
.gh-file + .gh-file { border-top: 1px solid var(--chrome-edge); }
.gh-file-head {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 8px 2px;
  background: none;
  border: 0;
  color: var(--chrome-ink);
  font: 400 11.5px/1.4 var(--ui);
  text-align: left;
  cursor: pointer;
}
.gh-file-head:hover { color: var(--chrome-ink); }
.gh-file-head:hover .gh-file-path { text-decoration: underline; }
.gh-file-path {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  direction: rtl;          /* keep the filename when a long path is cut */
  text-align: left;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 11px;
}
.gh-file.on .gh-file-path { font-weight: 600; }
/* A lock file is in the change and is not the change. Dimmed rather than
   hidden: somebody looking for it should find it where it actually is. */
.gh-file.dull .gh-file-path { opacity: .5; }
.gh-file-n {
  flex: none;
  display: flex;
  gap: 5px;
  font: 400 10.5px/1 var(--ui);
  font-variant-numeric: tabular-nums;
}
.gh-add { color: #1a7f37; }
.gh-del { color: #cf222e; }

/* The diff itself. Scrolls sideways rather than wrapping: a wrapped diff line
   is two lines that look like two changes. */
.gh-diff {
  overflow-x: auto;
  padding: 6px 0 10px;
  font: 400 10.5px/1.55 var(--mono, ui-monospace, monospace);
}
.gh-line {
  padding: 0 8px;
  white-space: pre;
  color: var(--chrome-ink);
}
/* Tinted by what the line does. The first character of a diff line is a verdict
   on it, and reading the whole thing in one colour is reading it without the
   only thing that makes it a diff. */
.gh-line.add { background: #1a7f3714; }
.gh-line.del { background: #cf222e14; }
.gh-line.at { color: var(--chrome-dim); background: var(--chrome-sunk); }
.gh-diff-none { padding: 10px 0; }

/* --- branches with a build on them ------------------------------------------
   Above the pull requests, in the same rows a file uses, because a branch being
   built and a file that changed are the same gesture: press the line, read the
   diff. It gets a heading and the pull requests do not, since the panel is
   titled Pull Requests already and this is the part of it that is something
   else. See laneView in ui/code.js. */
.gh-working {
  padding: 4px var(--tl-gut) 6px;
  border-bottom: 1px solid var(--chrome-edge);
}
.gh-working-head {
  padding: 6px 2px 2px;
  color: var(--chrome-dim);
  font: 600 10px/1.4 var(--ui);
  letter-spacing: .06em;
  text-transform: uppercase;
}
/* A branch reads left to right: the "soffo/" in front is the half that says
   what it is, so it is the half to keep when the name is cut. */
.gh-working .gh-file-path { direction: ltr; }
.gh-working .gh-file-path svg {
  width: 12px;
  height: 12px;
  margin-right: 5px;
  vertical-align: -2px;
}
.gh-working .gh-when { color: var(--chrome-dim); }

/* The one verb an open branch offers, under its diff rather than beside its
   name. Under, because it acts on what you have just read: a button in the row
   is a button you can press without having opened the thing it is about, and
   this one opens a pull request on somebody's repository.

   The button itself is .gh-verb, which is already the small action in this
   panel. This is only where it stands. */
.gh-lane-act {
  display: flex;
  justify-content: flex-end;
  padding: 6px 2px 2px;
  border-top: 1px solid var(--chrome-edge);
}
.gh-lane-act .gh-verb { margin-left: 0; display: inline-flex; align-items: center; gap: 5px; }

/* ---------------------------------------------------------------------------
   Code: the details

   Everything below is a state something could already be in and was not saying:
   in flight, focused, pressed, empty, or not the state the list is filtered to.
   --------------------------------------------------------------------------- */

/* A search in flight. The rows that are about to be replaced go quiet rather
   than vanishing — a list that empties and refills jumps twice, and a list that
   sits there unchanged is confidently showing the wrong answer. Pointer events
   go with it, so a row cannot be opened in the half-second before it stops being
   the row it looks like. */
.gh-pane.busy .tl-rows {
  opacity: .45;
  pointer-events: none;
  transition: opacity .18s ease;
}

/* The list before there is one, at the size it is about to be. Grey bars where
   words go, which is the same admission the integrations panel makes: this is a
   placeholder and it is not pretending otherwise. */
.gh-row.wait { pointer-events: none; }
.gh-row.wait .gh-face { background: var(--chrome-sunk); }
.gh-bar {
  display: block;
  height: 9px;
  width: 78%;
  border-radius: 5px;
  background: var(--chrome-sunk);
}
/* Where the time will be, at the width a time is. It stood for a second line
   until the row had one. */
.gh-bar.short { width: 22px; }
/* A slow breath rather than a shimmer travelling across the row: at this size a
   sweep is a moving thing to look at while waiting to look at something else. */
.gh-row.wait { animation: gh-breathe 1.5s ease-in-out infinite; }
@keyframes gh-breathe { 0%, 100% { opacity: .55; } 50% { opacity: .95; } }

/* --- pressed, and reachable by keyboard ----------------------------------- */

/* The ring the rest of this panel uses, given to everything here that takes
   focus and is not already a .tl-ctl. A control you can tab to and cannot see is
   a control that only exists for people who never do. */
.gh-pill:focus-visible,
.gh-seg:focus-visible,
.gh-file-head:focus-visible,
.gh-branch:focus-visible,
.gh-out:focus-visible,
.gh-row:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--sel) 20%, transparent);
  border-radius: 6px;
}

/* A row that is about to open says so under the finger. Hover already tints;
   this is the half-frame of acknowledgement between pressing and the room
   arriving, which is the difference between a list that responds and one that
   merely eventually changes. */
.gh-row:active { background: color-mix(in srgb, var(--chrome-ink) 7%, transparent); }
.gh-tab:active, .gh-seg:active, .gh-file-head:active, .gh-branch:active { transform: translateY(.5px); }

/* --- the diff ------------------------------------------------------------- */

/* Opening a file is a reveal rather than a jump. Capped rather than measured:
   an unknown height cannot be transitioned to, and a diff of forty lines and one
   of four hundred should not open at visibly different speeds. */
.gh-diff { animation: gh-open .18s var(--ease) both; }
@keyframes gh-open {
  from { opacity: 0; transform: translateY(-2px); }
  to { opacity: 1; transform: none; }
}

/* The three views, and the list under a filter, arrive rather than appear. Short
   enough that nobody waits for it and long enough that the eye follows what
   changed instead of re-reading the whole column. */
.gh-log, .gh-files { animation: gh-open .16s var(--ease) both; }

/* Motion here is all confirmation of something the person did, so when it is
   turned off the confirmation has to survive as state rather than as movement.
   Every rule above still reads with none of it. */
@media (prefers-reduced-motion: reduce) {
  .gh-row.wait { animation: none; opacity: .7; }
  .gh-diff, .gh-log, .gh-files { animation: none; }
  .gh-tab:active, .gh-seg:active, .gh-file-head:active, .gh-branch:active { transform: none; }
  .gh-pane.busy .tl-rows { transition: none; }
}


/* --- pressed now, not when the answer arrives ----------------------------- */

/* Every control in this tab responds to the press rather than to what the press
   went off to fetch. Removing the tap delay is most of it on a touchpad or a
   trackpad-less machine: 300ms of nothing is long enough to press a second time,
   and a second press on a menu is a menu that opens and shuts. */
.side-pane[data-pane="code"] button,
.side-pane[data-pane="code"] a { touch-action: manipulation; }

/* Colour follows the pointer immediately; only the fill is allowed to take a
   moment, and less of one than before. A control whose text lightens a tenth of
   a second after the pointer reaches it reads as sluggish even when nothing is
   waiting on anything. */
.gh-tab, .gh-verb, .gh-pill, .gh-seg, .gh-branch, .gh-file-head, .gh-out {
  transition: background .1s ease, color 0s, border-color .1s ease;
}

/* Gone to ask GitHub something before it can draw a menu. The press is
   acknowledged here rather than four hundred milliseconds later when the answer
   lands — which is the difference between a control that is thinking and one
   that is broken. */
.gh-verb.busy, .gh-pill.busy {
  color: var(--chrome-ink);
  background: var(--chrome-sunk);
  cursor: progress;
}
.gh-verb.busy svg, .gh-pill.busy svg { animation: gh-pulse .9s ease-in-out infinite; }
@keyframes gh-pulse { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }

@media (prefers-reduced-motion: reduce) {
  .gh-verb.busy svg, .gh-pill.busy svg { animation: none; opacity: .5; }
}


/* The search box, sharing the filter row and matching its height exactly.
   ---------------------------------------------------------------------------
   No border and no shadow. The three buttons beside it are outlined because
   each is one of a set you choose between; a field you type into is not one of
   those. The mark says what it is and the fill says where it ends, and at 26px
   in a 420px column a third edge treatment is one more line than the row can
   carry. */
.gh-find {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 5px;
  min-width: 0;
  flex: 0 1 136px;
  height: 26px;
  padding: 0 8px;
  border: 0;
  border-radius: 7px;
  background: var(--chrome-sunk);
  color: var(--chrome-dim);
  box-shadow: none;
  transition: color .12s ease;
}
.gh-find[hidden] { display: none; }
.gh-find:hover { color: var(--chrome-ink); }
/* Focused, said by the mark and the text going to ink rather than by a ring.
   The ring is how this panel marks a control you tabbed onto; a field you are
   typing in already says so with a caret. */
.gh-find:focus-within { color: var(--chrome-ink); }

/* Sized here and nowhere else. The icon helper draws a 24-unit viewBox with no
   width or height on it, so an svg that misses this rule is not a slightly
   large icon — it is a 24rem one, which is what a missing stylesheet looked
   like the first time this shipped. */
.gh-find svg {
  width: 13px;
  height: 13px;
  flex: none;
}

.gh-find input {
  min-width: 0;
  flex: 1 1 auto;
  height: 100%;
  margin: 0;
  padding: 0;
  background: none;
  border: 0;
  border-radius: 0;
  outline: none;
  box-shadow: none;
  -webkit-appearance: none;
  appearance: none;
  color: var(--chrome-ink);
  font: 400 11.5px/1 var(--ui);
}
.gh-find input::placeholder { color: var(--chrome-dim); }
/* WebKit's own clear button, which arrives on type="search" at a size and
   colour of its own choosing. Escape clears the box, and so does deleting. */
.gh-find input::-webkit-search-cancel-button,
.gh-find input::-webkit-search-decoration {
  -webkit-appearance: none;
  appearance: none;
}


/* --- the list row, restored ------------------------------------------------
   These went missing under an earlier edit that replaced a neighbouring block,
   and nothing caught it: the stylesheet has no test, and the failures were all
   quiet. The avatars kept their size from the img attributes and merely turned
   square; the marks vanished outright, since .tl-tag is invisible until its row
   is hovered unless something says otherwise; and the titles started wrapping
   again, which turns a list you scan into a page you read.
   --------------------------------------------------------------------------- */

.gh-row { cursor: pointer; }

/* A circle rather than a square, and its own box rather than the image's, so a
   picture that has not loaded leaves a grey disc rather than a gap the row
   collapses around. */
.gh-face {
  flex: none;
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  overflow: hidden;
  border-radius: 50%;
  background: var(--chrome-sunk);
}
.gh-face img { width: 100%; height: 100%; object-fit: cover; }
.gh-ev-head .gh-face { width: 20px; height: 20px; }

/* One line, cut. A pull request title is a sentence, and several of them
   wrapping to three lines each turns a list you scan into a page you read.
   Scoped to these rows: a task on the list above is something somebody wrote to
   themselves and has to be readable whole. */
.gh-row .tl-body {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Statements rather than controls, so unlike every other .tl-tag they do not
   wait for the pointer: whether a branch is a draft, and who it waits on, are
   the two things this list is scanned for. */
.gh-row .tl-tag { opacity: 1; }
.gh-draft { border-color: var(--chrome-edge); }
.gh-wait { background: var(--chrome-sunk); color: var(--chrome-ink); }

/* The time on a row, at the end of the title's own line. */
.gh-when {
  flex: none;
  color: var(--chrome-dim);
  font-variant-numeric: tabular-nums;
}


/* ---- the browser, on the canvas ------------------------------------------
   A page, as a block on the plane. The only object on this canvas that is
   mostly not drawn by this stylesheet.

   What is here is the chrome: the block's own tab strip, an address bar, a find
   bar, and a sheet for history and downloads. What is not here is the page,
   which is a second webview placed over .wb-page by AppKit — see
   src-tauri/src/browser.rs. Three things follow from that and every rule below
   is shaped by one of them.

   The page is ABOVE this document, natively. Nothing HTML can be drawn over it,
   which is why there is no dropdown anywhere in this block: what would have
   been a menu is .wb-sheet, which fills the page area and is only ever seen
   with the webview hidden.

   The page has square corners and cannot have any others. A webview is a view,
   not a box, and there is no radius to give it. So .wb-page sits inside the
   block's own rounded surface with a whole pixel of it showing on three sides:
   at a 10px corner the arc has come in about 3px by then, so the webview's
   corners land inside the rounded shape rather than poking out of it.

   The page is measured, not laid out. canvas/browser.js reads .wb-page's
   rectangle every frame and hands it to the shell, so any rule here that
   changes a height — the find bar appearing, most of all — moves the page as a
   consequence rather than by being told to. */

.block.web {
  /* Sized by canvas/blocks.js from the block's own w and h, the way a sheet of
     paper is. The width here is only what a block that somehow has neither
     falls back to; nothing puts one down without both. */
  width: 45rem;
  max-width: none;
  display: flex;
  flex-direction: column;
  /* The page's own ground, all the way up. It used to be --chrome, which in the
     dark is a step lighter than --bg, so the tab strip read as a band of a
     different colour stuck to the top of the block — two surfaces where there
     is one object. The strip has no colour of its own now; the only thing up
     there wearing one is the tab you are actually on. */
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 12px;
  /* A window, not a note. The shadow is what says the page is on top of the
     canvas rather than printed on it, and it is the one block here that is
     genuinely holding something from somewhere else. Kept faint: a page is
     already the loudest thing on any canvas it is on. */
  box-shadow: 0 1px 2px rgb(0 0 0 / .03), 0 10px 30px -18px rgb(0 0 0 / .22);
  /* No `overflow: hidden`, deliberately. It is the obvious way to keep the
     chrome inside the radius and it clips the eight edges you resize by, which
     sit 6px OUTSIDE the block — so the block was rounded and could only be
     pulled from the inner half of each edge. The corners are kept by the two
     elements that reach them instead: .wb-page rounds its own bottom, and the
     top row rounds its own top. */
}

/* Every icon in this block is drawn from a 24-unit viewBox with no dimensions
   on it, so one rule sizes all of them or none of them are sized at all. */
.block.web button > svg { width: 13px; height: 13px; }

/* ---- how little of this is ever on ----
   Everything that is a way IN to something you are not doing — history,
   downloads, closing a page — waits until the pointer is over the block. "+"
   does not, and the line between them is worth stating: a new page is the
   second thing anybody wants from a browser, and a control that has to be
   discovered by hovering is a control most people never find. What hides is
   only ever a door to somewhere else.

   This is the one rule that makes a browser sit quietly on a canvas. A window
   in a window is loud by default: two rows of controls, every one of them drawn
   at full strength, competing with the writing around it for the same
   attention. What somebody put on the canvas is the PAGE. Everything else is
   how they got to it, and it can wait to be asked for. */
.block.web .wb-more { opacity: 0; transition: opacity .12s ease; }
.block.web:hover .wb-more,
.block.web:focus-within .wb-more { opacity: 1; }

/* "+", at the end of the strip it adds to. It is the only control in this block
   that is a way in to something and is still drawn at rest, because it is the
   one that has to be found: a browser whose second page can only be reached by
   a keystroke is a browser most people will use one page at a time. */
.wb-add {
  flex: none;
  /* The toolbar button's box, not a smaller one of its own. Same width, same
     radius, same padding around it — which is what makes the two of them a
     column rather than two buttons that happen to be near each other. */
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
  border: 0;
  padding: 0;
  border-radius: 6px;
  background: none;
  color: var(--dim);
  cursor: pointer;
}
.wb-add:hover:not(:disabled) { background: var(--chrome-hover); color: var(--chrome-ink); }
.wb-add:disabled { opacity: .25; cursor: default; }
.wb-add { transition: background-color .14s ease, color .14s ease, opacity .14s ease; }

/* ---- the block's own tabs ----
   The canvas's strip holds canvases and this one holds pages, and that division
   is the whole argument in ui/tabs.js arriving at its conclusion: tabs belong
   to the thing they are tabs of. The window's strip cannot hold these, because
   the block they belong to may be nowhere near the window.

   The strip is here whether there is one page in it or six. It collapsed at one
   page for about an hour, on the Dia-ish argument that one page is not a choice
   and there is nothing to choose between — which is true, and is not the only
   thing a tab strip is for. It is also where you find out that a block HAS
   tabs. Collapsed, the block read as a window with no such idea in it, and the
   way to a second page was a keystroke nobody had been told about. A row that
   only appears once you have already done the thing it teaches is a row that
   teaches nobody.

   The bar is also the block's title bar: a press on the grey between the tabs
   drags the block, because canvas/browser.js hands everything that is not a
   control back to the plane. Nothing is drawn to say so, for the same reason a
   sheet of paper has no handle — the whole object is the handle, minus the
   parts that do something else. */
.wb-bar {
  flex: none;
  display: flex;
  align-items: center;
  gap: 2px;
  /* Air under the tabs as well as over them, because they are pills now rather
     than folders. A tab with four equal corners sitting flush on the toolbar
     has its bottom two clipped by the row below, which reads as a tab that did
     not finish drawing.

     The 7 across is the toolbar's 7, and that is what puts "+" and the "..."
     below it on one vertical axis. Two round buttons at the right edge of two
     stacked rows are read as a column whether or not anybody meant them to be,
     and three pixels out is enough to see. */
  padding: 5px 7px 4px;
  overflow: hidden;
  border-radius: 11px 11px 0 0;
}
.wb-tabs {
  position: relative;
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 2px;
  overflow: hidden;
}
/* The strip's own fill, in the strip's own colour and shape. Everything else
   about it is the shared rule; see .glide further up. */
.wb-tabs > .glide {
  border-radius: 8px;
  background: var(--key-fill);
}
.wb-tab {
  flex: 0 1 auto;
  min-width: 0;
  max-width: 190px;
  display: flex;
  align-items: center;
  gap: 7px;
  height: 26px;
  padding: 0 8px;
  /* All four corners, equally. Two rounded corners on top of two square ones is
     the shape of a folder tab, which is what a tab was drawn as when it was
     part of the window's own chrome and had a page welded to its underside.
     Here it is a pill on a bar, the bar is inside a block on a canvas, and
     every other rounded thing in this block — the block, the address, the
     buttons — is rounded the whole way round. */
  border-radius: 8px;
  color: var(--dim);
  font: 500 12px/1 var(--ui);
  cursor: pointer;
  /* The hover, and the name going from dim to full as the fill arrives under
     it. The fill's own journey is not here: it belongs to .glide, which is one
     element for the whole strip rather than a background on each tab. */
  transition: background-color .16s var(--ease), color .22s var(--ease);
}
.wb-tab:hover { background: var(--chrome-hover); }
/* The one at the front is the only thing in the strip with a colour, now that
   the strip has none. It is the shade the whole band used to be, which is the
   whole change stated in one line: the colour did not go, it stopped being
   spread across a row that was only ever holding one lit thing. */
.wb-tab.on {
  background: var(--key-fill);
  color: var(--chrome-ink);
}
/* Handed over to the fill that slides, once there is one. Both rules stay, so a
   strip whose script has not run yet still lights its own front page. */
.wb-tabs.glided .wb-tab.on { background: none; }

/* ---- a tab somebody's agent is working in ----
   The same light an app being written wears, at a tab's scale — see the shared
   ::before rule up beside .framewip, and --wip-far on .wb-tab.driving for the
   one number that differs.

   Nothing else. This wore an outline and a tinted name for a while, and both
   were wrong for the same reason: a line around a pill this size is a second
   border beside the strip's own, and a coloured label is a different design
   from the one the canvas already uses to say "something is happening in this
   rectangle". The tab that is being worked glows, exactly as the frame does. */

.wb-tab-name {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ---- a site's mark ----
   The globe and the image are stacked rather than swapped. The globe is the
   ground; the image is painted over it when one of the guesses turns out to be
   real, and simply never painted when none of them is. Nothing flickers, and a
   tab is never briefly markless. See wearMark and favCandidates. */
.wb-mark {
  position: relative;
  flex: none;
  width: 14px;
  height: 14px;
  display: grid;
  place-items: center;
  color: var(--dim);
}
.wb-mark > svg { width: 13px; height: 13px; stroke-width: 1.5; opacity: .5; }
.wb-mark > img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: 3px;
  object-fit: contain;
}
.wb-mark > img[hidden] { display: none; }

.wb-x {
  flex: none;
  width: 16px;
  height: 16px;
  display: grid;
  place-items: center;
  border: 0;
  padding: 0;
  border-radius: 5px;
  background: none;
  color: var(--dim);
  opacity: 0;
  cursor: pointer;
}
.wb-x > svg { width: 10px; height: 10px; }
.wb-x[hidden] { display: none; }
.wb-tab:hover .wb-x { opacity: .5; }
.wb-x:hover { background: var(--chrome-sunk); opacity: 1; }
/* Fades in and out with the pointer rather than flicking, the same as the one
   on a canvas tab. */
.wb-x { transition: opacity .14s ease, background-color .14s ease; }
/* "+" is drawn by `.wb-nav button` with everything else in the toolbar, because
   that is where it lives. It was at the end of the tab strip, which is the row
   that hides itself when there is one page — so the one state where you most
   want a second page was the one state with no way to ask for one. */

/* ---- the address ----
   A field, always, and visibly one.

   It was bare text at rest for an hour, on the argument that the page is the
   object and the address is only a caption on it. The argument is fine and the
   result was not: a caption is something you read, and this is something you
   TYPE IN. With no ground under it there was nothing to say so — an address bar
   that appears when the pointer crosses it is a thing you have to already know
   is there, and on a canvas full of writing, a line of grey text among other
   lines of grey text does not read as a control at all.

   So it keeps its field, at rest, in both themes, and only its shade moves:
   quiet, one step down under the pointer, and the chrome ground with a ring
   when the caret is in it. That is three states of the same object rather than
   an object that appears and disappears.

   Left-aligned, following the reload button, rather than centred. Centred was
   the right answer for a bar that spanned half a window and had nothing else in
   it — the address was the row. Here the row is four small controls and this,
   and centring left a gap on both sides that read as three separate groups. */
.wb-nav {
  position: relative;
  flex: none;
  height: 32px;
  display: flex;
  align-items: center;
  gap: 1px;
  padding: 0 7px;
  background: var(--bg);
}
.wb-nav button {
  flex: none;
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
  border: 0;
  padding: 0;
  border-radius: 7px;
  background: none;
  color: var(--dim);
  cursor: pointer;
}
.wb-nav button:hover:not(:disabled) { background: var(--chrome-hover); color: var(--chrome-ink); }
.wb-nav button:disabled { opacity: .22; cursor: default; }

.wb-url {
  flex: 1 1 auto;
  min-width: 0;
  height: 26px;
  margin: 0 4px;
  padding: 0 8px;
  border: 0;
  border-radius: 7px;
  background: var(--key-fill);
  color: var(--chrome-ink);
  font: 450 12.5px/1 var(--ui);
  text-align: left;
  transition: background .12s ease;
}
.wb-url::placeholder { color: var(--dim); }
.wb-url:hover { background: var(--key-cap); }
.wb-url:focus {
  outline: none;
  background: var(--chrome);
  box-shadow: 0 0 0 1px var(--sel);
}
/* Selected text in the address bar is the completion this did not ask for.
   Held quieter than a normal selection so it reads as a suggestion rather than
   as something already chosen. See complete() in canvas/browsing.js. */
.wb-url::selection { background: rgb(13 153 255 / .22); }

/* A page is on its way. Not a percentage: WKWebView reports no progress a
   sibling webview can read, and a bar that invented one would be lying at a
   rate somebody could time. This says only "still going", which is the true
   part. */
.wb-wire {
  position: absolute;
  left: 8px;
  right: 8px;
  bottom: 0;
  height: 2px;
  overflow: hidden;
  border-radius: 2px;
  opacity: 0;
  transition: opacity .2s ease;
}
.block.web.loading .wb-wire { opacity: 1; }
.wb-wire::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  width: 34%;
  border-radius: 2px;
  background: var(--accent);
  animation: wb-wire 1.1s var(--ease) infinite;
}
@keyframes wb-wire {
  from { transform: translateX(-105%); }
  to { transform: translateX(400%); }
}


/* ---- find on page ---- */

/* Part of the chrome rather than a bar floating over the page, which is the
   one transient thing in this browser that has to coexist with what it is
   searching. Everything else that would have covered the page hides it; this
   makes the page shorter instead, and the loop in canvas/browser.js hands the
   shell the new rectangle on the next frame. */
.wb-seek {
  flex: none;
  height: 34px;
  display: flex;
  align-items: center;
  gap: 1px;
  padding: 0 6px 6px;
  background: var(--bg);
}
.wb-seek[hidden] { display: none; }
.wb-seek button {
  flex: none;
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
  border: 0;
  padding: 0;
  border-radius: 7px;
  background: none;
  color: var(--dim);
  cursor: pointer;
}
.wb-seek button:hover { background: var(--chrome-hover); color: var(--chrome-ink); }
.wb-seek-in {
  flex: 1 1 auto;
  min-width: 0;
  height: 26px;
  margin-right: 5px;
  padding: 0 12px;
  border: 0;
  border-radius: 8px;
  background: var(--key-fill);
  color: var(--chrome-ink);
  font: 400 12.5px/1 var(--ui);
}
.wb-seek-in::placeholder { color: var(--dim); }
.wb-seek-in:focus { outline: none; box-shadow: 0 0 0 1px var(--sel); background: var(--chrome); }

/* ---- where the page goes ---- */

/* The rectangle the shell is given. Nothing inside it is seen at the same time
   as a page: the webview covers all of it. */
.wb-page {
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  margin: 0 1px 1px;
  border-radius: 0 0 9px 9px;
  background: var(--bg);
  overflow: hidden;
}

/* ---- a page being driven ----
   The light is the shared rule beside .framewip; the gap it is drawn in is
   RING in canvas/browser.js, which insets the page while a run is working so
   there is somewhere for it to fall off. Nothing is drawn ON the page: it is a
   native view and this document cannot draw over it at all. */

.wb-away {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 12px;
  text-align: center;
  color: var(--dim);
}

/* An empty tab shows nothing at all. It said "Start browsing" under a globe for
   a while, which is the kind of thing an application says on its own front door
   and the wrong thing for a block on a canvas: the address bar is six pixels
   above it, already lit, already the obvious place to type. A paragraph
   explaining a control that is visible from where you are reading the paragraph
   is furniture.

   .empty is the block saying the front tab has nowhere to be, which is what
   keeps the site's card from being drawn for a page that is not a page yet. */
.block.web .wb-away { display: none; }
.block.web.away:not(.empty) .wb-away { display: flex; }

/* What stands where the page would be when the page cannot be there: off the
   edge of the card, under a panel, in a mode where the pointer is not for
   pointing, or in a window with no shell to make one. The site's mark and its
   name, so a block that has scrolled halfway off reads as a page that is out of
   view rather than as one that has crashed. See paintAway(). */
.wb-away-mark {
  width: 26px;
  height: 26px;
}
.wb-away-mark > svg { width: 24px; height: 24px; }
.wb-away-name {
  margin: 0;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font: 500 13px/1.3 var(--ui);
  color: var(--chrome-ink);
}
/* Why the page is not here, when there is a reason worth reading: the shell
   refused to make it, in the shell's own words. Quieter than the name and
   allowed to wrap, because it is a sentence rather than a label, and it is the
   only thing on this card that is ever news. See raise() in canvas/browser.js. */
.wb-away-why {
  margin: 0;
  max-width: 32ch;
  text-align: center;
  font: 450 11.5px/1.45 var(--ui);
  color: var(--dim);
}
.wb-away-why[hidden] { display: none; }

/* ---- the page, stepped aside for a pen ----
   Drawing across a browser block takes the page away, because a native view
   cannot be drawn on top of and the pointer would go to the website rather than
   to the pen. What stood in was the card written for a page that could not be
   shown — a hostname, a globe, and a button offering to open the site in
   another browser — which is a reasonable thing to show somebody whose page
   failed and an alarming one to show somebody who has just picked up a pen. It
   reads as the site having been blocked.

   So while the pen is out the same card says what is actually happening, and
   the way out to another browser goes: nothing has gone wrong and there is
   nowhere else to be. */
.wb-away-pen {
  display: none;
  margin: 0;
  max-width: 30ch;
  text-align: center;
  font: 450 11.5px/1.45 var(--ui);
  color: var(--dim);
}
.block.web.penned .wb-away-pen { display: block; }
.block.web.penned .wb-away-go { display: none; }
.block.web.penned .wb-away-why { display: none; }

/* The one thing a window with no shell can do with an address, which is hand it
   to the browser this canvas is already inside. Hidden in the Mac app, where
   the page itself is what happens — unless it did not, which is the other time
   a way out is worth offering. */
.wb-away-go {
  height: 26px;
  padding: 0 12px;
  border: 0;
  border-radius: 7px;
  background: var(--key-fill);
  color: var(--chrome-ink);
  font: 500 12px/1 var(--ui);
  cursor: pointer;
}
.wb-away-go[hidden] { display: none; }
.wb-away-go:hover { background: var(--key-cap); }

/* ---- history and downloads ---- */

/* What every other panel in this app would have made a dropdown. It fills the
   page area and the page is hidden while it is up, because a menu over a native
   webview opens behind it. See the note at the top of this section. */
.wb-sheet {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  /* The block's own ground, like the strip above it. It was --chrome, which is
     a step lighter in the dark, so opening history swapped the whole page area
     for a panel of a different colour — which reads as a second window rather
     than as this one showing you something else. It is the same object; only
     what is in it changed. */
  background: var(--bg);
}
.wb-sheet[hidden] { display: none; }

.wb-sheet-head {
  flex: none;
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 8px 8px 2px;
}
.wb-sheet-tab {
  height: 26px;
  padding: 0 10px;
  border: 0;
  border-radius: 7px;
  background: none;
  color: var(--dim);
  font: 500 12.5px/1 var(--ui);
  cursor: pointer;
}
.wb-sheet-tab:hover { background: var(--chrome-hover); }
.wb-sheet-tab.on { background: var(--chrome-sunk); color: var(--chrome-ink); }
.wb-sheet-x {
  margin-left: auto;
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
  border: 0;
  padding: 0;
  border-radius: 7px;
  background: none;
  color: var(--dim);
  cursor: pointer;
}
.wb-sheet-x:hover { background: var(--chrome-hover); color: var(--chrome-ink); }

.wb-sift {
  flex: none;
  height: 28px;
  margin: 4px 8px 6px;
  padding: 0 12px;
  border: 0;
  border-radius: 8px;
  background: var(--key-fill);
  color: var(--chrome-ink);
  font: 400 12.5px/1 var(--ui);
}
.wb-sift[hidden] { display: none; }
.wb-sift::placeholder { color: var(--dim); }
.wb-sift:focus { outline: none; box-shadow: 0 0 0 1px var(--sel); background: var(--chrome); }

.wb-sheet-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: 0 8px 8px;
}

.wb-row {
  display: flex;
  align-items: center;
  gap: 10px;
  height: 42px;
  padding: 0 8px;
  border-radius: 8px;
  cursor: default;
}
.wb-row[data-go] { cursor: pointer; }
.wb-row:hover { background: var(--chrome-hover); }
.wb-row-say {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.wb-row-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font: 500 12.5px/1.35 var(--ui);
  color: var(--chrome-ink);
}
.wb-row-sub {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font: 400 11.5px/1.35 var(--ui);
  color: var(--dim);
}
.wb-row-when {
  flex: none;
  font: 400 11.5px/1 var(--ui);
  color: var(--dim);
  font-variant-numeric: tabular-nums;
}
.wb-row-x {
  flex: none;
  width: 20px;
  height: 20px;
  display: grid;
  place-items: center;
  border: 0;
  padding: 0;
  border-radius: 5px;
  background: none;
  color: var(--dim);
  opacity: 0;
  cursor: pointer;
}
.wb-row-x > svg { width: 11px; height: 11px; }
.wb-row:hover .wb-row-x { opacity: .55; }
.wb-row-x:hover { background: var(--chrome-sunk); opacity: 1; }

.wb-none {
  margin: 0;
  padding: 28px 8px;
  text-align: center;
  color: var(--dim);
  font: 400 12.5px/1 var(--ui);
}

.wb-sheet-foot {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 7px 8px;
  border-top: 1px solid var(--line);
}
.wb-forget {
  height: 26px;
  padding: 0 10px;
  border: 0;
  border-radius: 7px;
  background: none;
  color: var(--dim);
  font: 500 12px/1 var(--ui);
  cursor: pointer;
}
.wb-forget:hover { background: var(--chrome-hover); color: var(--accent); }

/* Zoom, where the browser's other settings would be if it had any. It is here
   rather than in the nav row because the nav row is four controls and an
   address and that is the whole of what it should ever be.

   It is the page's own magnification, on top of the canvas's. A block at 200%
   draws its page at 200% without anybody asking — see the zoom pass in
   canvas/browser.js — and this is the extra somebody asks for on top of that,
   which is why 100% here does not mean 100% on screen. */
.wb-mag {
  display: flex;
  align-items: center;
  gap: 1px;
}
.wb-mag button {
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
  border: 0;
  padding: 0;
  border-radius: 6px;
  background: none;
  color: var(--dim);
  font: 500 14px/1 var(--ui);
  cursor: pointer;
}
.wb-mag button:hover { background: var(--chrome-hover); color: var(--chrome-ink); }
.wb-mag-at {
  min-width: 40px;
  text-align: center;
  font: 500 11.5px/1 var(--ui);
  color: var(--dim);
  font-variant-numeric: tabular-nums;
}
