/* ============================================================================
   Best Guests app — shell and components
   ============================================================================

   Structure carried over from the old Mundial Partners dashboard (static4/
   dashboard.css + portal.js): a fixed-width left rail beside a scrolling main
   column, with main-head (title -> subtitle) above a stack of sections. That
   skeleton worked, so it survives intact.

   What changed is the finish. The old rail was a solid 288px ink column with a
   blue accent; this one is a floating Tier-1 glass pane over a low-alpha brand
   wash, echoing the landing page's floating nav bar. See docs/DESIGN.md.

   Requires tokens.css.
   ========================================================================= */

*, *::before, *::after { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  /* The x axis only ever produces sideways drift or swipe-to-go-back. The y
     axis keeps native behaviour — killing the rubber-band reads as the page
     being broken, not as polish. */
  overscroll-behavior-x: none;
  overflow-x: clip;
}

body {
  margin: 0;
  min-height: 100vh;
  font-family: var(--font);
  font-size: var(--text-body);
  line-height: 1.5;
  color: var(--ink);
  background: var(--canvas);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: clip;
}

/* No ambient wash, and no element for one — see the Surfaces note in
   tokens.css. Containers are separated from the page by shadow and a hairline
   rather than by sitting on a tinted ground. */

img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
ul { margin: 0; padding: 0; list-style: none; }
button { font-family: inherit; }

h1, h2, h3, h4 {
  margin: 0;
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.05;
  /* No orphans. `pretty` re-flows to avoid a single word alone on the last
     line; prefer it over `balance`, which optimises for equal line lengths and
     will happily strand a word. */
  text-wrap: pretty;
}
p { margin: 0; text-wrap: pretty; }

/* 700 is reserved for numeric/metric values — that reservation is what keeps
   figures reading as data rather than as headings. Never 700 for a heading. */

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

/* The `hidden` attribute has to actually hide things. The browser's own rule is
   `[hidden]{display:none}` at UA specificity, so ANY authored `display` — and
   most components here set one — silently beats it, and an element toggled with
   `el.hidden = true` stays on screen. This is the single global override that
   makes the attribute trustworthy; without it every component needs its own
   `.thing[hidden]{display:none}`, and the one you forget is a bug you find in a
   screenshot. */
[hidden] { display: none !important; }

/* No scrollbar anywhere — and everything still scrolls.
   The shell is exactly one viewport tall, so every scroll in this app is a
   LOCAL one inside a panel: a drawer, a table, a settings tab. At that size a
   scrollbar reads as a seam across a surface rather than as a page control, and
   on mobile it draws itself over the content it is scrolling.

   Global rather than the .scroll-quiet class this replaces the need for: the
   class only ever covered the containers someone remembered to add it to, and
   the settings panels — which scroll on mobile — were not among them. The class
   is kept because pages still reference it; it is now a no-op.

   Both halves are required and neither is optional: scrollbar-width is the
   standard property (Firefox), ::-webkit-scrollbar is what Chrome and Safari
   actually obey. */
* { scrollbar-width: none; -ms-overflow-style: none; }
*::-webkit-scrollbar { width: 0; height: 0; }

:focus-visible { outline: 2.5px solid var(--accent); outline-offset: 3px; border-radius: 3px; }

.skip-link {
  position: absolute; left: 16px; top: -60px; z-index: 300;
  background: var(--ink); color: #fff; padding: 12px 18px; border-radius: var(--r-xs);
  font-weight: 600; font-size: 14px;
  transition: top .2s var(--ease);
}
.skip-link:focus { top: 16px; }

/* ============================================================================
   Shell layout
   ========================================================================= */

/* ---------------------------------------------------------------------------
   ONE SCREEN, NO SCROLLING
   ---------------------------------------------------------------------------
   Current product rule: everything you need on a page is visible at once. The
   shell is exactly one viewport tall and the main column does not scroll, so a
   page that doesn't fit is a design problem to solve by cutting content, not by
   letting the user scroll for it.

   The document itself is left alone — body keeps its natural height and the
   platform's own elastic overscroll still works, so the frame still has that
   bit of give rather than feeling nailed down.

   This holds on mobile too. The topbar is 58px and in flow, so .app takes
   calc(100vh - 58px) and the two sum to exactly one viewport. A phone shows
   fewer rows, not a longer page.

   Consequences to know before adding to a page:
   - Content that overflows is CLIPPED, not scrollable. Check every page at the
     shortest viewport you care about.
   - A long list pages rather than scrolls, and its page SIZE is measured from
     the space available rather than fixed — see the Projects page script.
   - Anything that genuinely cannot be paged wants its own internal scroll
     container, which is the escape hatch of last resort.
   --------------------------------------------------------------------------- */
/* NO z-index here, deliberately.
   It used to carry `z-index: 1` to sit above the ambient wash, which no longer
   exists — and it didn't need it even then, since both were positioned in the
   same paint layer and .app came later in the DOM.
   What that z-index DID do was open a stacking context, trapping the rail's
   z-index:80 inside a box that itself sat at 1. The scrim is a body child at
   70, so on mobile the scrim painted over the whole app — drawer included —
   which is why the open drawer went grey and stopped taking clicks. The
   60/70/80 scale (topbar / scrim / rail) only works if all three are compared
   in the same context. */
.app {
  position: relative;
  display: flex;
  /* dvh, with vh as the fallback for anything that lacks it. On a phone the
     visible viewport shrinks and grows as the browser's own bars come and go;
     100vh is the LARGEST of those, so a vh-only layout is taller than the
     screen for as long as the bars are showing — which reintroduces exactly
     the scroll this rule exists to prevent. */
  height: 100vh;
  height: 100dvh;
  /* flex-start so the rail keeps its own height instead of stretching, which is
     what lets position:sticky apply to it. */
  align-items: flex-start;
}

/* ---------------- Rail ---------------- */

.rail {
  position: sticky;
  top: 14px;
  flex: none;
  width: var(--rail-w);
  height: calc(100vh - 28px);
  margin: 14px 0 14px 14px;
  padding: 22px 16px 16px;
  display: flex;
  flex-direction: column;
  border-radius: var(--r-panel);
  /* Flat white, not the glass gradient. --glass-fill ends on a violet-tinted
     stop, which over a full-height column pooled at the bottom and made the
     rail read as a lilac panel next to the near-white content cards. A tall
     surface shows a 150deg gradient far more than a short one does, so what
     reads as a subtle sheen on a card reads as a colour cast here.
     No backdrop-filter either: it is pure cost on an opaque surface, and it
     was repainting on every scroll. */
  background: var(--card);
  border: 1px solid var(--line);
  box-shadow: var(--glass-shadow);
  /* Labels are clipped rather than removed while collapsing — see .nav-label.
     Without this they'd spill out over the main column mid-animation. */
  overflow-x: hidden;
  overflow-y: auto;
  /* Width is the ONLY thing that animates on the rail itself. Padding stays put
     — animating it would drag every row sideways and vertically as the rail
     closes, which is exactly the drift this layout is built to avoid. */
  transition: width .3s var(--ease);
}

/* Set by shell.js while it restores the saved collapsed state, so that
   restoring is instant instead of animating on every page load. */
.rail.no-anim, .rail.no-anim * { transition: none !important; }

/* The crown-and-wordmark area is the collapse control — the only one. The whole
   row is the hit target, but it carries NO container of its own: no fill, no
   hover panel, no border. The brand mark should read as the brand, not as a
   button sitting in a box. It sits in the same place whether the rail is open
   or shut, which is what lets one target work in both directions.

   Padding matches the nav rows exactly (see the glyph-slot note below), so the
   crown sits on the same axis as the nav icons rather than hanging left of
   them. */
.rail-brand {
  /* gap 10 like the other rows, so the wordmark starts on exactly the same
     line as the nav labels and the account name. */
  display: flex; align-items: center; gap: 10px;
  width: 100%; flex: none;
  margin-bottom: 18px;
  /* Vertical padding never changes between states — zeroing it on collapse was
     what made the whole rail jump upward. */
  padding: 8px var(--rail-row-inset);
  border: 0; background: none; text-align: left;
  font-family: inherit; font-weight: 600; font-size: 17px; letter-spacing: -0.01em;
  color: inherit; cursor: pointer;
  white-space: nowrap;
}
.topbar .rail-brand { width: auto; margin: 0; padding: 0; }

/* The brand mark, whatever it currently is. Every value comes from the active
   theme file (see the switch at the top of tokens.css), because the two marks
   the app has worn are not the same KIND of object and a rule that hard-codes
   either one can't hold both:

     crown   1.13:1 full-colour art, centred in the slot with 4px a side, and
             painted by --brand-mark-bg as an image with no mask
     bowtie  2.82:1 silhouette, filling the slot edge to edge, drawn as a MASK
             over a currentColor fill so it takes its colour from CSS

   Both shapes fit because the pair --brand-mark-bg / --brand-mark-mask is
   filled in opposite ways: one theme puts the url in the background and sets
   the mask to `none`, the other does the reverse. Adding a third mark means
   adding a theme file, not touching this rule. */
.crown-mark {
  display: block; flex: none;
  width: var(--brand-mark-w); height: var(--brand-mark-h);
  margin: var(--brand-mark-margin);
  background: var(--brand-mark-bg);
  -webkit-mask: var(--brand-mark-mask);
          mask: var(--brand-mark-mask);
}
/* The top bar has no glyph slot to centre in, so the crown's slot margin comes
   back off. A no-op for a mark that already sits at 0. */
.topbar .crown-mark { margin: 0; }

/* ---- The glyph slot ----------------------------------------------------
   Every row in the rail — brand, nav item, identity — is built the same way:

       [ 10px inset ][ 34px slot, mark centred ][ gap ][ label ]

   The inset is identical on every row, so the avatar is padded exactly like
   the nav icons instead of sitting flush against the divider above it. The
   slot is 34px because that is the widest mark (the avatar), so each mark
   centres inside it with symmetric margins:

       crown  (34 - 26) / 2 = 4px
       icon   (34 - 16) / 2 = 9px
       avatar (34 - 34) / 2 = 0px

   Because the slot is a fixed width, every label also starts at the same
   x — the slot's trailing edge plus the row's gap.

   And because each mark is centred in the slot, all three centres land on one
   vertical axis, which is also the collapsed rail's centre line
   (1 + 16 + 10 + 17 = 44 = 88/2). That is the whole point: every mark is
   already exactly where the collapsed rail needs it, so collapsing moves
   nothing at all.

   Aligning mark centres rather than their left EDGES is forced, not stylistic.
   Centring requires left = (box - width) / 2, which depends on the mark's own
   width, so equal left edges and equal centres are mutually exclusive unless
   every mark is the same size. Edge alignment would make the crown and avatar
   slide on every collapse.
   ------------------------------------------------------------------------ */
.nav { display: flex; flex-direction: column; gap: 2px; }
/* Also applied to a <button> — the Support row opens a menu rather than
   navigating. These resets are what keep the two indistinguishable. */
.nav-item {
  display: flex; align-items: center; gap: 10px;
  width: 100%; padding: 9px var(--rail-row-inset);
  border: 0; background: none; text-align: left; cursor: pointer;
  font-family: inherit;
  border-radius: var(--r-chip);
  font-size: 14.5px; font-weight: 500; letter-spacing: -0.01em;
  color: var(--muted);
  white-space: nowrap;
  transition: color .2s var(--ease), background .2s var(--ease),
              box-shadow .2s var(--ease);
}
/* Glass hover, carried over from when the rail itself was glass — but tinted
   rather than white. Pure white translucency (the original rgba(255,255,255,.55))
   is mathematically invisible on a white rail: white over white is white at any
   alpha. The violet tint is what gives the pane something to show, so the row
   still reads as a lit piece of glass sliding under the cursor. */
.nav-item:hover {
  color: var(--ink);
  background: var(--accent-pane-soft);
  box-shadow: inset 0 1px 1px rgba(255,255,255,.9);
}

/* Collapsing animates max-width, not display or visibility: a discrete property
   would snap the text away while the rail is still mid-slide. max-width gives
   the label a width to animate to zero, and the opacity fade runs faster than
   the width so the text is gone before the column gets narrow enough to clip
   it mid-word. */
.nav-label {
  max-width: 170px; overflow: hidden;
  transition: max-width .3s var(--ease), opacity .16s var(--ease);
}

/* Active state is the one place accent colour appears in the rail. It uses the
   accent tint plus a lit top edge rather than a solid fill — a saturated block
   here would compete with the primary button, and DESIGN.md allows exactly one
   fully-saturated element per view. */
.nav-item.active {
  color: var(--accent-deep);
  font-weight: 600;
  /* The full glass treatment: violet-tinted fill, a bright inner top highlight
     for the lit edge, and a short violet glow beneath. This is the one place
     accent colour appears in the rail, and it stays a tinted pane rather than a
     solid block — DESIGN.md allows exactly one fully-saturated element per view
     and that slot is spent on the primary button. */
  background: var(--accent-pane);
  box-shadow: inset 0 1px 1px rgba(255,255,255,.95), var(--accent-lift);
}
.nav-item.active .nav-icon { color: var(--accent); }

/* Flat line icons, per DESIGN.md: UI chrome stays flat. Only icons that sit in
   a glass chip and carry meaning get the 3D treatment.
   16px mark centred in the 34px slot: (34 - 16) / 2 = 9px each side. */
.nav-icon {
  width: 16px; height: 16px; flex: none; color: currentColor;
  margin: 0 9px;
}

/* ---------------- Rail utility band ---------------- */

/* Settings, Support, and whatever else turns out to be configuration or help
   rather than work. This band carries margin-top:auto, so it and the identity
   block below it are what get pushed to the bottom of the rail — the foot used
   to hold that job and now simply follows. Rows inside are plain .nav-items, so
   alignment, hover, active state and collapse behaviour are inherited rather
   than restated.

   No divider above it: the whitespace that margin-top:auto opens up already
   separates these rows from the nav, so a hairline there was doing a job that
   was already done. The rail's one divider sits below instead, above the
   identity block (see .rail-foot). padding-top is only insurance — on a short
   viewport where auto collapses to nothing, it keeps Settings off Reports. */
.rail-utility {
  display: flex; flex-direction: column; gap: 2px;
  margin-top: auto; padding-top: 6px;
}

/* ---------------- Rail foot ---------------- */

/* The rail's only divider. It separates who you are from what you can do, which
   is the split worth drawing — Settings and Support are still navigation, and
   they belong with the nav above them rather than with the account.
   margin-top pairs with the Support row's own 9px of bottom padding to make
   ~16px above the line, matching the 16px it leaves below. */
.rail-foot {
  margin-top: 8px; padding-top: 16px;
  border-top: 1px solid var(--line);
}


/* Same inset as every other row, so the avatar lines up with the nav icons
   instead of sitting flush against the divider above it. */
.rf-identity {
  display: flex; align-items: center; gap: 10px; text-align: left;
  width: 100%; padding: 8px var(--rail-row-inset);
  background: none; border: none; border-radius: var(--r-chip);
  cursor: pointer;
  transition: background .2s var(--ease);
}
.rf-identity:hover {
  background: var(--accent-pane-soft);
  box-shadow: inset 0 1px 1px rgba(255,255,255,.9);
}
/* Set when Settings was reached through this row rather than the Settings nav
   item — see the entry-point note in shell.js. Identical recipe to
   .nav-item.active, because it means the same thing and there is no reason for
   the rail to mark "you are here" two different ways. */
.rf-identity.active {
  background: var(--accent-pane);
  box-shadow: inset 0 1px 1px rgba(255,255,255,.95), var(--accent-lift);
}
.rf-identity.active .rf-name { color: var(--accent-deep); }
/* The avatar IS the slot width, so it needs no centring margin of its own. */
.rf-avatar {
  position: relative;
  flex: none; width: 34px; height: 34px; border-radius: 50%;
  display: grid; place-items: center;
  font-size: 13px; font-weight: 600; color: var(--on-accent);
  background: var(--grad-btn);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.35), var(--accent-lift);
}
/* A stashed photograph replaces the initials, so it has to cover the disc
   rather than tile at its natural size. The initials are removed by the script
   at the same time — they are this element's own text, not a child.

   Shared with the roster's own discs on Settings' Workspace tab: same picture,
   same treatment, so your row there and your row in the rail match. The rule
   sets no background-image itself — that arrives inline, as a data URL. */
.rf-avatar.has-photo,
.ws-avatar.has-photo {
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}

/* A photograph is drawn as a layer OVER the disc rather than as the element's
   own background, and it is only revealed once the bytes have decoded.

   Assigning background-image the moment a URL is known — which is what this
   used to do — replaces the gradient immediately and leaves the disc EMPTY for
   however long the picture takes to arrive. That blank frame between the
   initials and the photograph is what flickered on every reload of Settings and
   on the rail just after logging in.

   The letters stay underneath, so the worst case is a disc with initials on it,
   which is the correct answer when there is no photograph anyway. It is the
   same graceful failure personChip already relies on. */
.rf-avatar::after,
.avatar-lg::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background-image: var(--avatar-photo, none);
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  opacity: 0;
  transition: opacity 160ms ease-out;
  pointer-events: none;
}
.rf-avatar.has-photo::after,
.avatar-lg.has-photo::after { opacity: 1; }

/* Repainted from the session cache on a reload: the picture is already in the
   browser's cache, so fading it in would be a flicker of its own making. */
.rf-avatar.photo-instant::after,
.avatar-lg.photo-instant::after { transition: none; }

/* Signup's first step, painted before its script runs.
   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
   The step sections carry the hidden attribute so the page stays honest with no
   JavaScript. That also means render(), at the bottom of the body, is what first
   puts anything in the card — so it painted empty and the form snapped in after.
   The head script sets data-boot-step from the URL hash, which is the only thing
   the step depends on, and this uncovers the one section it names. render()
   removes the attribute as soon as it takes over.

   !important is load-bearing, not decoration: the global `[hidden]{display:none
   !important}` near the top of this file is what makes the hidden attribute
   trustworthy everywhere else, and an ordinary `display:block` here lost to it
   silently. Which is to say this rule did nothing at all — the card still
   painted empty and the form still snapped in when the last script ran, which
   is the flash somebody sees toggling between /login and /signup. */
html[data-boot-step="account"] #step-account[hidden] { display: block !important; }

/* The signed-in face, dressed before the body exists.
   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
   The head script on every rail page reads the cached /api/me and sets
   --me-avatar there, early enough that the file is already on its way by the
   time this rule is first applied. Opacity is 1 from the outset rather than
   raised later, so there is no frame in which the initials underneath are the
   only thing drawn — which is the flash that survived doing this from shell.js,
   where the URL is not known until the last script in the body has run.

   THIS USED TO BE THE RAIL ALONE, and that exclusion was the bug rather than a
   precaution. Settings' large disc was left out because it shows an upload
   PREVIEW during an edit, which is deliberately not the saved photograph — so
   hard-coding --me-avatar here would have overridden the preview. The cost was
   that the profile picture took the slow path every single time: painted as
   gradient-plus-initials, then a `new Image()`, then `img.decode()`, then
   .has-photo, then a 160ms fade. That is one to three frames of the WRONG face
   on every load of Settings, which is exactly what it looked like.

   The fallback chain is what lets both discs share this rule. --avatar-photo is
   set inline by paintDiscPhoto, so the moment any script has an opinion — a
   preview, a photo changed since the head script ran, a staged removal — that
   value wins. --me-avatar is only ever the value for the frames BEFORE any
   script has run, which is the entire window this rule exists to cover. */
:root.has-me-photo .rf-avatar::after,
:root.has-me-photo .avatar-me::after {
  background-image: var(--avatar-photo, var(--me-avatar));
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .rf-avatar::after,
  .avatar-lg::after { transition: none; }
}

/* flex:1 so the name takes the room the log-out button leaves. */

/* min-width:0 is what lets the ellipsis below actually engage — without it a
   flex item refuses to shrink below its content width and the text overflows
   the rail instead of truncating. */
.rf-who {
  flex: 1; min-width: 0; overflow: hidden;
  transition: max-width .3s var(--ease), opacity .16s var(--ease);
}
/* display:block, because these are <span>s: as inline elements the name and
   company render on the same line and neither white-space nor text-overflow
   applies to them.

   The tightened line-height is load-bearing, not cosmetic. At the inherited 1.5
   these two lines stack to 39.5px — taller than the 34px avatar — so the row's
   height came from the text, and the text's height depended on what element
   hosted the row. Switching the row from <button> to <a> made it 5.5px taller
   (measured), which pushed the whole bottom of the rail up. At 1.25 the pair is
   33.25px, the avatar is the tallest thing in the row again, and the row is a
   flat 50px whatever it's built from. */
.rf-name {
  display: block; line-height: 1.25;
  font-size: 13px; font-weight: 600; letter-spacing: -0.01em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Two facts on one line: the workspace, and what it has left to spend. A flex row
   rather than a line of text, so the coin glyph centres on the type instead of
   riding above its baseline — the same arrangement .rm-note uses in the menu.

   The BALANCE is flex:none and the name is the part that truncates: running out
   of room should cost a few letters of a name you already know, not the number
   you came to read. */
.rf-company {
  display: flex; align-items: center; gap: 5px;
  line-height: 1.25; margin-top: 2px;
  font-size: 12px; color: var(--muted);
  overflow: hidden;
}
.rf-ws { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rf-company .rm-tokens,
.rf-company .rm-dot-sep { flex: none; }

/* ---------------- Collapsed rail ---------------- */
/*
   Scoped to desktop on purpose. Media queries add no specificity, so a bare
   `.rail.collapsed{width:70px}` (0,2,0) would outrank the mobile drawer's
   `.rail{width:min(300px,86vw)}` (0,1,0) and produce a 70px-wide drawer on a
   phone. Everything collapse-related therefore lives inside this query.

   Note what is NOT here: no padding changes, no justify-content, no margins,
   no per-element repositioning. Because every mark already sits on the glyph
   axis (see the note above .nav-item), and that axis is the collapsed rail's
   centre line, collapsing is a width change and nothing else. The crown, the
   four nav icons and the avatar all hold their exact position — measured
   dx=0, dy=0 — while the rail's right edge slides in past them.

   Two properties must never be used for this:
     - `justify-content` is discrete, so it cannot animate. It would snap every
       row to centre on the first frame while the rail was still 264px wide.
     - `display`/`visibility` on the labels, same problem — the text would
       vanish instantly instead of shrinking away with the rail.
*/
@media (min-width: 901px) {
  /* Two selectors for one width, and they are not redundant.

     `.rail.collapsed` is the state shell.js maintains once it has run, and it is
     what the toggle animates.

     `.rail-collapsed` on <html> is set by three lines in each page's <head>,
     before the first paint — shell.js loads at the bottom of the document, so
     without it the browser paints a 264px rail and then snaps it to 88px once
     mount() reads the preference. Restoring a collapsed rail was visibly a flash
     of the open one on every hard refresh, with the page shifting sideways.

     The pre-paint class has the HIGHER specificity of the two, so shell.js has to
     keep it in step rather than only toggling the rail's own class — see apply()
     in setupCollapse, which sets both. If it ever set only one, expanding the
     rail would leave it stuck narrow. */
  .rail.collapsed,
  .rail-collapsed .rail { width: var(--rail-w-collapsed); }

  /* The gap is deliberately left alone. Closing it would keep the row's
     contents inside the 36px box, but it also drags each label's left edge
     ~10px inward while it fades — a small drift, but drift. The row overflows
     its box by the gap width instead, which is invisible: the label is already
     zero-width, and the rail clips overflow anyway. Nothing moves. */

  /* max-width, not display or visibility. The opacity fade runs faster than the
     width so the text is gone before the column narrows enough to clip it
     mid-word. */
  .rail.collapsed .brand-word,
  .rail.collapsed .nav-label { max-width: 0; opacity: 0; }
  .rail.collapsed .rf-who { max-width: 0; opacity: 0; flex: none; }
}

/* The brand word needs a max-width to animate from; it has no intrinsic one. */
.brand-word {
  max-width: 170px; overflow: hidden;
  transition: max-width .3s var(--ease), opacity .16s var(--ease);
}

/* ---------------- Main ---------------- */

.main {
  flex: 1; min-width: 0;
  /* 100% of .app, NOT 100vh. Two elements both claiming a viewport height is
     one assumption too many: on mobile .app is calc(100vh - 58px) to make room
     for the topbar, and a main still insisting on 100vh overflowed by exactly
     that 58px — which quietly pushed the pagination off the bottom of the
     screen. Deferring to the parent means the height is stated once. */
  height: 100%;
  display: flex; flex-direction: column;
  overflow: hidden;
  /* Sides and bottom are the rail's 14px inset, so the gaps around the shell
     match and the content column's bottom edge lands on the rail's — the rail
     is calc(100vh - 28px) with 14px above and below, so its bottom sits at
     100vh - 14, and so does this.
     The TOP is larger on purpose: it aligns the page header with the rail's
     brand row rather than with the rail's outer edge. See --main-pad-y. */
  padding: var(--main-pad-y) var(--main-pad-x) 14px;
}

/* A glyph and a title on one line, and nothing else. What used to be here — an
   eyebrow pill restating the page name above a heading that already said it, a
   status badge labelling the whole page while the panels below carried their
   own, a rule drawn between a heading and the content it heads, and a paragraph
   of description — has all gone. The 3D glyph now carries what the eyebrow was
   reaching for: it names the page at a glance, in the one visual language the
   product already has, without spelling the word out twice.

   align-items:center rather than baseline, because the chip is a box and the
   title is text: baseline would hang the chip low by the font's descender. */
.main-head {
  flex: none;
  display: flex; align-items: center; gap: 14px;
  /* Every page's header occupies the same band, whether or not it carries an
     action button. Without this the header box is as tall as its tallest child,
     so Projects — the one page with a button — sat 5px taller than the rest and
     pushed its panel down by that much. The title is centred in the band, so it
     lands on the same line on every page and the gap beneath it is identical.
     Matches the primary button's height, which is what set the tall case. */
  min-height: 42px;
  margin-bottom: 26px;
}

.page-title { font-size: 30px; }

/* Detail screens keep the same full-size heading used everywhere else, but
   express their location as a single path. Previous levels are quieter and
   clickable; the current level retains the original page-title treatment. */
.page-breadcrumb {
  display: flex; align-items: center; gap: 8px;
  min-width: 0;
}
.page-breadcrumb-parent {
  flex: none;
  color: var(--muted);
  text-decoration: none;
  transition: color .15s var(--ease);
}
.page-breadcrumb-parent:hover { color: var(--ink); }
.page-breadcrumb-sep {
  flex: none;
  color: var(--line-firm);
}
.page-breadcrumb.is-detail .page-title {
  min-width: 0;
  color: var(--ink);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* A MIDDLE crumb — the project in Projects / <project> / <list>. Unlike the root,
   which is a fixed section name and short, this is a name somebody typed and can
   be any length; at 30px a long one used to push the page you are actually on
   off the end of the row.

   So it is the one crumb that shrinks. Capped at a third of the row as well as
   shrinkable, because a name allowed to take the whole width would leave the
   current page as a bare ellipsis — the trail would be legible and the
   destination would not. The full name stays available in its title attribute. */
/* The header of a run that has not loaded yet. Reserved, not removed: .main-head
   holds a fixed 42px band and .head-action is pushed right by margin-left:auto,
   so hiding this shifts nothing and revealing it shifts nothing back.

   Set before the first paint by three lines in enrich.html's <head> and dropped
   by paintCrumbs() as soon as the trail is real — the reasoning is written out
   beside both. Short version: a run's URL is an opaque code, so until the run is
   fetched anything this header said would be a guess, and it used to show two
   wrong headers in a row before landing on the right one. */
html.enrich-pending .page-breadcrumb { visibility: hidden; }

.page-breadcrumb-mid {
  flex: 0 1 auto; min-width: 0; max-width: 34%;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.page-breadcrumb-parent,
.page-breadcrumb-sep,
.page-breadcrumb.is-detail .page-title {
  font-family: inherit;
  font-size: 30px;
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.05;
}

.section { flex: none; margin-bottom: 18px; }
/* The last section carries no trailing margin, so the bottom edge of the last
   container on the page lands on exactly the same line as the rail's bottom
   edge (both at 100vh - 14). Without this the page content stopped 18px short
   of the rail and the two columns visibly failed to line up. */
.section:last-child { margin-bottom: 0; }

/* Put this on the LAST section of a page. It absorbs whatever height is left
   under the one-screen layout, so page content reaches the bottom of the
   screen itself rather than leaving dead air with nothing marking the end.
   min-height:0 is required throughout: a flex item's default min-height:auto
   refuses to shrink below its content, which would defeat flex:1 entirely.
   flex-direction:column so a .section-label can sit above the growing child
   (Home keeps its "Sections" label; the coming-soon pages have no label and
   the rule is a no-op for them).

   Every page uses it, so the content column always ends level with the rail.
   What fills the panel differs: a skeleton stretches (see .sk-page), while a
   page with real, finite content anchors that content to the top and leaves the
   surplus as white space below it. The panel reaching the bottom is the
   constant; its contents stretching is not.

   The old warning still stands for when real content arrives: do NOT put this
   on a grid of short cards. It was tried and reverted — a grid given the full
   column height either floats away from its own section label with a gap in
   between, or inflates every card into a mostly-empty box. Content that doesn't
   fill the screen should just sit at the top. */
.section-fill {
  flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column;
}
.section-fill > .panel,
.section-fill > .panel-solid {
  flex: 1; min-height: 0;
  display: flex; align-items: center;
}
/* For a panel holding a stack rather than one centred block — a tab strip above
   its panels, say. Undoes the centring above, which exists for empty states. */
.section-fill > .panel.panel-stack {
  flex-direction: column; align-items: stretch;
}
.section-label {
  display: flex; align-items: center; gap: 6px;
  margin-bottom: 14px;
  font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .1em;
  color: var(--muted);
}

/* ============================================================================
   Panels
   ========================================================================= */

/* Flat white, matching the rail. Every large surface in the app is now a plain
   white card — same reasoning as the rail: --glass-fill's violet end stop reads
   as a colour cast rather than a sheen once a surface gets big, and the
   surfaces ended up faintly different colours depending on where they sat over
   the background wash.

   The glass language has not gone away, it has moved down a level: it now lives
   on the small elements that sit ON these surfaces — buttons, badges, icon
   chips, the active nav row — where a translucent, lit-edged object reads
   as an object rather than as a tinted panel. Large surfaces are the ground;
   glass is what sits on it.

   No backdrop-filter here either: it is wasted work on an opaque fill, and it
   was repainting on scroll. */
.panel {
  padding: var(--panel-pad);
  border-radius: var(--r-panel);
  background: var(--card);
  border: 1px solid var(--line-firm);
  box-shadow: var(--glass-shadow);
}

/* Same fill as .panel now that both are opaque white — this variant only
   differs in carrying the tighter --shadow-panel. Reach for it when a surface
   holds many rows, a chart, or anything that scrolls inside itself, where the
   wider ambient shadow would smear against whatever sits below. */
.panel-solid {
  padding: var(--panel-pad);
  border-radius: var(--r-panel);
  background: var(--card);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-panel);
}

.panel-head {
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
  margin-bottom: 14px;
}
.panel-title { font-size: 17px; }
.panel-note { font-size: 13.5px; color: var(--muted); margin-top: 5px; }

.grid { display: grid; gap: var(--gap); }
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }

/* A whole panel that is a link. Card hover is -6px (buttons are -2px) — that
   split is a site-wide convention, not a per-component choice. */
.card-link {
  display: flex; flex-direction: column; align-items: flex-start;
  transition: transform .3s var(--ease), box-shadow .3s var(--ease);
}
.card-link:hover { transform: translateY(-6px); box-shadow: var(--glass-shadow-hover); }
.card-link .icon-chip { margin-bottom: 16px; }
.card-title { font-size: 17px; }
.card-note { font-size: 14px; color: var(--muted); margin-top: 8px; }
/* margin-top:auto pushes the badge to the card's bottom edge, so a row of cards
   with unequal copy lengths still has its badges on one line. Grid items
   stretch to equal height by default, which is what gives the auto room to
   work. */
.card-link .badge { margin-top: auto; }
.card-link .card-note { margin-bottom: 16px; }

/* ============================================================================
   Buttons
   ========================================================================= */

.btn {
  display: inline-flex; align-items: center; gap: 9px;
  padding: 11px 18px;
  border: 1px solid transparent;
  border-radius: var(--r-btn);
  font-size: 14.5px; font-weight: 600; letter-spacing: -0.01em;
  /* Stated, because <button> and <a> disagree about it otherwise: a button gets
     `normal` from the UA sheet while an anchor inherits the body's 1.5, which
     made the same .btn 42px tall in one place and 45.8px in another. The pairs
     sit side by side on the empty states, so the difference showed. */
  line-height: normal;
  white-space: nowrap; cursor: pointer;
  transition: transform .2s var(--ease), box-shadow .2s var(--ease),
              background-color .2s var(--ease), border-color .2s var(--ease),
              color .2s var(--ease), filter .2s var(--ease);
}
/* Button glyphs never move. A hover that slides the icon inside a button that
   is itself holding still reads as a rendering artefact rather than as
   feedback — the button already has brightness and shadow to say it's live. */
.btn svg { width: 16px; height: 16px; flex: none; }
.btn-sm { padding: 8px 14px; font-size: 13.5px; }
.btn:disabled { opacity: .5; cursor: not-allowed; transform: none !important; }

/* One per view. The most decorated element in the system: four stacked shadows
   plus a conic beam orbiting the border. */
@property --beam-angle { syntax: '<angle>'; initial-value: 0deg; inherits: false; }
.btn-primary {
  position: relative; isolation: isolate; --beam-angle: 0deg;
  background: var(--grad-btn);
  color: var(--on-accent);
  text-shadow: 0 1px 2px rgba(30,18,60,.32);
  border: 1px solid rgba(255,255,255,.28);
  box-shadow: var(--shadow-cta);
}
.btn-primary::after,
.btn-danger-fill::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1.6px;
  background: conic-gradient(from var(--beam-angle),
    rgba(255,255,255,0) 0deg, rgba(255,255,255,0) 250deg,
    rgba(255,255,255,.9) 320deg, rgba(255,255,255,0) 360deg);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor; mask-composite: exclude;
  pointer-events: none; z-index: 2;
  animation: beam-orbit 5.5s linear infinite;
}
@keyframes beam-orbit { to { --beam-angle: 360deg; } }
/* No translateY here. The lift fought the button's own four-layer shadow —
   the element moved up while the shadow re-rendered underneath it, which read
   as a stutter rather than as a lift. Brightness and a deeper shadow carry the
   hover on their own, and the arrow still slides. */
.btn-primary:hover {
  filter: brightness(1.05);
  box-shadow: var(--shadow-cta-hover);
}

.btn-ghost {
  color: var(--ink);
  background: var(--card);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-panel);
}
/* No lift, matching the primary button — a hover that moves the control is the
   one thing the button set no longer does anywhere. The shadow deepens instead,
   which is the same signal without the movement. --shadow-panel-hover rather
   than --glass-shadow-hover: the glass one throws a 68px ambient layer sized
   for a full panel, and under a 34px button it reads as a smudge. */
.btn-ghost:hover { box-shadow: var(--shadow-panel-hover); }

/* Text on its own — no box, no border, just a quiet secondary action. Muted by
   default so it sits behind the main controls, then up to full ink on hover or
   press. */
.btn-text {
  padding: 0; border: 0; border-radius: 0;
  background: none;
  font-weight: 600; color: var(--muted);
  text-decoration: none;
  transition: color .15s var(--ease);
}
.btn-text:hover,
.btn-text:focus-visible,
.btn-text:active { color: var(--ink); }

/* Destructive actions, in two weights that escalate.

   On the page: outlined on white. "Delete everything" should not compete for
   the eye with the action people actually came to do, and the button that only
   ASKS the question has not earned the weight of one that answers it. It fills
   in on hover, at the point the pointer is already committed. */
.btn-danger {
  color: var(--danger);
  background: var(--card);
  border: 1px solid var(--danger-tint);
}
.btn-danger:hover {
  color: var(--danger-on-solid);
  background: var(--danger-solid);
  border-color: var(--danger-solid);
  box-shadow: var(--shadow-danger);
}

/* Inside the dialog: the primary button's full glass — gradient, lit top edge,
   layered shadow in the danger hue, and the orbiting beam — because this is the
   press that actually destroys something, and by here the weight is earned.

   The beam is the one thing DESIGN.md rations to one per view, and this does
   not break that: it only ever appears inside a dialog, where the page behind
   is inert and dimmed. The delete dialog carries no blue CTA at all, so the two
   are never lit at the same time in the same layer. */
.btn-danger-fill {
  position: relative; isolation: isolate; --beam-angle: 0deg;
  background: var(--grad-danger);
  color: var(--danger-on-solid);
  text-shadow: 0 1px 2px rgba(70,10,10,.32);
  border: 1px solid rgba(255,255,255,.28);
  box-shadow: var(--shadow-cta-danger);
}
.btn-danger-fill:hover {
  filter: brightness(1.05);
  box-shadow: var(--shadow-cta-danger-hover);
}

/* ============================================================================
   Durable in-app notifications
   ============================================================================

   Same dark, bottom-entering language as Shell.toast(), but these are account
   records rather than timers. They stack without covering one another and keep
   a real dismiss control because leaving is an explicit decision. */
.app-notification-stack {
  position: fixed; z-index: 190;
  left: 50%; bottom: 26px; transform: translateX(-50%);
  width: min(560px, calc(100vw - 28px));
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  pointer-events: none;
}
.app-notification-stack[hidden] { display: none; }
.app-notification {
  flex: none;
  display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px;
  align-items: center;
  width: max-content; max-width: 100%;
  min-height: 48px; padding: 7px 8px 7px 14px;
  border: 1px solid rgba(255,255,255,.1); border-radius: var(--r-md);
  color: #fff; background: rgba(20,20,26,.94);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  box-shadow: 0 2px 6px rgba(20,20,26,.2),
              0 18px 40px -16px rgba(20,20,26,.4);
  pointer-events: auto;
  animation: notification-rise .22s var(--ease) both;
  transition: opacity .18s var(--ease), transform .18s var(--ease);
}
.app-notification.is-settled { animation: none; }
.app-notification.is-leaving { opacity: 0; transform: translateY(8px); }
.app-notification-body {
  display: flex; align-items: baseline; gap: 4px;
  min-width: 0; overflow: hidden;
  font-size: 13.5px; line-height: 1.35; white-space: nowrap;
}
.app-notification-name {
  min-width: 0; overflow: hidden; text-overflow: ellipsis;
  font-weight: 650; white-space: nowrap;
}
.app-notification-status { flex: none; color: rgba(255,255,255,.88); }
.app-notification-link {
  flex: none; margin-left: 8px;
  color: #fff; font-weight: 650; text-decoration: none; text-underline-offset: 3px;
}
.app-notification-link:hover,
.app-notification-link:focus-visible {
  text-decoration: underline; text-decoration-thickness: 1.5px;
}
.app-notification-link[aria-disabled="true"] { opacity: .66; pointer-events: none; }
.app-notification-close {
  display: grid; place-items: center;
  width: 32px; height: 32px; padding: 0;
  border: 0; border-radius: var(--r-xs);
  color: rgba(255,255,255,.72); background: transparent;
  cursor: pointer;
  transition: color .15s var(--ease), background .15s var(--ease);
}
.app-notification-close:hover,
.app-notification-close:focus-visible {
  color: #fff; background: rgba(255,255,255,.1);
}
.app-notification-close svg { width: 16px; height: 16px; }
@keyframes notification-rise {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}

/* ============================================================================
   Form controls
   ========================================================================= */

/* First pass, built for the Projects toolbar. DESIGN.md listed inputs as
   unbuilt; this is the start of that set, kept deliberately plain — a hairline
   box on white, no glass. Glass belongs on things you press, not on things you
   type into: a translucent field makes its own text harder to read. */
.field {
  height: 40px; padding: 0 14px;
  border: 1px solid var(--line-firm);
  border-radius: var(--r-md);
  background: var(--card);
  font-family: inherit; font-size: 14px; color: var(--ink);
  transition: border-color .2s var(--ease);
}
.field::placeholder { color: var(--muted); }
/* A .field that holds more than a line. Height comes from rows= rather than a
   fixed px, so the markup says how tall it is; vertical resize only, because
   dragging one of these wider than its column is never what anyone meant. */
.field-area {
  height: auto; min-height: 84px;
  padding: 11px 14px;
  line-height: 1.5;
  resize: vertical;
}
/* The native date field's calendar button. The picker behind it is the
   platform's and stays that way — a date picker is a component, not a style —
   but the trigger can at least be the app's grey rather than the browser's
   near-black, so it sits with the caret on the menu beside it.
   filter, because the control is a UA shadow element with no colour of its own
   to set. */
.field[type="date"]::-webkit-calendar-picker-indicator {
  opacity: .55;
  cursor: pointer;
  transition: opacity .15s var(--ease);
}
.field[type="date"]:hover::-webkit-calendar-picker-indicator { opacity: .9; }

/* A field that cannot be edited has to say so before it is clicked. The canvas
   step rather than a lighter ink: the VALUE is still information worth reading
   (the workspace really is called that), it is the box that is inert — so the
   ground recedes and the text stays legible. */
.field:disabled {
  background: var(--bg-alt);
  color: var(--muted);
  cursor: not-allowed;
}

/* Fields opt OUT of the global 2.5px offset focus ring and use a tight, quiet
   one instead.
   Why they need their own: :focus-visible matches on a text field even when you
   reached it with the mouse — the browser assumes you're about to type, which
   is right — so the loud ring fired on every click into the search box and read
   as an error state.
   What replaces it is still a real focus indicator, just proportionate: the
   border goes accent and a 3px halo sits tight against it. This is scoped to
   .field ONLY. Everything else keeps the global ring, because removing focus
   indication outright would leave keyboard users with no idea where they are. */
.field:focus,
.field:focus-visible {
  outline: none;
  border-color: var(--accent-soft);
  box-shadow: var(--accent-ring);
}

/* Icon inside the field, so the search box reads as a search box before you
   click it. The padding on the input is what keeps the text clear of it. */
.search { position: relative; flex: 1; min-width: 0; display: flex; }
.search .field { width: 100%; padding-left: 40px; padding-right: 42px; }

/* The browser's search cancel control chooses its own colour and, more
   importantly, may hide when the field blurs. Suppress it and use a real button
   whose state follows the value via :placeholder-shown, so a non-empty search
   always has a visible way out. */
.search .field::-webkit-search-cancel-button {
  -webkit-appearance: none;
  appearance: none;
  display: none;
}
.search-clear {
  position: absolute; z-index: 1;
  top: 50%; right: 7px; transform: translateY(-50%);
  display: grid; place-items: center;
  width: 28px; height: 28px; padding: 0;
  border: 0; border-radius: var(--r-xs);
  background: transparent; color: var(--muted);
  cursor: pointer;
  transition: opacity .15s var(--ease), color .15s var(--ease),
              background-color .15s var(--ease), visibility .15s var(--ease);
}
.search-clear svg { width: 15px; height: 15px; }
.search-clear:hover { color: var(--ink); background: var(--bg-alt); }
.search .field:placeholder-shown + .search-clear {
  opacity: 0; visibility: hidden; pointer-events: none;
}
.search-icon {
  position: absolute; left: 13px; top: 50%; transform: translateY(-50%);
  width: 16px; height: 16px; color: var(--muted); pointer-events: none;
}

/* ---- Dropdown ----------------------------------------------------------
   A menu that looks like the rest of the app, which a native <select> can't:
   its popup is drawn by the OS, so it can't take our radius, our type, or the
   purple check on the chosen row.

   Built as an upgrade over a real <select> rather than instead of one — see
   Shell.enhanceSelect. The native element stays in the DOM holding the value
   and firing `change`, so page code never knows the difference and the control
   still works if the script fails.
   ------------------------------------------------------------------------ */
.dropdown { position: relative; flex: none; }

.dropdown-trigger {
  display: inline-flex; align-items: center; gap: 8px;
  cursor: pointer; text-align: left;
}
.dropdown-trigger.is-placeholder { color: var(--muted); }
.dropdown-trigger .dropdown-caret {
  width: 15px; height: 15px; flex: none; color: var(--muted);
  transition: transform .2s var(--ease);
}
.dropdown[data-open="true"] .dropdown-caret { transform: rotate(180deg); }

/* The glyph a fixed-label trigger carries ahead of its word — Sort's, say. Muted
   like the search box's magnifier, because it labels the control rather than
   being the thing you read. */
.dropdown-lead { width: 15px; height: 15px; flex: none; color: var(--muted); }

/* An open menu keeps its trigger lit, matching a focused .field exactly.
   Without this the highlight vanished the moment the panel appeared — focus
   moves into the list, so :focus leaves the button — and the open panel looked
   detached from the control that opened it. */
.dropdown[data-open="true"] .dropdown-trigger {
  border-color: var(--accent-soft);
  box-shadow: var(--accent-ring);
}

/* ...and drops it again the moment the menu closes. Picking an option returns
   focus to the trigger — it has to, or a keyboard user is left nowhere — and
   the plain :focus half of the .field rule kept the ring on afterwards, so a
   closed menu still looked open. :not(:focus-visible) is the distinction that
   fixes it: a mouse user gets focus without the ring, a keyboard user still
   gets both. */
.dropdown-trigger:focus:not(:focus-visible) {
  border-color: var(--line-firm);
  box-shadow: none;
}

/* right:0 so a menu near the window edge opens inward instead of off-screen —
   which also means any extra width spills leftward, away from the edge.

   Deliberately wider than the trigger, not merely as wide. Matching the trigger
   exactly made the panel look like a slightly swollen copy of the button rather
   than a surface that opened out of it, and the near-miss read as a rendering
   error. +44px is enough to be clearly intentional. */
/* .is-up is set by enhanceSelect when the menu will not fit below its trigger
   inside whatever is clipping it — see placeList(). */
.dropdown-list.is-up { top: auto; bottom: calc(100% + 6px); }
.dropdown-list {
  position: absolute; z-index: 60;
  top: calc(100% + 6px); right: 0;
  min-width: calc(100% + 44px); width: max-content; max-width: 300px;
  /* A menu with a lot of options opens downward from a trigger that may already
     be low on the page — signup's company type ran off the bottom of the screen
     at nine of them. It scrolls inside itself past this height rather than
     running off, which is the same escape hatch long lists take elsewhere. */
  max-height: min(320px, 58vh);
  overflow-y: auto;
  padding: 6px;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-panel);
}

/* Label left, check right. Labels all start on the menu's left edge, which is
   what makes the list scannable; the check is a mark against the row you
   already chose, so it sits at the end of that row. */
.dropdown-item {
  display: flex; align-items: center; gap: 16px;
  justify-content: space-between;
  width: 100%; padding: 8px 10px;
  border: 0; border-radius: var(--r-xs);
  background: none; color: var(--ink);
  /* 400, matching .rm-item — and it widens the gap to the chosen row's 600,
     which is now the only thing marking it besides the tick. */
  font-family: inherit; font-size: 14px; font-weight: 400; text-align: left;
  white-space: nowrap; cursor: pointer;
  transition: background .15s var(--ease);
}
/* The rail's row hover, exactly — same tinted pane on the workspace menu, the
   nav rows and here. A list of choices should not change its manners depending
   on which corner of the app it opened in. */
.dropdown-item:hover { background: var(--accent-pane-soft); }
/* Bold black, not accent: the tick is what marks the chosen row, and colouring
   the label too says the same thing twice — then competes with the tick for
   which one is the signal. Weight carries it in the scan, the tick confirms it.
   Same rule as .rm-item[aria-checked]. */
.dropdown-item[aria-selected="true"] { font-weight: 600; }

/* visibility, not display: the check keeps its box on every row so the labels
   all start on the same x whether or not they're the chosen one. */
.dropdown-check {
  width: 15px; height: 15px; flex: none;
  color: var(--accent); visibility: hidden;
}
.dropdown-item[aria-selected="true"] .dropdown-check { visibility: visible; }

/* ---- Date picker --------------------------------------------------------
   The browser's date popup cannot be styled, so New project uses an app-owned
   calendar. Its trigger and floating surface deliberately share the dropdown
   language above: one hairline field, one caret, and the same white panel,
   radius, shadow, accent ring and selected-state colour. */
.date-picker { position: relative; align-self: start; max-width: 100%; }
.date-trigger { width: max-content; max-width: 100%; }
.date-trigger.is-placeholder { color: var(--muted); }
.date-picker[data-open="true"] .date-trigger {
  border-color: var(--accent-soft);
  box-shadow: var(--accent-ring);
}
.date-picker[data-open="true"] .dropdown-caret { transform: rotate(180deg); }

.date-panel {
  position: absolute; z-index: 60;
  top: calc(100% + 6px); left: 0;
  width: 264px; max-width: calc(100vw - 48px);
  padding: 8px;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-panel);
}
.date-panel.is-up { top: auto; bottom: calc(100% + 6px); }
.date-panel-head {
  display: grid; grid-template-columns: 30px 1fr 30px;
  align-items: center; gap: 4px;
  margin-bottom: 5px;
}
.date-month-title {
  font-size: 13.5px; font-weight: 600; text-align: center;
  letter-spacing: -0.005em;
}
.date-month-nav {
  display: grid; place-items: center;
  width: 30px; height: 30px; padding: 0;
  border: 0; border-radius: var(--r-xs);
  background: none; color: var(--muted); cursor: pointer;
  transition: color .15s var(--ease), background .15s var(--ease);
}
.date-month-nav:hover { color: var(--ink); background: var(--accent-pane-soft); }
.date-month-nav svg { width: 15px; height: 15px; }

.date-weekdays,
.date-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.date-weekdays { margin-bottom: 3px; }
.date-weekdays span {
  display: grid; place-items: center; height: 20px;
  font-size: 10.5px; font-weight: 600; color: var(--muted);
  text-transform: uppercase;
}
.date-day {
  display: grid; place-items: center;
  min-width: 0; height: 28px; padding: 0;
  border: 0; border-radius: var(--r-xs);
  background: none; color: var(--ink);
  font-family: inherit; font-size: 13px; cursor: pointer;
  transition: color .15s var(--ease), background .15s var(--ease);
}
.date-day:hover { background: var(--accent-pane-soft); }
.date-day.is-today { box-shadow: inset 0 0 0 1px var(--line-firm); }
.date-day[aria-pressed="true"] {
  background: var(--accent); color: #fff; font-weight: 600;
  box-shadow: none;
}
.date-day-spacer { height: 28px; }
.date-panel-foot {
  display: flex; justify-content: space-between; align-items: center;
  margin-top: 5px; padding: 5px 3px 0;
  border-top: 1px solid var(--line);
}
.date-text-action {
  padding: 4px 6px; border: 0; border-radius: var(--r-xs);
  background: none; color: var(--muted);
  font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer;
}
.date-text-action:hover { color: var(--ink); background: var(--accent-pane-soft); }

/* appearance:none to drop the platform control, then the chevron is redrawn as
   a background image — inline data URI, because this stylesheet must not make
   a network request for a 300-byte arrow. Only used if the dropdown upgrade
   doesn't run. */
.select {
  appearance: none; -webkit-appearance: none;
  padding-right: 34px;
  background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236E6E78' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 11px center;
  background-size: 15px 15px;
  cursor: pointer;
}

/* ============================================================================
   Projects
   ========================================================================= */

/* Pushes a header's action button to the far right, across from the title. */
.head-action { margin-left: auto; }

.toolbar {
  display: flex; align-items: center; gap: 10px; flex: none;
  flex-wrap: wrap;
}
/* Under the filter bar and directly above the results, which is the order the
   page is used in: search, narrow, see how many are left, then read them.
   It sat inside the toolbar for a while so mobile could pull its two halves up
   onto the Filters/Sort row; that is gone, and being a plain sibling again is
   both simpler and the same at every width. */
.filter-summary {
  display: flex; align-items: center; gap: 12px;
  margin: 14px 0 12px;
}
.toolbar-count {
  flex: 1; min-width: 0;
  font-size: 13px; color: var(--muted);
}

/* ---- Filters and Sort --------------------------------------------------------

   Two controls of different kinds, which is why they look alike but behave
   differently. Sort picks ONE value, so it opens a menu (the app's dropdown,
   with a fixed "Sort" label — see Shell.enhanceSelect). Filters sets SEVERAL, so
   it opens a row: a menu holding five dropdowns is a menu of menus, and the row
   lets you see every filter you have set at once rather than opening each one to
   check. */

/* A button that has to sit in a row with the search box, so it takes the FIELD's
   metrics and type rather than a button's: same 40px height, same 14px at weight
   500, same border and radius. As a .btn it was two pixels shorter and a weight
   heavier, which made a row of three controls look like three different kits. */
.btn-tool {
  height: 40px; padding: 0 14px;
  gap: 8px;
  border: 1px solid var(--line-firm);
  border-radius: var(--r-md);
  background: var(--card);
  color: var(--ink);
  font-size: 14px; font-weight: 500;
}
.btn-tool svg { width: 15px; height: 15px; color: var(--ink); }
.btn-tool:hover { border-color: var(--line-firm); background: var(--card); }
.btn-tool:focus-visible {
  outline: none;
  border-color: var(--accent-soft);
  box-shadow: var(--accent-ring);
  background: var(--card);
}
.btn-tool:focus:not(:focus-visible) {
  outline: none;
  border-color: var(--line-firm);
  box-shadow: none;
  background: var(--card);
}
.btn-tool:active { background: var(--card); }
/* Toolbar labels are active controls, so their sort glyph uses the same ink as
   the label rather than the muted hint colour used by ordinary dropdowns. */
.toolbar .dropdown-trigger {
  color: var(--ink);
  font-size: 14px;
  font-weight: 500;
}
.toolbar .dropdown-trigger .dropdown-lead,
.toolbar .dropdown-trigger .dropdown-caret,
.toolbar .dropdown-trigger svg { color: var(--ink); }

/* A toolbar toggle that is currently on, wearing exactly what a focused field
   and an open dropdown wear — the soft accent edge and the same halo. It used to
   take the full accent border, which was a third selection style on one row. */
.btn-tool.is-on {
  border-color: var(--accent-soft);
  box-shadow: var(--accent-ring);
  background: var(--card);
  color: var(--ink);
}
.btn-tool.is-on:focus:not(:focus-visible) {
  border-color: var(--accent-soft);
  box-shadow: var(--accent-ring);
  background: var(--card);
}
.btn-tool.is-on:hover,
.btn-tool.is-on:focus,
.btn-tool.is-on:focus-visible,
.btn-tool.is-on:active { background: var(--card); }
.btn-tool.is-on svg { color: var(--ink); }
/* How many filters are set, so a collapsed row still says it is doing something.
   Hidden at zero rather than showing "0", which is a number nobody needs. */
.tool-count {
  display: inline-grid; place-items: center;
  min-width: 17px; height: 17px; padding: 0 4px;
  border-radius: 999px;
  background: var(--accent); color: #fff;
  font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums;
}

/* The second row. A hairline band under the toolbar rather than a floating
   panel: it belongs to the toolbar it came out of, and a shadowed card here
   would read as a dialog that failed to open properly. */
.filter-bar {
  flex: none;
  /* Tight to the toolbar above — it belongs to the Filters button that opened
     it. Nothing below: the count line that follows carries its own 14px, and
     two margins between the same pair of boxes is how a gap ends up twice the
     size it was drawn at. */
  margin-top: 12px; margin-bottom: 0;
  padding: 14px;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
}
/* auto-fit, so five filters share the width on a wide screen and wrap to two
   rows on a narrow one without a breakpoint of their own. */
.filter-grid {
  display: grid; gap: 12px 14px;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
/* Sentence-case labels at the app's own field size — deliberately not the tiny
   tracked-out capitals a filter bar usually gets, for the same reason the tables
   lost theirs. */
.filter-grid .field-label { color: var(--muted); }

/* Each filter fills its column, like the fields on any form — the toolbar's
   content-width triggers are for a row of controls sitting beside each other, and
   in a labelled grid a hugging control reads as a stray button in a column of
   matched ones. Same rule .auth-form applies, for the same reason. */
.filter-grid .dropdown { display: block; }
.filter-grid .dropdown-trigger { width: 100%; }
.filter-grid .dropdown-list { left: 0; right: auto; min-width: 100%; max-width: none; }

/* Month has 13 choices and is short enough to show as one complete list. */
.dropdown:has(#monthFilter) .dropdown-list { max-height: none; overflow: visible; }

/* One per row, stacked. Paginated rather than scrolled: with full-width rows a
   page holds few enough of them that a scrollbar inside a panel would be
   fighting the page for the same gesture. align-content:start keeps a short
   last page at the top instead of floating it in the leftover space. */
/* --rows is set from JS to match the page size, so the grid always lays out a
   FIXED number of equal row tracks whether or not the current page fills them.
   That's what makes a full page reach the pagination with no dead strip above
   it, while a half-full last page keeps its rows exactly the same height as
   every other page instead of stretching two cards to fill the whole panel. */
/* The cards and their pager, in one container — the same shape Enrichment's
   history tile has, so the pager sits on the same line on both pages and does
   not jump when you switch tabs. The grid grows, the pager takes its own
   height at the foot of it. */
.proj-panel {
  flex: 1; min-height: 0;
  display: flex; flex-direction: column;
}

.proj-grid {
  flex: 1; min-height: 0;
  /* clip, not hidden. The page size is measured so the rows always fit (see the
     Projects page script), which leaves nothing to scroll to — and a list that
     scrolls independently inside a page that doesn't is exactly the "why is
     this bit moving on its own" feeling the one-screen rule exists to avoid.
     `clip` says that without creating a scroll container, and unlike `hidden`
     it takes a margin: the last card's hover shadow was being sliced off flat
     at the grid's bottom edge, which read as the card being cut in half. */
  overflow: clip;
  overflow-clip-margin: 14px;
  display: grid; gap: 12px;
  grid-template-columns: minmax(0, 1fr);
  /* Every track is the SAME FIXED height, and that is the whole point.

     --row-h is computed once per layout from the page size — the space the grid
     got, divided by the number of rows a page holds — so it does not depend on
     how many cards the page being looked at happens to contain. A card is
     therefore exactly the same size on page 2 as on page 1.

     This replaced `minmax(min-content, 168px)`, which looked equivalent and was
     not: grid shares free space out between tracks up to their growth limits,
     and on a page holding two cards the two empty tracks took none of it, so
     the two real ones grew about 25px taller than they were on a full page.
     Tabbing between pages visibly resized every card. A fixed length has no
     free space to distribute and so cannot do that.

     Both bounds still exist, in the script: the height is clamped to ROW_MIN so
     a short viewport can't squeeze a card below its own content, and to ROW_MAX
     so one lonely project on a tall screen doesn't inflate into a 255px card
     with its text marooned in the middle.

     align-content:start collects the leftover below the last row, where it
     reads as margin. */
  grid-template-rows: repeat(var(--rows, 4), var(--row-h, 152px));
  align-content: start;
}

/* Numbered pages, centred under the list. Same three-state logic as a tab or a
   nav row — grey at rest, ink on hover, accent on the tinted pane when it's the
   page you're on — because "which of these am I looking at" is the same
   question in all three places. */
/* No rule above it. The row of cards already ends on a hard edge, so a line
   between them was separating two things that were never running together. */
.pager {
  flex: none;
  display: flex; justify-content: center; align-items: center; gap: 4px;
  margin-top: 16px;
}
/* Numerals only — no pane, no chip, no border. A page number is a small piece
   of text you either are on or aren't; wrapping it in a container made it look
   like a button that had been pressed and got stuck. Weight and colour carry
   the state instead. */
.pager-btn {
  min-width: 26px; padding: 4px 6px;
  border: 0; border-radius: var(--r-xs);
  background: none;
  color: rgba(20,20,26,.42);
  font-family: inherit; font-size: 14px; font-weight: 500;
  font-variant-numeric: tabular-nums;   /* so 9 → 10 doesn't shuffle the row */
  cursor: pointer;
  transition: color .15s var(--ease);
}
.pager-btn:hover { color: var(--ink); }
/* Ink, not accent: accent is the app's "act on this" colour, and a page you're
   already on isn't an action. Bold as well as dark — the row sits below the
   fold of the eye's path down the list, and at 14px against the grey the colour
   step alone was easy to skim past. */
.pager-btn[aria-current="page"] { color: var(--ink); font-weight: 700; }

/* Hairline tiles, not shadowed panels: they sit on a panel already, and a card
   inside a card is two elevations for one level of information.

   No hover lift here, unlike .card-link's -6px. A grid of tiles inside a panel
   is dense enough that a card jumping out of the row drags the eye off whatever
   you were reading. The accent border plus a shadow says "this one" just as
   clearly and nothing moves. */
/* justify-content:center because the row is now taller than its contents — the
   track height comes from the grid, not from the copy — so the text sits in the
   middle of the row rather than pinned to its top edge with a gap beneath. */
.proj-card {
  /* position + overflow are for .proj-media: the picture is absolutely
     positioned so it can bleed to the card's edges without the flex column
     having to make room for it, and overflow:hidden is what clips it to the
     card's own rounded corners rather than restating the radius on it. */
  position: relative; overflow: hidden;
  display: flex; flex-direction: column; justify-content: center; gap: 9px;
  padding: 18px;
  /* The text starts after the picture's feather has finished. Written as a
     calc off the same 38% the band uses, so the two can only move together. */
  padding-left: calc(38% + 20px);
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  background: var(--card);
  cursor: pointer;
  transition: box-shadow .2s var(--ease), border-color .2s var(--ease);
}

/* ---------- the card's picture ----------

   A band down the left edge of every card, bleeding to its top, bottom and left
   and feathering out before the text begins. Absolutely positioned, so adding a
   picture cannot change a card's height — which matters here more than usual,
   because Projects measures a row's height to decide how many fit on a page.

   The fade is an alpha MASK rather than a white gradient painted on top: a
   gradient to white only works while the card is white, and this app has themes
   and a canvas that has already changed once. A mask fades to whatever is
   actually behind it.

   Opacity only — the picture stays sharp the whole way across and simply loses
   itself into the card. A blur across the trailing edge was tried and dropped:
   it drew attention to the transition instead of hiding it, and softening a
   photograph of a room full of people reads as a mistake in the photograph.

   The ramp is long and eased rather than linear: eight stops over the last half
   of the band. A straight linear fade has a visible "knee" where it begins,
   because the eye picks up the sudden change in rate; easing it in and holding
   the tail almost to nothing is what makes it read as one continuous dissolve. */
.proj-media {
  position: absolute;
  top: 0; bottom: 0; left: 0;
  width: 38%;
  background: var(--bg-alt) center/cover no-repeat;
  /* Both spellings: Safari still wants the prefix for mask-image. */
  -webkit-mask-image: linear-gradient(90deg,
    #000 0, #000 48%, rgba(0,0,0,.95) 58%, rgba(0,0,0,.84) 66%,
    rgba(0,0,0,.64) 74%, rgba(0,0,0,.42) 82%, rgba(0,0,0,.22) 89%,
    rgba(0,0,0,.08) 95%, transparent 100%);
  mask-image: linear-gradient(90deg,
    #000 0, #000 48%, rgba(0,0,0,.95) 58%, rgba(0,0,0,.84) 66%,
    rgba(0,0,0,.64) 74%, rgba(0,0,0,.42) 82%, rgba(0,0,0,.22) 89%,
    rgba(0,0,0,.08) 95%, transparent 100%);
  pointer-events: none;
}

/* The empty slot. The brand gradient rather than a grey step — a project with
   no picture is the common case, not a failure, and a wall of grey rectangles
   made the list look broken. The gradient is the same one the primary button
   wears, so an empty card still reads as part of the product.

   Theme-owned, so a palette swap takes these with it.

   No glyph on it. A picture-placeholder icon over the gradient says "something
   is missing here", which was true of the grey box and is not true of this —
   the gradient is a finished-looking ground, not a gap. */
.proj-media-empty { background: var(--grad-btn); }
.proj-media-glyph { display: none; }

/* Below this width the band down the LEFT would leave the name a column three
   words wide — a project's name matters more than a picture of the room. So the
   picture turns through ninety degrees instead of going away: it runs across the
   top of the card and feathers downward into the text under it.

   Same idea as the desktop band, same eight-stop eased ramp, one axis different.
   The card keeps its picture on a phone, which is what the grid is for.

   --proj-band is the one place this height is written down. The Projects script
   reads it off :root to work out how many cards fit a page — the card is that
   much taller here, and a row height that did not know it would fit one card too
   many and overflow a page that is not allowed to scroll. */
@media (max-width: 700px) {
  /* 92px, and the number is load-bearing. A phone gives the grid ~483px once
     the topbar, the page header and the toolbar have taken theirs, so a card
     has to come in at or under 235px for TWO to fit a page — 138px of content
     leaves 97px for the picture. Taller and the page holds a single card, which
     is a worse way to browse a list than a slightly shorter photograph. */
  :root {
    --proj-band: 92px;
    /* The ceiling on a card, read by the Projects script.
       Much higher than the desktop 168 on purpose: that limit exists so a lonely
       project on a tall screen does not inflate into a card with its text
       marooned in the middle of it — but here the slack goes to the PICTURE,
       which cannot be marooned. Letting the row grow is what closes the last few
       pixels of gap above the pagination. */
    --proj-row-max: 320px;
  }

  .proj-card {
    /* No top padding: the picture is flush to the card's top edge. The sides
       stay padded for the text, and the picture bleeds back out through them
       with a negative margin below. */
    padding: 0 18px 18px;
    justify-content: flex-start;
  }
  .proj-media {
    display: block;
    /* BACK INTO THE FLOW, unlike the desktop band.
       Absolute positioning is right on desktop, where the picture runs down the
       side and must not affect the card's height. Here it is stacked above the
       text, and being out of flow meant it could only ever be a fixed height —
       so on a screen where the row came out taller than its content needed, the
       slack collected as a blank strip under the description instead of going
       to the picture. 69px of it at 700x900.

       As a flex item that grows, the picture takes whatever the text does not.
       There is no dead space left to trim because there is nothing else in the
       column that can take it. */
    position: relative;
    flex: 1 1 auto;
    width: auto; height: auto;
    /* The floor, and the number the Projects script adds to its row height —
       a card can never be shorter than the text plus this. */
    min-height: var(--proj-band);
    /* Out through the card's side padding, so the picture still bleeds to both
       edges while the text stays inset. */
    margin: 0 -18px;
    -webkit-mask-image: linear-gradient(180deg,
      #000 0, #000 48%, rgba(0,0,0,.95) 58%, rgba(0,0,0,.84) 66%,
      rgba(0,0,0,.64) 74%, rgba(0,0,0,.42) 82%, rgba(0,0,0,.22) 89%,
      rgba(0,0,0,.08) 95%, transparent 100%);
    mask-image: linear-gradient(180deg,
      #000 0, #000 48%, rgba(0,0,0,.95) 58%, rgba(0,0,0,.84) 66%,
      rgba(0,0,0,.64) 74%, rgba(0,0,0,.42) 82%, rgba(0,0,0,.22) 89%,
      rgba(0,0,0,.08) 95%, transparent 100%);
  }
  /* The glyph lives INSIDE .proj-media, which is now position:relative — so it
     is positioned against the picture rather than against the whole card, and a
     percentage is the right unit again. 24%, which is the middle of the solid
     part before the fade begins at 48%: the same reasoning as the desktop
     glyph, on the other axis. */
  .proj-media-glyph { top: 24%; left: 50%; }
}
/* Shadow only. The border stays exactly as it is — any colour change on hover
   made the card look selected, and selection is a state these cards don't
   have. A card you're pointing at should lift off the page slightly, nothing
   more. */
.proj-card:hover {
  box-shadow: 0 1px 2px rgba(20,20,26,.04), 0 8px 20px -10px rgba(20,20,26,.16);
}
/* align-items:flex-start so a two-line event name doesn't drag the badge down
   with it — the badge stays pinned to the first line. */
.proj-card-head { display: flex; align-items: flex-start; gap: 10px; }
.proj-name {
  flex: 1; min-width: 0;
  font-size: 16px; font-weight: 600; letter-spacing: -0.01em;
}
/* Amber ahead, green done, red called off. The icon is currentColor, so it
   follows the label and each state stays one rule.

   THE WEIGHT IS THEME-OWNED, the meaning is not. Each theme supplies its own
   --status-*-bg / --status-*-ink pair: purple-crown wears these tinted, because
   three saturated blocks per card against a violet-through-gold palette were the
   loudest thing on the page, and blue-bowtie wears them filled, because one flat
   blue accent leaves room for a saturated pill. What a state MEANS is not
   something the brand colour gets a vote on, so the hues themselves stay in
   tokens.css and every theme spends the same three.

   Upcoming is amber and not the accent, which it briefly was. The accent is the
   colour the product acts THROUGH — every primary button, every current state —
   so spending it on one of three statuses made the pill read as something you
   could press, and put brand colour on a row that was only reporting a fact.

   Both treatments clear 4.5:1. The tinted set does it with darkened inks on
   pale grounds; the filled set has a different ink per colour, because a bright
   yellow with white text lands at 1.53:1. */
.status {
  display: inline-flex; align-items: center; justify-content: center; gap: 5px;
  flex: none;
  padding: 4px 10px 4px 9px;
  border-radius: var(--r-sm);
  font-size: 11.5px; font-weight: 600; letter-spacing: .005em;
  white-space: nowrap;
}
.status svg { width: 12px; height: 12px; flex: none; }
.status-upcoming  { background: var(--status-upcoming-bg);  color: var(--status-upcoming-ink); }
.status-done      { background: var(--status-done-bg);      color: var(--status-done-ink); }
.status-cancelled { background: var(--status-cancelled-bg); color: var(--status-cancelled-ink); }
/* Stopped deliberately, part-way. Grey because nothing went wrong — red is for
   a run that broke, and colouring a decision like a fault reads as an alarm
   nobody needs to answer. */
.status-idle      { background: var(--bg-alt);              color: var(--muted); }

.proj-meta {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  font-size: 13px; color: var(--muted);
}
.proj-dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .5; }
/* Clamped to two lines so a long description can't make one card taller than
   its neighbours. */
.proj-desc {
  margin: 0; font-size: 14px; color: var(--muted);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden;
}

/* The first-run state. Centred, unlike .empty elsewhere — with nothing else on
   the page there is no left edge for it to line up with, and an empty page's
   one call to action belongs in the middle of it. */
/* Written as .empty.empty-center, not .empty-center: .empty is defined further
   down this file and sets align-items:flex-start, so a single-class modifier
   here loses to it on source order and the block silently stays left-aligned. */
.empty.empty-center {
  margin: auto;
  align-items: center; text-align: center;
  max-width: 46ch;
}
.empty.empty-center .empty-note { max-width: none; }

/* Looser than the inline .empty defaults (8px and 18px). Those were set for a
   block sitting in a corner of a busy page; alone in the middle of an empty one
   the same numbers read as cramped, because there's nothing nearby for the
   spacing to feel proportionate to.

   The 12/24 split matters more than the absolute values: the heading and its
   description are one thought and sit close, the button is a separate thing and
   sits twice as far away. Equal gaps would make all three read as one list. */
.empty.empty-center .empty-note { margin-top: 12px; }
.empty.empty-center .empty-actions { margin-top: 24px; }

/* ============================================================================
   Tabs
   ========================================================================= */

/* Sections within one page. Same three-state logic as a rail nav item — muted
   at rest, ink on hover, accent + tinted glass pane when selected — because a
   tab and a nav row are doing the same job at different scales, and giving them
   different treatments would read as two navigation systems on one screen.

   No underline indicator. The glass pane already marks the selection, and an
   underline would be a second, competing one.

   The tab strip lives INSIDE the page's panel, above a hairline. Tabs belong to
   the container they switch, so putting them outside it would make the panel
   look like it changes for reasons of its own. */
.tabs {
  display: flex; align-items: center; gap: 4px; flex: none;
  flex-wrap: wrap;
  padding-bottom: 14px; margin-bottom: 16px;
  border-bottom: 1px solid var(--line);
}
.tab {
  padding: 8px 13px;
  border: 0; border-radius: var(--r-chip);
  background: none;
  font-family: inherit; font-size: 14px; font-weight: 500; letter-spacing: -0.01em;
  color: var(--muted);
  white-space: nowrap; cursor: pointer;
  transition: color .2s var(--ease), background .2s var(--ease),
              box-shadow .2s var(--ease);
}
.tab:hover { color: var(--ink); }
/* aria-selected is the state, so style off it rather than a parallel class —
   one source of truth means the visual and the accessible state cannot drift. */
.tab[aria-selected="true"] {
  color: var(--accent-deep);
  font-weight: 600;
  background: var(--accent-pane);
  box-shadow: inset 0 1px 1px rgba(255,255,255,.95), var(--accent-lift);
}

/* The switched region. flex so a panel's skeleton can still stretch to the
   bottom of the container exactly as it does on every other page. */
.tab-panels { flex: 1; min-height: 0; display: flex; }
/* The display:flex here is exactly the kind of rule that would beat the
   browser's `[hidden]{display:none}` — see the global override near the top. */
.tab-panel { flex: 1; min-width: 0; min-height: 0; display: flex; }

/* ============================================================================
   Badges
   ========================================================================= */

.badge {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 3px 9px;
  border-radius: var(--r-sm);
  font-size: 11.5px; font-weight: 600; letter-spacing: .01em;
}
/* Same treatment, two names: -soon marks something that isn't built yet, -accent
   marks something that simply is (an admin, say). Aliased rather than copied so
   the pair can never drift apart. */
.badge-soon,
/* SOLID, like every other tag on the site: the status pills are --ok-solid,
   --danger-solid and --warn-solid with their on-solid ink, so an accent tag on a
   tint with accent text was the one badge that read as a different component.
   The deep step rather than the base accent, because this sits on white cards and
   next to blue text — and #fff for the ink the same way .tool-count and the
   selected calendar day already do it. */
.badge-accent { color: #fff; background: var(--accent-deep); }
/* Solid, like the status pills and the accent tag: the only thing wearing this is
   a connected integration, and a connected thing saying so in tinted green while
   Completed and Failed are solid was the same state drawn two ways. --ok-solid is
   the pair the pills use, ink included. */
.badge-ok     { color: var(--ok-on-solid); background: var(--ok-solid); }
.badge-warn   { color: var(--warn);   background: var(--warn-tint); }
.badge-danger { color: var(--danger); background: var(--danger-tint); }
/* "Nothing has happened here" — an off state, not a bad one. The other four all
   carry a judgement, and a not-connected integration is neither a warning nor a
   success; it's the resting state. Grey on the canvas step, so it recedes
   instead of competing with the Connect button beside it. */
.badge-neutral { color: var(--muted); background: var(--bg-alt); }

/* ---------- A badge that is also a control -------------------------------
   The roster's role badge, which an owner or admin can press to change the level
   it names. It has to keep looking like a badge — the roster is a list of states,
   and turning one row's state into something button-shaped would suggest the
   others are missing theirs — so this resets what <button> brings with it and
   then says "pressable" with the cursor and a hover lift rather than a border.

   The affordance is deliberately quiet. What makes it discoverable is the
   pointer and the hover, not a permanent outline: at 11.5px inside a 3px pad
   there is no room for a chevron that would not read as a glyph in the label. */
.badge-btn {
  border: 0; margin: 0; font: inherit; font-size: 11.5px; font-weight: 600;
  letter-spacing: .01em;
  cursor: pointer;
  /* Belongs to the badge's own transition vocabulary — same --ease as the tiles
     and rows, so a hovered badge and a hovered card move at one speed. */
  transition: filter .16s var(--ease), box-shadow .16s var(--ease);
}
/* filter rather than a second background per variant: -accent is solid accent and
   -neutral is the canvas step, and one brightness step reads correctly on both
   without this rule having to know which it got. */
.badge-btn:hover { filter: brightness(.94); }
.badge-btn:active { filter: brightness(.89); }
/* The standard field ring, so a badge focused by Tab is ringed the same way a
   focused input is. :focus-visible, not :focus — a mouse press should not leave
   a ring sitting on the roster. */
.badge-btn:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}
.badge-btn:disabled { cursor: default; filter: none; opacity: .6; }

/* Role badges are labels or compact controls, never selectable copy. Without
   this, the non-editable Owner <span> inherits the text I-beam while the other
   roles are buttons, making identical-looking badges feel inconsistent. */
.ws-role-badge { user-select: none; }
.ws-role-badge:not(.badge-btn) { cursor: default; }

/* A badge can carry a small flat line icon. currentColor, so it inherits the
   semantic colour rather than needing a variant per state. 12px because the
   label is 11.5px — a glyph taller than its own text reads as a button. */
.badge svg {
  width: 12px; height: 12px; flex: none;
  /* -1px optical: a round glyph next to uppercase-height text sits low by
     about a pixel on this font. */
  margin-top: -1px;
}

/* ============================================================================
   Icon chips + empty states
   ========================================================================= */

/* Tier-3 glass squircle holding a 3D display icon. The squircle is a percentage
   so it scales with the box. Never put a flat line icon in here. */
.icon-chip {
  position: relative;        /* anchors the ::before sheen */
  display: grid; place-items: center; flex: none;
  width: 44px; height: 44px;
  border-radius: var(--r-squircle);
  background: var(--glass-icon-fill);
  border: 1px solid var(--line);
  box-shadow: var(--glass-icon-shadow);
  overflow: visible;         /* so the icon's contact shadow isn't clipped */
}
/* Top-left specular catch. */
.icon-chip::before {
  content: ""; position: absolute; inset: 0; border-radius: inherit;
  background: linear-gradient(150deg, rgba(255,255,255,.7) 0%, rgba(255,255,255,.12) 34%, transparent 55%);
  pointer-events: none;
}
.icon-chip .ic3d { width: 30px; height: 30px; overflow: visible; }
.icon-chip-lg { width: 64px; height: 64px; }
.icon-chip-lg .ic3d { width: 44px; height: 44px; }

/* Left-aligned, not centred. Everything on a page now shares one left edge:
   the title, the sub-heading, and the contents of every panel
   below them. Centring inside the panel put the copy on its own private axis
   and broke that line, which is the thing that made the pages read as loose.
   The panel supplies the padding; the block just fills its width. */
.empty {
  display: flex; flex-direction: column; align-items: flex-start;
  text-align: left;
  width: 100%;
  padding: 0;
}
.empty .icon-chip { margin-bottom: 14px; }
.empty-title { font-size: 21px; }
.empty-note { font-size: 14.5px; color: var(--muted); margin-top: 8px; max-width: 56ch; }
.empty-actions { display: flex; align-items: center; gap: 12px; margin-top: 18px; flex-wrap: wrap; }
.empty .badge { margin-bottom: 16px; }

/* A short list of what a coming-soon page will do, so the placeholder still
   says something. Hairline rows — the one place a line beats a shadow, because
   a stack of glass panes at this density reads as clutter. */
.plan-list { margin-top: 18px; text-align: left; width: 100%; max-width: 560px; }
.plan-item {
  display: flex; align-items: center; gap: 11px;
  padding: 8px 0;
  font-size: 14px;
  border-bottom: 1px solid rgba(20,20,26,.07);
}
.plan-item:last-child { border-bottom: 0; }
.plan-dot {
  flex: none; width: 6px; height: 6px; border-radius: 50%;
  background: var(--accent); opacity: .55;
}


/* ============================================================================
   Tiles and setting rows
   ========================================================================= */

/* The Account tab's own column. A .tab-panel is a flex row of one, so its
   child has to be the column — that is what lets the log-out button's
   margin-top:auto find a bottom to sit on. */
.acct-panel { flex: 1; min-width: 0; display: flex; flex-direction: column; }

/* Account's two-up: the photo needs only its own width, the form wants the
   rest. */
.acct-grid {
  display: grid; gap: 14px;
  grid-template-columns: auto minmax(0, 1fr);
}

/* Square, and as tall as the form beside it. The tile stretches to the row
   height like any grid item; aspect-ratio then derives its WIDTH from that
   height, which is why its column is `auto` rather than a fixed 230px — the
   track has to take whatever width the ratio asks for. Set a fixed column and
   the ratio silently loses. */
.acct-photo {
  aspect-ratio: 1;
  align-items: center; justify-content: center;
}
/* Workspace's mark tile is this same tile, unmodified: same square, same 88px
   disc, same button pair, same column. The two headers are meant to be the same
   header. */

/* Field pairs. An odd number of fields leaves the last cell empty rather than
   stretching something across it: a lone full-width control under two half
   ones reads as a different kind of field. */
.field-grid {
  display: grid; gap: 15px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

/* The generic hairline container for content sitting ON a panel. A shadowed
   card inside a shadowed panel is two elevations for one level of information. */
.tile {
  display: flex; flex-direction: column;
  min-width: 0;
  padding: 18px;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
}
.tile-title { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; }
/* A second fact on a heading's own line — a filename after "Guest list". Grey and
   at body weight, so it reads as an answer to the heading rather than as part of
   it. */
.tile-sub {
  margin-left: 7px;
  font-size: 13px; font-weight: 400; letter-spacing: 0;
  color: var(--muted);
}
.tile-note { margin-top: 4px; font-size: 13.5px; color: var(--muted); }

/* A list of "here is a thing, here is the control for it" rows. Hairlines
   rather than nested boxes — at this density a stack of bordered cards reads
   as clutter, which is the same call .plan-list made. */
.setting-rows { display: flex; flex-direction: column; }
.setting-row {
  display: flex; align-items: center; gap: 16px;
  padding: 15px 0;
  border-top: 1px solid var(--line);
}
.setting-row:first-child { border-top: 0; padding-top: 4px; }
.setting-row:last-child { padding-bottom: 4px; }
.setting-row-main { flex: 1; min-width: 0; }
.setting-row-title { font-size: 14.5px; font-weight: 600; letter-spacing: -0.005em; }
.setting-row-note { margin-top: 3px; font-size: 13px; color: var(--muted); }

/* ============================================================================
   Integrations
   ========================================================================= */

/* Settings' Integrations tab: one card per service.

   A .tab-panel is a flex ROW of one, so this list is the column inside it —
   same arrangement as .acct-panel. The cards do NOT grow: this is real, finite
   content, so the stack anchors to the top and the surplus height falls below
   it. Stretching three cards to fill the panel would just put a hole in the
   middle of each one. See "One screen, no scrolling" in DESIGN.md.

   A card, not a .setting-row, because each service has two levels of
   information — what it is and what state it's in, then what it does for you
   and the control — and a single row would put five things on one line. */
/* The Integrations tab's column, so the workspace switcher can sit ABOVE the
   list rather than beside it. .tab-panel is a flex row holding one child; the
   other three tabs already had a column wrapper of their own (.ws-panel,
   .plan-panel, .bill-panel) and this tab did not, because until the switcher
   arrived it had nothing to stack. */
.int-panel { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }

.int-list {
  flex: 1; min-width: 0; min-height: 0;
  display: flex; flex-direction: column;
  gap: var(--gap-sm);
  /* The list is the one thing on this page that grows without limit as more
     services are supported, so it takes the escape hatch DESIGN.md names for
     exactly this: its own scroll container, rather than letting the page clip.
     Nothing scrolls until it has to. */
  overflow-y: auto;
}

/* Same hairline container as .tile — a shadowed card inside a shadowed panel is
   two elevations for one level of information. One box, no internal rule: the
   head and the note are two parts of one statement about one service, and a
   divider between them draws a boundary where there isn't one. The 12px gap
   does that job on its own. */
.int-card {
  display: flex; flex-direction: column; gap: 12px;
  padding: 16px;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  background: var(--card);
}

.int-head { display: flex; align-items: center; gap: 12px; }
/* Same size as a .setting-row-title: this is the same kind of label, one level
   down. */
.int-name {
  flex: 1; min-width: 0;
  margin: 0;
  font-size: 14.5px; font-weight: 600; letter-spacing: -0.005em;
}

.int-body { display: flex; align-items: center; gap: 16px; }
.int-note { flex: 1; min-width: 0; margin: 0; font-size: 13.5px; color: var(--muted); }
/* The action never shrinks — "Connect Partiful" wrapping to two lines would
   make one card taller than its neighbours. */
.int-body .btn { flex: none; }

/* The third-party mark's box.

   Deliberately NOT an .icon-chip: that is tier-3 glass holding OUR 3D sprite,
   and a borrowed logo sitting in it reads as one of ours. A flat white square
   with a hairline is the neutral frame — each brand keeps its own plate inside
   it without the app claiming the mark.

   The hairline is what a light logo needs. Partiful and Posh bring their own
   near-black plates and would read as chips regardless; Luma's is white, and
   without the border its chip would dissolve into the card behind it.

   The marks in /assets/integrations/ are the brands' own, cleaned to sit at one
   optical weight: Luma's shipped with a drop shadow on a white plate, which is
   a grey smudge at this size, so the shadow is gone and the star is scaled to
   the same 74% of the box that the two P's occupy. */
.int-logo {
  display: grid; place-items: center; flex: none;
  width: 34px; height: 34px;
  border-radius: var(--r-chip);
  border: 1px solid var(--line);
  background: #FFFFFF;
  overflow: hidden;
}
.int-logo img, .int-logo svg { width: 100%; height: 100%; display: block; }

/* Mobile: the note and its button stop sharing a line — at ~380px the two
   together leave the sentence a column three words wide. */
@media (max-width: 560px) {
  .int-body { flex-direction: column; align-items: flex-start; gap: 12px; }
  .int-body .btn { width: 100%; justify-content: center; }
}

/* ============================================================================
   Plan tab
   ========================================================================= */

.plan-panel { flex: 1; min-width: 0; display: flex; flex-direction: column; }

/* Three across, equal width. No "most popular" card blown up a size: the tier
   you are on is the one this page marks out, and a second emphasis competing
   with it turns the page into an ad. */
.tier-grid {
  display: grid; gap: 14px;
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.tier { display: flex; flex-direction: column; }

/* A tier this workspace cannot move to — Personal, on anything that is not the
   workspace your account came with. Shown rather than removed: three tiers are
   what this product sells, and a page that renders two would misrepresent the
   offer to somebody comparing it.
   
   The dimming is on the card's CONTENTS rather than the card, so it does not
   compound with the .5 that .btn:disabled already applies and leave the button
   too faint to read. not-allowed on the whole card so the answer is the same
   wherever the pointer lands, not only over the button. */
.tier.is-unavailable { background: var(--bg-alt); cursor: not-allowed; }
.tier.is-unavailable .tier-head,
.tier.is-unavailable .tier-price,
.tier.is-unavailable .tier-tokens,
.tier.is-unavailable .tier-list { opacity: .5; }
.tier-head { display: flex; align-items: center; gap: 10px; }
.tier-name { flex: 1; min-width: 0; font-size: 15px; font-weight: 600; letter-spacing: -0.01em; }

/* The price is the one number on the card worth reading first, so it takes the
   step up in size; the period beside it stays body-sized and grey, because
   "per month" is a unit rather than a value. */
.tier-price { display: flex; align-items: baseline; gap: 6px; margin-top: 12px; }
.tier-amount { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; }
.tier-per { font-size: 13px; color: var(--muted); }

/* What the tier includes in tokens, said once and in ink: it is the line people
   actually compare, so it sits above the feature list rather than inside it.
   The coin stack is the same glyph a token purchase carries on the Billing tab.
   In ink, not the accent: it sits on the line it belongs to rather than joining
   the column of accent ticks underneath, which is what keeps the allowance
   reading as the card's one comparable fact. */
.tier-tokens {
  display: flex; align-items: center; gap: 8px;
  margin-top: 10px; padding-top: 12px;
  border-top: 1px solid var(--line);
  font-size: 14px; font-weight: 600;
}
.tier-coins {
  width: 16px; height: 16px; flex: none;
  color: var(--ink);
}

.tier-list { margin: 10px 0 0; padding: 0; list-style: none; }
.tier-item {
  display: flex; align-items: flex-start; gap: 8px;
  padding: 4px 0;
  font-size: 13.5px; color: var(--muted);
}
/* Accent, and small. A column of full-strength ticks reads as a checklist you
   are meant to work through rather than a list of what you get. */
.tier-item svg {
  width: 13px; height: 13px; flex: none; margin-top: 3px;
  color: var(--accent); opacity: .8;
}

/* margin-top:auto pins the action to the bottom, so three cards with different
   numbers of features still line their buttons up. */
.tier-action { margin-top: auto; padding-top: 16px; }
.tier-action .btn { width: 100%; justify-content: center; }

/* The tier you are on. An accent hairline plus the same soft ring a focused
   field takes — enough to lift the card out of the row of three without
   filling it with colour, which would make the plan you already have the
   loudest thing you are being sold. */
.tier.is-current {
  border-color: var(--accent);
  box-shadow: var(--accent-ring);
}

/* ---------- tokens ---------- */

.token-tile {
  margin-top: 14px;
  display: flex;
  flex-direction: column;
  gap: 15px;
}
.token-head { display: flex; align-items: flex-start; gap: 16px; }
.token-heading { flex: 1; min-width: 0; }
.token-head .btn { flex: none; }

.token-main {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* A bar, not a control — so it takes the full-round end that the "nothing is a
   capsule" rule denies to buttons and chips. The radius is half the height and
   the browser clamps it there anyway. */
.meter {
  height: 10px;
  border-radius: 5px;
  background: var(--bg-alt);
  overflow: hidden;
}
.meter-fill {
  display: block; height: 100%;
  border-radius: inherit;
  background: var(--grad-btn);
  transition: width .3s var(--ease);
}
/* The balance is one complete sentence: the actionable total first, then the
   two sources that make it up. Keeping it to one line makes the bar's meaning
   immediately legible without turning the card into two competing balances. */
.token-balance { font-size: 13.5px; line-height: 20px; color: var(--muted); }
.token-balance strong { color: var(--ink); font-weight: 600; }

.usage-history { flex: 1; min-height: 0; margin-top: 14px; }
.usage-list {
  margin-top: 14px;
  flex: 1; min-height: 0;
  display: flex; flex-direction: column;
  overflow-y: auto;
}
.usage-row {
  display: flex; align-items: center; gap: 12px;
  padding: 10px 0;
  border-top: 1px solid var(--line);
}
.usage-row:first-child { border-top: 0; padding-top: 0; }
.usage-row-main { flex: 1; min-width: 0; }
.usage-row-label { font-size: 14px; font-weight: 600; letter-spacing: -0.005em; }
.usage-row-date { margin-top: 2px; font-size: 12.5px; color: var(--muted); }
/* The coin stack and the figure, right-aligned as a pair — the mark says what the
   number is, so the number needs no unit beside it. Same arrangement as the rail's
   workspace menu and the Plan tab's allowances.

   min-width keeps the column steady from a 2-token row to a 5-figure one, and
   tabular figures keep the digits over each other down the list. */
.usage-row-amount {
  flex: none;
  display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px;
  min-width: 72px;
}
.usage-row-n {
  font-size: 14px; font-weight: 600; color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.usage-coin { width: 14px; height: 14px; flex: none; stroke-width: 2.4; }
.usage-empty { margin-top: 16px; font-size: 13.5px; color: var(--muted); }

@media (max-width: 900px) {
  .tier-grid { grid-template-columns: minmax(0, 1fr); }
}

/* ============================================================================
   Hint bubble
   ========================================================================= */

/* A question mark that explains a word the reader cannot guess the meaning of —
   "eligible", say. Hover OR keyboard focus, because a hover-only explanation is
   no explanation for anyone using a keyboard, and role=tooltip plus a labelled
   button is what makes it announced rather than skipped.

   Kept for the cases where a line of copy would be worse: this answers a question
   nobody has until they see a number smaller than the file they dropped. It is not
   a place to put things that should have been in the interface. */
.hint { position: relative; display: inline-flex; }
.hint-btn {
  display: grid; place-items: center;
  width: 16px; height: 16px; padding: 0;
  border: 1px solid var(--line); border-radius: 50%;
  background: var(--card);
  color: var(--muted);
  font-family: inherit; font-size: 10.5px; font-weight: 700; line-height: 1;
  cursor: help;
  transition: color .15s var(--ease), border-color .15s var(--ease);
}
.hint-btn:hover { color: var(--ink); border-color: var(--accent-soft); }

/* Opens upward and to the left of its own right edge: these sit at the right end
   of a tile header, where a bubble growing rightward would leave the panel. */
.hint-bubble {
  position: absolute; z-index: 70;
  bottom: calc(100% + 8px); right: -6px;
  width: max-content; max-width: 260px;
  padding: 9px 11px;
  border-radius: var(--r-sm);
  background: rgba(20,20,26,.94);
  color: #FFFFFF;
  font-size: 12.5px; font-weight: 400; line-height: 1.45;
  text-align: left; white-space: normal;
  box-shadow: 0 2px 6px rgba(20,20,26,.2), 0 14px 30px -12px rgba(20,20,26,.4);
  opacity: 0; visibility: hidden;
  transition: opacity .15s var(--ease), visibility .15s var(--ease);
  pointer-events: none;
}
.hint-btn:hover + .hint-bubble,
.hint-btn:focus-visible + .hint-bubble { opacity: 1; visibility: visible; }

/* ============================================================================
   Switch
   ========================================================================= */

/* An on/off control for a setting that takes effect immediately — no Save, no
   confirm. A <button role="switch">, not a checkbox: a checkbox is an answer to
   a form you submit, and this is a lever you throw.

   The one capsule in the system, and deliberately so. "Nothing is a capsule"
   keeps buttons and chips from reading as pills; a switch's shape IS its
   meaning, and squaring it off makes it read as a small empty field. */
.switch {
  position: relative; flex: none;
  width: 42px; height: 24px; padding: 0;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--bg-alt);
  cursor: pointer;
  transition: background .2s var(--ease), border-color .2s var(--ease);
}
/* The knob. A shadow rather than a border, so it reads as sitting ON the track
   rather than being cut out of it. */
.switch::after {
  content: "";
  position: absolute; top: 2px; left: 2px;
  width: 18px; height: 18px;
  border-radius: 50%;
  background: #FFFFFF;
  box-shadow: 0 1px 2px rgba(20,20,26,.25);
  transition: transform .2s var(--ease);
}
/* On, and wearing the primary button's glass: the same --grad-btn ramp, the same
   1px white inner edge, and the same --shadow-cta so it sits in light rather than
   reading as a flat coloured capsule beside buttons that don't. Hover matches
   too — brightness plus the deeper shadow, no lift, for the reason spelled out
   on .btn-primary:hover.

   It does NOT take the orbiting beam. That is the CTA's way of drawing an eye to
   the one thing on a page it wants pressed, and a settings toggle competing with
   Save for that attention would be the tail wagging the dog. */
.switch[aria-checked="true"] {
  background: var(--grad-btn);
  border-color: rgba(255,255,255,.28);
  box-shadow: var(--shadow-cta);
}
.switch[aria-checked="true"]:hover {
  filter: brightness(1.05);
  box-shadow: var(--shadow-cta-hover);
}
/* The knob gains a hairline once it is sitting on the accent, so a white circle
   on a light-blue ramp keeps an edge. */
.switch[aria-checked="true"]::after {
  transform: translateX(18px);
  box-shadow: 0 1px 2px rgba(20,20,26,.3), 0 0 0 .5px rgba(20,20,26,.06);
}
.switch:disabled { opacity: .5; cursor: not-allowed; }

/* ============================================================================
   Billing tab
   ========================================================================= */

.bill-panel { flex: 1; min-width: 0; display: flex; flex-direction: column; }

/* The cycle and the card, side by side: two short facts that answer "what am I
   about to be charged" and "what is it coming out of". */
.bill-grid {
  display: grid; gap: 14px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

/* The amount and its date, in the same relationship the Plan tab's price has
   with its period: the number is what you came for, the rest is a unit. */
.bill-amount {
  display: flex; align-items: baseline; gap: 7px;
  margin-top: 14px;
}
.bill-figure { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; }
.bill-when { font-size: 13px; color: var(--muted); }

/* The auto-pay row sits on a hairline directly under the amount, NOT pinned to
   the bottom of the tile. It is content rather than an action: pinned, the
   surplus height of a row set by a taller neighbour opens up between the figure
   and the rule, and a hairline floating in white space reads as a mistake. The
   buttons in the tiles beside it still pin, because a button at the bottom edge
   is where a button belongs. */
.bill-toggle {
  display: flex; align-items: center; gap: 16px;
  margin-top: 16px; padding-top: 16px;
  border-top: 1px solid var(--line);
}
.bill-toggle-main { flex: 1; min-width: 0; }

/* The card itself. The chip is a flat line glyph, never a brand mark: shipping
   a Visa or Mastercard logo means shipping someone else's trademark and keeping
   it current. The brand name in text says the same thing and cannot go stale. */
.bill-card { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
.bill-card-chip {
  display: grid; place-items: center; flex: none;
  width: 42px; height: 30px;
  border: 1px solid var(--line); border-radius: var(--r-xs);
  color: var(--muted);
}
.bill-card-chip svg { width: 20px; height: 20px; }
.bill-card-main { flex: 1; min-width: 0; }
.bill-card-name { font-size: 14.5px; font-weight: 600; letter-spacing: -0.005em; }
.bill-card-exp { margin-top: 2px; font-size: 13px; color: var(--muted); }
/* A tile's actions, pinned to its bottom-RIGHT corner. Two declarations do the
   pinning and they are independent: margin-top:auto pushes the row to the floor
   (.tile is already a flex column), and justify-content:flex-end pushes it to
   the trailing edge. Together the buttons sit in the corner however tall the
   row's other tile makes this one, rather than floating under content that
   ended early.
   
   Trailing rather than leading, which is where the modals put their actions too
   — a control you press when you are FINISHED reading belongs at the end of the
   reading, not back at the start of it. flex-end rather than a margin on the
   first child, so a wrapped second line stays aligned with the first.
   
   Named for the tile rather than the card since the billing details tile uses it
   too: its Update details button was in the heading row, which put the only
   action on this screen that opens a form in a different place from every other
   tile's action. */
.bill-tile-action {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  justify-content: flex-end;
  margin-top: auto; padding-top: 16px;
}

/* ---------- billing details ---------- */

.bill-details { margin-top: 14px; }

/* Label above value, in three columns across the tile.
   
   This has been both ways round, and which is right depends on the tile's width.
   While it was half width, sharing a row with another tile, equal columns read as
   crowded — the email nearly filled its track and ran up to within a few pixels
   of the next label, while the short name left a wide moat, so the gutters looked
   uneven even though the arithmetic was regular. Shrinking each column to its
   content fixed that.
   
   Full width, shrink-to-content is the wrong answer for the opposite reason: the
   three values huddle in the left third and leave two thirds of the tile empty,
   with the button stranded diagonally opposite them. What reads as clean at this
   width is a regular rhythm of column STARTS — which is what the eye actually
   tracks in a row of labelled values — so the columns share the space equally
   and the last one lands above the action in the corner. There is no crowding to
   recreate here, because at this width no column is ever close to full.
   
   A <dl> rather than a table: three labelled values are a description list, and
   a table would draw rules between things that are one block of information. */
.bill-dl {
  display: flex; flex-wrap: wrap;
  gap: 16px 40px;
  margin: 14px 0 0;
}
/* Equal shares of the row, and min-width:0 so a long address wraps inside its own
   column rather than refusing to shrink — flex items default to min-width:auto,
   which is the setting that would let it push the row wider than the tile. */
.bill-dl > div { flex: 1 1 0; min-width: 0; }
.bill-dt { font-size: 12.5px; font-weight: 600; color: var(--muted); }
.bill-dd {
  margin: 3px 0 0;
  font-size: 14px;
  /* An address keeps its line breaks; everything else is one line. */
  white-space: pre-line;
  /* An address with no spaces in it, or a very long email, breaks rather than
     escaping the tile. */
  overflow-wrap: anywhere;
}
.bill-dd-empty { color: var(--muted); }

/* The dialog's form. Two columns for the short fields, and the long ones span
   both — the same idea as .field-grid, which can't be reused here because it
   has no way to say "this one takes the whole row". */
.bill-form {
  display: grid; gap: 15px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  margin-top: 20px;
}
.bill-span { grid-column: 1 / -1; }

/* ---------- purchases ---------- */

.bill-history { flex: 1; min-height: 0; margin-top: 14px; }
.bill-list {
  margin-top: 14px;
  flex: 1; min-height: 0;
  display: flex; flex-direction: column;
  overflow-y: auto;
}

/* Hairlines, like every other list in Settings. The amount is tabular so the
   column of figures lines up on the decimal without a table. */
.bill-row {
  display: flex; align-items: center; gap: 12px;
  padding: 10px 0;
  border-top: 1px solid var(--line);
}
.bill-row:first-child { border-top: 0; padding-top: 0; }
.bill-row-main { flex: 1; min-width: 0; }
.bill-row-label { font-size: 14px; font-weight: 600; letter-spacing: -0.005em; }
.bill-row-date { margin-top: 2px; font-size: 12.5px; color: var(--muted); }
/* Right-aligned in a fixed column, so the figures line up on their decimal AND
   the badges beside them line up too — with the amount sized by its own content,
   a $49.00 row pushed its badge 30px right of the $299.00 row above it. */
.bill-row-amount {
  flex: none;
  min-width: 78px;
  text-align: right;
  font-size: 14px; font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* Same square as the integration marks, holding a flat glyph that says which
   kind of purchase this was — a renewal or a top-up. */
.bill-kind {
  display: grid; place-items: center; flex: none;
  width: 32px; height: 32px;
  border: 1px solid var(--line); border-radius: var(--r-chip);
  color: var(--muted);
}
.bill-kind .kind-glyph { width: 16px; height: 16px; }

/* Both tiles hold a fact and then an action pinned to the bottom, so they need
   to be columns for margin-top:auto to have a bottom to find. */
.bill-tile { display: flex; flex-direction: column; }

.bill-empty { margin-top: 16px; font-size: 13.5px; color: var(--muted); }

@media (max-width: 900px) {
  .bill-grid { grid-template-columns: minmax(0, 1fr); }
}

/* ============================================================================
   Enrich
   ========================================================================= */

/* min-height:0 as well as flex:1 — without it this column refuses to shrink below
   its content, so the history spilled past the panel's bottom edge instead of
   scrolling inside itself. Same reason .tab-panel carries it in Settings. */
.enr-panel {
  flex: 1; min-width: 0; min-height: 0;
  display: flex; flex-direction: column;
}

/* One third to starting a run, two thirds to the log of previous ones. Written as
   `flex: 1` against `flex: 2` on a zero basis so the ratio holds at any height
   rather than being two guessed pixel values. */
.enr-new {
  flex: 1 1 0; min-height: 0;
  display: flex; flex-direction: column;
}

/* ---------- the dropzone ----------

   A label, so the click target is native. Dashed, because a dashed edge is the
   one border treatment in this app that says "not a thing yet, put something
   here" — everything real is a hairline.

   It takes whatever the heading leaves, and centres its contents, so the target
   is the whole top third of the panel rather than a strip you have to aim at. */
.drop {
  /* The floor is the height of what is inside it — glyph, title, note and the
     padding around them — not an arbitrary 96px. It used to be the smaller
     number, which was invisible on a desktop panel where flex hands this box a
     third of the screen, and wrong on a phone where flex hands it exactly the
     floor: the note then spilled straight through the dashed edge into the row
     below, because the panel is one screen with no scroll to absorb it. */
  flex: 1; min-height: 140px;
  display: grid; place-items: center;
  margin-top: 14px; padding: 20px;
  border: 1.5px dashed var(--line);
  border-radius: var(--r-tile);
  background: var(--card);
  text-align: center;
  cursor: pointer;
  transition: border-color .15s var(--ease), background .15s var(--ease);
}
.drop:hover { border-color: var(--accent-soft); background: var(--accent-tint); }
/* While a file is over it. Same treatment as hover but committed — the accent at
   full strength, so there is no doubt the drop will land. */
.drop.is-over { border-color: var(--accent); background: var(--accent-tint); }

.drop-inner { display: flex; flex-direction: column; align-items: center; }

/* The file that was read, centred in the dropzone's own footprint so the
   container does not change size when one replaces the other — and centred the
   same way, so the name lands where the invitation was. No glyph: the filename is
   the thing to read, and an icon beside it in a box this size is decoration.
   Hairline rather than dashed, because this is a real thing now. */
.drop-file {
  /* Matches .drop exactly, because the two swap places in the same footprint —
     a box that changed size when a file landed in it would move the row beneath. */
  flex: 1; min-height: 140px;
  display: grid; place-items: center;
  margin-top: 14px; padding: 20px;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  text-align: center;
}
.drop-file-inner {
  display: flex; flex-direction: column; align-items: center;
  max-width: 100%;
}
.drop-file-name {
  max-width: 100%;
  font-size: 15px; font-weight: 600; letter-spacing: -0.005em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.drop-file-meta { margin-top: 3px; font-size: 13px; color: var(--muted); }
/* The action and the way out, on one line under the name. Primary first because
   it is what you came to do; Remove is text-only, so undoing is available without
   competing with it. */
.drop-file-actions {
  display: flex; align-items: center; gap: 12px;
  margin-top: 16px;
}
/* The same square every other glyph on this page sits in: a hairline box with no
   fill — see .enr-kind on the history rows and the folder in .enr-chip. It was a
   filled grey tile, which made the one icon above the fold the only container in
   the app of its kind. A step larger than the row glyphs, because this one is the
   page's primary target, but the treatment is identical. */
.drop-glyph {
  display: grid; place-items: center;
  width: 40px; height: 40px; margin-bottom: 12px;
  border: 1px solid var(--line);
  border-radius: var(--r-chip);
  color: var(--muted);
}
.drop-icon { width: 18px; height: 18px; }
.drop-title { font-size: 15px; font-weight: 600; letter-spacing: -0.005em; }
.drop-note { margin-top: 3px; font-size: 13px; color: var(--muted); }

/* ---------- pulling a list off a platform ----------

   One line under the dropzone: a label, the four platform marks, and the way in.
   Wraps rather than scrolling — at every width this panel has, it fits. */
.src-row {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  margin-top: 12px;
}
.src-label { font-size: 12.5px; color: var(--muted); }
.src-logos { display: flex; align-items: center; gap: 6px; }
/* Both the strip's marks and the dialog's tiles are wrapped, because a disabled
   button dispatches no events: the TOOLTIP that says why it is grey has to live
   on something around it.

   The cursor does not — it is resolved from whatever is painted under the
   pointer, disabled or not, so the button's own `pointer` wins over anything the
   slot sets and the refusal has to be stated on the button itself. Both are set
   here: the slot covers the sliver of padding around the button, the button
   covers the button. */
.src-mark-slot, .pull-tile-slot { display: inline-flex; }
.src-mark-slot.is-off, .pull-tile-slot.is-off { cursor: not-allowed; }
.src-mark:disabled, .pull-tile:disabled { cursor: not-allowed; }
/* The logo in a hairline box — the same square every other glyph on this page
   sits in (see .enr-kind and .drop-glyph). Without the outline four small logos
   floating on the panel read as printed decoration rather than as things to
   press. */
.src-mark {
  display: grid; place-items: center;
  padding: 5px;
  border: 1px solid var(--line); border-radius: var(--r-chip);
  background: var(--card);
  cursor: pointer;
  transition: border-color .15s var(--ease), background .15s var(--ease);
}
/* The same accent wash the dropzone takes on hover, because it is the same
   offer: put a guest list in. Only on the ones you can actually pull from. */
.src-mark:not(:disabled):hover { border-color: var(--accent-soft); background: var(--accent-tint); }
.src-mark:focus-visible {
  outline: none; border-color: var(--accent-soft); box-shadow: var(--accent-ring);
}
/* Clipped to a soft corner, like .int-logo on the Integrations cards. Two of the
   four platform marks are artwork on a solid dark square — the files are 96px of
   black out to their own edges — and unclipped they put a hard 90° corner inside a
   rounded cell, the one place in the app where a square edge sits inside a curve.
   The clip is the whole treatment; nothing is baked into the artwork.

   --r-xs (5px) on this 22px box, inside a cell rounded to --r-chip (6px). Slightly
   rounder in proportion than the Integrations cards, which are 6px on 34px — a
   corner needs a little more of the box to read as a corner at all once the box is
   only 22px across, and matching the ratio exactly (4px here) reads square. */
.src-logo {
  display: grid; place-items: center; flex: none;
  width: 22px; height: 22px;
  border-radius: var(--r-xs);
  overflow: hidden;
}
.src-logo img { width: 100%; height: 100%; object-fit: contain; }
/* Not connected: the colour comes out, and that is the whole treatment. No cross
   over the logo — a greyed mark that will not take a press has already said it,
   and four crosses in a row was more marks than meaning. The words are in the
   tooltip and the accessible name for anyone who needs them. */
.src-mark:disabled .src-logo img,
.pull-tile:disabled .src-logo img { opacity: .4; filter: grayscale(1); }

/* ---------- the pull dialog ----------

   Four tiles rather than a dropdown, because a platform's STATE is half the
   answer and a menu row cannot carry it. A radiogroup: one of them is the
   answer, and the arrow keys move between them. */
.pull-platforms { display: flex; gap: 8px; flex-wrap: wrap; }
.pull-tile {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 7px 12px 7px 9px;
  border: 1px solid var(--line); border-radius: var(--r-chip);
  background: var(--card);
  font-family: inherit; font-size: 13px; font-weight: 600; color: var(--ink);
  cursor: pointer;
  transition: border-color .15s var(--ease), background .15s var(--ease),
              box-shadow .15s var(--ease), color .15s var(--ease);
}
.pull-tile:not(:disabled):hover { border-color: var(--accent-soft); background: var(--accent-tint); }
.pull-tile:focus-visible {
  outline: none; border-color: var(--accent-soft); box-shadow: var(--accent-ring);
}
.pull-tile[aria-checked="true"] {
  border-color: var(--accent); background: var(--accent-tint); color: var(--accent);
}
.pull-tile:disabled { color: var(--muted); }
/* Selected but not connected. Only reachable when NOTHING is connected — the
   dialog has to open on something — and then the note under the dropdown is what
   explains it. Marked as the one being looked at without the accent, which would
   say it is ready to use. */
.pull-tile:disabled[aria-checked="true"] {
  border-color: var(--muted); background: var(--card); color: var(--muted);
}
.pull-tile-name { white-space: nowrap; }
/* The line under the dropdown: what the chosen list is, or why there is nothing
   to choose. Same muted note the settings rows use, and it holds a link when the
   answer is "connect the account first". */
.pull-hint-link { color: var(--accent); text-decoration: none; font-weight: 600; }
.pull-hint-link:hover { text-decoration: underline; }

/* What the run costs: the coin stack and a figure, no unit word. Same mark the
   rail's workspace menu and the Plan tab's allowances carry, which is what lets
   the number stand on its own. */
.enr-budget {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: 14px; font-weight: 600; color: var(--ink);
}
.enr-budget-coins { width: 15px; height: 15px; flex: none; }
/* Not enough tokens for the list that was dropped. Danger rather than warn: the
   run cannot start, which is a wall and not a caution. */
.enr-budget.is-short { color: var(--danger); }

/* ---------- history ---------- */

.enr-history { flex: 2 1 0; min-height: 0; margin-top: 14px; }
/* A flex row, because the eligible count carries a ? bubble beside it and the two
   were sitting flush against each other. */
.enr-count {
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 13px; color: var(--muted);
}
/* Paged, not scrolled — same as Projects, and for the same reason: the page is
   sized to what fits, so the list always ends on a flat edge. overflow:hidden
   rather than auto, because a list that scrolls inside a page that doesn't is
   the "why is this bit moving on its own" feeling the one-screen rule avoids. */
.enr-list {
  margin-top: 14px;
  flex: 1; min-height: 0;
  display: flex; flex-direction: column; gap: 8px;
  /* Same as .proj-grid: clipped, but with room for the last card's hover
     shadow to finish rather than being cut off square. */
  overflow: clip;
  overflow-clip-margin: 14px;
}

/* Each run is its own container now, like a project card, rather than a row
   separated by a hairline. A hairline says "another line of the same list"; a
   card says "a thing you can open", which is what these are. */
.enr-row {
  display: flex; align-items: center; gap: 12px;
  flex: none;
  padding: 11px 13px;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  background: var(--card);
  transition: box-shadow .2s var(--ease), border-color .2s var(--ease);
}
/* The same lift Projects' cards use on hover, at the same depth, so a card that
   can be opened feels the same wherever it is in the app. */
.enr-row.is-clickable { cursor: pointer; }
.enr-row.is-clickable:hover {
  box-shadow: 0 1px 2px rgba(20,20,26,.04), 0 8px 20px -10px rgba(20,20,26,.16);
}
.enr-row.is-clickable:active { box-shadow: 0 1px 2px rgba(20,20,26,.06); }
.enr-row.is-clickable:focus-visible {
  outline: none; border-color: var(--accent-soft); box-shadow: var(--accent-ring);
}
.enr-row-main { flex: 1; min-width: 0; }
.enr-row-name {
  font-size: 14px; font-weight: 600; letter-spacing: -0.005em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.enr-row-meta {
  margin-top: 2px;
  display: flex; align-items: center; gap: 5px; flex-wrap: wrap;
  font-size: 12.5px; color: var(--muted);
}
/* A fixed column for the pill, so the three labels — Completed, Processing,
   Failed — don't shuffle the chip beside them from row to row. */
.enr-row-state { flex: none; display: flex; justify-content: flex-end; min-width: 104px; }

/* Same square as the billing rows' kind glyph, so a log row reads the same way
   on both pages. */
.enr-kind {
  display: grid; place-items: center; flex: none;
  width: 32px; height: 32px;
  border: 1px solid var(--line); border-radius: var(--r-chip);
  color: var(--muted);
}
.enr-kind-glyph { width: 16px; height: 16px; }

/* The project a run belongs to. Its own chip, so the column can be scanned:
   "what has this event had done to it" is the question the history answers, and
   a filename cannot answer it. */
.enr-chip {
  display: inline-flex; align-items: center; gap: 6px; flex: none;
  max-width: 220px;
  padding: 4px 9px;
  border: 1px solid var(--line); border-radius: var(--r-sm);
  font-size: 12px; color: var(--muted);
}
.enr-chip > span {
  min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.enr-chip-glyph { width: 13px; height: 13px; flex: none; }
/* No project. A dashed edge for the same reason the dropzone has one: this is an
   absence rather than a value. */
.enr-chip-none { border-style: dashed; }

/* ---------- who ran it ----------

   One colleague, small: a 22px disc and their name. Drawn by Shell.personChip in
   three places — the Enrich history, a project's guest lists, and the run's own
   screen — so this is the only description of what that looks like.

   Grey and regular-weight by default. In a list row it is context, not the
   headline: the run's name is the thing being scanned, and a bold name beside it
   would compete with it for the same glance. `.is-strong` is the run screen,
   where the sentence IS about the person and the name is its subject — ink and
   semibold, so the colleague is what you read out of "Started by: … · … ". */
.person-chip {
  display: inline-flex; align-items: center; gap: 7px; flex: none;
  min-width: 0;
  font-size: 12.5px; color: var(--muted);
}
.person-chip-name {
  min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.person-chip.is-strong { color: var(--ink); }
.person-chip.is-strong .person-chip-name { font-weight: 600; }

/* The disc is the positioning context for the photo, and holds the initials
   underneath it. Both children are stacked rather than swapped, so a photo that
   fails to load reveals letters that were already drawn instead of leaving a
   hole — see the note in personChip. */
.person-chip-disc {
  position: relative; flex: none;
  width: 22px; height: 22px; border-radius: 50%;
  display: grid; place-items: center;
  overflow: hidden;
  font-size: 9px; font-weight: 600; letter-spacing: .01em;
  color: var(--muted); background: var(--bg-alt);
}
.person-chip-photo {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  /* cover, not fill: a photo that is not square must be cropped to the circle
     rather than squashed into it. */
  object-fit: cover; object-position: center;
  display: block;
}

/* In the row, the starter sits to the LEFT of the project chip and the status
   pill — outside .enr-row-main, so it keeps its own width and the run's name
   goes on taking whatever is left. Capped, because a long name must not push the
   pill off the end of a narrow row. */
.enr-row-who { max-width: 190px; }

/* Hidden on a narrow row before the pill or the project is: it is the one thing
   in the row that is also visible one click deeper. */
@media (max-width: 720px) {
  .enr-row-who { display: none; }
}

/* ---------- the setup screen ----------

   Where a dropped file lands: what the run is on the left, the file itself on the
   right, and the one irreversible button at the bottom. Two columns rather than a
   stack because the form and the data answer each other — you name the list and
   pick the points while looking at the rows they will be run against. */
.enr-setup {
  flex: 1; min-width: 0; min-height: 0;
  display: flex; flex-direction: column;
}
.setup-grid {
  flex: 1; min-height: 0;
  display: grid; gap: 14px;
  grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
}
/* A flex column, so the context box at the bottom can take whatever the fields
   above it leave and the tile ends on a flat edge. Still scrolls if the viewport
   is too short to hold the fields at their minimums. */
.setup-form {
  min-height: 0; overflow-y: auto;
  display: flex; flex-direction: column;
  /* Traded for the ::after spacer below — see why. */
  padding-bottom: 0;
}
/* The tile's bottom padding, as a real element rather than as padding.
   A scroll container's padding-bottom is not part of its scrollable overflow in
   Chrome, so once this column was tall enough to scroll — a 1280x720 window is
   already enough — the 18px vanished and the context box ended flush against
   the tile's bottom border. A flex item cannot be dropped that way.
   flex:none so it keeps its height instead of being squeezed out by the context
   box above it, which is the one thing in this column that grows. */
.setup-form::after {
  content: '';
  display: block;
  flex: none;
  height: 18px;
}
/* The last block in the column, and the one that stretches. Its own flex column
   so the textarea inside it is what actually grows. */
/* flex: 1 0 auto, and NOT min-height: 0.
   It had both, which let this block shrink below its own content once the
   column was too short to hold it — the textarea then spilled out of the bottom
   of its own parent and ran to the tile's edge, past the padding meant to be
   under it. Refusing to shrink is what makes the form scroll instead of
   overflowing, which is the behaviour the tile's overflow-y was already
   written for. */
.setup-context {
  flex: 1 0 auto;
  display: flex; flex-direction: column;
}
/* The group is what fills the column; the textarea inside it is what stretches. */
.setup-context-field { flex: 1; min-height: 0; }
.setup-context .field-area {
  flex: 1;
  min-height: 96px;
  /* No resize handle: this box's height is the column's to decide, and a corner
     grabber on a field that already fills its container is a control that can
     only make the layout worse. */
  resize: none;
}
.setup-data { min-height: 0; display: flex; flex-direction: column; }

/* ============================================================================
   Enrichment results
   ========================================================================= */

.enr-results {
  flex: 1; min-width: 0; min-height: 0;
  display: flex; flex-direction: column; gap: 14px;
}

/* A dialog with a form in it rather than a sentence — Run details is four
   fields, and the default 420px turns every one of them into two lines. */
.modal-wide { max-width: 560px; }

/* No resize grabber. The field is disabled, so dragging it larger reveals more
   of a box nobody can type in — a control whose only effect is on the layout. */
.dt-context { resize: none; }

/* The three counts under the bar. A row of numbers rather than a sentence:
   "how much is left for me to do" is a quantity, and three quantities side by
   side compare at a glance in a way three clauses never do. */
.enr-summary {
  display: flex; align-items: center; gap: 26px; flex-wrap: wrap;
  margin-top: 14px; padding-top: 14px;
  border-top: 1px solid var(--line);
}
.enr-stat { display: flex; align-items: baseline; gap: 7px; }
/* Back to a plain dot. The icons were correct and were too much — four solid
   pills in the table below plus three more up here made a page that is mostly
   colour. The colour still carries the meaning; it just stops shouting. */
.enr-stat-dot {
  align-self: center;
  width: 7px; height: 7px; border-radius: 50%; flex: none;
}
/* The SOLID pair, not the text pair. --ok / --warn / --danger are darkened
   until they read as type against a pale ground; a 7px dot borrowing them came
   out muddy beside the vivid pills in the table. These are the exact fills the
   pills use, so the dot and the label it counts are the same colour. */
.enr-stat-dot.is-verified    { background: var(--status-done-bg); }
.enr-stat-dot.is-unconfirmed { background: var(--status-upcoming-bg); }
.enr-stat-dot.is-missing     { background: var(--status-cancelled-bg); }
/* The fourth. Missing entirely until now, so the manual count sat beside a
   blank space where its colour should have been — the accent, matching the
   pill in the table, because a person made that call rather than the search. */
.enr-stat-dot.is-manual      { background: var(--accent); }

/* The cost, pushed to the far end — a different kind of fact from the three
   counts, so it reads as a footnote to them rather than as a fourth one. */
.enr-stat-cost { margin-left: auto; }
/* Ink, not muted, and drawn heavier — the coin belongs to the FIGURE beside it
   rather than to the label after it, so it carries the number's weight. Grey at
   1.8 it read as part of the caption and the cost lost its subject. */
.enr-stat-coin { align-self: center; display: inline-flex; color: var(--ink); }
.enr-coin { width: 14px; height: 14px; flex: none; stroke-width: 2.4; }
.enr-stat-n {
  font-size: 16px; font-weight: 600; letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
}
.enr-stat-label { font-size: 13px; color: var(--muted); }

/* Run details at one end, the way out at the other. */
.enr-foot { flex: none; }

/* "Started by <person> · <when>", at the bottom-left of the run, on the same line
   as the buttons that change it. It replaces the plain spacer that used to hold
   that end of the bar open: the attribution IS the left half of this row now, and
   flex:1 on it is what still pushes the buttons to the right edge.

   min-width:0 so the name truncates inside it rather than widening the bar and
   shoving the buttons off the screen on a narrow window. */
.enr-foot-who {
  flex: 1; min-width: 0;
  display: flex; align-items: center; gap: 7px; flex-wrap: wrap;
  font-size: 12.5px; color: var(--muted);
}
.enr-foot-who[hidden] { display: none; }
/* The label and the timestamp stay grey and regular; only the person is
   emphasised, which is what .is-strong on the chip does. */
.enr-foot-who-label { flex: none; }
.enr-foot-who .person-chip { max-width: 260px; }

/* The progress block. Sits above the table rather than over it: the table is
   the thing being filled, and covering it with an overlay would hide the very
   evidence that the run is working. */
.enr-progress {
  flex: none;
  padding: 16px 18px;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
}
.enr-progress-head { display: flex; align-items: center; gap: 13px; }
.enr-progress-text { flex: 1; min-width: 0; }
/* Finished: the bar goes green and the spinner is replaced by a tick. */
.enr-progress.is-done .progress-fill { background: var(--status-done-ink, #1c8c4a); }

/* ---- spinner ----
   A rotating arc, drawn with a border and one transparent side. Deliberately
   not an SVG or a GIF: this animates on the compositor, so it keeps turning at
   full rate while the main thread parses a poll response. Anything driven by
   script or by frame-by-frame decoding visibly stutters exactly when the app is
   busiest, which is the one moment it is being watched. */
.spinner {
  flex: none;
  width: 20px; height: 20px;
  border: 2.5px solid var(--line-firm);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* Swapped in when the run finishes, so the block reads as an outcome rather
   than as something still going. */
.spinner-done {
  flex: none;
  width: 20px; height: 20px;
  display: grid; place-items: center;
  border-radius: 50%;
  background: var(--status-done-bg); color: var(--status-done-ink);
}
.spinner-done svg { width: 12px; height: 12px; }
/* Stopped on purpose: grey and a dash. Nothing succeeded and nothing broke. */
.spinner-done.is-stopped { background: var(--bg-alt); color: var(--muted); }
/* Broke: red and a cross. */
.spinner-done.is-failed  { background: var(--status-cancelled-bg); color: var(--status-cancelled-ink); }

/* ---- progress bar ---- */
.progress {
  margin-top: 12px;
  height: 6px; border-radius: 999px;
  background: var(--bg-alt);
  overflow: hidden;
}
.progress-fill {
  display: block; height: 100%; width: 0;
  border-radius: inherit;
  background: var(--accent);
  /* Eased and slow-ish: the bar moves in jumps as batches land, and an instant
     jump reads as a glitch rather than as progress. */
  transition: width .45s var(--ease), background-color .3s var(--ease);
}

/* The disclosure and the detail panel are OFF here, and switched on in the
   900px block. The table has room for its columns on a desktop, so there is
   nothing to disclose — every value is already on the row. */
.res-toggle { display: none; }
.res-detail { display: none; }
/* The row's copy of the verdict. Mobile only — desktop has a Result column, and
   showing both would be the same answer twice on one screen. */
.res-row-mark { display: none; }
.res-name { display: flex; align-items: center; gap: 8px; }
.res-name-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }

/* ---- the table ---- */
.enr-results-tile { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.enr-results-wrap { flex: 1; min-height: 0; margin-top: 12px; }
/* Fixed, for the same reason the guest-list preview is: an email or a long
   handle must clip rather than make the table scroll sideways. */
.enr-tbl { table-layout: fixed; width: 100%; }
/* .enr-results, not .enr-tbl: the base `.tbl .tbl-rownum { width: 1% }` lives
   further down this file at the same specificity, so an equally-specific rule
   here loses on order and the column came out 11px wide with its digits sliced
   in half. One more class settles it regardless of where either sits. */
.enr-results .tbl .tbl-rownum { width: 52px; }
/* Name, Email, Segment and Instagram have no individual width: fixed table
   layout divides the room left after the two compact edge columns equally.
   This gives every value column the same rhythm regardless of its contents. */
.enr-results .enr-tbl th:nth-child(6),
.enr-results .enr-tbl td:nth-child(6) { width: 92px; }

/* A row still waiting. The name is there, the answer is not — greyed so the
   eye skips it and lands on the rows that have arrived. */
.enr-tbl tr.is-waiting td { color: var(--muted); }
/* Freshly arrived. A brief wash of accent, once — enough to catch the eye of
   someone watching the table fill, gone before it becomes decoration. */
@keyframes row-in {
  from { background: var(--accent-pane-soft); }
  to   { background: transparent; }
}
.enr-tbl tr.is-new td { animation: row-in 1.1s var(--ease); }

.enr-handle {
  color: var(--accent); text-decoration: none; font-weight: 500;
}
.enr-handle:hover { text-decoration: underline; }
.enr-segment { font-weight: 500; color: var(--ink); }
.enr-dash { color: var(--muted); }

/* The button that opens the picker. Looks like the link it replaces so the
   column stays one column, rather than a mix of text and controls. */
.enr-pick {
  padding: 0; border: 0; background: none;
  font: inherit; color: var(--accent); font-weight: 500;
  text-decoration: underline; text-underline-offset: 2px;
  cursor: pointer;
}

/* ---- the picker ---- */
.pick-list { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; max-height: 42vh; overflow-y: auto; }
/* A <label> wrapping a real radio, so the whole card is the target and the
   keyboard behaviour is the browser's rather than ours. */
.pick-item {
  display: flex; align-items: flex-start; gap: 11px;
  width: 100%; text-align: left;
  padding: 11px 13px;
  border: 1px solid var(--line); border-radius: var(--r-tile);
  background: var(--card); color: var(--ink);
  font: inherit; cursor: pointer;
  transition: border-color .15s var(--ease), background .15s var(--ease);
}
.pick-item:hover { background: var(--bg-alt); }
.pick-item:has(.pick-radio:checked) { border-color: var(--accent-soft); background: var(--accent-pane-soft); }
/* Focus lands on the radio, but the ring belongs on the card it selects. */
.pick-item:has(.pick-radio:focus-visible) {
  border-color: var(--accent-soft); box-shadow: var(--accent-ring);
}
.pick-radio { margin-top: 3px; flex: none; accent-color: var(--accent); width: 15px; height: 15px; }
.pick-body { flex: 1; min-width: 0; }
.pick-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pick-handle {
  font-weight: 600; font-size: 14px;
  color: var(--accent); text-decoration: none;
}
.pick-handle:hover { text-decoration: underline; }
.pick-why { margin-top: 4px; font-size: 13px; color: var(--muted); }

/* The result, as a mark rather than a sentence.
   Nineteen rows of "Verified" / "Unconfirmed" / "Not found" is a wall of type
   repeating four words; the colour and the glyph carry it instead, and the word
   itself is one hover away (and is the accessible name, so a screen reader
   still hears it). Solid fills, matching the counts above the table. */
.res-dot {
  display: inline-grid; place-items: center;
  width: 26px; height: 26px;
  /* The same corner every other label in the app wears — a circle read as a
     different family of thing from the pills these replace. Square keeps them
     recognisably the icon-only version of a badge. */
  border-radius: var(--r-sm);
  flex: none;
}
.res-dot svg { width: 13px; height: 13px; }
.res-dot.is-verified    { background: var(--status-done-bg);      color: var(--status-done-ink); }
.res-dot.is-unconfirmed { background: var(--status-upcoming-bg);  color: var(--status-upcoming-ink); }
.res-dot.is-missing     { background: var(--status-cancelled-bg); color: var(--status-cancelled-ink); }
/* A choice a PERSON made — not good news or bad, so the accent rather than one
   of the three verdict colours. */
.res-dot.is-manual      { background: var(--accent);              color: var(--on-accent); }
/* No verdict at all: never asked, or still being asked. */
.res-dot.is-idle        { background: var(--bg-alt);              color: var(--muted); }

/* The mark IS the control — the verdict and the way to change it are one idea,
   so they are one target. */
.res-dot-btn {
  border: 0; padding: 0; font-family: inherit; cursor: pointer;
  transition: filter .15s var(--ease), box-shadow .15s var(--ease);
}
/* No growing on hover. At 24px a scale is a lurch rather than a highlight, and
   nineteen of them in a column made the table twitch under the pointer. The
   cursor already says it is clickable. */
.res-dot-btn:hover { filter: brightness(1.08); }
.res-dot-btn:focus-visible { outline: none; box-shadow: var(--accent-ring); }


/* The guest-list preview clips; it never scrolls sideways.
   A CSV's own columns decide how wide this table wants to be, and one long
   address — sebastian.giordano18@gmail.com — pushed it past the tile and put a
   horizontal scrollbar under a five-row preview. Reading half an address and
   dragging for the rest is worse than reading most of it and seeing an ellipsis
   that says there is more.

   table-layout:fixed is what makes it possible: without it a table is sized by
   its content and no amount of overflow rule on the cells will shrink it. With
   it the columns divide the width available, and .tbl td's existing
   nowrap/ellipsis does the rest. */
.setup-data .tbl { table-layout: fixed; width: 100%; }
/* The row number is ours and always two or three digits — a fixed width, since
   fixed layout would otherwise hand it an equal share like any other column. */
.setup-data .tbl .tbl-rownum { width: 46px; }
.setup-data .table-wrap { overflow-x: hidden; }

/* A heading inside a tile that already has one. Smaller than .tile-title and
   spaced away from what precedes it, so the form reads as three questions rather
   than one long column of controls. */
.setup-sub {
  margin: 22px 0 0;
  font-size: 13.5px; font-weight: 600; letter-spacing: -0.005em;
}
/* Two across rather than .choice-list's single column. Six stacked rows pushed
   the event context box below the fold of a scrolling column, which for an
   optional field nobody has been told about is the same as not shipping it.

   Written as .choice-list.setup-points because .choice-list is declared later in
   this file, and at equal specificity its `display: flex` would win — which it
   did, silently, until the box was measured. */
.choice-list.setup-points {
  display: grid; gap: 8px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  margin-top: 12px;
}
.setup-form .tile-note { margin-top: 4px; }
.setup-form .field-area { margin-top: 12px; }

/* The action bar. The button that spends tokens is the last thing on the page and
   the only accent on it; Cancel is a ghost, and the cost sits between them so it
   is read on the way to the button rather than after. */
.setup-foot {
  flex: none;
  display: flex; align-items: center; justify-content: flex-end; gap: 14px;
  margin-top: 14px;
}
/* The cost is part of the group at the end of the bar rather than pushed to one
   side of it: flex:1 on this used to stretch it across the empty middle, which is
   what left the figure adrift from the buttons it belongs to. */
.setup-foot .enr-budget { flex: none; margin-right: 4px; }

/* ---------- the guest list table ----------

   The app's first real data table. A <table>, not a grid of divs: a screen reader
   announces the column header with each cell, which is the whole point of tabular
   data, and no amount of ARIA on divs does it as reliably.

   Scrolls in both directions inside its tile — a guest list is wider than the
   column and longer than the screen, and neither is a reason for the page to
   move. */
.table-wrap {
  flex: 1; min-height: 0;
  margin-top: 14px;
  overflow: auto;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
}
.tbl {
  width: 100%;
  border-collapse: separate; border-spacing: 0;
  font-size: 13px;
}
/* Sticky, so the column names stay while the rows go past. WHITE, not the canvas
   step: a grey band across the top of every table was the heaviest thing on the
   page and made a table look like a screenshot of a spreadsheet. The single
   hairline under it does the same job — it separates the names from the rows, and
   that is all it was ever for. Opaque, because a translucent header over
   scrolling text is unreadable.

   Sentence case at 12.5px rather than 11px uppercase with letter-spacing. Tiny
   tracked-out capitals are the default look of a table nobody designed; a column
   is named, and a name reads better as a word. */
.tbl thead th {
  position: sticky; top: 0; z-index: 1;
  padding: 11px 12px;
  background: var(--card);
  border-bottom: 1px solid var(--line-firm);
  font-size: 12.5px; font-weight: 600; letter-spacing: -0.005em;
  color: var(--muted);
  text-align: left; white-space: nowrap;
}
.tbl td {
  padding: 10px 12px;
  border-bottom: 1px solid var(--line);
  /* Cells hold one line each and truncate: a table where one long note doubles
     the height of its row stops being scannable. */
  max-width: 260px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tbl tbody tr:last-child td { border-bottom: 0; }
.tbl tbody tr:hover td { background: var(--bg-alt); }
/* The row number is ours rather than the file's, so it is greyed and as narrow as
   its digits. */
.tbl .tbl-rownum {
  width: 1%;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  text-align: center;
}
.table-note { margin: 10px 0 0; font-size: 12.5px; color: var(--muted); }
/* Numeric columns centre, header and cells together, and share one width per
   digit. Ragged right-alignment under a left-aligned label is the arrangement
   that reads as unconsidered: the eye follows the header down and finds the
   figures somewhere else. Centring both puts them on one axis. */
.tbl .tbl-num { text-align: center; font-variant-numeric: tabular-nums; }
/* A text column that centres with its header — the segment, which is a label per
   row rather than a sentence, and reads as a column of its own when the header
   sits over the middle of it rather than off to one side. */
.tbl .tbl-mid { text-align: center; }

/* ============================================================================
   Network
   ========================================================================= */

/* The directory fills what the toolbar and the count leave, and scrolls inside
   itself. min-height:0 as ever, or the table refuses to shrink and pushes the
   panel past the bottom of the screen instead. */
.net-wrap { flex: 1; min-height: 0; margin-top: 0; }

/* The creator cell is the one place a row breaks the table's one-line rule: a
   person is a face, a name, a tier and a handle, and stacking the last two is
   what keeps the column narrow enough for the numbers to fit beside it. */
.net-cell { padding-top: 8px; padding-bottom: 8px; }
.net-who { display: flex; align-items: center; gap: 11px; }
/* Same neutral disc as the workspace roster's — initials until there are real
   avatars to load. */
.net-avatar {
  flex: none; width: 32px; height: 32px; border-radius: 50%;
  display: grid; place-items: center;
  font-size: 11.5px; font-weight: 600;
  color: var(--muted); background: var(--bg-alt);
}
.net-id { min-width: 0; }
.net-name-row { display: flex; align-items: center; gap: 7px; }
.net-name {
  min-width: 0;
  font-size: 14px; font-weight: 600; letter-spacing: -0.005em; color: var(--ink);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* A link to the profile, so it stays muted at rest — it sits under 72 names and
   colouring every one of them turns the column into a wall of blue. It reads as
   a link on hover, which is when it matters. */
.net-handle {
  display: block; margin-top: 1px; width: fit-content; max-width: 100%;
  font-size: 12.5px; color: var(--muted); text-decoration: none;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
a.net-handle:hover, a.net-handle:focus-visible {
  color: var(--accent); text-decoration: underline;
}
/* The handle and the mark that can follow it, on one line. A flex row because
   .net-handle is a block with its own ellipsis: as siblings the mark would drop
   to the next line, and inside the link it would be part of what ellipsises. */
.net-handle-row { display: flex; align-items: flex-start; min-width: 0; }

/* A handle the research was not sure about. Marked rather than hidden: it is the
   best answer there is and worth showing, but a medium-confidence guess set in
   the same type as a confirmed match is how the wrong Instagram ends up in a
   client's deck.

   The same '?' the app uses wherever a value needs a footnote (see .hint-btn),
   at footnote scale rather than as a 16px circle — a ringed button beside a
   12.5px handle reads as a control, and this is an annotation. cursor:help is
   what says the answer is on hover. */
.net-flag {
  flex: none;
  margin-left: 3px;
  font-size: 10px; font-weight: 700; line-height: 1;
  color: var(--muted);
  cursor: help;
}
.net-flag:hover { color: var(--ink); }
/* No handle at all. Same slot and same muting as one, so a column of them reads
   as "not known" rather than as a broken link. */
.net-handle.is-unknown { color: var(--muted); }
/* The tier, set small and uppercase. A classification wants to read as a stamp
   rather than as a word in the sentence beside it — and uppercasing it is what
   keeps "Macro" from being mistaken for part of the name.

   Three neutral weights that sort by size — ink, grey, canvas — so a column of
   them can be scanned without reading. See the tier tokens for why they are
   neutral rather than semantic. */
.badge-tier {
  padding: 2px 7px;
  font-size: 10px; letter-spacing: .06em; text-transform: uppercase;
}
.badge-macro { background: var(--tier-macro-bg); color: var(--tier-macro-ink); }
.badge-micro { background: var(--tier-micro-bg); color: var(--tier-micro-ink); }
.badge-nano  { background: var(--tier-nano-bg);  color: var(--tier-nano-ink); }

/* The figure the table is sorted by out of the box, so it carries the weight the
   others don't. */
.net-emv { font-weight: 600; color: var(--ink); }

/* Headers and cells share one width per column. Creator takes what is left, which
   is what stops six numeric columns from squeezing the names. */
/* FIXED, like .enr-tbl — the app's other real table. With auto layout every
   column was sized by whatever happened to be in it, so a workspace whose
   segments are all unknown drew a "Segment" header over a column of dashes barely
   wider than the word, and the same table looked different in another workspace.
   Fixed means the grid is the grid: the header sits over its own column, and the
   figures line up down the page whatever the rows say.

   The creator keeps a third — it holds two lines of type, everything else holds
   one value — and the four facts split what is left evenly. Even quarters rather
   than a width per column: they are four facts of equal standing, and tuning each
   one to its longest possible value ("1,681,104" against "Nano") is how a table
   ends up looking hand-placed. */
.net-tbl { table-layout: fixed; }
.net-tbl th:first-child, .net-tbl td:first-child { width: 34%; }
.net-tbl th:not(:first-child), .net-tbl td:not(:first-child) { width: 16.5%; }

/* ---- The Best Guests bowtie ----------------------------------------------

   The three highest lifetime EMV in a workspace's directory wear the brand mark
   across their avatar. Drawn as a MASK over a currentColor fill rather than as
   an <img>, which is how .crown-mark uses the same file: the shape comes from
   the PNG, the colour comes from CSS, so it stays legible on a dark disc and
   costs no second asset.

   TWO LAYERS, because the tie is not one thing. The BOW sits on top of the
   avatar and is allowed to overhang it — that is what makes it read as pinned
   on rather than printed into the picture. The BAND does not: a strap floating
   in the air on either side of a head looks like a mistake, so it is clipped to
   the disc and reads as passing behind it.

   Which is why the disc is wrapped in .pfp. One layer has to be clipped by the
   circle and the other must not, and a single element cannot be both — the
   clipped half is a child of the disc (which hides its overflow), the free half
   is a child of the wrapper (which does not).

   Decoration in both cases; the accolade reaches a screen reader as text in the
   row and in the dialog. */
.pfp { position: relative; flex: none; display: block; line-height: 0; }

/* Where the knot sits, as a fraction of the disc. ONE anchor for both pieces:
   they are two halves of a single object, and nudging the bow without the band
   pulls them apart — so this pair moves the whole mark and nothing else has to
   be touched. Up and left of the disc's centre. */
.pfp.is-best { --tie-x: 33%; --tie-y: 29.7%; }

/* TWO PIECES, and only one of them is the picture.

   The BAND is a plain bar. Measured off the asset — its strap is a rectangle
   22.6% of the tie's height, centred, uniform to both ends — so drawing it
   rather than masking it costs nothing and buys the one thing the image cannot
   give: length. The file stops where the artboard stops, which left the strap
   ending in mid-air inside the disc. This runs well past the rim in both
   directions and lets the circle do the cutting, so it reads as passing behind
   the head rather than as a sticker that came up short.

   Placed on the tie's axis, not the disc's: left/top are the centre of the
   strap in the tie, which sits up and left of the disc's own centre. */
.pfp.is-best .net-avatar::after,
.pfp.is-best .person-disc::after {
  content: '';
  position: absolute;
  left: var(--tie-x); top: var(--tie-y);
  width: 170%; height: 6.25%;
  transform: translate(-50%, -50%) rotate(-40deg);
  background: var(--ink);
  pointer-events: none;
}

/* The BOW is the picture, and it is the half that may cross the rim — which is
   why it hangs off the wrapper rather than the disc. clip-path trims the file
   down to the middle of the tie: the ink is 22% tall out to 19% and then jumps
   to the full height of a wing, so the cut lands exactly at the wing tips and
   the band underneath carries straight through it.

   inset() rather than a second mask layer composited with `intersect`: that
   worked, and ruled two faint lines the length of the tie across the avatar
   where the layer's own box edges were.

   2.82:1 is the file's own ratio — anything else squashes the knot. */
.pfp.is-best::after {
  content: '';
  position: absolute;
  left: var(--tie-x); top: var(--tie-y);
  width: 78%; aspect-ratio: 2.82;
  /* rotate() resolves first and turns the box about its own centre, so the
     translate that follows lands that centre on the anchor — the same point the
     band is hung from, which is what keeps the two collinear. */
  transform: translate(-50%, -50%) rotate(-40deg);
  background: var(--ink);
  -webkit-mask: url("/assets/bowtie.png") center/contain no-repeat;
          mask: url("/assets/bowtie.png") center/contain no-repeat;
  clip-path: inset(0 19%);
  pointer-events: none;
}

/* clip-path rather than overflow:hidden. Both discs are already round-cornered
   boxes that hide their overflow, and that clipped the band to the SQUARE —
   the corners are exactly where it kept escaping. clip-path states the circle
   outright instead of inferring it from a radius.

   position matters too: an absolutely-positioned child is clipped by its
   CONTAINING BLOCK, so without this the band resolves against .pfp and the
   disc never gets a say. */
.pfp.is-best .net-avatar,
.pfp.is-best .person-disc {
  position: relative;
  clip-path: circle(50%);
}

/* The accolade sits over the identity; it does not move it. Initials remain
   optically centred exactly like every other avatar in both the row and the
   person dialog, even when the knot covers part of a letter. */
.pfp.is-best .net-avatar,
.pfp.is-best .person-disc { padding: 0; }

/* ---- The person panel -----------------------------------------------------

   A row opens one. Rows are focusable so the keyboard gets there too, which
   means they need a focus ring of their own — the cell has no other control in
   it to inherit one from. */
.net-row { cursor: pointer; }
.net-row:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* Mark left, identity right — the New workspace dialog's arrangement. */
.person-head {
  display: grid; gap: 18px;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  margin-top: 2px;
  /* Clear of the close button hanging above the right of this row. */
  padding-right: 42px;
}
/* .avatar-lg is built for a workspace: brand gradient, white initials. A person
   is not brand-owned, so this takes the neutral disc the table already gives
   them and only keeps the size. overflow is unset because the bowtie crosses
   the edge and .avatar-lg clips to the circle for photographs. */
/* Both are one class, and .avatar-lg is defined further down the file, so it
   wins on source order unless this is the more specific selector. */
.avatar-lg.person-disc {
  background: var(--bg-alt);
  color: var(--muted);
  box-shadow: none;
  font-size: 26px;
}
.person-id { min-width: 0; }
.person-name-row { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.person-name {
  font-size: 20px; font-weight: 600; letter-spacing: -0.015em; color: var(--ink);
  overflow-wrap: anywhere;
}
.person-sub { margin-top: 6px; font-size: 13.5px; color: var(--muted); }
.person-mail {
  display: inline-block; margin-top: 6px;
  font-size: 13.5px; color: var(--accent); text-decoration: none;
  overflow-wrap: anywhere;
}
.person-mail:hover { text-decoration: underline; }

/* Two quantities, two tiles, side by side. Fixed columns rather than auto-fit:
   the numbers vary in width from "3" to "1,681,104", and a track sized to content
   would put a different column edge under every person. It was a 2x2 of four
   tiles until Lifetime EMV and Media published came out — see openPerson. */
.person-stats {
  display: grid; gap: 10px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  margin-top: 22px;
}
.person-stat {
  display: flex; flex-direction: column; gap: 3px;
  padding: 12px 14px;
  border: 1px solid var(--line); border-radius: var(--r-sm);
  background: var(--bg-alt);
}
.person-stat-value {
  font-size: 18px; font-weight: 600; letter-spacing: -0.015em; color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.person-stat-label { font-size: 12px; color: var(--muted); }

.person-section { margin-top: 22px; }
.person-section-title {
  font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .1em;
  color: var(--muted);
}
.person-socials { margin-top: 10px; }
.person-social {
  display: flex; align-items: center; gap: 11px;
  padding: 9px 0;
  border-bottom: 1px solid var(--line);
}
/* The hairline goes on the last row — there is nothing under it to divide — but
   the PADDING stays. Zeroing it left the last handle 26px from the card's bottom
   edge with every row above it breathing 9px, so the block ended flush while the
   rest of the card had rhythm. Kept, the space below the last row is 9px + the
   dialog's own 26px, which reads as the end of a list rather than as a crop. */
.person-social:last-child { border-bottom: 0; }
.person-social-mark {
  display: grid; place-items: center; flex: none;
  width: 28px; height: 28px;
  border: 1px solid var(--line); border-radius: var(--r-sm);
  color: var(--muted); background: var(--card);
}
.person-social-mark svg { width: 15px; height: 15px; }
.person-social-name { font-size: 13.5px; color: var(--muted); }
/* margin-left:auto rather than justify-content:space-between, so the icon and
   the network name stay a pair and only the handle goes to the far edge.
   On the WRAPPER, because the handle can be followed by the unconfirmed mark and
   the two travel to that edge together — and top-aligned inside it, since a flex
   line ignores vertical-align and a centred superscript is not a superscript. */
.person-social-value {
  margin-left: auto; min-width: 0;
  display: flex; align-items: flex-start;
}
.person-social-handle {
  min-width: 0;
  font-size: 13.5px; font-weight: 600; color: var(--ink); text-decoration: none;
  overflow-wrap: anywhere;
}
a.person-social-handle:hover { color: var(--accent); text-decoration: underline; }
/* Not enriched yet — derived so the row can be seen at full width. Muted and
   not a link, because there is no profile on the other end of it. */
.person-social-handle.is-mock { font-weight: 500; color: var(--muted); }

/* The destructive entry point belongs at the card's lower left, away from the
   close control and from every profile link above it. It keeps the lighter
   outlined treatment until the confirmation face earns the filled red action. */
.modal-actions.person-actions { justify-content: flex-start; }

/* One column below the width where the form and the table stop being able to sit
   side by side, and no project chip in the history once a row has to choose
   between its name and its tag. */
@media (max-width: 1000px) {
  .setup-grid { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 760px) {
  .enr-chip { display: none; }
}
@media (max-width: 900px) {
  /* The desktop split gives the upload tile a third of the panel and the history
     two thirds, which is the right emphasis on a wide screen and impossible on a
     phone: a third of a short panel is less than the dropzone's own contents.
     Here the upload tile sizes to what is in it and the history takes whatever is
     left — it paginates, so it can live with less, and the dropzone cannot.

     Both change together on purpose. Content-sizing one flex item without freeing
     the other just moves the squeeze. */
  .enr-new { flex: 0 0 auto; }
  .enr-history { flex: 1 1 0; }
}

/* ============================================================================
   Quiet scroll containers
   ========================================================================= */

/* A list that scrolls inside a tile, without a permanent grey track down its
   edge. On any machine set to always-show scrollbars, that track is the
   heaviest line on a page built from hairlines, and it is drawn whether or not
   anyone is scrolling.

   NO scrollbar is drawn at all now: the wheel, a trackpad, the keyboard and a
   touch drag all still scroll. This used to fade a thin thumb in on hover, and
   the fade itself turned out to be the distraction — movement at the edge of a
   list you were only reading.

   Known trade-off: with no thumb there is nothing saying a list continues past
   its last visible row. Mitigated by the lists being cut MID-ROW rather than at a
   tidy edge, which is its own affordance, and by nothing here being the only
   route to the information.

   Three properties for three engines, and none of them reserves space, so
   nothing reflows. */
.scroll-quiet {
  scrollbar-width: none;                                    /* Firefox */
  -ms-overflow-style: none;                                 /* old Edge */
}
.scroll-quiet::-webkit-scrollbar { width: 0; height: 0; }   /* Chrome, Safari */

/* ============================================================================
   Workspace tab
   ========================================================================= */

/* A .tab-panel is a flex row of one, so this is the column inside it — same
   arrangement as .acct-panel. The switcher and the danger row take their own
   height; the two-up between them absorbs the rest. */
.ws-panel { flex: 1; min-width: 0; display: flex; flex-direction: column; }

/* The switcher sits above everything it changes, on the panel's own left edge
   rather than inside a tile: it is not one of this workspace's properties, it
   is the choice of WHICH workspace the tiles below are describing.

   Smaller than a form field, and deliberately so. It is a control you use once
   and then read past, not something you fill in, so it sits a step below the
   fields underneath it in both height and type size. */
.ws-switch { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.ws-switch .field-label { flex: none; }
/* The New workspace dialog's top half: the mark, then the two names beside it.
   Deliberately NOT .acct-grid — that tile is square and stretches to the height
   of the form next to it, which inside a dialog would make a 200px box out of
   two fields. Here the mark takes only the width it needs and centres itself
   against the fields. */
.new-ws-grid {
  display: grid; gap: 18px;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  margin-top: 20px;
}
.new-ws-mark { display: flex; flex-direction: column; align-items: center; }
/* The dialog is narrower than the Workspace tab, so the two buttons under the
   disc would otherwise push the column wider than the disc itself. */
.new-ws-mark .avatar-actions { margin-top: 10px; flex-wrap: wrap; justify-content: center; }

/* ---- New project ---------------------------------------------------------

   One column. The picture used to sit in a second column beside the name and
   the venue, which left both fields at half width, put the Add image button on
   a baseline of its own, and made a form of six short answers feel packed. A
   cover image is a wide thing anyway — showing it in a narrow box previewed a
   crop the card will never use. */

/* The one dialog long enough to need scrolling. .modal keeps overflow:visible
   on purpose — the UA's `auto` clips absolutely-positioned children, which cost
   a dropdown its last option once — so the scroll goes on the FORM instead,
   exactly as the note on that rule says to do it. The head stays put and only
   the fields move, which is the better behaviour anyway: the close button does
   not scroll away on a phone.

   [open] on this rule, not just a bare id: a <dialog> is hidden by the UA's
   `dialog:not([open]) { display: none }`, which an unqualified `display: flex`
   outranks — and the dialog then stayed on the page after it was closed.

   dvh, not vh: on mobile Safari vh is the tallest the viewport ever gets, so
   the actions would sit under the address bar. */
#newProjModal[open] {
  display: flex; flex-direction: column;
  max-height: calc(100dvh - 32px);
}
/* Project editing carries the same fields and thumbnail as project creation,
   so it uses the same fixed-head, scrolling-form behavior. */
#editProjModal[open] {
  display: flex; flex-direction: column;
  max-height: calc(100dvh - 32px);
}
/* The horizontal padding is what stops a focused field's ring being sliced off
   at the edges. overflow-y:auto computes overflow-x to auto as well, so the
   scroll box clips sideways too — and a focus ring is drawn OUTSIDE the input's
   own box, in the few pixels that were not there. The negative margin gives the
   padding back to the dialog, so the fields stay on the same line as the title
   above them. */
#newProjForm {
  overflow-y: auto; min-height: 0;
  margin: 0 -10px; padding: 0 10px;
}
.project-edit-form {
  overflow-y: auto; min-height: 0;
  margin: 0 -10px; padding: 0 10px;
}
/* No resize grabber. Dragging a textarea taller inside a box that is already
   scrolling fights the scroll, and on a phone the grabber is a 12px target
   nobody aimed for. Same call .dt-context makes on the Enrich dialog. */
#newProjDesc { resize: none; }
#editProjDesc { resize: none; }

/* One rhythm for the whole form, set once. Every block was carrying its own
   margin — some in the stylesheet, some inline — so adding a field meant
   remembering which. */
#newProjForm > * { margin-top: 16px; }
.project-edit-form > * { margin-top: 16px; }
/* The id selector above outranks .modal-actions' own 24px, and the action row
   wants more air than a field does — it is the end of the form, not another
   line of it. */
#newProjForm > .modal-actions { margin-top: 24px; }
.project-edit-form > .modal-actions { margin-top: 24px; }

/* The cover picker, and the preview that replaces it. Same height either way,
   so choosing an image does not make the form jump under the cursor. Wide,
   because that is the shape of the banner across a project card. */
.cover-pick,
.cover-preview {
  display: grid; place-items: center;
  width: 100%; height: 132px;
  border-radius: var(--r-tile);
}
.cover-pick {
  padding: 16px;
  border: 1.5px dashed var(--line);
  background: var(--card);
  text-align: center;
  cursor: pointer;
  transition: border-color .15s var(--ease), background .15s var(--ease);
}
.cover-pick:hover { border-color: var(--accent-soft); background: var(--accent-tint); }
/* While a file is over it. Same treatment as hover but committed — the accent
   at full strength, so there is no doubt the drop will land. */
.cover-pick.is-over { border-color: var(--accent); background: var(--accent-tint); }
.cover-pick-inner { display: flex; flex-direction: column; align-items: center; }
/* The same hairline square Enrich's dropzone puts its upload arrow in — see
   .drop-glyph. Two dropzones in the app, one treatment for the glyph in them. */
.cover-glyph {
  display: grid; place-items: center;
  width: 40px; height: 40px;
  border: 1px solid var(--line);
  border-radius: var(--r-chip);
  color: var(--muted);
}
.cover-glyph svg { width: 18px; height: 18px; }
.cover-title {
  margin-top: 12px;
  font-size: 14.5px; font-weight: 600; letter-spacing: -0.005em; color: var(--ink);
}
.cover-note { margin-top: 3px; font-size: 13px; color: var(--muted); }

/* Hairline rather than dashed: this is a real thing now. */
.cover-preview {
  border: 1px solid var(--line);
  background: var(--bg-alt) center/cover no-repeat;
}
/* One button under the preview, at the right — where the eye already is after
   reading a picture left to right. */
.cover-chosen-foot {
  display: flex; justify-content: flex-end;
  margin-top: 10px;
}

/* Two fields that answer short questions, side by side rather than stacked. */
.new-proj-row {
  /* The same 16px the form stack uses, so the pair reads as two of its lines
     whether they are side by side or stacked on a phone. */
  display: grid; gap: 16px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* Inside the row they are columns of a grid, not blocks in the stack. */
.new-proj-row > * { margin-top: 0; }

/* These two controls share a row, so they share its geometry too. Letting each
   trigger hug its label left a large, accidental pocket between them even
   though their labels already established two equal columns. Full-width
   triggers make the pair read like the text fields around it: aligned outer
   edges, one deliberate 16px gutter, and carets pinned to the far edge. */
.modal .new-proj-row .date-picker,
.modal .new-proj-row .dropdown {
  width: 100%;
  align-self: stretch;
}
.modal .new-proj-row .dropdown-trigger {
  width: 100%;
  justify-content: space-between;
}

/* The picture on a project's own page. Read-only, so there is no empty-versus-
   filled border language to carry — just the frame, and a placeholder glyph for
   a project that has no image. */
.thumb-lg {
  position: relative;
  display: grid; place-items: center;
  flex: none;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  /* The brand gradient behind, a picture over it once there is one — same call
     the card's empty slot makes, so a project without a thumbnail looks the
     same in the list and on its own page. */
  background: var(--grad-btn) center/cover no-repeat;
  color: #fff;
  overflow: hidden;
}
.thumb-lg.has-image .thumb-empty { display: none; }
.thumb-empty svg { width: 26px; height: 26px; }

/* An action row at the foot of a dropdown — "Add workspace" under the list of
   workspaces. Separated by a rule so it reads as a different KIND of thing from
   the options above it: those change what is selected, this one opens a dialog.
   Mirrors .rm-sep + .rm-item in the rail menu, which is the same pattern. */
.dropdown-sep { height: 1px; margin: 6px 4px; background: var(--line); }
.dropdown-action { justify-content: flex-start; gap: 10px; }
.dropdown-action svg { width: 15px; height: 15px; flex: none; color: var(--muted); }
/* Sized by the name it is showing, so the caret sits directly after the last
   letter and there is never a gap between the two. That means the control
   changes width when you switch workspaces, which is normally something to
   avoid — but the alternative is a box wide enough for the longest name in the
   account, which leaves every shorter one trailing dead space. A label followed
   by its own caret reads as one object; a label stranded from its caret reads
   as an empty field. */
.ws-switch .dropdown, .ws-switch .field {
  width: auto;
  height: 34px;
  font-size: 13.5px;
}
/* max-content rather than auto: the trigger is a flex item, and a flex item's
   auto width would let it shrink below its content. */
.ws-switch .dropdown-trigger { width: max-content; max-width: 100%; }
/* The menu opens FROM the trigger, so it starts on the trigger's left edge and
   grows rightward if a name needs the room. The app's other dropdowns hang the
   other way (right:0) because they sit at the right end of a toolbar, where
   growing rightward would run them off the panel. */
.ws-switch .dropdown-list {
  left: 0; right: auto;
  min-width: 100%; max-width: 340px;
}

/* Stacked, for the invite dialog. The details form uses Account's .field-grid.
   Three fields in a two-column grid leaves the last cell empty, which is the
   arrangement Account already chose for an odd field out. */
.ws-fields { display: grid; gap: 15px; }

/* The roster fills whatever the two-up above it and the row below it leave.
   min-height:0 on both the tile and the list: without it the list refuses to
   shrink below its content and pushes the panel past the bottom of the screen
   instead of scrolling inside itself. */
.ws-members { flex: 1; min-height: 0; margin-top: 14px; }
/* min-height is the button's height, so a head WITH a button and a head with
   only a title occupy the same band — otherwise the gap under the heading
   differs by 12px from one tile to the next, which is visible the moment two
   such tiles are stacked (Billing details above Recent activity). Every list
   under one of these heads then takes the same 14px. */
.ws-members-head {
  display: flex; align-items: center; gap: 16px;
  min-height: 34px;
}
.ws-members-head .tile-title { flex: 1; min-width: 0; }
.ws-members-head .btn { flex: none; }
/* The count is part of the heading, not a second heading. */
.ws-count { color: var(--muted); }
/* Taking back an invitation used to be a bare "Revoke" link at the end of its
   row — one click, no confirmation, on the same act that removing a member asks
   about twice. It moved into the membership dialog the Invited badge already
   opens, so the rule that styled it is gone with it. */

.ws-member-list {
  margin-top: 14px;
  flex: 1; min-height: 0;
  display: flex; flex-direction: column;
  overflow-y: auto;
}

/* Hairlines rather than nested boxes, the same call .setting-rows makes: at
   this density a stack of bordered cards reads as clutter. */
.ws-member {
  display: flex; align-items: center; gap: 11px;
  padding: 9px 0;
  border-top: 1px solid var(--line);
}
.ws-member:first-child { border-top: 0; padding-top: 0; }

/* Neutral for everyone else, accent for you.

   This is the one place a roster earns the accent: six gradient discs in a
   column is the accent being spent on decoration, but one of them marking
   which row is you is the accent doing a job — and it matches the disc in the
   rail, which is the same person. */
.ws-avatar {
  flex: none; width: 30px; height: 30px; border-radius: 50%;
  display: grid; place-items: center;
  font-size: 11.5px; font-weight: 600;
  color: var(--muted); background: var(--bg-alt);
}
.ws-avatar-you {
  color: var(--on-accent);
  background: var(--grad-btn);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.35), var(--accent-lift);
}

/* min-width:0 twice over — on the column and on each line — is what lets the
   ellipsis engage on a long address instead of pushing the badge off the row. */
.ws-member-who { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.ws-member-name, .ws-member-email {
  min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ws-member-name { font-size: 14px; font-weight: 600; letter-spacing: -0.005em; }
.ws-member-email { margin-top: 2px; font-size: 12.5px; color: var(--muted); }
.ws-you { margin-left: 7px; font-size: 12px; font-weight: 500; color: var(--muted); }

.ws-danger { margin-top: 14px; }

/* ============================================================================
   Rail menu
   ========================================================================= */

/* Opens upward out of the identity block: workspaces, then log out.

   A popover, so it renders in the TOP LAYER. That is not a nicety — the rail
   sets overflow-x:hidden and overflow-y:auto to clip its labels while
   collapsing, and an absolutely positioned menu inside it would be cut off at
   the rail's edge. The top layer is outside that box entirely, and brings
   light-dismiss and Escape with it.

   inset:auto and margin:0 undo the browser's own popover centring; the script
   sets top and left. */
.rail-menu {
  position: fixed; inset: auto; margin: 0;
  padding: 6px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--card);
  box-shadow: var(--shadow-panel-hover);
}
.rail-menu:popover-open { animation: modal-in .14s var(--ease); }

.rm-label {
  padding: 7px 10px 5px;
  font-size: 10.5px; font-weight: 600; text-transform: uppercase;
  letter-spacing: .09em; color: var(--muted);
}
/* Label left, tick right — the same arrangement as the toolbar dropdowns, so a
   list of choices reads the same way wherever it appears. */
.rm-item {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  width: 100%; padding: 8px 10px;
  border: 0; border-radius: var(--r-xs);
  background: none; color: var(--ink);
  font-family: inherit; font-size: 14px; text-align: left;
  cursor: pointer;
  transition: background .15s var(--ease);
}
.rm-item:hover { background: var(--accent-pane-soft); }
/* .rm-item is used on both <button> and <a> — the anchors need the text
   decoration and colour reset that the buttons get for free. */
a.rm-item { text-decoration: none; color: var(--ink); }
/* visibility, not display: the tick keeps its box on every row, so the names
   line up whether or not a row is the current one. */
.rm-check { width: 15px; height: 15px; flex: none; color: var(--accent); visibility: hidden; }
.rm-item[aria-checked="true"] .rm-check { visibility: visible; }
.rm-item[aria-checked="true"] .rm-name { font-weight: 600; }
.rm-icon { width: 15px; height: 15px; flex: none; color: var(--muted); }
.rm-who { flex: 1; min-width: 0; }
.rm-name { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* A flex row rather than a line of text, so the plan, the separator and the coin
   stack all centre on one axis. Left to the baseline, a 13px glyph beside 12px
   type rides above it and the row reads as two lines that failed to line up.

   The gap is what spaces the separator evenly: 5px each side of a dot that is
   its own flex item, rather than a space typed into one of the strings, which
   would push it against whichever side it was typed on. */
.rm-note {
  display: flex; align-items: center; gap: 5px;
  margin-top: 1px;
  font-size: 12px; color: var(--muted);
}
/* Fainter than the words it divides — it is punctuation, not a third fact. */
.rm-dot-sep { opacity: .65; }
.rm-tokens { display: inline-flex; align-items: center; gap: 3px; }
/* 13px against 12px type: the stack is three strokes tall, and at 12 the two
   upper coins close up into one shape. Inherits the note's grey through
   currentColor. */
.rm-coins { width: 13px; height: 13px; flex: none; }
.rm-sep { height: 1px; margin: 6px 4px; background: var(--line); }

/* ============================================================================
   Modal
   ========================================================================= */

/* Built on <dialog> + showModal(), not a div. The native element brings the
   things a hand-rolled overlay always gets wrong: focus is trapped inside it,
   Escape closes it, the page behind goes inert so it can't be tabbed into or
   clicked, and it renders in the top layer — above every z-index on the page,
   which is what makes the rail's 80 and the scrim's 70 irrelevant here. */
.modal {
  width: min(460px, calc(100vw - 32px));
  padding: 26px;
  /* The UA stylesheet gives <dialog> `overflow: auto`, which clips anything
     absolutely positioned inside it — a dropdown opened near the bottom of a
     dialog lost its last option to the dialog's own edge. These dialogs are all
     short enough to never need to scroll; if one ever does, give its body its
     own scroll container rather than putting this back. */
  overflow: visible;
  border: 1px solid var(--line);
  border-radius: var(--r-panel);
  background: var(--card);
  box-shadow: var(--shadow-panel-hover);
  color: var(--ink);
}
.modal::backdrop {
  background: rgba(20,20,26,.32);
  backdrop-filter: blur(5px);
  -webkit-backdrop-filter: blur(5px);
}
/* Reduced motion is covered by the global rule at the bottom of this file. */
.modal[open] { animation: modal-in .18s var(--ease); }
.modal[open]::backdrop { animation: modal-fade .18s var(--ease); }
@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(.985); } }
@keyframes modal-fade { from { opacity: 0; } }

/* A confirmation opened from another modal is a state change, not a fresh
   arrival. Drawing it before closing the first dialog keeps the backdrop solid;
   skipping the entrance fade prevents the old face flashing through it. */
.modal-handoff[open],
.modal-handoff[open]::backdrop { animation: none; }

.modal-head { display: flex; align-items: flex-start; gap: 16px; }
/* No title beside it, so the close button has nothing pushing it right — and
   nothing to give the row height for either. Zero-height so the button hangs
   out of the flow instead of reserving a band of empty dialog above the face;
   it is at the far right and the avatar is at the far left, so they share the
   line without meeting. */
.modal-head-bare { justify-content: flex-end; height: 0; }
.modal-title { flex: 1; font-size: 19px; letter-spacing: -0.015em; }
.modal-close {
  display: inline-flex; align-items: center; justify-content: center; flex: none;
  width: 32px; height: 32px; margin: -4px -4px 0 0;
  border: 1px solid var(--line); border-radius: var(--r-sm);
  background: var(--card); color: var(--muted);
  cursor: pointer;
  transition: color .15s var(--ease), background .15s var(--ease);
}
.modal-close:hover { color: var(--ink); background: var(--bg-alt); }
.modal-close svg { width: 15px; height: 15px; }

.modal-body { margin-top: 12px; font-size: 14.5px; color: var(--muted); }
/* A destructive question and its consequence are two distinct reading blocks.
   Give confirmation faces one full 16px spacing step; form-dialog intros remain
   at the tighter 12px relationship they already use with their fields. */
.modal-confirm > .modal-body,
.modal-confirm-dialog > .modal-body { margin-top: 16px; }
/* The consequential noun — an address, a workspace name — in ink, so the thing
   being acted on is the thing you read first. */
.modal-body strong { color: var(--ink); font-weight: 600; }
/* The gap between a dialog's intro line and its first field, in one place.
   Every form dialog carried this as an inline style — and had drifted to two
   different numbers, so Edit run's fields sat 2px tighter than Invite's, Change
   role's and Buy tokens'. Nobody would name the difference; it just made one
   dialog feel slightly unlike the others.

   20px, which is what four of the five already used. It is a step above
   .modal-body's own 12px from the title on purpose: the intro belongs to the
   heading, the fields are the next thing. */
.modal-fields { margin-top: 20px; }

/* A note under a field, explaining the choice above it. .setting-row-note was
   doing this job, borrowed from the Settings ROW pattern, and it brought its own
   `margin-top: 3px` into a .field-group — which is a flex column with its own
   7px gap. So the note sat 10px under its control while the error message sat at
   7px, two different distances from the same field. Same type, no margin: the
   container's gap is what spaces every child of a field group. */
.field-note { font-size: 13px; color: var(--muted); }

.modal-actions {
  display: flex; justify-content: flex-end; gap: 10px;
  margin-top: 24px;
}
/* Pushes whatever follows it to the right, so a destructive action can sit at
   the far left of the same row without becoming a neighbour of Save. */
.modal-actions-gap { flex: 1; }


/* ============================================================================
   Avatar
   ========================================================================= */

/* Initials by default, a photograph once there is one. The initials live in a
   child rather than as the element's text, so setting a background image on the
   parent doesn't have to fight text on top of it — the script hides one and
   shows the other. */
.avatar-lg {
  position: relative;
  width: 88px; height: 88px; flex: none;
  display: grid; place-items: center;
  border-radius: 50%;
  background: var(--grad-btn) center/cover no-repeat;
  color: var(--on-accent);
  font-size: 30px; font-weight: 600; letter-spacing: -0.02em;
  /* The inset highlight only. --accent-lift is a shadow for something that
     SITS on the surface — a selected row, a primary button — and a photograph
     is not a control: under a picture it read as a halo around the disc rather
     than as depth, and it was the one thing on the tile drawing its own edge. */
  box-shadow: inset 0 1px 0 rgba(255,255,255,.35);
  overflow: hidden;
}
.avatar-actions { display: flex; align-items: center; gap: 8px; margin-top: 14px; }

/* ============================================================================
   Auth — login, signup, verification
   ========================================================================= */

/* These pages are the hinge between the marketing site and the app, and they
   are the only ones with no rail: there is no session yet, so there is nothing
   to navigate. A centred card over the ambient wash instead.

   They also opt out of the one-screen rule — deliberately. A signup step is the
   one place where a short viewport plus a soft keyboard genuinely can't fit the
   content, and clipping a form field is worse than scrolling to it. */
.auth {
  position: relative; z-index: 1;
  min-height: 100vh; min-height: 100dvh;
  display: grid; place-items: center;
  padding: 32px 16px;
}
.auth-inner { width: 100%; max-width: 452px; }

/* The brand is the card's heading — it stands in for the "Log in" / "Create
   your account" title rather than sitting above the card as a separate lockup.
   Two elements saying "this is Best Guests and this is the sign-in page" was
   one more than the page needed, and the generic title was the weaker of them:
   the form underneath already says which one it is. */
.auth-mark {
  display: flex; align-items: center; justify-content: center; gap: 0;
  margin: 0 0 26px;
}
/* Much bigger than the rail's 34px slot version: here the mark carries the
   header on its own rather than sitting beside a nav. Theme-owned, like the
   rail mark — the two logos have different aspect ratios, so one pair of
   numbers cannot serve both. */
.auth-mark .crown-mark {
  width: var(--brand-mark-auth-w);
  height: var(--brand-mark-auth-h);
  margin: 0;
}

/* The wordmark is hidden, not deleted. It stays in the accessibility tree as
   the page's heading, so an h1 that is now purely a picture still announces
   "Best Guests — log in"; removing it would leave a heading with no text. */
.auth-mark > span:not(.crown-mark):not(.sr-only) {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}

/* Adjacent sibling rather than a modifier on the mark: the lede is present but
   hidden on the signup steps that have their own heading, and this way the
   mark's spacing is correct in both cases without the script touching it. */
/* Roughly even space above and below, rather than tucked under the mark.
   At -18px the lede sat 8px below the wordmark and 26px above the content,
   which by proximity alone made it read as part of the logo lockup instead of
   as the page's own line. Equal gaps let it stand on its own. */
.auth-mark + .auth-lede {
  margin: -2px 0 26px;
  text-align: center; font-size: 14.5px; color: var(--muted);
}
/* The one part of that line worth reading twice — the address a reset was sent
   to. Ink against the muted grey around it, at the same size: the emphasis is the
   colour and the weight, not a second type size inside one sentence. */
.auth-mark + .auth-lede .auth-lede-strong { color: var(--ink); font-weight: 600; }

/* One back control, on the brand's line. Arrow only, in the same grey as the
   lede: it's a correction path, not the way forward, and a labelled button here
   competed with the one that continues. A left arrow at the top of a stepped
   form doesn't need the word.

   Absolutely positioned rather than in flow, which is what lets it share the
   line with a CENTRED mark — in flow it would either push the mark off-centre
   or need a matching spacer on the right to cancel itself out. top matches the
   card's padding and the height matches the crown, so the two line up exactly;
   left is the padding minus 6px, so the glyph's optical edge lands on the
   card's text edge instead of being indented by its own hit area. */
.auth-back {
  /* Centred on the mark's line rather than pinned to a number, because the mark
     changes height with the theme — 37px for the bowtie, 55px for the crown,
     and at a fixed top the arrow drifts off the mark's centre the moment the
     two differ. 30px is the card's top padding, where the mark starts; the
     correction is half the difference between the mark's height and this
     button's own 38px. left is the padding minus the glyph's own inset, which
     puts its optical edge on the card's text edge. */
  position: absolute;
  top: calc(30px + (var(--brand-mark-auth-h) - 38px) / 2);
  left: 18px;
  display: inline-flex; align-items: center; justify-content: center;
  width: 38px; height: 38px;
  border: 0; border-radius: var(--r-xs);
  background: none; color: var(--muted);
  cursor: pointer;
  transition: color .15s var(--ease), background .15s var(--ease);
}
.auth-back:hover { color: var(--ink); background: rgba(20,20,26,.045); }
.auth-back svg { width: 18px; height: 18px; }

.auth-card {
  position: relative;              /* anchors .auth-back */
  padding: 30px 28px;
  border-radius: var(--r-panel);
  background: var(--card);
  border: 1px solid var(--line);
  box-shadow: var(--glass-shadow);
}
.auth-title { font-size: 23px; text-align: center; }
.auth-sub {
  margin-top: 9px; text-align: center;
  font-size: 14.5px; color: var(--muted);
}
.auth-sub strong { color: var(--ink); font-weight: 600; }

.auth-form { display: flex; flex-direction: column; gap: 15px; margin-top: 24px; }

/* A dropdown in a form is a field like any other, so it takes the same full
   width as the text inputs above and below it — the toolbar's content-width
   trigger would read as a stray button in a column of matched controls. Its
   panel matches the trigger too: the +44px overhang is a toolbar affordance,
   and inside a 452px card it would just hang off the edge. */
.auth-form .dropdown { display: block; }
.auth-form .dropdown-trigger { width: 100%; }
.auth-form .dropdown-list { min-width: 100%; max-width: none; }

/* A dropdown in a DIALOG, or in Enrich's run details, hugs its value instead: it
   carries a short label ("Admin", "No project"), and a full-width control strands
   the caret halfway across the panel with dead space after it.

   align-self:start is what lets the wrapper shrink. A .field-group is a flex
   column, so its children stretch by default — and a stretched wrapper is also
   what made the panel measure `min-width` against the COLUMN's width rather than
   the trigger's, so it opened wider than the control it belongs to and, being
   right-aligned, hung off the left edge with its first letters clipped.
   left:0 fixes the direction; min-width:100% of a shrunk wrapper fixes the size,
   and the base rule's max-content keeps it as wide as its longest option. */
.modal .dropdown,
.setup-form .dropdown { align-self: start; max-width: 100%; }
.modal .dropdown-trigger,
.setup-form .dropdown-trigger { width: max-content; max-width: 100%; }
.modal .dropdown-list,
.setup-form .dropdown-list { left: 0; right: auto; min-width: 100%; }
.field-group { display: flex; flex-direction: column; gap: 7px; }
.field-label { font-size: 13px; font-weight: 600; letter-spacing: -0.005em; }
/* A label with something at the other end of its line — "Forgot your password?"
   beside Password. Baseline, not centre, so the two sit on the same line of type
   rather than being centred against each other; the link is a step smaller so the
   label still reads as the label. .field-group's own 7px gap puts the row the
   usual distance above its input. */
.field-label-row {
  display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
}
/* Set like the "(optional)" hint beside a label — .field-hint's muted colour and
   lighter 500 weight — because that is the same kind of thing: a note at the end
   of a label rather than a call to action. As accent blue at 600 it was the
   heaviest text on the form, competing with the button somebody actually came to
   press.

   SELECTED THROUGH ITS ROW, which is the only reason it takes effect: .link-btn
   sets a colour, a weight and a size of its own, it is one class deep exactly like
   .field-label-link, and it sits later in this file — so at equal specificity it
   wins on order. Two classes settle it wherever either rule ends up sitting.
   (Being explicit about the values does nothing for that; it was the specificity,
   not the values, that had to change.)

   Still plainly a control: it darkens to ink on hover and .link-btn underlines
   it. */
.field-label-row .field-label-link {
  flex: none;
  font-size: 12.5px; font-weight: 500; color: var(--muted);
}
.field-label-row .field-label-link:hover { color: var(--ink); }
/* Reserves no space when empty — an always-present error line under every field
   makes a clean form look like a form full of problems. */
.field-error { font-size: 12.5px; font-weight: 500; color: var(--danger); }

/* A note occupying a grid cell of its own, next to the field it belongs to.
   It has to line up with the INPUT opposite, not the top of the cell — so it
   drops past the height of the label row first, then centres itself against the
   input's own height. Both numbers are derived rather than typed: the label is
   13px at the inherited 1.5 line-height, the gap below it is .field-group's,
   and 40px is the field height. Change any of those and this follows. */
.field-aside {
  align-self: start;
  margin-top: calc(13px * 1.5 + 7px);
  min-height: 40px;
  display: flex; align-items: center;
  font-size: 13px; color: var(--muted);
}
.field.is-invalid { border-color: var(--danger); }
/* Marks the one optional field in a column of required ones. Muted and a weight
   lighter, sitting immediately after the label text — an aside to the name
   rather than part of it. */
.field-hint { font-weight: 500; color: var(--muted); }

/* ---------- Multi-select answer list ----------

   A checkbox square on the left, not the tick-on-the-right the dropdowns use.
   Those pick ONE value, where the tick marks the survivor; these can all be on
   at once, and an empty box is the only thing that says so BEFORE anything has
   been clicked. Same reason the rows are buttons carrying role=checkbox rather
   than options in a listbox: each is an independent yes/no. */
.choice-list { display: flex; flex-direction: column; gap: 8px; }
/* Two across rather than a column, for a list of short options — the same grid
   .setup-points gives the enrichment choices, which are the same component doing
   the same job. A stack of four one-word rows the width of a dialog is a lot of
   box for very little content. */
.choice-grid {
  display: grid; gap: 8px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.choice {
  display: flex; align-items: center; gap: 11px;
  width: 100%; padding: 10px 12px;
  border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--card); color: var(--ink);
  font-family: inherit; font-size: 14px; text-align: left;
  cursor: pointer;
  transition: background .15s var(--ease), border-color .15s var(--ease);
}
.choice:hover { background: var(--accent-pane-soft); }

/* An option that exists but isn't built yet: greyed and inert. Shown rather than
   hidden, because a list of one reads as the whole product where a greyed list of
   four reads as three still to come. */
.choice:disabled {
  cursor: not-allowed;
  color: var(--muted);
  background: var(--card);
}
.choice:disabled .choice-box { background: var(--bg-alt); border-color: var(--line); }

.choice-box {
  width: 18px; height: 18px; flex: none;
  display: grid; place-items: center;
  border: 1.5px solid var(--line); border-radius: 6px;
  background: var(--card);
  transition: background .15s var(--ease), border-color .15s var(--ease);
}
/* The tick keeps its box at all times — fading opacity rather than toggling
   display means the square never reflows when it fills. */
.choice-box svg { width: 11px; height: 11px; color: var(--on-accent); opacity: 0; }

.choice[aria-checked="true"],
.choice-free.is-filled {
  border-color: var(--accent);
  background: var(--accent-tint);
}
.choice[aria-checked="true"] { font-weight: 600; }
.choice[aria-checked="true"] .choice-box,
.choice-free.is-filled .choice-box { background: var(--accent); border-color: var(--accent); }
.choice[aria-checked="true"] .choice-box svg,
.choice-free.is-filled .choice-box svg { opacity: 1; }

/* The free-text row is a .choice holding an input rather than a field of its
   own: aligned to the same left edge as the five labels above it, so the six
   answers read as one list instead of five answers and a text box. */
.choice-input {
  flex: 1; min-width: 0;
  border: 0; background: none; padding: 0;
  font-family: inherit; font-size: 14px; color: var(--ink);
}
.choice-input::placeholder { color: var(--muted); }
.choice-input:focus { outline: none; }
/* The row takes the focus ring the input gave up, so focus lands on the whole
   row the way it does on a .field. */
.choice-free:focus-within { border-color: var(--accent); box-shadow: var(--accent-ring); }

/* No red borders on the rows when the group is invalid — six red boxes for one
   message reads as six problems, and none of the rows is individually wrong.
   The message under the list carries it, which is what it does under a field
   too. .is-invalid still lands on the list so the state is inspectable. */

.btn-block { width: 100%; justify-content: center; }
.auth-actions { margin-top: 6px; }

.auth-alt { margin-top: 18px; text-align: center; font-size: 13.5px; color: var(--muted); }
.auth-sso-error { display: block; margin: -8px 0 18px; text-align: center; }
.auth-alt a { color: var(--accent-deep); font-weight: 600; }
.auth-alt a:hover { text-decoration: underline; }

/* "or" rule. The lines are pseudo-elements so the word stays centred whatever
   the card's width. */
.auth-or {
  display: flex; align-items: center; gap: 12px;
  margin: 20px 0;
  font-size: 12.5px; color: var(--muted);
}
.auth-or::before, .auth-or::after {
  content: ""; flex: 1; height: 1px; background: var(--line);
}

/* Identity-provider buttons. The marks stay full-colour on a neutral ground,
   while the surface behaves like the dashboard's outlined controls: white at
   rest, then a deeper panel shadow on hover. A grey fill made these look dirty
   beside the Personal/workspace switch rather than like members of one system. */
.btn-google, .btn-sso {
  width: 100%; justify-content: center;
  background: var(--card);
  border: 1px solid var(--line);
  color: var(--ink);
  box-shadow: var(--shadow-panel);
}
.btn-google:hover, .btn-sso:hover {
  background: var(--card);
  box-shadow: var(--shadow-panel-hover);
}
.btn-google svg, .btn-sso svg { width: 18px; height: 18px; }
/* Stacked, so the pair reads as one choice rather than two competing ones. */
.btn-sso { margin-top: 10px; }

/* Password field with a reveal toggle. */
.pw-wrap { position: relative; display: flex; }
.pw-wrap .field { width: 100%; padding-right: 44px; }
.pw-toggle {
  position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
  display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px;
  border: 0; border-radius: var(--r-xs);
  background: none; color: var(--muted);
  cursor: pointer;
}
.pw-toggle:hover { color: var(--ink); }
.pw-toggle svg { width: 17px; height: 17px; }

/* Progress across the signup steps. Bars rather than numbered dots: the point
   is "how much is left", not "which step is this".
   Only two of them, and only from the third screen on — see the markup. */
.steps { display: flex; gap: 6px; margin-bottom: 22px; }
.step { flex: 1; height: 4px; border-radius: 2px; background: var(--line); }
.step.is-done { background: var(--accent); }

/* One box per digit. Six separate inputs rather than one field, because the
   boxes tell you the length before you start typing and make a mistyped digit
   obvious. The script handles advance, backspace and paste. */
.otp { display: flex; gap: 9px; justify-content: center; margin-top: 24px; }
.otp input {
  width: 100%; max-width: 56px; height: 62px;
  padding: 0; text-align: center;
  font-family: inherit; font-size: 22px; font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
  border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--card);
  caret-color: transparent;
  transition: border-color .15s var(--ease), box-shadow .15s var(--ease);
}
.otp input:focus {
  outline: none;
  border-color: var(--accent-soft);
  box-shadow: var(--accent-ring);
}
/* A greyed 0 in every empty box. It shows the shape of what goes there — six
   digits, one each — which an empty outline alone doesn't. opacity:1 because
   Firefox dims placeholders by default and would make these near-invisible at
   this weight. */
.otp input::placeholder { color: rgba(20,20,26,.28); opacity: 1; }
.otp input.is-filled { border-color: var(--accent-edge); }

.auth-note { margin-top: 16px; text-align: center; font-size: 13px; color: var(--muted); }

/* ...unless it is INSIDE the form, which is where /login puts the line
   confirming a password reset. There the column's own 15px gap is already the
   spacing, and .auth-note's 16px added a second one on top of it — so the line
   sat 31px below the password field and 21px above the button, reading as a
   caption on the button rather than as a message about the form. Zeroing both
   margins puts it on the form's own rhythm, evenly between the two. */
.auth-form > .auth-note { margin-top: 0; }
.auth-form > .auth-note + .auth-actions { margin-top: 0; }

/* The temporary preview gate deliberately borrows the auth card rather than
   inventing a second visual language for the door immediately before it. */
.access-card .auth-mark { margin-bottom: 22px; }
.auth-card .access-lede {
  margin: -2px 0 0;
  text-align: center; font-size: 14.5px; color: var(--muted);
}
.access-form { margin-top: 24px; }
/* The way out of a forgotten password, at the end of the field it belongs to.
   Right-aligned and small: it is the alternative to the thing you came to do, not
   a second thing to do. */
/* WHAT YOU ARE BEING INVITED TO, on the invitation page. The same shape the New
   workspace dialog uses for a workspace's identity — a mark on the left, name and
   detail on the right — so a workspace looks like a workspace before you are in
   it. */
.invite-card {
  display: flex; align-items: center; gap: 14px;
  margin-top: 18px; padding: 14px;
  border: 1px solid var(--line); border-radius: var(--r-tile);
  background: var(--card);
  text-align: left;
}
.invite-mark {
  display: grid; place-items: center; flex: none;
  width: 40px; height: 40px;
  /* A circle, like every other disc standing for a workspace or a person — the
     rail's, the roster's, Settings' large one. This was the one square left, and
     on the invitation page of all places: the first thing somebody sees of a
     workspace should not be a different shape from the workspace itself. */
  border-radius: 50%;
  background: var(--grad-btn);
  color: #fff; font-size: 14px; font-weight: 600; letter-spacing: -0.01em;
}
.invite-id { min-width: 0; }
.invite-workspace {
  font-size: 15px; font-weight: 600; letter-spacing: -0.01em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.invite-meta { margin-top: 2px; font-size: 13px; color: var(--muted); }
/* Two buttons stacked, when the page offers a choice. */
.auth-actions .btn + .btn { margin-top: 10px; }

/* On the invitation page the button follows the workspace card, and
   .auth-actions' 6px left the two touching — a card and a button sharing an
   edge read as one control with a lid on it. 18px is the card's own top margin,
   so the three things in that column are evenly spaced. */
.invite-actions { margin-top: 18px; }

/* The HORIZONTAL variant, which the Settings forms use: Save, with a Cancel that
   appears beside it once there is something to undo.

   Resetting that margin is the whole point of this class. The rule above stacks
   buttons for the auth pages, where .auth-actions holds full-width buttons one
   under another and 10px is the gap between them. In a flex row the same
   declaration makes the second button's outer box 10px taller than the first, so
   `align-items: center` grew the line — and pushed everything below it down —
   the moment Cancel stopped being hidden. Which is to say the form changed
   height because somebody started typing in it, and sat Cancel 5px below the
   button it is paired with.

   Declared here rather than inline on the two rows that need it, because an
   inline `display:flex` cannot override a margin from a stylesheet — the
   original attempt at this was exactly that, and it is why the bug survived. */
.auth-actions-row {
  display: flex; align-items: center; gap: 10px;
}
.auth-actions-row .btn + .btn { margin-top: 0; }

.link-btn {
  border: 0; background: none; padding: 0;
  font-family: inherit; font-size: inherit; font-weight: 600;
  color: var(--accent-deep); cursor: pointer;
}
.link-btn:hover { text-decoration: underline; }
.link-btn:disabled { color: var(--muted); cursor: default; text-decoration: none; }

/* ============================================================================
   Skeletons
   ========================================================================= */

/* TEMPORARY, and worth saying so loudly: every page body is currently a wireframe
   of grey blocks standing in for content that does not exist yet. It is here so
   the team can see the intended shape of each page before the features behind
   them are built. As a page gets real content, delete its .sk-page block — do
   not grow this vocabulary into a layout system. It is scaffolding.

   Deliberately inert: neutral grey, no accent, no text. A skeleton that carries
   brand colour stops reading as "absent" and starts reading as a design, and
   people begin reviewing it as one.

   The blocks are also honest about the one-screen rule — the whole wireframe is
   flex, so it fills the panel exactly and ends on the rail's bottom edge at any
   viewport height rather than overflowing or leaving dead air. */

/* Fills the panel. .section-fill > .panel centres its child (for the old empty
   states), so this has to stretch itself back out. */
.sk-page {
  align-self: stretch;
  flex: 1; min-width: 0; min-height: 0;
  display: flex; flex-direction: column; gap: 14px;
}

/* ---- layout ---- */
.sk-row { display: flex; align-items: center; gap: 12px; }
/* Rows of equal-height columns, as opposed to .sk-row's centred items. */
.sk-split { display: flex; gap: 14px; }
.sk-col { display: flex; flex-direction: column; gap: 9px; min-width: 0; }

.sk-grid { display: grid; gap: 14px; }
.sk-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sk-grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.sk-grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
/* 1fr rather than auto, so a grid told to grow splits the height evenly across
   its rows instead of leaving the last one short. */
.sk-grid.sk-grow { grid-auto-rows: minmax(0, 1fr); }

/* A card outline. Hairline only — a skeleton inside a shadowed panel inside
   another shadowed panel is three levels of elevation for zero information. */
.sk-tile {
  display: flex; flex-direction: column; gap: 10px;
  min-width: 0; min-height: 0;
  padding: 16px;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
}

/* Rows share the leftover height equally, so a list always ends flush with the
   bottom of its container whatever the viewport does. */
.sk-list { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.sk-list-row {
  display: flex; align-items: center; gap: 12px;
  flex: 1; min-height: 0;
  border-top: 1px solid var(--line);
}
.sk-list-row:first-child { border-top: 0; }

/* ---- blocks ---- */
/* One base class, then a shape. The sheen is a moving background image rather
   than a pseudo-element so it costs nothing to put on a hundred blocks; the
   global prefers-reduced-motion rule already stops it. */
.sk {
  flex: none;
  border-radius: 999px;
  background-color: var(--skeleton);
  background-image: linear-gradient(90deg, transparent, rgba(255,255,255,.75), transparent);
  background-size: 220px 100%;
  background-repeat: no-repeat;
  animation: sk-sheen 1.9s linear infinite;
}
@keyframes sk-sheen {
  from { background-position: -220px 0; }
  to   { background-position: calc(100% + 220px) 0; }
}

.sk-line   { height: 10px; }
.sk-lead   { height: 15px; }            /* a heading's worth */
.sk-avatar { width: 38px; height: 38px; border-radius: 50%; }
.sk-thumb  { width: 56px; height: 40px; border-radius: var(--r-sm); }
.sk-pill   { width: 60px; height: 20px; }
.sk-btn    { width: 118px; height: 36px; border-radius: var(--r-btn); }
.sk-input  { height: 38px; border-radius: var(--r-md); }
.sk-square { border-radius: var(--r-sm); }

/* Widths, as a fixed ladder. Ragged line lengths are what make a skeleton read
   as prose rather than as a bar chart, so pages mix these deliberately. */
.sk-w15 { width: 15%; } .sk-w25 { width: 25%; } .sk-w35 { width: 35%; }
.sk-w45 { width: 45%; } .sk-w55 { width: 55%; } .sk-w65 { width: 65%; }
.sk-w75 { width: 75%; } .sk-w90 { width: 90%; }

/* The flex utilities live AFTER .sk deliberately. .sk sets flex:none so a block
   keeps its own size in a row, and these have the same specificity — declared
   first they would lose to it, and `sk sk-input sk-grow` would refuse to fill
   its row. Same reason .sk-tile and .sk-grid pick them up: they're last. */
.sk-grow  { flex: 1; min-width: 0; min-height: 0; }
.sk-flex2 { flex: 2; min-width: 0; min-height: 0; }
.sk-flex3 { flex: 3; min-width: 0; min-height: 0; }

/* A bar chart's worth of columns, bottom-aligned. */
.sk-bars { display: flex; align-items: flex-end; gap: 10px; flex: 1; min-height: 0; }
.sk-bars > .sk { flex: 1; border-radius: var(--r-sm) var(--r-sm) 2px 2px; }
.sk-h30 { height: 30%; } .sk-h45 { height: 45%; } .sk-h55 { height: 55%; }
.sk-h70 { height: 70%; } .sk-h85 { height: 85%; } .sk-h100 { height: 100%; }


/* ============================================================================
   Mobile
   ========================================================================= */

/* Shown only below the drawer breakpoint. Glass bar matching the rail.

   Above BOTH the scrim (70) and the drawer (80), which is the one ordering that
   keeps the close button usable: under the scrim it would be dimmed and inert,
   and under the drawer it would be hidden entirely — leaving an open drawer
   with no visible way to shut it. The 58px of drawer it covers is the drawer's
   own brand row, which duplicates the topbar's anyway. */
.topbar {
  display: none;
  position: sticky; top: 0; z-index: 90;
  align-items: center; gap: 12px;
  /* padding-x matches .main's on this breakpoint, which is what puts the crown
     on the same left edge as the page's icon chip and the panel below it. The
     two numbers have to move together. */
  height: 58px; padding: 0 var(--main-pad-x);
  /* Flat white, like the rail and every other large surface. It was Tier-1
     glass, which exists to show content passing behind it — and nothing ever
     passes behind it now that no page scrolls. All the blur bought was a
     slightly different white than the drawer it sits against. */
  background: var(--card);
  border-bottom: 1px solid var(--line);
}
.topbar .rail-brand { margin: 0; padding: 0; font-size: 16px; }

/* Deliberately NOT toggled with `display` or the `hidden` attribute.
   A `display:block` rule inside a media query beats the UA stylesheet's
   `[hidden]{display:none}` — author origin always wins over UA origin,
   regardless of specificity — which would leave this invisible full-viewport
   overlay on top of the page swallowing every click on mobile.
   visibility + pointer-events is unambiguous, keeps it out of the
   accessibility tree, and still animates. */
.scrim {
  position: fixed; inset: 0; z-index: 70;
  background: rgba(20,20,26,.28);
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity .3s var(--ease), visibility .3s var(--ease);
}
.scrim.open { opacity: 1; visibility: visible; pointer-events: auto; }

@media (max-width: 900px) {
  .topbar { display: flex; }

  /* The one-screen rule holds here too. The topbar is in flow and 58px tall, so
     the app takes the rest — that sum being exactly 100vh is what stops the
     document scrolling by the height of the bar.
     Pages that don't fit paginate (see .pager) rather than scroll. */
  .app { height: calc(100vh - 58px); height: calc(100dvh - 58px); }

  /* The rail becomes an off-canvas drawer. It slides rather than expanding in
     flow — animating height would shove the page around. Note there is no
     body{overflow:hidden} scroll lock: that drops position:sticky elements to
     their static position and the topbar vanishes mid-page. The drawer contains
     its own scroll instead. */
  .rail {
    position: fixed; top: 0; left: 0; z-index: 80;
    width: min(300px, 86vw); height: 100vh;
    /* Top-right only. The drawer runs the full height of the screen, so a
       rounded bottom-right corner cuts a notch out of the screen's own corner
       rather than reading as a rounded panel. */
    margin: 0; border-radius: 0 var(--r-panel) 0 0;
    transform: translateX(-102%);
    /* visibility, not just transform: a translated-off-screen drawer is still in
       the tab order, so a keyboard user would tab into invisible controls.
       visibility:hidden removes it from the tab order and the accessibility
       tree, and still animates because it's a discrete-but-transitionable
       property — the reveal is immediate, the hide waits out the slide. */
    visibility: hidden;
    transition: transform .34s var(--ease), visibility .34s var(--ease);
    overflow-y: auto; overscroll-behavior: contain;
    /* Same flat white as the desktop rail and the topbar. */
    background: var(--card);
    border-right: 1px solid var(--line);
  }
  .rail.open { transform: none; visibility: visible; }
  /* Bottom padding matches the desktop 14px so the content column still ends on
     the same line the rail does — there is no scroll to absorb a larger one. */
  /* The larger top inset exists to line the header up with the rail's brand
     row, and on mobile there is no rail on screen — the topbar does that job.
     So the top goes back to matching the sides. */
  .main { padding: 14px var(--main-pad-x); }
  .page-title { font-size: 25px; }
  .page-breadcrumb-parent,
  .page-breadcrumb-sep,
  .page-breadcrumb.is-detail .page-title { font-size: 25px; }
  .grid-2, .grid-3 { grid-template-columns: minmax(0, 1fr); }
  .acct-grid { grid-template-columns: minmax(0, 1fr); }
  .acct-photo { aspect-ratio: auto; }
  .field-grid { grid-template-columns: minmax(0, 1fr); }

  /* The header's action button drops under the title rather than squeezing it,
     but keeps margin-left:auto either way so it stays pinned to the container's
     right edge — wrapped or not. */
  .main-head { flex-wrap: wrap; }

  /* GRID, not flex, for the toolbar on mobile.
     Filters and Sort have to be the same width, and flex cannot promise that
     here: they are different elements (a .btn-tool and a .dropdown wrapper) with
     different content widths, and `flex: 1 1 0` still resolved to 170/140 —
     free space was being shared on top of their content sizes rather than
     replacing them. `1fr 1fr` is a guarantee rather than an approximation, and
     equal columns is the thing grid exists to do.

     Two columns, one per control. Rows are stated explicitly rather than left
     to auto-placement, because the sort <select>'s visually-hidden <label> sits
     between the two controls in source order and auto-placement has no idea it
     should be ignored.

     minmax(0, …) rather than a bare 1fr: a plain `1fr` is `minmax(auto, 1fr)`,
     so the track refuses to go below its content and Filters — the wider of the
     two once it carries a count badge — pushed its column out to 117 while Sort
     took what was left. The explicit zero minimum is what makes the two columns
     actually equal. */
  .toolbar {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-items: center;
  }
  .search { grid-column: 1 / -1; grid-row: 1; }
  .toolbar > .btn-tool { grid-column: 1; grid-row: 2; }
  .toolbar > .dropdown { grid-column: 2; grid-row: 2; }
  /* Both centred in their equal boxes. Left-aligned content in a box wider than
     it needs reads as a half-empty field rather than as a button. */
  .toolbar > .btn-tool,
  .toolbar .dropdown-trigger { justify-content: center; }
  .toolbar .dropdown-trigger { width: 100%; }
  /* Trimmed from 14px. With Clear filters taking its own column there are only
     ~111px per control, and "Filters" plus its icon plus a count badge wants
     117 at the desktop padding — this is the difference between fitting and
     spilling out of the box. */
  .toolbar > .btn-tool { padding: 0 11px; }

  /* The summary needs nothing here any more. It is a sibling of the toolbar
     again, and its own flex row — count left, Clear filters pushed right by
     .toolbar-count's flex:1 — lays out the same at every width. */
  /* No +44px here. The panel is anchored to the right of its trigger, so extra
     width spills LEFT — which on the leftmost control means straight off the
     side of the screen. Matching the trigger is the only width guaranteed to
     stay on-screen wherever the control sits. */
  .dropdown-list { min-width: 100%; max-width: calc(100vw - 32px); }

  /* ---- Enrichment results: one column and a disclosure --------------------
     Five value columns do not fit a phone. Same treatment as the Network table, for
     the same reason — reading an email meant scrolling the table sideways and
     losing sight of whose email it was. The three value columns are hidden and
     move into a panel under the name. */
  .enr-tbl thead th:nth-child(n+3),
  .enr-tbl tbody td:nth-child(n+3) { display: none; }
  /* PINNED, not auto. The detail row is one td with colspan=2, and left to
     work the column widths out for itself the browser gave that cell's content
     a say in them — opening a panel collapsed the name column from 258px to 65
     and the name vanished. An explicit width leaves nothing to redistribute. */
  .enr-results .enr-tbl th:nth-child(2),
  .enr-results .enr-tbl td:nth-child(2) { width: calc(100% - 52px); }
  /* The name takes the room, the chevron takes what it needs. */
  .res-name-text { flex: 1 1 auto; }

  /* Verdict, then chevron, both pushed to the right end of the name cell. */
  .res-row-mark { display: inline-flex; flex: none; margin-left: auto; }
  .res-toggle {
    display: grid; place-items: center;
    flex: none;
    width: 30px; height: 30px; padding: 0;
    border: 0; border-radius: var(--r-xs);
    background: none; color: var(--muted);
    cursor: pointer;
  }
  .res-toggle svg { width: 17px; height: 17px; transition: transform .18s var(--ease); }
  .res-toggle[aria-expanded="true"] { color: var(--ink); }
  /* Points up while its panel is open — the control says which way it will go. */
  .res-toggle[aria-expanded="true"] svg { transform: rotate(180deg); }

  /* A real row, spanning the two columns that exist here.
     It was a display:block box for a while, to stop a colspan=5 cell voting on
     column widths — that fixed the widths and broke the alignment instead, because
     a block inside a tbody is wrapped in an anonymous cell and sizes against
     the wrong box, putting the values 35px past the table's right edge.
     colspan=2 is the honest answer: it names exactly the columns that are
     visible, so it has nothing to argue about and stays inside the table.
     The global [hidden]{display:none !important} still wins while it is closed,
     so `hidden` stays the single source of truth for open/closed. */
  .res-detail { display: table-row; }
  .res-detail > td {
    /* The row cells truncate to one line — see .tbl td. A panel of three facts
       must do the opposite. */
    max-width: none; white-space: normal; overflow: visible;
    /* 64 = the 52px row-number column plus the cell's own 12px padding, which
       is exactly where the name above it starts. The labels line up under the
       name rather than hanging 6px to its left. */
    padding: 0 12px 12px 64px;
  }
  /* No fill on either half, and no divider between them: an open row and its
     panel are one guest, not two things stacked. */
  .res-row:has(+ .res-detail:not([hidden])) > td { border-bottom: 0; }

  .res-facts { display: grid; gap: 7px; padding-top: 8px; }
  /* min-width:0 on both, or neither can shrink below its content and the row
     overflows the panel. A grid item and a flex item both default to a
     min-width of auto, which for an email address — one long word with no
     break opportunity — is the whole string. That put the values 35px past the
     table's right edge. */
  .res-fact {
    min-width: 0;
    display: flex; align-items: baseline; justify-content: space-between; gap: 14px;
  }
  .res-fact dt { flex: none; font-size: 12.5px; color: var(--muted); }
  .res-fact dd {
    min-width: 0; margin: 0;
    font-size: 13px; font-weight: 600; color: var(--ink);
    /* Right-aligned, matching the Network panel, and allowed to break mid-word
       so a long address wraps rather than pushing the row wide. */
    text-align: right;
    overflow-wrap: anywhere;
  }

  /* ---- Settings: scroll the tab panel, on mobile only -------------------
     The one-screen rule holds the shell to a single viewport and expects each
     page to fit. On a phone Settings does not: Workspace, Plan and Billing all
     run past the fold, and with nowhere to scroll the bottom of each was simply
     cut off — Delete workspace and the invoice list were unreachable.

     So the panel takes the escape hatch DESIGN.md names for exactly this, the
     same one Integrations already uses: its own scroll container. Scoped to
     mobile deliberately. On desktop these panels DO fit, and adding a scrollbar
     there would trade a rule that works for one that is merely tolerable.

     .tab-panel is used only by Settings, so this reaches nothing else. */
  .tab-panel {
    /* BLOCK, not flex. As a row flex container the panel sized its one child
       from that child's content, and the child could not be made to shrink back
       — Account came out 660px wide inside a 320px panel and the whole tab
       scrolled sideways. A block child is constrained by its parent's width by
       definition, which is the behaviour wanted, and it takes its natural
       height so the vertical scroll below has something to scroll. */
    display: block;
    overflow-y: auto;
    /* Vertical only. Nothing here is meant to be reachable sideways, so an
       element that misbehaves should be visibly wrong rather than hidden
       behind a scroll gesture nobody thinks to try. */
    overflow-x: hidden;
    overscroll-behavior: contain;
  }

  /* ---- Network: one column and a disclosure --------------------------------
     Six columns do not fit on a phone. The table came out ~700px wide inside a
     320px panel, so reading one follower count meant scrolling the table
     sideways and losing sight of whose row it was.

     The five value columns are hidden, and the person panel carries them
     instead — it opens on a tap of the row and has room to set the numbers out
     properly. There used to be an accordion under each name doing the same job
     in less space, with a chevron of its own to open it; two ways in for one
     person was one too many once the panel existed. */
  .net-tbl thead th:not(:first-child),
  .net-tbl tbody td.tbl-mid,
  .net-tbl tbody td.tbl-num { display: none; }
  .net-tbl th:first-child, .net-tbl td:first-child { width: auto; }

  /* No hover fill on a touch screen. :hover is not a hover there — it latches
     onto whatever was tapped last and stays, so a row kept a grey wash long
     after your finger had gone. There is no pointer here to give feedback to;
     the panel opening is the feedback. */
  .tbl tbody tr:hover td { background: none; }

  /* One scroller per panel, not nested ones. These lists each carry their own
     scroll on desktop, where they are filling a fixed-height column; here the
     panel scrolls instead, and a list scrolling inside a scrolling panel is two
     places to be lost in. */
  .int-list, .usage-list, .bill-list, .ws-member-list { overflow-y: visible; }
  /* Their wrappers claim the leftover height on desktop. There is no leftover
     height to claim once the panel is content-sized. */
  .usage-history, .bill-history, .ws-members { flex: none; }
}

/* Every animation needs an escape. No exceptions. */
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; }
  .btn-primary::after { animation: none; }
  .rail, .scrim { transition: none; }
  .btn:hover, .panel:hover, .proj-card:hover { transform: none; }
}


/* ============================================================================
   One project
   ========================================================================= */

/* The header of a project's own page. Same shape as the person panel on
   Network — mark left, identity right — because both answer "what is this one
   thing" and answering it two different ways in two places is two things to
   learn. */
.proj-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
/* The picture is a BAND down the left, feathering out before the text — the
   same treatment .proj-media gives it on the card you arrived from, so opening
   a project does not re-frame its own photograph. Absolutely positioned, so it
   cannot change the header's height. The thumbnail owns its own bounds and
   radius, leaving the header free to render button shadows and focus rings. */
.proj-view-head {
  position: relative;
  display: flex; flex-direction: column; justify-content: center; gap: 2px;
  min-height: 132px;
  /* The text starts after the feather has finished. A calc off the same 34%
     the band uses, so the two can only move together. */
  /* The right inset reserves the action's width plus a readable gap, so a long
     project name can never flow underneath the absolutely positioned button. */
  padding: 4px 132px 20px calc(34% + 20px);
  border-bottom: 1px solid var(--line);
}
/* Edit belongs to the project identity rather than the empty body below it.
   Position is the only project-specific rule: its surface and every interaction
   state come unchanged from .btn-ghost, exactly like Reset password. Keeping
   the header's overflow visible also gives the shared focus outline and shadow
   room to render instead of shearing them at the top/right edge. */
.proj-view-edit {
  position: absolute; z-index: 3;
  top: 0; right: 0;
}
.proj-view-edit svg { width: 14px; height: 14px; }
.proj-view-thumb {
  position: absolute;
  top: 0; bottom: 20px; left: 0;
  width: 34%;
  border: 0; border-radius: var(--r-md);
  /* Both spellings: Safari still wants the prefix for mask-image. Same eight
     stop eased ramp as .proj-media — an alpha mask rather than a gradient to
     white, which only works while the surface behind it is white. */
  -webkit-mask-image: linear-gradient(90deg,
    #000 0, #000 48%, rgba(0,0,0,.95) 58%, rgba(0,0,0,.84) 66%,
    rgba(0,0,0,.64) 74%, rgba(0,0,0,.42) 82%, rgba(0,0,0,.22) 89%,
    rgba(0,0,0,.08) 95%, transparent 100%);
  mask-image: linear-gradient(90deg,
    #000 0, #000 48%, rgba(0,0,0,.95) 58%, rgba(0,0,0,.84) 66%,
    rgba(0,0,0,.64) 74%, rgba(0,0,0,.42) 82%, rgba(0,0,0,.22) 89%,
    rgba(0,0,0,.08) 95%, transparent 100%);
  pointer-events: none;
}
/* Same call as the card's: the gradient stands on its own. */
.proj-view-thumb .thumb-empty { display: none; }
.proj-view-id { min-width: 0; }
.proj-view-name-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.proj-view-name {
  font-size: 20px; font-weight: 600; letter-spacing: -0.015em; color: var(--ink);
  overflow-wrap: anywhere;
}
.proj-view-meta {
  display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
  margin-top: 7px;
  font-size: 13.5px; color: var(--muted);
}
.proj-view-desc {
  margin-top: 9px; max-width: 70ch;
  font-size: 14px; color: var(--muted);
}
/* The body claims whatever the header leaves, and centres in it — .empty-center
   does that with `margin: auto`, which has no room to work once the box is also
   flex:1, so the centring has to come from the box itself. Without this the
   placeholder sat tight under the header's rule with the rest of the panel
   empty below it. */
.proj-view-empty {
  flex: 1;
  justify-content: center;
  padding: 28px 0;
}

/* What a project holds, under its identity header. A scrolling column rather than
   a page that grows: the header is the answer to "which project am I in", and it
   has to stay on screen while the sections under it are read. Clipped with room
   for the last tile's hover shadow to finish, the same way .enr-list and
   .proj-grid handle their own last row. */
.proj-body {
  flex: 1; min-height: 0;
  display: flex; flex-direction: column; gap: 14px;
  margin-top: 14px;
  /* Only the vertical axis scrolls, and only when the sections genuinely do not
     fit — on a normal window the lists tile absorbs the slack and there is nothing
     to scroll. Sideways stays clipped with room for a tile's shadow to finish, the
     same pairing .enr-list uses. */
  overflow-x: clip; overflow-y: auto;
  overflow-clip-margin: 14px;
  padding-bottom: 4px;
}
/* The lists take the room; everything below them keeps its own size. The lists
   are what this page is for, and a tile three rows deep with half a panel empty
   underneath reads as a page that failed to load rather than a project with three
   lists.

   The floor is about five rows, so a project with ONE list is still a section
   rather than a single row with a heading over it — and the rows scroll inside the
   tile, so a project with twenty does not push the roadmap cards off the bottom. */
.proj-body > .tile { flex: none; }
/* Selected through .proj-body, not on its own: the rule above is two classes deep,
   so a single-class rule here LOSES to it whatever order they sit in — which is
   what left the guest lists at content height and a strip of empty panel under the
   cards below them. */
.proj-body > .proj-lists { flex: 1 1 auto; min-height: 320px; }
/* overflow-Y only: the base .enr-list clips sideways with a 14px margin so a row's
   hover shadow can finish, and replacing the whole property with `hidden auto`
   threw that away for every row in the list. */
.proj-lists .enr-list { flex: 1; min-height: 0; overflow-y: auto; padding-bottom: 4px; }
/* The column's own gap already separates the label from the cards under it. */
.proj-body > .section-label { margin-bottom: 0; }
/* Centred in the tile rather than in the panel: this empty state belongs to the
   Guest lists section, which now has a heading and an action above it. */
.proj-lists-empty { padding: 26px 0 14px; }
/* WHAT IS COMING, as two cards side by side rather than one tile with a list in
   it. Two sections are coming and they are separate destinations, so they get
   separate boxes — and a pair of equal cards is the shape the rest of the app uses
   for "here are two things of the same kind" (see .grid-2 and .tier-grid).

   Quieter than the section above them throughout: these describe work that does
   not exist and must not compete with the one that does. */
.proj-next {
  display: grid; gap: 14px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* Centred, because there is nothing to scan down — one glyph, one line, one
   sentence. Padding is generous on purpose: an empty-ish card with tight padding
   reads as a row that failed to load. */
.proj-next-card {
  align-items: center; justify-content: center;
  gap: 4px;
  padding: 26px 22px;
  text-align: center;
}
/* The same hairline square every other glyph on this page sits in — .enr-kind on a
   run row, .drop-glyph on the dropzone — at the dropzone's larger size, since this
   one is the only thing above a heading. */
.proj-next-glyph {
  display: grid; place-items: center;
  width: 40px; height: 40px; margin-bottom: 10px;
  border: 1px solid var(--line);
  border-radius: var(--r-chip);
  color: var(--muted);
}
.proj-next-glyph svg { width: 19px; height: 19px; }
.proj-next-title { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; color: var(--muted); }
.proj-next-note { font-size: 13px; color: var(--muted); max-width: 34ch; }

@media (max-width: 700px) {
  /* Two cards this small side by side leave each description a column three words
     wide. They stack instead, the same call .grid-2 makes. */
  .proj-next { grid-template-columns: minmax(0, 1fr); }
}

@media (max-width: 700px) {
  /* Below this width a band down the LEFT leaves the name a column three words
     wide, so the picture turns through ninety degrees and runs across the top
     instead — the same call .proj-card makes, for the same reason. */
  .proj-view-head { padding: 132px 0 20px; }
  .proj-view-id { display: flex; flex-direction: column; }
  /* Last item in the identity stack: below the description, aligned to the
     content's right edge, with the header's bottom padding separating it from
     the divider. If there is no description it follows the metadata instead. */
  .proj-view-edit {
    position: static; z-index: auto;
    align-self: flex-end;
    margin-top: 14px;
  }
  .proj-view-thumb {
    top: 0; bottom: auto; left: 0; right: 0;
    width: auto; height: 120px;
    -webkit-mask-image: linear-gradient(180deg,
      #000 0, #000 48%, rgba(0,0,0,.95) 58%, rgba(0,0,0,.84) 66%,
      rgba(0,0,0,.64) 74%, rgba(0,0,0,.42) 82%, rgba(0,0,0,.22) 89%,
      rgba(0,0,0,.08) 95%, transparent 100%);
    mask-image: linear-gradient(180deg,
      #000 0, #000 48%, rgba(0,0,0,.95) 58%, rgba(0,0,0,.84) 66%,
      rgba(0,0,0,.64) 74%, rgba(0,0,0,.42) 82%, rgba(0,0,0,.22) 89%,
      rgba(0,0,0,.08) 95%, transparent 100%);
  }

  /* Not the whole screen. The dialog is long, and filling the viewport edge to
     edge makes it read as a page you have navigated to rather than something
     over the list you were reading. It scrolls inside itself instead. */
  #newProjModal[open],
  #editProjModal[open] { max-height: 82dvh; }
  /* Stacked. The grid's own gap separates them — adding the stack's margin on
     top of it would double the space between these two and nothing else. */
  .new-proj-row { grid-template-columns: minmax(0, 1fr); }
}
