/* Month grid, filters and the publisher dialogs.
 *
 * Written against STYLE.md: no border-radius anywhere, no `border:`
 * shorthand, no raw colour, hover behind @media (hover: hover) with :active
 * and :focus-visible reproducing the same effects. The accent appears only as
 * a marker — it never fills anything and never carries text.
 */

/* month grid ----------------------------------------------------------- */


.cal-head {
  display: flex;
  align-items: baseline;
  gap: var(--space-4);
  margin-bottom: var(--space-4);
  flex-wrap: wrap;
}
.cal-month {
  margin: 0;
  font-size: var(--text-2xl);
  font-weight: var(--wght-medium);
  letter-spacing: var(--track-title);
  line-height: var(--leading-tight);
}
.cal-nav { margin-left: auto; display: flex; gap: var(--space-2); align-items: center; }

.cal-weekdays {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: 1px;
  margin-bottom: var(--space-1);
}
.cal-weekday {
  padding: 0 var(--space-2);
  font-size: var(--text-3xs);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-faint);
}

/* The grid's own background shows through the 1px gaps as the dividers, so no
   cell needs a border of its own.

   --line-strong, not --line. The days of the displayed month are a filled
   block now (see .cal-day), and in light that fill is #d3d3d3 against a #c9c9c9
   hairline — ten levels, which is a rule nobody can see. This is what the
   "emphasised separation" step is for: a grid needs its cells told apart, and a
   month grid needs it more than a list of rows does. */
.cal-grid {
  position: relative;
  display: grid;
  gap: 1px;
  background: var(--line-strong);
  border-top: 1px solid var(--line-strong);
  border-bottom: 1px solid var(--line-strong);
}

/* One grid per week. The day cells occupy row 1 through the last lane, so
   they are the background of the whole week; chips sit on top of them in
   rows 2..n. Entries therefore appear inside their day while a multi-day bar
   can still span columns — a separate strip below the cells would get the
   spanning right and the reading wrong. */
.cal-week {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  column-gap: 1px;
  row-gap: var(--space-1);
  background: var(--line);
}

/* The displayed month is the filled block; the adjacent-month fillers keep the
   page ground and recede by not being filled at all (see .cal-day-out).
   This is the inversion of what shipped first, where the *fillers* took the
   darker surface — in dark that was #131314 against #1a1a1a, seven levels per
   channel, which is a distinction that exists only in the stylesheet.

   --surface-00 is the right step for it and not merely the darkest one: it is
   the single token defined as darker in both themes, so "the month is a sunken
   field" reads the same way on dark and on light. Every other step moves in
   opposite directions between the two, which is how the old pairing ended up
   twice as strong in light as in dark. */
.cal-day {
  position: relative;
  background: var(--surface-00);
  min-height: 5rem;
  padding: var(--space-1);
  cursor: pointer;
}

/* The whole cell opens that day. Stretched by a pseudo-element, the same
   pattern the feed rows use, so the entries above it keep their own hit area
   instead of being nested inside an anchor. */
.cal-day-link {
  display: flex;
  align-items: baseline;
  gap: var(--space-1);
  text-decoration: none;
}
.cal-day-link::after {
  content: "";
  position: absolute;
  inset: 0;
}
/* One hover fill for both kinds of cell, deliberately. --surface-10 is the only
   step that differs from *both* resting fills in *both* themes without landing
   on --surface-15 or --surface-20, which the entry chips already occupy — a
   hovered cell that takes the chip's colour erases the chips sitting on it.
   The move is toward the ground from an in-month cell and away from it from a
   filler, which is the same gesture read from either side: this cell is live. */
@media (hover: hover) {
  .cal-day:hover { background: var(--surface-10); }
}
.cal-day:active { background: var(--surface-10); }
.cal-day:focus-within { background: var(--surface-10); }

/* Hidden by default and shown where dots stop being countable by eye: on a
   crowded cell, and on mobile at any count, because there only three of them
   are ever drawn. The number is the honest total either way. */
.cal-day-count {
  display: none;
  margin-left: auto;
  font-size: var(--text-3xs);
  font-weight: var(--wght-medium);
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
}

.cal-day-dense .cal-day-count { display: block; }

.cal-sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* One day's entries, in normal flow. Lifted above the cell's stretched link
   so each one is its own target. */
.cal-stack {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  padding: 0 var(--space-1);
}

.cal-entry {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  min-width: 0;
  padding: 2px var(--space-1);
  font: inherit;
  font-size: var(--text-3xs);
  font-weight: var(--wght-medium);
  line-height: 1.35;
  text-decoration: none;
  color: var(--text);
  background: var(--surface-15);
}
.cal-entry-title {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

/* The dot is the dense form of an entry; in the readable form the chip itself
   carries the meaning and the dot would be a second bullet. */
.cal-entry-dot { display: none; }

@media (hover: hover) {
  .cal-entry:hover { background: var(--surface-20); }
}
.cal-entry:active,
.cal-entry:focus-visible { background: var(--surface-20); }

.cal-entry-cancelled .cal-entry-title { text-decoration: line-through; color: var(--text-muted); }

/* Saved carries the accent AND a ring. Colour alone is ruled out — the ring is
   what survives greyscale and colour blindness, same rule as the struck-through
   cancellation above. */
.cal-entry-saved { box-shadow: inset 0 0 0 1px var(--accent); }
.cal-entry-saved .cal-entry-dot { background: var(--accent); }

.cal-entry-highlight { background: var(--surface-20); box-shadow: inset 2px 0 0 0 var(--accent); }

/* Dense: the title goes, the dot arrives, and the entries wrap into rows so a
   day with thirty of them stays a block rather than a column thirty tall.
   Set by the server above DENSE_FROM, and unconditional below 40rem where no
   title fits in a seventh of the viewport anyway. */
.cal-stack-dense {
  flex-direction: row;
  flex-wrap: wrap;
  gap: 3px;
  align-content: start;
}
.cal-stack-dense .cal-entry {
  padding: 7px;
  background: none;
  justify-content: center;
}
/* Past the eighth the cell stops being readable as a count and the number
   takes over. The entries stay in the markup — hidden, not dropped, so the
   keyboard and the no-JS page still reach every one of them. */
.cal-stack-dense .cal-entry:nth-child(n+9) { display: none; }
.cal-stack-dense .cal-entry-title { display: none; }

.cal-stack-dense .cal-entry-dot {
  display: block;
  width: 6px;
  height: 6px;
  background: var(--text-muted);
  flex: none;
}
@media (hover: hover) {
  .cal-stack-dense .cal-entry:hover .cal-entry-dot { background: var(--text); }
}
/* The halo is not "the cell, punched out" — it is the accent's guaranteed
   ground, and that is why it stays --surface-05 now that the cell underneath it
   does not. The curated publisher accents are certified against --surface-05
   and against nothing else: on light, four of the eight fall below 3:1 on
   #d3d3d3. A 2px plate of the ground under the ring keeps the whole marker on
   the one surface the band was measured on, whichever cell it lands in. */
.cal-stack-dense .cal-entry-saved { box-shadow: none; }
.cal-stack-dense .cal-entry-saved .cal-entry-dot {
  background: var(--accent);
  box-shadow: 0 0 0 2px var(--surface-05), 0 0 0 3px var(--accent);
}

/* Snappy means short and few, and never for somebody who asked for less. */
@media (prefers-reduced-motion: no-preference) {
  .cal-day { transition: background var(--dur-surface) var(--ease); }
  .cal-entry { transition: background var(--dur-fast) var(--ease); }
  .cal-entry-dot {
    transition: background var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease);
  }
  @media (hover: hover) {
    .cal-entry:hover .cal-entry-dot { transform: scale(1.4); }
  }
}

/* Below 40rem a seventh of the viewport is ~44px: exactly one tap target wide
   and far too narrow for a title. Every cell is dense here, whatever it holds,
   and the dot keeps a full-size hit area around it. */
@media (max-width: 40rem) {
  .cal-stack {
    flex-direction: row;
    flex-wrap: wrap;
    gap: 3px;
    align-content: start;
  }
  /* The dots stop being individual targets here and become the cell's own
     indicator: a 46px column cannot hold seven 44px targets, and the approved
     design is "tap opens the day, titles live there". Taps fall through to the
     cell link underneath; the keyboard still reaches every entry, because
     pointer-events does not affect focus. */
  .cal-stack { pointer-events: none; }
  .cal-stack .cal-entry { padding: 0; background: none; }
  /* Three is the shape that was signed off. The count carries the truth. */
  .cal-stack .cal-entry:nth-child(n+4) { display: none; }
  .cal-day-count { display: block; }
  .cal-stack .cal-entry-title { display: none; }
  .cal-stack .cal-entry-dot {
    display: block;
    width: 6px;
    height: 6px;
    background: var(--text-muted);
    flex: none;
  }
  .cal-stack .cal-entry-saved { box-shadow: none; }
  .cal-stack .cal-entry-saved .cal-entry-dot {
    background: var(--accent);
    box-shadow: 0 0 0 2px var(--surface-05), 0 0 0 3px var(--accent);
  }
  .cal-day { min-height: 3.5rem; }
  .cal-day-count { font-size: var(--text-3xs); }
}

/* A readable chip is a real target on touch, so it gets the full tap height.
   Dots do not appear in this branch: below 40rem they are not targets at all,
   and above it they carry their own padding. */
@media (pointer: coarse) and (min-width: 40rem) {
  .cal-stack:not(.cal-stack-dense) .cal-entry { min-height: var(--tap); }
}
/* A filler takes the page ground and no fill of its own, so it reads as the
   page the month block is lying on. It is faded, not shaded: the previous
   version gave it a *second* surface, which is what made the boundary a
   difference of seven levels in dark and fifteen in light. */
.cal-day-out { background: var(--surface-05); }

.cal-day-number {
  font-size: var(--text-3xs);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}

/* The fade, and its whole extent: the day number, one step quieter.
 *
 * Never `opacity` on the cell, and never a fade of the stack. An adjacent-month
 * cell carries real occurrences, and a chip at 60% is still a link somebody
 * taps — dimming the one control in a cell to signal something about the cell
 * is how a date becomes untappable-looking and gets tapped anyway. Chips,
 * bars, dots and counts keep their full contrast in every cell.
 *
 * The markup helps here and should not be relied on: .cal-stack is a *sibling*
 * of .cal-day, not a child, so a descendant rule cannot reach the chips even by
 * accident. What can is an opacity or a filter on .cal-week, which would take
 * the whole row down including the six in-month days beside the filler.
 *
 * --text-faint is the sanctioned pairing here and only here: it clears AA on
 * --surface-05 in both themes and on nothing else, and --surface-05 is exactly
 * what a filler cell is.
 *
 * The month boundary does not rest on this. It is carried by the fill — the
 * displayed month is a block, a filler is bare page — and by the numbering
 * restarting at 1 mid-grid. The fade is the third signal, not the only one.
 * Before it, the boundary rested on colour alone, which is what the roadmap
 * entry was actually about. */
.cal-day-out .cal-day-number { color: var(--text-faint); }

/* Today is marked, not merely coloured: a square in the accent beside the
   number, because no state may be carried by colour alone.
 *
 * The number sits on a plate of --surface-05 rather than on the cell. Two
 * reasons, and the second is the binding one. It lifts today out of the month
 * block, which is now a filled field that a colour change alone would sink
 * into — and it puts the accent square back on the one surface the curated
 * publisher accents are certified against. On light, green, vermilion, magenta
 * and olive all drop below 3:1 on the in-month cell; on the ground every one of
 * the eight clears it. A calendar page carrying the venue's own accent must not
 * be the surface where "today" quietly stops being visible.
 *
 * The plate is also the non-colour half of the signal, alongside the square and
 * the weight: today is the one date on a raised chip. */
.cal-day-today .cal-day-number {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: 0 var(--space-1);
  background: var(--surface-05);
  color: var(--text);
  font-weight: var(--wght-medium);
}
.cal-day-today .cal-day-number::before {
  content: "";
  width: var(--marker);
  height: var(--marker);
  background: var(--accent);
  flex: none;
}

.cal-chip {
  position: relative;
  z-index: 1;
  margin: 0 var(--space-1);
  align-self: start;
  display: block;
  width: calc(100% - var(--space-2));
  font: inherit;
  font-size: var(--text-3xs);
  font-weight: var(--wght-medium);
  line-height: 1.35;
  text-align: left;
  text-decoration: none;
  padding: var(--space-1) var(--space-2);
  background: var(--surface-15);
  color: var(--text);
  border-style: none;
  cursor: pointer;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
  transition: background var(--dur-surface) var(--ease);
}

@media (hover: hover) {
  .cal-chip:hover { background: var(--surface-20); }
}
.cal-chip:active,
.cal-chip:focus-visible { background: var(--surface-20); }

/* A month grid cannot give every chip 44 px on a desktop without becoming
   absurd, but on a touch device the chip is the only way in — so the tap
   target is restored exactly where it is needed. */
@media (pointer: coarse) {
  .cal-chip { min-height: var(--tap); display: flex; align-items: center; }
}

/* Never colour alone: a cancelled date is struck through, a moved one carries
   a marker, a draft is dimmed. */
.cal-chip-cancelled { text-decoration: line-through; color: var(--text-muted); }
.cal-chip-draft { color: var(--text-muted); background: var(--surface-10); }
.cal-chip-moved::before {
  content: "";
  display: inline-block;
  width: var(--marker);
  height: var(--marker);
  background: var(--accent);
  margin-right: var(--space-1);
  vertical-align: middle;
}

/* An entry continuing into the next week must not look finished, so the edge
   it crosses loses its margin and the bar runs to the edge of the week. */
.cal-chip-open-right { margin-right: 0; }
.cal-chip-open-left { margin-left: 0; }

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

.cal-picking .cal-day { cursor: pointer; }
@media (hover: hover) {
  .cal-picking .cal-day:hover { background: var(--surface-10); }
}
.cal-picking .cal-day:active { background: var(--surface-10); }

.cal-chip-selected { background: var(--surface-20); }
.cal-chip-selected::after {
  content: "";
  display: inline-block;
  width: var(--marker);
  height: var(--marker);
  background: var(--accent);
  margin-left: var(--space-1);
  vertical-align: middle;
}

.cal-chip-dragging { opacity: 0.4; }
.cal-day-drop { background: var(--surface-10); }

.cal-hint {
  margin: 0 0 var(--space-3);
  padding: var(--space-2) var(--space-3);
  background: var(--surface-15);
  color: var(--text);
  font-size: var(--text-xs);
}

/* menu ----------------------------------------------------------------- */

.cal-menu {
  position: absolute;
  z-index: 30;
  display: grid;
  min-width: 11rem;
  background: var(--surface-15);
  border-top: 1px solid var(--line-strong);
  border-bottom: 1px solid var(--line-strong);
  overflow: hidden;
}
.cal-menu button {
  font: inherit;
  font-size: var(--text-xs);
  text-align: left;
  min-height: var(--tap);
  padding: var(--space-2) var(--space-4);
  background: none;
  border-style: none;
  color: var(--text);
  cursor: pointer;
}
@media (hover: hover) {
  .cal-menu button:hover { background: var(--surface-20); }
}
.cal-menu button:active,
.cal-menu button:focus-visible { background: var(--surface-20); }

/* view toggle ---------------------------------------------------------- */

/* Two links, not a script. The public calendar has to work with JavaScript
   switched off, so the toggle is two URLs and the state lives in the query
   string — which also makes a calendar view shareable. */
.view-toggle {
  display: flex;
  gap: var(--space-4);
  margin: var(--space-4) 0 var(--space-3);
  font-size: var(--text-xs);
}
.view-toggle a {
  color: var(--text-muted);
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--tap);
}
@media (hover: hover) {
  .view-toggle a:hover { color: var(--text); }
}
.view-toggle a[aria-current="page"] { color: var(--text); font-weight: var(--wght-medium); }
.view-toggle a[aria-current="page"]::before {
  content: "";
  width: var(--marker);
  height: var(--marker);
  background: var(--accent);
}

.cal-chip-highlight { background: var(--surface-20); color: var(--text); }

/* The day the open event panel is about. A ring rather than a fill: the cell
   already carries chips that use fill for their own state, and today's date
   carries the accent plate — a second filled thing in the same cell would be
   two answers to "what is special here".
   
   Inset so it does not sit on the grid line and read as a border that moved. */
.cal-day-chosen {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* filters -------------------------------------------------------------- */

/* A plain GET form: the browser builds the query string, so filtering works
   with JavaScript switched off and every filtered view is a shareable URL. */
/* One row, always. Five filled blocks wrapping onto three lines read as a form
   to fill in; one line of quiet controls reads as a toolbar you glance at.
   Scrolls inside itself on narrow screens rather than wrapping — the page must
   never scroll sideways, but this row may. */
/* Two rows, and the control row wraps rather than scrolling sideways.
   The sideways scroll was the right answer while every control was a select:
   it kept the first event on the first screen. It stopped being the right
   answer the moment a control opens a panel — `overflow-x: auto` makes a
   scroll container, a scroll container clips on *both* axes, and the open
   menu was drawn behind the feed where nobody could see it. Wrapping costs
   one line of height at 375px and is what makes the menus exist at all. */
.filters {
  display: grid;
  gap: var(--space-2);
  margin: var(--space-4) 0 0;
}
.filter-row {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  align-items: center;
  /* The focus ring sits outside the control, so a clipping container would
     shave it off. One step of padding buys the room back. */
  padding-block: var(--space-1);
}
.filter-row-search { padding-block: 0; }

/* `.filters .filter-search`, not `.filter-search`: base.css styles the field
   as `input[type="search"]`, which is one attribute selector and beats a bare
   class. The input kept 16px type and 12px padding while the selects took
   these values, so the row had two heights. */
.filters select,
.filters .filter-search {
  font: inherit;
  font-size: var(--text-2xs);
  /* Not var(--tap): these sit in a row of their own with nothing to mis-tap
     into, and 44px each is what made the bar read as a stack of buttons. The
     row keeps the tap height; each control gives back the padding. */
  min-height: 2.25rem;
  padding: var(--space-2);
  width: auto;
  flex: 0 0 auto;
  background: var(--surface-00);
  color: var(--text-muted);
  border-style: none;
  border-bottom: 1px solid var(--line);
  appearance: none;
}
/* appearance: none strips the native select chrome, and with it the pointer
   cursor that told people these were clickable at all. */
.filters select { cursor: pointer; }
@media (hover: hover) {
  .filters select:hover { color: var(--text); border-bottom-color: var(--line-strong); }
  .filters select:not([data-empty]):hover { border-bottom-color: var(--accent); }
}
/* A set filter is content, an unset one is a label. Colour is not the only
   signal — the option text changes from "Alle Städte" to the city itself. */
.filters select:not([data-empty]) { color: var(--text); border-bottom-color: var(--accent); }

/* An outline, not the inset bar base.css gives form fields: these controls sit
   in a scrolling row, and an inset mark on the left edge is invisible the
   moment the control is scrolled to the edge of it. */
.filters select:focus-visible,
.filters .filter-search:focus-visible {
  box-shadow: none;
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Search takes its own row now, so it takes all of it: it is the one control
   whose content is a phrase rather than a word, and sharing a line with five
   others left it too narrow to read back what was typed. */
.filters .filter-search {
  flex: 1 1 100%;
  min-width: 0;
  color: var(--text);
}
/* The reset only has something to undo once a filter is set, but it keeps its
   square either way. Removing it shifted every control in the row sideways the
   moment a filter was chosen — the same defect the nav marker had, and the same
   fix: reserve the box, switch only what is painted in it.
   `hidden` rather than a class, so it also leaves the tab order and the
   accessibility tree when there is nothing to reset. */
.filter-reset { flex: none; }

/* A span, not a disabled link: with nothing to undo there is no destination,
   so it carries no href, no focus and no pointer at all — an inert mark rather
   than a control that quietly does nothing.

   It stays visible and keeps its square either way. Removing it, or hiding it,
   shifted every field in the row sideways the moment a filter was chosen. In
   the inert state it takes the hairline tone, so it sits at the weight of the
   field edges beside it instead of asking to be pressed. */
span.filter-reset { color: var(--line); cursor: default; }

/* Hidden only once the enhancement script has run and set the attribute — a
   page without JavaScript keeps the button, because there it is the only way
   to apply a filter at all. Keyed off the script's own signal rather than a
   media query or a class on <html>, so the button cannot disappear while the
   thing that replaces it failed to load. */
.filters[data-autofilter="on"] .filter-submit { display: none; }
/* The timeline is a second GET form with the same bargain, so it keeps its own
   button on the same terms. Written as its own rule rather than by widening
   the selector above to a bare attribute, because that would also swallow any
   future form that carries the attribute for a different reason. */
.timeline[data-autofilter="on"] .filter-submit { display: none; }

/* timeline — the list's date axis --------------------------------------- */

/* Twelve months, two handles, and nothing drawn between them but the selection
   itself. A bar per month showing how much programme it holds was specced and
   dropped before it was built: this is a range picker, and a range picker that
   also renders a chart is two things.
 *
 * Every position on this control comes out of one piece of arithmetic. A native
 * range thumb's centre travels between half a thumb from each end, so the track,
 * the selection, the drawn handles and the month labels all have to use the same
 * inset or they drift apart from the handles they belong to — most visibly at
 * the two ends, where a naive twelve-column grid is half a month out. --tl-hit
 * is that thumb width, and --tl-run is the distance its centre covers. */
.timeline {
  --tl-hit: var(--tap);
  --tl-run: calc(100% - var(--tl-hit, 44px));
  /* The band's last index: 364 or 365, because twelve months is 365 or 366 days
     depending on whether a 29 February falls inside them. Computed by the
     server and read here rather than hardcoded, since a constant would be
     wrong every fourth year and only for a month. */
  --tl-span: 365;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  margin-bottom: var(--space-5);
}

/* **This row may not become two rows, and that is not a taste.** The band sits
   directly beneath it, so a label that wraps pushes the track down the page —
   and it wraps exactly when a range is chosen, which is to say while a thumb is
   still under a fingertip. The handle then moves out from under the finger that
   is dragging it.

   `nowrap` is affordable because the no-JS submit button leaves the layout
   entirely (`display: none`, not `visibility`) the moment filters.js sets
   data-autofilter, so with a script this row holds three items and never four.
   Without one it holds four and the readout is what gives way, which is the
   right thing to sacrifice: the button is how a filter applies at all there. */
.timeline-head {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: var(--space-2);
}
/* Only the readout absorbs a shortfall. A switch whose word wraps inside its own
   pill, or a Heute that loses its last letter, would both be worse than a date
   that ends in an ellipsis. */
.timeline-head .pill-switch,
.timeline-head .filter-submit { flex: none; }
/* The one thing on this control that states the selection in words. Takes the
   full text tone rather than the muted one for exactly that reason.

   A step down the scale from the row's other text, and measured rather than
   guessed: the widest range this can hold — `30. Sept. 26 – 30. Sept. 27`, both
   ends in the longest German month abbreviation — is 131px at 12px against the
   126px this row has to spare, and 120px at 11px. `min-width: 0` is what lets a
   flex item shrink past its content at all, and the ellipsis is the guarantee
   for the case the arithmetic misses: a 320px screen, a longer language, a
   future third control. It truncates; it never wraps. */
.timeline-readout {
  margin: 0 auto 0 0;
  font-size: var(--text-3xs);
  color: var(--text);
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* A link, not a button, and shaped like neither of its neighbours on purpose:
   Heute replaces the range rather than narrowing it. Same quiet chip the cycle
   switch uses, square like everything that is not a switch. */
.timeline-today {
  display: inline-flex;
  flex: 0 0 auto;
  align-items: center;
  min-height: 2.25rem;
  padding: 0 var(--space-3);
  background: var(--surface-00);
  color: var(--text-muted);
  font-size: var(--text-2xs);
  text-decoration: none;
  white-space: nowrap;
  transition: color var(--dur-fast) ease;
}
@media (hover: hover) {
  .timeline-today:hover { color: var(--text); }
}
.timeline-today:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.timeline-band {
  position: relative;
  height: var(--tl-hit, 44px);
  touch-action: none;
}

/* The unselected band. --line-strong rather than a surface step: it has to be
   visible as a track on both themes, and it is the ground the selection is
   measured against. */
.timeline-track {
  position: absolute;
  top: 50%;
  left: calc(var(--tl-hit, 44px) / 2);
  right: calc(var(--tl-hit, 44px) / 2);
  height: 2px;
  margin-top: -1px;
  background: var(--line-strong);
}

/* **The selection is not a tint.** It is three times the thickness of the track
   underneath it as well as a different colour, so what is chosen still reads for
   somebody who cannot tell the accent from the hairline — the same rule that put
   a moving knob next to the green on the free filter. The month labels inside it
   change weight and tone for the same reason.
 *
 * Measured, both themes, at the four boundaries that matter:
 *
 *   handle on track      9.91 dark / 8.51 light
 *   handle on selection  4.09 / 3.94
 *   selection on page    3.73 / 3.60
 *   selection vs track   2.42 / 2.16
 *
 * The last one sits under 3:1 and cannot be lifted over it with these tokens:
 * the accent's luminance is pinned to the 0.135–0.220 band that clears 3:1
 * against *both* grounds at once, and the only greys far enough from it are the
 * sunken step — which would drop the unselected track to 1.28:1 against the page
 * and make the part of the year you did not choose disappear. So the difference
 * in thickness, the accent tick marks and the label weight carry the selection,
 * and the hue is the fourth signal rather than the only one. */
.timeline-selection {
  position: absolute;
  top: 50%;
  height: 6px;
  margin-top: -3px;
  background: var(--accent);
  left: calc(var(--tl-hit, 44px) / 2 + var(--tl-from, 0) * var(--tl-run, 100%) / var(--tl-span, 365));
  width: calc((var(--tl-to, 365) - var(--tl-from, 0)) * var(--tl-run, 100%) / var(--tl-span, 365));
}

/* **A day index as three decimal digits**, because there is no other way to
   hand CSS an arbitrary number here. The month band got away with one rule per
   value — twelve of them. A day band would need 366 per handle, and the
   production CSP is `style-src 'self'`, which drops an inline custom property
   without a word; a per-request stylesheet is not on the table for a filter
   control. Three digits is 24 rules that every position on this control shares,
   it is exact rather than rounded, and the script moves a handle by rewriting
   the same attributes — one mechanism for the server's first paint and the
   browser's updates rather than two.

   Unscoped on purpose: the band carries an `f` set and a `t` set for its two
   handles, and each month tick carries an `f` set for its own offset. */
[data-f3="0"] { --f3: 0; }
[data-f3="1"] { --f3: 1; }
[data-f3="2"] { --f3: 2; }
[data-f3="3"] { --f3: 3; }
[data-f2="0"] { --f2: 0; }
[data-f2="1"] { --f2: 1; }
[data-f2="2"] { --f2: 2; }
[data-f2="3"] { --f2: 3; }
[data-f2="4"] { --f2: 4; }
[data-f2="5"] { --f2: 5; }
[data-f2="6"] { --f2: 6; }
[data-f2="7"] { --f2: 7; }
[data-f2="8"] { --f2: 8; }
[data-f2="9"] { --f2: 9; }
[data-f1="0"] { --f1: 0; }
[data-f1="1"] { --f1: 1; }
[data-f1="2"] { --f1: 2; }
[data-f1="3"] { --f1: 3; }
[data-f1="4"] { --f1: 4; }
[data-f1="5"] { --f1: 5; }
[data-f1="6"] { --f1: 6; }
[data-f1="7"] { --f1: 7; }
[data-f1="8"] { --f1: 8; }
[data-f1="9"] { --f1: 9; }
[data-t3="0"] { --t3: 0; }
[data-t3="1"] { --t3: 1; }
[data-t3="2"] { --t3: 2; }
[data-t3="3"] { --t3: 3; }
[data-t2="0"] { --t2: 0; }
[data-t2="1"] { --t2: 1; }
[data-t2="2"] { --t2: 2; }
[data-t2="3"] { --t2: 3; }
[data-t2="4"] { --t2: 4; }
[data-t2="5"] { --t2: 5; }
[data-t2="6"] { --t2: 6; }
[data-t2="7"] { --t2: 7; }
[data-t2="8"] { --t2: 8; }
[data-t2="9"] { --t2: 9; }
[data-t1="0"] { --t1: 0; }
[data-t1="1"] { --t1: 1; }
[data-t1="2"] { --t1: 2; }
[data-t1="3"] { --t1: 3; }
[data-t1="4"] { --t1: 4; }
[data-t1="5"] { --t1: 5; }
[data-t1="6"] { --t1: 6; }
[data-t1="7"] { --t1: 7; }
[data-t1="8"] { --t1: 8; }
[data-t1="9"] { --t1: 9; }

.timeline-band {
  --tl-from: calc(var(--f3, 0) * 100 + var(--f2, 0) * 10 + var(--f1, 0));
  --tl-to: calc(var(--t3, 0) * 100 + var(--t2, 0) * 10 + var(--t1, 0));
}
/* A tick reuses the `f` set for its own offset. It is not inside the band, so
   the two never see each other's digits. */
.timeline-tick {
  --tl-day: calc(var(--f3, 0) * 100 + var(--f2, 0) * 10 + var(--f1, 0));
}

/* Twelve months are 28 to 31 days each, so the band's own span is what the
   server measured — 364 or 365, depending on whether a 29 February falls
   inside them. Two values, because there are only ever two. */
.timeline[data-span="364"] { --tl-span: 364; }
.timeline[data-span="365"] { --tl-span: 365; }

/* Two inputs lying on top of each other, both spanning the whole band. If the
   track took pointer events one of them would swallow every event meant for the
   other, everywhere — not just where the handles overlap. So only the thumbs are
   live, and the price is that clicking the bare track does not jump a handle. */
.timeline-range {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  background: transparent;
  appearance: none;
  pointer-events: none;
  z-index: 2;
}
.timeline-range::-webkit-slider-runnable-track { height: 100%; background: transparent; }
.timeline-range::-moz-range-track { height: 100%; background: transparent; }
/* The full tap target, and transparent. Twelve segments across 375px is about
   31px per month, so a thumb drawn at the size it should *look* would be well
   under a fingertip — and a thumb cannot hold a smaller shape inside itself, so
   what is drawn is a sibling element instead. */
/* **The handle says it can be grabbed.** The band is the only thing in the
   product that is dragged, so it is the only thing that has to say so — and
   `pointer` says "this does something", which every other control here also
   does. Two mechanics rather than one preference:
 *
 * The cursor sits on the *thumb*, not on the mark. They are two elements since
 * the hit area became 44px and the drawn handle 12px, and the pointer only ever
 * meets the thumb — the mark takes no events at all.
 *
 * And `grabbing` hangs off the flag the script sets on pointerdown rather than
 * off `:active`, which does not hold through a drag that leaves the element.
 * That flag is `data-sliding`, which already exists for the mark's transition
 * and already covers exactly pointerdown through pointerup: one gesture, one
 * flag. With no script the resting `grab` still shows, which is the half that
 * carries the affordance — `grabbing` is feedback during a gesture that is
 * already under way. */
.timeline-range::-webkit-slider-thumb {
  appearance: none;
  width: var(--tl-hit, 44px);
  height: var(--tl-hit, 44px);
  background: transparent;
  pointer-events: auto;
  cursor: grab;
}
.timeline-range::-moz-range-thumb {
  width: var(--tl-hit, 44px);
  height: var(--tl-hit, 44px);
  background: transparent;
  border-style: none;
  pointer-events: auto;
  cursor: grab;
}
/* The band as well as the thumb: a drag that slips off the 44px box keeps the
   closed hand as long as it is still over the control. */
.timeline-band[data-sliding] { cursor: grabbing; }
.timeline-band[data-sliding] .timeline-range::-webkit-slider-thumb { cursor: grabbing; }
.timeline-band[data-sliding] .timeline-range::-moz-range-thumb { cursor: grabbing; }

/* **Whichever handle can still move is the one that receives the pointer.** With
   both on the same month one sits exactly on the other, and the one on top wins
   every event; if that were the handle already pinned against the end of the
   band, the control would look stuck. At the band's maximum the one with room
   left is `from`, everywhere else it is `to`. The server decides which — it is a
   fact about the values, not about the layout — and the script keeps it current
   as the handles move. */
.timeline-band[data-stack="to"] .timeline-range-to { z-index: 3; }
.timeline-band[data-stack="from"] .timeline-range-from { z-index: 3; }

.timeline-mark {
  position: absolute;
  top: 0;
  width: var(--tl-hit, 44px);
  height: 100%;
  pointer-events: none;
  transition: left var(--dur-fast) var(--ease);
}
.timeline-mark::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0.75rem;
  height: 1.5rem;
  margin: -0.75rem 0 0 -0.375rem;
  background: var(--invert-surface);
}
/* No half-thumb inset here, unlike the track and the ticks: the mark's box is
   the thumb's box, so its left edge travels exactly where the thumb's does. */
.timeline-mark-from { left: calc(var(--tl-from, 0) * var(--tl-run, 100%) / var(--tl-span, 365)); }
.timeline-mark-to { left: calc(var(--tl-to, 365) * var(--tl-run, 100%) / var(--tl-span, 365)); }

/* The thumb the browser draws is invisible, so the focus ring has to land on the
   mark that stands in for it. The marks come after the inputs in the markup for
   exactly this — a sibling combinator only reaches forwards. */
.timeline-range-from:focus-visible ~ .timeline-mark-from::before,
.timeline-range-to:focus-visible ~ .timeline-mark-to::before {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* A drag is the one case where the mark must not glide: the invisible thumb is
   under the finger and the visible one is what stands in for it, so 120ms of
   easing reads as the handle lagging behind the touch. Set while a pointer is
   moving, cleared when it is released — arrow keys keep the glide. */
.timeline-band[data-sliding] .timeline-mark { transition: none; }
@media (prefers-reduced-motion: reduce) {
  .timeline-mark { transition: none; }
}

.timeline-ticks {
  position: relative;
  height: 1.5rem;
  margin: 0;
  padding: 0;
  list-style: none;
}
.timeline-tick {
  position: absolute;
  top: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-1);
  transform: translateX(-50%);
}
.timeline-tick-mark {
  width: 1px;
  height: 4px;
  background: var(--line-strong);
}
.timeline-tick-label {
  font-size: var(--text-3xs);
  color: var(--text-faint);
  white-space: nowrap;
}
/* The second signal the selection carries, after its thickness: a month inside
   the range is set in the full text tone and the medium weight, one outside it
   in the faint tone and the light one. Weight is a shape, not a hue. */
.timeline-tick[data-in] .timeline-tick-mark { background: var(--accent); }
.timeline-tick[data-in] .timeline-tick-label {
  color: var(--text);
  font-weight: var(--wght-medium);
}

/* **The months are no longer evenly spaced**, so a tick cannot be placed by its
   position in the list. Twelve equal columns were right while the value was a
   month and are up to a day and a half out per tick now — visibly wrong exactly
   where it matters, at the boundary a handle is magnetic to. Each tick carries
   its own day offset instead, through the same three digits the handles use.
   The half-thumb inset is the track's, because a tick marks a point on the
   track rather than the left edge of a thumb. */
.timeline-tick {
  left: calc(var(--tl-hit, 44px) / 2 + var(--tl-day, 0) * var(--tl-run, 100%) / var(--tl-span, 365));
}

/* Twelve labels want about 27px each on a 375px screen and "März" needs more
   than that, so below the breakpoint every second one is dropped and its tick
   stays. A ruler with half its numbers is still a ruler; overlapping month
   names are not — and the exact selection is written out above the band either
   way, which is where precision actually belongs. */
@media (max-width: 29.999rem) {
  .timeline-tick:nth-child(2n) .timeline-tick-label { display: none; }
}

/* artwork -------------------------------------------------------------- */

.img-list {
  list-style: none;
  margin: 0 0 var(--space-2);
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(5rem, 1fr));
  gap: var(--space-2);
}
.img-item { position: relative; }
.img-item img {
  /* The carrier is exactly the image's size, so it is the empty state rather
     than a frame — and flyers are often near-black or near-white, so it has
     to hold either against both themes. */
  background: var(--surface-20);
  width: 100%;
  aspect-ratio: var(--img-ratio);
  object-fit: cover;
  display: block;
}
.img-remove {
  position: absolute;
  top: 0;
  right: 0;
  width: var(--tap);
  height: var(--tap);
  border-style: none;
  background: var(--surface-15);
  color: var(--text);
  cursor: pointer;
  line-height: 1;
  font: inherit;
}

/* Under the thumbnail, not over it: the description is as much a part of the
   upload as the file, and hiding it behind a hover would mean it stays empty. */
.img-alt {
  width: 100%;
  min-height: var(--tap);
  margin-top: var(--space-1);
  padding: var(--space-2);
  border-style: none;
  border-bottom: 1px solid var(--line);
  background: var(--surface-00);
  color: var(--text);
  font: inherit;
  font-size: var(--text-2xs);
}
.img-alt:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.img-drop {
  font: inherit;
  font-size: var(--text-xs);
  width: 100%;
  min-height: var(--tap);
  padding: var(--space-4);
  background: var(--surface-00);
  color: var(--text-muted);
  border-style: dashed;
  border-width: 1px;
  border-color: var(--line);
  border-left-color: var(--accent);
  border-right-color: var(--accent);
  cursor: pointer;
}
@media (hover: hover) {
  .img-drop:hover { color: var(--text); border-left-color: var(--accent); border-right-color: var(--accent); }
}
.img-drop-over { color: var(--text); background: var(--surface-10); border-left-color: var(--accent); border-right-color: var(--accent); }

/* dialogs -------------------------------------------------------------- */

/* One height for every dialog, and the form scrolls inside it rather than the
   page growing to fit. Unbounded, the event wizard ran past the bottom of a
   desktop window and its buttons went with it — which is also why .ev-foot
   sticks below. min() rather than a plain vh: on a tall monitor a dialog that
   is 86% of the screen is a page, not a dialog. */
.pub-dialog {
  border-style: none;
  background: var(--surface-05);
  color: var(--text);
  padding: 0;
  max-width: 26rem;
  width: calc(100vw - 2rem);
  max-height: min(86vh, 46rem);
  overflow: hidden;
}
.pub-dialog > form {
  padding: var(--space-5);
  max-height: inherit;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.pub-dialog-wide { max-width: 34rem; }
.pub-dialog-widest { max-width: 56rem; }

/* color-mix over a token rather than a raw rgba(): the backdrop is a value
   derived from the palette, not a colour introduced here. */
.pub-dialog::backdrop { background: color-mix(in srgb, var(--surface-00) 70%, transparent); }

.dialog-title {
  margin: 0;
  font-size: var(--text-lg);
  font-weight: var(--wght-medium);
  letter-spacing: var(--track-title);
}

.form-wide { max-width: none; }
.field-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); }
.check { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-xs); min-height: var(--tap); }
.check input { width: auto; }
/* Same as .field-check: the whole label is the target, so all of it points. */
@media (hover: hover) {
  .check, .check input { cursor: pointer; }
}

/* recurrence ----------------------------------------------------------- */

/* A sentence, not a form: the controls sit inline so the rule reads as
   "Findet statt wöchentlich am Freitag" rather than as four labelled fields
   the reader has to assemble. */
.rec { display: grid; gap: var(--space-2); }
.rec-label {
  font-size: var(--text-2xs);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-faint);
}
.rec-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  font-size: var(--text-xs);
}
.rec-row select { width: auto; min-height: var(--tap); }
.rec-bits, .rec-inline, .rec-until { display: inline-flex; align-items: center; gap: var(--space-2); }
.rec-bits[hidden], .rec-until[hidden] { display: none; }
.rec-until input { width: auto; min-height: var(--tap); }

.rec-summary { margin: 0; font-size: var(--text-xs); color: var(--text); }

/* The preview is the safety net: a publisher checks concrete dates rather
   than trusting that the sentence means what they think it means. */
.rec-preview {
  list-style: none;
  margin: 0;
  padding: var(--space-2) var(--space-3);
  background: var(--surface-00);
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-3);
  font-size: var(--text-2xs);
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
}
.rec-preview-label {
  width: 100%;
  text-transform: uppercase;
  letter-spacing: var(--track-label);
  color: var(--text-muted);
}
.rec-preview-none { color: var(--danger); }

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

/* Collapsed by default and visually quieter than Save: these act on the whole
   series at once and should not read as peers of the ordinary controls. */
.bulk {
  border-top: 1px solid var(--line);
  padding-top: var(--space-3);
  font-size: var(--text-xs);
}
.bulk summary { cursor: pointer; color: var(--text-muted); min-height: var(--tap); }
.bulk-body { display: grid; gap: var(--space-2); margin-top: var(--space-3); }
.bulk-body input { width: auto; min-height: var(--tap); }
.bulk-body label { color: var(--text-muted); }

/* page builder --------------------------------------------------------- */

.page-form-grid { display: grid; grid-template-columns: 1fr 24rem; gap: var(--space-5); }
.page-form-fields { display: grid; gap: var(--space-4); align-content: start; }

/* The preview is the real page in an iframe at phone width. A mock-up would
   drift from what it claims to show; this one cannot, because it is the page. */
.page-preview {
  width: 100%;
  max-width: 375px;
  height: 34rem;
  border-top: 1px solid var(--line-strong);
  border-bottom: 1px solid var(--line-strong);
  background: var(--surface-05);
}

.radio-row { display: flex; gap: var(--space-4); flex-wrap: wrap; }
.series-picks { display: grid; gap: var(--space-1); }

.accent-picker { display: flex; gap: var(--space-2); flex-wrap: wrap; }
.accent-swatch {
  position: relative; /* so the clipped radio below stays inside the label */
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2);
  min-height: var(--tap);
  font-size: var(--text-2xs);
  cursor: pointer;
  background: var(--surface-00);
}
.accent-swatch:has(input:checked) { background: var(--surface-20); color: var(--text); }
/* The one place a colour sample is the content rather than decoration — and
   it still carries no text.

   --swatch, not --accent. The tokens for the accents are declared on
   :root[data-accent], and this label carries data-accent on itself, so
   var(--accent) here resolved to whatever the *page* is set to and drew all
   eight swatches in the same violet. */
.accent-swatch::before {
  content: "";
  width: var(--space-4);
  height: var(--space-4);
  background: var(--swatch, var(--accent));
  flex: none;
}
/* Clipped, not absolutely positioned. `position: absolute` with no positioned
   ancestor put the radio wherever the page happened to allow, and
   pointer-events: none plus opacity: 0 left a keyboard user with a control
   that has focus and shows nothing. The input stays where it is; the label
   wears the ring. */
.accent-swatch input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.accent-swatch:has(input:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* members and moderation ----------------------------------------------- */

.members { display: grid; gap: var(--space-2); }
.mod-section { margin-block: var(--space-6) 0; }

@media (max-width: 48rem) {
  .page-form-grid { grid-template-columns: 1fr; }
}

@media (max-width: 40rem) {
  .cal-day { min-height: 3.5rem; }
  .cal-weekday { padding: 0 var(--space-1); }
  .cal-month { font-size: var(--text-lg); }
  /* The row scrolls instead of stacking. One control per line put the first
     event 716px down an 812px screen — the whole first viewport was chrome —
     and two-up still costs three lines. Search leads because it is the one
     control somebody reaches for deliberately; the rest are one flick away. */
  .filters { padding-inline: var(--space-5); margin-inline: calc(var(--space-5) * -1); }
  .field-row { grid-template-columns: 1fr; }
}

/* grid placement without inline styles ---------------------------------- */

/* The production CSP is style-src 'self', and that governs style *attributes*
   too. Grid geometry written as style="grid-column: …" works on localhost —
   where the dev CSP relaxes to 'unsafe-inline' — and is dropped in silence
   once deployed, collapsing the month view on the bio-link page.
   
   So the geometry is a fixed set of classes. Seven columns, seven spans and
   as many lanes as a week can hold. */

.cal-day { grid-row: 1 / -1; }

.cal-c1 { grid-column-start: 1; }
.cal-c2 { grid-column-start: 2; }
.cal-c3 { grid-column-start: 3; }
.cal-c4 { grid-column-start: 4; }
.cal-c5 { grid-column-start: 5; }
.cal-c6 { grid-column-start: 6; }
.cal-c7 { grid-column-start: 7; }

.cal-s1 { grid-column-end: span 1; }
.cal-s2 { grid-column-end: span 2; }
.cal-s3 { grid-column-end: span 3; }
.cal-s4 { grid-column-end: span 4; }
.cal-s5 { grid-column-end: span 5; }
.cal-s6 { grid-column-end: span 6; }
.cal-s7 { grid-column-end: span 7; }

.cal-r1 { grid-row: 2; }
.cal-r2 { grid-row: 3; }
.cal-r3 { grid-row: 4; }
.cal-r4 { grid-row: 5; }
.cal-r5 { grid-row: 6; }
.cal-r6 { grid-row: 7; }
.cal-r7 { grid-row: 8; }
.cal-r8 { grid-row: 9; }

/* Row 1 holds the date numbers, then one row per lane, then a trailing
   spacer so the last chip does not sit flush against the cell edge.
   Retained for the backend renderer, which still places every entry as a
   segment — see the stackSingleDays flag in lib/calendar.js. */
.cal-lanes-0 { grid-template-rows: auto var(--space-1); }
.cal-lanes-1 { grid-template-rows: auto auto var(--space-1); }
.cal-lanes-2 { grid-template-rows: auto repeat(2, auto) var(--space-1); }
.cal-lanes-3 { grid-template-rows: auto repeat(3, auto) var(--space-1); }
.cal-lanes-4 { grid-template-rows: auto repeat(4, auto) var(--space-1); }
.cal-lanes-5 { grid-template-rows: auto repeat(5, auto) var(--space-1); }
.cal-lanes-6 { grid-template-rows: auto repeat(6, auto) var(--space-1); }
.cal-lanes-7 { grid-template-rows: auto repeat(7, auto) var(--space-1); }
.cal-lanes-8 { grid-template-rows: auto repeat(8, auto) var(--space-1); }

/* The public grid: date row, then one row per multi-day bar, then ONE row for
   the per-day stacks. The stack row is `auto`, so a week is exactly as tall as
   its fullest day and a day is not limited by anything — which is the whole
   point. Thirty entries cost thirty boxes in normal flow, not thirty rows. */
.cal-bars-0 { grid-template-rows: auto auto var(--space-1); }
.cal-bars-1 { grid-template-rows: auto auto auto var(--space-1); }
.cal-bars-2 { grid-template-rows: auto repeat(2, auto) auto var(--space-1); }
.cal-bars-3 { grid-template-rows: auto repeat(3, auto) auto var(--space-1); }
.cal-bars-4 { grid-template-rows: auto repeat(4, auto) auto var(--space-1); }
.cal-bars-5 { grid-template-rows: auto repeat(5, auto) auto var(--space-1); }
.cal-bars-6 { grid-template-rows: auto repeat(6, auto) auto var(--space-1); }

.cal-bars-0 .cal-stack { grid-row: 2; }
.cal-bars-1 .cal-stack { grid-row: 3; }
.cal-bars-2 .cal-stack { grid-row: 4; }
.cal-bars-3 .cal-stack { grid-row: 5; }
.cal-bars-4 .cal-stack { grid-row: 6; }
.cal-bars-5 .cal-stack { grid-row: 7; }
.cal-bars-6 .cal-stack { grid-row: 8; }

.pub-error-detail { margin: var(--space-2) 0 0; }


/* hover card ------------------------------------------------------------ */

/* What the entry cannot show in a 129px column: the time that was taken out of
   the chip, where it is, and the flyer. Enhancement only — the `title`
   attribute is the preview when this never runs, and both links underneath
   carry the same information for touch and keyboard.
   Not position: fixed. It is placed against .cal-grid in offsets, so a scroll
   cannot detach it from the entry it belongs to. */
.cal-card {
  position: absolute;
  z-index: 5;
  /* Flex, not grid. A two-column grid kept the flyer's column when the flyer
     was hidden, so the title and the meta stood side by side with a hole where
     the image would have been. One row of [flyer?, text block] degrades to just
     the text block by itself. */
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  width: max-content;
  max-width: 19rem;
  padding: var(--space-3);
  background: var(--surface-20);
  box-shadow: 0 0 0 1px var(--line);
  pointer-events: none;
}
.cal-card-body { min-width: 0; }
.cal-card[hidden] { display: none; }

.cal-card-flyer {
  display: block;
  flex: none;
  width: 80px;
  height: 100px;
  object-fit: cover;
  background: var(--surface-10);
}
.cal-card-flyer[hidden] { display: none; }

.cal-card-title {
  margin: 0;
  font-size: var(--text-sm);
  font-weight: var(--wght-medium);
  line-height: var(--leading-tight);
}
.cal-card-meta {
  margin: var(--space-1) 0 0;
  font-size: var(--text-xs);
  font-weight: var(--wght-light);
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
}

/* A merged dot lists its events instead of naming one, so the meta line becomes
   several. One rule, because the single-event card still writes plain text and
   a span that never appears costs nothing. */
.cal-card-line { display: block; }

/* A hover card on a touch screen has no trigger, and on a 375px viewport it
   would cover the grid it describes. The cell link is the route there. */
@media (max-width: 40rem), (pointer: coarse) {
  .cal-card { display: none; }
}

@media (prefers-reduced-motion: no-preference) {
  .cal-card {
    /* --dur-surface. `--dur-snap` was never a token, and an unresolvable var()
       makes the whole `animation` declaration invalid at parse time — so the
       card had no entry animation at all and nothing anywhere said so. */
    animation: cal-card-in var(--dur-surface) var(--ease) both;
  }
  @keyframes cal-card-in {
    from { opacity: 0; transform: translateY(8px); }
    to { opacity: 1; transform: translateY(0); }
  }
}

/* tag input ------------------------------------------------------------- */

/* Chips above the field, not inside it. A token input that grows from within
   reflows the whole form on every pick, and at 375px it wraps into a shape
   nobody can aim at. */
.tag-input-wrap { position: relative; }
.tag-input-wrap .tag-input { width: 100%; }
.tag-chips { display: flex; flex-wrap: wrap; gap: var(--space-2); list-style: none; margin: 0; padding: 0; }
.tag-chips:empty { display: none; }
/* The same small block the public pages use, plus a remove button. It was
   44px tall because the button was — which made two tags look like two
   toolbar items rather than two words. The button shrinks with the chip and
   takes its tap target back on coarse pointers, where it is the only way to
   remove one. */
.tag-chip {
  display: inline-flex;
  align-items: center;
  padding-left: var(--space-2);
  background: var(--surface-20);
  color: var(--text);
  font-size: var(--text-2xs);
  line-height: 1.6;
}
.tag-chip-remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.5rem;
  align-self: stretch;
  padding: 0;
  font: inherit;
  font-size: var(--text-2xs);
  background: none;
  border-style: none;
  color: var(--text-faint);
  cursor: pointer;
}
@media (pointer: coarse) {
  .tag-chip { min-height: var(--tap); padding-left: var(--space-3); }
  .tag-chip-remove { width: var(--tap); }
}
@media (hover: hover) {
  .tag-chip-remove:hover { color: var(--danger); }
}
.tag-chip-remove:active { color: var(--danger); }

.tag-suggestions {
  position: absolute;
  z-index: 3;
  top: 100%;
  left: 0;
  right: 0;
  max-height: 14rem;
  overflow-y: auto;
  list-style: none;
  margin: 0;
  padding: 0;
  background: var(--surface-15);
  border-bottom: 1px solid var(--line-strong);
}
.tag-suggestion {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  min-height: var(--tap);
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-2xs);
  cursor: pointer;
}
@media (hover: hover) {
  .tag-suggestion:hover { background: var(--surface-20); }
}
.tag-suggestion-note { color: var(--text-faint); font-size: var(--text-3xs); }
/* Offered last and named as what it is. First or unlabelled is how "dancen"
   gets created by somebody who meant to pick "dancing" from the row above. */
.tag-suggestion-new .tag-suggestion-name { color: var(--text-muted); }
.tag-suggestion-new .tag-suggestion-note { color: var(--accent); }

/* The link list in the editor. Two inputs and a remove button per row, at both
   levels the model has — the label is short, the URL is long, so the grid
   gives the URL the space and collapses to one column per field on a phone
   rather than squeezing three controls into 375px. */
.link-field { border-style: none; margin: 0; padding: 0; min-width: 0; }
.link-field legend {
  padding: 0;
  font-size: var(--text-2xs);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-muted);
}
.link-rows { list-style: none; margin: 0 0 var(--space-2); padding: 0; display: grid; gap: var(--space-2); }
.link-rows:empty { display: none; }
.link-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto;
  gap: var(--space-2);
  align-items: center;
}
@media (max-width: 30rem) {
  .link-row { grid-template-columns: minmax(0, 1fr) auto; }
  .link-row input:first-child { grid-column: 1 / -1; }
}

/* The facet editor. Chips rather than checkboxes: the vocabulary is a few
   dozen short words, and a pressed/unpressed chip reads faster than a column
   of boxes — same register as the tag chips beside it, one row per axis. The
   scoped block appears only once a domain is chosen, so the form is three
   options wide until it needs to be more. */
.facet-field { border-style: none; margin: 0; padding: 0; min-width: 0; }
.facet-field legend {
  padding: 0;
  font-size: var(--text-2xs);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-muted);
}
.facet-group { display: grid; gap: var(--space-1); margin-top: var(--space-2); }
.facet-label { font-size: var(--text-3xs); color: var(--text-faint); }
.facet-row { display: flex; flex-wrap: wrap; gap: var(--space-1); }
.facet-chip {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap);
  padding: 0 var(--space-2);
  font: inherit;
  font-size: var(--text-2xs);
  line-height: 1.6;
  background: var(--surface-15);
  color: var(--text-muted);
  border-style: none;
  cursor: pointer;
}
/* Inverted for the same reason the switch is: on light, --surface-20 sits three
   hex values off --surface-15 and a pressed chip in a row of twenty read as
   unpressed. Three picks out of twenty-five should be findable at a glance —
   that is the whole job of this row. */
.facet-chip[aria-pressed='true'] { background: var(--invert-surface); color: var(--invert-text); }
@media (hover: hover) {
  .facet-chip:not([aria-pressed='true']):hover { color: var(--text); }
}
@media (pointer: fine) {
  .facet-chip { min-height: 1.75rem; }
}

/* A facet filter is a dropdown, not a panel: it sits in a row of selects, so
   it has to read as one of them. Same height, same surface, same hairline, and
   a caret in the place a select puts its own. What differs is the list it
   opens, because a select cannot hold several ticks at once — which is the
   whole reason this control exists. */
.filter-menu { flex: 0 0 auto; }
.filter-menu > summary {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 2.25rem;
  padding: var(--space-2);
  font-size: var(--text-2xs);
  color: var(--text-muted);
  background: var(--surface-00);
  border-bottom: 1px solid var(--line);
  cursor: pointer;
  white-space: nowrap;
  list-style: none;
}
.filter-menu > summary::-webkit-details-marker { display: none; }
.filter-caret { font-size: var(--text-3xs); color: var(--text-faint); }
.filter-menu[open] > summary,
.filter-menu[data-active] > summary { color: var(--text); border-bottom-color: var(--line-strong); }
@media (hover: hover) {
  .filter-menu > summary:hover { color: var(--text); }
}
.filter-menu > summary:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* The open list. Raised one step above the row it covers, because a dropdown
   that shares the page surface reads as content rather than as a menu. */
.filter-menu-body {
  position: absolute;
  z-index: 3;
  top: 100%;
  left: 0;
  right: 0;
  max-height: 50vh;
  overflow-y: auto;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  padding: var(--space-1) 0;
  background: var(--surface-15);
  border-bottom: 1px solid var(--line-strong);
}

/* Narrow screens keep the row-spanning form — a dropdown anchored to a control
   near the right edge hangs off a 375px screen and makes the *page* scroll
   sideways, which is the one thing this row has always been shaped to avoid.
   It is also what a native select does on a phone: a sheet, not a popover. */
@media (min-width: 40rem) {
  .filter-menu { position: relative; }
  .filter-menu-body {
    right: auto;
    min-width: 100%;
    width: max-content;
    max-width: 26rem;
  }
}

.filter-check {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--tap);
  padding: 0 var(--space-3);
  font-size: var(--text-2xs);
  color: var(--text);
  cursor: pointer;
}
@media (pointer: fine) {
  .filter-check { min-height: 2rem; }
}
@media (hover: hover) {
  .filter-check:hover { background: var(--surface-20); }
}
.filter-check input { width: 1rem; height: 1rem; accent-color: var(--accent); }

/* One control, one or two halves, for a field whose whole vocabulary fits on
   the row. Two separate buttons read as two questions; this reads as one with
   two answers, which is what indoor and outdoor are.

   Square on purpose. STYLE.md deletes the radius token rather than zeroing it
   and the design guard fails the suite on any `border-radius`, so the pill
   switch this replaces would have been the one rounded thing in the product.
   The state is carried by fill and weight instead, the same way the calendar
   chips and tag chips carry theirs. */
.filter-toggle {
  display: inline-flex;
  flex: 0 0 auto;
  background: var(--surface-00);
  border-bottom: 1px solid var(--line);
}
.filter-segment {
  display: inline-flex;
  align-items: center;
  min-height: 2.25rem;
  padding: 0 var(--space-3);
  font-size: var(--text-2xs);
  color: var(--text-muted);
  cursor: pointer;
  white-space: nowrap;
}
/* A hairline between the halves, not around them: the group is one control and
   an outline would make it two again. */
.filter-segment + .filter-segment { border-left: 1px solid var(--line); }
.filter-segment input {
  /* Off-screen rather than display:none — a hidden input is not focusable, and
     the keyboard path to a toggle has to stay a real one. */
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.filter-segment[data-active] { color: var(--invert-text); background: var(--invert-surface); }
@media (hover: hover) {
  .filter-segment:hover { color: var(--text); }
}
.filter-segment:focus-within {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
