/* ============================================================
   Lunicle — content styles inside the darkness-toolkit shell.

   Every colour routes through the toolkit's flat `--t-<token>`
   palette (painted on :root by the shell's applyTheme on every
   theme change), so the board, the issue windows and the modals
   follow whichever theme the user picks — Lunamux Dark by
   default — in both appearances. The fallbacks are the original
   hand-written "Lunamux Dark" values, so the content is legible
   for the beat before the shell's first paint.

   NOTE: the --green* custom-property names are historical, kept
   from the pre-toolkit stylesheet (in step with lunamux-web) —
   they hold "primary/accent/dim text", not green.
   ============================================================ */

:root {
  --bg:        var(--t-bg, #04090f);          /* ground */
  /* NOTE on tokens: a lunamux pane's INTERIOR is painted in --t-surface
     (white-ish under Lemonade, raised navy under the dark themes) against
     --t-bg/--t-canvas chrome. Window interiors here (board, issue) use
     --panel for that reason, with --bg supplying the quieter tone for the
     columns sitting on them. Measured, not assumed: under Lemonade,
     --t-canvas equals --t-bg (the soft yellow) and --t-surface is the
     near-white — the reverse of what the names suggest. */
  --panel:     var(--t-surface, #070f1a);     /* surface — window interiors */
  --panel-2:   var(--t-surface-alt, #0b1626); /* raised surface */
  --green:     var(--t-text, #4dc8f5);        /* primary text */
  --green-hi:  var(--t-accent, #66d9ff);      /* bright accent */
  --green-dim: var(--t-text-dim, #5f7590);    /* secondary / comments */
  --border:    var(--t-border, rgba(77, 200, 245, .20));
  --border-hi: var(--t-accent-soft, rgba(77, 200, 245, .45));
  --glow:      var(--t-glow, rgba(77, 200, 245, .35));
  --red:       var(--t-danger, #ff5f57);
  --yellow:    var(--t-warn, #febc2e);        /* validation */
  --head:      var(--t-text-bright, #eaf3fb); /* headings and field text */
  --body:      var(--t-text, #cfe0ee);        /* running text */
  --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  /* Transparent rather than painted: inside the lunamux.dev iframe the
     surrounding page's background shows through, so the embed has no seam.
     Opened directly, the html background below supplies the ground. */
  background: transparent;
  color: var(--green);
  font-family: var(--mono);
  font-size: 15px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

html { background: var(--bg); }

/* ============================================================
   Scrollbars

   Painted from the palette rather than left to the browser. A native
   scrollbar takes its colour from `color-scheme`, which the shell's
   applyTheme sets from the *appearance* (Auto/Dark/Light) — not from the
   lightness of the palette on screen (see isDarkActive). Lunicle seeds
   appearance to Dark (main.kt), so a light theme like Lemonade paints a
   near-black track across the bottom of the board: light palette, dark
   scrollbar. Driving them off --t-* instead keeps them with whatever is
   actually on screen, whichever way that mismatch falls.

   Both spellings, and they are not redundant: scrollbar-color is the
   standard one (Firefox, Chrome 121+), ::-webkit-scrollbar is what Safari
   still needs. Neither the toolkit nor this stylesheet styled scrollbars
   before, so this fills a gap rather than overriding anything — which is
   also why it is unscoped: the shell's own scrolling chrome has the same
   problem, and a thumb that changed colour at the pane border would be
   worse than either tone alone.

   --green-dim (text-dim) for the thumb, not --border-hi: the border tokens
   are translucent by design and a .45-alpha thumb reads as a smudge. */
* {
  scrollbar-width: thin;
  scrollbar-color: var(--green-dim) transparent;
}

::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
/* Inset via a transparent border rather than a smaller thumb: background-clip
   keeps the fill off the border box, so the thumb has breathing room without
   the track having to be painted to supply it. */
::-webkit-scrollbar-thumb {
  background: var(--green-dim);
  background-clip: padding-box;
  border: 2px solid transparent;
  border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover { background: var(--green); }
::-webkit-scrollbar-corner { background: transparent; }

/* ============================================================
   Windows — Lunicle's content inside the darkness shell.

   The old page shell (layout grid, top bar, sticky footer) is
   gone: the toolkit owns the app frame, the top bar and the
   bottom bar now. What is left to lay out is the INSIDE of two
   window kinds — the board pane and the issue panes — plus the
   bits Lunicle contributes to the toolkit's chrome slots.
   ============================================================ */

/* The board window's content: switcher row on top, board taking
   the rest, scrolling in both directions inside the pane. */
.board-pane {
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: 14px 16px;
  min-width: 0;
  /* The surface tone — what a lunamux pane's interior is painted in
     (white-ish under Lemonade, raised navy under the dark themes). Without
     this the board interior kept the window-chrome tone and the columns
     supplied the surface one — the same two colours, inverted, which read
     as off against every lunamux pane. See the :root token note. */
  background: var(--panel);
}

.board-toolbar {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: none;
  padding-bottom: 12px;
}

/* The live filter box, pushed hard to the right of the toolbar. `margin-left:
   auto` takes all the slack between the gear and it, so it stays pinned to the
   far edge whatever sits to its left. It is a `.field`, so the surface, border
   and focus glow come for free; only its width and its place are set here. */
.board-filter {
  margin-left: auto;
  flex: none;
  width: 220px;
  max-width: 40%;
  padding: 8px 12px;
}

.board-pane .board {
  flex: 1;
  min-height: 0;
  /* The board scrolls sideways only; the vertical scroll belongs to each lane
     (.column-list below). Scrolling the whole board vertically carried the
     column heads off the top with the cards, so a long column left you reading
     nameless lists — the heads have to stay put while their own cards move. */
  overflow-x: auto;
  overflow-y: hidden;
}

/* An issue window's content: the body bands take the slack, the
   footer buttons stay pinned at the bottom of the pane. */
.issue-window-host { height: 100%; }

.issue-window {
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: 12px 16px;
  min-width: 0;
  /* Surface interior, same reasoning as .board-pane: an issue window is a
     document being read, and documents live on the content surface. */
  background: var(--panel);
}

/* The body reuses the modal-large band layout (.modal-body /
   .issue-fields / .issue-scroll / .editor-host below) — the
   issue window IS the old issue dialog, re-housed. */
.issue-window .modal-body {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.issue-footer {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: none;
  padding-top: 10px;
  border-top: 1px solid var(--border);
}

/* The bar's padding, rebalanced around the maximised board. The pane's
   border sits 12px in from the frame edge (4px maximised-pane inset + the
   8px frame pad), and that 12 is the measure the bar's content has to
   agree with on every side: the same 12 above the text (the inset and the
   frame pad supply it — the bar adds nothing on top), 12 below it, and 12
   from the left edge to the brand icon so the brand lines up with the
   pane's border rather than floating at its own indent. The toolkit's own
   2px/16px is tuned for a bar inside busier chrome; here the bar borders
   the pane directly and the pane sets the grid.

   Doubled ancestor selector for the same reason as .dt-modal-backdrop
   below: the toolkit's sheet is injected after this one, so a bare
   .dt-bottombar here would lose the tie. */
.dt-app-frame-bottombar .dt-bottombar {
  padding: 0 12px 12px;
}

/* The "+" menu's rows, back in the chrome's proportional face.
   The toolkit gives .dt-hover-menu-item `font: inherit` and mounts the menu
   on the body, so Lunicle's mono body font — meant for the CONTENT, the board
   and the issue windows — leaked into it, and "New issue"/"New project" came
   out fixed-width while every other piece of chrome (sidebar, top bar,
   tooltips) stayed proportional. Naming the family here on the menu box, not
   the item, is enough: `font: inherit` then picks it up. Same fallback stack
   the toolkit's own sidebar rule carries, so a theme that sets
   --dt-font-sidebar still wins and the menu matches the sidebar either way. */
.dt-hover-menu {
  font-family: var(--dt-font-sidebar, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif);
}

/* The brand line on the topbar's leading edge — what the old sticky footer
   said, then the bottom bar, now here (see AppShellSpec.topbarLeading). The
   bottom bar held nothing else, so carrying this was the only reason it
   existed. No padding of its own: the topbar's leading cluster already
   places it, and padding here would push it off the edge the account corner
   lines up against. */
.topbar-brand {
  display: flex;
  align-items: center;
  gap: 8px;
  /* The topbar's leading and trailing clusters are each pinned to the top of
     its content box (align-self: flex-start, so they stay on row 1 when a tab
     strip wraps). That means neither centres in the BAR — each centres its own
     content in its OWN height, so two clusters line up only when they are the
     same height. This 11px line is ~14px tall on its own; the row opposite is
     30px (.dt-topbar-icon-button, and .account matching it). Hence 30 here.
     Keep this and .account's min-height equal — that equality IS the
     alignment, and either one alone just moves the misalignment around. */
  min-height: 30px;
}

.topbar-brand-name {
  color: var(--green-dim);
  font-size: 11px;
  letter-spacing: .08em;
  text-transform: uppercase;
  /* The topbar is a fixed-height strip, unlike the bottom bar this came from
     — the line must not wrap the chrome taller on a narrow window. */
  white-space: nowrap;
}

/* The sign-in/profile corner, now living in the toolkit's
   trailing top-bar slot. */
.topbar-account {
  display: flex;
  align-items: center;
  min-width: 0;
}

/* The closed face of a Dropdown (see Dom.kt) — a <button> the user reads as a
   value with a chevron. It is not a <select>: `appearance: none` takes the
   platform's chrome off the *closed* control, but the OS draws the *open* list
   itself — an Aqua popup with a system-blue selection, in nobody's palette but
   the OS's — and that surface a stylesheet cannot reach. So the open list is our
   own themed menu (.dt-hover-menu, mounted on the body), and the closed control
   is this button, dressed to read exactly as the old <select> did.

   This base carries only what both variants share: the native-chrome reset, the
   chevron, and left-aligned text (a button centres its label; a value belongs at
   the leading edge, where the menu's rows start beneath it). Surface — border,
   radius, padding, background — is the variant's own, because the two live on
   different surfaces on purpose: .picker sits in the top bar on --panel-2,
   .dropdown.field sits in a dialog beside text inputs on --bg.

   Deliberately no background-COLOR here, only the arrow image: a colour here
   would paint over whichever surface the variant sets below. */
.dropdown {
  appearance: none;
  -webkit-appearance: none;
  font-family: inherit;
  color: var(--head);
  text-align: left;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%2366d9ff' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 14px center;
  background-size: 12px 8px;
  cursor: pointer;
}

.picker {
  font-size: 14px;
  background-color: var(--panel-2);
  border: 1px solid var(--border-hi);
  border-radius: 4px;
  padding: 8px 38px 8px 12px;
  min-width: 200px;
}

/* The three dropdowns in the issue editor. `.field` brings the text-input
   surface (border, radius, padding, --bg) so they sit level with the title
   input beside them; this only reserves room for the chevron on the right.
   The arrow's inset and the padding are one measurement made twice and must
   agree — a padding smaller than inset + arrow width lets a long name run
   under the arrow. Keep padding-right >= 14 + 12 + a few px of air. */
.dropdown.field {
  padding-right: 38px;
}

.picker:focus,
.dropdown:focus { outline: none; border-color: var(--green-hi); box-shadow: 0 0 10px var(--glow); }

/* The open list needs no chrome rules here: it is a .dt-hover-menu, so the
   toolkit paints it as it paints the shell's "+" menu, under every theme,
   without us. Its width is set inline to the control it drops out of (see
   Dropdown.open) — the toolkit's 180px floor is tuned for a topbar icon's menu,
   and a menu narrower than its own button reads as a different control's. */

.icon-btn {
  font-family: inherit;
  line-height: 1;
  color: var(--green-hi);
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 4px;
  width: 36px;
  height: 36px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: border-color .12s, box-shadow .12s;
}

.icon-btn:hover { border-color: var(--border-hi); box-shadow: 0 0 12px var(--glow); }

/* ============================================================
   Icons — the hand-drawn SVGs. See Icons.kt.

   The span is what gets sized; the svg fills it. Sizing the svg
   from outside means every caller repeats a width AND a height,
   and one of them eventually gets forgotten.

   `stroke: currentColor` in the markup is the whole trick: an
   icon takes the colour of the button around it, so hover,
   :disabled and the palette all reach it with no rules here.
   ============================================================ */

.icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none; /* or a flex parent squashes the icon before it wraps the text */
}

.icon > svg {
  width: 100%;
  height: 100%;
  display: block; /* an inline svg sits on the text baseline and adds descender space */
}

/* 20 in a 36px button. The emoji this replaced was set at font-size 15px and
   drew nothing like 15px — a glyph's ink fills whatever fraction of its em box
   the font decided, which is why the cogwheel read as small in a box that was
   already the right size. A vector has no such gap: 20px of viewBox is 20px of
   drawing. */
.icon-gear { width: 20px; height: 20px; }
/* 16, matching the size .dt-topbar-icon-button forces on every svg it wraps:
   this one sits in the topbar's trailing slot with that whole row of toolkit
   icons, and an icon there is read against its neighbours, not against the
   36px .icon-btn the 20-in-36 note above is about. It is also what keeps
   .account-btn inside .account's 30px row — 16 + 10 padding + 2 border = 28. */
.icon-profile { width: 16px; height: 16px; }
.icon-logo { width: 18px; height: 18px; color: var(--green-hi); }
/* Sits inside .agent-badge beside 12px text; 13px keeps the drawing level with
   the cap height rather than towering over it. Colour comes from the badge. */
.icon-agent { width: 13px; height: 13px; }

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

/* The centring is load-bearing, not decoration. A button's text centres itself
   only while the button is a *button* box; the moment the views show one with
   `visible(displayValue = "inline-flex")` it becomes a flex container, and a
   flex container ignores text-align and packs its line box at flex-start. With
   .modal-footer's min-width the box is wider than the word, so "OK" sat left of
   centre while "Cancel" — never toggled, so never inline-flex — stayed centred.
   Declaring the flex box here means every .btn is the same box whether or not a
   view has toggled it, and justify-content is what centres a flex line. */
/* The .dt-modal-btn recipe from darkness-toolkit.css, applied to every Lunicle
   button so a button in an issue window is indistinguishable from one in a
   toolkit dialog: 7px/18px padding, 6px radius, 13px, quiet --t-bg surface
   with a --t-border border. Primary is the toolkit's confirm shape — accent
   fill, bg-coloured label, weight 600, brightness hover. Danger is the
   toolkit's muted `.dt-destructive` treatment — a translucent danger tint
   with danger text/border — rather than the old solid-red border. */
.btn {
  font-family: inherit;
  font-size: 13px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--green);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 7px 18px;
  cursor: pointer;
  appearance: none;
  transition: background .12s, border-color .12s, box-shadow .12s, color .12s, filter .12s;
}

/* Hover LIGHTS UP, deliberately beyond the dt recipe's surface swap. The
   resting shape still matches a toolkit dialog's, but --bg → --panel is one
   near-black to a neighbouring near-black and read as a dead button; the
   border and the glow are what the eye actually catches, same treatment the
   lunamux site's buttons use. */
.btn:hover:not(:disabled) {
  background: var(--panel);
  border-color: var(--border-hi);
  box-shadow: 0 0 10px var(--glow);
}
.btn:disabled { opacity: .5; cursor: default; }

.btn-primary {
  background: var(--green-hi);
  color: var(--bg);
  border-color: transparent;
  font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
  background: var(--green-hi);
  border-color: transparent;
  filter: brightness(1.12);
  box-shadow: 0 0 14px var(--glow);
}
.btn-quiet { color: var(--green-dim); background: transparent; }
/* A quiet button rests on whatever surface its dialog painted, so the base
   hover's --panel fill can vanish into it — a translucent accent wash shows
   on every surface, and the dim label brightens with it. */
.btn-quiet:hover:not(:disabled) {
  color: var(--green-hi);
  background: color-mix(in srgb, var(--green-hi) 8%, transparent);
}
.btn-danger {
  background: color-mix(in srgb, var(--red) 14%, transparent);
  color: var(--red);
  border-color: color-mix(in srgb, var(--red) 45%, transparent);
}
/* The danger variants restate border and glow in their own colour: without
   this the base hover paints a cyan edge and cyan halo on a red button. */
.btn-danger:hover:not(:disabled) {
  background: color-mix(in srgb, var(--red) 26%, transparent);
  border-color: color-mix(in srgb, var(--red) 60%, transparent);
  box-shadow: 0 0 10px color-mix(in srgb, var(--red) 30%, transparent);
}
.btn-danger-quiet {
  color: var(--red);
  border-color: color-mix(in srgb, var(--red) 30%, transparent);
  background: transparent;
}
.btn-danger-quiet:hover:not(:disabled) {
  background: color-mix(in srgb, var(--red) 12%, transparent);
  border-color: color-mix(in srgb, var(--red) 55%, transparent);
  box-shadow: 0 0 10px color-mix(in srgb, var(--red) 25%, transparent);
}

/* A button that reads as a link — for per-comment actions, which must be
   available without competing with the dialog's real buttons. */
.link-btn {
  font-family: inherit;
  font-size: 11px;
  color: var(--green-dim);
  background: none;
  border: none;
  padding: 0 6px;
  cursor: pointer;
  text-decoration: underline;
}
.link-btn:hover { color: var(--green-hi); }
.link-btn-danger:hover { color: var(--red); }

/* ============================================================
   Board
   ============================================================ */

.board {
  display: flex;
  gap: 12px;
  /* `stretch`, not `flex-start`: the columns fill the board's height so each can
     own its own vertical scroll (see .column-list). Left at flex-start every
     column shrank to its cards and the board itself had to scroll. */
  align-items: stretch;
  /* The columns scroll sideways rather than squeezing. The default board is five
     statuses and a project can add more, so the count is not ours to bound —
     and narrowing columns to fit whatever it is makes every card unreadable. */
  overflow-x: auto;
  padding-bottom: 20px;
  flex: 1;
  min-height: 0;
}

/* `flex: 1 1 220px` — grow to share the width, but never shrink below 220px.

   The three parts each do a job, and the middle one is the one worth naming:
     - grow 1: the columns divide whatever width is left between them, so a wide
       monitor gets wide columns rather than a 1100px board and a lake of empty
       space to its right.
     - shrink 1 with min-width: they may not squeeze past 220px. Without the
       min-width, `flex-shrink` would happily compress eight columns into a
       phone's width and every card would be one word per line.
     - basis 220px: the width they start from, and therefore the width they all
       agree on before any growing happens.

   Below that floor .board's `overflow-x: auto` takes over and the board scrolls,
   which is the behaviour that was here before — it is now the fallback rather
   than the norm. */
.column {
  /* The ground tone, quieter than the surface the board interior now paints
     — so the columns recede into the window the way chrome recedes behind a
     lunamux pane, instead of glowing brighter than their container. */
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 10px;
  min-width: 220px;
  flex: 1 1 220px;
  transition: border-color .12s, background .12s;
  /* A column of its own: the head pins, the list (below) takes the slack and
     scrolls. `min-height: 0` lets that list actually shrink below its content
     — without it a flex child refuses to, and the scroll never engages. */
  display: flex;
  flex-direction: column;
  min-height: 0;
}

/* The drop target, while something is over it. Without this, dragging is
   guesswork — nothing else tells you which column will take the card.

   An accent wash, not a fill: the old #0a1524 was a fixed navy, which is the
   ground tone of exactly one theme. Under Lemonade it dropped a black slab on
   a yellow board. Washing the accent over whatever --bg the column already
   paints tints the column instead of replacing it — same reasoning as
   .btn-quiet:hover. */
.column-drop {
  border-color: var(--green-hi);
  background: color-mix(in srgb, var(--green-hi) 10%, transparent);
}

.column-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 2px 4px 10px;
  border-bottom: 1px solid var(--border);
  margin-bottom: 10px;
}

.column-name {
  color: var(--green-hi);
  font-size: 11px;
  letter-spacing: .1em;
  text-transform: uppercase;
}

.column-count { color: var(--green-dim); font-size: 11px; }

/* Takes the column's slack and scrolls its own cards, so a long lane moves
   under a head that stays put. `min-height: 0` for the same reason as .column:
   a flex child needs it before `overflow-y: auto` will engage. */
.column-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-height: 40px;
  flex: 1;
  overflow-y: auto;
}

/* The header over each run of cards — a priority's name, or a resolution's in a
   closing column. See BoardColumn.groups.

   Quieter than .column-name, and that hierarchy is the point: the column head is
   the thing you navigate by, and a group header that shouted as loudly would make
   a five-column board read as fifteen. Same uppercase letterspacing so they read
   as the same *kind* of thing, two steps down in weight.

   The negative top margin pulls each header closer to the cards under it than to
   the ones above, against .column-list's uniform 8px gap. Without it the header
   floats equidistant between two groups and stops belonging to either — the
   oldest bug in list typography. First-child is exempt: there is nothing above
   it to pull away from. */
.group-head {
  margin: 6px 0 -2px;
  padding: 0 2px;
  color: var(--green-dim);
  font-size: 9.5px;
  letter-spacing: .12em;
  text-transform: uppercase;
}

.group-head:first-child { margin-top: 0; }

/* The resolution picker's buttons, stacked full width — the same shape as the
   provider picker's, for the same reason: they are the choice, not a preview of
   it. See ResolutionDialog. */
.resolution-choices {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 16px;
}

.resolution-choice { width: 100%; padding: 11px 16px; }

.card {
  /* The surface tone — same as a terminal pane's interior (and this board
     window's own background). On the --bg-toned column the card reads as a
     bright slip of the content surface, which is the most readable pairing
     the palette offers: under Lemonade a near-white card on the soft-yellow
     column, instead of the deeper-yellow-on-yellow that --panel-2 gave. */
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 10px;
  cursor: pointer;
  transition: border-color .12s;
}

.card:hover { border-color: var(--border-hi); }
.card-dragging { opacity: .4; }

/* Where the card will land. A line above or below the card under the pointer,
   decided by which half of it the pointer is in — see makeReorderTarget.

   An inset box-shadow rather than a border: a border would change the card's
   height by 2px and nudge every card below it down as the pointer crossed the
   midpoint, so the list would twitch under the drag. An inset shadow paints
   inside the existing box and moves nothing.

   Cyan, matching .column-drop's border: both answer "where does this land?", and
   they are frequently on screen together — the column lights up while a card in
   it shows the line. */
.card-drop-before { box-shadow: inset 0 2px 0 0 var(--green-hi); }
.card-drop-after { box-shadow: inset 0 -2px 0 0 var(--green-hi); }

/* Still clickable — a reader may open it — but it does not offer to move. */
.card-readonly { cursor: pointer; }

/* Three lines, then an ellipsis. A title is free text and someone will paste a
   paragraph into one; a card that grows to fit pushes every card below it down
   and turns the column into a wall.

   `-webkit-line-clamp` with `display: -webkit-box` is the only thing that clamps
   by LINE rather than by height, and despite the prefix it is not a WebKit
   quirk — Firefox and Chrome both implement it under these exact names, which is
   why the prefixed spelling is the portable one here. Clamping by `max-height`
   instead would cut the fourth line in half rather than removing it, and would
   have to be kept in step with line-height by hand.

   The full title is in the tooltip and in the issue dialog, so nothing is
   unreachable — see .card-title's title attribute in MainView. */
.card-title {
  margin: 0;
  color: var(--body);
  font-size: 12.5px;
  line-height: 1.45;
  word-break: break-word;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }

.tag {
  font-size: 12px;
  padding: 2px 7px;
  border-radius: 3px;
  border: 1px solid var(--border);
  color: var(--green-dim);
}

.tag-label { border-color: rgba(77, 200, 245, .4); color: var(--green-hi); }
.tag-component { border-color: rgba(95, 168, 255, .4); color: #5fa8ff; }
.tag-status { border-color: var(--border-hi); color: var(--green-hi); }
/* Yellow, alone among the tags. It is the one that carries urgency, and the
   palette already uses --yellow for "pay attention" (see .field-validation). */
.tag-priority { border-color: rgba(240, 200, 90, .4); color: var(--yellow); }
/* Dimmer than the rest. A resolution only exists on a closed issue, and a closed
   issue is finished business — it should not be the brightest thing on the
   card. */
.tag-resolution { border-color: var(--border); color: var(--green-dim); }

/* The "made by an agent" badge, on an issue byline or a comment head.

   Deliberately NOT a .tag. The tags catalogue what an issue IS — its labels, its
   status — and share the outlined, unfilled treatment for that reason. This flags
   where a row came FROM, which is a louder thing to say, so it is filled and
   carries an icon the tags do not.

   The colour is a violet the rest of the palette never reaches. Green, blue and
   yellow are all "a human working"; a fourth hue that belongs to nothing else is
   how the eye is told, before it reads a word, that this row was not typed by
   hand. Not a --token because there is no theme slot for "machine" and inventing
   one would let a theme quietly turn the signal green — but it still has to hold
   in both themes, so `light-dark()` picks the shade off the `color-scheme` the
   toolkit sets on :root: a deep violet on a light board, a bright one on a dark
   one. A single fixed violet washed out on whichever background it was not tuned
   for. The icon takes `currentColor`, so it follows the text either way. */
.agent-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 12px;
  padding: 2px 7px 2px 5px;
  border-radius: 3px;
  border: 1px solid light-dark(rgba(124, 58, 237, .55), rgba(185, 140, 255, .5));
  background: light-dark(rgba(124, 58, 237, .1), rgba(185, 140, 255, .13));
  color: light-dark(#6d28d9, #c4a2ff);
  white-space: nowrap;
}
.agent-badge-label { line-height: 1; }

/* On the issue, the badge is its own line under the byline. Pulled up close to
   the byline it belongs to (which carries 14px below it), and carrying that 14px
   itself so the fields below keep their spacing whether or not the badge is
   shown. */
.issue-fields > .agent-badge { margin: -8px 0 14px; }

.board-empty { color: var(--green-dim); font-size: 13px; }

/* ============================================================
   Modals
   ============================================================ */

/* Lunicle modals ride the toolkit's `.dt-modal*` classes for their entire
   visual identity — surface, border, shadow, title, message, button row — so
   they are indistinguishable from the darkness/lunamux dialogs and track any
   toolkit restyle for free (see Modal.kt). The rules below carry only what dt
   deliberately doesn't decide for us: our width band, and a scroll-friendly
   backdrop. The doubled selectors are for specificity — the toolkit's styles
   are injected at mount time, AFTER this sheet, so a single-class rule here
   would lose every tie. */

.dt-modal-backdrop.modal-backdrop {
  /* flex-start + margin:auto on the panel, instead of dt's center: a flex
     item centred that way and taller than the viewport overflows BOTH ends,
     and the top half becomes unreachable — the scroll container will not
     scroll above its content's start. Auto margins collapse to zero instead
     of going negative, so a tall dialog pins to the top and scrolls
     normally, while a short one still sits in the middle. */
  align-items: flex-start;
  padding: 40px 20px;
  overflow-y: auto;
  /* Same layer as the toolkit's own .dt-modal-backdrop (10000): a maximised
     darkness pane is `z-index: 1000 !important`, and at the old page-era 10
     the sign-in picker opened BEHIND the maximised board. One shared layer
     means whichever dialog opened last paints on top (both mount into body
     in open order). */
  z-index: 10000;
}

.dt-modal.modal {
  width: 100%;
  max-width: 640px;
  margin: auto; /* the vertical centring — see the backdrop comment above */
}

/* The prose dialogs — an issue with its comments, and a comment being written.

   They differ from the default panel in two ways, and the second is the point:
   they are WIDER, and they do not GROW. The default panel is fit-content and
   lets the backdrop scroll, which is right for a form of six fields and wrong
   for a thread: the panel got taller with every comment, so the title scrolled
   off the top and the OK button sat somewhere below the fold, and reaching
   either meant scrolling the whole dialog rather than the thing you were
   reading.

   Fixed height instead, with the body scrolling inside it. `height` and not
   `max-height`: at max-height a short issue still produces a short panel, so the
   dialog changes size as you click between issues — and the buttons move under
   the cursor. A constant panel is worth more than a snug one here.

   dvh, not vh: on a mobile browser vh counts the URL bar's space even while the
   bar is covering it, which puts the footer under it. The 80px is the backdrop's
   40px padding, twice. */
.dt-modal.modal-large {
  max-width: 880px;
  height: calc(100dvh - 80px);
  /* Beyond this the measure gets unreadable and the buttons end up a long way
     from the text, on a desktop monitor in particular. */
  max-height: 860px;
  /* So .modal-body can be the flex child that takes the slack and scrolls. */
  display: flex;
  flex-direction: column;
}

/* The body is the flex child that takes the slack — but it does NOT scroll.
   Its children do, and which one depends on the face being shown: the editor's
   surface while editing, .issue-scroll while reading. See below.

   min-height: 0 is load-bearing, not defensive. A flex item's default
   `min-height: auto` refuses to shrink below its content, so any overflow-y
   below this would never engage — the body would size to its whole content and
   push the footer out of the panel, which is the bug this section fixes. It has
   to be repeated on every nested flex child for the same reason; that is why it
   appears four times between here and .editor-surface. */
.modal-large .modal-body {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/* The issue's identity: title, status, priority, labels. Fixed — it does not
   scroll away.

   This is the whole point of the three-band split. Before it, the modal body
   scrolled as one, so typing into a long description pushed the title, the
   status and the priority off the top of the dialog: the fields describing the
   thing you were editing were only visible when you were not editing it. */
.issue-fields { flex: none; }

/* The reading face — rendered description plus the comment thread — scrolling as
   one. The fields above it stay put. */
.issue-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  /* The scrollbar's gutter — same reasoning as .settings-body: it is laid out
     inside this element, so without this the comment cards' right border ends
     exactly where the thumb starts. .editor-surface, the other face of this
     window, needs nothing: its own 12px padding already supplies the room. */
  padding-right: 14px;
}

/* Emptying it is not enough. Every child of the reading face hides itself while
   editing, but the box stays, and `flex: 1` on an empty box still claims its
   half of the slack next to .editor-host — which is why the editor stopped
   halfway down the dialog with nothing under it. */
.modal-body.editing .issue-scroll { display: none; }

/* The editor fills the rest of the dialog while editing, and its SURFACE is what
   scrolls. The chain of `flex: 1; min-height: 0` from .modal-body down to
   .editor-surface is what carries the height: break it at any link and the
   editor either collapses to nothing or grows until the footer is gone. */
/* `display: flex` belongs here and not only on the issue dialog's inline toggle.
   The issue dialog shows and hides this box by face and so sets the display
   itself (see IssueDialog.render); the comment dialog is only ever editing and
   has nothing to toggle, so it inherited a plain block, `flex: 1` on .editor
   meant nothing, and the editor sat at content height in a dialog twice its
   size. The inline `none`/`flex` still outranks this, so the issue dialog is
   unaffected. */
.editor-host {
  flex: 1;
  min-height: 0;
  display: flex;
}

.editor-host .editor {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.editor-host .editor-toolbar { flex: none; }

.editor-host .editor-surface {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
}

/* A file being dragged over the editor: the surface advertises itself as the
   drop target. The class rides on .editor (the root) — see MarkdownEditor's
   drop wiring. */
.editor-drop .editor-surface {
  outline: 2px dashed var(--green-hi);
  outline-offset: -4px;
}

/* Status and priority share a row — but the two faces of the dialog want
   genuinely different rows, and giving them the same one was a bug worth naming.

   READING is the default here: two tags, side by side, sized to their words. The
   grid below applied to read mode too, which put "New" in a half-width track and
   "Normal" marooned at the midpoint of the dialog with a hand's width of nothing
   between them — two labels pretending to be columns of a table that does not
   exist. */
.field-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}

/* EDITING wants the grid: two full-width selects sized to their content would
   jump between "New" and "Ready for development" as the value changed, so the
   tracks are equal and fixed. */
/* auto-fit with a floor, not a fixed `1fr 1fr` or `1fr 1fr 1fr`: the row holds
   two cells or three depending on whether the chosen status demands a resolution,
   and the resolution cell appears and disappears as the status changes. A fixed
   three-track grid would leave a hole where it was; a fixed two-track one would
   wrap it to a second line. This sizes to whatever is actually there, and drops
   to fewer columns rather than crushing them when the dialog is narrow. */
/* The row owes its cells the 16px their labels cannot claim: a cell's <label> is
   its first child, so `.field-label:first-child` zeroes the margin every other
   label in the dialog uses to stand off the field above it. Without this, STATUS
   sits on top of the title input. */
.modal-body.editing .field-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 14px;
  align-items: start;
  margin-top: 16px;
}

.field-cell { min-width: 0; }

/* Reading, the row is two or three cells of "CAPTION Tag" sitting next to each
   other, so the space BETWEEN cells has to beat the space inside one — at the
   8px the row uses by default, "PRIORITY" was as close to the status tag on its
   left as to the priority tag it names on its right, which is precisely the
   ambiguity the captions exist to remove. */
.modal-body:not(.editing) .field-row { gap: 20px; }

/* The read face's answer to .field-label-edit: the <label> above each select
   names it while editing, this names the tag while reading. Neither is ever
   visible at the same time as the other. Kept `inline` rather than made a flex
   row on .field-cell on purpose — the resolution cell's display is driven by an
   inline style (see render()), which no rule here can outrank. An inline caption
   next to an inline-block tag flows onto one line inside a plain block cell, so
   the layout does not depend on the cell's display at all. */
.tag-caption { display: none; }

.modal-body:not(.editing) .tag-caption {
  display: inline;
  margin-right: 7px;
  color: var(--green-dim);
  font-size: 11px;
  letter-spacing: .1em;
  text-transform: uppercase;
  vertical-align: middle;
}

/* Against a caption the tag's baseline sits low — it is an inline-block with
   its own padding, so its baseline is its last line's, not its box's centre. */
.issue-fields .field-cell .tag { vertical-align: middle; }

/* Sized for a dialog, not for a card. .tag's 10px is right on a board where a
   card carries four of them and the title is what matters; here they are two of
   the six facts about the issue you are reading, and they were rendering as
   fine print. */
.issue-fields .tag {
  font-size: 11px;
  padding: 3px 8px;
  line-height: 1.5;
}

/* The label above the FIRST field, which sits directly under the title bar and
   needs no separation from it.

   `:first-of-type` rather than `:first-child`, because in edit mode the first
   child is the read-mode <h3> — hidden, but still first — so `:first-child`
   matched nothing and the gap stayed.

   The `>` is load-bearing and was missing. `:first-of-type` is scoped to an
   element's own PARENT, not to the ancestor named on the left of the selector, so
   without the child combinator this also matched the first label inside each
   `.field-cell` — which is every one of them, since a cell holds exactly one
   label. STATUS and PRIORITY lost their 16px and sat jammed under the title
   field. The rule is about one label at the top of the dialog; the combinator is
   what says so. */
.issue-fields > label.field-label:first-of-type { margin-top: 0; }

/* Title, message and button-row visuals come from the toolkit's
   .dt-modal-title / .dt-modal-message / .dt-modal-buttons — see Modal.kt.
   What remains here is layout the dt rules don't decide: the body band
   between title and buttons, and the footer's vertical rhythm. */

.modal-body { padding: 12px 0 0; }

.dt-modal-buttons.modal-footer {
  align-items: center;
  margin-top: 16px;
}

/* A floor, not a fixed width. The footer buttons already share .btn's padding,
   so they agree on height; what they disagreed on was width, because that came
   from the text and "OK" is two characters against "Cancel"'s six. The dismissal
   thereby ended up the bigger target of the pair, which is backwards. A min-width
   evens the common cases without truncating a longer label like "Delete project",
   which simply grows past it. */
.modal-footer .btn { min-width: 92px; }

.modal-footer-spacer { flex: 1; }
.modal-error { color: var(--red); font-size: 12px; margin: 12px 0 0; }

/* ============================================================
   Fields
   ============================================================ */

.field-label {
  display: block;
  color: var(--green-dim);
  font-size: 12px;
  letter-spacing: .1em;
  text-transform: uppercase;
  margin: 16px 0 6px;
}

.field-label:first-child { margin-top: 0; }

/* Everything that only belongs in edit mode rides on one class on the body,
   rather than being toggled element by element — there are six of them, and
   forgetting one leaves a stray "Components" heading over a read-only issue. */
.field-label-edit { display: none; }
.modal-body.editing .field-label-edit { display: block; }

.field {
  width: 100%;
  font-family: inherit;
  font-size: 15px;
  color: var(--head);
  /* `background-color`, not the `background` shorthand: a .field is also worn by
     the issue editor's dropdowns (.dropdown.field), and .dropdown draws its
     chevron with `background-image`. The shorthand resets background-image to
     none, and since this rule follows .dropdown at equal specificity it would
     win and strip the arrow off every editor picker — which is exactly what it
     did. The color longhand leaves the image alone. */
  background-color: var(--bg);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 9px 11px;
}

.field:focus { outline: none; border-color: var(--green-hi); box-shadow: 0 0 10px var(--glow); }

/* Ghost text, and it has to *look* ghosted. Unstyled, Chrome picks its own grey
   — tuned for a white page, and bright enough against this near-black --bg that
   a placeholder read as a filled-in value: the project dialog appeared to come
   pre-filled with "Lunamux" / "LMX". --green-dim is what every other piece of
   secondary text here uses (.field-hint, .field-label), so an example now reads
   as an example. */
.field::placeholder { color: var(--green-dim); opacity: 1; }
.field-multiline { min-height: 180px; resize: vertical; line-height: 1.6; }

.field-hint { color: var(--green-dim); font-size: 12px; margin: 6px 0 0; }
.field-validation { color: var(--yellow); font-size: 13px; margin: 12px 0 0; }

/* A boolean and its label on one line, darkness's On/Off pair standing where
   the checkbox used to. See Dom.kt's Toggle.

   Label leads, pair trails. The label takes the slack, so the pairs land on a
   common right edge and a column of them reads as one control per row rather
   than as a ragged line of buttons each at its own indent. The pair keeps its
   intrinsic width (`flex: none` on .toggle), so a long label — "Public —
   anyone can read this project's issues…" — wraps and pushes the row taller
   rather than squeezing the buttons.

   `align-items: center` reads right for the one-line labels; a wrapped label
   centres against the pair, which beats the checkbox row's old top-alignment
   now that the control is 28px tall rather than a 13px box. */
.toggle-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 18px 0 0;
  color: var(--body);
  font-size: 13px;
}

.toggle-label { flex: 1; min-width: 0; }

/* The pair itself. The buttons are the toolkit's own .dt-settings-choice-btn,
   so they arrive themed; only the row's own gap is ours, tightened from the
   sidebar's 6px because On/Off is a pair rather than a list of choices. */
.toggle {
  flex: none;
  gap: 4px;
}

/* The toolkit sizes its choice buttons for a settings sidebar, where they are
   the only thing on the row. In a dialog they sit next to running text and a
   14px pad on a two-letter word left them looking like Submit buttons — so:
   narrower, and a fixed min-width so "On" and "Off" come out the same size
   rather than one being visibly slimmer than its twin. */
.toggle .dt-settings-choice-btn {
  padding: 5px 0;
  min-width: 44px;
  text-align: center;
}

/* A dead toggle still has to look like one — see .vocab-delete below for the
   same reasoning. Dim both sides, and say with the cursor that the answer to a
   click is no, rather than leaving the accented side looking live. */
.toggle .dt-settings-choice-btn:disabled {
  opacity: .45;
  cursor: not-allowed;
}

/* Label/component pickers. Buttons rather than a multi-select, which is
   unusable on touch and invisible about what is selected. */
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }

.chip {
  font-family: inherit;
  font-size: 12px;
  color: var(--green-dim);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 4px 10px;
  cursor: pointer;
  transition: border-color .12s, color .12s;
}

/* Scoped off the on-state: an unscoped .chip:hover would out-specify .chip-on
   and dim a selected chip's solid accent border back to the translucent one
   the moment the mouse reached it. */
.chip:not(.chip-on):hover { border-color: var(--border-hi); color: var(--green); }

.chip-on { color: var(--head); border-color: var(--green-hi); background: color-mix(in srgb, var(--green-hi) 14%, transparent); }

/* ============================================================
   Editor
   ============================================================ */

.editor { border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }

.editor-toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  padding: 6px;
  background: var(--panel-2);
  border-bottom: 1px solid var(--border);
}

.editor-btn {
  font-family: inherit;
  font-size: 12px;
  min-width: 26px;
  color: var(--green-dim);
  background: transparent;
  border: 1px solid transparent;
  border-radius: 3px;
  padding: 4px 7px;
  cursor: pointer;
}

.editor-btn:hover { color: var(--green-hi); border-color: var(--border); }

/* The formatting the caret is currently inside. See refreshToolbar().

   This has to out-shout :hover, and it is the same treatment .chip-on uses for
   the same reason — "this is on" and "your mouse is here" are different claims,
   and the first one has to survive the second. Filled and bordered rather than
   only re-coloured: hover already re-colours, so a colour-only "on" state is
   indistinguishable from hovering, and the button would go back to looking off
   the moment you moved the mouse to read it. */
.editor-btn-on {
  color: var(--head);
  border-color: var(--green-hi);
  background: color-mix(in srgb, var(--green-hi) 14%, transparent);
}

.editor-btn-on:hover { color: var(--head); border-color: var(--green-hi); }

/* The way out of every other button, so it is set apart from the set it undoes.
   A margin alone would read as a gap rather than as a division, hence the rule.

   The rule is a pseudo-element and NOT the button's own right border, which is
   what this was first: a border-right in --border, restated at higher
   specificity so it would survive .editor-btn-on recolouring the other three.
   That worked exactly as written and looked broken — Plain lights up whenever
   the caret is in ordinary text, so most of the time it drew three --green-hi
   edges and one dim one, which does not read as "separator", it reads as a
   border that got cut off. The divider is not part of the button, so it is not
   drawn as part of the button. */
.editor-btn-plain {
  position: relative;
  margin-right: 9px;
}

.editor-btn-plain::after {
  content: "";
  position: absolute;
  /* Centred in the 9px margin: 4px of gap either side of a 1px rule. */
  right: -5px;
  top: 50%;
  transform: translateY(-50%);
  /* Short of full height, so it reads as a divider between buttons rather than
     as an edge belonging to one of them. */
  height: 60%;
  border-right: 1px solid var(--border);
}

/* The toolbar buttons look like what they do — the conventional cue, and the
   only thing that makes a one-letter button readable. */
.editor-btn-bold { font-weight: 700; }
.editor-btn-italic { font-style: italic; }
.editor-btn-underline { text-decoration: underline; }
.editor-btn-strike { text-decoration: line-through; }

.editor-btn:disabled { opacity: .4; cursor: default; }
.editor-btn:disabled:hover { color: var(--green-dim); border-color: transparent; }

/* The editable surface. It carries .markdown too, so what is being typed is
   drawn by the very same rules that draw the issue once it is saved — which is
   the entire claim a WYSIWYG editor makes, and it only holds while there is one
   stylesheet for both. */
.editor-surface {
  padding: 12px;
  min-height: 180px;
  background: var(--bg);
  outline: none;
  overflow-wrap: break-word;
}

.editor-surface:focus { box-shadow: inset 0 0 0 1px var(--border-hi); }

/* contenteditable has no placeholder attribute, so this draws one. :empty is
   true only with no child nodes at all, which is exactly the state the surface
   is in before the first keystroke. */
.editor-surface:empty::before {
  content: attr(data-placeholder);
  color: var(--green-dim);
  opacity: .6;
}

.editor-disabled .editor-surface { opacity: .5; }

.editor-link-row {
  display: flex;
  gap: 6px;
  padding: 6px;
  background: var(--panel-2);
  border-bottom: 1px solid var(--border);
}

.editor-link-row .field { flex: 1; }

/* ============================================================
   Rendered markdown

   Everything in here was produced by renderMarkdown, which emits
   only tags it built itself. See client/Markdown.kt.
   ============================================================ */

.markdown { color: var(--body); font-size: 15px; }
.markdown p { margin: 0 0 10px; }
.markdown h1, .markdown h2, .markdown h3,
.markdown h4, .markdown h5, .markdown h6 {
  color: var(--head);
  margin: 16px 0 8px;
  font-size: 16px;
}
.markdown h1 { font-size: 20px; }
.markdown h2 { font-size: 18px; }
.markdown ul { margin: 0 0 10px; padding-left: 18px; }
.markdown a { color: var(--green-hi); }
.markdown code {
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 3px;
  padding: 1px 4px;
  font-size: .9em;
}
/* An oversized screenshot must not blow out the modal. */
/* Scaled down to a thumbnail, not shown at whatever size the camera produced.

   A screenshot pasted into an issue is routinely 2000px tall, and at full size it
   *is* the issue — the description above it and the comments below it are a
   scroll away in either direction. The height cap is what keeps an image an
   illustration rather than the page. `width: auto` with `max-height` preserves
   the aspect ratio; setting both dimensions would squash it.

   The full-size view is a click away — see .lightbox, and Lightbox.kt. That is
   what makes capping the height honest rather than lossy: nothing is hidden, it
   is one click away instead of thirty centimetres of scroll. */
.markdown img {
  max-width: 100%;
  max-height: 260px;
  width: auto;
  border-radius: 4px;
  border: 1px solid var(--border);
  cursor: zoom-in;
  /* An image is inline by default, so it sits on the text baseline and drags a
     descender's worth of gap under itself — visible as a stray few pixels inside
     the border. */
  display: block;
}

/* Inside the editor a click means "put the caret here", so the cursor must not
   promise a viewer that deliberately does not open there. See Lightbox.kt. */
.editor-surface img { cursor: default; }

/* A file that is not an image — a PDF, a zip of logs, a screen recording.

   Drawn as a box rather than as underlined blue text because it is not a link,
   whatever its markup says: clicking it does not take you anywhere, it puts a
   file on your disk. Something that looks like a link and behaves like a button
   is the sort of thing people click once and then never trust again.

   The label already carries the filename and the size — the renderer puts them
   there, because markdown has nowhere else to keep them; see attachmentMarkdown.
   So this adds only the one thing the label cannot say, which is what will
   happen when you click it: the arrow, and the frame around it that says the
   whole thing is one target.

   `display: inline-flex` and not `block`: the anchor sits inside whatever
   paragraph the editor dropped it in, and a block-level anchor inside a <p> is
   invalid markup the browser then re-parents, which moves the caret. */
.markdown a.attachment {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  max-width: 100%;
  padding: 7px 11px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--panel-2);
  color: var(--body);
  text-decoration: none;
  font-size: 12px;
  /* A long filename must not push the dialog wider than the screen. It wraps
     rather than being clipped: the end of a filename is where the extension is,
     which is the part a reader is looking for. */
  overflow-wrap: anywhere;
}

.markdown a.attachment:hover {
  border-color: var(--green-hi);
  color: var(--head);
}

/* The download arrow, drawn in CSS rather than shipped as an icon: it is two
   characters, and an <img> here would be a network request — and, in the editor's
   surface, a second element for the serialiser to have opinions about. ::before
   is not in the DOM at all, so it cannot end up in the stored markdown. */
.markdown a.attachment::before {
  content: "↓";
  flex: none;
  font-size: 13px;
  line-height: 1;
  color: var(--green-hi);
}

/* ============================================================
   Lightbox — the full-size image viewer
   ============================================================ */

/* Above .modal-backdrop's 10000. An image is almost always opened from
   inside a modal — an issue's description, a comment — and a viewer that
   appeared behind the dialog it was opened from would look like nothing
   happened. */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 10010;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 32px;
  /* Darker than .modal-backdrop's .75: this one is showing a picture, and the
     less of the page bleeding through the better. */
  background: rgba(2, 6, 12, .92);
  cursor: zoom-out;
}

/* Bounded by the VIEWPORT, not by the image's own size. `object-fit: contain`
   keeps the aspect ratio while fitting inside both bounds — without it a tall
   image would be squashed to the height cap. A small image is left at its own
   size rather than blown up: there is nothing to reveal, and upscaling it would
   just make it blurry. */
.lightbox-image {
  max-width: 92vw;
  max-height: 84vh;
  object-fit: contain;
  border-radius: 4px;
  border: 1px solid var(--border-hi);
}

/* The corner close button. Absolute, not a flex child: .lightbox centres its
   column, and a button left in that flow would sit above the image rather than
   over the corner. .lightbox's `position: fixed` is the positioned ancestor
   this resolves against. `cursor: default` overrides the overlay's zoom-out so
   the button reads as a button, not more backdrop. */
.lightbox-close {
  position: absolute;
  top: 20px;
  right: 20px;
  cursor: pointer;
}

.icon-close { width: 18px; height: 18px; }

.lightbox-hint {
  margin: 0;
  color: var(--green-dim);
  font-size: 11px;
  letter-spacing: .06em;
}

.issue-title { color: var(--head); font-size: 18px; margin: 0 0 4px; }
.issue-byline { color: var(--green-dim); font-size: 13px; margin: 0 0 14px; }
/* Who is watching, under the notify toggle. Dim, like the byline — it is
   context, not a control. The bottom margin sets it apart from the
   status/priority row below, which otherwise crowds it. */
.issue-watchers { color: var(--green-dim); font-size: 12px; margin: 8px 0 16px; }
.issue-description { margin-top: 12px; }

/* ============================================================
   Comments
   ============================================================ */

.comments-heading {
  color: var(--green-dim);
  font-size: 12px;
  letter-spacing: .1em;
  text-transform: uppercase;
  margin: 24px 0 10px;
  padding-top: 16px;
  border-top: 1px solid var(--border);
}

/* The gap has to beat the padding inside a comment, or the space between two
   comments reads as part of one — at 10px against .comment's 10px, a thread was
   a single wall of text with hairlines through it. */
.comments { display: flex; flex-direction: column; gap: 20px; margin-bottom: 12px; }
.comments-empty { color: var(--green-dim); font-size: 13px; margin: 0; }

/* Outlined, not filled. A comment sits directly on the issue window's own
   --panel interior, so it is not raised above anything and --panel-2's raised
   tone had nothing to raise off — under Lemonade --t-surface-alt is a deep
   yellow, and a thread read as a stack of muddy slabs under the description's
   near-white. This is the same deeper-yellow-on-yellow trap .card hit; there
   the fix was --panel-2 → --panel because a card lies on a --bg column, but a
   comment's parent is already --panel, so the equivalent fix is no fill at all.
   The border and the 20px gap carry the card shape on their own. */
.comment { border: 1px solid var(--border); border-radius: 4px; padding: 10px; }
.comment-head { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; }
/* Author and its agent badge, grouped on the left. This carries the flex the
   author span used to, so the Edit/Delete buttons still land far right while the
   badge stays beside the name it names. min-width:0 lets the group shrink rather
   than shoving the buttons off the edge on a narrow modal. */
.comment-meta { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
/* The byline is author AND timestamp in one span (see commentByline) — they
   cannot be sized apart without splitting it, and at 11px they read as fine
   print beside 15px body text. */
.comment-author { color: var(--green-hi); font-size: 13px; }
.comment-body p:last-child { margin-bottom: 0; }

/* ============================================================
   The account corner — far right of the top bar.

   Was a 260px sidebar with two stacked provider buttons. It is
   now one button: "Sign in…", which opens the picker, or the
   user's name and mark, which open a menu holding sign-out. The
   provider buttons moved into the modal (see .signin-option),
   where there is room to say what choosing one costs.
   ============================================================ */

.account {
  /* The anchor for .account-menu, which is absolutely positioned. Without a
     positioned ancestor the menu would hang off the viewport's top-left rather
     than off the name it belongs to. */
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  /* Reserve the row before the first session fetch returns. The view toggles
     `visibility` rather than `display` for the same reason: a top bar that grows
     a button's width once the server answers shoves the whole row sideways, and
     that reads as a glitch.

     30px, not the 36px this reserved back when the account corner was its own
     bar and could pick any height it liked. It now rides the toolkit's trailing
     top-bar slot, alongside .dt-topbar-icon-button, whose chrome row is 30px.
     The slot sizes to its tallest child, so a 36px reservation here made the
     whole trailing cluster 36 and pushed the toolkit's own icons 3px below the
     brand opposite. Whatever this number is, .topbar-brand must match it. */
  min-height: 30px;
}

/* Name and mark are one button. */
.account-btn {
  font-family: inherit;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  color: var(--green-hi);
  background: transparent;
  border: 1px solid transparent;
  border-radius: 4px;
  padding: 5px 8px;
  cursor: pointer;
}

.account-btn:hover { border-color: var(--border); background: var(--panel-2); }

.account-name {
  font-size: 12px;
  color: var(--head);
  /* A display name is whatever the provider had on file — it can be a sentence.
     Truncate rather than wrap: the top bar is one row, and a name that wraps
     takes the whole bar's height with it. */
  max-width: 160px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.account-signin { font-size: 12px; }

/* The menu. Right-aligned to the corner it hangs from — `left: auto; right: 0`
   rather than a left offset, because it is wider than the button above it and
   growing leftward is the only direction with room.

   Opened by HOVER, in CSS, with no state in the view model behind it. That is
   why there is no `.account-menu-open` class: a hover menu driven by JS needs
   mouseenter/mouseleave plus a close timer to survive the pointer crossing the
   gap, and every one of those is a way to strand the menu open.

   Keyboard access is `:has(:focus-visible)` and NOT `:focus-within`, which is a
   distinction with a bug behind it. :focus-within is true for a MOUSE click too
   — clicking the account button focuses it — so the menu stayed open after the
   pointer had left and nothing but a click elsewhere would dismiss it. It read as
   a menu that never goes away. :focus-visible is set only when the browser judges
   focus should be *shown*, which is keyboard navigation and not a click, so the
   two cases stop fighting: hover governs the mouse, focus-visible governs the
   keyboard, and neither strands the other. */
.account-menu {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  /* The menu hangs off the topbar's account button, so it must clear the
     maximised-pane layer (1000) or it would open behind a maximised board.
     Below the modal layer (10000) so dialogs still cover it. */
  z-index: 2000;
  display: none;
  flex-direction: column;
  min-width: 160px;
  padding: 4px;
  background: var(--panel-2);
  border: 1px solid var(--border-hi);
  border-radius: 4px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, .5);
}

/* The bridge across the 6px gap above.

   Without it the menu is unreachable: the pointer leaves the button, crosses six
   pixels of nothing, :hover goes false, and the menu vanishes before it can be
   entered. Invisible and unclickable, purely a hover surface — the alternative,
   removing the gap, glues the menu to the button and reads as one shape. */
.account-menu::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: -7px; /* the 6px gap, plus this element's own 1px border */
  height: 7px;
}

.account-signed-in:hover .account-menu,
.account-signed-in:has(:focus-visible) .account-menu { display: flex; }

.account-menu-item {
  font-family: inherit;
  font-size: 12px;
  text-align: left;
  color: var(--green-dim);
  background: none;
  border: none;
  border-radius: 3px;
  padding: 8px 10px;
  cursor: pointer;
  /* The buttons are <button> and the submenu parent is a <div>; without this the
     two sit at different widths and the hover highlight is visibly ragged. */
  width: 100%;
}

.account-menu-item:hover:not(:disabled) { background: color-mix(in srgb, var(--green-hi) 12%, transparent); color: var(--green-hi); }
.account-menu-item:disabled { opacity: .5; cursor: default; }

/* ── The impersonation submenu ──────────────────────────────────────────────

   "Impersonate ▸" with the user list folding out of it. The submenu is a CHILD
   of this item rather than a sibling, which is what makes the hover survive the
   pointer travelling into it — a sibling would drop :hover the moment the
   pointer left the parent's box. */
.account-menu-parent {
  position: relative;
  display: flex;
  align-items: center;
  gap: 8px;
}

.account-menu-label { flex: 1; }
.account-menu-arrow { color: var(--green-dim); font-size: 10px; }

/* Leftward, not rightward. The account corner is already at the right edge of
   the viewport, so a submenu opening to the right would open off-screen —
   `right: 100%` hangs it off the menu's left edge instead.

   `right: 100%` — the boxes TOUCH. No gap, and therefore no bridge.

   This took three goes and the history is worth keeping, because the obvious fix
   is the one that does not work. A gap of any size is dead space: crossing it
   leaves .account-menu-parent, :hover goes false, and the submenu closes under
   the pointer on its way to being used. The first attempt left 4px while claiming
   the boxes touched. The second bridged the gap with an ::after — which was
   invisible for a reason that has nothing to do with hover: `overflow-y: auto`
   below establishes a clipping box, and a pseudo-element positioned OUTSIDE its
   own element's padding box is clipped by it. The bridge was never on screen.

   Touching sidesteps both. Nested menus that share an edge are what every desktop
   menu bar does, and the 1px borders read as a seam rather than a mistake. */
.account-submenu {
  position: absolute;
  top: -5px; /* aligns the first row with the parent, allowing for padding */
  right: 100%;
  display: none;
  flex-direction: column;
  min-width: 170px;
  max-height: 320px;
  /* A long user list must not run off the bottom of the viewport. It scrolls
     inside itself, like every other overflow in this app. */
  overflow-y: auto;
  padding: 4px;
  background: var(--panel-2);
  border: 1px solid var(--border-hi);
  border-radius: 4px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, .5);
}

.account-menu-parent:hover .account-submenu,
.account-menu-parent:has(:focus-visible) .account-submenu { display: flex; }

.account-submenu-empty {
  color: var(--green-dim);
  font-size: 11px;
  margin: 0;
  padding: 8px 10px;
}

/* ============================================================
   The provider picker's options — inside the modal.
   ============================================================ */

.signin-option {
  /* Not a flex row: the explanation belongs UNDER its button, close enough to
     read as that button's consequence rather than as a caption for the pair. */
  margin-bottom: 18px;
}

.signin-option:last-of-type { margin-bottom: 0; }

/* Full width and stacked, so the two providers are the same target. They are not
   equal in *emphasis* — GitHub is .btn-primary and Google is not, because
   contributing needs GitHub — but a wider button would say that with the one
   property that also means "easier to hit", which is not the difference being
   drawn. */
.signin-provider-btn {
  display: flex;
  width: 100%;
  padding: 11px 16px;
}

.signin-option-why {
  color: var(--green-dim);
  font-size: 11px;
  line-height: 1.5;
  margin: 8px 2px 0;
}

/* ── The profile modal's Connections section ────────────────────────────────
   The toggle is the least interesting thing here after the first day; the list
   of what is connected is what people come back for. Styled so the list reads
   as the subject and the setup instructions read as reference material. */

.section-title {
  color: var(--head);
  font-size: 13px;
  margin: 20px 0 0;
}

/* The Profile dialog's User/MCP tab strip. Reuses the toolkit's segmented
   button chrome (dt-settings-button-row via the Toggle classes); this only
   spaces it from the body and lets the two tabs share the width evenly, so it
   reads as a tab bar rather than a stray On/Off pair. The MCP tab's leading
   .section-title carries its own top margin, so none is needed here. */
.profile-tabs { margin-bottom: 4px; }
.profile-tabs .dt-settings-choice-btn { flex: 1; }

/* A value nobody should be asked to type: the server URL, and the command.
   The value wraps rather than truncating — a URL cut off at the panel edge is
   one somebody will copy by hand and get wrong, which is the whole failure this
   row exists to prevent. */
.copy-row {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  margin-bottom: 4px;
}

.copy-value {
  flex: 1;
  font-family: var(--mono);
  font-size: 11px;
  color: var(--body);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 8px 10px;
  /* anywhere, not break-word: these have no spaces to break at, so break-word
     would leave the line overflowing exactly as if nothing had been set. */
  overflow-wrap: anywhere;
  user-select: all;
}

.btn-small { font-size: 11px; padding: 6px 10px; }

.connections-setup { margin-top: 4px; }

.connections-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.connection-row {
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 12px;
}

.connection-text { flex: 1; min-width: 0; }

/* The agent's self-reported name. Truncated rather than wrapped: it is chosen
   by whoever registered the client, so a long one is as likely to be someone
   pushing at the layout as it is to be honest. The row must stay one row. */
.connection-name {
  color: var(--head);
  font-size: 13px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.connection-detail {
  color: var(--green-dim);
  font-size: 11px;
  margin-top: 2px;
}

/* ============================================================
   The project dialog's settings sections — vocabularies and
   privileges.

   These only appear when EDITING a project, which is why they
   share the dialog rather than getting one of their own: what an
   admin actually wants is "configure this project", and a name,
   its board columns and who may post on it are all that one
   thing. The panel is .modal-large in that mode — see
   .settings-body below for the one thing that borrows from the
   issue modal and must not.
   ============================================================ */

/* This dialog's body scrolls as ONE column, and has to say so.
   `.modal-large .modal-body` above is `overflow: hidden` on purpose — the issue
   modal is a three-band split whose MIDDLE band scrolls, so a scrolling body
   there would scroll the title out from over the description. This dialog has no
   inner band to hand the job to: it is a form, then five vocabularies, then a
   privileges table, all of it one continuous read.

   Without this rule the body inherits that `overflow: hidden` and simply clips.
   Found by opening the dialog and finding the privileges section unreachable —
   1300 of 2035 pixels of content, below the fold of a panel that would not
   scroll, with no scrollbar to suggest anything was missing. `display: block`
   goes with it: the inherited flex column is the shape the three bands need, and
   this content is not three bands. */
.modal-large .modal-body.settings-body {
  display: block;
  overflow-y: auto;
  /* Air between the content and the scrollbar. Not cosmetic: the scrollbar is
     laid out INSIDE this element, so without a gutter the fields and the
     On/Off pairs end exactly where the thumb begins and the column reads as
     jammed against it. Nothing needed this while scrollbars were the macOS
     overlay kind, which float over the content and take no width — styling
     them (see the Scrollbars section) made them real, and this is the room
     they now need. */
  padding-right: 14px;
}

.project-settings {
  margin-top: 24px;
  /* A rule, not a gap: everything above is a form that saves on OK, everything
     below writes the moment you touch it. That is a real difference in
     behaviour and the eye should be told about it. */
  border-top: 1px solid var(--border);
  padding-top: 4px;
}

.vocab-section { margin-top: 20px; }

/* The rows, and then the add field under them — in that order because the list
   is the subject. An add field above its list reads as a search box. */
.vocab-rows { margin-top: 10px; }

.vocab-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 6px;
}

/* The name takes the room and everything else takes what it needs. Without this
   the field collapses to its content width and five rows of different-length
   names become a ragged left edge on every control after them. */
.vocab-row .field { flex: 1; min-width: 0; }

/* Reset .toggle-row's stacked-form margins: inside a row it is one control
   among several, not a paragraph. Its label never wraps here — the row lays out
   horizontally and a wrapped "needs a resolution" would push the row taller
   than the field it sits beside. */
.vocab-flag {
  margin: 0;
  flex: none;
  white-space: nowrap;
  font-size: 11px;
  gap: 8px;
}

/* No right edge to reach for here — this row sits among the field and the
   buttons rather than spanning the dialog, so the label sizes to its text and
   the pair follows it directly. */
.vocab-flag .toggle-label { flex: none; }

/* Square, so the two arrows read as one pair of nudges rather than as two
   buttons with words missing. */
.vocab-move {
  flex: none;
  padding: 6px 9px;
  line-height: 1;
}

.vocab-delete { flex: none; }

/* A disabled control still has to look like it exists, and — for Delete — still
   has to be hoverable: its title says WHY it is dead ("3 issues are in this
   status"), and a control with pointer-events off never shows a tooltip. That
   is the whole reason this sets opacity rather than pointer-events. */
.vocab-row .btn:disabled,
.vocab-add .btn:disabled {
  opacity: .4;
  cursor: not-allowed;
}

.vocab-add {
  display: flex;
  gap: 8px;
  margin-top: 8px;
}

.vocab-add .field { flex: 1; min-width: 0; }
.vocab-add .btn { flex: none; }

/* ── Privileges ─────────────────────────────────────────────────────────────
   One block per account rather than a grid with role columns. A grid is the
   obvious shape and the wrong one here: the roles are sentences ("Edit issues
   they did not create"), not words, so a header row would either truncate them
   or be three columns wide on a phone. Stacked, each checkbox carries its own
   explanation and the row reads as a person. */

.member-row {
  border-top: 1px solid var(--border);
  padding: 12px 0;
}

.member-row:last-child { padding-bottom: 0; }

.member-name {
  color: var(--head);
  font-size: 13px;
}

.role-row { margin-top: 4px; }

/* Tighter than a standalone .toggle-row: these come in threes under one name,
   and the default 18px would make one person's privileges a screenful. */
.role-row .toggle-row { margin-top: 6px; }
