:root {
  --axis: 140px;  /* both hour columns */
  /* The floor under a day column. The rolling view picks its day count from
     IDEAL_DAY_PX (app.js) and must never floor higher than that, or it would
     ask for more width than it just decided to use — a few pixels below it, so
     a column edge landing on a fraction cannot tip it over. The week view is pinned to
     seven columns: raising its floor cannot widen them, it can only make the
     table scroll sooner, so it keeps the floor it always had. */
  --day-min: 118px;
  --week-day-min: 99px;
  color-scheme: light dark;
  --surface: #fcfcfb;
  --plane: #f9f9f7;
  --ink: #0b0b0b;
  --ink-2: #52514e;
  --muted: #898781;
  --grid: #e1e0d9;
  /* One step lighter than --grid: the odd-hour rule is a landmark, not a
     division, and must not weigh as much as the two-hour one. */
  --grid-soft: #ebeae4;
  --rule: #c3c2b7;
  --gutter: #ecebe5;
  /* Tonight's column. Cool, where every other ground here is a warm neutral,
     so it reads as « this one » at a glance — and outside the status palette
     (green / yellow / orange), so it can never be mistaken for a status. */
  --today: #eef3fb;
  /* Status palette: one HSL hue wheel, red at 1 % to green at 100 %
     (hue = (value - 1) / 99 * 120), at fixed saturation and lightness. The
     three steps below are that ramp at 33 / 66 / 100 — possible, probable,
     confirmé — so a flat chip and a graded mark cannot drift apart. The
     yellow-green is light on the light surface: every mark also carries the
     member's initials and a tone-on-tone edge, which is its relief.
     Text never wears the fill lightness: --ramp-text-l is the same hue made
     dark enough to read (>= 4:1 on the surface, bold). */
  --ramp-s: 75%;
  --ramp-l: 48%;
  --ramp-text-l: 30%;
  --s-confirmed: hsl(120 var(--ramp-s) var(--ramp-l));
  --s-probable: hsl(78.8 var(--ramp-s) var(--ramp-l));
  --s-possible: hsl(38.8 var(--ramp-s) var(--ramp-l));
  --s-confirmed-text: hsl(120 var(--ramp-s) var(--ramp-text-l));
  --s-probable-text: hsl(78.8 var(--ramp-s) var(--ramp-text-l));
  --s-possible-text: hsl(38.8 var(--ramp-s) var(--ramp-text-l));
  --s-none: #e1e0d9;
  --s-unknown-border: #b5b4a9;
  --mark-ink: #0b0b0b;
  --mark-edge: rgba(0, 0, 0, 0.14);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --surface: #1a1a19;
    --plane: #0d0d0d;
    --ink: #ffffff;
    --ink-2: #c3c2b7;
    --muted: #898781;
    --grid: #2c2c2a;
    --grid-soft: #242423;
    --rule: #383835;
    --gutter: #232322;
    --today: #1b222c;
    --ramp-text-l: 60%;
    --s-none: #2c2c2a;
    --s-unknown-border: #55544f;
    --mark-edge: rgba(0, 0, 0, 0.35);
  }
}

* { box-sizing: border-box; }
body {
  margin: 0;
  background: var(--plane);
  color: var(--ink);
  font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.wrap { max-width: 1430px; margin: 0 auto; padding: 24px 16px 64px; }
h1 { font-size: 20px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 24px 0 8px; }
.sub { color: var(--ink-2); margin: 0 0 20px; }
.sub.links { display: flex; flex-wrap: wrap; gap: 8px 16px; max-width: none; }
.sub.links .right { margin-left: auto; }
a { color: inherit; }
p { max-width: 70ch; }

.bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 16px; }
button {
  font: inherit; color: var(--ink); background: var(--surface);
  border: 1px solid var(--rule); border-radius: 6px; padding: 6px 12px; cursor: pointer;
}
button:hover { background: var(--grid); }
/* Icon button: square, the glyph sized off the text so it lines up with the
   labelled buttons beside it whatever the font metrics. */
button.icon { display: inline-flex; align-items: center; justify-content: center;
              padding: 6px; line-height: 0; }
button.icon svg { width: 1.15em; height: 1.15em; }
/* While a refresh is in flight the icon turns: the only feedback a manual
   click gets, since a fast answer would otherwise change nothing visible. */
body.refreshing button.icon svg { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  body.refreshing button.icon svg { animation: none; opacity: .5; }
}
.week-label { font-weight: 600; margin: 0 4px; }
.spacer { flex: 1; }

.now { background: var(--surface); border: 1px solid var(--grid); border-radius: 8px;
       padding: 12px 14px; margin-bottom: 20px; }
.now h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em;
          color: var(--muted); margin: 0 0 10px; }
.now ul { list-style: none; display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; padding: 0; }
.now li { display: flex; align-items: center; gap: 6px; font-weight: 600; }
/* The status colours the name and rings the picture; no separate chip. */
.now li.confirmed { color: var(--s-confirmed-text); }
.now li.probable  { color: var(--s-probable-text); }
.now li.possible  { color: var(--s-possible-text); }
.now li.none, .now li.unknown { color: var(--muted); font-weight: 500; }
.now li .face { box-shadow: 0 0 0 2px currentColor; }
.now li.unknown .face { box-shadow: none; outline: 2px dashed var(--s-unknown-border); }
.now li.none .face, .now li.unknown .face { opacity: .55; filter: grayscale(1); }
.now .voice { margin: 10px 0 0; color: var(--ink-2); display: flex; flex-wrap: wrap;
              align-items: center; gap: 4px 6px; }

/* Colour chips: the same classes as the marks, so legend and grid cannot drift. */
.chip { width: 12px; height: 12px; border-radius: 3px; flex: none;
        box-shadow: inset 0 0 0 1px var(--mark-edge); }
.chip.confirmed { background: var(--s-confirmed); }
.chip.probable  { background: var(--s-probable); }
.chip.possible  { background: var(--s-possible); }
.chip.none      { background: var(--s-none); }
/* The legend shows the ramp as a ramp: five discrete chips would promise steps
   the grid no longer draws. Stops every 30° of hue, so the gradient walks the
   wheel like the marks do instead of cutting across it. */
.chip.ramp { width: 64px; border-radius: 3px;
  background: linear-gradient(to right,
    hsl(0 var(--ramp-s) var(--ramp-l)), hsl(30 var(--ramp-s) var(--ramp-l)),
    hsl(60 var(--ramp-s) var(--ramp-l)), hsl(90 var(--ramp-s) var(--ramp-l)),
    hsl(120 var(--ramp-s) var(--ramp-l))); }
/* Square like the marks it stands for, and big enough for the orbit to read. */
.chip.full { position: relative; width: 20px; height: 20px; border-radius: 4px;
  background: var(--s-confirmed); }
.chip.unknown, .mark.unknown {
  background: transparent; box-shadow: none;
  border: 1px dashed var(--s-unknown-border);
  background-image: repeating-linear-gradient(45deg,
    var(--s-unknown-border) 0 1px, transparent 1px 6px);
}

.scroll { overflow-x: auto; background: var(--surface);
          border: 1px solid var(--grid); border-radius: 8px; }
/* Fixed layout: the seven evenings are the same week, so they get the same
   width. Under `auto` a column is as wide as its contents ask — a day where
   everyone has a picture claims 26px per mark, a day of text marks collapses —
   so the columns moved about as people declared, and comparing two evenings
   meant comparing two different scales. The hour columns carry the only
   explicit widths; the days share what is left, equally. */
table {
  border-collapse: separate; table-layout: fixed; width: 100%;
  /* The floor is per day column, because the rolling view shows fewer of them
     on a narrow screen. --days comes from the script, --axis and --day-min
     from the breakpoints below, so the two agree by construction. */
  min-width: calc(var(--days, 7) * var(--day-min) + var(--axis));
}
table[data-view="week"] {
  --day-min: var(--week-day-min);
  /* The gutter between evenings is the table's own spacing, not a border on
     each cell. Under `table-layout: fixed` a border lives inside the column's
     width, and seven of them on fractional columns make the browser round the
     scrollable width up by a pixel — a scrollbar for a pixel that is not
     there. Spacing is subtracted before the columns are divided, so the sums
     stay whole. The colour comes from the table's own background, which the
     gaps let through; every cell paints its own. */
  border-spacing: 8px 0; background: var(--gutter);
}
th { font-weight: 600; font-size: 12px; color: var(--ink-2); padding: 8px 4px; }
th.day { border-bottom: 2px solid var(--rule); background: var(--plane); line-height: 1.2; }
/* Fixed columns do not grow for their contents: a day name that does not fit
   would hang over the next column, or past the table. Truncate instead — the
   date underneath still identifies the evening. */
th.day .dow, th.day .dom { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
th.day .dow { display: block; text-transform: capitalize; color: var(--ink); font-size: 13px; }
th.day .dow .short { display: none; }
th.day .dom { display: block; color: var(--muted); font-weight: 500; }
th.time, td.time {
  width: 70px; text-align: right; color: var(--muted);
  font-variant-numeric: tabular-nums; font-size: 12px; padding-right: 8px;
  vertical-align: top; position: relative;
}
/* The same axis, mirrored on the right edge: the reading direction flips, so
   the text hugs the last day column instead of the table's outer border. */
th.time.end, td.time.end { text-align: left; padding-right: 4px; padding-left: 8px; }
td.time.end .next-day { right: auto; left: 2px; }
td { padding: 0; }
/* Each evening is its own column, alternating tint. Every cell paints a
   background: the table's own shows through the spacing and is the gutter. */
th, td { background: var(--surface); }
td.day { padding: 1px 3px; }
td.day:nth-child(odd), th.day:nth-child(odd) { background: var(--plane); }
/* Tonight's evening keeps its own ground in both views, so the column that
   matters is found without reading the dates. Same specificity as the
   alternating tint above, so it has to come after it to win. */
td.day.today, th.day.today { background: var(--today); }
.cell { display: flex; flex-wrap: wrap; gap: 2px; min-height: 22px; }
.mark {
  position: relative;
  flex: 1; min-width: 0; border-radius: 4px; font-size: 10px; line-height: 22px;
  text-align: center; overflow: hidden; color: var(--mark-ink);
  box-shadow: inset 0 0 0 1px var(--mark-edge);
}
.mark.confirmed { background: var(--s-confirmed); }
.mark.probable  { background: var(--s-probable); }
.mark.possible  { background: var(--s-possible); }
/* A ramp, not three flats: the value itself picks the colour, so 45 % and 49 %
   no longer look identical while 49 % and 50 % jump. The hue walks the wheel
   from red (1 %) through orange and yellow to green (100 %); see the palette.
   Comes after the flat rules so it wins for the marks that carry `--v`. */
.graded {
  --hue: calc((clamp(1, var(--v), 100) - 1) * 120 / 99);
  --ramp: hsl(var(--hue) var(--ramp-s) var(--ramp-l));
  --ramp-text: hsl(var(--hue) var(--ramp-s) var(--ramp-text-l));
}
.mark.graded { background: var(--ramp); }
.now li.graded { color: var(--ramp-text); }
.mark.none      { background: var(--s-none); color: var(--muted); }
.mark.unknown   { color: var(--muted); }
.mark.small     { flex: 0 0 auto; width: 30px; }

/* With a picture the mark is a fixed square: the face carries identity, the
   background keeps the status. Absent states dim the face. */
.mark.has-face { flex: 0 0 26px; width: 26px; height: 26px; line-height: 26px; padding: 2px; }
.mark .face { display: block; width: 22px; height: 22px; border-radius: 50%; object-fit: cover; }
.mark.none .face, .mark.unknown .face { opacity: .45; filter: grayscale(1); }

/* 100 %: two lights orbiting the mark, one turn every 5 s. A full house is the
   thing the page exists to announce, and at the top of the ramp there is no
   colour left to say it with. The ring rides the mark's own edge — inset 0, no
   halo — because the grid has no spare pixel, and it spins by moving the light
   round a fixed ring rather than by rotating the element: rotating a rounded
   rectangle would make the ring itself wobble. */
@property --spark-angle {
  syntax: "<angle>";
  initial-value: 0deg;
  inherits: false;
}
.mark.full::after, .chip.full::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit;
  padding: 2px; pointer-events: none;
  background: conic-gradient(from var(--spark-angle),
    transparent 0deg, #fff 10deg, rgba(255, 255, 255, .55) 22deg, transparent 40deg,
    transparent 180deg, #fff 190deg, rgba(255, 255, 255, .55) 202deg, transparent 220deg);
  /* Ring only: the border box minus the content box. */
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask-composite: exclude;
  animation: spark-orbit 5s linear infinite;
}
@keyframes spark-orbit { to { --spark-angle: 360deg; } }
/* Without the animation the two lights simply stand still on the ring — the
   mark is still the one marked out. */
@media (prefers-reduced-motion: reduce) {
  .mark.full::after, .chip.full::after { animation: none; }
}
.now .face { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; }


/* Under the table: members with no availability that evening, one chip each. */
tfoot tr.absent td { border-top: 2px solid var(--rule); padding-top: 6px; padding-bottom: 6px;
                     vertical-align: top; }
tfoot tr.absent td.time { color: var(--muted); }
.absent-label { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }

/* A rail every two hours, so a row in the middle of the table can be traced
   back to its hour without returning to an axis. Midnight keeps a rule of its
   own: same weight, darker, because it is a change of date and not just a
   landmark. */
tr.hour-mark td { border-top: 2px dashed var(--grid); }
/* Every hour is marked, but the odd ones are a continuous hairline, one step
   lighter: dashed-2px, dashed-2px-darker and solid-1px-lighter read as three
   ranks — midnight, the two-hour rail, the hour — where three identical marks
   would read as noise. */
tr.hour-mark-odd td { border-top: 1px solid var(--grid-soft); }
tr.midnight td { border-top: 2px dashed var(--rule); }
tr.midnight td.time { padding-top: 14px; }
td.time .next-day {
  position: absolute; top: 1px; right: 4px; font-size: 9px; text-transform: uppercase;
  letter-spacing: .02em; color: var(--muted); white-space: nowrap;
}
td.time .next-day .short { display: none; }

tr.quiet { display: none; }
body.show-all tr.quiet { display: table-row; }

.legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 16px 0 0; padding: 0;
          list-style: none; color: var(--ink-2); font-size: 13px; }
.legend li { display: flex; align-items: center; gap: 6px; }
.msg { color: var(--ink-2); padding: 24px; text-align: center; }


/* ---------------------------------------------------------------------------
   Narrow screens. The table has a floor — seven evenings side by side is the
   point of the page — so the job is to spend the width on the day columns and
   take it back from everything that is not one.

   Each breakpoint sits well above the width it has to rescue. A browser matches
   `max-width` against the window INCLUDING the vertical scrollbar, while the
   layout gets about fifteen pixels less: a breakpoint placed exactly where the
   table stops fitting leaves a band that scrolls. Measured, not guessed.
   --------------------------------------------------------------------------- */

/* The mirrored hour axis exists because on a wide week the seventh day sits far
   from the left one. Under this width nothing is far, and the column would only
   take room from the days. It is an affordance, not information: the left axis
   still says everything it said. */
@media (max-width: 900px) {
  :root { --axis: 70px; }  /* the mirrored axis is gone */
  th.time.end, td.time.end { display: none; }
}

@media (max-width: 860px) {
  .wrap { padding: 16px 10px 48px; }
  :root { --axis: 46px; --day-min: 95px; --week-day-min: 73px; }
  th.time, td.time { width: 46px; padding-right: 5px; font-size: 11px; }
  /* « lendemain » does not fit a 46px column in any font: say it shorter. */
  td.time .next-day { font-size: 8px; right: 4px; }
  td.time .next-day .long { display: none; }
  td.time .next-day .short { display: inline; }
  /* The gutter only has to read as a break between evenings. */
  table { border-spacing: 5px 0; }
  td.day { padding: 1px 2px; }
  th.day { padding: 6px 2px; }
}

@media (max-width: 620px) {
  .wrap { padding: 12px 8px 40px; }
  :root { --axis: 44px; --day-min: 77px; --week-day-min: 59px; }
  th.time, td.time { width: 44px; padding-right: 4px; }
  td.time .next-day { right: 3px; }
  table { border-spacing: 4px 0; }
  td.day { padding: 1px 1px; }
  th.day { font-size: 11px; }
  th.day .dow .long { display: none; }
  th.day .dow .short { display: inline; }
}

/* Phones. « mercredi » becomes « mer » — an unbreakable word is what sets a
   column's width, and at this size the date underneath carries the precision. */
@media (max-width: 520px) {
  :root { --axis: 38px; --day-min: 52px; --week-day-min: 40px; }
  th.day .dom { font-size: 10px; }
  th.time, td.time { width: 38px; padding-right: 3px; font-size: 10px; }
  table { border-spacing: 2px 0; }
  th.day { padding: 6px 1px; }
  /* The « inconnu » chips are fixed-width, so they, not the marks, are what
     set a column's floor. Measured: they alone cost the table 30px here. */
  .mark.small { width: 22px; }
  .mark.has-face { flex-basis: 22px; width: 22px; height: 22px; line-height: 22px; }
  .mark .face { width: 18px; height: 18px; }
}
