/* Kafka Lab - the API Producer and the API Listener.
 *
 * Its own sheet rather than more rules in developer-lab.css, because these two pages are a
 * different SHAPE from the rest of the Lab: the others are lists of things you own (endpoints,
 * databases, request logs), while these are a configuration form beside a result panel. Mixing the
 * two vocabularies in one file would leave the next person guessing which half applied.
 *
 * Both pages wear .tstudio, so every --ts-* token is available and the buttons are .ts-btn. What is
 * here is only what the studio skin has no opinion about.
 */

/* ---- tokens ----
   These used to be five hard-coded amber values with a light set and a dark twin, carrying the
   note: "Kafka's established tone on this site is amber - both existing Kafka tools carry it - so
   the Lab keeps it rather than introducing a fourth accent for the same subject."

   THAT NOTE WAS WRONG, and it is quoted here so nobody reinstates it. There is no amber Kafka
   tone on this site to be consistent with. tools-kafka.html and tools-kafka-size.html both wear
   .tstudio and have always rendered indigo from --ts-brand; the only amber on either of them is
   two semantic `text-warning` strings (an unkeyed-record caveat and a JSON validation message),
   which are warnings and must stay amber. The one other amber is `text-warning-emphasis` on their
   icons in the tools catalogue (js/app.js), and that is a per-tile rotation through Bootstrap's
   emphasis palette - cron is danger, timestamp is primary, HTTP is info - not a Kafka identity.

   So these five values were the only place a "Kafka is amber" idea existed, and aliasing them
   onto --ts-* did not introduce a seam between the Lab and the tools. It removed one.

   Every value is now an alias onto a --ts-* token, and that is what buys dark mode for nothing:
   the studio redefines those tokens under [data-bs-theme="dark"], so the whole
   [data-bs-theme="dark"] .tstudio block this file used to carry has been deleted rather than
   maintained in parallel. --kf is --ts-brand-t and not --ts-brand because it is used as TEXT and
   as an icon colour; the studio keeps those two apart precisely so accent text stays readable on
   a dark card. */
/* .ts-tokens as well as .tstudio, and the second selector is load-bearing.
   A Bootstrap modal is appended to <body>, which puts it OUTSIDE main.tstudio - so a panel that
   carried only .ts-tokens got every --ts-* token and not one of the five below. Every var(--kf)
   in it then resolved to nothing: the selected format button was `background: var(--kf);
   color: #fff`, which with no background is white text on white, and the borders, the right-hand
   tint and the record cards all vanished the same way. The panel did not look badly designed so
   much as unstyled. */
.tstudio,
.ts-tokens {
  --kf: var(--ts-brand-t);
  --kf-soft: var(--ts-brand-s);
  --kf-line: var(--ts-line);
  --kf-surface: var(--ts-card);
  --kf-sunken: var(--ts-soft);
}

/* Local hedge. Four elements on these pages are shown and hidden through the DOM hidden property,
   and their own rules set display:flex or inline-flex - which beats the UA sheet's [hidden] rule,
   because an author rule always does. Today assets/css/theme.min.css carries
   [hidden] { display: none !important; } and saves us. That is a vendor file we neither own nor
   control the next version of, so these pages state the requirement themselves. */
.tstudio [hidden] { display: none !important; }

/* ---- page header ----
   Everything here is TRIMMED against the Lab's defaults, and scoped to the two Kafka pages so no
   other Lab page moves.

   The reason: these two pages carry more preamble than their siblings - an illustration, a usage
   meter, and two standing notices that have to be read before anybody types a password. At the
   shared sizes that added up to about 590px of chrome above the form, so the thing the visitor
   came to use started below the fold on a laptop. Every reduction below is to buy that back; none
   of it changes what is said. */
.kf-hero { flex: 0 0 auto; width: 7rem; max-width: 24vw; }
.kf-hero svg { width: 100%; height: auto; display: block; }
@media (max-width: 767.98px) { .kf-hero { display: none; } }

/* The illustration's theme-sensitive shapes. The artwork carries these as presentation attributes
   too, which act as the fallback if this sheet fails to load; CSS outranks a presentation
   attribute, so these win when it does.

   Needed because the drawing was authored light-only: a near-white #f1eefe disc carrying #fff
   cards. On a dark page that is the brightest thing on screen - brighter than the h1 - sitting in
   the top-left corner, which is precisely where the eye lands first. */
.kf-art-disc { fill: #f1eefe; }
.kf-art-dot { fill: #d7d5f4; }
.kf-art-card { fill: #fff; stroke: #dbe0f5; }
[data-bs-theme="dark"] .kf-art-disc { fill: rgba(129, 140, 248, .12); }
[data-bs-theme="dark"] .kf-art-dot { fill: rgba(165, 180, 252, .38); }
[data-bs-theme="dark"] .kf-art-card { fill: #1b2133; stroke: #373e53; }

.tstudio[data-kafka-mode] .dl-head { gap: .85rem; margin-bottom: .85rem; align-items: center; }
/* A step down from the Lab's clamp(1.5rem, 1.2rem + 1.2vw, 2rem). Still comfortably the largest
   thing on the page, which is all a page title has to be. */
.tstudio[data-kafka-mode] .dl-head-txt h1 {
  font-size: clamp(1.3rem, 1.05rem + .7vw, 1.6rem); margin-bottom: .15rem;
}
.tstudio[data-kafka-mode] .dl-head-txt p { font-size: .82rem; line-height: 1.5; }
/* The usage meter used to be shrunk to header scale here - eleven rules squeezing it into the
   third slot of .dl-head, where it stood taller than the title beside it. Both pages now give it
   its own column in .kf-top and its full card size, so every one of those rules was overridden by
   the LAB LAYOUT block at the foot of this file and did nothing. Deleted rather than left as a
   decoy for whoever next wonders why editing them changes nothing. */

/* ---- the credential promise ----
   Still given room, because it is the one thing on the page a visitor has to believe before they
   type a password - but Bootstrap's .alert ships 24px of padding on every side, which on two
   side-by-side notices was over 200px of header on its own. */
.kf-vows { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; margin: 0 0 .85rem; }
@media (max-width: 991.98px) { .kf-vows { grid-template-columns: 1fr; } }
/* .tstudio .kf-vow, not .kf-vow, and the extra class is load-bearing. Bootstrap's own .alert sets
   padding and border-radius at the same specificity as a bare .kf-vow would have - and
   assets/css/theme.min.css is linked AFTER this file, so at equal specificity Bootstrap wins on
   source order. The padding silently stayed at 24px until this selector outranked it. */
.tstudio .kf-vow {
  font-size: .78rem; line-height: 1.55; align-items: flex-start; margin: 0;
  padding: .65rem .8rem; border-radius: .7rem;
}
.kf-vow em { font-style: normal; opacity: .85; }

/* ---- cards ---- */
.kf-card {
  border: 1px solid var(--kf-line); border-radius: 1.1rem; overflow: hidden;
  background: var(--kf-surface); display: flex; flex-direction: column;
  box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.kf-card-h {
  display: flex; align-items: center; gap: .7rem; padding: .85rem 1rem;
  border-bottom: 1px solid var(--kf-line); background: var(--kf-sunken);
}
.kf-card-h > span:nth-child(2) { display: flex; flex-direction: column; margin-right: auto; min-width: 0; }
/* Card titles are h2 so the page has a heading outline below its h1 - four full-height panels
   with nothing to jump between is a page a screen-reader user has to read linearly. Sized as a
   card title, not as a document heading. */
.kf-card-h h2 {
  font-size: .92rem; line-height: 1.2; color: var(--ts-ink); margin: 0; font-weight: 700;
}
.kf-card-h em { font-style: normal; font-size: .72rem; margin-top: .1rem; color: var(--ts-muted); }
.kf-ico {
  flex: 0 0 auto; width: 2.25rem; height: 2.25rem; border-radius: .75rem;
  display: grid; place-items: center; font-size: .85rem;
  background: var(--kf-soft); color: var(--kf);
}
.kf-body { padding: 1rem; display: flex; flex-direction: column; gap: .1rem; }

/* ---- fields ---- */
.kf-lbl {
  display: block; font-size: .7rem; font-weight: 700; letter-spacing: .04em;
  text-transform: uppercase; margin: .85rem 0 .3rem; color: var(--ts-muted);
}
.kf-body > .kf-lbl:first-child, .kf-body > div:first-child .kf-lbl { margin-top: 0; }
/* "optional" is on the label rather than in the placeholder, because a placeholder disappears the
   moment somebody types - and requirement 4 is that these two fields are optional, which is
   exactly the fact a half-filled form needs to keep showing. */
.kf-opt {
  font-weight: 600; letter-spacing: 0; text-transform: none; font-size: .68rem;
  color: var(--ts-muted); margin-left: .25rem;
}

/* A leading glyph inside the control. Decorative and aria-hidden: every one of these fields has a
   real label, and the icon only repeats it. */
.kf-field { position: relative; display: block; }
.kf-field > .kf-field-ico {
  position: absolute; left: .75rem; top: 50%; transform: translateY(-50%);
  font-size: .78rem; color: var(--ts-faint); pointer-events: none; z-index: 1;
}
.kf-field > .dl-input { padding-left: 2.25rem; }

.kf-hint { font-size: .72rem; line-height: 1.5; margin: .35rem 0 0; color: var(--ts-muted); }
.kf-hint code { font-size: .95em; }
.kf-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .8rem; }
.kf-2col { display: grid; grid-template-columns: 1fr 1fr; gap: .1rem .75rem; }
@media (max-width: 575.98px) { .kf-2col { grid-template-columns: 1fr; } }
textarea.kf-value { min-height: 9rem; resize: vertical; }
/* Taller than the value box, not shorter, because for Avro the schema is the LARGER artifact -
   the sample value is four fields and the record declaring them is a dozen lines. At 7rem a
   loaded sample schema showed its opening brace and about two fields, so the thing the sample
   exists to demonstrate was the one part scrolled out of sight. Still `resize: vertical`, and
   applySample grows it to fit what it just loaded. */
textarea.kf-schema { min-height: 12rem; resize: vertical; }
.kf-sample { max-width: 13rem; flex: 0 0 auto; }

/* The picker, once a sample is actually loaded.
   Without it the select falls back to "Load a sample…" wording in the reader's memory only: the
   fields are full of somebody else's data and nothing on screen says which sample put it there,
   which matters most after switching format twice and wondering what is in the boxes. */
.kf-sample.is-loaded {
  border-color: var(--kf);
  background-color: var(--kf-soft);
  color: var(--ts-ink);
  font-weight: 600;
}

.kf-check {
  display: flex; gap: .55rem; align-items: flex-start; margin: .9rem 0 0;
  font-size: .82rem; cursor: pointer; color: var(--ts-ink);
}
.kf-check input { margin-top: .2rem; flex: 0 0 auto; }
.kf-check em { font-style: normal; color: var(--ts-muted); font-size: .76rem; }

/* ---- format switch ----
   Three options that change what the field BELOW means, and which one is active has to stay
   visible while the payload is being written.

   This is the DECODE format, sent to the server. The per-record JSON/Text control in the results
   panel is a DISPLAY toggle that changes nothing fetched. Two different jobs that look alike, so
   they are named apart - .kf-seg against .kf-view - and must not be merged. */
.kf-seg { display: inline-flex; gap: .4rem; margin-top: .9rem; align-self: flex-start; flex-wrap: wrap; }
.kf-seg button {
  display: inline-flex; align-items: center; gap: .45rem;
  border: 1px solid var(--kf-line); background: var(--kf-surface);
  padding: .5rem .9rem; border-radius: .7rem; cursor: pointer;
  font-size: .8rem; font-weight: 600; color: var(--ts-muted);
  transition: border-color .15s, color .15s, background .15s;
}
.kf-seg button:hover { border-color: var(--kf); color: var(--kf); }
.kf-seg button.on { background: var(--ts-brand); border-color: var(--ts-brand); color: #fff; }

/* ---- the auto-stop note ---- */
.kf-note {
  display: flex; gap: .6rem; align-items: flex-start; margin-top: .9rem;
  padding: .7rem .85rem; border-radius: .7rem;
  background: var(--kf-soft); color: var(--ts-ink); font-size: .78rem; line-height: 1.55;
}
/* Matches the icon that is actually there. The rule named .fa-solid while the markup carries
   fa-regular fa-clock, so it styled nothing; the child selector also covers the <svg> a SVG-JS
   FontAwesome build would put in the span's place. */
.kf-note > span, .kf-note > svg { color: var(--kf); margin-top: .15rem; flex: 0 0 auto; }

/* ---- headers editor (producer only) ---- */
.kf-hrow { display: flex; align-items: center; gap: .5rem; margin-top: .9rem; }
.kf-hrow .ts-btn { margin-left: auto; }
.kf-kv { display: flex; flex-direction: column; gap: .4rem; margin-top: .4rem; }
.kf-kv-row { display: grid; grid-template-columns: 1fr 1.4fr auto; gap: .4rem; }
.kf-kv-row button {
  border: 1px solid var(--kf-line); background: transparent; border-radius: .5rem;
  width: 2.1rem; cursor: pointer; color: var(--ts-muted);
}
.kf-kv-row button:hover { color: #b91c1c; border-color: #b91c1c; }
/* Same rose the file already uses for .kf-stop in dark: the light red is unreadable on a dark
   card, so the hover state was less legible than the resting one. */
[data-bs-theme="dark"] .kf-kv-row button:hover { color: #fda4af; border-color: #fda4af; }

/* ---- actions ---- */
.kf-actions { display: flex; align-items: center; gap: .6rem; margin-top: 1.1rem; flex-wrap: wrap; }
/* Room for a whole sentence: this runs from "" through "3 of 20 calls left today" to a refusal,
   and a narrow slot would reflow the row every time it changed. */
.kf-status { font-size: .78rem; color: var(--ts-muted); min-width: 9rem; }
.kf-status.is-bad { color: #b91c1c; }
/* A failed press is said beside the button that was pressed as well as in the page alert. */
#kf-send-error { font-size: .8rem; margin-top: .6rem; }
/* The show/hide toggle inside the password field. */
.kf-field > .kf-eye {
  position: absolute; right: .35rem; top: 50%; transform: translateY(-50%);
  border: 0; background: none; color: var(--ts-muted); cursor: pointer; padding: .25rem .45rem;
  border-radius: .35rem; font-size: .8rem; line-height: 1;
}
.kf-field > .kf-eye:hover, .kf-field > .kf-eye:focus-visible { color: var(--ts-ink); background: var(--ts-soft); outline: none; }
.kf-field.has-eye > .dl-input { padding-right: 2.4rem; }
/* The size line under the producer's value: bytes against the Lab's ceiling, red once over. */
.kf-size { font-variant-numeric: tabular-nums; }
.kf-size.is-over { color: #b91c1c; font-weight: 600; }
.kf-partition { color: var(--kf); font-weight: 600; }

.kf-stop { color: #b91c1c; border-color: rgba(185, 28, 28, .35); }
.kf-stop:hover { background: rgba(185, 28, 28, .08); border-color: #b91c1c; }
[data-bs-theme="dark"] .kf-stop { color: #fda4af; border-color: rgba(253, 164, 175, .35); }

/* The countdown reads as status, not as a control: same size and colour as .kf-status, with a
   pulsing dot to carry the one fact the words cannot, which is that it is still running. */
.kf-countdown {
  display: inline-flex; align-items: center; gap: .45rem; margin-left: auto;
  font-size: .78rem; color: var(--ts-muted); font-variant-numeric: tabular-nums;
}
/* Tabular figures above: without them the digits are proportionally spaced and the whole line
   shuffles sideways once a second as the numbers change. */
.kf-pulse {
  width: .5rem; height: .5rem; border-radius: 50%; background: #16a34a;
  animation: kf-pulse 1.6s ease-in-out infinite;
}
@keyframes kf-pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: .35; transform: scale(.8); }
}

/* Disabled while listening: the address, topic and from-the-beginning choice all belong to the
   session that is already open, so they are shown as fixed rather than silently ignored. */
.kf-body input:disabled, .kf-body select:disabled { opacity: .6; cursor: not-allowed; }

/* ---- results panel ---- */
.kf-panel-h { gap: .55rem; flex-wrap: wrap; }
.kf-count {
  display: inline-grid; place-items: center; min-width: 1.6rem; height: 1.6rem;
  padding: 0 .4rem; border-radius: .5rem; background: var(--ts-brand); color: #fff;
  font-size: .76rem; font-weight: 700; font-variant-numeric: tabular-nums;
}
.kf-live {
  display: inline-flex; align-items: center; gap: .35rem; padding: .16rem .5rem;
  border-radius: 999px; font-size: .7rem; font-weight: 600;
  background: rgba(34, 197, 94, .14); color: #15803d;
}
[data-bs-theme="dark"] .kf-live { color: #86efac; }
.kf-live.is-paused { background: rgba(180, 83, 9, .14); color: #b45309; }
[data-bs-theme="dark"] .kf-live.is-paused { color: #fcd34d; }
.kf-tools { display: flex; gap: .4rem; margin-left: auto; flex-wrap: wrap; }
/* Growth-safe: this runs to "1,284 messages so far - 340 older not shown" and updates every two
   seconds, so it must not sit anywhere that reflows when it grows. */
.kf-took { font-size: .74rem; color: var(--ts-muted); white-space: nowrap; }

/* ---- one record ----
   Records are read top to bottom, so they stack rather than sitting in a table - a Kafka payload
   is many lines and a table cell is one. */
.kf-rec { border: 1px solid var(--kf-line); border-radius: .85rem; overflow: hidden; background: var(--kf-surface); }
.kf-rec + .kf-rec { margin-top: .6rem; }
.kf-rec-h {
  display: flex; flex-wrap: wrap; gap: .4rem .75rem; align-items: center; width: 100%;
  padding: .55rem .7rem; background: var(--kf-sunken); border: 0;
  border-bottom: 1px solid var(--kf-line);
  font-size: .74rem; text-align: left; cursor: pointer; color: inherit;
}
.kf-rec-h:hover { background: var(--kf-soft); }
.kf-n {
  flex: 0 0 auto; padding: .1rem .4rem; border-radius: .45rem;
  background: var(--kf-soft); color: var(--kf);
  font-size: .7rem; font-weight: 700; font-variant-numeric: tabular-nums;
}
.kf-tag { display: inline-flex; align-items: center; gap: .3rem; color: var(--ts-muted); }
.kf-tag b {
  font-weight: 700; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  color: var(--ts-ink);
}
.kf-caret { margin-left: .25rem; color: var(--ts-faint); transition: transform .15s; }
.kf-rec.is-shut .kf-caret { transform: rotate(-90deg); }
.kf-rec.is-shut .kf-rec-b { display: none; }
.kf-rec-b { padding: .65rem .7rem; }
.kf-hdrs { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .55rem; }
.kf-hdr {
  font-size: .7rem; padding: .16rem .45rem; border-radius: 999px;
  background: var(--kf-soft); color: var(--kf);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.kf-hdr-none { font-size: .72rem; color: var(--ts-muted); margin-bottom: .5rem; }

/* payload toolbar: the display toggle and copy, above the code */
.kf-payh { display: flex; align-items: center; gap: .35rem; margin-bottom: .35rem; }
/* .dl-copy is declared position:absolute in developer-lab.css, where its anchor is .dl-code
   (position:relative). Nothing on the path from this toolbar to the page is positioned, so the
   button escaped its record entirely and every copy button stacked in the corner of the records
   card - measured 114px above the record it belonged to. It is authored here as a flex child of
   the toolbar row, so it is put back in the flow. */
.kf-payh .dl-copy { position: static; width: 1.7rem; height: 1.7rem; }
.kf-view { display: inline-flex; gap: .25rem; margin-left: auto; }
.kf-view button {
  border: 1px solid var(--kf-line); background: transparent; color: var(--ts-muted);
  border-radius: .45rem; padding: .15rem .5rem; font-size: .7rem; font-weight: 600; cursor: pointer;
}
.kf-view button.on { background: var(--kf-soft); color: var(--kf); border-color: var(--kf-soft); }

/* ---- the payload, with a line-number gutter ----
   Authored rather than reused: the only other gutter in this codebase belongs to the JSON diff and
   exists because it is a diff. Two rules make this one behave. The gutter is user-select:none, or
   dragging across the payload copies the line numbers along with the code; and it disappears on a
   narrow screen, where columns of payload matter more than knowing which line you are on. */
.kf-pay {
  margin: 0; border-radius: .6rem; background: var(--kf-sunken);
  border: 1px solid var(--kf-line);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .74rem; line-height: 1.6; color: var(--ts-ink);
  max-height: 22rem; overflow: auto; counter-reset: kfline;
}
.kf-pay code { display: block; padding: .55rem 0; white-space: pre; min-width: 100%; }
.kf-ln { display: block; padding-right: .7rem; white-space: pre; }
.kf-ln::before {
  counter-increment: kfline; content: counter(kfline);
  display: inline-block; width: 2.6rem; margin-right: .7rem; padding-right: .55rem;
  text-align: right; color: var(--ts-muted); border-right: 1px solid var(--kf-line);
  user-select: none; -webkit-user-select: none; background: var(--kf-sunken);
}
@media (max-width: 575.98px) {
  .kf-pay { font-size: .72rem; }
  .kf-ln::before { display: none; }
  .kf-ln { padding-left: .7rem; }
}
/* Wrapped rather than scrolled, for a payload that arrives as one enormous line. */
.kf-pay.is-wrap .kf-ln { white-space: pre-wrap; word-break: break-word; }

.kf-cut { display: block; margin-top: .4rem; font-size: .7rem; color: var(--ts-muted); }
.kf-empty { text-align: center; padding: 2.5rem 1rem; font-size: .84rem; color: var(--ts-muted); }
.kf-empty .fa-solid { display: block; font-size: 1.5rem; margin-bottom: .6rem; color: var(--ts-faint); }

/* ---- pager ----
   OUTSIDE #kf-result, deliberately: the script owns that element's children and replaces them, so
   a footer placed inside it would be destroyed on the next render. */
.kf-foot {
  display: flex; align-items: center; gap: .6rem; flex-wrap: wrap;
  padding: .7rem 1rem; border-top: 1px solid var(--kf-line); background: var(--kf-sunken);
}
.kf-showing { font-size: .76rem; color: var(--ts-muted); margin-right: auto; }
.kf-perpage { display: flex; align-items: center; gap: .4rem; font-size: .76rem; color: var(--ts-muted); }
.kf-perpage select { max-width: 7.5rem; }

/* The practice-broker button, under the address field.

   A tinted PILL rather than the square outline .ts-btn gives it. Two reasons beyond matching the
   design. It sits inside a paragraph of grey help text, where an outlined grey button is nearly
   invisible - and this is the one control that turns an empty form into a working one, so a
   visitor who never notices it types localhost:9092 instead and concludes the feature is broken.
   And it is a SUGGESTION, not the page's action: the tint says "offered" where the solid indigo of
   Start listening says "do it".

   display:flex with width:fit-content, so it sits on its own line under the hint rather than
   flowing inline with the last words of it. Colours come from the same --kf-soft/--kf pair as the
   card icons, which is what makes it correct in dark mode without a second rule. */
.kf-practice {
  display: flex; width: fit-content; margin-top: .55rem;
  font-size: .74rem; font-weight: 600; padding: .3rem .7rem; gap: .4rem;
  border-radius: 999px; border-color: transparent;
  background: var(--kf-soft); color: var(--kf);
}
.kf-practice:hover {
  background: var(--ts-brand); color: #fff; border-color: transparent;
}

@media (prefers-reduced-motion: reduce) {
  .kf-pulse { animation: none; }
  .kf-caret, .kf-seg button, .kf-rec-h { transition: none; }
}

/* ---- the practice playground ----
   Producing and listening in one view. Built only from the --kf-* and --ts-* tokens already in
   this sheet, so it follows the theme without a dark block of its own. */
/* A small pill, not a full-width call to action. It sits above a form the reader came here to
   use, so it announces itself once and then stays out of the way. */
#kf-play-mount { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin: 0 0 .85rem; }
.kf-play-open {
  display: inline-flex; align-items: center; gap: .45rem;
  padding: .3rem .7rem .3rem .55rem; border-radius: 999px; cursor: pointer;
  font-size: .76rem; font-weight: 600; white-space: nowrap;
  border: 1px solid var(--kf); background: var(--kf-soft); color: var(--kf);
  transition: background-color .15s ease, transform .15s ease, box-shadow .15s ease;
}
.kf-play-open:hover { background: var(--kf); color: #fff; transform: translateY(-1px); }
.kf-play-open:hover .kf-play-dot { background: #fff; box-shadow: none; animation: none; }
.kf-play-open:active { transform: none; }
.kf-play-open:focus-visible { outline: 2px solid var(--kf); outline-offset: 2px; }

/* The animated part: a broadcast blip, which is what the feature does. */
.kf-play-dot {
  width: .5rem; height: .5rem; border-radius: 50%; background: var(--kf); flex: 0 0 auto;
  animation: kfBlip 2.4s ease-in-out infinite;
}
@keyframes kfBlip {
  0%, 100% { box-shadow: 0 0 0 0 var(--kf-soft); opacity: .85; }
  50%      { box-shadow: 0 0 0 .3rem transparent; opacity: 1; }
}
/* Anyone who has asked for less motion gets a dot that simply sits there. */
@media (prefers-reduced-motion: reduce) {
  .kf-play-dot { animation: none; }
  .kf-play-open:hover { transform: none; }
}
.kf-play-sub { font-size: .72rem; color: var(--ts-muted); line-height: 1.5; }

.kf-play-shell { border: 1px solid var(--kf-line); border-radius: 1rem; }
.kf-play-shell .modal-header { border-bottom: 1px solid var(--kf-line); gap: .7rem; align-items: center; }
.kf-play-shell .modal-footer { border-top: 1px solid var(--kf-line); gap: .5rem; }
.kf-play-heads { margin-right: auto; min-width: 0; }
.kf-play-ico {
  width: 2.1rem; height: 2.1rem; flex: 0 0 2.1rem; border-radius: .6rem;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--kf-soft); color: var(--kf);
}
/* The one moving thing, and only while it is genuinely live. */
.kf-play-live {
  font-size: .72rem; font-weight: 700; letter-spacing: .03em; text-transform: uppercase;
  padding: .2rem .55rem; border-radius: 999px;
  background: var(--kf-sunken); color: var(--ts-muted); white-space: nowrap;
}
.kf-play-live.is-live { background: rgba(34, 197, 94, .14); color: #15803d; }
[data-bs-theme="dark"] .kf-play-live.is-live { color: #86efac; }

.kf-play-body { padding: 0; }
.kf-play-grid { display: grid; grid-template-columns: 1fr 1fr; }
@media (max-width: 767.98px) { .kf-play-grid { grid-template-columns: 1fr; } }
.kf-play-col { padding: 1rem 1.1rem; min-width: 0; }
/* The divider carries the split rather than a border on each child, so it disappears cleanly when
   the columns stack on a phone. */
.kf-play-right { border-left: 1px solid var(--kf-line); background: var(--kf-sunken); }
@media (max-width: 767.98px) {
  .kf-play-right { border-left: 0; border-top: 1px solid var(--kf-line); }
}

.kf-play-h {
  display: flex; align-items: center; gap: .45rem; font-size: .8rem; font-weight: 700;
  color: var(--ts-ink); margin: 0 0 .75rem;
}
.kf-play-h .fa-solid { color: var(--kf); }
.kf-play-auto {
  margin-left: auto; font-size: .68rem; font-weight: 600; text-transform: none;
  letter-spacing: 0; color: var(--ts-muted);
}
.kf-play-lbl {
  display: block; font-size: .68rem; font-weight: 700; letter-spacing: .05em;
  text-transform: uppercase; color: var(--ts-muted); margin: .7rem 0 .25rem;
}
.kf-play-lbl em { font-style: normal; text-transform: none; letter-spacing: 0; font-weight: 500; }
#kf-play-value { min-height: 8rem; resize: vertical; }
#kf-play-schema { min-height: 8rem; resize: vertical; }

.kf-play-seg { display: inline-flex; border: 1px solid var(--kf-line); border-radius: .5rem; overflow: hidden; }
.kf-play-seg button {
  border: 0; background: transparent; padding: .3rem .7rem; cursor: pointer;
  font-size: .76rem; font-weight: 600; color: var(--ts-muted);
}
.kf-play-seg button + button { border-left: 1px solid var(--kf-line); }
.kf-play-seg button.on { background: var(--kf); color: #fff; }

.kf-play-actions { display: flex; align-items: center; gap: .6rem; margin-top: .9rem; flex-wrap: wrap; }
.kf-play-note { font-size: .72rem; color: var(--ts-muted); }
.kf-play-msg {
  margin-top: .7rem; padding: .55rem .7rem; border-radius: .5rem; font-size: .78rem;
  background: rgba(239, 68, 68, .1); color: #b91c1c;
}
[data-bs-theme="dark"] .kf-play-msg { color: #fca5a5; }

/* Read-only by design - requirement 4. Rendered as a definition list because that is what it is:
   settings the panel decided, shown so the reader knows what they are watching. */
.kf-play-cfg { display: grid; gap: .3rem; margin: 0 0 .8rem; font-size: .74rem; }
.kf-play-cfg > div { display: flex; gap: .5rem; }
.kf-play-cfg dt { color: var(--ts-muted); min-width: 5.5rem; font-weight: 600; }
.kf-play-cfg dd {
  margin: 0; color: var(--ts-ink); font-family: ui-monospace, Menlo, Consolas, monospace;
  overflow-wrap: anywhere;
}

.kf-play-records { display: grid; gap: .5rem; }
.kf-play-empty { font-size: .78rem; color: var(--ts-muted); margin: 0; padding: 1.2rem 0; }
.kf-play-rec {
  border: 1px solid var(--kf-line); border-radius: .6rem; background: var(--kf-surface);
  padding: .5rem .65rem;
}
.kf-play-rec header {
  display: flex; gap: .6rem; flex-wrap: wrap; align-items: baseline;
  font-size: .68rem; color: var(--ts-muted); margin-bottom: .35rem;
}
.kf-play-key { color: var(--kf); font-weight: 600; }
.kf-play-rec pre {
  margin: 0; font-size: .74rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere;
  color: var(--ts-ink); max-height: 9rem; overflow: auto;
}
.kf-play-pager {
  display: flex; align-items: center; justify-content: space-between; gap: .5rem;
  margin-top: .7rem;
}

/* Clear, in the listener's own heading rather than beside the pager: it acts on the whole list,
   and a control that empties everything should not sit next to one that moves through it. */
.kf-play-clear {
  margin-left: .5rem; border-radius: 999px; cursor: pointer;
  /* Taller and tinted. It was a hairline outline at .68rem, which read as a caption rather than a
     control - easy to miss, and easy to mis-click beside the heading it sits in. */
  font-size: .74rem; font-weight: 600; padding: .3rem .7rem;
  display: inline-flex; align-items: center; gap: .35rem;
  /* Amber, not the panel's indigo: this is the one control here that throws work away, and giving
     it the same accent as Send and the format switch would make a destructive action look like
     the ordinary ones. Not red either - nothing is destroyed on the broker, only forgotten on
     screen, and red would overstate it. */
  border: 1px solid rgba(217, 119, 6, .35);
  background: rgba(245, 158, 11, .12);
  color: #b45309;
}
.kf-play-clear:hover {
  background: rgba(245, 158, 11, .22); border-color: rgba(217, 119, 6, .55); color: #92400e;
}
.kf-play-clear:focus-visible { outline: 2px solid #d97706; outline-offset: 1px; }
[data-bs-theme="dark"] .kf-play-clear {
  border-color: rgba(251, 191, 36, .35); background: rgba(251, 191, 36, .14); color: #fcd34d;
}
[data-bs-theme="dark"] .kf-play-clear:hover {
  background: rgba(251, 191, 36, .24); color: #fde68a;
}

/* The soft notice after a config change emptied the list. Deliberately quiet: it is confirming
   something the reader just did, not warning them, so it takes the neutral surface rather than an
   alert colour. */
.kf-play-cleared {
  margin: 0 0 .7rem; padding: .45rem .6rem; border-radius: .5rem;
  background: var(--kf-soft); color: var(--ts-ink);
  border-left: 3px solid var(--kf); font-size: .74rem; line-height: 1.5;
}
.kf-play-cleared.d-none { display: none; }
#kf-play-compression { max-width: 12rem; }

/* ---- the arrival animation ----
   A ball rolls in from one edge, strikes "Try it live" and bursts. Once per page load; see
   arrive() for why it is not replayed on every render.

   fixed + pointer-events:none on the stage, and the elements inside are positioned purely by the
   transform the Web Animations API writes - so this layer never occupies layout, never reflows the
   page it is drawing over, and can never swallow the click it exists to encourage. */
.kf-play-fx {
  position: fixed; inset: 0; pointer-events: none;
  /* Above the page, below Bootstrap's modal (1055) and backdrop (1050): the cue belongs to the
     page, and should never be able to draw over the panel it opens. */
  z-index: 1040;
  overflow: hidden;
}
.kf-play-fx > span {
  position: absolute; top: 0; left: 0; display: block; border-radius: 50%;
  /* The transform carries the position, so the element's own box must not offset it. */
  margin: -5px 0 0 -5px; will-change: transform, opacity;
}
.kf-play-ball {
  width: 10px; height: 10px; background: var(--kf, #4f46e5);
  box-shadow: 0 0 0 4px var(--kf-soft, rgba(79, 70, 229, .14)),
              0 2px 8px rgba(15, 23, 42, .25);
}
.kf-play-spark {
  width: 6px; height: 6px; margin: -3px 0 0 -3px;
  background: var(--kf, #4f46e5);
  box-shadow: 0 0 6px var(--kf, #4f46e5);
}
/* Belt and braces beside the JS check: if the animation is ever started some other way, the media
   query still stops it moving. */
@media (prefers-reduced-motion: reduce) {
  .kf-play-fx { display: none; }
}

/* ==========================================================================
   Generate Maven / Gradle Spring Boot Project
   ========================================================================== */

/* .kf-project, .kf-project-btns and .kf-project-btns .ts-btn[disabled] used to live here: the
   dashed-rule row of two plain buttons that both pages carried. The .kf-gen panel at the foot of
   this file replaced that markup on the Listener and then on the Producer, so no page emits those
   classes any more and the rules are deleted rather than left to rot.

   What survives below is only what kafka-project.js still WRITES. It assigns
   note.className = 'kf-project-note' (plus ' kf-project-note-warn') outright on every refresh, so
   the note cannot be given a class of its own - these three rules are how it is reachable at all,
   and .kf-gen .kf-project-note in the panel's own section layers on top of them. */
.kf-project-note {
  margin: .55rem 0 0;
  font-size: .82rem;
  line-height: 1.45;
  color: var(--ts-muted, #6b7280);
}

.kf-project-note-warn {
  color: var(--ts-warn-t, #92400e);
}

.kf-project-go {
  font-weight: 600;
  white-space: nowrap;
}

/* ---- the dialog ----
   Every colour below comes from a token, and the panel's root carries .ts-tokens so that the
   tokens resolve at all - see the note at the top of this file. A literal #fff in here would be
   the one value that survived a missing token and would take the whole dialog white-on-white
   with it. */

.kf-proj {
  background: var(--kf-surface);
  border: 1px solid var(--kf-line);
  border-radius: 14px;
  color: var(--ts-text, inherit);
}

.kf-proj-head {
  display: flex;
  align-items: flex-start;
  gap: .8rem;
  padding: 1.1rem 1.2rem .9rem;
  border-bottom: 1px solid var(--kf-line);
}

.kf-proj-icon {
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  border-radius: 10px;
  background: var(--kf-soft);
  color: var(--kf);
  flex: none;
}

.kf-proj-title {
  margin: 0;
  font-size: 1.05rem;
  font-weight: 700;
  overflow-wrap: anywhere;
}

.kf-proj-sub {
  margin: .15rem 0 0;
  font-size: .82rem;
  color: var(--ts-muted, #6b7280);
}

.kf-proj-body {
  padding: 1rem 1.2rem;
}

.kf-proj-desc {
  margin: 0 0 1rem;
  font-size: .9rem;
  line-height: 1.55;
}

.kf-proj-h {
  margin: 1.1rem 0 .5rem;
  font-size: .76rem;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ts-muted, #6b7280);
}

.kf-proj-count {
  float: right;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
}

.kf-proj-chips {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: .35rem;
}

.kf-proj-chips li {
  font-size: .84rem;
  padding: .4rem .6rem;
  border-radius: 8px;
  background: var(--kf-sunken);
  border: 1px solid var(--kf-line);
  overflow-wrap: anywhere;
}

/* The file list can be long and its paths are wide. It scrolls inside its own box so the dialog
   itself never grows a horizontal scrollbar. */
.kf-proj-files {
  max-height: 260px;
  overflow: auto;
  border: 1px solid var(--kf-line);
  border-radius: 8px;
}

.kf-proj-files table {
  width: 100%;
  border-collapse: collapse;
  font-size: .8rem;
}

.kf-proj-files td {
  padding: .34rem .6rem;
  border-bottom: 1px solid var(--kf-line);
  white-space: nowrap;
}

.kf-proj-files tr:last-child td {
  border-bottom: 0;
}

.kf-proj-size {
  text-align: right;
  color: var(--ts-muted, #6b7280);
}

.kf-proj-busy {
  display: grid;
  place-items: center;
  gap: .9rem;
  padding: 2.4rem 1rem;
  text-align: center;
  color: var(--ts-muted, #6b7280);
  font-size: .88rem;
}

.kf-proj-spin {
  width: 30px;
  height: 30px;
  border-radius: 50%;
  border: 3px solid var(--kf-soft);
  border-top-color: var(--kf);
  animation: kf-proj-spin 720ms linear infinite;
}

@keyframes kf-proj-spin {
  to { transform: rotate(360deg); }
}

/* Reduced motion still needs SOMETHING to say the dialog is working, so the ring stays and only
   the rotation stops - a static ring plus the sentence beneath it. */
@media (prefers-reduced-motion: reduce) {
  .kf-proj-spin { animation: none; }
}

.kf-proj-error {
  padding: .8rem .9rem;
  border-radius: 8px;
  border: 1px solid var(--ts-danger-b, #fecaca);
  background: var(--ts-danger-s, #fef2f2);
  color: var(--ts-danger-t, #991b1b);
  font-size: .87rem;
  line-height: 1.5;
}

.kf-proj-foot {
  display: flex;
  align-items: center;
  gap: .5rem;
  padding: .85rem 1.2rem;
  border-top: 1px solid var(--kf-line);
}

.kf-proj-left {
  margin-right: auto;
  font-size: .78rem;
  color: var(--ts-muted, #6b7280);
}

/* ==========================================================================
   LAB LAYOUT - both Kafka pages
   ==========================================================================

   This block started life scoped to [data-kafka-mode="listener"], because only the Listener had
   been redesigned and the Producer loads the same sheet. The Producer now wears the same shape,
   so the scope is the bare [data-kafka-mode] attribute - which is on both pages and on nothing
   else in the codebase. The remaining Lab pages (databases, logs, tester, rest) do not carry the
   attribute and are untouched.

   Scoping is still not optional. Several selectors below reach OUTSIDE these pages' own classes -
   .dl-head, .jch-meter and its children belong to developer-lab.css and tool-studio.css and are
   shared with every other Lab page. Dropping the attribute would redesign five pages nobody asked
   about.

   Sections that RE-STATE an earlier rule at the same specificity - the masthead does - rely on
   source order to win, which is why this block is at the END of the file and must stay there. */

/* ---- masthead: title beside the meter ----
   The meter used to be a third child of .dl-head, shrunk to header scale so three things fitted
   on one line. It has its own column here and its natural size below. */
[data-kafka-mode] .kf-top {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: .9rem;
  align-items: center;
  margin-bottom: 1rem;
}
@media (min-width: 992px) {
  [data-kafka-mode] .kf-top { grid-template-columns: minmax(0, 1fr) 23rem; }
}
/* The gap between the two now belongs to the grid above. */
.tstudio[data-kafka-mode] .dl-head { margin-bottom: 0; }
/* A little larger than the shared 7rem. It is the only illustration above the fold and it now has
   a taller neighbour to hold its own against. */
[data-kafka-mode] .kf-hero { width: 9.5rem; }

/* The usage meter, back at its natural size on both Kafka pages.
   Same specificity as the .tstudio[data-kafka-mode] block near the top of this file, so these win
   on source order - see the note at the head of this section. flex-wrap plus a full-width gauge
   is what turns it from a header strip into a card: identity and numbers on the first row, the
   bar and the ring on the second. */
.tstudio[data-kafka-mode] .jch-meter {
  padding: 1.15rem 1.3rem; gap: .8rem 1rem; margin-bottom: 0; flex-wrap: wrap;
  border-radius: 1.1rem;
}
/* Round, matching the icon treatment on the generate cards. */
.tstudio[data-kafka-mode] .jch-meter-ico {
  width: 3rem; height: 3rem; border-radius: 50%; font-size: 1.1rem;
}
.tstudio[data-kafka-mode] .jch-meter-txt { flex: 1 1 11rem; }
/* The count as the headline, the label above it.
   jch-devlab-meter.js writes "<strong>0 of 100</strong> Kafka calls today" as one line, and the
   count is the thing being read - it should not be the same size as the words explaining it.
   column-reverse inverts the two without touching the shared script: the loose text after the
   </strong> becomes an anonymous flex item, which is what lets it be reordered at all. Sizing the
   label therefore has to happen on the CONTAINER, since an anonymous item has no selector. */
.tstudio[data-kafka-mode] .jch-meter-head {
  display: flex; flex-direction: column-reverse; align-items: flex-start; gap: .1rem;
  font-size: .78rem; color: var(--ts-muted);
}
.tstudio[data-kafka-mode] .jch-meter-head strong {
  font-size: 1.55rem; font-weight: 700; line-height: 1.1; color: var(--ts-ink);
  font-variant-numeric: tabular-nums;
}
.tstudio[data-kafka-mode] .jch-meter-sub { font-size: .75rem; margin-top: .15rem; }
.tstudio[data-kafka-mode] .jch-meter-gauge { flex: 1 1 100%; gap: .8rem; }
.tstudio[data-kafka-mode] .jch-meter-ring { width: 2.6rem; height: 2.6rem; }
.tstudio[data-kafka-mode] .jch-meter-ring i { width: 2rem; height: 2rem; font-size: .62rem; }

/* ---- "Try it live", now inside the masthead ----
   Solid rather than tinted here. In its old position it sat alone on a line and a tint was enough
   to find it; sitting under a paragraph of body copy it needs to read as the one thing on the
   masthead you can press. The tinted treatment stays untouched for the producer page. */
[data-kafka-mode] .dl-head-txt #kf-play-mount { margin: .7rem 0 0; }
[data-kafka-mode] .dl-head-txt .kf-play-open {
  background: var(--ts-brand); border-color: transparent; color: #fff;
  padding: .42rem .9rem .42rem .72rem; font-size: .79rem;
}
[data-kafka-mode] .dl-head-txt .kf-play-open .kf-play-dot { background: #fff; }
[data-kafka-mode] .dl-head-txt .kf-play-open:hover { background: var(--ts-brand-d); color: #fff; }

/* ---- the two-column body ----
   The aside belongs on the RIGHT on anything that is recognisably a desktop, so the threshold is
   992px, not the 1400px this started at.

   1400 was wrong, and the reasoning behind it is worth recording so it does not come back: it was
   picked so the aside would never squeeze the console's own col-xl-4/col-xl-8 pair. But those two
   columns only exist from Bootstrap's xl (1200px) - below that they are already stacked full
   width - so between 992 and 1199 there is nothing to squeeze, and the aside costs a stacked
   column some width it has to spare. The practical cost of getting it wrong was large: a 1366px
   laptop, or any 1080p screen at 125% scaling (1536 physical, 1229 CSS), is under 1400 and got
   the guide dumped underneath the form, which is exactly where its "what a listen costs" note
   is of no use to anybody.

   The column widens in two steps rather than sitting at one size, because 18rem out of 992 is a
   third of the page and out of 1600 it is a sixth. */
[data-kafka-mode] .kf-shell {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 1rem;
  align-items: stretch;
}
@media (min-width: 992px) {
  [data-kafka-mode] .kf-shell { grid-template-columns: minmax(0, 1fr) 15rem; }
}
@media (min-width: 1200px) {
  [data-kafka-mode] .kf-shell { grid-template-columns: minmax(0, 1fr) 16.5rem; }
}
@media (min-width: 1400px) {
  [data-kafka-mode] .kf-shell { grid-template-columns: minmax(0, 1fr) 18rem; }
}
/* The guide runs to the same floor as the form cards beside it, so the columns bottom out
   together instead of leaving a short card stranded at the top of a tall gap.
   Only while there are no results: once #kf-result-card is showing, the main column grows by
   however many records arrived, and a guide stretched to match would be a 2000px card holding
   400px of text. Then it reverts to its natural height. */
@media (min-width: 992px) {
  [data-kafka-mode] .kf-shell:has(#kf-result-card[hidden]) .kf-guide { height: 100%; }
}
[data-kafka-mode] .kf-main { min-width: 0; }

/* When the server says the Lab is switched off, developer-lab-kafka.js puts .d-none on
   #kf-console. The aside is a SIBLING of that element, so it would otherwise be left explaining
   the workings of a console that is no longer on screen. A browser without :has() simply keeps
   the aside, which is untidy rather than broken. */
[data-kafka-mode] .kf-shell:has(#kf-console.d-none) .kf-aside { display: none; }

/* ---- the standing column ---- */
.kf-guide {
  border: 1px solid var(--kf-line);
  border-radius: 1.1rem;
  background: var(--kf-surface);
  box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
  padding: 1.1rem 1.15rem 1.2rem;
}
.kf-guide-art { max-width: 11rem; margin: -.2rem auto .85rem; }
.kf-guide-art svg { width: 100%; height: auto; display: block; }
/* Sized as a card title, matching .kf-card-h h2: these are headings so the aside appears in the
   document outline, not so it competes with the h1. */
.kf-guide h2 { font-size: .92rem; font-weight: 700; line-height: 1.2; color: var(--ts-ink); margin: 0 0 .45rem; }
.kf-guide p { font-size: .78rem; line-height: 1.6; color: var(--ts-muted); margin: 0; }
/* A rule between the two halves, because they answer different questions - what it costs, and
   what it touches - and a reader looking for the second should not have to read the first. */
.kf-guide h2.kf-guide-next { margin-top: 1rem; padding-top: .95rem; border-top: 1px solid var(--kf-line); }

.kf-safe { list-style: none; margin: 0; padding: 0; display: grid; gap: .45rem; }
.kf-safe li {
  display: flex; align-items: center; gap: .5rem;
  font-size: .78rem; line-height: 1.4; color: var(--ts-ink);
}
/* Both selectors are needed: the FontAwesome build on this site is the SVG-JS one, which replaces
   each icon <span> with an <svg> after load. A rule that named only the span would style the
   ticks for one frame and then stop. */
.kf-safe li > span, .kf-safe li > svg { flex: 0 0 auto; font-size: .85rem; color: #16a34a; }
[data-bs-theme="dark"] .kf-safe li > span, [data-bs-theme="dark"] .kf-safe li > svg { color: #4ade80; }

/* Stacked above the console rather than beside it, the guide is a wide band. Running the four
   promises across it beats a single column of four with a metre of white space to its right. */
@media (min-width: 768px) and (max-width: 991.98px) {
  .kf-safe { display: flex; flex-wrap: wrap; gap: .5rem 1.6rem; }
}

/* ==========================================================================
   Generate Maven / Gradle Spring Boot Project
   ==========================================================================

   This replaced the plain .kf-project button row on both pages. The old .kf-project rules are
   still in this file above, unused by either page, and kept only because nothing else has been
   verified to be free of them.
   Two named things to choose between rather than two buttons in a row of five, because the
   choice is Maven-or-Gradle and the row above is about starting a listen. */
.kf-gen {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: .35rem 1rem;
  margin-top: 1.15rem;
  padding: 1rem 1.05rem;
  border-radius: .95rem;
  background: var(--kf-soft);
  border: 1px solid color-mix(in srgb, var(--ts-brand) 16%, transparent);
}
/* Both placements are EXPLICIT. .kf-gen-art carries grid-row: 1 / -1, which makes its row
   definite but leaves its column to auto-placement - and the packing algorithm then put the
   illustration in column 1 and pushed the heading and the two cards over to column 2, i.e. the
   panel back to front. Naming the columns is what stops that. */
.kf-gen-txt { grid-column: 1; grid-row: 1; min-width: 0; }
/* h3, under the card's h2: this is a section of "What to read", not a peer of it. */
.kf-gen h3 { font-size: .95rem; font-weight: 700; line-height: 1.25; color: var(--ts-ink); margin: 0 0 .2rem; }
.kf-gen-txt > p { font-size: .78rem; line-height: 1.5; color: var(--ts-muted); margin: 0 0 .8rem; }

.kf-gen-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: .7rem; }
.kf-gen-card {
  position: relative;
  display: flex;
  align-items: center;
  gap: .8rem;
  width: 100%;
  /* Right padding leaves the corner clear for the absolutely-positioned arrow, which is out of
     flow and would otherwise sit on top of a long label. */
  padding: .95rem 1.7rem .95rem .9rem;
  text-align: left;
  cursor: pointer;
  border: 1px solid var(--kf-line);
  border-radius: .9rem;
  background: var(--kf-surface);
  color: var(--ts-ink);
  transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
.kf-gen-card:hover:not([disabled]) {
  border-color: var(--ts-brand);
  box-shadow: 0 6px 16px rgba(79, 70, 229, .16);
  transform: translateY(-1px);
}
.kf-gen-card:focus-visible { outline: 2px solid var(--kf); outline-offset: 2px; }
/* Disabled here means "not yet", not "never", and #kf-project-note below says which. Dimming
   without also killing the pointer cursor leaves a control that looks pressable and does nothing. */
.kf-gen-card[disabled] { opacity: .55; cursor: not-allowed; }

/* Round, not a rounded square. The card is already a rounded rectangle and a second one nested
   inside it at 12px radius read as a mis-set thumbnail rather than as a badge. */
.kf-gen-ico {
  position: relative; flex: 0 0 auto;
  width: 2.7rem; height: 2.7rem; border-radius: 50%;
  display: grid; place-items: center; font-size: 1.05rem;
}
/* Green for Maven, indigo for Gradle: the two cards are one word apart in their labels, and a
   reader picking between them at a glance is picking on colour and glyph, not on reading. */
.kf-gen-ico-maven { background: rgba(34, 197, 94, .14); color: #15803d; }
[data-bs-theme="dark"] .kf-gen-ico-maven { color: #6ee7b7; }
.kf-gen-ico-gradle { background: var(--kf-soft); color: var(--kf); }
/* Sits on the tile's corner and deliberately overhangs it, so the tile keeps its silhouette. */
.kf-gen-badge {
  position: absolute; right: -.15rem; bottom: -.15rem;
  width: 1.3rem; height: 1.3rem; border-radius: 50%;
  display: grid; place-items: center; font-size: .55rem;
  background: var(--kf-surface); border: 1px solid var(--kf-line); color: var(--ts-muted);
}
.kf-gen-name { display: flex; flex-direction: column; min-width: 0; font-size: .86rem; line-height: 1.25; }
/* Its own element rather than a loose text node, purely so it can be told not to wrap. "Generate
   Maven" breaking after "Generate" turned a two-line card into a three-line one and made the two
   cards different heights. */
.kf-gen-name b { font-weight: 700; white-space: nowrap; }
.kf-gen-name em { font-style: normal; font-weight: 500; font-size: .78rem; color: var(--ts-muted); margin-top: .12rem; }
/* Out of flow in the top-right corner rather than a third flex child. As a child it competed for
   width with the two-line label and pushed it into three lines on the narrower of the two cards. */
.kf-gen-arrow {
  position: absolute; top: .7rem; right: .8rem;
  font-size: .78rem; color: var(--ts-faint); transition: color .15s ease, transform .15s ease;
}
.kf-gen-card:hover:not([disabled]) .kf-gen-arrow { color: var(--kf); transform: translateX(2px); }

/* Bottom-aligned and allowed to overhang the panel's floor, so the figure stands ON the panel
   edge instead of floating in a box of its own air. */
.kf-gen-art { grid-column: 2; grid-row: 1 / -1; width: 9.5rem; align-self: end; margin-bottom: -1rem; }
.kf-gen-art svg { width: 100%; height: auto; display: block; }

/* #kf-project-note is shared with the producer page and kafka-project.js assigns its className
   outright, so it cannot be given a class of its own here - it is reached through .kf-gen
   instead. Full width under both cards: it explains why they are off, and a note that explained
   only the card it sat beside would be answering half the question. */
/* Column 1 only, NOT 1 / -1. The illustration spans both rows and overhangs the panel floor, so a
   full-width note would run underneath it and lose its last words behind a laptop. */
.kf-gen .kf-project-note {
  grid-column: 1;
  margin: .75rem 0 0;
  font-size: .78rem;
  line-height: 1.5;
}
.kf-gen .kf-project-note-warn { color: var(--ts-warn-t, #92400e); }
[data-bs-theme="dark"] .kf-gen .kf-project-note-warn { color: #fcd34d; }

/* The illustration is the first thing to go: it says nothing the heading has not. */
@media (max-width: 991.98px) {
  .kf-gen { grid-template-columns: minmax(0, 1fr); }
  .kf-gen-art { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .kf-gen-card, .kf-gen-arrow { transition: none; }
  .kf-gen-card:hover:not([disabled]) { transform: none; }
}
