/* Lecture Board — game-like drag & drop timetable.
   Visual world: the "Algorithmic Botany" family (shared with
   alperenkizilkulak.com), spoken in its calm Operate dialect — the dark
   plate: a COLD near-black ground carrying the rule language on top.
   THE TERM IS THE THEME. Fall and Spring are two separate calendars, so
   each wears its own season: 🍂 Fall is ice — blue accent #5b93e0 on the
   site's own neutral dark-plate values — and 🌸 Spring is sakura — pink
   accent #e78bb8 over a faintly plum-cast dark. Both stay dark, both
   keep the identical rule language, and the chrome (never the grid)
   carries the weather; see "the season, as weather" at the foot of this
   file. Course blocks wear one of the ten TONES in board_app.py (mirrored
   below as .tone-N) — two arcs, teal→indigo and magenta→rose, every one
   ≥ .10 ΔE from the verdict inks. Two courses on one COHORT's 📅 Board never
   share a tone while it holds ≤ 10, unless a course it shares with another
   year's board leaves no free colour; the room and lecturer boards mix cohorts
   and may repeat one — the same course is the same tone everywhere.
   Warm and green survive ONLY as meaning — green = legal/ok, red =
   refusal, gold = suggestion/warn, orange = adjacent-year, lilac = the
   lecturer and the AI, slate = quiet context. Square corners
   (2-3px), JuliaMono for the grid's times, day heads and data; STIX Two
   for page titles. Behavior rules and their comments are contracts —
   retheme values, never the mechanics. */

@font-face {
  font-family: "JuliaMono";
  font-style: normal; font-weight: 400; font-display: swap;
  src: url(fonts/juliamono-regular.woff2) format("woff2");
}
@font-face {
  font-family: "JuliaMono";
  font-style: normal; font-weight: 500; font-display: swap;
  src: url(fonts/juliamono-medium.woff2) format("woff2");
}
@font-face {
  font-family: "STIX Two Text";
  font-style: normal; font-weight: 400 700; font-display: swap;
  src: url(fonts/stix-two-normal-latin.woff2) format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+2191, U+2193, U+2212;
}
@font-face {
  font-family: "STIX Two Text";
  font-style: normal; font-weight: 400 700; font-display: swap;
  src: url(fonts/stix-two-normal-latin-ext.woff2) format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1E00-1E9F, U+1EF2-1EFF;
}

* { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  /* 🍂 FALL — cold, icy. The ground stays neutral-dark; the cast is a
     breath of blue in the panels, not a colour wash. */
  --bg: #0e0f11;
  --panel: #16181c;
  --panel-2: #1e2128;
  --line: #262a30;
  --text: #e9eaec;
  --muted: #a9aeb6;
  --ok: #4ec981;
  --bad: #e06050;
  --accent: #5b93e0;      /* ice */
  --wx-ink: #dbeaff;      /* the snow itself */
  --wx-edge: rgba(150, 194, 248, .17);   /* what settles on an edge */
  --warn: #d9a93f;
  --gold: #e0b13c;
  --lilac: #ab90ef;
  --lilac-deep: #45317e;
  --sage: #7d90a8;
  /* wizard.css and the embedded page styles read --fg; it is the text ink */
  --fg: var(--text);
  --mono: "JuliaMono", ui-monospace, "Cascadia Mono", Consolas, monospace;
  --serif: "STIX Two Text", "Iowan Old Style", Charter, Georgia, serif;
}
/* 🌸 SPRING — sakura on a dark night. Same dark ground, faint plum cast,
   pink accent. The rule language (green legal · red refusal · gold
   suggestion · lilac lecturer) is identical in both terms. */
body.spring {
  --bg: #100e12;
  --panel: #1a171c;
  --panel-2: #221d26;
  --line: #2c2630;
  --accent: #e78bb8;      /* sakura */
  --wx-ink: #ffd2e6;
  --wx-edge: rgba(240, 165, 205, .17);
}

html, body { height: 100%; }
body {
  /* Two rungs derived from the seasonal plate, so they follow the term on
     their own. Declared HERE, on body — NOT on :root. board.css puts the
     Spring plate on body.spring, and a custom property resolves its var()
     against the element that DECLARES it: on :root these would freeze at
     the Fall ground and inherit that cold colour straight into Spring. */
  --well: color-mix(in srgb, var(--bg) 70%, var(--panel));      /* below a panel */
  --panel-3: color-mix(in srgb, var(--panel-2) 96%, white);     /* hover rung */
  background: var(--bg);
  color: var(--text);
  font-family: "Segoe UI", Arial, sans-serif;
  overflow: hidden;
  user-select: none;
}
body.dragging { cursor: grabbing; }
/* While dragging, blocks must not swallow the pointer — hit-testing needs the cells. */
body.dragging .block { pointer-events: none; }

::selection { background: rgba(78, 201, 129, .28); }
:focus-visible { outline: 2px solid var(--ok); outline-offset: 1px; }
/* browser surfaces carry the design too: caret, placeholder, scrollbars */
input, textarea, select { caret-color: var(--ok); }
::placeholder { color: #98a0ad; opacity: 1; }
html { scrollbar-color: var(--line) transparent; }

/* ---------------------------------------------------------------- header */
#topbar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;      /* the bar is a bounded band: rows, never a tower */
  gap: 8px 18px;
  padding: 8px 18px;
  background: var(--panel);
  border-bottom: 2px solid var(--accent);
}
/* boards with a chip CLOUD (every lecturer / every room): the cloud takes
   its own full row under the title/toggle/meter row, wrapping inside it,
   instead of stacking five rows beside an empty title region */
body.lectboard #cohorts, body.roomboard #cohorts { flex: 1 1 100%; order: 9; }
#title {
  font-family: var(--serif);
  font-size: 1.22rem; font-weight: 600; color: var(--text);
  white-space: nowrap;
}

.chip {
  border: 1px solid transparent;
  border-radius: 3px;
  padding: 5px 13px;
  font-size: .85rem;
  font-weight: 600;
  background: rgba(232, 234, 238, .1);
  color: var(--text);
  cursor: pointer;
  transition: transform .1s, background .15s, border-color .15s;
}
.chip:hover { transform: translateY(-1px); background: rgba(232, 234, 238, .2); }
.chip:active { transform: scale(.97); }
.chip.active { background: var(--accent); border-color: var(--accent); color: #0f1013; }
#term-toggle, #cohorts { display: flex; gap: 6px; flex-wrap: wrap; }
/* the term pair is ONE component on every board: a single inline row that
   never wraps or stacks — the crowd (name/room chips) wraps instead */
#term-toggle { flex: none; flex-wrap: nowrap; }
#term-toggle .chip, #view-toggle .chip { white-space: nowrap; }
#cohorts .group-label { color: var(--muted); font-size: .8rem; align-self: center; margin: 0 2px 0 10px; }

#progress-wrap {
  margin-left: auto;
  /* ⚠️ The pill must NOT be sized by its label. #topbar wraps, and a flex
     line is broken on each item's hypothetical size — so a meter measured
     from its text was 150 px while it said "⏳ analysing…" and 216 px once
     the answer arrived, which no longer fit the row: it dropped to a second
     line and shoved the whole page down 34 px at exactly the moment the
     reader started reading. The reservation is now the same before and after
     (basis 150, then it grows into whatever the row has left, capped at
     320), so the swap changes the TEXT and nothing else. Long sentences
     clip inside, which is what the ellipsis and the tooltip are for. */
  flex: 1 1 150px;
  max-width: 320px;
  background: rgba(0, 0, 0, .35);
  border: 1px solid var(--line);
  border-radius: 3px;
  height: 26px;
  position: relative;
  overflow: hidden;
}
/* the bar paints BEHIND the in-flow label, so the pill sizes to its text */
#progress-bar {
  position: absolute;
  left: 0; top: 0; bottom: 0;
  width: 0%;
  background: #2f5aa8; /* deep enough that the light label reads on the fill */
  border-radius: 0;
  transition: width .4s ease;
}
#progress-text {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  font-family: var(--mono);
  font-size: .74rem;
  font-weight: 500;
  color: #f2f4f7;
  /* one legible layer over fill AND ground: a dark halo, not a split tone */
  text-shadow: 0 0 4px rgba(8, 9, 12, .95), 0 1px 2px rgba(8, 9, 12, .9),
               0 -1px 2px rgba(8, 9, 12, .9);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  padding: 0 10px;
}

/* ---------------------------------------------------------------- layout */
/* the page is a column: whatever height the header band takes, the working
   area flexes to exactly the remainder — no hardcoded bar arithmetic */
body.boardpage { display: flex; flex-direction: column; }
main { display: flex; height: calc(100vh - 47px); }
body.boardpage main { flex: 1 1 auto; height: auto; min-height: 0; }

#tray {
  width: 290px;
  min-width: 290px;
  background: var(--panel);
  border-right: 1px solid var(--line);
  padding: 14px;
  overflow-y: auto;
  scrollbar-color: var(--line) transparent;
}
#tray h2 { font-size: .95rem; color: var(--muted); margin-bottom: 10px; }
#hint { font-size: .74rem; color: var(--muted); margin-top: 14px; line-height: 1.5; }
#tray-empty {
  font-size: 1rem;
  padding: 18px 10px;
  text-align: center;
  background: rgba(78, 201, 129, .1);
  border: 1px solid rgba(78, 201, 129, .4);
  border-radius: 3px;
  animation: pop .35s ease;
}

/* saved boards (💾 Save progress) */
#snap-box { margin-bottom: 12px; }
#snap-box > summary { cursor: pointer; font-size: .8rem; color: var(--muted);
                      padding: 4px 2px; }
#snap-box > summary b { color: var(--text); }
.tiny { font-size: .68rem; color: var(--muted); line-height: 1.45; margin-top: 6px; }
.snap { background: var(--panel-2); border: 1px solid var(--line);
        border-radius: 3px; padding: 6px 8px; margin: 6px 0; }
.snap.auto { border-style: dashed; opacity: .85; }
.snap-name { font-size: .78rem; font-weight: 600; white-space: nowrap;
             overflow: hidden; text-overflow: ellipsis; }
.snap-meta { font-family: var(--mono); font-size: .64rem; color: var(--muted); margin-top: 1px; }
.snap-btns { display: flex; gap: 5px; margin-top: 5px; }
.snap-btns .chip { padding: 2px 9px; font-size: .7rem; background: rgba(232, 234, 238,.1); }
.snap-btns .chip:hover { background: rgba(232, 234, 238,.2); }

/* tray section headings: Mandatory / Faculty / Department / University */
.traygroup {
  display: flex; align-items: center; justify-content: space-between;
  gap: 8px; margin: 14px 0 7px;
  font-size: .78rem; font-weight: 700; color: var(--text);
  border-bottom: 1px solid var(--line); padding-bottom: 4px;
}
.traygroup:first-child { margin-top: 0; }
.traygroup b {
  background: rgba(232, 234, 238, .1); color: var(--muted);
  font-family: var(--mono);
  border-radius: 3px; padding: 1px 7px; font-size: .68rem;
}

.piece {
  border-radius: 3px;
  padding: 8px 10px;
  margin-bottom: 8px;
  cursor: grab;
  color: #fff;
  box-shadow: 0 2px 6px rgba(0, 0, 0, .35);
  transition: transform .12s, box-shadow .12s;
  touch-action: none;
}
.piece:hover { transform: translateY(-2px); box-shadow: 0 5px 12px rgba(0, 0, 0, .5); }
.piece .code { font-weight: 700; font-size: .9rem; }
.piece .meta { font-size: .72rem; opacity: .9; margin-top: 2px; }
/* split lectures: how many hours to drop now */
.piece .partpick {
  display: flex; align-items: center; gap: 5px; margin-top: 6px;
  font-size: .7rem; background: rgba(0, 0, 0, .25);
  border-radius: 2px; padding: 3px 6px;
}
.piece .partpick select {
  background: rgba(0, 0, 0, .4); color: #fff; font-size: .72rem;
  border: 1px solid rgba(232, 234, 238, .35); border-radius: 2px;
  padding: 1px 3px; cursor: pointer;
}
/* 🏫 Room Board tray: this piece has no room it COULD take at that hour —
   the 📅 Board allows the drop and sends it here, so the dead end has to be
   visible rather than sitting quietly among the merely-unassigned */
.piece.stuck { outline: 2px dashed var(--gold); outline-offset: -3px; }
.piece .stucktag {
  float: right; font-size: .6rem; font-weight: 700; letter-spacing: .01em;
  font-family: var(--mono);
  padding: 1px 6px; border-radius: 2px;
  background: rgba(90, 65, 12, .85); color: #f0d9a0;
}
.piece .cells { display: flex; gap: 3px; margin-top: 6px; }
.piece .cells span {
  flex: 1;
  height: 6px;
  border-radius: 1px;
  background: rgba(255, 255, 255, .45);
}

/* ---------------------------------------------------------------- grid */
#board { flex: 1; overflow: auto; padding: 14px; scrollbar-color: var(--line) transparent; }
#grid {
  position: relative;
  display: grid;
  grid-template-columns: 86px repeat(5, minmax(130px, 1fr));
  gap: 4px;
  min-width: 780px;
}
/* 🌐 a seventh column, beside Friday, for the lectures that have no hour
   (Alperen, 2026-09-08: "Add 'Online Courses' group next to Days in
   Calendar"). It exists only while the cohort has one: without the class the
   grid is the five-day one it has always been. */
#grid.has-online {
  grid-template-columns: 86px repeat(5, minmax(130px, 1fr)) minmax(150px, .7fr);
  min-width: 940px;
}
.onlinecol { display: flex; flex-direction: column; gap: 6px; overflow: auto; }
/* 🌐 turquoise into white with dark ink (Alperen, 2026-09-08: "every online
   class is mandatory so no need for red color. Make them unique color, with
   'online' theme. Like turquoise - white color") — the one plate on the board
   that is light, so the eye tells "no hour" from every block at a glance;
   #0b3a37 on #1fb7ad is 6.3:1, on the white end far more */
.onl {
  border-radius: 3px;
  color: #0b3a37;
  background: linear-gradient(135deg, #1fb7ad, #effefc);
  padding: 6px 8px;
  font-size: .78rem;
  line-height: 1.2;
  display: flex;
  flex-direction: column;
  gap: 1px;
  box-shadow: 0 3px 8px rgba(0, 0, 0, .4);
  cursor: default;            /* nothing here is dragged: there is no hour to move */
}
.onl .code { font-weight: 700; white-space: nowrap; overflow: hidden;
             text-overflow: ellipsis; }
.onl .nm { font-size: .67rem; overflow: hidden;
           display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.onl .sub { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap;
            font-size: .66rem; margin-top: 4px; }
.onl .sub .hrs { font-family: var(--mono); font-size: .6rem; opacity: .92; }
.onl .sub .onltag { font-size: .58rem; font-weight: 800; letter-spacing: .04em;
                    text-transform: uppercase; padding: 0 5px; border-radius: 2px;
                    background: rgba(11, 58, 55, .16); border: 1px solid rgba(11, 58, 55, .35); }

.head, .time {
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--mono);
  font-size: .72rem;
  color: var(--muted);
  font-weight: 500;
}
.head { height: 34px; background: var(--panel); border-radius: 2px; }
.time { height: 48px; background: var(--panel); border-radius: 2px; font-size: .66rem; }
.cell {
  height: 48px;
  background: var(--panel-2);
  border-radius: 2px;
  border: 1px solid transparent;
  transition: background .1s, border-color .1s;
}
/* danger zone while dragging: the lecturer's unavailable hours */
.cell.gh-danger {
  background: repeating-linear-gradient(135deg,
      rgba(224, 96, 80, .28) 0 8px, rgba(224, 96, 80, .1) 8px 16px);
  border-color: rgba(224, 96, 80, .55);
}
.cell.hl-ok { background: rgba(78, 201, 129, .3); border-color: var(--ok); }
.cell.hl-bad { background: rgba(224, 96, 80, .3); border-color: var(--bad); }
/* allowed, but no classroom is left for that hour — the drop goes through and
   the lecture waits for a room on 🏫 Room Board, so amber, never red */
.cell.hl-warn { background: rgba(217, 169, 63, .3); border-color: rgba(217, 169, 63, .85); }

/* ------------------------------------------------ holding a block
   While you are dragging, the question on screen is "where can this GO?" —
   not "what is already here". So the whole board inverts its emphasis: every
   legal slot lights up at once (the server already told us about all of them),
   and the lectures already placed step back to being scenery. The ghosts and
   the danger hatching stay, but quietly: they were loud enough to hide the
   free space they were drawn on top of. */
#grid.board-dragging .cell:not(.free):not(.free-noroom) {
  background: color-mix(in srgb, var(--panel-2) 70%, transparent);
}
#grid.board-dragging .cell.free {
  background: rgba(78, 201, 129, .15);
  border-color: rgba(78, 201, 129, .38);
}
/* legal too, but every classroom of the group is busy then: amber, so the
   free-with-a-room hours still stand out as the ones to aim for */
#grid.board-dragging .cell.free-noroom {
  background: rgba(217, 169, 63, .13);
  border-color: rgba(217, 169, 63, .35);
}
/* the first hour of each run gets a soft left edge, so a 3-hour opening reads
   as one opening rather than three lit squares */
#grid.board-dragging .cell.free-start {
  box-shadow: inset 3px 0 0 rgba(78, 201, 129, .75);
}
#grid.board-dragging .cell.free-noroom.free-start {
  box-shadow: inset 3px 0 0 rgba(217, 169, 63, .75);
}
/* placed lectures recede: dim, desaturated, and fractionally smaller, so
   the eye passes over them and lands on the gaps between — and on the one
   kind of block that does NOT recede, the held lecturer's other lectures */
#grid.board-dragging .block {
  opacity: .18;
  filter: saturate(.2);
  transform: scale(.965);
  /* filter is deliberately NOT transitioned: interpolating from `none` starts
     at saturate(1), so a dropped frame leaves the block briefly full-colour —
     the one thing this rule exists to prevent. Snapping is also the right feel. */
  transition: opacity .16s ease, transform .16s ease;
}
/* ...except the one being moved, which stays legible as its own reference */
#grid.board-dragging .block.faded {
  opacity: .5;
  filter: none;
}
/* ...and the lectures the SAME LECTURER teaches, which are the reason most
   hours are refused: full colour, a bright ring and a 👤 corner badge, above
   the free-slot wash. These are what the held block is being fitted around. */
#grid.board-dragging .block.same-lect {
  opacity: 1;
  filter: none;
  transform: none;
  z-index: 7;
  overflow: visible;   /* the badge sits on the corner, outside the box */
  outline: 3px solid #f2eeff;
  outline-offset: -1px;
  box-shadow: 0 0 0 5px rgba(171, 144, 239, .55), 0 0 22px rgba(171, 144, 239, .55);
}
#grid.board-dragging .block.same-lect::after {
  content: "👤";
  position: absolute; top: -9px; right: -7px;
  font-size: .78rem; line-height: 1;
  padding: 1px 4px; border-radius: 3px;
  background: var(--lilac-deep); color: #fff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, .6);
}
#grid.board-dragging .ghostblk {
  opacity: .5;
  border-style: dotted;
  border-width: 1px;
}
/* the lecturer's lectures on OTHER cohorts' boards: the one silhouette that
   is drawn loud, in the same lilac as the lit blocks, so "👤 = this person is
   busy then" reads as one thing wherever it stands */
#grid.board-dragging .ghostblk.lect {
  opacity: 1;
  border: 2px solid #cab6f6;
  background: rgba(143, 111, 224, .4);
  color: #f2eeff;
  font-weight: 700;
  box-shadow: 0 0 0 3px rgba(171, 144, 239, .35), 0 0 18px rgba(171, 144, 239, .5);
  z-index: 7;
}
#grid.board-dragging .cell.gh-danger {
  background: repeating-linear-gradient(135deg,
      rgba(224, 96, 80, .15) 0 8px, rgba(224, 96, 80, .05) 8px 16px);
  border-color: rgba(224, 96, 80, .3);
}

/* suggested slots while a block is held — golden pulse, brightest = best */
@keyframes goldpulse {
  0%, 100% { background-color: rgba(224, 177, 60, .1);
             box-shadow: inset 0 0 0 2px rgba(224, 177, 60, .45); }
  50%      { background-color: rgba(224, 177, 60, .3);
             box-shadow: inset 0 0 0 2px rgba(224, 177, 60, .95),
                         0 0 16px rgba(224, 177, 60, .4); }
}
@keyframes goldpulse2 {
  0%, 100% { background-color: rgba(224, 177, 60, .07);
             box-shadow: inset 0 0 0 2px rgba(224, 177, 60, .28); }
  50%      { background-color: rgba(224, 177, 60, .19);
             box-shadow: inset 0 0 0 2px rgba(224, 177, 60, .6); }
}
@keyframes goldpulse3 {
  0%, 100% { background-color: rgba(224, 177, 60, .05);
             box-shadow: inset 0 0 0 2px rgba(224, 177, 60, .18); }
  50%      { background-color: rgba(224, 177, 60, .13);
             box-shadow: inset 0 0 0 2px rgba(224, 177, 60, .4); }
}
.cell { position: relative; }
.cell.sug-1 { animation: goldpulse 1.25s ease-in-out infinite; }
.cell.sug-2 { animation: goldpulse2 1.7s ease-in-out infinite; }
.cell.sug-3 { animation: goldpulse3 2.1s ease-in-out infinite; }
/* the slot under the cursor answers with green/amber/red instead of pulsing */
.cell.hl-ok, .cell.hl-bad, .cell.hl-warn { animation: none !important; }
/* the head count on a placed block — small, but it moves when the block does */
.block .code .heads {
  float: right;
  font-family: var(--mono);
  font-size: .6rem;
  font-weight: 500;
  padding: 0 4px;
  border-radius: 2px;
  background: rgba(0, 0, 0, .3);
  color: rgba(255, 255, 255, .92);
  letter-spacing: .01em;
}
.block.h1 .code .heads { font-size: .56rem; padding: 0 3px; }

.sugbadge {
  position: absolute; top: 2px; right: 3px; pointer-events: none;
  font-family: var(--mono);
  font-size: .58rem; font-weight: 500; color: #f0d9a0;
  background: rgba(90, 65, 12, .8); border-radius: 2px; padding: 0 5px;
  text-shadow: 0 1px 2px rgba(0, 0, 0, .8);
}
.cell.hl-ok .sugbadge, .cell.hl-bad .sugbadge,
.cell.hl-warn .sugbadge { display: none; }
@media (prefers-reduced-motion: reduce) {
  .cell.sug-1, .cell.sug-2, .cell.sug-3 { animation: none;
    background-color: rgba(224, 177, 60, .22);
    box-shadow: inset 0 0 0 2px rgba(224, 177, 60, .7); }
}

/* conflict silhouettes while dragging: the lecturer's other lectures (grey)
   and adjacent-year mandatory lectures (orange) */
.ghostblk {
  position: absolute;
  z-index: 6;
  pointer-events: none;
  border: 2px dashed rgba(232, 234, 238, .6);
  border-radius: 3px;
  background: rgba(166, 172, 182, .16);
  color: rgba(232, 234, 238, .95);
  font-size: .68rem;
  font-weight: 600;
  padding: 3px 6px;
  overflow: hidden;
  text-shadow: 0 1px 2px rgba(0, 0, 0, .8);
  animation: pop .2s ease;
}
.ghostblk.mand {
  border-color: rgba(217, 138, 74, .85);
  background: rgba(217, 138, 74, .15);
  color: #eec39c;
}
/* A shared course clashing for a cohort whose board is not on screen. This is
   that cohort's whole week, so it is drawn lighter than the other ghosts —
   background context, not an alarm. */
.ghostblk.coh {
  border-width: 1px;
  border-color: rgba(125, 144, 168, .6);
  background: rgba(125, 144, 168, .1);
  color: rgba(188, 202, 221, .9);
  font-weight: 500;
}
/* An external lecture the user hid with 👁. It is not a warning — it is the
   thing that was always there, drawn back in while a block is held so no hour
   is refused by something invisible. Grey and quiet, like the block it stands
   in for. */
.ghostblk.ext {
  border-width: 1px;
  border-color: rgba(166, 172, 182, .5);
  background: rgba(110, 118, 130, .16);
  color: rgba(214, 219, 227, .85);
  font-weight: 500;
}
.ghostblk i { font-style: normal; opacity: .85; font-size: .62rem; }
/* one silhouette standing for N sections of a course: the ×N is the loud part */
.ghostblk.many b { font-weight: 800; margin-left: 2px; opacity: .95; }

.block {
  position: absolute;
  border-radius: 3px;
  color: #fff;
  padding: 6px 8px;
  font-size: .78rem;
  overflow: hidden;
  cursor: grab;
  box-shadow: 0 3px 8px rgba(0, 0, 0, .4);
  animation: pop .25s ease;
  touch-action: none;
  z-index: 5;
}
.block { display: flex; flex-direction: column; gap: 1px; line-height: 1.2; }
.block .code { font-weight: 700; white-space: nowrap; overflow: hidden;
               text-overflow: ellipsis; }
/* course name: as many lines as the block's height affords */
.block .nm {
  font-size: .67rem; opacity: 1; overflow: hidden;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3;
}
/* lecturer on the left (truncates), room on the right (always readable) */
.block .sub { display: flex; gap: 6px; align-items: baseline;
              font-size: .66rem; opacity: 1; margin-top: auto; }
.block .sub .who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.block .sub .room { margin-left: auto; flex: none; opacity: .92; }
/* 🏛 the OWNING faculty's room, not one of ours: the same place on the block,
   said in a quieter voice — nobody must read it as a classroom this board
   booked, and nobody should have to hover to learn which it is */
.block .sub .room.theirs { font-style: italic; opacity: .72; }
/* one-hour blocks only fit two lines: code + who teaches it */
.block.h1 { padding: 3px 6px; font-size: .72rem; }
.block.h1 .nm { display: none; }
.block.h1 .code, .block.h1 .sub { font-size: .63rem; }
.block.h2 .nm { -webkit-line-clamp: 1; }
.block.narrow { padding: 4px 5px; }
.block.narrow .code { font-size: .68rem; }
.block.narrow .nm, .block.narrow .sub { font-size: .59rem; }
.block.narrow.h1 .sub .room { display: none; }
/* category (for the viewed cohort) and section, as pills + a left stripe —
   the block's TONE stays the course, the pill says what kind of lecture it is
   for these students and which section this block is */
.block .catpill, .block .secpill, .piece .secpill {
  display: inline-block; vertical-align: middle; line-height: 1.25;
  font-size: .58rem; font-weight: 800; letter-spacing: .03em;
  padding: 0 5px; border-radius: 2px; margin-left: 4px;
  color: #fff; text-shadow: 0 1px 1px rgba(0, 0, 0, .45);
  border: 1px solid rgba(255, 255, 255, .6);
}
.block .secpill, .piece .secpill { margin-left: 3px; font-size: .6rem; }
.piece .secpill { font-size: .66rem; }
.block .sub .catpill { margin: 0 4px 0 0; flex: none; }
.block.h1 .catpill, .block.narrow .catpill { font-size: .52rem; padding: 0 4px; }
/* the category hues are a documented language (MAND red · DEPT blue ·
   FAC green · UNI violet) — retuned to the plate, never re-assigned */
.catpill.cat-mand { background: #bc4136; }
.catpill.cat-dept { background: #3e6cae; }
.catpill.cat-fac { background: #2f8f5b; }
.catpill.cat-uni { background: #8a63d6; }
.catpill.cat-none { background: #6f757f; }
.block.cat-mand { border-left: 4px solid #ef8a7c; }
.block.cat-dept { border-left: 4px solid #7ba7dd; }
.block.cat-fac { border-left: 4px solid #6fd39b; }
.block.cat-uni { border-left: 4px solid #c3a8f2; }
.block.cat-none { border-left: 4px solid #a2a8b2; }

/* 🔒 a finalized lecture: day, hour and room are settled. It keeps its
   colour and can still be dragged — the server answers the drop with
   "locked" and the page asks "are you sure?" — the ring just says "think
   before you touch". */
.block.locked {
  box-shadow: 0 0 0 2px var(--gold), 0 3px 8px rgba(0, 0, 0, .4);
}
.block.faded { opacity: .35; }

/* ---------------------------------------------------------------- course tones
   The ten identity colours, MIRRORED from TONES in board_app.py (test_board
   asserts every hex is here). --ta = top stop (also the Excel fill), --tb =
   bottom stop. A block/piece/mblock is painted by `.toned` + one `.tone-N`;
   nothing else may set a course block's background (custom blocks and
   mini-board ghosts never receive .toned and keep their own). Assigned per
   COHORT board (term_tones): distinct on one cohort's 📅 Board up to ten
   courses; 🏫/👤 mix cohorts and may show two courses in one tone. */
.tone-0 { --ta: #008476; --tb: #00675b; }  /* teal        */
.tone-1 { --ta: #005a7b; --tb: #004059; }  /* deep azure  */
.tone-2 { --ta: #0077bc; --tb: #005c94; }  /* blue        */
.tone-3 { --ta: #9752a1; --tb: #7d3488; }  /* magenta     */
.tone-4 { --ta: #76317d; --tb: #5d0e65; }  /* plum        */
.tone-5 { --ta: #b1486b; --tb: #962751; }  /* rose        */
.tone-6 { --ta: #43459d; --tb: #2e2886; }  /* deep indigo */
.tone-7 { --ta: #8e2641; --tb: #710029; }  /* wine        */
.tone-8 { --ta: #007f99; --tb: #006378; }  /* cyan        */
.tone-9 { --ta: #6066c1; --tb: #4849a9; }  /* indigo      */
.block.toned, .piece.toned, .mblock.toned {
  background: linear-gradient(135deg, var(--ta), var(--tb)); }
/* the section pill: the NUMBER is the identity; colour belongs to the course now */
.block .secpill, .piece .secpill { background: rgba(8, 9, 12, .55); }

/* ---------------------------------------------------------------- ↔ kin
   Rings in WHITE — no verdict owns it (green legal · red refused · gold
   suggested/locked · orange Y±1 · lilac the lecturer · slate context). Hover
   rings only; a PIN also steps the rest back (.45 — lighter than the drag's
   .18/saturate/scale, no transform, so the two states never look alike).
   Never inside a drag: kin.js suspends itself at activateDrag() and the 📅
   rules are additionally scoped out of #grid.board-dragging. */
#grid:not(.board-dragging) .block.kin,     #tray-list .piece.kin {
  outline: 2px solid #fff; outline-offset: -2px; z-index: 6; }
#grid:not(.board-dragging) .block.kin2,    #tray-list .piece.kin2 {
  outline: 1px dashed rgba(255, 255, 255, .7); outline-offset: -2px; }
#grid:not(.board-dragging) .block.kin-src, #tray-list .piece.kin-src {
  outline: 3px solid #fff; outline-offset: -2px; z-index: 6;
  box-shadow: 0 0 0 4px rgba(255, 255, 255, .22), 0 3px 8px rgba(0, 0, 0, .4); }
/* keep the 🔒 ring visible under the white halo */
#grid:not(.board-dragging) .block.locked.kin-src, #tray-list .piece.locked.kin-src {
  box-shadow: 0 0 0 2px var(--gold), 0 0 0 5px rgba(255, 255, 255, .22), 0 3px 8px rgba(0, 0, 0, .4); }
#grid:not(.board-dragging).kin-pinned .block:not(.kin):not(.kin2):not(.kin-src),
#tray-list.kin-pinned .piece:not(.kin):not(.kin2):not(.kin-src) {
  opacity: .45; transition: opacity .12s; }
/* ⛔ a 🏫 tray piece no room could take keeps its gold dashed ring under a kin
   hover/pin — "nothing can take it" outranks "this is kin"; the white kin ring
   would otherwise replace it (.piece.stuck is (0,2,0), #tray-list .piece.kin is
   (1,2,0)). Only the colour and style are restored; width/offset stay kin's. */
#tray-list .piece.stuck.kin, #tray-list .piece.stuck.kin2, #tray-list .piece.stuck.kin-src {
  outline-color: var(--gold); outline-style: dashed; }
/* the ↔ tag on a 📅 block (internal lectures only): where its other end is */
.block .kintag {
  font-family: var(--mono); font-weight: 600; font-size: .58rem; letter-spacing: .01em;
  margin-left: 4px; padding: 0 4px; border-radius: 2px;
  background: rgba(255, 255, 255, .18); color: #fff; }
.block.kin .kintag, .block.kin-src .kintag { background: #fff; color: var(--bg); }   /* inverts — no new element */
.block.narrow.h1 .kintag { display: none; }   /* the code line is ellipsized there; the rings carry it */
@media (prefers-reduced-motion: reduce) {
  #grid.kin-pinned .block, #tray-list.kin-pinned .piece { transition: none; } }

/* the lock toggle on Room Board blocks and tray pieces: tiny, bottom-right,
   loud only on hover so a busy day view stays readable */
.lockbtn {
  /* centred in the block (v0.43) — bottom-right sat exactly on the room
     label, which is the one thing the Room Board must keep readable */
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: 20px; height: 20px; padding: 0; line-height: 18px;
  border: 1px solid rgba(255, 255, 255, .35); border-radius: 2px;
  background: rgba(8, 9, 12, .6); color: #fff; font-size: .7rem;
  cursor: pointer; opacity: .35; z-index: 6;
}
/* the ✕ on a 🧱 custom block reuses the button style but stays in the corner —
   there is no room label to cover, and centred it would sit on the text */
.lockbtn.cdel {
  left: auto; top: auto; transform: none; right: 3px; bottom: 3px;
}
.lockbtn.on { opacity: .95; background: #8a6114; border-color: var(--gold); }
.block:hover .lockbtn, .piece:hover .lockbtn, .lockbtn:focus-visible { opacity: 1; }
.piece { position: relative; }
.piece.locked { box-shadow: 0 0 0 2px var(--gold); }

/* 🧱 custom blocks on the Room Board: hatched, never draggable, not a lecture */
.block.custom {
  cursor: default; color: #f0d9a0;
  background: repeating-linear-gradient(135deg, rgba(90, 65, 12, .85) 0 8px,
                                        rgba(46, 33, 6, .85) 8px 16px);
  border: 1px dashed rgba(240, 217, 160, .7);
  box-shadow: none; z-index: 4;
}
.block.custom.placeholder {
  color: var(--text);
  background: repeating-linear-gradient(135deg, rgba(60, 66, 78, .8) 0 8px,
                                        rgba(30, 33, 40, .85) 8px 16px);
  border-color: rgba(232, 234, 238, .5);
}
.block.custom .cdel { opacity: .4; }
.block.custom:hover .cdel { opacity: 1; }
#custom-box { margin-top: 12px; font-size: .8rem; }
#custom-box > summary { cursor: pointer; font-weight: 700; }
#custom-box .tiny { margin: 6px 0; }
.cbform input, .cbform select {
  width: 100%; box-sizing: border-box; margin-bottom: 5px; font: inherit; font-size: .78rem;
  background: var(--panel-2); color: var(--text); border: 1px solid var(--line);
  border-radius: 2px; padding: 4px 7px;
}
.cbform .cbrow { display: flex; gap: 6px; align-items: center; }
.cbform .cbrow select { width: auto; margin: 0; }
.cbform #cb-place { font-size: .78rem; margin-top: 2px; }
.cbform #cb-place.active { background: #8a6114; color: #fff; border-color: var(--gold); }
body.placing #grid .cell { cursor: crosshair; }
body.placing #grid .cell:hover { outline: 2px solid var(--gold); outline-offset: -2px; }

/* ---------------------------------------------------------------- drag bits */
#ghost {
  position: fixed;
  pointer-events: none;
  z-index: 100;
  opacity: .9;
  transform: rotate(2deg) scale(1.03);
  width: 180px;
}
#dragtip {
  position: fixed;
  z-index: 101;
  pointer-events: none;
  background: #101216;
  border: 1px solid var(--line);
  color: var(--text);
  font-size: .72rem;
  padding: 4px 8px;
  border-radius: 2px;
  max-width: 260px;
}
#dragtip.ok { border-color: var(--ok); color: #8fe0b0; }
#dragtip.bad { border-color: var(--bad); color: #f0a99e; }
#dragtip.ok.sug { border-color: var(--gold); color: #f0d9a0; }
#dragtip.warn { border-color: var(--warn); color: #edd08e; }

/* ---------------------------------------------------------------- room board */
/* room chips are drop targets: drop a lecture on one to move it there */
.roomchip b { font-weight: 700; opacity: .7; font-size: .78em; margin-left: 2px; }
.roomchip.drop-ok { outline: 2px solid var(--ok); background: rgba(78, 201, 129, .35); }
.roomchip.drop-bad { outline: 2px solid var(--bad); opacity: .6; }
body.dragging .roomchip { transform: none; }
/* the only slot a piece can land on — its own hours */
.cell.own-slot { border-color: rgba(232, 234, 238, .35); }
/* room does not fit the class (too small, or the wrong group) */
.block.roombad { outline: 2px dashed #f0a99e; outline-offset: -3px; }

/* ✍ custom rooms — a classroom typed on this board, not one of 🚪 Classrooms.
   Every group's table ends in a ✍ column (and its chip cloud in a ✍ chip): a
   place rather than a room, so it is drawn as one — a dashed seam and a faint
   paper tint. The block's typed name is the ONE loud thing: a light plate with
   dark ink, the way a number is written on a door — it is the one fact the
   reader cannot find on any chip or column head. */
.head.roomhead.customhead { border-left: 1px dashed rgba(232, 234, 238, .45); }
.head.roomhead.customhead b { color: var(--muted); }
.cell.customcell {
  background: rgba(232, 234, 238, .045);
  border-left: 1px dashed rgba(232, 234, 238, .35);
}
.roomchip.customchip { border: 1px dashed rgba(232, 234, 238, .45); }
.roomchip.customchip.active { border-style: solid; }
.block .roomplate {
  display: block; align-self: flex-start; max-width: 100%; box-sizing: border-box;
  margin: 2px 0 1px; padding: 0 5px; border-radius: 2px; line-height: 1.45;
  background: rgba(245, 246, 248, .92); color: #171b22; font-weight: 700; font-size: .66rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.block.narrow .roomplate { font-size: .6rem; }
/* a one-hour block has room for two lines: the code and, in a custom room,
   the plate — the lecturer line yields, because the room is what this
   column is about (the tooltip still names the lecturer) */
.block.customroom.h1 .sub { display: none; }
/* ✎ rename sits top-right; the 🔓 stays centred and the 🧱 ✕ bottom-right */
.lockbtn.cedit { left: auto; top: 3px; right: 3px; bottom: auto; transform: none; }
/* ✍ "which classroom?" and ⧉ "which second room?" — a small box at the drop
   point (both hang off <body>, both wear .askbox; the ids stay so each can be
   found and closed on its own) */
.askbox {
  position: fixed; z-index: 102; width: 262px; box-sizing: border-box;
  background: #101216; border: 1px solid var(--line); border-radius: 3px;
  padding: 10px 12px; box-shadow: 0 12px 32px rgba(0, 0, 0, .55); font-size: .8rem;
}
.askbox h4 { margin: 0 0 4px; font-size: .82rem; }
.askbox .tiny { margin: 0 0 8px; }
.askbox input {
  width: 100%; box-sizing: border-box; font: inherit; font-size: .9rem; font-weight: 600;
  padding: 6px 8px; background: var(--panel-2); color: var(--text);
  border: 1px solid var(--line); border-radius: 2px;
}
.askbox input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* ⧉ wraps because the second-room box has FOUR buttons since v0.80 (Cancel
   · Take it away · Show it first · Change) and the box is 262 px wide */
.askbox .row {
  display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; margin-top: 8px;
}
.askbox .row button { font-size: .78rem; padding: 5px 11px; }
.askbox .err { color: #f0a99e; margin: 6px 0 0; font-size: .74rem; }
.askbox .err[hidden] { display: none; }
/* ⧉ the second-room box asks with a <select>, not a text field — a second room
   is one of 🚪 Classrooms and is never typed */
.askbox label { display: block; margin: 6px 0 2px; color: var(--muted); }
.askbox select {
  width: 100%; box-sizing: border-box; font: inherit; font-size: .82rem;
  padding: 5px 6px; background: var(--panel-2); color: var(--text);
  border: 1px solid var(--line); border-radius: 2px;
}
.askbox select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.askbox optgroup { color: var(--muted); font-style: normal; }
.askbox option { color: var(--text); }
.askbox option:disabled { color: var(--muted); }

/* ⧉ second rooms — ONE lecture in TWO of this board's classrooms at the same
   hour. The duplicate is drawn in the second room's column with a dashed WHITE
   seam (the ink no verdict owns: this is not a warning, ⚠️ pink and 🔒 gold
   still mean what they always did — hence :not(.roombad), so a pair too small
   for its class keeps its ⚠️ outline), and BOTH copies wear a quiet plate
   naming the other room. */
.block.second:not(.roombad) {
  outline: 2px dashed rgba(255, 255, 255, .72); outline-offset: -3px; opacity: .92;
}
.block .roomplate.dual {
  background: rgba(232, 234, 238, .14); color: var(--text);
  font-weight: 500; font-size: .6rem;
}
.block.narrow .roomplate.dual { font-size: .56rem; }
/* a 1-hour block holds the code and one plate; the lecturer line yields, as it
   does for a ✍ typed room — the tooltip still names them */
.block.h1 .roomplate.dual ~ .sub { display: none; }
/* ⧉ "add a second room" sits top-right, where ✎ sits on a ✍ block (a block is
   never both), and the duplicate's ✕ keeps the 🧱 corner */
.lockbtn.dual { left: auto; top: 3px; right: 3px; bottom: auto; transform: none; }

/* day view: every classroom side by side, one timetable per room group */
#grid.daygrid {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 20px;
  grid-template-columns: none;
  min-width: 0;
}
.rgroup { flex: 0 1 auto; }
.rgroup h3 {
  font-size: .85rem;
  margin: 0 0 8px;
  color: var(--text);
  display: flex;
  align-items: baseline;
  gap: 8px;
}
.rgroup h3 span { font-size: .7rem; font-weight: 500; color: var(--muted); }
.rgrid { position: relative; display: grid; gap: 4px; }
.rgrid .time { font-size: .62rem; }
.head.roomhead {
  flex-direction: column;
  justify-content: center;
  gap: 0;
  height: 40px;
  line-height: 1.15;
  font-size: .7rem;
  color: var(--text);
  padding: 0 4px;
  overflow: hidden;
}
.head.roomhead b { font-weight: 500; font-size: .56rem; color: var(--muted); }
/* while dragging: which columns would accept this lecture */
.cell.slot-ok { background: rgba(78, 201, 129, .13); border-color: rgba(78, 201, 129, .35); }
.cell.slot-bad { background: rgba(224, 96, 80, .09); }
#view-toggle { display: flex; gap: 6px; }
/* the chip's count sits in a reserved cell: same width before and after
   /api/board answers, so the top bar never widens under the reader.
   ⚠️ `body.boardpage` is on ALL THREE board shells (board/roomboard/
   lecturerboard.html:11), and #view-toggle is the same id on each — these
   rules are inert on the other two only because their chips are built with
   textContent (roomboard.js, lecturerboard.js) and so carry neither a
   <b class="n"> nor .moot. Keep it that way, or scope them harder. */
body.boardpage #view-toggle .chip .n {
  display: inline-block; min-width: 2ch; margin-left: 4px;
  text-align: right; font-weight: 700;
}
/* § moot: everything it would hide is already hidden by 👁 — still a real
   switch (its remembered answer matters the moment 👁 goes off), so it is
   dimmed, never disabled */
body.boardpage #view-toggle .chip.moot { opacity: .55; }
body.boardpage #view-toggle .chip.moot:hover { opacity: .8; }
/* ⚠️ Under 1500 px the chips are icon + count only. Two worded chips are
   303 px wide where one was 163, and between 1366 px and ~1499 px that pushed
   #progress-wrap onto a second row of #topbar — a static 34 px of board height
   gone on exactly the laptop widths, which is the shove every skeleton rule
   above exists to prevent. The words move into the title (which already
   carries the whole sentence); the icon and the reserved count stay, so the
   chip is still readable, still the same width before and after /api/board,
   and still findable by its "👁"/"§" prefix. Measured at 1366/1440/1500/1680:
   one row (48 px) at all four, first paint and after the answer. */
@media (max-width: 1500px) {
  body.boardpage #view-toggle .chip .lbl { display: none; }
}

/* ---------------------------------------------------------------- lecturer board */
/* a name chip: hours taught, ⚠️ when something is wrong with that week */
.lectchip b { font-weight: 700; opacity: .7; font-size: .78em; margin-left: 2px; }
.lectchip.idle { opacity: .5; }
.rgroup.wide { flex: 1 1 100%; }
.head.lecthead { cursor: pointer; }
.head.lecthead:hover { color: var(--text); background: var(--panel-2); }
/* The classroom is the whole point of this board, so it never gets dropped —
   the shared `.block.narrow.h1 .sub .room { display: none }` rule above would
   hide it on exactly the 1-hour labs the room matters most for. The cohort
   list yields the line instead. */
/* the selector must out-specify `.block.narrow.h1 .sub .room` (5 classes) */
body.lectboard .block.narrow.h1 .sub .room,
body.lectboard .block .sub .room { display: block; }
body.lectboard .block .sub .room b { font-weight: 700; }
body.lectboard .block.h1 .sub .who,
body.lectboard .block.narrow.h2 .sub .who { display: none; }
/* 🤝 A lecture this person only HELPS in is somebody else's lecture on their
   week: quieter, and marked down its leading edge. Deliberately NOT a new
   verdict ink and NOT white (white is ↔ kin's ring) — a helper is never a
   problem, so the mark is a tint of the block's own plate, and .roombad /
   .locked keep their outlines untouched. */
body.lectboard .block.helping {
  opacity: .9;
  box-shadow: inset 4px 0 0 rgba(232, 234, 238, .55);
}
body.lectboard .block.helping .nm { font-style: italic; }
.lectrow {
  display: flex; align-items: baseline; gap: 8px;
  padding: 4px 8px; border-radius: 2px; font-size: .78rem;
  cursor: pointer; background: var(--panel-2); margin-bottom: 4px;
}
.lectrow:hover { background: #262a32; }
.lectrow span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lectrow em { margin-left: auto; font-style: normal; font-size: .7rem;
              color: var(--muted); flex: none; }
.lectrow.bad { border-left: 3px solid var(--bad); }
.emptybox {
  margin: 40px auto; max-width: 520px; text-align: center;
  padding: 24px 18px; border: 1px dashed var(--line); border-radius: 3px;
  color: var(--muted); font-size: .9rem;
}

#toast {
  pointer-events: none;
  position: fixed;
  bottom: 22px;
  left: 50%;
  transform: translateX(-50%);
  background: #101216;
  border: 1px solid var(--bad);
  color: #f0a99e;
  padding: 10px 18px;
  border-radius: 3px;
  font-size: .85rem;
  z-index: 200;
  max-width: 70vw;
  box-shadow: 0 6px 20px rgba(0, 0, 0, .5);
}
#toast.good { border-color: var(--ok); color: #8fe0b0; }

@keyframes pop {
  0% { transform: scale(.92); opacity: 0; }
  100% { transform: scale(1); opacity: 1; }
}
@keyframes shake {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-6px); }
  75% { transform: translateX(6px); }
}
.shake { animation: shake .25s ease 2; }

/* ---- 🧩 Tak, the assistant ----------------------------------------------
   Bottom-right, never modal, never over the grid's working area. He appears
   on the THIRD identical refusal or on a crash — not on every refusal, which
   in this app would be constant noise. Lilac: the thinking ink. */
#tak {
  position: fixed; right: 20px; bottom: 20px; z-index: 60;
  display: flex; gap: 13px; align-items: flex-start;
  max-width: 430px; padding: 17px 19px;
  background: var(--panel); border: 1px solid var(--line);
  border-left: 5px solid var(--lilac); border-radius: 4px;
  box-shadow: 0 12px 34px rgba(0, 0, 0, .6);
  animation: takin .22s ease;
}
/* `display: flex` above is an AUTHOR rule, and it beats the browser's own
   [hidden] { display: none }. So setting el.hidden = true left the panel on
   screen with its property quietly set — which is exactly how "Got it" looked
   broken while a test checking `.hidden` passed. Say it explicitly. */
#tak[hidden] { display: none; }
#tak.mood-alert { border-left-color: var(--warn); }
#tak.mood-pleased { border-left-color: var(--ok); }
@keyframes takin {
  from { opacity: 0; transform: translateY(14px) scale(.96); }
  to   { opacity: 1; transform: none; }
}
.tak-face { flex: 0 0 auto; animation: takbob 3.2s ease-in-out infinite; }
@keyframes takbob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-3px); }
}
.tak-say b { font-size: 1rem; display: block; margin-bottom: 5px;
             line-height: 1.3; }
.tak-say p { font-size: .88rem; line-height: 1.55; opacity: .93; margin: 0 0 9px; }
.tak-quote {
  font-size: .78rem !important; opacity: .62 !important; font-style: italic;
  border-left: 2px solid var(--line); padding-left: 9px;
}
.tak-btns { display: flex; gap: 8px; }
.tak-btns button {
  border: 1px solid rgba(232, 234, 238, .25); border-radius: 3px;
  background: rgba(232, 234, 238, .08); color: inherit;
  font-size: .8rem; padding: 5px 16px; cursor: pointer;
}
.tak-btns button:hover { background: rgba(232, 234, 238, .18); }
@media (prefers-reduced-motion: reduce) {
  #tak, .tak-face { animation: none; }
}

/* ═══ the season, as weather ═══════════════════════════════════════════
   Fall and Spring are real product states — two separate calendars — so
   the chrome wears the season and you can tell which term is open from
   across the room. Strictly CHROME: the timetable grid never carries a
   particle, because a lecture block must never be read through drifting
   snow. Bounded and cheap: transform/opacity only, no canvas, no rAF loop
   competing with the drag handler.
   It stops when it should — paused mid-drag and while the tab is hidden,
   removed entirely under prefers-reduced-motion or the bar's ❄/🌸 toggle
   (body.wx-off). What SETTLES on an edge is drawn statically, so the
   no-animation state is still the finished picture.

   ONE BLOCK, NOT TWO BARS. The tabs and the board's header are one slab
   of chrome, so they wear ONE snowfall: the layer lives in #wizard-nav —
   the top-most bar, and the only one on every page — and hangs DOWN
   across #topbar, clipped at the foot of the band. Nothing is cut at the
   seam between the bars any more, a flake crosses the whole block once,
   and only the FOOT of the block collects a drift — the tabs never carry
   settled snow, so that edge stays smooth.                              */

#topbar, #tray, #wizard-nav { position: relative; }
#tray > *:not(.wx), #wizard-nav > *:not(.wx) { position: relative; z-index: 1; }
/* The nav is a stacking context of its own (position: sticky; z-index: 50,
   wizard.css) and the field hangs out of it — so everything the HEADER
   holds must sit above that context, or the snow would drift in front of
   the title and the chips instead of behind them. */
#topbar > *:not(.wx) { position: relative; z-index: 51; }

.wx { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 0; }
/* --wx-band is the height of the whole block — tabs + header — and it is
   the one number weather.js measures. The fall is that height plus a
   little, so a flake crosses the block exactly once and fades into the
   drift at its foot instead of being clipped halfway down a bar. */
.wx-band { bottom: auto; height: var(--wx-band, 108px);
           --dy: calc(var(--wx-band, 108px) + 22px); }
/* The tray is a tall narrow column BESIDE the grid, not part of the band.
   One field stretched over both would have to be an L, and the tray would
   catch only the two or three flakes whose column happens to fall inside
   its 290px — so it keeps its own weather, falling its own height. */
.wx-tray { --dy: calc(100vh - var(--wx-band, 108px) + 20px); }

.wx i {
  position: absolute; top: -14px; display: block;
  width: var(--s, 3px); height: var(--s, 3px);
  background: var(--wx-ink);
  border-radius: 50%;
  opacity: 0;
  /* linear, because falling is linear once it is falling. No will-change:
     a running transform/opacity animation is composited anyway, and 40
     forced layers is a cost with nothing bought. */
  animation: wxdrift var(--t, 13s) linear infinite;
  animation-delay: var(--d, 0s);
}
/* a petal is not a dot: wider than tall, one rounded corner pair, and it
   turns as it falls */
body.spring .wx i {
  width: calc(var(--s, 3px) * 2.7);
  height: calc(var(--s, 3px) * 1.9);
  border-radius: 74% 8% 74% 8%;
  background: linear-gradient(140deg, var(--wx-ink),
              color-mix(in srgb, var(--wx-ink) 45%, transparent));
}

@keyframes wxdrift {
  0%   { transform: translate3d(0, 0, 0) rotate(0deg); opacity: 0; }
  14%  { opacity: var(--o, .5); }
  82%  { opacity: var(--o, .5); }
  100% { transform: translate3d(var(--dx, 10px), var(--dy, 200px), 0)
                    rotate(var(--rot, 0deg)); opacity: 0; }
}

/* Hand-set particles rather than a random loop: deterministic, diffable,
   and no per-frame JS. Sizes, speeds and drifts are varied so the field
   never reads as a grid; negative delays start them mid-fall so nothing
   "begins" when a page loads.
   The columns are laid down in THREE PASSES of eight across the width
   (1-8, then 9-16 between them, then 17-24 between those), so a layer
   built with 8, 16 or 24 of them is evenly spread either way — the tray
   takes the first sixteen and is not left with a bare right-hand half. */
.wx i:nth-child(1)  { left:  3%; --s: 3px;   --t: 14s;   --d:  -3s;   --dx:  12px; --rot:   90deg; --o: .60; }
.wx i:nth-child(2)  { left: 15%; --s: 2px;   --t: 19s;   --d:  -9s;   --dx:  -8px; --rot:  -60deg; --o: .44; }
.wx i:nth-child(3)  { left: 27%; --s: 4px;   --t: 11s;   --d: -14s;   --dx:  16px; --rot:  130deg; --o: .66; }
.wx i:nth-child(4)  { left: 39%; --s: 2.5px; --t: 17s;   --d:  -6s;   --dx: -14px; --rot:  -95deg; --o: .48; }
.wx i:nth-child(5)  { left: 51%; --s: 3px;   --t: 13s;   --d: -11s;   --dx:   9px; --rot:  110deg; --o: .58; }
.wx i:nth-child(6)  { left: 63%; --s: 2px;   --t: 21s;   --d:  -1s;   --dx: -11px; --rot:  -75deg; --o: .40; }
.wx i:nth-child(7)  { left: 75%; --s: 3.5px; --t: 12s;   --d: -16s;   --dx:  14px; --rot:  150deg; --o: .64; }
.wx i:nth-child(8)  { left: 87%; --s: 2.5px; --t: 18s;   --d:  -5s;   --dx:  -9px; --rot:  -50deg; --o: .46; }
.wx i:nth-child(9)  { left:  9%; --s: 3px;   --t: 15s;   --d: -12s;   --dx:  11px; --rot:  100deg; --o: .56; }
.wx i:nth-child(10) { left: 21%; --s: 2px;   --t: 20s;   --d:  -8s;   --dx: -13px; --rot:  -85deg; --o: .42; }
.wx i:nth-child(11) { left: 33%; --s: 4px;   --t: 10s;   --d:  -4s;   --dx:  15px; --rot:  125deg; --o: .68; }
.wx i:nth-child(12) { left: 45%; --s: 2.5px; --t: 16s;   --d: -13s;   --dx: -10px; --rot:  -70deg; --o: .50; }
.wx i:nth-child(13) { left: 57%; --s: 3px;   --t: 12.5s; --d:  -7s;   --dx:  13px; --rot:  115deg; --o: .59; }
.wx i:nth-child(14) { left: 69%; --s: 2px;   --t: 22s;   --d: -17s;   --dx:  -7px; --rot:  -55deg; --o: .38; }
.wx i:nth-child(15) { left: 81%; --s: 3.5px; --t: 13.5s; --d:  -2s;   --dx:  10px; --rot:  140deg; --o: .62; }
.wx i:nth-child(16) { left: 93%; --s: 2.5px; --t: 18.5s; --d: -10s;   --dx: -15px; --rot:  -90deg; --o: .47; }
.wx i:nth-child(17) { left:  6%; --s: 2px;   --t: 16.5s; --d: -15s;   --dx:   8px; --rot:   80deg; --o: .41; }
.wx i:nth-child(18) { left: 18%; --s: 3px;   --t: 11.5s; --d:  -6.5s; --dx: -12px; --rot: -120deg; --o: .57; }
.wx i:nth-child(19) { left: 30%; --s: 2.5px; --t: 19.5s; --d: -18s;   --dx:  14px; --rot:  105deg; --o: .49; }
.wx i:nth-child(20) { left: 42%; --s: 3.5px; --t: 14.5s; --d:  -2.5s; --dx:  -9px; --rot:  -65deg; --o: .63; }
.wx i:nth-child(21) { left: 54%; --s: 2px;   --t: 20.5s; --d: -11.5s; --dx:  12px; --rot:  135deg; --o: .39; }
.wx i:nth-child(22) { left: 66%; --s: 3px;   --t: 12.8s; --d:  -8.5s; --dx: -16px; --rot: -100deg; --o: .58; }
.wx i:nth-child(23) { left: 78%; --s: 4px;   --t: 10.5s; --d: -19s;   --dx:  10px; --rot:  145deg; --o: .67; }
.wx i:nth-child(24) { left: 90%; --s: 2.5px; --t: 17.5s; --d:  -4.5s; --dx: -11px; --rot:  -80deg; --o: .45; }

/* WHAT SETTLES. A scalloped drift along the FOOT of the block — snow in
   Fall, fallen petals in Spring. Painted, not animated, so it survives
   reduced motion and the toggle: the season is still legible standing
   still. One drift per block: the tabs' own lower edge stays bare, so the
   band reads as a single surface rather than two stacked bars.
   Drift, not dots: overlapping bumps sitting on one thin continuous line,
   so the edge reads as something that SETTLED there rather than a dotted
   border. Two backgrounds, both painted — no motion involved. */
#topbar::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: 7px; pointer-events: none; z-index: 2;
  /* two scallop rows at co-prime periods (17px and 23px) so the drift
     never shows its tile — a single repeat reads as a machined edge */
  background-image:
    radial-gradient(12px 7px at 50% 100%, var(--wx-edge) 0 62%, transparent 64%),
    radial-gradient(9px 5px at 50% 100%, var(--wx-edge) 0 58%, transparent 60%),
    linear-gradient(to top, var(--wx-edge), transparent);
  background-size: 17px 7px, 23px 5px, 100% 3px;
  background-position: 0 100%, 7px 100%, 0 100%;
  background-repeat: repeat-x, repeat-x, repeat-x;
}
/* the tray's top edge catches a little too */
#tray::after {
  content: ""; position: absolute; left: 0; right: 0; top: 0;
  height: 6px; pointer-events: none; z-index: 2;
  background-image:
    radial-gradient(11px 6px at 50% 0, var(--wx-edge) 0 60%, transparent 62%),
    linear-gradient(to bottom, var(--wx-edge), transparent);
  background-size: 15px 6px, 100% 2px;
  background-position: 0 0, 0 0;
  background-repeat: repeat-x, repeat-x;
}

/* ── when the weather must stop ── */
/* holding a lecture is the one thing this app is for: nothing moves but
   the block under the cursor */
body.dragging .wx i,
body.wx-paused .wx i { animation-play-state: paused; }
/* switched off from the bar: the particles go, the settled drift stays */
body.wx-off .wx { display: none; }
/* “Reduce motion” does NOT gate the weather (Alperen, 2026-08-27). The query
   is answered by the browser and not always by the reader — Brave replies
   `reduce` to every page while the machine's own animation setting is plainly
   on — so gating on it handed a still board to people who never asked for
   one. The season runs by default and the ❄/🌸 switch is the way out: it is
   one press, it is remembered per browser for ever after, and it is in the
   same corner on every page. Everything else on the board still obeys the
   query (the mascot, the unread dot, the pulses); those are one-shot and
   tiny, and none of them is the point of the page. */

/* the ❄ / 🌸 switch in the nav's right-hand cluster */
#wizard-nav .wxbtn {
  background: none; border: 1px solid transparent; border-radius: 3px;
  color: var(--muted); font-size: .82rem; line-height: 1;
  padding: 5px 7px; cursor: pointer;
}
#wizard-nav .wxbtn:hover { color: var(--text); background: var(--panel-2); }
#wizard-nav .wxbtn[aria-pressed="false"] { opacity: .45; }

/* ============================================================ v0.61 shared
   Three things every page now shares, so they can only look one way:
   a toast that can be read, a skeleton that reserves the space its content
   will take, and a control that is visibly off rather than silently dead. */

/* --- the toast, when it carries a reference ---------------------------- */
/* An ordinary toast disappears. One with a six-character crash reference is
   the ONE string a person has to send Alperen, and #toast is pointer-events:
   none inside a body that sets user-select: none — so it could neither be
   selected nor clicked. Sticky, selectable, with its own copy button. */
#toast.sticky {
  pointer-events: auto;
  user-select: text;
  -webkit-user-select: text;
  display: flex; align-items: center; gap: 10px;
  max-width: 46rem; text-align: left;
}
/* An AUTHOR display: flex outranks the browser's own [hidden]{display:none},
   so the ✕ (which only sets el.hidden) left the toast fully painted AND still
   pointer-events: auto — a dismissed reference went on swallowing clicks in a
   46rem band over the board. Same trap as #tak, #ai-modal and .pwform. */
#toast[hidden] { display: none !important; }
#toast .tcopy {
  flex: none; cursor: pointer;
  background: var(--panel-2); color: var(--text);
  border: 1px solid var(--line); border-radius: 3px;
  padding: 4px 9px; font-size: .76rem; font-family: inherit;
}
#toast .tcopy:hover { border-color: var(--accent); color: var(--accent); }
#toast .tclose { padding: 4px 8px; }

/* --- skeletons: a region waiting on a fetch, at its real size ---------- */
/* The rule is "reserve, then swap in one write", never "grow when the answer
   arrives": the measured pop-in was a 6723 px growth on /report and a 67 px
   shove on /lecturerboard, both of which move whatever the reader was
   already looking at. Sizes come from the real components. */
.skel {
  background: var(--panel-2);
  border-radius: 3px;
  position: relative;
  overflow: hidden;
}
.skel::after {
  content: "";
  position: absolute; inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(90deg, transparent, var(--line), transparent);
  animation: skelshine 1.6s ease-in-out infinite;
}
@keyframes skelshine { 100% { transform: translateX(100%); } }
/* A shimmer is decoration; somebody who asked for less motion gets a plain
   grey block, which reserves exactly the same space. */
@media (prefers-reduced-motion: reduce) {
  .skel::after { animation: none; background: none; }
}
.skel.line { height: 1em; margin: .35em 0; }
.skel.line.short { width: 40%; }
.skel.line.half { width: 60%; }
.skel.chip { display: inline-block; height: 26px; width: 84px;
             margin: 0 6px 6px 0; border-radius: 3px; vertical-align: middle; }
.skel.card { height: 92px; margin-bottom: 10px; }
.skel.row { height: 30px; margin-bottom: 4px; }
.skel.pill { height: 44px; margin-bottom: 6px; }
.skel.tile { height: 64px; }
/* a week grid, the shape of every board on this app: 5 days × 10 hours */
.skel-grid {
  display: grid; gap: 3px;
  grid-template-columns: repeat(5, 1fr);
  grid-auto-rows: 48px;
}
.skel-grid > .skel { height: 100%; margin: 0; }

/* --- a control that cannot be used says so ---------------------------- */
/* Buttons were dimmed by the browser's own default only, which on this dark
   plate is almost invisible: an ⚡ Auto-assign that does nothing looks the
   same as one that works. The title says WHY; this says THAT. */
/* ⚠️ wizard.css is loaded AFTER this file and sets cursor: pointer on
   button.primary / button.ghost with the SAME specificity, so a plain
   `button:disabled` lost the tie on every one of the app's real buttons (and
   button.primary:hover's brightness even made a dead ⚡ Auto-assign light up
   under the pointer). The component selectors are spelled out here so this
   rule wins on specificity instead of on the order two files happen to load
   in. */
button:disabled, button.primary:disabled, button.ghost:disabled,
.btn.off, [data-download][disabled] {
  opacity: .42;
  cursor: not-allowed;
  filter: grayscale(.5);
}
button:disabled:hover, button.primary:disabled:hover,
button.ghost:disabled:hover, .btn.off:hover {
  transform: none; filter: grayscale(.5);
}

/* --- 👁 a view-only board ---------------------------------------------- */
/* The server is still the judge (require_login refuses every non-GET from a
   viewer). This is so the refusal is not the first time they hear about it. */
body.viewer .needs-edit,
body.viewer button.needs-edit,
body.viewer input.needs-edit,
body.viewer select.needs-edit { opacity: .45; cursor: not-allowed; }
body.viewer .viewer-note {
  color: var(--muted); font-size: .78rem; margin: 6px 0;
}

/* ==================================================== v0.61 the board shells
   📅 Board, 🏫 Room Board and 👤 Lecturer Board are three documents that share
   this stylesheet. What they now share on top of the drag rules: a region that
   waits on a fetch is already the right size, an empty region says what to do
   next instead of nothing, and a view-only visitor can see that they are one. */

/* --- the week grid while its first fetch is in flight ------------------- */
/* #grid is a display:grid whose columns are the calendar's own; a skeleton
   dropped into it would land in the 86 px hour track (that is exactly how the
   old empty message became one word per line). While it holds a placeholder
   — or an empty-state card — it is a plain block instead. */
#grid.loading, #grid.empty { display: block; }
#grid.loading .skel-grid {
  /* the calendar's own frame: the hour column plus five days, the day-header
     row plus ten hours, and the same 4 px gap — so the swap to the drawn
     week moves nothing */
  grid-template-columns: 86px repeat(5, minmax(130px, 1fr));
  grid-auto-rows: 46px;
  gap: 4px;
  min-width: 780px;
}

/* --- the chip cloud reserves its height -------------------------------- */
/* On 🏫 Room Board and 👤 Lecturer Board the chips are the CROWD (every room,
   every lecturer): they take their own full row of the band, they are not
   known until the fetch answers, and their arrival was what pushed the whole
   board down — 28 px and 67 px measured. The row is reserved at the height it
   nearly always ends up with, and grows only past that. */
body.roomboard #cohorts { min-height: 27px; align-content: flex-start; }
body.lectboard #cohorts { min-height: 60px; align-content: flex-start; }
/* the skeleton chips are laid out by the same flex gap as the real ones, so
   they must not bring a margin of their own into the row */
#cohorts .skel.chip { margin: 0; }

/* --- an empty board says where to start -------------------------------- */
/* The tray is 290 px wide, so the centred page-width card of the wizard pages
   would read as a column of single words here. */
#tray .emptycard {
  margin: 6px 0 12px; padding: 16px 13px; text-align: left;
  font-size: .8rem; max-width: none;
}
#tray .emptycard .ec-icon { font-size: 1.5rem; margin-bottom: 6px; }
#tray .emptycard .btn { width: 100%; justify-content: center; margin-top: 8px; }

/* --- 👁 view-only ------------------------------------------------------ */
/* The line is written into every shell's tray and shown only to a viewer —
   body.viewer styles it, this is what keeps it out of everybody else's way. */
#tray .viewer-note { display: none; }
body.viewer #tray .viewer-note {
  display: block; margin: 0 0 10px; padding: 8px 10px;
  border: 1px solid var(--line); border-left: 3px solid var(--warn);
  border-radius: 3px; background: var(--well); line-height: 1.45;
}
/* the 🔒 lock buttons and the 🧱 form are write controls like any other */
body.viewer .lockbtn { opacity: .4; cursor: not-allowed; }
body.viewer #custom-box > summary { cursor: not-allowed; }

/* ↻ a repeat offering (Repeat master column): the plan is in the other term,
   the owning faculty opens it this term for repeaters — one glyph on the
   block and the tray piece; the rules already treat it as not taken by all */
.block .rep, .piece .rep { font-weight: 700; opacity: .95; }
.reppill { display: inline-block; font-size: .68rem; font-weight: 700; padding: 0 5px;
  border-radius: 4px; border: 1px dashed currentColor; opacity: .85; vertical-align: 1px; }
.srcpill.rep { border: 1px dashed currentColor; background: none; }
